Files
indiekit-endpoint-blogroll/lib/storage/items.js
Ricardo 8ace76f8c2 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>
2026-02-08 12:35:52 +01:00

277 lines
8.5 KiB
JavaScript

/**
* 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
* @param {object} application - Application instance
* @returns {Collection} MongoDB collection
*/
function getCollection(application) {
const db = application.getBlogrollDb();
return db.collection("blogrollItems");
}
/**
* 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
*/
export async function getItems(application, options = {}) {
const db = application.getBlogrollDb();
const { blogId, category, limit = 50, offset = 0 } = options;
// 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 } },
{
$lookup: {
from: "blogrollBlogs",
localField: "blogId",
foreignField: "_id",
as: "blog",
},
},
{ $unwind: "$blog" },
// Exclude hidden blogs and Microsub blogs (their items come from microsub_items)
{ $match: { "blog.hidden": { $ne: true }, "blog.source": { $ne: "microsub" } } },
];
if (blogId) {
regularPipeline.unshift({ $match: { blogId: new ObjectId(blogId) } });
}
if (category) {
regularPipeline.push({ $match: { "blog.category": category } });
}
const regularItems = await db.collection("blogrollItems").aggregate(regularPipeline).toArray();
// Get items from Microsub-sourced blogs
const microsubBlogsQuery = {
source: "microsub",
hidden: { $ne: true },
};
if (category) {
microsubBlogsQuery.category = category;
}
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, 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 })
.limit(limit)
.toArray();
}
/**
* 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 db = application.getBlogrollDb();
// Count regular items
const regularQuery = {};
if (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 regularCount + microsubCount;
}
/**
* Upsert an item
* @param {object} application - Application instance
* @param {object} data - Item data
* @returns {Promise<object>} Result with upserted flag
*/
export async function upsertItem(application, data) {
const collection = getCollection(application);
const now = new Date();
const result = await collection.updateOne(
{ blogId: new ObjectId(data.blogId), uid: data.uid },
{
$set: {
url: data.url,
title: data.title,
content: data.content,
summary: data.summary,
published: data.published,
updated: data.updated,
author: data.author,
photo: data.photo,
categories: data.categories || [],
fetchedAt: now,
},
$setOnInsert: {
blogId: new ObjectId(data.blogId),
uid: data.uid,
},
},
{ upsert: true }
);
return {
upserted: result.upsertedCount > 0,
modified: result.modifiedCount > 0,
};
}
/**
* Delete items for a blog
* @param {object} application - Application instance
* @param {string|ObjectId} blogId - Blog ID
* @returns {Promise<number>} Deleted count
*/
export async function deleteItemsForBlog(application, blogId) {
const collection = getCollection(application);
const objectId = typeof blogId === "string" ? new ObjectId(blogId) : blogId;
const result = await collection.deleteMany({ blogId: objectId });
return result.deletedCount;
}
/**
* Delete old items beyond retention period
* This encourages discovery by showing only recent content
* @param {object} application - Application instance
* @param {number} maxAgeDays - Max age in days (default 7)
* @returns {Promise<number>} Deleted count
*/
export async function deleteOldItems(application, maxAgeDays = 7) {
const collection = getCollection(application);
const cutoff = new Date(Date.now() - maxAgeDays * 24 * 60 * 60 * 1000);
const result = await collection.deleteMany({
published: { $lt: cutoff },
});
if (result.deletedCount > 0) {
console.log(`[Blogroll] Cleaned up ${result.deletedCount} items older than ${maxAgeDays} days`);
}
return result.deletedCount;
}
/**
* Get item by ID
* @param {object} application - Application instance
* @param {string|ObjectId} id - Item ID
* @returns {Promise<object|null>} Item or null
*/
export async function getItem(application, id) {
const collection = getCollection(application);
const objectId = typeof id === "string" ? new ObjectId(id) : id;
return collection.findOne({ _id: objectId });
}