mirror of
https://github.com/svemagie/indiekit-endpoint-blogroll.git
synced 2026-04-02 15:34:59 +02:00
feat: Add Microsub integration with reference-based data approach
- Add Microsub source type to sync subscriptions from Microsub channels - Use reference-based approach to avoid data duplication: - Blogs store microsubFeedId reference instead of copying data - Items for Microsub blogs are queried from microsub_items directly - No duplicate storage or retention management needed - Add channel filter and category prefix options for Microsub sources - Add webhook endpoint for Microsub subscription change notifications - Update scheduler to skip item fetching for Microsub blogs - Update items storage to combine results from both collections - Bump version to 1.0.7 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
4
index.js
4
index.js
@@ -89,6 +89,10 @@ export default class BlogrollEndpoint {
|
||||
// Feed discovery (protected to prevent abuse)
|
||||
protectedRouter.get("/api/discover", apiController.discover);
|
||||
|
||||
// Microsub integration (protected - internal use)
|
||||
protectedRouter.post("/api/microsub-webhook", apiController.microsubWebhook);
|
||||
protectedRouter.get("/api/microsub-status", apiController.microsubStatus);
|
||||
|
||||
return protectedRouter;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getItems, getItemsForBlog } from "../storage/items.js";
|
||||
import { getSyncStatus } from "../sync/scheduler.js";
|
||||
import { generateOpml } from "../sync/opml.js";
|
||||
import { discoverFeeds } from "../utils/feed-discovery.js";
|
||||
import { handleMicrosubWebhook, isMicrosubAvailable } from "../sync/microsub.js";
|
||||
|
||||
/**
|
||||
* List blogs with optional filtering
|
||||
@@ -57,7 +58,9 @@ async function getBlogDetail(request, response) {
|
||||
return response.status(404).json({ error: "Blog not found" });
|
||||
}
|
||||
|
||||
const items = await getItemsForBlog(application, blog._id, 20);
|
||||
// Pass blog to getItemsForBlog to avoid duplicate lookup
|
||||
// This handles both regular and Microsub-sourced blogs transparently
|
||||
const items = await getItemsForBlog(application, blog._id, 20, blog);
|
||||
|
||||
response.json({
|
||||
...sanitizeBlog(blog),
|
||||
@@ -214,7 +217,7 @@ async function discover(request, response) {
|
||||
* @returns {object} Sanitized blog
|
||||
*/
|
||||
function sanitizeBlog(blog) {
|
||||
return {
|
||||
const sanitized = {
|
||||
id: blog._id.toString(),
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
@@ -230,6 +233,14 @@ function sanitizeBlog(blog) {
|
||||
pinned: blog.pinned,
|
||||
lastFetchAt: blog.lastFetchAt,
|
||||
};
|
||||
|
||||
// Include Microsub metadata if applicable
|
||||
if (blog.source === "microsub") {
|
||||
sanitized.source = "microsub";
|
||||
sanitized.microsubChannel = blog.microsubChannelName;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,6 +261,85 @@ function sanitizeItem(item) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsub webhook handler
|
||||
* Receives subscription change notifications from Microsub
|
||||
* POST /api/microsub-webhook
|
||||
*/
|
||||
async function microsubWebhook(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
|
||||
try {
|
||||
// Verify Microsub is available
|
||||
if (!isMicrosubAvailable(application)) {
|
||||
return response.status(503).json({
|
||||
ok: false,
|
||||
error: "Microsub integration not available",
|
||||
});
|
||||
}
|
||||
|
||||
const { action, url, channelName, title } = request.body;
|
||||
|
||||
if (!action || !url) {
|
||||
return response.status(400).json({
|
||||
ok: false,
|
||||
error: "Missing required fields: action and url",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await handleMicrosubWebhook(application, {
|
||||
action,
|
||||
url,
|
||||
channelName,
|
||||
title,
|
||||
});
|
||||
|
||||
response.json(result);
|
||||
} catch (error) {
|
||||
console.error("[Blogroll API] microsubWebhook error:", error);
|
||||
response.status(500).json({
|
||||
ok: false,
|
||||
error: "Webhook processing failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Microsub integration status
|
||||
* GET /api/microsub-status
|
||||
*/
|
||||
async function microsubStatus(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
|
||||
try {
|
||||
const available = isMicrosubAvailable(application);
|
||||
|
||||
if (!available) {
|
||||
return response.json({
|
||||
available: false,
|
||||
message: "Microsub plugin not installed or collections not available",
|
||||
});
|
||||
}
|
||||
|
||||
// Get count of microsub-sourced blogs
|
||||
const db = application.getBlogrollDb();
|
||||
const microsubBlogCount = await db.collection("blogrollBlogs").countDocuments({
|
||||
source: { $regex: /^microsub/ },
|
||||
});
|
||||
|
||||
response.json({
|
||||
available: true,
|
||||
blogs: microsubBlogCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Blogroll API] microsubStatus error:", error);
|
||||
response.status(500).json({
|
||||
available: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const apiController = {
|
||||
listBlogs,
|
||||
getBlog: getBlogDetail,
|
||||
@@ -259,4 +349,6 @@ export const apiController = {
|
||||
exportOpml,
|
||||
exportOpmlCategory,
|
||||
discover,
|
||||
microsubWebhook,
|
||||
microsubStatus,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
deleteSource,
|
||||
} from "../storage/sources.js";
|
||||
import { syncOpmlSource } from "../sync/opml.js";
|
||||
import {
|
||||
syncMicrosubSource,
|
||||
getMicrosubChannels,
|
||||
isMicrosubAvailable,
|
||||
} from "../sync/microsub.js";
|
||||
|
||||
/**
|
||||
* List sources
|
||||
@@ -50,12 +55,22 @@ async function list(request, response) {
|
||||
* New source form
|
||||
* GET /sources/new
|
||||
*/
|
||||
function newForm(request, response) {
|
||||
async function newForm(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
|
||||
// Check if Microsub is available and get channels
|
||||
const microsubAvailable = isMicrosubAvailable(application);
|
||||
const microsubChannels = microsubAvailable
|
||||
? await getMicrosubChannels(application)
|
||||
: [];
|
||||
|
||||
response.render("blogroll-source-edit", {
|
||||
title: request.__("blogroll.sources.new"),
|
||||
source: null,
|
||||
isNew: true,
|
||||
baseUrl: request.baseUrl,
|
||||
microsubAvailable,
|
||||
microsubChannels,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,7 +80,16 @@ function newForm(request, response) {
|
||||
*/
|
||||
async function create(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const { name, type, url, opmlContent, syncInterval, enabled } = request.body;
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
url,
|
||||
opmlContent,
|
||||
syncInterval,
|
||||
enabled,
|
||||
channelFilter,
|
||||
categoryPrefix,
|
||||
} = request.body;
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
@@ -83,18 +107,37 @@ async function create(request, response) {
|
||||
return response.redirect(`${request.baseUrl}/sources/new`);
|
||||
}
|
||||
|
||||
const source = await createSource(application, {
|
||||
if (type === "microsub" && !isMicrosubAvailable(application)) {
|
||||
request.session.messages = [
|
||||
{ type: "error", content: "Microsub plugin is not available" },
|
||||
];
|
||||
return response.redirect(`${request.baseUrl}/sources/new`);
|
||||
}
|
||||
|
||||
const sourceData = {
|
||||
name,
|
||||
type,
|
||||
url: url || null,
|
||||
opmlContent: opmlContent || null,
|
||||
syncInterval: Number(syncInterval) || 60,
|
||||
enabled: enabled === "on" || enabled === true,
|
||||
});
|
||||
};
|
||||
|
||||
// Trigger initial sync for OPML sources
|
||||
// Add microsub-specific fields
|
||||
if (type === "microsub") {
|
||||
sourceData.channelFilter = channelFilter || null;
|
||||
sourceData.categoryPrefix = categoryPrefix || "";
|
||||
}
|
||||
|
||||
const source = await createSource(application, sourceData);
|
||||
|
||||
// Trigger initial sync based on source type
|
||||
try {
|
||||
if (type === "microsub") {
|
||||
await syncMicrosubSource(application, source);
|
||||
} else {
|
||||
await syncOpmlSource(application, source);
|
||||
}
|
||||
request.session.messages = [
|
||||
{ type: "success", content: request.__("blogroll.sources.created_synced") },
|
||||
];
|
||||
@@ -134,11 +177,19 @@ async function edit(request, response) {
|
||||
return response.status(404).render("404");
|
||||
}
|
||||
|
||||
// Check if Microsub is available and get channels
|
||||
const microsubAvailable = isMicrosubAvailable(application);
|
||||
const microsubChannels = microsubAvailable
|
||||
? await getMicrosubChannels(application)
|
||||
: [];
|
||||
|
||||
response.render("blogroll-source-edit", {
|
||||
title: request.__("blogroll.sources.edit"),
|
||||
source,
|
||||
isNew: false,
|
||||
baseUrl: request.baseUrl,
|
||||
microsubAvailable,
|
||||
microsubChannels,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Blogroll] Edit source error:", error);
|
||||
@@ -156,7 +207,16 @@ async function edit(request, response) {
|
||||
async function update(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const { id } = request.params;
|
||||
const { name, type, url, opmlContent, syncInterval, enabled } = request.body;
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
url,
|
||||
opmlContent,
|
||||
syncInterval,
|
||||
enabled,
|
||||
channelFilter,
|
||||
categoryPrefix,
|
||||
} = request.body;
|
||||
|
||||
try {
|
||||
const source = await getSource(application, id);
|
||||
@@ -165,14 +225,22 @@ async function update(request, response) {
|
||||
return response.status(404).render("404");
|
||||
}
|
||||
|
||||
await updateSource(application, id, {
|
||||
const updateData = {
|
||||
name,
|
||||
type,
|
||||
url: url || null,
|
||||
opmlContent: opmlContent || null,
|
||||
syncInterval: Number(syncInterval) || 60,
|
||||
enabled: enabled === "on" || enabled === true,
|
||||
});
|
||||
};
|
||||
|
||||
// Add microsub-specific fields
|
||||
if (type === "microsub") {
|
||||
updateData.channelFilter = channelFilter || null;
|
||||
updateData.categoryPrefix = categoryPrefix || "";
|
||||
}
|
||||
|
||||
await updateSource(application, id, updateData);
|
||||
|
||||
request.session.messages = [
|
||||
{ type: "success", content: request.__("blogroll.sources.updated") },
|
||||
@@ -234,7 +302,13 @@ async function sync(request, response) {
|
||||
return response.status(404).render("404");
|
||||
}
|
||||
|
||||
const result = await syncOpmlSource(application, source);
|
||||
// Use appropriate sync function based on source type
|
||||
let result;
|
||||
if (source.type === "microsub") {
|
||||
result = await syncMicrosubSource(application, source);
|
||||
} else {
|
||||
result = await syncOpmlSource(application, source);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
request.session.messages = [
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* Item storage operations
|
||||
* @module storage/items
|
||||
*
|
||||
* IMPORTANT: This module handles items from TWO sources:
|
||||
* - Regular blogs: items stored in blogrollItems collection
|
||||
* - Microsub blogs: items queried from microsub_items collection (no duplication)
|
||||
*/
|
||||
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getMicrosubItemsForBlog } from "../sync/microsub.js";
|
||||
|
||||
/**
|
||||
* Get collection reference
|
||||
@@ -17,6 +22,7 @@ function getCollection(application) {
|
||||
|
||||
/**
|
||||
* Get items with optional filtering
|
||||
* Combines items from blogrollItems (regular blogs) and microsub_items (Microsub blogs)
|
||||
* @param {object} application - Application instance
|
||||
* @param {object} options - Query options
|
||||
* @returns {Promise<Array>} Items with blog info
|
||||
@@ -25,10 +31,21 @@ export async function getItems(application, options = {}) {
|
||||
const db = application.getBlogrollDb();
|
||||
const { blogId, category, limit = 50, offset = 0 } = options;
|
||||
|
||||
const pipeline = [
|
||||
// If requesting items for a specific blog, check if it's a Microsub blog
|
||||
if (blogId) {
|
||||
const blog = await db.collection("blogrollBlogs").findOne({ _id: new ObjectId(blogId) });
|
||||
if (blog?.source === "microsub" && blog.microsubFeedId) {
|
||||
const microsubItems = await getMicrosubItemsForBlog(application, blog, limit + 1);
|
||||
const itemsWithBlog = microsubItems.map((item) => ({ ...item, blog }));
|
||||
const hasMore = itemsWithBlog.length > limit;
|
||||
if (hasMore) itemsWithBlog.pop();
|
||||
return { items: itemsWithBlog, hasMore };
|
||||
}
|
||||
}
|
||||
|
||||
// Get regular items from blogrollItems
|
||||
const regularPipeline = [
|
||||
{ $sort: { published: -1 } },
|
||||
{ $skip: offset },
|
||||
{ $limit: limit + 1 }, // Fetch one extra to check hasMore
|
||||
{
|
||||
$lookup: {
|
||||
from: "blogrollBlogs",
|
||||
@@ -38,36 +55,80 @@ export async function getItems(application, options = {}) {
|
||||
},
|
||||
},
|
||||
{ $unwind: "$blog" },
|
||||
{ $match: { "blog.hidden": { $ne: true } } },
|
||||
// Exclude hidden blogs and Microsub blogs (their items come from microsub_items)
|
||||
{ $match: { "blog.hidden": { $ne: true }, "blog.source": { $ne: "microsub" } } },
|
||||
];
|
||||
|
||||
if (blogId) {
|
||||
pipeline.unshift({ $match: { blogId: new ObjectId(blogId) } });
|
||||
regularPipeline.unshift({ $match: { blogId: new ObjectId(blogId) } });
|
||||
}
|
||||
|
||||
if (category) {
|
||||
pipeline.push({ $match: { "blog.category": category } });
|
||||
regularPipeline.push({ $match: { "blog.category": category } });
|
||||
}
|
||||
|
||||
const items = await db.collection("blogrollItems").aggregate(pipeline).toArray();
|
||||
const regularItems = await db.collection("blogrollItems").aggregate(regularPipeline).toArray();
|
||||
|
||||
const hasMore = items.length > limit;
|
||||
if (hasMore) items.pop();
|
||||
// Get items from Microsub-sourced blogs
|
||||
const microsubBlogsQuery = {
|
||||
source: "microsub",
|
||||
hidden: { $ne: true },
|
||||
};
|
||||
if (category) {
|
||||
microsubBlogsQuery.category = category;
|
||||
}
|
||||
|
||||
return { items, hasMore };
|
||||
const microsubBlogs = await db.collection("blogrollBlogs").find(microsubBlogsQuery).toArray();
|
||||
|
||||
let microsubItems = [];
|
||||
for (const blog of microsubBlogs) {
|
||||
if (blog.microsubFeedId) {
|
||||
const items = await getMicrosubItemsForBlog(application, blog, 100);
|
||||
microsubItems.push(...items.map((item) => ({ ...item, blog })));
|
||||
}
|
||||
}
|
||||
|
||||
// Combine and sort all items by published date
|
||||
const allItems = [...regularItems, ...microsubItems];
|
||||
allItems.sort((a, b) => {
|
||||
const dateA = a.published ? new Date(a.published) : new Date(0);
|
||||
const dateB = b.published ? new Date(b.published) : new Date(0);
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Apply pagination
|
||||
const paginatedItems = allItems.slice(offset, offset + limit + 1);
|
||||
const hasMore = paginatedItems.length > limit;
|
||||
if (hasMore) paginatedItems.pop();
|
||||
|
||||
return { items: paginatedItems, hasMore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get items for a specific blog
|
||||
* Handles both regular blogs (blogrollItems) and Microsub blogs (microsub_items)
|
||||
* @param {object} application - Application instance
|
||||
* @param {string|ObjectId} blogId - Blog ID
|
||||
* @param {number} limit - Max items
|
||||
* @param {object} blog - Optional blog document (to avoid extra lookup)
|
||||
* @returns {Promise<Array>} Items
|
||||
*/
|
||||
export async function getItemsForBlog(application, blogId, limit = 20) {
|
||||
const collection = getCollection(application);
|
||||
export async function getItemsForBlog(application, blogId, limit = 20, blog = null) {
|
||||
const db = application.getBlogrollDb();
|
||||
const objectId = typeof blogId === "string" ? new ObjectId(blogId) : blogId;
|
||||
|
||||
// Get blog if not provided
|
||||
if (!blog) {
|
||||
blog = await db.collection("blogrollBlogs").findOne({ _id: objectId });
|
||||
}
|
||||
|
||||
// For Microsub-sourced blogs, query microsub_items directly
|
||||
if (blog?.source === "microsub" && blog.microsubFeedId) {
|
||||
return getMicrosubItemsForBlog(application, blog, limit);
|
||||
}
|
||||
|
||||
// For regular blogs, query blogrollItems
|
||||
const collection = getCollection(application);
|
||||
return collection
|
||||
.find({ blogId: objectId })
|
||||
.sort({ published: -1 })
|
||||
@@ -76,20 +137,55 @@ export async function getItemsForBlog(application, blogId, limit = 20) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Count items
|
||||
* Count items (including Microsub items)
|
||||
* @param {object} application - Application instance
|
||||
* @param {object} options - Query options
|
||||
* @returns {Promise<number>} Count
|
||||
*/
|
||||
export async function countItems(application, options = {}) {
|
||||
const collection = getCollection(application);
|
||||
const query = {};
|
||||
const db = application.getBlogrollDb();
|
||||
|
||||
// Count regular items
|
||||
const regularQuery = {};
|
||||
if (options.blogId) {
|
||||
query.blogId = new ObjectId(options.blogId);
|
||||
regularQuery.blogId = new ObjectId(options.blogId);
|
||||
}
|
||||
const regularCount = await db.collection("blogrollItems").countDocuments(regularQuery);
|
||||
|
||||
// Count Microsub items for microsub-sourced blogs
|
||||
let microsubCount = 0;
|
||||
const itemsCollection = application.collections?.get("microsub_items");
|
||||
|
||||
if (itemsCollection) {
|
||||
if (options.blogId) {
|
||||
// Count for specific blog
|
||||
const blog = await db.collection("blogrollBlogs").findOne({ _id: new ObjectId(options.blogId) });
|
||||
if (blog?.source === "microsub" && blog.microsubFeedId) {
|
||||
microsubCount = await itemsCollection.countDocuments({
|
||||
feedId: new ObjectId(blog.microsubFeedId),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Count all Microsub items from blogroll-associated feeds
|
||||
const microsubBlogs = await db
|
||||
.collection("blogrollBlogs")
|
||||
.find({ source: "microsub", microsubFeedId: { $exists: true } })
|
||||
.toArray();
|
||||
|
||||
const feedIds = microsubBlogs
|
||||
.map((b) => b.microsubFeedId)
|
||||
.filter(Boolean)
|
||||
.map((id) => new ObjectId(id));
|
||||
|
||||
if (feedIds.length > 0) {
|
||||
microsubCount = await itemsCollection.countDocuments({
|
||||
feedId: { $in: feedIds },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collection.countDocuments(query);
|
||||
return regularCount + microsubCount;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,10 +48,13 @@ export async function createSource(application, data) {
|
||||
const now = new Date();
|
||||
|
||||
const source = {
|
||||
type: data.type, // "opml_url" | "opml_file" | "manual" | "json_feed"
|
||||
type: data.type, // "opml_url" | "opml_file" | "manual" | "json_feed" | "microsub"
|
||||
name: data.name,
|
||||
url: data.url || null,
|
||||
opmlContent: data.opmlContent || null,
|
||||
// Microsub-specific fields
|
||||
channelFilter: data.channelFilter || null,
|
||||
categoryPrefix: data.categoryPrefix || "",
|
||||
enabled: data.enabled !== false,
|
||||
syncInterval: data.syncInterval || 60, // minutes
|
||||
lastSyncAt: null,
|
||||
@@ -157,7 +160,7 @@ export async function getSourcesDueForSync(application) {
|
||||
return collection
|
||||
.find({
|
||||
enabled: true,
|
||||
type: { $in: ["opml_url", "json_feed"] },
|
||||
type: { $in: ["opml_url", "json_feed", "microsub"] },
|
||||
$or: [
|
||||
{ lastSyncAt: null },
|
||||
{
|
||||
|
||||
277
lib/sync/microsub.js
Normal file
277
lib/sync/microsub.js
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Microsub integration - sync subscriptions from Microsub channels
|
||||
* @module sync/microsub
|
||||
*
|
||||
* IMPORTANT: This module uses a REFERENCE-BASED approach to avoid data duplication.
|
||||
* - Blogs from Microsub are stored with `source: "microsub"` and `microsubFeedId`
|
||||
* - Items are NOT copied to blogrollItems - we query microsub_items directly
|
||||
* - The blogroll API joins data from both collections as needed
|
||||
*/
|
||||
|
||||
import { upsertBlog, getBlogByFeedUrl } from "../storage/blogs.js";
|
||||
import { updateSourceSyncStatus } from "../storage/sources.js";
|
||||
|
||||
/**
|
||||
* Sync blogs from Microsub subscriptions
|
||||
* Creates references to Microsub feeds, NOT copies of the data
|
||||
* @param {object} application - Application instance
|
||||
* @param {object} source - Source document with microsub config
|
||||
* @returns {Promise<object>} Sync result
|
||||
*/
|
||||
export async function syncMicrosubSource(application, source) {
|
||||
try {
|
||||
// Get Microsub collections via Indiekit's collection system
|
||||
const channelsCollection = application.collections?.get("microsub_channels");
|
||||
const feedsCollection = application.collections?.get("microsub_feeds");
|
||||
|
||||
if (!channelsCollection || !feedsCollection) {
|
||||
throw new Error("Microsub collections not available. Is the Microsub plugin installed?");
|
||||
}
|
||||
|
||||
// Get channels (optionally filter by specific channel)
|
||||
const channelQuery = source.channelFilter
|
||||
? { uid: source.channelFilter }
|
||||
: {};
|
||||
const channels = await channelsCollection.find(channelQuery).toArray();
|
||||
|
||||
if (channels.length === 0) {
|
||||
console.log("[Blogroll] No Microsub channels found");
|
||||
await updateSourceSyncStatus(application, source._id, { success: true });
|
||||
return { success: true, added: 0, updated: 0, total: 0 };
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
let updated = 0;
|
||||
let total = 0;
|
||||
|
||||
for (const channel of channels) {
|
||||
// Get all feeds subscribed in this channel
|
||||
const feeds = await feedsCollection.find({ channelId: channel._id }).toArray();
|
||||
|
||||
for (const feed of feeds) {
|
||||
total++;
|
||||
|
||||
// Store REFERENCE to Microsub feed, not a copy
|
||||
// Items will be queried from microsub_items directly
|
||||
const blogData = {
|
||||
title: feed.title || extractDomainFromUrl(feed.url),
|
||||
feedUrl: feed.url,
|
||||
siteUrl: extractSiteUrl(feed.url),
|
||||
feedType: "rss",
|
||||
category: source.categoryPrefix
|
||||
? `${source.categoryPrefix}${channel.name}`
|
||||
: channel.name,
|
||||
// Mark as microsub source - items come from microsub_items, not blogrollItems
|
||||
source: "microsub",
|
||||
sourceId: source._id,
|
||||
// Store reference IDs for joining with Microsub data
|
||||
microsubFeedId: feed._id.toString(),
|
||||
microsubChannelId: channel._id.toString(),
|
||||
microsubChannelName: channel.name,
|
||||
// Mirror status from Microsub (don't duplicate, just reference)
|
||||
status: feed.status === "error" ? "error" : "active",
|
||||
lastFetchAt: feed.lastFetchedAt || null,
|
||||
photo: feed.photo || null,
|
||||
// Flag to skip item fetching - Microsub handles this
|
||||
skipItemFetch: true,
|
||||
};
|
||||
|
||||
const result = await upsertBlog(application, blogData);
|
||||
|
||||
if (result.upserted) added++;
|
||||
else if (result.modified) updated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update source sync status
|
||||
await updateSourceSyncStatus(application, source._id, { success: true });
|
||||
|
||||
console.log(
|
||||
`[Blogroll] Synced Microsub source "${source.name}": ${added} added, ${updated} updated, ${total} total from ${channels.length} channels (items served from Microsub)`
|
||||
);
|
||||
|
||||
return { success: true, added, updated, total };
|
||||
} catch (error) {
|
||||
// Update source with error status
|
||||
await updateSourceSyncStatus(application, source._id, {
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
console.error(`[Blogroll] Microsub sync failed for "${source.name}":`, error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get items for a Microsub-sourced blog
|
||||
* Queries microsub_items directly instead of blogrollItems
|
||||
* @param {object} application - Application instance
|
||||
* @param {object} blog - Blog with microsubFeedId
|
||||
* @param {number} limit - Max items to return
|
||||
* @returns {Promise<Array>} Items from Microsub
|
||||
*/
|
||||
export async function getMicrosubItemsForBlog(application, blog, limit = 20) {
|
||||
if (!blog.microsubFeedId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemsCollection = application.collections?.get("microsub_items");
|
||||
if (!itemsCollection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { ObjectId } = await import("mongodb");
|
||||
const feedId = new ObjectId(blog.microsubFeedId);
|
||||
|
||||
const items = await itemsCollection
|
||||
.find({ feedId })
|
||||
.sort({ published: -1 })
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
|
||||
// Transform Microsub item format to Blogroll format
|
||||
return items.map((item) => ({
|
||||
_id: item._id,
|
||||
blogId: blog._id,
|
||||
url: item.url,
|
||||
title: item.name || item.url,
|
||||
summary: item.summary || item.content?.text?.substring(0, 300),
|
||||
published: item.published,
|
||||
author: item.author?.name,
|
||||
photo: item.photo?.[0] || item.featured,
|
||||
categories: item.category || [],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Microsub subscription webhook
|
||||
* Called when a feed is subscribed/unsubscribed in Microsub
|
||||
* @param {object} application - Application instance
|
||||
* @param {object} data - Webhook data
|
||||
* @param {string} data.action - "subscribe" or "unsubscribe"
|
||||
* @param {string} data.url - Feed URL
|
||||
* @param {string} data.channelName - Channel name
|
||||
* @param {string} [data.title] - Feed title
|
||||
* @returns {Promise<object>} Result
|
||||
*/
|
||||
export async function handleMicrosubWebhook(application, data) {
|
||||
const { action, url, channelName, title } = data;
|
||||
|
||||
if (action === "subscribe") {
|
||||
// Check if blog already exists
|
||||
const existing = await getBlogByFeedUrl(application, url);
|
||||
|
||||
if (existing) {
|
||||
// Update category if it's from microsub
|
||||
if (existing.source === "microsub") {
|
||||
console.log(`[Blogroll] Webhook: Feed ${url} already exists, skipping`);
|
||||
return { ok: true, action: "skipped", reason: "already_exists" };
|
||||
}
|
||||
// Don't overwrite manually added blogs
|
||||
return { ok: true, action: "skipped", reason: "manual_entry" };
|
||||
}
|
||||
|
||||
// Add new blog
|
||||
await upsertBlog(application, {
|
||||
title: title || extractDomainFromUrl(url),
|
||||
feedUrl: url,
|
||||
siteUrl: extractSiteUrl(url),
|
||||
feedType: "rss",
|
||||
category: channelName || "Microsub",
|
||||
source: "microsub-webhook",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
console.log(`[Blogroll] Webhook: Added feed ${url} from Microsub`);
|
||||
return { ok: true, action: "added" };
|
||||
}
|
||||
|
||||
if (action === "unsubscribe") {
|
||||
// Mark as inactive rather than delete (preserve history)
|
||||
const existing = await getBlogByFeedUrl(application, url);
|
||||
|
||||
if (existing && existing.source?.startsWith("microsub")) {
|
||||
// Update status to inactive
|
||||
const db = application.getBlogrollDb();
|
||||
await db.collection("blogrollBlogs").updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: {
|
||||
status: "inactive",
|
||||
unsubscribedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`[Blogroll] Webhook: Marked feed ${url} as inactive`);
|
||||
return { ok: true, action: "deactivated" };
|
||||
}
|
||||
|
||||
return { ok: true, action: "skipped", reason: "not_found_or_not_microsub" };
|
||||
}
|
||||
|
||||
return { ok: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Microsub channels for source configuration UI
|
||||
* @param {object} application - Application instance
|
||||
* @returns {Promise<Array>} Array of channels
|
||||
*/
|
||||
export async function getMicrosubChannels(application) {
|
||||
const channelsCollection = application.collections?.get("microsub_channels");
|
||||
|
||||
if (!channelsCollection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const channels = await channelsCollection.find({}).sort({ order: 1 }).toArray();
|
||||
|
||||
return channels.map((ch) => ({
|
||||
uid: ch.uid,
|
||||
name: ch.name,
|
||||
_id: ch._id.toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Microsub plugin is available
|
||||
* @param {object} application - Application instance
|
||||
* @returns {boolean} True if Microsub is available
|
||||
*/
|
||||
export function isMicrosubAvailable(application) {
|
||||
return !!(
|
||||
application.collections?.get("microsub_channels") &&
|
||||
application.collections?.get("microsub_feeds")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from URL for fallback title
|
||||
* @param {string} url - Feed URL
|
||||
* @returns {string} Domain name
|
||||
*/
|
||||
function extractDomainFromUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract site URL from feed URL
|
||||
* @param {string} feedUrl - Feed URL
|
||||
* @returns {string} Site URL
|
||||
*/
|
||||
function extractSiteUrl(feedUrl) {
|
||||
try {
|
||||
const parsed = new URL(feedUrl);
|
||||
return `${parsed.protocol}//${parsed.host}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { getSources } from "../storage/sources.js";
|
||||
import { getBlogs, countBlogs } from "../storage/blogs.js";
|
||||
import { countItems, deleteOldItems } from "../storage/items.js";
|
||||
import { syncOpmlSource } from "./opml.js";
|
||||
import { syncMicrosubSource } from "./microsub.js";
|
||||
import { syncBlogItems } from "./feed.js";
|
||||
|
||||
let syncInterval = null;
|
||||
@@ -38,10 +39,10 @@ export async function runFullSync(application, options = {}) {
|
||||
// First, clean up old items to encourage discovery
|
||||
const deletedItems = await deleteOldItems(application, maxItemAge);
|
||||
|
||||
// Sync all enabled OPML/JSON sources
|
||||
// Sync all enabled sources (OPML, JSON, Microsub)
|
||||
const sources = await getSources(application);
|
||||
const enabledSources = sources.filter(
|
||||
(s) => s.enabled && ["opml_url", "opml_file", "json_feed"].includes(s.type)
|
||||
(s) => s.enabled && ["opml_url", "opml_file", "json_feed", "microsub"].includes(s.type)
|
||||
);
|
||||
|
||||
let sourcesSuccess = 0;
|
||||
@@ -49,7 +50,12 @@ export async function runFullSync(application, options = {}) {
|
||||
|
||||
for (const source of enabledSources) {
|
||||
try {
|
||||
const result = await syncOpmlSource(application, source);
|
||||
let result;
|
||||
if (source.type === "microsub") {
|
||||
result = await syncMicrosubSource(application, source);
|
||||
} else {
|
||||
result = await syncOpmlSource(application, source);
|
||||
}
|
||||
if (result.success) sourcesSuccess++;
|
||||
else sourcesFailed++;
|
||||
} catch (error) {
|
||||
@@ -58,14 +64,21 @@ export async function runFullSync(application, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync all non-hidden blogs
|
||||
// Sync all non-hidden blogs (skip microsub blogs - their items come from Microsub)
|
||||
const blogs = await getBlogs(application, { includeHidden: false, limit: 1000 });
|
||||
|
||||
let blogsSuccess = 0;
|
||||
let blogsFailed = 0;
|
||||
let blogsSkipped = 0;
|
||||
let newItems = 0;
|
||||
|
||||
for (const blog of blogs) {
|
||||
// Skip microsub blogs - items are served directly from microsub_items
|
||||
if (blog.source === "microsub" || blog.skipItemFetch) {
|
||||
blogsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncBlogItems(application, blog, {
|
||||
maxItems: maxItemsPerBlog,
|
||||
@@ -84,6 +97,10 @@ export async function runFullSync(application, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (blogsSkipped > 0) {
|
||||
console.log(`[Blogroll] Skipped ${blogsSkipped} Microsub blogs (items served from Microsub)`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Update sync stats in meta collection
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@rmdes/indiekit-endpoint-blogroll",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"description": "Blogroll endpoint for Indiekit. Aggregates blog feeds from OPML, JSON feeds, or manual entry.",
|
||||
"keywords": [
|
||||
"indiekit",
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
<select id="type" name="type" required onchange="toggleTypeFields()">
|
||||
<option value="opml_url" {% if source.type == 'opml_url' %}selected{% endif %}>OPML URL (auto-sync)</option>
|
||||
<option value="opml_file" {% if source.type == 'opml_file' %}selected{% endif %}>OPML File (one-time import)</option>
|
||||
{% if microsubAvailable %}
|
||||
<option value="microsub" {% if source.type == 'microsub' %}selected{% endif %}>Microsub Subscriptions</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
<span class="br-field-hint">{{ __("blogroll.sources.form.typeHint") }}</span>
|
||||
</div>
|
||||
@@ -98,6 +101,23 @@
|
||||
<span class="br-field-hint">{{ __("blogroll.sources.form.opmlContentHint") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="br-field" id="microsubChannelField" style="display: none;">
|
||||
<label for="channelFilter">{{ __("blogroll.sources.form.microsubChannel") | default("Microsub Channel") }}</label>
|
||||
<select id="channelFilter" name="channelFilter">
|
||||
<option value="">All channels</option>
|
||||
{% for channel in microsubChannels %}
|
||||
<option value="{{ channel.uid }}" {% if source.channelFilter == channel.uid %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="br-field-hint">{{ __("blogroll.sources.form.microsubChannelHint") | default("Sync feeds from a specific channel, or all channels") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="br-field" id="categoryPrefixField" style="display: none;">
|
||||
<label for="categoryPrefix">{{ __("blogroll.sources.form.categoryPrefix") | default("Category Prefix") }}</label>
|
||||
<input type="text" id="categoryPrefix" name="categoryPrefix" value="{{ source.categoryPrefix if source else '' }}" placeholder="e.g., Microsub: ">
|
||||
<span class="br-field-hint">{{ __("blogroll.sources.form.categoryPrefixHint") | default("Optional prefix for blog categories (e.g., 'Following: ')") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="br-field">
|
||||
<label for="syncInterval">{{ __("blogroll.sources.form.syncInterval") }}</label>
|
||||
<select id="syncInterval" name="syncInterval">
|
||||
@@ -128,13 +148,23 @@ function toggleTypeFields() {
|
||||
const type = document.getElementById('type').value;
|
||||
const urlField = document.getElementById('urlField');
|
||||
const opmlContentField = document.getElementById('opmlContentField');
|
||||
const microsubChannelField = document.getElementById('microsubChannelField');
|
||||
const categoryPrefixField = document.getElementById('categoryPrefixField');
|
||||
|
||||
// Hide all type-specific fields first
|
||||
urlField.style.display = 'none';
|
||||
opmlContentField.style.display = 'none';
|
||||
if (microsubChannelField) microsubChannelField.style.display = 'none';
|
||||
if (categoryPrefixField) categoryPrefixField.style.display = 'none';
|
||||
|
||||
// Show fields based on type
|
||||
if (type === 'opml_url') {
|
||||
urlField.style.display = 'flex';
|
||||
opmlContentField.style.display = 'none';
|
||||
} else if (type === 'opml_file') {
|
||||
urlField.style.display = 'none';
|
||||
opmlContentField.style.display = 'flex';
|
||||
} else if (type === 'microsub') {
|
||||
if (microsubChannelField) microsubChannelField.style.display = 'flex';
|
||||
if (categoryPrefixField) categoryPrefixField.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user