/** * Timeline item storage operations * @module storage/timeline */ /** * Add a timeline item (uses atomic upsert for deduplication) * @param {object} collections - MongoDB collections * @param {object} item - Timeline item data * @param {string} item.uid - Canonical AP object URL (dedup key) * @param {string} item.type - "note" | "article" | "boost" * @param {string} item.url - Post URL * @param {string} [item.name] - Post title (articles only) * @param {object} item.content - { text, html } * @param {string} [item.summary] - Content warning text * @param {boolean} item.sensitive - Sensitive content flag * @param {Date} item.published - Published date (kept as Date for sort queries) * @param {object} item.author - { name, url, photo, handle } * @param {string[]} item.category - Hashtag strings (# prefix stripped) * @param {Array<{name: string, url: string}>} [item.mentions] - @mention entries with actor URLs * @param {string[]} item.photo - Photo URLs * @param {string[]} item.video - Video URLs * @param {string[]} item.audio - Audio URLs * @param {string} [item.inReplyTo] - Parent post URL * @param {object} [item.boostedBy] - { name, url, photo, handle } for boosts * @param {string} [item.boostedAt] - Boost timestamp (ISO string) * @param {string} [item.originalUrl] - Original post URL for boosts * @param {Array<{url: string, title: string, description: string, image: string, favicon: string, domain: string, fetchedAt: string}>} [item.linkPreviews] - OpenGraph link previews for external links in content * @param {string} item.createdAt - ISO string creation timestamp * @returns {Promise} Created or existing item */ export async function addTimelineItem(collections, item) { const { ap_timeline } = collections; const result = await ap_timeline.updateOne( { uid: item.uid }, { $setOnInsert: { ...item, readBy: [], }, }, { upsert: true }, ); if (result.upsertedCount > 0) { return await ap_timeline.findOne({ uid: item.uid }); } // Return existing document if it was a duplicate return await ap_timeline.findOne({ uid: item.uid }); } /** * Get timeline items with cursor-based pagination * @param {object} collections - MongoDB collections * @param {object} options - Query options * @param {string} [options.before] - Before cursor (published date) * @param {string} [options.after] - After cursor (published date) * @param {number} [options.limit=20] - Items per page * @param {string} [options.type] - Filter by type * @param {string} [options.authorUrl] - Filter by author URL * @param {string} [options.tag] - Filter by hashtag (case-insensitive exact match) * @returns {Promise} { items, before, after } */ export async function getTimelineItems(collections, options = {}) { const { ap_timeline } = collections; const parsedLimit = Number.parseInt(options.limit, 10); const limit = Math.min( Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20, 100, ); const query = {}; // Exclude context-only items (ancestors fetched for thread reconstruction) // unless explicitly requested via options.includeContext if (!options.includeContext) { query.isContext = { $ne: true }; } // Exclude private/direct posts from the main timeline feed — // these belong in messages/notifications, not the public reader if (!options.includePrivate) { query.visibility = { $nin: ["private", "direct"] }; } // Type filter if (options.type) { query.type = options.type; } // Exclude replies (notes with inReplyTo set) if (options.excludeReplies) { query.$or = [ { inReplyTo: null }, { inReplyTo: "" }, { inReplyTo: { $exists: false } }, ]; } // Author filter (for profile view) — validate string type to prevent operator injection if (options.authorUrl) { if (typeof options.authorUrl !== "string") { throw new Error("Invalid authorUrl"); } query["author.url"] = options.authorUrl; } // Tag filter — case-insensitive exact match against the category[] array // Escape regex special chars to prevent injection if (options.tag) { if (typeof options.tag !== "string") { throw new Error("Invalid tag"); } const escapedTag = options.tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); query.category = { $regex: new RegExp(`^${escapedTag}$`, "i") }; } // Unread-only filter if (options.unread) { query.read = { $ne: true }; } // Cursor pagination — published is stored as ISO string, so compare // as strings (lexicographic ISO 8601 comparison is correct for dates) if (options.before) { if (Number.isNaN(new Date(options.before).getTime())) { throw new Error("Invalid before cursor"); } query.published = { $lt: options.before }; } else if (options.after) { if (Number.isNaN(new Date(options.after).getTime())) { throw new Error("Invalid after cursor"); } query.published = { $gt: options.after }; } const rawItems = await ap_timeline .find(query) .sort({ published: -1 }) .limit(limit) .toArray(); // Normalize published dates to ISO strings for Nunjucks | date filter const items = rawItems.map((item) => ({ ...item, published: item.published instanceof Date ? item.published.toISOString() : item.published, })); // Generate cursors for pagination // Items are sorted newest-first, so: // - "before" cursor (for "Older" link) = oldest item's date (last in array) // - "after" cursor (for "Newer" link) = newest item's date (first in array) const before = items.length === limit ? items[items.length - 1].published : null; const after = items.length > 0 && (options.before || options.after) ? items[0].published : null; return { items, before, after, }; } /** * Get a single timeline item by UID * @param {object} collections - MongoDB collections * @param {string} uid - Item UID (canonical URL) * @returns {Promise} Timeline item or null */ export async function getTimelineItem(collections, uid) { const { ap_timeline } = collections; return await ap_timeline.findOne({ uid }); } /** * Delete a timeline item by UID * @param {object} collections - MongoDB collections * @param {string} uid - Item UID * @returns {Promise} Delete result */ export async function deleteTimelineItem(collections, uid) { const { ap_timeline } = collections; return await ap_timeline.deleteOne({ uid }); } /** * Update a timeline item's content (for Update activities) * @param {object} collections - MongoDB collections * @param {string} uid - Item UID * @param {object} updates - Fields to update * @param {object} [updates.content] - New content * @param {string} [updates.name] - New title * @param {string} [updates.summary] - New content warning * @param {boolean} [updates.sensitive] - New sensitive flag * @returns {Promise} Update result */ export async function updateTimelineItem(collections, uid, updates) { const { ap_timeline } = collections; return await ap_timeline.updateOne({ uid }, { $set: updates }); } /** * Delete timeline items older than a cutoff date (retention cleanup) * @param {object} collections - MongoDB collections * @param {Date} cutoffDate - Delete items published before this date * @returns {Promise} Number of items deleted */ export async function deleteOldTimelineItems(collections, cutoffDate) { const { ap_timeline } = collections; // published is stored as ISO string — convert cutoff to string for comparison const cutoff = cutoffDate instanceof Date ? cutoffDate.toISOString() : cutoffDate; const result = await ap_timeline.deleteMany({ published: { $lt: cutoff } }); return result.deletedCount; } /** * Delete timeline items by count-based retention (keep N newest) * @param {object} collections - MongoDB collections * @param {number} keepCount - Number of items to keep * @returns {Promise} Number of items deleted */ export async function cleanupTimelineByCount(collections, keepCount) { const { ap_timeline } = collections; // Find the Nth newest item's published date const items = await ap_timeline .find({}) .sort({ published: -1 }) .skip(keepCount) .limit(1) .toArray(); if (items.length === 0) { return 0; // Fewer than keepCount items exist } const cutoffDate = items[0].published; return await deleteOldTimelineItems(collections, cutoffDate); } /** * Count timeline items newer than a given date * @param {object} collections - MongoDB collections * @param {string} after - ISO date string — count items published after this * @param {object} [options] - Filter options * @param {string} [options.type] - Filter by type * @param {boolean} [options.excludeReplies] - Exclude replies * @returns {Promise} Count of new items */ export async function countNewItems(collections, after, options = {}) { const { ap_timeline } = collections; if (!after || Number.isNaN(new Date(after).getTime())) return 0; const query = { published: { $gt: after }, isContext: { $ne: true }, visibility: { $nin: ["private", "direct"] }, }; if (options.type) query.type = options.type; if (options.excludeReplies) { query.$or = [ { inReplyTo: null }, { inReplyTo: "" }, { inReplyTo: { $exists: false } }, ]; } return await ap_timeline.countDocuments(query); } /** * Mark timeline items as read * @param {object} collections - MongoDB collections * @param {string[]} uids - Array of item UIDs to mark as read * @returns {Promise} Number of items updated */ export async function markItemsRead(collections, uids) { const { ap_timeline } = collections; if (!uids || uids.length === 0) return 0; const result = await ap_timeline.updateMany( { uid: { $in: uids }, read: { $ne: true } }, { $set: { read: true } }, ); return result.modifiedCount; } /** * Count unread timeline items * @param {object} collections - MongoDB collections * @returns {Promise} */ export async function countUnreadItems(collections) { const { ap_timeline } = collections; return await ap_timeline.countDocuments({ read: { $ne: true }, isContext: { $ne: true }, visibility: { $nin: ["private", "direct"] }, }); }