diff --git a/index.js b/index.js index b200f2f..8eded93 100644 --- a/index.js +++ b/index.js @@ -3,7 +3,10 @@ import { fileURLToPath } from "node:url"; import express from "express"; -import { importBookmarkAsFollow } from "./lib/bookmark-import.js"; +import { + importBookmarkAsFollow, + updateBookmarkFollow, +} 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"; @@ -28,35 +31,87 @@ 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; - } + if (request.method !== "POST") 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), - ); + + // ── CREATE: new bookmark post ──────────────────────────────────────────── + if ( + action === "create" && + (response.statusCode === 201 || response.statusCode === 202) + ) { + const bookmarkOf = + request.body?.["bookmark-of"] || + request.body?.properties?.["bookmark-of"]?.[0]; + if (!bookmarkOf) return; + + // Collect all tags (all micropub body formats) + const rawCategory = + request.body?.category || + request.body?.properties?.category; + const tags = Array.isArray(rawCategory) + ? rawCategory.filter(Boolean) + : rawCategory + ? [rawCategory] + : []; + + // The post permalink may appear in the Location response header + const postUrl = response.getHeader?.("Location") || undefined; + + importBookmarkAsFollow(application, bookmarkOf, tags, userId, postUrl).catch( + (err) => + console.warn("[Microsub] bookmark-import failed:", err.message), + ); + return; + } + + // ── UPDATE: bookmark post edited ───────────────────────────────────────── + if ( + action === "update" && + (response.statusCode === 200 || + response.statusCode === 204) + ) { + const postUrl = request.body?.url; + if (!postUrl) return; + + // Detect what changed + const replace = request.body?.replace || {}; + const deleteFields = request.body?.delete || []; + const deleteList = Array.isArray(deleteFields) + ? deleteFields + : Object.keys(deleteFields); + + // bookmark-of removed? + const bookmarkRemoved = + deleteList.includes("bookmark-of") || + (replace["bookmark-of"] !== undefined && + !replace["bookmark-of"]?.[0]); + + // New tags? + const rawNewTags = + replace.category || + replace?.properties?.category; + const newTags = rawNewTags + ? Array.isArray(rawNewTags) + ? rawNewTags.filter(Boolean) + : [rawNewTags] + : null; + + if (bookmarkRemoved || newTags) { + updateBookmarkFollow( + application, + postUrl, + { bookmarkRemoved: !!bookmarkRemoved, newTags: newTags || undefined }, + userId, + ).catch((err) => + console.warn("[Microsub] bookmark-update failed:", err.message), + ); + } + return; + } }); next(); diff --git a/lib/bookmark-import.js b/lib/bookmark-import.js index 848c88f..73c76e1 100644 --- a/lib/bookmark-import.js +++ b/lib/bookmark-import.js @@ -2,34 +2,85 @@ * 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. + * as a feed in the Microsub reader. + * + * Flow: bookmark created → find/create channel from tag → create feed → + * notify blogroll (which gets its entry from microsub, not independently). + * * @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"; +import { createChannel, getChannels } from "./storage/channels.js"; +import { + createFeed, + deleteFeedById, + findFeedAcrossChannels, + getFeedByMicropubPostUrl, + updateFeed, +} from "./storage/feeds.js"; +import { notifyBlogroll } from "./utils/blogroll-notify.js"; const BOOKMARKS_CHANNEL_NAME = "Bookmarks"; +const SYSTEM_CHANNELS = new Set(["notifications", "activitypub"]); + +/** + * Resolve (find or create) a Microsub channel by name. + * Uses exact case-insensitive match; creates a new channel if none found. + * + * @param {object} application - Indiekit application context + * @param {string} channelName - Desired channel name + * @param {string} userId - User ID + * @returns {Promise} Full channel document (with _id) + */ +async function resolveChannel(application, channelName, userId) { + const channelsCollection = application.collections.get("microsub_channels"); + const nameLower = channelName.toLowerCase(); + + // Try to find existing channel (full documents needed for _id) + const channels = await channelsCollection + .find(userId ? { userId } : {}) + .sort({ order: 1 }) + .toArray(); + + const existing = channels.find( + (ch) => ch.name?.toLowerCase() === nameLower, + ); + if (existing) return existing; + + // Create new channel + const created = await createChannel(application, { name: channelName, userId }); + + // createChannel returns the document from collection but may not have _id if + // it was returned before insertOne; fetch it back to be safe. + const fresh = await channelsCollection.findOne( + userId ? { uid: created.uid, userId } : { uid: created.uid }, + ); + return fresh || created; +} /** * 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. + * - Uses the FIRST tag as the channel name (creates the channel if needed). + * - Falls back to "Bookmarks" channel if no tag is given. + * - If the feed already exists in a DIFFERENT channel, move it. + * - Notifies the blogroll plugin after creating/moving the feed. + * - Stores micropubPostUrl on the feed for future update/delete tracking. * * @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|string[]} [tags=[]] - Micropub category/tags for channel selection * @param {string} [userId="default"] - User ID for channel lookup + * @param {string} [postUrl] - Permalink of the micropub post (for tracking) */ export async function importBookmarkAsFollow( application, bookmarkUrl, - category = "bookmarks", + tags = [], userId = "default", + postUrl, ) { const url = Array.isArray(bookmarkUrl) ? bookmarkUrl[0] : bookmarkUrl; @@ -41,37 +92,66 @@ export async function importBookmarkAsFollow( } if (!application.collections?.has("microsub_channels")) { - console.warn("[Microsub] bookmark-import: microsub collections not available"); + console.warn( + "[Microsub] bookmark-import: microsub collections not available", + ); return { error: "microsub not initialised" }; } + // Normalise tags to an array of trimmed, non-empty strings + const tagList = (Array.isArray(tags) ? tags : [tags]) + .map((t) => String(t).trim()) + .filter(Boolean); + + // Desired channel: first tag or "Bookmarks" fallback + const desiredChannelName = + tagList[0] || + BOOKMARKS_CHANNEL_NAME; + + // Resolve (find or create) the target channel + const targetChannel = await resolveChannel( + application, + desiredChannelName, + userId, + ); + + if (!targetChannel) { + console.warn("[Microsub] bookmark-import: could not resolve channel"); + return { error: "could not resolve channel" }; + } + // 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 }; + const existingFeed = existing.feed; + const existingChannelName = existing.channelName; + + // If in the correct channel already, nothing to do + if ( + existingFeed.channelId.toString() === targetChannel._id.toString() + ) { + // Update micropubPostUrl if we now know it and it wasn't set + if (postUrl && !existingFeed.micropubPostUrl) { + await updateFeed(application, existingFeed._id, { + micropubPostUrl: postUrl, + }); + } + console.log( + `[Microsub] bookmark-import: ${url} already followed in "${existingChannelName}" (correct channel)`, + ); + return { alreadyExists: true, url, channel: existingChannelName }; + } + + // Wrong channel — move the feed: delete from old channel, create in new one. + console.log( + `[Microsub] bookmark-import: moving ${url} from "${existingChannelName}" → "${targetChannel.name}"`, + ); + await deleteFeedById(application, existingFeed._id); + // Fall through to create below } - // 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 + // Create feed subscription in the target channel let feed; try { feed = await createFeed(application, { @@ -79,10 +159,13 @@ export async function importBookmarkAsFollow( url, title: undefined, photo: undefined, + micropubPostUrl: postUrl || undefined, }); } catch (error) { if (error.code === "DUPLICATE_FEED") { - console.log(`[Microsub] bookmark-import: feed already exists for ${url}`); + console.log( + `[Microsub] bookmark-import: duplicate feed detected for ${url}`, + ); return { alreadyExists: true, url }; } throw error; @@ -95,19 +178,136 @@ export async function importBookmarkAsFollow( 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, - ); - }); + detectCapabilities(url).catch((error) => { + console.error( + `[Microsub] bookmark-import: capability detection error for ${url}:`, + error.message, + ); + }); + + // Notify blogroll (it gets its entries from microsub, not independently) + notifyBlogroll(application, "follow", { + url, + title: feed.title, + channelName: targetChannel.name, + feedId: feed._id.toString(), + channelId: targetChannel._id.toString(), + }).catch((error) => { + console.error(`[Microsub] bookmark-import: blogroll notify error:`, error.message); + }); console.log( `[Microsub] bookmark-import: added ${url} to channel "${targetChannel.name}"`, ); return { added: 1, url, channel: targetChannel.name }; } + +/** + * Handle a bookmark post UPDATE. + * + * Called when a micropub update action is detected and the post previously + * created a feed subscription. Handles: + * - Tag change → move feed to new channel, update blogroll category + * - bookmark-of removed → unfollow from microsub and remove from blogroll + * + * @param {object} application - Indiekit application context + * @param {string} postUrl - Permalink of the micropub post being updated + * @param {object} changes - Detected changes from the micropub update body + * @param {string[]} [changes.newTags] - New category/tag values (if changed) + * @param {boolean} [changes.bookmarkRemoved] - True if bookmark-of was deleted + * @param {string} [changes.newBookmarkUrl] - New bookmark-of URL (if changed) + * @param {string} [userId="default"] - User ID + */ +export async function updateBookmarkFollow( + application, + postUrl, + changes, + userId = "default", +) { + if (!application.collections?.has("microsub_channels")) return; + + // Find the feed that was created from this post + const existing = await getFeedByMicropubPostUrl(application, postUrl); + if (!existing) { + console.log( + `[Microsub] bookmark-update: no feed found for post ${postUrl}`, + ); + return; + } + + const { feed, channel } = existing; + + // Case 1: bookmark-of removed or post type changed → unfollow + if (changes.bookmarkRemoved) { + console.log( + `[Microsub] bookmark-update: bookmark-of removed for ${postUrl}, unfollowing ${feed.url}`, + ); + await deleteFeedById(application, feed._id); + notifyBlogroll(application, "unfollow", { url: feed.url }).catch( + (error) => { + console.error( + `[Microsub] bookmark-update: blogroll notify error:`, + error.message, + ); + }, + ); + return; + } + + // Case 2: tag/category changed → move feed to new channel + if (changes.newTags && changes.newTags.length > 0) { + const desiredChannelName = changes.newTags[0]; + + // Already in the right channel? + if (channel?.name?.toLowerCase() === desiredChannelName.toLowerCase()) { + console.log( + `[Microsub] bookmark-update: channel unchanged for ${feed.url}`, + ); + return; + } + + const newChannel = await resolveChannel( + application, + desiredChannelName, + userId, + ); + + // Move: delete from old, create in new + console.log( + `[Microsub] bookmark-update: moving ${feed.url} → channel "${newChannel.name}"`, + ); + await deleteFeedById(application, feed._id); + + let newFeed; + try { + newFeed = await createFeed(application, { + channelId: newChannel._id, + url: feed.url, + title: feed.title, + photo: feed.photo, + micropubPostUrl: postUrl, + }); + } catch (error) { + if (error.code !== "DUPLICATE_FEED") throw error; + // If it ended up there already, that's fine + return; + } + + // Refresh the feed in its new home + refreshFeedNow(application, newFeed._id).catch(() => {}); + + // Update blogroll category + notifyBlogroll(application, "follow", { + url: newFeed.url, + title: newFeed.title, + channelName: newChannel.name, + feedId: newFeed._id.toString(), + channelId: newChannel._id.toString(), + }).catch((error) => { + console.error( + `[Microsub] bookmark-update: blogroll notify error:`, + error.message, + ); + }); + } +} diff --git a/lib/storage/feeds.js b/lib/storage/feeds.js index a6836fd..960cf74 100644 --- a/lib/storage/feeds.js +++ b/lib/storage/feeds.js @@ -91,11 +91,12 @@ export async function findFeedAcrossChannels(application, url) { * @param {string} data.url - Feed URL * @param {string} [data.title] - Feed title * @param {string} [data.photo] - Feed icon URL + * @param {string} [data.micropubPostUrl] - Micropub post URL that created this feed (for update tracking) * @returns {Promise} Created feed */ export async function createFeed( application, - { channelId, url, title, photo }, + { channelId, url, title, photo, micropubPostUrl }, ) { const collection = getCollection(application); @@ -122,6 +123,7 @@ export async function createFeed( url, title: title || undefined, photo: photo || undefined, + micropubPostUrl: micropubPostUrl || undefined, tier: 1, // Start at tier 1 (2 minutes) unmodified: 0, nextFetchAt: new Date(), // Fetch immediately (kept as Date for query compatibility) @@ -177,6 +179,47 @@ export async function getFeedById(application, id) { return collection.findOne({ _id: objectId }); } +/** + * Get a feed by the micropub post URL that created it. + * Used for update/delete tracking when a bookmark post changes. + * @param {object} application - Indiekit application + * @param {string} postUrl - The micropub post permalink + * @returns {Promise<{feed: object, channel: object}|null>} Feed + channel, or null + */ +export async function getFeedByMicropubPostUrl(application, postUrl) { + const collection = getCollection(application); + const feed = await collection.findOne({ micropubPostUrl: postUrl }); + if (!feed) return null; + + const channelsCollection = application.collections.get("microsub_channels"); + const channel = await channelsCollection.findOne({ _id: feed.channelId }); + + return { feed, channel }; +} + +/** + * Delete a feed by its ObjectId (regardless of channel). + * Used when removing a feed without knowing the channel. + * @param {object} application - Indiekit application + * @param {ObjectId|string} feedId - Feed ObjectId + * @returns {Promise} True if deleted + */ +export async function deleteFeedById(application, feedId) { + const collection = getCollection(application); + const objectId = typeof feedId === "string" ? new ObjectId(feedId) : feedId; + + const feed = await collection.findOne({ _id: objectId }); + if (!feed) return false; + + const itemsDeleted = await deleteItemsForFeed(application, feed._id); + console.info( + `[Microsub] Deleted ${itemsDeleted} items from feed ${feed.url}`, + ); + + const result = await collection.deleteOne({ _id: objectId }); + return result.deletedCount > 0; +} + /** * Update a feed * @param {object} application - Indiekit application