Merge pull request #1 from svemagie/bookmarks-import

feat: bookmarks import
This commit is contained in:
svemagie
2026-03-12 12:28:10 +01:00
committed by GitHub
3 changed files with 460 additions and 1 deletions

103
index.js
View File

@@ -3,6 +3,10 @@ import { fileURLToPath } from "node:url";
import express from "express";
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";
@@ -14,6 +18,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 +28,95 @@ 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") return;
const action =
request.query?.action || request.body?.action || "create";
const { application } = request.app.locals;
const userId = getUserId(request);
// ── 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();
});
export default class MicrosubEndpoint {
name = "Microsub endpoint";
@@ -68,6 +162,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

313
lib/bookmark-import.js Normal file
View File

@@ -0,0 +1,313 @@
/**
* Bookmark-to-microsub import
*
* When a Micropub bookmark-of post is created, automatically follow that URL
* 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 { 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<object>} 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.
*
* - 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|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,
tags = [],
userId = "default",
postUrl,
) {
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" };
}
// 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) {
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
}
// Create feed subscription in the target channel
let feed;
try {
feed = await createFeed(application, {
channelId: targetChannel._id,
url,
title: undefined,
photo: undefined,
micropubPostUrl: postUrl || undefined,
});
} catch (error) {
if (error.code === "DUPLICATE_FEED") {
console.log(
`[Microsub] bookmark-import: duplicate feed detected 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).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,
);
});
}
}

View File

@@ -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<object>} 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<boolean>} 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