mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
feat: auto-follow bookmarked URLs as Microsub feed subscriptions
When a Micropub bookmark-of post is created (HTTP 201/202), intercept via contentNegotiationRoutes and subscribe the bookmarked URL as a feed in the Microsub reader. Mirrors the blogroll bookmark-import pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
48
index.js
48
index.js
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import express from "express";
|
||||
|
||||
import { importBookmarkAsFollow } from "./lib/bookmark-import.js";
|
||||
import { microsubController } from "./lib/controllers/microsub.js";
|
||||
import { opmlController } from "./lib/controllers/opml.js";
|
||||
import { readerController } from "./lib/controllers/reader.js";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
cleanupStaleItems,
|
||||
createIndexes,
|
||||
} from "./lib/storage/items.js";
|
||||
import { getUserId } from "./lib/utils/auth.js";
|
||||
import { webmentionReceiver } from "./lib/webmention/receiver.js";
|
||||
import { websubHandler } from "./lib/websub/handler.js";
|
||||
|
||||
@@ -23,6 +25,43 @@ const defaults = {
|
||||
const router = express.Router();
|
||||
const readerRouter = express.Router();
|
||||
|
||||
const bookmarkHookRouter = express.Router();
|
||||
bookmarkHookRouter.use((request, response, next) => {
|
||||
response.on("finish", () => {
|
||||
if (
|
||||
request.method !== "POST" ||
|
||||
(response.statusCode !== 201 && response.statusCode !== 202)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action =
|
||||
request.query?.action || request.body?.action || "create";
|
||||
if (action !== "create") return;
|
||||
|
||||
const bookmarkOf =
|
||||
request.body?.["bookmark-of"] ||
|
||||
request.body?.properties?.["bookmark-of"]?.[0];
|
||||
if (!bookmarkOf) return;
|
||||
|
||||
const rawCategory =
|
||||
request.body?.category ||
|
||||
request.body?.properties?.category;
|
||||
const category = Array.isArray(rawCategory)
|
||||
? rawCategory[0] || "bookmarks"
|
||||
: rawCategory || "bookmarks";
|
||||
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
importBookmarkAsFollow(application, bookmarkOf, category, userId).catch(
|
||||
(err) =>
|
||||
console.warn("[Microsub] bookmark-import failed:", err.message),
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
export default class MicrosubEndpoint {
|
||||
name = "Microsub endpoint";
|
||||
|
||||
@@ -68,6 +107,15 @@ export default class MicrosubEndpoint {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware hook registered on the Micropub route to intercept bookmark
|
||||
* posts and auto-follow them as Microsub feed subscriptions.
|
||||
* @returns {import("express").Router} Express router
|
||||
*/
|
||||
get contentNegotiationRoutes() {
|
||||
return bookmarkHookRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsub API and reader UI routes (authenticated)
|
||||
* @returns {import("express").Router} Express router
|
||||
|
||||
113
lib/bookmark-import.js
Normal file
113
lib/bookmark-import.js
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Bookmark-to-microsub import
|
||||
*
|
||||
* When a Micropub bookmark-of post is created, automatically follow that URL
|
||||
* as a feed in the Microsub reader. Mirrors the blogroll bookmark-import pattern.
|
||||
* @module bookmark-import
|
||||
*/
|
||||
|
||||
import { detectCapabilities } from "./feeds/capabilities.js";
|
||||
import { refreshFeedNow } from "./polling/scheduler.js";
|
||||
import { getChannels } from "./storage/channels.js";
|
||||
import { createFeed, findFeedAcrossChannels } from "./storage/feeds.js";
|
||||
|
||||
const BOOKMARKS_CHANNEL_NAME = "Bookmarks";
|
||||
|
||||
/**
|
||||
* Follow a bookmarked URL as a Microsub feed subscription.
|
||||
*
|
||||
* Finds the best matching channel (by category name → "Bookmarks" → first
|
||||
* non-special channel) and creates a feed subscription if one does not
|
||||
* already exist.
|
||||
*
|
||||
* @param {object} application - Indiekit application context
|
||||
* @param {string|string[]} bookmarkUrl - The bookmarked URL
|
||||
* @param {string} [category="bookmarks"] - Micropub category hint for channel selection
|
||||
* @param {string} [userId="default"] - User ID for channel lookup
|
||||
*/
|
||||
export async function importBookmarkAsFollow(
|
||||
application,
|
||||
bookmarkUrl,
|
||||
category = "bookmarks",
|
||||
userId = "default",
|
||||
) {
|
||||
const url = Array.isArray(bookmarkUrl) ? bookmarkUrl[0] : bookmarkUrl;
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
console.warn(`[Microsub] bookmark-import: invalid URL: ${url}`);
|
||||
return { error: `Invalid bookmark URL: ${url}` };
|
||||
}
|
||||
|
||||
if (!application.collections?.has("microsub_channels")) {
|
||||
console.warn("[Microsub] bookmark-import: microsub collections not available");
|
||||
return { error: "microsub not initialised" };
|
||||
}
|
||||
|
||||
// Check if already followed in any channel
|
||||
const existing = await findFeedAcrossChannels(application, url);
|
||||
if (existing) {
|
||||
console.log(`[Microsub] bookmark-import: ${url} already followed`);
|
||||
return { alreadyExists: true, url };
|
||||
}
|
||||
|
||||
// Find a suitable channel — category match > "Bookmarks" > first non-special
|
||||
const channels = await getChannels(application, userId);
|
||||
const categoryLower = (category || "").toLowerCase();
|
||||
|
||||
const targetChannel =
|
||||
channels.find((ch) => ch.name?.toLowerCase() === categoryLower) ||
|
||||
channels.find(
|
||||
(ch) => ch.name?.toLowerCase() === BOOKMARKS_CHANNEL_NAME.toLowerCase(),
|
||||
) ||
|
||||
channels.find(
|
||||
(ch) => ch.name !== "Notifications" && ch.name !== "ActivityPub",
|
||||
) ||
|
||||
channels[0];
|
||||
|
||||
if (!targetChannel) {
|
||||
console.warn("[Microsub] bookmark-import: no channels available");
|
||||
return { error: "no channels available" };
|
||||
}
|
||||
|
||||
// Create feed subscription
|
||||
let feed;
|
||||
try {
|
||||
feed = await createFeed(application, {
|
||||
channelId: targetChannel._id,
|
||||
url,
|
||||
title: undefined,
|
||||
photo: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === "DUPLICATE_FEED") {
|
||||
console.log(`[Microsub] bookmark-import: feed already exists for ${url}`);
|
||||
return { alreadyExists: true, url };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Fire-and-forget: fetch and detect capabilities
|
||||
refreshFeedNow(application, feed._id).catch((error) => {
|
||||
console.error(
|
||||
`[Microsub] bookmark-import: error fetching ${url}:`,
|
||||
error.message,
|
||||
);
|
||||
});
|
||||
detectCapabilities(url)
|
||||
.then((_capabilities) => {
|
||||
// capabilities are stored by refreshFeedNow/processor; nothing needed here
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`[Microsub] bookmark-import: capability detection error for ${url}:`,
|
||||
error.message,
|
||||
);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Microsub] bookmark-import: added ${url} to channel "${targetChannel.name}"`,
|
||||
);
|
||||
return { added: 1, url, channel: targetChannel.name };
|
||||
}
|
||||
Reference in New Issue
Block a user