/** * Blog storage operations * @module storage/blogs */ import { ObjectId } from "mongodb"; /** * Get collection reference * @param {object} application - Application instance * @returns {Collection} MongoDB collection */ function getCollection(application) { const db = application.getBlogrollDb(); return db.collection("blogrollBlogs"); } /** * Get all blogs * @param {object} application - Application instance * @param {object} options - Query options * @returns {Promise} Blogs */ export async function getBlogs(application, options = {}) { const collection = getCollection(application); const { category, sourceId, includeHidden = false, limit = 100, offset = 0 } = options; const query = { status: { $ne: "deleted" } }; if (!includeHidden) query.hidden = { $ne: true }; if (category) query.category = category; if (sourceId) query.sourceId = new ObjectId(sourceId); if (options.source) query.source = options.source; // Default sort: pinned first, then alphabetical // "recent" sort: pinned first, then by last fetch time (newest first) const sortOrder = options.sort === "recent" ? { pinned: -1, lastFetchAt: -1, title: 1 } : { pinned: -1, title: 1 }; return collection .find(query) .sort(sortOrder) .skip(offset) .limit(limit) .toArray(); } /** * Count blogs * @param {object} application - Application instance * @param {object} options - Query options * @returns {Promise} Count */ export async function countBlogs(application, options = {}) { const collection = getCollection(application); const { category, source, includeHidden = false } = options; const query = { status: { $ne: "deleted" } }; if (!includeHidden) query.hidden = { $ne: true }; if (category) query.category = category; if (source) query.source = source; return collection.countDocuments(query); } /** * Get blog by ID * @param {object} application - Application instance * @param {string|ObjectId} id - Blog ID * @returns {Promise} Blog or null */ export async function getBlog(application, id) { const collection = getCollection(application); const objectId = typeof id === "string" ? new ObjectId(id) : id; return collection.findOne({ _id: objectId }); } /** * Get blog by feed URL * @param {object} application - Application instance * @param {string} feedUrl - Feed URL * @returns {Promise} Blog or null */ export async function getBlogByFeedUrl(application, feedUrl) { const collection = getCollection(application); return collection.findOne({ feedUrl, status: { $ne: "deleted" } }); } /** * Create a new blog * @param {object} application - Application instance * @param {object} data - Blog data * @returns {Promise} Created blog */ export async function createBlog(application, data) { const collection = getCollection(application); const now = new Date().toISOString(); const blog = { sourceId: data.sourceId ? new ObjectId(data.sourceId) : null, title: data.title, description: data.description || null, feedUrl: data.feedUrl, siteUrl: data.siteUrl || null, feedType: data.feedType || "rss", category: data.category || "", tags: data.tags || [], photo: data.photo || null, author: data.author || null, status: "active", lastFetchAt: null, lastItemAt: null, lastError: null, itemCount: 0, pinned: data.pinned || false, hidden: data.hidden || false, notes: data.notes || null, createdAt: now, updatedAt: now, }; const result = await collection.insertOne(blog); return { ...blog, _id: result.insertedId }; } /** * Update a blog * @param {object} application - Application instance * @param {string|ObjectId} id - Blog ID * @param {object} data - Update data * @returns {Promise} Updated blog */ export async function updateBlog(application, id, data) { const collection = getCollection(application); const objectId = typeof id === "string" ? new ObjectId(id) : id; const update = { ...data, updatedAt: new Date().toISOString(), }; // Remove fields that shouldn't be updated directly delete update._id; delete update.createdAt; return collection.findOneAndUpdate( { _id: objectId }, { $set: update }, { returnDocument: "after" } ); } /** * Delete a blog and its items (soft delete) * Marks blog as deleted so sync won't recreate it. * @param {object} application - Application instance * @param {string|ObjectId} id - Blog ID * @returns {Promise} Success */ export async function deleteBlog(application, id) { const db = application.getBlogrollDb(); const objectId = typeof id === "string" ? new ObjectId(id) : id; // Delete items for this blog await db.collection("blogrollItems").deleteMany({ blogId: objectId }); // Soft delete: mark as deleted so upsertBlog won't recreate it const result = await db.collection("blogrollBlogs").updateOne( { _id: objectId }, { $set: { status: "deleted", hidden: true, deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, } ); return result.modifiedCount > 0; } /** * Update blog fetch status * @param {object} application - Application instance * @param {string|ObjectId} id - Blog ID * @param {object} status - Fetch status */ export async function updateBlogStatus(application, id, status) { const collection = getCollection(application); const objectId = typeof id === "string" ? new ObjectId(id) : id; const update = { updatedAt: new Date().toISOString(), }; if (status.success) { update.status = "active"; update.lastFetchAt = new Date().toISOString(); update.lastError = null; if (status.itemCount !== undefined) { update.itemCount = status.itemCount; } if (status.lastItemAt) update.lastItemAt = status.lastItemAt; if (status.title) update.title = status.title; if (status.photo) update.photo = status.photo; if (status.siteUrl) update.siteUrl = status.siteUrl; } else { update.status = "error"; update.lastError = status.error; } return collection.updateOne({ _id: objectId }, { $set: update }); } /** * Get blogs due for refresh * @param {object} application - Application instance * @param {number} maxAge - Max age in minutes before refresh * @returns {Promise} Blogs needing refresh */ export async function getBlogsDueForRefresh(application, maxAge = 60) { const collection = getCollection(application); const cutoff = new Date(Date.now() - maxAge * 60000); return collection .find({ hidden: { $ne: true }, status: { $ne: "deleted" }, $or: [{ lastFetchAt: null }, { lastFetchAt: { $lt: cutoff } }], }) .toArray(); } /** * Get categories with counts * @param {object} application - Application instance * @returns {Promise} Categories with counts */ export async function getCategories(application) { const collection = getCollection(application); return collection .aggregate([ { $match: { hidden: { $ne: true }, status: { $ne: "deleted" }, category: { $ne: "" } } }, { $group: { _id: "$category", count: { $sum: 1 } } }, { $sort: { _id: 1 } }, ]) .toArray(); } /** * Upsert a blog (for OPML sync) * @param {object} application - Application instance * @param {object} data - Blog data * @returns {Promise} Result with upserted flag */ export async function upsertBlog(application, data) { const collection = getCollection(application); const now = new Date().toISOString(); // Skip if a blog with this feedUrl was soft-deleted const deleted = await collection.findOne({ feedUrl: data.feedUrl, status: "deleted", }); if (deleted) { return { upserted: false, modified: false, skippedDeleted: true }; } const filter = { feedUrl: data.feedUrl }; if (data.sourceId) { filter.sourceId = new ObjectId(data.sourceId); } // Build $set with base fields const setFields = { title: data.title, siteUrl: data.siteUrl, feedType: data.feedType, category: data.category, sourceId: data.sourceId ? new ObjectId(data.sourceId) : null, updatedAt: now, }; // Conditionally add microsub/optional fields to $set when provided if (data.source !== undefined) setFields.source = data.source; if (data.microsubFeedId !== undefined) setFields.microsubFeedId = data.microsubFeedId; if (data.microsubChannelId !== undefined) setFields.microsubChannelId = data.microsubChannelId; if (data.microsubChannelName !== undefined) setFields.microsubChannelName = data.microsubChannelName; if (data.skipItemFetch !== undefined) setFields.skipItemFetch = data.skipItemFetch; if (data.photo !== undefined) setFields.photo = data.photo; if (data.lastFetchAt !== undefined) setFields.lastFetchAt = data.lastFetchAt; if (data.lastItemAt !== undefined) setFields.lastItemAt = data.lastItemAt; if (data.status !== undefined) setFields.status = data.status; // $setOnInsert only for fields NOT already in $set (avoids MongoDB path conflicts) const insertDefaults = { description: null, tags: [], author: null, lastError: null, itemCount: 0, pinned: false, hidden: false, notes: null, createdAt: now, }; // Add defaults for optional fields only when they're NOT in $set if (!("source" in setFields)) insertDefaults.source = null; if (!("microsubFeedId" in setFields)) insertDefaults.microsubFeedId = null; if (!("microsubChannelId" in setFields)) insertDefaults.microsubChannelId = null; if (!("microsubChannelName" in setFields)) insertDefaults.microsubChannelName = null; if (!("skipItemFetch" in setFields)) insertDefaults.skipItemFetch = false; if (!("photo" in setFields)) insertDefaults.photo = null; if (!("lastFetchAt" in setFields)) insertDefaults.lastFetchAt = null; if (!("lastItemAt" in setFields)) insertDefaults.lastItemAt = null; if (!("status" in setFields)) insertDefaults.status = "active"; const result = await collection.updateOne( filter, { $set: setFields, $setOnInsert: insertDefaults, }, { upsert: true } ); return { upserted: result.upsertedCount > 0, modified: result.modifiedCount > 0, }; }