Files
indiekit-endpoint-blogroll/lib/storage/blogs.js
Ricardo c2074ffde5 fix: soft delete blogs to prevent sync from recreating deleted entries
Deleted blogs are now marked with status: "deleted" instead of being
removed from MongoDB. The upsertBlog function skips deleted feedUrls,
preventing OPML/Microsub sync from recreating them. All queries exclude
deleted blogs. Flash messages now use Indiekit's native notificationBanner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:23:28 +01:00

299 lines
8.1 KiB
JavaScript

/**
* 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<Array>} 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);
return collection
.find(query)
.sort({ pinned: -1, title: 1 })
.skip(offset)
.limit(limit)
.toArray();
}
/**
* Count blogs
* @param {object} application - Application instance
* @param {object} options - Query options
* @returns {Promise<number>} Count
*/
export async function countBlogs(application, options = {}) {
const collection = getCollection(application);
const { category, includeHidden = false } = options;
const query = { status: { $ne: "deleted" } };
if (!includeHidden) query.hidden = { $ne: true };
if (category) query.category = category;
return collection.countDocuments(query);
}
/**
* Get blog by ID
* @param {object} application - Application instance
* @param {string|ObjectId} id - Blog ID
* @returns {Promise<object|null>} 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<object|null>} 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<object>} Created blog
*/
export async function createBlog(application, data) {
const collection = getCollection(application);
const now = new Date();
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,
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<object|null>} 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(),
};
// 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<boolean>} 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(),
updatedAt: new Date(),
},
}
);
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(),
};
if (status.success) {
update.status = "active";
update.lastFetchAt = new Date();
update.lastError = null;
if (status.itemCount !== undefined) {
update.itemCount = status.itemCount;
}
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<Array>} 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<Array>} 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<object>} Result with upserted flag
*/
export async function upsertBlog(application, data) {
const collection = getCollection(application);
const now = new Date();
// 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);
}
const result = await collection.updateOne(
filter,
{
$set: {
title: data.title,
siteUrl: data.siteUrl,
feedType: data.feedType,
category: data.category,
sourceId: data.sourceId ? new ObjectId(data.sourceId) : null,
updatedAt: now,
},
$setOnInsert: {
description: null,
tags: [],
photo: null,
author: null,
status: "active",
lastFetchAt: null,
lastError: null,
itemCount: 0,
pinned: false,
hidden: false,
notes: null,
createdAt: now,
},
},
{ upsert: true }
);
return {
upserted: result.upsertedCount > 0,
modified: result.modifiedCount > 0,
};
}