From 93de24c593c7738db180651be93ff4fb4ad99f8f Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:12:14 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20add=20bookmark-import=20module=20fo?= =?UTF-8?q?r=20micropub=20bookmark=20=E2=86=92=20blogroll=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bookmark-import.js | 79 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 lib/bookmark-import.js diff --git a/lib/bookmark-import.js b/lib/bookmark-import.js new file mode 100644 index 0000000..4b9b1da --- /dev/null +++ b/lib/bookmark-import.js @@ -0,0 +1,79 @@ +/** + * Bookmark → blogroll import + * Called when a micropub bookmark post is created. + * Discovers feeds for the bookmarked site's origin and adds them to the blogroll. + * @module lib/bookmark-import + */ + +import { discoverFeeds } from "./utils/feed-discovery.js"; +import { upsertBlog } from "./storage/blogs.js"; + +/** + * Import a bookmarked URL's site into the blogroll. + * Extracts the origin URL, discovers feeds, and upserts the first feed as a blog entry. + * + * @param {object} application - Indiekit application object + * @param {string} bookmarkUrl - The URL that was bookmarked (bookmark-of value) + * @returns {Promise} Result { added, alreadyExists, noFeeds, error } + */ +export async function importBookmarkUrl(application, bookmarkUrl) { + // Normalise: bookmark-of may be an array in some micropub clients + const url = Array.isArray(bookmarkUrl) ? bookmarkUrl[0] : bookmarkUrl; + + let siteUrl; + try { + siteUrl = new URL(url).origin; + } catch { + return { error: `Invalid bookmark URL: ${url}` }; + } + + // Guard: blogroll DB must be available + if (typeof application.getBlogrollDb !== "function") { + console.warn("[Blogroll] bookmark-import: getBlogrollDb not available"); + return { error: "blogroll not initialised" }; + } + + const db = application.getBlogrollDb(); + + // Check if any active blog with this siteUrl is already in the blogroll + const existing = await db.collection("blogrollBlogs").findOne({ + siteUrl, + status: { $ne: "deleted" }, + }); + + if (existing) { + console.log( + `[Blogroll] bookmark-import: ${siteUrl} already in blogroll ("${existing.title}")` + ); + return { alreadyExists: true, siteUrl }; + } + + // Discover feeds from the origin + const discovery = await discoverFeeds(siteUrl); + + if (!discovery.success || discovery.feeds.length === 0) { + console.log(`[Blogroll] bookmark-import: no feeds found for ${siteUrl}`); + return { noFeeds: true, siteUrl }; + } + + // Add the first discovered feed + const feed = discovery.feeds[0]; + const result = await upsertBlog(application, { + title: discovery.pageTitle || siteUrl, + feedUrl: feed.url, + siteUrl, + feedType: feed.type || "rss", + category: "bookmarks", + source: "bookmark", + sourceId: null, + status: "active", + }); + + if (result.upserted) { + console.log( + `[Blogroll] bookmark-import: added ${feed.url} ("${discovery.pageTitle || siteUrl}")` + ); + } + + return { added: result.upserted ? 1 : 0, siteUrl }; +} From 34739735e7fc8d4e76f681342928e0c69b6b3e8e Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:23:44 +0100 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20integrate=20bookmark=E2=86=92blogro?= =?UTF-8?q?ll=20hook=20via=20contentNegotiationRoutes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses res.on('finish') middleware mounted at '/' to detect successful micropub bookmark creations and auto-import the bookmarked site's feed into the blogroll. Self-contained within the plugin — no external patch scripts required. --- index.js | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 6b4ec5d..a327e73 100644 --- a/index.js +++ b/index.js @@ -7,12 +7,48 @@ import { blogsController } from "./lib/controllers/blogs.js"; import { sourcesController } from "./lib/controllers/sources.js"; import { apiController } from "./lib/controllers/api.js"; import { startSync, stopSync } from "./lib/sync/scheduler.js"; +import { importBookmarkUrl } from "./lib/bookmark-import.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const protectedRouter = express.Router(); const publicRouter = express.Router(); +// Global hook router: intercepts POST requests site-wide to detect micropub +// bookmark creations and auto-import the bookmarked site into the blogroll. +// Mounted at "/" via contentNegotiationRoutes (runs before auth middleware). +const bookmarkHookRouter = express.Router(); +bookmarkHookRouter.use((request, response, next) => { + response.on("finish", () => { + // Only act on successful POST creates (201 Created / 202 Accepted) + if ( + request.method !== "POST" || + (response.statusCode !== 201 && response.statusCode !== 202) + ) { + return; + } + + // Ignore non-create actions (update, delete, undelete) + const action = + request.query?.action || request.body?.action || "create"; + if (action !== "create") return; + + // bookmark-of may be a top-level field (form-encoded / JF2 JSON) + // or nested inside properties (MF2 JSON format) + const bookmarkOf = + request.body?.["bookmark-of"] || + request.body?.properties?.["bookmark-of"]?.[0]; + if (!bookmarkOf) return; + + const { application } = request.app.locals; + importBookmarkUrl(application, bookmarkOf).catch((err) => + console.warn("[Blogroll] bookmark-import failed:", err.message) + ); + }); + + next(); +}); + const defaults = { mountPath: "/blogrollapi", syncInterval: 3600000, // 1 hour @@ -54,6 +90,14 @@ export default class BlogrollEndpoint { }; } + /** + * Global middleware (mounted at "/") — intercepts micropub bookmark creations. + * Uses res.on("finish") so it never interferes with the request lifecycle. + */ + get contentNegotiationRoutes() { + return bookmarkHookRouter; + } + /** * Protected routes (require authentication) * Admin dashboard and management @@ -149,4 +193,4 @@ export default class BlogrollEndpoint { destroy() { stopSync(); } -} +} \ No newline at end of file From adbdadd5089404431ef3ff8d13493cf9174098ec Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:51:08 +0100 Subject: [PATCH 3/5] feat: use bookmark post category as blogroll category --- lib/bookmark-import.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/bookmark-import.js b/lib/bookmark-import.js index 4b9b1da..2675d77 100644 --- a/lib/bookmark-import.js +++ b/lib/bookmark-import.js @@ -14,9 +14,10 @@ import { upsertBlog } from "./storage/blogs.js"; * * @param {object} application - Indiekit application object * @param {string} bookmarkUrl - The URL that was bookmarked (bookmark-of value) + * @param {string} [category="bookmarks"] - Category to assign in the blogroll * @returns {Promise} Result { added, alreadyExists, noFeeds, error } */ -export async function importBookmarkUrl(application, bookmarkUrl) { +export async function importBookmarkUrl(application, bookmarkUrl, category = "bookmarks") { // Normalise: bookmark-of may be an array in some micropub clients const url = Array.isArray(bookmarkUrl) ? bookmarkUrl[0] : bookmarkUrl; @@ -63,7 +64,7 @@ export async function importBookmarkUrl(application, bookmarkUrl) { feedUrl: feed.url, siteUrl, feedType: feed.type || "rss", - category: "bookmarks", + category, source: "bookmark", sourceId: null, status: "active", @@ -71,9 +72,9 @@ export async function importBookmarkUrl(application, bookmarkUrl) { if (result.upserted) { console.log( - `[Blogroll] bookmark-import: added ${feed.url} ("${discovery.pageTitle || siteUrl}")` + `[Blogroll] bookmark-import: added ${feed.url} ("${discovery.pageTitle || siteUrl}") → category "${category}"` ); } return { added: result.upserted ? 1 : 0, siteUrl }; -} +} \ No newline at end of file From 66bc404f0301907f63b9d6b976631f80ffa4ee40 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:53:46 +0100 Subject: [PATCH 4/5] feat: pass bookmark post category through to blogroll entry --- index.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index a327e73..f5d1a3f 100644 --- a/index.js +++ b/index.js @@ -40,8 +40,19 @@ bookmarkHookRouter.use((request, response, next) => { request.body?.properties?.["bookmark-of"]?.[0]; if (!bookmarkOf) return; + // Extract category from any micropub body format: + // form-encoded: category=tech or category[]=tech&category[]=web + // JF2 JSON: { "category": ["tech", "web"] } + // MF2 JSON: { "properties": { "category": ["tech"] } } + const rawCategory = + request.body?.category || + request.body?.properties?.category; + const category = Array.isArray(rawCategory) + ? rawCategory[0] || "bookmarks" + : rawCategory || "bookmarks"; + const { application } = request.app.locals; - importBookmarkUrl(application, bookmarkOf).catch((err) => + importBookmarkUrl(application, bookmarkOf, category).catch((err) => console.warn("[Blogroll] bookmark-import failed:", err.message) ); }); From 381b0397a55f75747af28c7221af31af3cc32c3f Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:33:42 +0100 Subject: [PATCH 5/5] feat: guard bookmark hook when microsub is available, update category on tag change - index: skip direct bookmark import when microsub plugin is present; microsub handles the flow and notifies blogroll via notifyBlogroll() to avoid duplicate entries - bookmark-import: when blog already exists and category differs, update it instead of skipping (handles tag changes on existing bookmark posts) Co-Authored-By: Claude Sonnet 4.6 --- index.js | 14 +++++++++++++- lib/bookmark-import.js | 13 ++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index f5d1a3f..22c82be 100644 --- a/index.js +++ b/index.js @@ -17,6 +17,11 @@ const publicRouter = express.Router(); // Global hook router: intercepts POST requests site-wide to detect micropub // bookmark creations and auto-import the bookmarked site into the blogroll. // Mounted at "/" via contentNegotiationRoutes (runs before auth middleware). +// +// NOTE: When the Microsub plugin is installed it acts as the single source of +// truth for bookmarks — it creates the feed subscription AND notifies the +// blogroll via notifyBlogroll(). This hook therefore skips processing if +// Microsub is available, acting only as a standalone fallback. const bookmarkHookRouter = express.Router(); bookmarkHookRouter.use((request, response, next) => { response.on("finish", () => { @@ -40,6 +45,14 @@ bookmarkHookRouter.use((request, response, next) => { request.body?.properties?.["bookmark-of"]?.[0]; if (!bookmarkOf) return; + const { application } = request.app.locals; + + // Microsub plugin is installed → it will handle this bookmark and notify + // the blogroll. Skip direct import to avoid duplicate entries. + if (application.collections?.has("microsub_channels")) { + return; + } + // Extract category from any micropub body format: // form-encoded: category=tech or category[]=tech&category[]=web // JF2 JSON: { "category": ["tech", "web"] } @@ -51,7 +64,6 @@ bookmarkHookRouter.use((request, response, next) => { ? rawCategory[0] || "bookmarks" : rawCategory || "bookmarks"; - const { application } = request.app.locals; importBookmarkUrl(application, bookmarkOf, category).catch((err) => console.warn("[Blogroll] bookmark-import failed:", err.message) ); diff --git a/lib/bookmark-import.js b/lib/bookmark-import.js index 2675d77..429d6cd 100644 --- a/lib/bookmark-import.js +++ b/lib/bookmark-import.js @@ -43,8 +43,19 @@ export async function importBookmarkUrl(application, bookmarkUrl, category = "bo }); if (existing) { + // If the category differs, update it (tag changed on the bookmark post) + if (existing.category !== category) { + await db.collection("blogrollBlogs").updateOne( + { _id: existing._id }, + { $set: { category, updatedAt: new Date().toISOString() } }, + ); + console.log( + `[Blogroll] bookmark-import: updated category for "${existing.title}" → "${category}"`, + ); + return { updated: true, siteUrl }; + } console.log( - `[Blogroll] bookmark-import: ${siteUrl} already in blogroll ("${existing.title}")` + `[Blogroll] bookmark-import: ${siteUrl} already in blogroll ("${existing.title}")`, ); return { alreadyExists: true, siteUrl }; }