mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
feat: bookmark flow — tag→channel, notifyBlogroll, update tracking
- bookmark-import: find/create channel from first tag (no more fallback-only) - bookmark-import: call notifyBlogroll() after creating feed so blogroll gets its entries from microsub, not independently - bookmark-import: store micropubPostUrl on feed for update/delete tracking - bookmark-import: move feed when tag changes (delete old, create in new channel) - feeds: add micropubPostUrl field to createFeed() - feeds: add getFeedByMicropubPostUrl() for update hook lookup - feeds: add deleteFeedById() for channel-agnostic removal - index: pass full tags array (not just first) to importBookmarkAsFollow - index: capture Location header as postUrl for tracking - index: handle update action — detect tag change or bookmark-of removal and call updateBookmarkFollow() accordingly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
83
index.js
83
index.js
@@ -3,7 +3,10 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
import express from "express";
|
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 { microsubController } from "./lib/controllers/microsub.js";
|
||||||
import { opmlController } from "./lib/controllers/opml.js";
|
import { opmlController } from "./lib/controllers/opml.js";
|
||||||
import { readerController } from "./lib/controllers/reader.js";
|
import { readerController } from "./lib/controllers/reader.js";
|
||||||
@@ -28,35 +31,87 @@ const readerRouter = express.Router();
|
|||||||
const bookmarkHookRouter = express.Router();
|
const bookmarkHookRouter = express.Router();
|
||||||
bookmarkHookRouter.use((request, response, next) => {
|
bookmarkHookRouter.use((request, response, next) => {
|
||||||
response.on("finish", () => {
|
response.on("finish", () => {
|
||||||
if (
|
if (request.method !== "POST") return;
|
||||||
request.method !== "POST" ||
|
|
||||||
(response.statusCode !== 201 && response.statusCode !== 202)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const action =
|
const action =
|
||||||
request.query?.action || request.body?.action || "create";
|
request.query?.action || request.body?.action || "create";
|
||||||
if (action !== "create") return;
|
const { application } = request.app.locals;
|
||||||
|
const userId = getUserId(request);
|
||||||
|
|
||||||
|
// ── CREATE: new bookmark post ────────────────────────────────────────────
|
||||||
|
if (
|
||||||
|
action === "create" &&
|
||||||
|
(response.statusCode === 201 || response.statusCode === 202)
|
||||||
|
) {
|
||||||
const bookmarkOf =
|
const bookmarkOf =
|
||||||
request.body?.["bookmark-of"] ||
|
request.body?.["bookmark-of"] ||
|
||||||
request.body?.properties?.["bookmark-of"]?.[0];
|
request.body?.properties?.["bookmark-of"]?.[0];
|
||||||
if (!bookmarkOf) return;
|
if (!bookmarkOf) return;
|
||||||
|
|
||||||
|
// Collect all tags (all micropub body formats)
|
||||||
const rawCategory =
|
const rawCategory =
|
||||||
request.body?.category ||
|
request.body?.category ||
|
||||||
request.body?.properties?.category;
|
request.body?.properties?.category;
|
||||||
const category = Array.isArray(rawCategory)
|
const tags = Array.isArray(rawCategory)
|
||||||
? rawCategory[0] || "bookmarks"
|
? rawCategory.filter(Boolean)
|
||||||
: rawCategory || "bookmarks";
|
: rawCategory
|
||||||
|
? [rawCategory]
|
||||||
|
: [];
|
||||||
|
|
||||||
const { application } = request.app.locals;
|
// The post permalink may appear in the Location response header
|
||||||
const userId = getUserId(request);
|
const postUrl = response.getHeader?.("Location") || undefined;
|
||||||
importBookmarkAsFollow(application, bookmarkOf, category, userId).catch(
|
|
||||||
|
importBookmarkAsFollow(application, bookmarkOf, tags, userId, postUrl).catch(
|
||||||
(err) =>
|
(err) =>
|
||||||
console.warn("[Microsub] bookmark-import failed:", err.message),
|
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();
|
next();
|
||||||
|
|||||||
@@ -2,34 +2,85 @@
|
|||||||
* Bookmark-to-microsub import
|
* Bookmark-to-microsub import
|
||||||
*
|
*
|
||||||
* When a Micropub bookmark-of post is created, automatically follow that URL
|
* 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
|
* @module bookmark-import
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { detectCapabilities } from "./feeds/capabilities.js";
|
import { detectCapabilities } from "./feeds/capabilities.js";
|
||||||
import { refreshFeedNow } from "./polling/scheduler.js";
|
import { refreshFeedNow } from "./polling/scheduler.js";
|
||||||
import { getChannels } from "./storage/channels.js";
|
import { createChannel, getChannels } from "./storage/channels.js";
|
||||||
import { createFeed, findFeedAcrossChannels } from "./storage/feeds.js";
|
import {
|
||||||
|
createFeed,
|
||||||
|
deleteFeedById,
|
||||||
|
findFeedAcrossChannels,
|
||||||
|
getFeedByMicropubPostUrl,
|
||||||
|
updateFeed,
|
||||||
|
} from "./storage/feeds.js";
|
||||||
|
import { notifyBlogroll } from "./utils/blogroll-notify.js";
|
||||||
|
|
||||||
const BOOKMARKS_CHANNEL_NAME = "Bookmarks";
|
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.
|
* Follow a bookmarked URL as a Microsub feed subscription.
|
||||||
*
|
*
|
||||||
* Finds the best matching channel (by category name → "Bookmarks" → first
|
* - Uses the FIRST tag as the channel name (creates the channel if needed).
|
||||||
* non-special channel) and creates a feed subscription if one does not
|
* - Falls back to "Bookmarks" channel if no tag is given.
|
||||||
* already exist.
|
* - 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 {object} application - Indiekit application context
|
||||||
* @param {string|string[]} bookmarkUrl - The bookmarked URL
|
* @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} [userId="default"] - User ID for channel lookup
|
||||||
|
* @param {string} [postUrl] - Permalink of the micropub post (for tracking)
|
||||||
*/
|
*/
|
||||||
export async function importBookmarkAsFollow(
|
export async function importBookmarkAsFollow(
|
||||||
application,
|
application,
|
||||||
bookmarkUrl,
|
bookmarkUrl,
|
||||||
category = "bookmarks",
|
tags = [],
|
||||||
userId = "default",
|
userId = "default",
|
||||||
|
postUrl,
|
||||||
) {
|
) {
|
||||||
const url = Array.isArray(bookmarkUrl) ? bookmarkUrl[0] : bookmarkUrl;
|
const url = Array.isArray(bookmarkUrl) ? bookmarkUrl[0] : bookmarkUrl;
|
||||||
|
|
||||||
@@ -41,37 +92,66 @@ export async function importBookmarkAsFollow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!application.collections?.has("microsub_channels")) {
|
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" };
|
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
|
// Check if already followed in any channel
|
||||||
const existing = await findFeedAcrossChannels(application, url);
|
const existing = await findFeedAcrossChannels(application, url);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log(`[Microsub] bookmark-import: ${url} already followed`);
|
const existingFeed = existing.feed;
|
||||||
return { alreadyExists: true, url };
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find a suitable channel — category match > "Bookmarks" > first non-special
|
// Wrong channel — move the feed: delete from old channel, create in new one.
|
||||||
const channels = await getChannels(application, userId);
|
console.log(
|
||||||
const categoryLower = (category || "").toLowerCase();
|
`[Microsub] bookmark-import: moving ${url} from "${existingChannelName}" → "${targetChannel.name}"`,
|
||||||
|
);
|
||||||
const targetChannel =
|
await deleteFeedById(application, existingFeed._id);
|
||||||
channels.find((ch) => ch.name?.toLowerCase() === categoryLower) ||
|
// Fall through to create below
|
||||||
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;
|
let feed;
|
||||||
try {
|
try {
|
||||||
feed = await createFeed(application, {
|
feed = await createFeed(application, {
|
||||||
@@ -79,10 +159,13 @@ export async function importBookmarkAsFollow(
|
|||||||
url,
|
url,
|
||||||
title: undefined,
|
title: undefined,
|
||||||
photo: undefined,
|
photo: undefined,
|
||||||
|
micropubPostUrl: postUrl || undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === "DUPLICATE_FEED") {
|
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 };
|
return { alreadyExists: true, url };
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -95,19 +178,136 @@ export async function importBookmarkAsFollow(
|
|||||||
error.message,
|
error.message,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
detectCapabilities(url)
|
detectCapabilities(url).catch((error) => {
|
||||||
.then((_capabilities) => {
|
|
||||||
// capabilities are stored by refreshFeedNow/processor; nothing needed here
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(
|
console.error(
|
||||||
`[Microsub] bookmark-import: capability detection error for ${url}:`,
|
`[Microsub] bookmark-import: capability detection error for ${url}:`,
|
||||||
error.message,
|
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(
|
console.log(
|
||||||
`[Microsub] bookmark-import: added ${url} to channel "${targetChannel.name}"`,
|
`[Microsub] bookmark-import: added ${url} to channel "${targetChannel.name}"`,
|
||||||
);
|
);
|
||||||
return { added: 1, url, 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,11 +91,12 @@ export async function findFeedAcrossChannels(application, url) {
|
|||||||
* @param {string} data.url - Feed URL
|
* @param {string} data.url - Feed URL
|
||||||
* @param {string} [data.title] - Feed title
|
* @param {string} [data.title] - Feed title
|
||||||
* @param {string} [data.photo] - Feed icon URL
|
* @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
|
* @returns {Promise<object>} Created feed
|
||||||
*/
|
*/
|
||||||
export async function createFeed(
|
export async function createFeed(
|
||||||
application,
|
application,
|
||||||
{ channelId, url, title, photo },
|
{ channelId, url, title, photo, micropubPostUrl },
|
||||||
) {
|
) {
|
||||||
const collection = getCollection(application);
|
const collection = getCollection(application);
|
||||||
|
|
||||||
@@ -122,6 +123,7 @@ export async function createFeed(
|
|||||||
url,
|
url,
|
||||||
title: title || undefined,
|
title: title || undefined,
|
||||||
photo: photo || undefined,
|
photo: photo || undefined,
|
||||||
|
micropubPostUrl: micropubPostUrl || undefined,
|
||||||
tier: 1, // Start at tier 1 (2 minutes)
|
tier: 1, // Start at tier 1 (2 minutes)
|
||||||
unmodified: 0,
|
unmodified: 0,
|
||||||
nextFetchAt: new Date(), // Fetch immediately (kept as Date for query compatibility)
|
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 });
|
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
|
* Update a feed
|
||||||
* @param {object} application - Indiekit application
|
* @param {object} application - Indiekit application
|
||||||
|
|||||||
Reference in New Issue
Block a user