commit 8344a59b76a1d6f7b6dd0766926336118a8c053e Author: Ricardo Date: Sat Feb 7 09:55:53 2026 +0100 feat: initial blogroll endpoint plugin OPML/RSS aggregator for IndieWeb blogroll management: - Multiple source types: OPML URL, OPML file, manual entry - Background sync scheduler with configurable intervals - 7-day item retention for fresh content discovery - MongoDB storage for sources, blogs, items - Admin UI for sources and blogs management - Public JSON API endpoints for frontend consumption - OPML export by category Co-Authored-By: Claude Opus 4.5 diff --git a/index.js b/index.js new file mode 100644 index 0000000..f05e4ad --- /dev/null +++ b/index.js @@ -0,0 +1,138 @@ +import express from "express"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +import { dashboardController } from "./lib/controllers/dashboard.js"; +import { blogsController } from "./lib/controllers/blogs.js"; +import { sourcesController } from "./lib/controllers/sources.js"; +import { apiController } from "./lib/controllers/api.js"; +import { startSync, stopSync } from "./lib/sync/scheduler.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const protectedRouter = express.Router(); +const publicRouter = express.Router(); + +const defaults = { + mountPath: "/blogrollapi", + syncInterval: 3600000, // 1 hour + maxItemsPerBlog: 50, + maxItemAge: 30, // days + fetchTimeout: 15000, +}; + +export default class BlogrollEndpoint { + name = "Blogroll endpoint"; + + constructor(options = {}) { + this.options = { ...defaults, ...options }; + this.mountPath = this.options.mountPath; + } + + get localesDirectory() { + return path.join(__dirname, "locales"); + } + + get navigationItems() { + return { + href: this.options.mountPath, + text: "blogroll.title", + requiresDatabase: true, + }; + } + + get shortcutItems() { + return { + url: this.options.mountPath, + name: "blogroll.title", + iconName: "bookmark", + requiresDatabase: true, + }; + } + + /** + * Protected routes (require authentication) + * Admin dashboard and management + */ + get routes() { + // Dashboard + protectedRouter.get("/", dashboardController.get); + + // Manual sync trigger + protectedRouter.post("/sync", dashboardController.sync); + + // Clear and re-sync + protectedRouter.post("/clear-resync", dashboardController.clearResync); + + // Sources management + protectedRouter.get("/sources", sourcesController.list); + protectedRouter.get("/sources/new", sourcesController.newForm); + protectedRouter.post("/sources", sourcesController.create); + protectedRouter.get("/sources/:id", sourcesController.edit); + protectedRouter.post("/sources/:id", sourcesController.update); + protectedRouter.post("/sources/:id/delete", sourcesController.remove); + protectedRouter.post("/sources/:id/sync", sourcesController.sync); + + // Blogs management + protectedRouter.get("/blogs", blogsController.list); + protectedRouter.get("/blogs/new", blogsController.newForm); + protectedRouter.post("/blogs", blogsController.create); + protectedRouter.get("/blogs/:id", blogsController.edit); + protectedRouter.post("/blogs/:id", blogsController.update); + protectedRouter.post("/blogs/:id/delete", blogsController.remove); + protectedRouter.post("/blogs/:id/refresh", blogsController.refresh); + + return protectedRouter; + } + + /** + * Public routes (no authentication required) + * Read-only JSON API endpoints for frontend + */ + get routesPublic() { + // Blogs API (read-only) + publicRouter.get("/api/blogs", apiController.listBlogs); + publicRouter.get("/api/blogs/:id", apiController.getBlog); + + // Items API (read-only) + publicRouter.get("/api/items", apiController.listItems); + + // Categories API + publicRouter.get("/api/categories", apiController.listCategories); + + // Status API + publicRouter.get("/api/status", apiController.status); + + // OPML export + publicRouter.get("/api/opml", apiController.exportOpml); + publicRouter.get("/api/opml/:category", apiController.exportOpmlCategory); + + return publicRouter; + } + + init(Indiekit) { + Indiekit.addEndpoint(this); + + // Add MongoDB collections + Indiekit.addCollection("blogrollSources"); + Indiekit.addCollection("blogrollBlogs"); + Indiekit.addCollection("blogrollItems"); + Indiekit.addCollection("blogrollMeta"); + + // Store config in application for controller access + Indiekit.config.application.blogrollConfig = this.options; + Indiekit.config.application.blogrollEndpoint = this.mountPath; + + // Store database getter for controller access + Indiekit.config.application.getBlogrollDb = () => Indiekit.database; + + // Start background sync if database is available + if (Indiekit.config.application.mongodbUrl) { + startSync(Indiekit, this.options); + } + } + + destroy() { + stopSync(); + } +} diff --git a/lib/controllers/api.js b/lib/controllers/api.js new file mode 100644 index 0000000..4465ec1 --- /dev/null +++ b/lib/controllers/api.js @@ -0,0 +1,240 @@ +/** + * Public API controller + * @module controllers/api + */ + +import { ObjectId } from "mongodb"; +import { getBlogs, countBlogs, getBlog, getCategories } from "../storage/blogs.js"; +import { getItems, getItemsForBlog } from "../storage/items.js"; +import { getSyncStatus } from "../sync/scheduler.js"; +import { generateOpml } from "../sync/opml.js"; + +/** + * List blogs with optional filtering + * GET /api/blogs + */ +async function listBlogs(request, response) { + const { application } = request.app.locals; + + const { category, limit = 100, offset = 0 } = request.query; + + try { + const blogs = await getBlogs(application, { + category, + limit: Number(limit), + offset: Number(offset), + }); + + const total = await countBlogs(application, { category }); + + response.json({ + items: blogs.map(sanitizeBlog), + total, + hasMore: Number(offset) + blogs.length < total, + }); + } catch (error) { + console.error("[Blogroll API] listBlogs error:", error); + response.status(500).json({ error: "Failed to fetch blogs" }); + } +} + +/** + * Get single blog with recent items + * GET /api/blogs/:id + */ +async function getBlogDetail(request, response) { + const { application } = request.app.locals; + const { id } = request.params; + + try { + if (!ObjectId.isValid(id)) { + return response.status(400).json({ error: "Invalid blog ID" }); + } + + const blog = await getBlog(application, id); + if (!blog) { + return response.status(404).json({ error: "Blog not found" }); + } + + const items = await getItemsForBlog(application, blog._id, 20); + + response.json({ + ...sanitizeBlog(blog), + items: items.map(sanitizeItem), + }); + } catch (error) { + console.error("[Blogroll API] getBlog error:", error); + response.status(500).json({ error: "Failed to fetch blog" }); + } +} + +/** + * List items across all blogs + * GET /api/items + */ +async function listItems(request, response) { + const { application } = request.app.locals; + + const { blog, category, limit = 50, offset = 0 } = request.query; + + try { + const result = await getItems(application, { + blogId: blog, + category, + limit: Number(limit), + offset: Number(offset), + }); + + response.json({ + items: result.items.map((item) => ({ + ...sanitizeItem(item), + blog: item.blog + ? { + id: item.blog._id.toString(), + title: item.blog.title, + siteUrl: item.blog.siteUrl, + category: item.blog.category, + photo: item.blog.photo, + } + : null, + })), + hasMore: result.hasMore, + }); + } catch (error) { + console.error("[Blogroll API] listItems error:", error); + response.status(500).json({ error: "Failed to fetch items" }); + } +} + +/** + * List categories + * GET /api/categories + */ +async function listCategories(request, response) { + const { application } = request.app.locals; + + try { + const categories = await getCategories(application); + + response.json({ + items: categories.map((c) => ({ name: c._id, count: c.count })), + }); + } catch (error) { + console.error("[Blogroll API] listCategories error:", error); + response.status(500).json({ error: "Failed to fetch categories" }); + } +} + +/** + * Status endpoint + * GET /api/status + */ +async function status(request, response) { + const { application } = request.app.locals; + + try { + const syncStatus = await getSyncStatus(application); + response.json(syncStatus); + } catch (error) { + console.error("[Blogroll API] status error:", error); + response.status(500).json({ error: "Failed to fetch status" }); + } +} + +/** + * Export OPML + * GET /api/opml + */ +async function exportOpml(request, response) { + const { application } = request.app.locals; + + try { + const blogs = await getBlogs(application, { limit: 1000 }); + const opml = generateOpml(blogs, "Blogroll"); + + response.set("Content-Type", "text/x-opml"); + response.set("Content-Disposition", 'attachment; filename="blogroll.opml"'); + response.send(opml); + } catch (error) { + console.error("[Blogroll API] exportOpml error:", error); + response.status(500).json({ error: "Failed to export OPML" }); + } +} + +/** + * Export OPML for category + * GET /api/opml/:category + */ +async function exportOpmlCategory(request, response) { + const { application } = request.app.locals; + const { category } = request.params; + + try { + const blogs = await getBlogs(application, { category, limit: 1000 }); + const opml = generateOpml(blogs, `Blogroll - ${category}`); + + response.set("Content-Type", "text/x-opml"); + response.set( + "Content-Disposition", + `attachment; filename="blogroll-${encodeURIComponent(category)}.opml"` + ); + response.send(opml); + } catch (error) { + console.error("[Blogroll API] exportOpmlCategory error:", error); + response.status(500).json({ error: "Failed to export OPML" }); + } +} + +// Helper functions + +/** + * Sanitize blog for API response + * @param {object} blog - Blog document + * @returns {object} Sanitized blog + */ +function sanitizeBlog(blog) { + return { + id: blog._id.toString(), + title: blog.title, + description: blog.description, + feedUrl: blog.feedUrl, + siteUrl: blog.siteUrl, + feedType: blog.feedType, + category: blog.category, + tags: blog.tags, + photo: blog.photo, + author: blog.author, + status: blog.status, + itemCount: blog.itemCount, + pinned: blog.pinned, + lastFetchAt: blog.lastFetchAt, + }; +} + +/** + * Sanitize item for API response + * @param {object} item - Item document + * @returns {object} Sanitized item + */ +function sanitizeItem(item) { + return { + id: item._id.toString(), + url: item.url, + title: item.title, + summary: item.summary, + published: item.published, + author: item.author, + photo: item.photo, + categories: item.categories, + }; +} + +export const apiController = { + listBlogs, + getBlog: getBlogDetail, + listItems, + listCategories, + status, + exportOpml, + exportOpmlCategory, +}; diff --git a/lib/controllers/blogs.js b/lib/controllers/blogs.js new file mode 100644 index 0000000..21f37e1 --- /dev/null +++ b/lib/controllers/blogs.js @@ -0,0 +1,298 @@ +/** + * Blogs controller + * @module controllers/blogs + */ + +import { + getBlogs, + getBlog, + getBlogByFeedUrl, + createBlog, + updateBlog, + deleteBlog, +} from "../storage/blogs.js"; +import { getItemsForBlog, deleteItemsForBlog } from "../storage/items.js"; +import { syncBlogItems } from "../sync/feed.js"; + +/** + * List blogs + * GET /blogs + */ +async function list(request, response) { + const { application } = request.app.locals; + const { category, status: filterStatus } = request.query; + + try { + const blogs = await getBlogs(application, { + category, + includeHidden: true, + limit: 100, + }); + + // Filter by status if specified + let filteredBlogs = blogs; + if (filterStatus) { + filteredBlogs = blogs.filter((b) => b.status === filterStatus); + } + + // Get unique categories for filter dropdown + const categories = [...new Set(blogs.map((b) => b.category).filter(Boolean))]; + + response.render("blogs", { + title: request.__("blogroll.blogs.title"), + blogs: filteredBlogs, + categories, + filterCategory: category, + filterStatus, + baseUrl: request.baseUrl, + }); + } catch (error) { + console.error("[Blogroll] Blogs list error:", error); + response.status(500).render("error", { + title: "Error", + message: "Failed to load blogs", + }); + } +} + +/** + * New blog form + * GET /blogs/new + */ +function newForm(request, response) { + response.render("blog-edit", { + title: request.__("blogroll.blogs.new"), + blog: null, + isNew: true, + baseUrl: request.baseUrl, + }); +} + +/** + * Create blog + * POST /blogs + */ +async function create(request, response) { + const { application } = request.app.locals; + const { feedUrl, title, siteUrl, category, tags, notes, pinned, hidden } = request.body; + + try { + // Validate required fields + if (!feedUrl) { + request.session.messages = [ + { type: "error", content: "Feed URL is required" }, + ]; + return response.redirect(`${request.baseUrl}/blogs/new`); + } + + // Check for duplicate + const existing = await getBlogByFeedUrl(application, feedUrl); + if (existing) { + request.session.messages = [ + { type: "error", content: "A blog with this feed URL already exists" }, + ]; + return response.redirect(`${request.baseUrl}/blogs/new`); + } + + const blog = await createBlog(application, { + feedUrl, + title: title || feedUrl, + siteUrl: siteUrl || null, + category: category || "", + tags: tags ? tags.split(",").map((t) => t.trim()).filter(Boolean) : [], + notes: notes || null, + pinned: pinned === "on" || pinned === true, + hidden: hidden === "on" || hidden === true, + }); + + // Trigger initial fetch + try { + const result = await syncBlogItems(application, blog, application.blogrollConfig); + if (result.success) { + request.session.messages = [ + { + type: "success", + content: request.__("blogroll.blogs.created_synced", { items: result.added }), + }, + ]; + } else { + request.session.messages = [ + { + type: "warning", + content: request.__("blogroll.blogs.created_sync_failed", { error: result.error }), + }, + ]; + } + } catch (syncError) { + request.session.messages = [ + { + type: "warning", + content: request.__("blogroll.blogs.created_sync_failed", { error: syncError.message }), + }, + ]; + } + + response.redirect(`${request.baseUrl}/blogs`); + } catch (error) { + console.error("[Blogroll] Create blog error:", error); + request.session.messages = [ + { type: "error", content: error.message }, + ]; + response.redirect(`${request.baseUrl}/blogs/new`); + } +} + +/** + * Edit blog form + * GET /blogs/:id + */ +async function edit(request, response) { + const { application } = request.app.locals; + const { id } = request.params; + + try { + const blog = await getBlog(application, id); + + if (!blog) { + return response.status(404).render("404"); + } + + const items = await getItemsForBlog(application, blog._id, 10); + + response.render("blog-edit", { + title: request.__("blogroll.blogs.edit"), + blog, + items, + isNew: false, + baseUrl: request.baseUrl, + }); + } catch (error) { + console.error("[Blogroll] Edit blog error:", error); + response.status(500).render("error", { + title: "Error", + message: "Failed to load blog", + }); + } +} + +/** + * Update blog + * POST /blogs/:id + */ +async function update(request, response) { + const { application } = request.app.locals; + const { id } = request.params; + const { feedUrl, title, siteUrl, category, tags, notes, pinned, hidden } = request.body; + + try { + const blog = await getBlog(application, id); + + if (!blog) { + return response.status(404).render("404"); + } + + await updateBlog(application, id, { + feedUrl, + title: title || feedUrl, + siteUrl: siteUrl || null, + category: category || "", + tags: tags ? tags.split(",").map((t) => t.trim()).filter(Boolean) : [], + notes: notes || null, + pinned: pinned === "on" || pinned === true, + hidden: hidden === "on" || hidden === true, + }); + + request.session.messages = [ + { type: "success", content: request.__("blogroll.blogs.updated") }, + ]; + + response.redirect(`${request.baseUrl}/blogs`); + } catch (error) { + console.error("[Blogroll] Update blog error:", error); + request.session.messages = [ + { type: "error", content: error.message }, + ]; + response.redirect(`${request.baseUrl}/blogs/${id}`); + } +} + +/** + * Delete blog + * POST /blogs/:id/delete + */ +async function remove(request, response) { + const { application } = request.app.locals; + const { id } = request.params; + + try { + const blog = await getBlog(application, id); + + if (!blog) { + return response.status(404).render("404"); + } + + await deleteBlog(application, id); + + request.session.messages = [ + { type: "success", content: request.__("blogroll.blogs.deleted") }, + ]; + + response.redirect(`${request.baseUrl}/blogs`); + } catch (error) { + console.error("[Blogroll] Delete blog error:", error); + request.session.messages = [ + { type: "error", content: error.message }, + ]; + response.redirect(`${request.baseUrl}/blogs`); + } +} + +/** + * Refresh blog + * POST /blogs/:id/refresh + */ +async function refresh(request, response) { + const { application } = request.app.locals; + const { id } = request.params; + + try { + const blog = await getBlog(application, id); + + if (!blog) { + return response.status(404).render("404"); + } + + const result = await syncBlogItems(application, blog, application.blogrollConfig); + + if (result.success) { + request.session.messages = [ + { + type: "success", + content: request.__("blogroll.blogs.refreshed", { items: result.added }), + }, + ]; + } else { + request.session.messages = [ + { type: "error", content: result.error }, + ]; + } + + response.redirect(`${request.baseUrl}/blogs/${id}`); + } catch (error) { + console.error("[Blogroll] Refresh blog error:", error); + request.session.messages = [ + { type: "error", content: error.message }, + ]; + response.redirect(`${request.baseUrl}/blogs/${id}`); + } +} + +export const blogsController = { + list, + newForm, + create, + edit, + update, + remove, + refresh, +}; diff --git a/lib/controllers/dashboard.js b/lib/controllers/dashboard.js new file mode 100644 index 0000000..5002a62 --- /dev/null +++ b/lib/controllers/dashboard.js @@ -0,0 +1,149 @@ +/** + * Dashboard controller + * @module controllers/dashboard + */ + +import { getSources } from "../storage/sources.js"; +import { getBlogs, countBlogs } from "../storage/blogs.js"; +import { countItems } from "../storage/items.js"; +import { runFullSync, clearAndResync, getSyncStatus } from "../sync/scheduler.js"; + +/** + * Dashboard page + * GET / + */ +async function get(request, response) { + const { application } = request.app.locals; + + try { + const [sources, blogs, blogCount, itemCount, syncStatus] = await Promise.all([ + getSources(application), + getBlogs(application, { limit: 10 }), + countBlogs(application), + countItems(application), + getSyncStatus(application), + ]); + + // Get blogs with errors + const errorBlogs = await getBlogs(application, { includeHidden: true, limit: 100 }); + const blogsWithErrors = errorBlogs.filter((b) => b.status === "error"); + + response.render("dashboard", { + title: request.__("blogroll.title"), + sources, + recentBlogs: blogs, + stats: { + sources: sources.length, + blogs: blogCount, + items: itemCount, + errors: blogsWithErrors.length, + }, + syncStatus, + blogsWithErrors: blogsWithErrors.slice(0, 5), + baseUrl: request.baseUrl, + }); + } catch (error) { + console.error("[Blogroll] Dashboard error:", error); + response.status(500).render("error", { + title: "Error", + message: "Failed to load dashboard", + }); + } +} + +/** + * Manual sync trigger + * POST /sync + */ +async function sync(request, response) { + const { application } = request.app.locals; + + try { + const result = await runFullSync(application, application.blogrollConfig); + + if (result.skipped) { + request.session.messages = [ + { type: "warning", content: request.__("blogroll.sync.already_running") }, + ]; + } else if (result.success) { + request.session.messages = [ + { + type: "success", + content: request.__("blogroll.sync.success", { + blogs: result.blogs.success, + items: result.items.added, + }), + }, + ]; + } else { + request.session.messages = [ + { type: "error", content: request.__("blogroll.sync.error", { error: result.error }) }, + ]; + } + } catch (error) { + console.error("[Blogroll] Manual sync error:", error); + request.session.messages = [ + { type: "error", content: request.__("blogroll.sync.error", { error: error.message }) }, + ]; + } + + response.redirect(request.baseUrl); +} + +/** + * Clear and re-sync + * POST /clear-resync + */ +async function clearResync(request, response) { + const { application } = request.app.locals; + + try { + const result = await clearAndResync(application, application.blogrollConfig); + + if (result.success) { + request.session.messages = [ + { + type: "success", + content: request.__("blogroll.sync.cleared_success", { + blogs: result.blogs.success, + items: result.items.added, + }), + }, + ]; + } else { + request.session.messages = [ + { type: "error", content: request.__("blogroll.sync.error", { error: result.error }) }, + ]; + } + } catch (error) { + console.error("[Blogroll] Clear resync error:", error); + request.session.messages = [ + { type: "error", content: request.__("blogroll.sync.error", { error: error.message }) }, + ]; + } + + response.redirect(request.baseUrl); +} + +/** + * Status API (for dashboard) + * GET /api/status (duplicated in api.js for public access) + */ +async function status(request, response) { + const { application } = request.app.locals; + + try { + const syncStatus = await getSyncStatus(application); + response.json(syncStatus); + } catch (error) { + console.error("[Blogroll] Status error:", error); + response.status(500).json({ error: "Failed to fetch status" }); + } +} + +export const dashboardController = { + get, + sync, + clearResync, + status, +}; diff --git a/lib/controllers/sources.js b/lib/controllers/sources.js new file mode 100644 index 0000000..0e57f34 --- /dev/null +++ b/lib/controllers/sources.js @@ -0,0 +1,269 @@ +/** + * Sources controller + * @module controllers/sources + */ + +import { + getSources, + getSource, + createSource, + updateSource, + deleteSource, +} from "../storage/sources.js"; +import { syncOpmlSource } from "../sync/opml.js"; + +/** + * List sources + * GET /sources + */ +async function list(request, response) { + const { application } = request.app.locals; + + try { + const sources = await getSources(application); + + response.render("sources", { + title: request.__("blogroll.sources.title"), + sources, + baseUrl: request.baseUrl, + }); + } catch (error) { + console.error("[Blogroll] Sources list error:", error); + response.status(500).render("error", { + title: "Error", + message: "Failed to load sources", + }); + } +} + +/** + * New source form + * GET /sources/new + */ +function newForm(request, response) { + response.render("source-edit", { + title: request.__("blogroll.sources.new"), + source: null, + isNew: true, + baseUrl: request.baseUrl, + }); +} + +/** + * Create source + * POST /sources + */ +async function create(request, response) { + const { application } = request.app.locals; + const { name, type, url, opmlContent, syncInterval, enabled } = request.body; + + try { + // Validate required fields + if (!name || !type) { + request.session.messages = [ + { type: "error", content: "Name and type are required" }, + ]; + return response.redirect(`${request.baseUrl}/sources/new`); + } + + if (type === "opml_url" && !url) { + request.session.messages = [ + { type: "error", content: "URL is required for OPML URL source" }, + ]; + return response.redirect(`${request.baseUrl}/sources/new`); + } + + const source = await createSource(application, { + name, + type, + url: url || null, + opmlContent: opmlContent || null, + syncInterval: Number(syncInterval) || 60, + enabled: enabled === "on" || enabled === true, + }); + + // Trigger initial sync for OPML sources + if (source.type === "opml_url" || source.type === "opml_file") { + try { + await syncOpmlSource(application, source); + request.session.messages = [ + { type: "success", content: request.__("blogroll.sources.created_synced") }, + ]; + } catch (syncError) { + request.session.messages = [ + { + type: "warning", + content: request.__("blogroll.sources.created_sync_failed", { + error: syncError.message, + }), + }, + ]; + } + } else { + request.session.messages = [ + { type: "success", content: request.__("blogroll.sources.created") }, + ]; + } + + response.redirect(`${request.baseUrl}/sources`); + } catch (error) { + console.error("[Blogroll] Create source error:", error); + request.session.messages = [ + { type: "error", content: error.message }, + ]; + response.redirect(`${request.baseUrl}/sources/new`); + } +} + +/** + * Edit source form + * GET /sources/:id + */ +async function edit(request, response) { + const { application } = request.app.locals; + const { id } = request.params; + + try { + const source = await getSource(application, id); + + if (!source) { + return response.status(404).render("404"); + } + + response.render("source-edit", { + title: request.__("blogroll.sources.edit"), + source, + isNew: false, + baseUrl: request.baseUrl, + }); + } catch (error) { + console.error("[Blogroll] Edit source error:", error); + response.status(500).render("error", { + title: "Error", + message: "Failed to load source", + }); + } +} + +/** + * Update source + * POST /sources/:id + */ +async function update(request, response) { + const { application } = request.app.locals; + const { id } = request.params; + const { name, type, url, opmlContent, syncInterval, enabled } = request.body; + + try { + const source = await getSource(application, id); + + if (!source) { + return response.status(404).render("404"); + } + + await updateSource(application, id, { + name, + type, + url: url || null, + opmlContent: opmlContent || null, + syncInterval: Number(syncInterval) || 60, + enabled: enabled === "on" || enabled === true, + }); + + request.session.messages = [ + { type: "success", content: request.__("blogroll.sources.updated") }, + ]; + + response.redirect(`${request.baseUrl}/sources`); + } catch (error) { + console.error("[Blogroll] Update source error:", error); + request.session.messages = [ + { type: "error", content: error.message }, + ]; + response.redirect(`${request.baseUrl}/sources/${id}`); + } +} + +/** + * Delete source + * POST /sources/:id/delete + */ +async function remove(request, response) { + const { application } = request.app.locals; + const { id } = request.params; + + try { + const source = await getSource(application, id); + + if (!source) { + return response.status(404).render("404"); + } + + await deleteSource(application, id); + + request.session.messages = [ + { type: "success", content: request.__("blogroll.sources.deleted") }, + ]; + + response.redirect(`${request.baseUrl}/sources`); + } catch (error) { + console.error("[Blogroll] Delete source error:", error); + request.session.messages = [ + { type: "error", content: error.message }, + ]; + response.redirect(`${request.baseUrl}/sources`); + } +} + +/** + * Sync single source + * POST /sources/:id/sync + */ +async function sync(request, response) { + const { application } = request.app.locals; + const { id } = request.params; + + try { + const source = await getSource(application, id); + + if (!source) { + return response.status(404).render("404"); + } + + const result = await syncOpmlSource(application, source); + + if (result.success) { + request.session.messages = [ + { + type: "success", + content: request.__("blogroll.sources.synced", { + added: result.added, + updated: result.updated, + }), + }, + ]; + } else { + request.session.messages = [ + { type: "error", content: result.error }, + ]; + } + + response.redirect(`${request.baseUrl}/sources`); + } catch (error) { + console.error("[Blogroll] Sync source error:", error); + request.session.messages = [ + { type: "error", content: error.message }, + ]; + response.redirect(`${request.baseUrl}/sources`); + } +} + +export const sourcesController = { + list, + newForm, + create, + edit, + update, + remove, + sync, +}; diff --git a/lib/storage/blogs.js b/lib/storage/blogs.js new file mode 100644 index 0000000..ad7bc96 --- /dev/null +++ b/lib/storage/blogs.js @@ -0,0 +1,277 @@ +/** + * 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 = {}; + 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} Count + */ +export async function countBlogs(application, options = {}) { + const collection = getCollection(application); + const { category, includeHidden = false } = options; + + const query = {}; + 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} 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 }); +} + +/** + * 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(); + + 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} 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 + * @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 }); + + // Delete the blog + const result = await db.collection("blogrollBlogs").deleteOne({ _id: objectId }); + return result.deletedCount > 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} 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 }, + $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 }, 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(); + + 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, + }; +} diff --git a/lib/storage/items.js b/lib/storage/items.js new file mode 100644 index 0000000..403ef6f --- /dev/null +++ b/lib/storage/items.js @@ -0,0 +1,180 @@ +/** + * Item storage operations + * @module storage/items + */ + +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("blogrollItems"); +} + +/** + * Get items with optional filtering + * @param {object} application - Application instance + * @param {object} options - Query options + * @returns {Promise} Items with blog info + */ +export async function getItems(application, options = {}) { + const db = application.getBlogrollDb(); + const { blogId, category, limit = 50, offset = 0 } = options; + + const pipeline = [ + { $sort: { published: -1 } }, + { $skip: offset }, + { $limit: limit + 1 }, // Fetch one extra to check hasMore + { + $lookup: { + from: "blogrollBlogs", + localField: "blogId", + foreignField: "_id", + as: "blog", + }, + }, + { $unwind: "$blog" }, + { $match: { "blog.hidden": { $ne: true } } }, + ]; + + if (blogId) { + pipeline.unshift({ $match: { blogId: new ObjectId(blogId) } }); + } + + if (category) { + pipeline.push({ $match: { "blog.category": category } }); + } + + const items = await db.collection("blogrollItems").aggregate(pipeline).toArray(); + + const hasMore = items.length > limit; + if (hasMore) items.pop(); + + return { items, hasMore }; +} + +/** + * Get items for a specific blog + * @param {object} application - Application instance + * @param {string|ObjectId} blogId - Blog ID + * @param {number} limit - Max items + * @returns {Promise} Items + */ +export async function getItemsForBlog(application, blogId, limit = 20) { + const collection = getCollection(application); + const objectId = typeof blogId === "string" ? new ObjectId(blogId) : blogId; + + return collection + .find({ blogId: objectId }) + .sort({ published: -1 }) + .limit(limit) + .toArray(); +} + +/** + * Count items + * @param {object} application - Application instance + * @param {object} options - Query options + * @returns {Promise} Count + */ +export async function countItems(application, options = {}) { + const collection = getCollection(application); + const query = {}; + + if (options.blogId) { + query.blogId = new ObjectId(options.blogId); + } + + return collection.countDocuments(query); +} + +/** + * Upsert an item + * @param {object} application - Application instance + * @param {object} data - Item data + * @returns {Promise} 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} 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} 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} 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 }); +} diff --git a/lib/storage/sources.js b/lib/storage/sources.js new file mode 100644 index 0000000..d3517ac --- /dev/null +++ b/lib/storage/sources.js @@ -0,0 +1,174 @@ +/** + * Source storage operations + * @module storage/sources + */ + +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("blogrollSources"); +} + +/** + * Get all sources + * @param {object} application - Application instance + * @returns {Promise} Sources + */ +export async function getSources(application) { + const collection = getCollection(application); + return collection.find({}).sort({ name: 1 }).toArray(); +} + +/** + * Get source by ID + * @param {object} application - Application instance + * @param {string|ObjectId} id - Source ID + * @returns {Promise} Source or null + */ +export async function getSource(application, id) { + const collection = getCollection(application); + const objectId = typeof id === "string" ? new ObjectId(id) : id; + return collection.findOne({ _id: objectId }); +} + +/** + * Create a new source + * @param {object} application - Application instance + * @param {object} data - Source data + * @returns {Promise} Created source + */ +export async function createSource(application, data) { + const collection = getCollection(application); + const now = new Date(); + + const source = { + type: data.type, // "opml_url" | "opml_file" | "manual" | "json_feed" + name: data.name, + url: data.url || null, + opmlContent: data.opmlContent || null, + enabled: data.enabled !== false, + syncInterval: data.syncInterval || 60, // minutes + lastSyncAt: null, + lastSyncError: null, + createdAt: now, + updatedAt: now, + }; + + const result = await collection.insertOne(source); + return { ...source, _id: result.insertedId }; +} + +/** + * Update a source + * @param {object} application - Application instance + * @param {string|ObjectId} id - Source ID + * @param {object} data - Update data + * @returns {Promise} Updated source + */ +export async function updateSource(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 source and its associated blogs + * @param {object} application - Application instance + * @param {string|ObjectId} id - Source ID + * @returns {Promise} Success + */ +export async function deleteSource(application, id) { + const db = application.getBlogrollDb(); + const objectId = typeof id === "string" ? new ObjectId(id) : id; + + // Get blogs from this source + const blogs = await db + .collection("blogrollBlogs") + .find({ sourceId: objectId }) + .toArray(); + const blogIds = blogs.map((b) => b._id); + + // Delete items from those blogs + if (blogIds.length > 0) { + await db.collection("blogrollItems").deleteMany({ blogId: { $in: blogIds } }); + } + + // Delete blogs from this source + await db.collection("blogrollBlogs").deleteMany({ sourceId: objectId }); + + // Delete the source + const result = await db.collection("blogrollSources").deleteOne({ _id: objectId }); + return result.deletedCount > 0; +} + +/** + * Update source sync status + * @param {object} application - Application instance + * @param {string|ObjectId} id - Source ID + * @param {object} status - Sync status + */ +export async function updateSourceSyncStatus(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.lastSyncAt = new Date(); + update.lastSyncError = null; + } else { + update.lastSyncError = status.error; + } + + return collection.updateOne({ _id: objectId }, { $set: update }); +} + +/** + * Get sources due for sync + * @param {object} application - Application instance + * @returns {Promise} Sources needing sync + */ +export async function getSourcesDueForSync(application) { + const collection = getCollection(application); + const now = new Date(); + + return collection + .find({ + enabled: true, + type: { $in: ["opml_url", "json_feed"] }, + $or: [ + { lastSyncAt: null }, + { + $expr: { + $lt: [ + "$lastSyncAt", + { $subtract: [now, { $multiply: ["$syncInterval", 60000] }] }, + ], + }, + }, + ], + }) + .toArray(); +} diff --git a/lib/sync/feed.js b/lib/sync/feed.js new file mode 100644 index 0000000..69e8400 --- /dev/null +++ b/lib/sync/feed.js @@ -0,0 +1,318 @@ +/** + * Feed fetching and parsing for blogroll + * @module sync/feed + */ + +import { Readable } from "node:stream"; +import FeedParser from "feedparser"; +import sanitizeHtml from "sanitize-html"; +import crypto from "node:crypto"; + +import { upsertItem } from "../storage/items.js"; +import { updateBlogStatus } from "../storage/blogs.js"; + +const SANITIZE_OPTIONS = { + allowedTags: [ + "a", + "b", + "i", + "em", + "strong", + "p", + "br", + "ul", + "ol", + "li", + "blockquote", + "code", + "pre", + ], + allowedAttributes: { a: ["href"] }, +}; + +/** + * Fetch and parse a blog feed + * @param {string} url - Feed URL + * @param {object} options - Options + * @returns {Promise} Parsed feed with items + */ +export async function fetchAndParseFeed(url, options = {}) { + const { timeout = 15000, maxItems = 50 } = options; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { + "User-Agent": "Indiekit-Blogroll/1.0", + Accept: + "application/atom+xml, application/rss+xml, application/json, application/feed+json, */*", + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const content = await response.text(); + const contentType = response.headers.get("Content-Type") || ""; + + // Check for JSON Feed + if (contentType.includes("json") || content.trim().startsWith("{")) { + try { + return parseJsonFeed(content, url, maxItems); + } catch { + // Not valid JSON, try XML + } + } + + // Parse as RSS/Atom + return parseXmlFeed(content, url, maxItems); + } catch (error) { + clearTimeout(timeoutId); + if (error.name === "AbortError") { + throw new Error("Request timed out"); + } + throw error; + } +} + +/** + * Parse XML feed (RSS/Atom) + * @param {string} content - XML content + * @param {string} feedUrl - Feed URL + * @param {number} maxItems - Max items to parse + * @returns {Promise} Parsed feed + */ +async function parseXmlFeed(content, feedUrl, maxItems) { + return new Promise((resolve, reject) => { + const feedparser = new FeedParser({ feedurl: feedUrl }); + const items = []; + let meta; + + feedparser.on("error", reject); + feedparser.on("meta", (m) => { + meta = m; + }); + + feedparser.on("readable", function () { + let item; + while ((item = this.read()) && items.length < maxItems) { + items.push(normalizeItem(item, feedUrl)); + } + }); + + feedparser.on("end", () => { + resolve({ + title: meta?.title, + description: meta?.description, + siteUrl: meta?.link, + photo: meta?.image?.url || meta?.favicon, + author: meta?.author ? { name: meta.author } : undefined, + items, + }); + }); + + Readable.from([content]).pipe(feedparser); + }); +} + +/** + * Parse JSON Feed + * @param {string} content - JSON content + * @param {string} feedUrl - Feed URL + * @param {number} maxItems - Max items to parse + * @returns {object} Parsed feed + */ +function parseJsonFeed(content, feedUrl, maxItems) { + const feed = JSON.parse(content); + + const items = (feed.items || []).slice(0, maxItems).map((item) => ({ + uid: generateUid(feedUrl, item.id || item.url), + url: item.url || item.external_url, + title: item.title || "Untitled", + content: { + html: item.content_html + ? sanitizeHtml(item.content_html, SANITIZE_OPTIONS) + : undefined, + text: item.content_text, + }, + summary: item.summary || truncateText(item.content_text, 300), + published: item.date_published ? new Date(item.date_published) : new Date(), + updated: item.date_modified ? new Date(item.date_modified) : undefined, + author: item.author || (item.authors?.[0]), + photo: item.image ? [item.image] : undefined, + categories: item.tags || [], + })); + + return { + title: feed.title, + description: feed.description, + siteUrl: feed.home_page_url, + photo: feed.icon || feed.favicon, + author: feed.author || (feed.authors?.[0]), + items, + }; +} + +/** + * Normalize RSS/Atom item to common format + * @param {object} item - FeedParser item + * @param {string} feedUrl - Feed URL + * @returns {object} Normalized item + */ +function normalizeItem(item, feedUrl) { + const description = item.description || item.summary || ""; + + return { + uid: generateUid(feedUrl, item.guid || item.link), + url: item.link || item.origlink, + title: item.title || "Untitled", + content: { + html: description ? sanitizeHtml(description, SANITIZE_OPTIONS) : undefined, + text: stripHtml(description), + }, + summary: truncateText(stripHtml(item.summary || description), 300), + published: item.pubdate || item.date || new Date(), + updated: item.date, + author: item.author ? { name: item.author } : undefined, + photo: extractPhotos(item), + categories: item.categories || [], + }; +} + +/** + * Generate unique ID for item + * @param {string} feedUrl - Feed URL + * @param {string} itemId - Item ID or URL + * @returns {string} Unique hash + */ +function generateUid(feedUrl, itemId) { + return crypto + .createHash("sha256") + .update(`${feedUrl}::${itemId}`) + .digest("hex") + .slice(0, 24); +} + +/** + * Strip HTML tags from string + * @param {string} html - HTML string + * @returns {string} Plain text + */ +function stripHtml(html) { + if (!html) return ""; + return html + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Truncate text to max length + * @param {string} text - Text to truncate + * @param {number} maxLength - Max length + * @returns {string} Truncated text + */ +function truncateText(text, maxLength) { + if (!text) return ""; + if (text.length <= maxLength) return text; + return text.slice(0, maxLength - 3).trim() + "..."; +} + +/** + * Extract photos from feed item + * @param {object} item - FeedParser item + * @returns {Array|undefined} Photo URLs + */ +function extractPhotos(item) { + const photos = []; + + if (item.enclosures) { + for (const enc of item.enclosures) { + if (enc.type?.startsWith("image/")) { + photos.push(enc.url); + } + } + } + + if (item["media:content"]) { + const media = Array.isArray(item["media:content"]) + ? item["media:content"] + : [item["media:content"]]; + for (const m of media) { + if (m.type?.startsWith("image/") || m.medium === "image") { + photos.push(m.url); + } + } + } + + if (item.image?.url) { + photos.push(item.image.url); + } + + return photos.length > 0 ? photos : undefined; +} + +/** + * Sync items from a blog feed + * @param {object} application - Application instance + * @param {object} blog - Blog document + * @param {object} options - Sync options + * @returns {Promise} Sync result + */ +export async function syncBlogItems(application, blog, options = {}) { + const { maxItems = 50, timeout = 15000 } = options; + + try { + const feed = await fetchAndParseFeed(blog.feedUrl, { timeout, maxItems }); + + let added = 0; + + for (const item of feed.items) { + const result = await upsertItem(application, { + ...item, + blogId: blog._id, + }); + + if (result.upserted) added++; + } + + // Update blog metadata + const updateData = { + success: true, + itemCount: feed.items.length, + }; + + // Update title if not manually set (still has feedUrl as title) + if (blog.title === blog.feedUrl && feed.title) { + updateData.title = feed.title; + } + + // Update photo if not set + if (!blog.photo && feed.photo) { + updateData.photo = feed.photo; + } + + // Update siteUrl if not set + if (!blog.siteUrl && feed.siteUrl) { + updateData.siteUrl = feed.siteUrl; + } + + await updateBlogStatus(application, blog._id, updateData); + + return { success: true, added, total: feed.items.length }; + } catch (error) { + // Update blog with error status + await updateBlogStatus(application, blog._id, { + success: false, + error: error.message, + }); + + return { success: false, error: error.message }; + } +} diff --git a/lib/sync/opml.js b/lib/sync/opml.js new file mode 100644 index 0000000..aff1010 --- /dev/null +++ b/lib/sync/opml.js @@ -0,0 +1,208 @@ +/** + * OPML parsing and synchronization + * @module sync/opml + */ + +import { parseStringPromise } from "xml2js"; +import { upsertBlog } from "../storage/blogs.js"; +import { updateSourceSyncStatus } from "../storage/sources.js"; + +/** + * Parse OPML content and extract blog entries + * @param {string} opmlContent - OPML XML content + * @returns {Promise} Array of blog entries + */ +export async function parseOpml(opmlContent) { + const result = await parseStringPromise(opmlContent, { explicitArray: false }); + const blogs = []; + + const body = result?.opml?.body; + if (!body?.outline) return blogs; + + const outlines = Array.isArray(body.outline) ? body.outline : [body.outline]; + + for (const outline of outlines) { + // Handle nested outlines (categories) + if (outline.outline) { + const children = Array.isArray(outline.outline) + ? outline.outline + : [outline.outline]; + const category = outline.$?.text || outline.$?.title || ""; + + for (const child of children) { + if (child.$ && child.$.xmlUrl) { + blogs.push({ + title: child.$.text || child.$.title || "Unknown", + feedUrl: child.$.xmlUrl, + siteUrl: child.$.htmlUrl || "", + feedType: detectFeedType(child.$.type), + category, + }); + } + } + } else if (outline.$ && outline.$.xmlUrl) { + // Direct feed outline (no category) + blogs.push({ + title: outline.$.text || outline.$.title || "Unknown", + feedUrl: outline.$.xmlUrl, + siteUrl: outline.$.htmlUrl || "", + feedType: detectFeedType(outline.$.type), + category: "", + }); + } + } + + return blogs; +} + +/** + * Detect feed type from OPML type attribute + * @param {string} type - OPML type attribute + * @returns {string} Feed type + */ +function detectFeedType(type) { + if (!type) return "rss"; + const t = type.toLowerCase(); + if (t.includes("atom")) return "atom"; + if (t.includes("json")) return "jsonfeed"; + return "rss"; +} + +/** + * Fetch and parse OPML from URL + * @param {string} url - OPML URL + * @param {number} timeout - Fetch timeout in ms + * @returns {Promise} Array of blog entries + */ +export async function fetchAndParseOpml(url, timeout = 15000) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { + "User-Agent": "Indiekit-Blogroll/1.0", + Accept: "application/xml, text/xml, text/x-opml, */*", + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const content = await response.text(); + return parseOpml(content); + } catch (error) { + clearTimeout(timeoutId); + if (error.name === "AbortError") { + throw new Error("Request timed out"); + } + throw error; + } +} + +/** + * Sync blogs from an OPML source + * @param {object} application - Application instance + * @param {object} source - Source document + * @returns {Promise} Sync result + */ +export async function syncOpmlSource(application, source) { + let blogs; + + try { + if (source.type === "opml_url") { + blogs = await fetchAndParseOpml(source.url); + } else if (source.type === "opml_file") { + blogs = await parseOpml(source.opmlContent); + } else { + throw new Error(`Unsupported source type: ${source.type}`); + } + + let added = 0; + let updated = 0; + + for (const blog of blogs) { + const result = await upsertBlog(application, { + ...blog, + sourceId: source._id, + }); + + if (result.upserted) added++; + else if (result.modified) updated++; + } + + // Update source sync status + await updateSourceSyncStatus(application, source._id, { success: true }); + + console.log( + `[Blogroll] Synced OPML source "${source.name}": ${added} added, ${updated} updated, ${blogs.length} total` + ); + + return { success: true, added, updated, total: blogs.length }; + } catch (error) { + // Update source with error status + await updateSourceSyncStatus(application, source._id, { + success: false, + error: error.message, + }); + + console.error(`[Blogroll] OPML sync failed for "${source.name}":`, error.message); + return { success: false, error: error.message }; + } +} + +/** + * Generate OPML XML from blogs + * @param {Array} blogs - Array of blog objects + * @param {string} title - OPML title + * @returns {string} OPML XML + */ +export function generateOpml(blogs, title = "Blogroll") { + // Group blogs by category + const grouped = {}; + for (const blog of blogs) { + const cat = blog.category || "Uncategorized"; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(blog); + } + + let outlines = ""; + for (const [category, categoryBlogs] of Object.entries(grouped)) { + const children = categoryBlogs + .map( + (b) => + ` ` + ) + .join("\n"); + outlines += ` \n${children}\n \n`; + } + + return ` + + + ${escapeXml(title)} + ${new Date().toUTCString()} + + +${outlines} +`; +} + +/** + * Escape XML special characters + * @param {string} str - String to escape + * @returns {string} Escaped string + */ +function escapeXml(str) { + if (!str) return ""; + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/lib/sync/scheduler.js b/lib/sync/scheduler.js new file mode 100644 index 0000000..d1a9646 --- /dev/null +++ b/lib/sync/scheduler.js @@ -0,0 +1,232 @@ +/** + * Background sync scheduler + * @module sync/scheduler + */ + +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 { syncBlogItems } from "./feed.js"; + +let syncInterval = null; +let isRunning = false; + +/** + * Run full sync of all sources and blogs + * @param {object} application - Application instance + * @param {object} options - Sync options + * @returns {Promise} Sync results + */ +export async function runFullSync(application, options = {}) { + const { + maxItemsPerBlog = 50, + fetchTimeout = 15000, + maxItemAge = 7, // days - encourage discovery with fresh content + } = options; + + if (isRunning) { + console.log("[Blogroll] Sync already running, skipping"); + return { skipped: true }; + } + + isRunning = true; + console.log("[Blogroll] Starting full sync..."); + const startTime = Date.now(); + + try { + // First, clean up old items to encourage discovery + const deletedItems = await deleteOldItems(application, maxItemAge); + + // Sync all enabled OPML/JSON sources + const sources = await getSources(application); + const enabledSources = sources.filter( + (s) => s.enabled && ["opml_url", "opml_file", "json_feed"].includes(s.type) + ); + + let sourcesSuccess = 0; + let sourcesFailed = 0; + + for (const source of enabledSources) { + try { + const result = await syncOpmlSource(application, source); + if (result.success) sourcesSuccess++; + else sourcesFailed++; + } catch (error) { + console.error(`[Blogroll] Source sync failed (${source.name}):`, error.message); + sourcesFailed++; + } + } + + // Sync all non-hidden blogs + const blogs = await getBlogs(application, { includeHidden: false, limit: 1000 }); + + let blogsSuccess = 0; + let blogsFailed = 0; + let newItems = 0; + + for (const blog of blogs) { + try { + const result = await syncBlogItems(application, blog, { + maxItems: maxItemsPerBlog, + timeout: fetchTimeout, + }); + + if (result.success) { + blogsSuccess++; + newItems += result.added || 0; + } else { + blogsFailed++; + } + } catch (error) { + console.error(`[Blogroll] Blog sync failed (${blog.title}):`, error.message); + blogsFailed++; + } + } + + const duration = Date.now() - startTime; + + // Update sync stats in meta collection + const db = application.getBlogrollDb(); + await db.collection("blogrollMeta").updateOne( + { key: "syncStats" }, + { + $set: { + key: "syncStats", + lastFullSync: new Date(), + duration, + sources: { + total: enabledSources.length, + success: sourcesSuccess, + failed: sourcesFailed, + }, + blogs: { + total: blogs.length, + success: blogsSuccess, + failed: blogsFailed, + }, + items: { + added: newItems, + deleted: deletedItems, + }, + }, + }, + { upsert: true } + ); + + console.log( + `[Blogroll] Full sync complete in ${duration}ms: ` + + `${sourcesSuccess}/${enabledSources.length} sources, ` + + `${blogsSuccess}/${blogs.length} blogs, ` + + `${newItems} new items, ${deletedItems} old items removed` + ); + + return { + success: true, + duration, + sources: { total: enabledSources.length, success: sourcesSuccess, failed: sourcesFailed }, + blogs: { total: blogs.length, success: blogsSuccess, failed: blogsFailed }, + items: { added: newItems, deleted: deletedItems }, + }; + } catch (error) { + console.error("[Blogroll] Full sync failed:", error.message); + return { success: false, error: error.message }; + } finally { + isRunning = false; + } +} + +/** + * Get sync status + * @param {object} application - Application instance + * @returns {Promise} Status info + */ +export async function getSyncStatus(application) { + const db = application.getBlogrollDb(); + + const [blogCount, itemCount, syncStats] = await Promise.all([ + countBlogs(application), + countItems(application), + db.collection("blogrollMeta").findOne({ key: "syncStats" }), + ]); + + return { + status: "ok", + isRunning, + blogs: { count: blogCount }, + items: { count: itemCount }, + lastSync: syncStats?.lastFullSync || null, + lastSyncStats: syncStats || null, + }; +} + +/** + * Clear all data and resync + * @param {object} application - Application instance + * @param {object} options - Options + * @returns {Promise} Result + */ +export async function clearAndResync(application, options = {}) { + const db = application.getBlogrollDb(); + + console.log("[Blogroll] Clearing all items for resync..."); + + // Clear all items (but keep blogs and sources) + await db.collection("blogrollItems").deleteMany({}); + + // Reset blog item counts and status + await db.collection("blogrollBlogs").updateMany( + {}, + { + $set: { + itemCount: 0, + lastFetchAt: null, + status: "active", + lastError: null, + }, + } + ); + + // Run full sync + return runFullSync(application, options); +} + +/** + * Start background sync scheduler + * @param {object} Indiekit - Indiekit instance + * @param {object} options - Options + */ +export function startSync(Indiekit, options) { + const { syncInterval: interval, maxItemAge = 7 } = options; + const application = Indiekit.config.application; + + // Initial sync after short delay (let server start up) + setTimeout(async () => { + if (application.getBlogrollDb()) { + console.log("[Blogroll] Running initial sync..."); + await runFullSync(application, { ...options, maxItemAge }); + } + }, 15000); + + // Periodic sync + syncInterval = setInterval(async () => { + if (application.getBlogrollDb()) { + await runFullSync(application, { ...options, maxItemAge }); + } + }, interval); + + console.log( + `[Blogroll] Scheduler started (interval: ${Math.round(interval / 60000)}min, item retention: ${maxItemAge} days)` + ); +} + +/** + * Stop background sync scheduler + */ +export function stopSync() { + if (syncInterval) { + clearInterval(syncInterval); + syncInterval = null; + console.log("[Blogroll] Scheduler stopped"); + } +} diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..75656e5 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,122 @@ +{ + "blogroll": { + "title": "Blogroll", + "description": "Manage your blogroll sources and blogs", + "enabled": "Enabled", + "disabled": "Disabled", + "edit": "Edit", + "sync": "Sync", + "refresh": "Refresh", + "cancel": "Cancel", + "never": "Never", + + "stats": { + "title": "Overview", + "sources": "Sources", + "blogs": "Blogs", + "items": "Items", + "errors": "Errors", + "lastSync": "Last Sync" + }, + + "actions": { + "title": "Actions", + "syncNow": "Sync All Now", + "clearResync": "Clear & Re-sync", + "clearConfirm": "This will delete all cached items and re-fetch everything. Continue?" + }, + + "errors": { + "title": "Blogs with Errors", + "seeAll": "See all %{count} blogs with errors" + }, + + "sources": { + "title": "Sources", + "manage": "Manage Sources", + "add": "Add Source", + "new": "New Source", + "edit": "Edit Source", + "create": "Create Source", + "save": "Save Source", + "empty": "No sources configured yet.", + "recent": "Recent Sources", + "interval": "Every %{minutes} min", + "lastSync": "Last synced", + "deleteConfirm": "Delete this source? Blogs imported from it will remain.", + "created": "Source created successfully.", + "created_synced": "Source created and synced successfully.", + "created_sync_failed": "Source created, but sync failed: %{error}", + "updated": "Source updated successfully.", + "deleted": "Source deleted successfully.", + "synced": "Synced successfully. Added: %{added}, Updated: %{updated}", + "form": { + "name": "Name", + "type": "Type", + "typeHint": "How to import blogs from this source", + "url": "OPML URL", + "urlHint": "URL of the OPML file to import", + "opmlContent": "OPML Content", + "opmlContentHint": "Paste the full OPML XML content here", + "syncInterval": "Sync Interval", + "enabled": "Enable automatic syncing" + } + }, + + "blogs": { + "title": "Blogs", + "manage": "Manage Blogs", + "add": "Add Blog", + "new": "New Blog", + "edit": "Edit Blog", + "create": "Add Blog", + "save": "Save Blog", + "empty": "No blogs yet. Add one or import from an OPML source.", + "recent": "Recent Blogs", + "pinned": "Pinned", + "hidden": "Hidden", + "noItems": "No items fetched yet.", + "recentItems": "Recent Items", + "allCategories": "All Categories", + "allStatuses": "All Statuses", + "statusActive": "Active", + "statusError": "Error", + "statusPending": "Pending", + "clearFilters": "Clear filters", + "deleteConfirm": "Delete this blog and all its cached items?", + "created": "Blog added successfully.", + "created_synced": "Blog added and synced. Fetched %{items} items.", + "created_sync_failed": "Blog added, but initial fetch failed: %{error}", + "updated": "Blog updated successfully.", + "deleted": "Blog deleted successfully.", + "refreshed": "Blog refreshed. Added %{items} new items.", + "form": { + "feedUrl": "Feed URL", + "feedUrlHint": "RSS, Atom, or JSON Feed URL", + "title": "Title", + "titlePlaceholder": "Auto-detected from feed", + "titleHint": "Leave blank to use the feed title", + "siteUrl": "Site URL", + "siteUrlHint": "Link to the blog's homepage (optional)", + "category": "Category", + "categoryHint": "Group blogs by category for filtering and OPML export", + "tags": "Tags", + "tagsHint": "Comma-separated tags for additional organization", + "notes": "Notes", + "notesPlaceholder": "Why you follow this blog...", + "notesHint": "Personal notes (not shown publicly)", + "pinned": "Pin this blog (show at top of lists)", + "hidden": "Hide from public API (visible only to you)" + } + }, + + "api": { + "title": "API Endpoints", + "blogs": "List all blogs with metadata", + "items": "List recent items from all blogs", + "categories": "List all categories", + "opml": "Export as OPML", + "status": "Sync status and statistics" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dcaf96d --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "@rmdes/indiekit-endpoint-blogroll", + "version": "1.0.0", + "description": "Blogroll endpoint for Indiekit. Aggregates blog feeds from OPML, JSON feeds, or manual entry.", + "keywords": [ + "indiekit", + "indiekit-plugin", + "indieweb", + "blogroll", + "opml", + "rss", + "feeds" + ], + "homepage": "https://github.com/rmdes/indiekit-endpoint-blogroll", + "bugs": { + "url": "https://github.com/rmdes/indiekit-endpoint-blogroll/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rmdes/indiekit-endpoint-blogroll.git" + }, + "author": { + "name": "Ricardo Mendes", + "url": "https://rmendes.net" + }, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js" + }, + "files": [ + "lib", + "locales", + "views", + "assets", + "index.js" + ], + "dependencies": { + "@indiekit/error": "^1.0.0-beta.25", + "@indiekit/frontend": "^1.0.0-beta.25", + "express": "^5.0.0", + "feedparser": "^2.2.10", + "sanitize-html": "^2.13.0", + "xml2js": "^0.6.2" + }, + "peerDependencies": { + "@indiekit/indiekit": ">=1.0.0-beta.25" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/views/blog-edit.njk b/views/blog-edit.njk new file mode 100644 index 0000000..b30a555 --- /dev/null +++ b/views/blog-edit.njk @@ -0,0 +1,199 @@ +{% extends "document.njk" %} + +{% block content %} + + + + +{% for message in request.session.messages %} +
+

{{ message.content }}

+
+{% endfor %} + +
+
+ + + {{ __("blogroll.blogs.form.feedUrlHint") }} +
+ +
+ + + {{ __("blogroll.blogs.form.titleHint") }} +
+ +
+ + + {{ __("blogroll.blogs.form.siteUrlHint") }} +
+ +
+ + + {{ __("blogroll.blogs.form.categoryHint") }} +
+ +
+ + + {{ __("blogroll.blogs.form.tagsHint") }} +
+ +
+ + + {{ __("blogroll.blogs.form.notesHint") }} +
+ +
+ + +
+ +
+ + +
+ +
+ + {{ __("blogroll.cancel") }} +
+
+ +{% if not isNew and items %} +
+

{{ __("blogroll.blogs.recentItems") }}

+ {% if items.length > 0 %} +
    + {% for item in items %} +
  • + +
    + {{ item.published | date("PPpp") }} +
    +
  • + {% endfor %} +
+ {% else %} +

{{ __("blogroll.blogs.noItems") }}

+ {% endif %} +
+{% endif %} +{% endblock %} diff --git a/views/blogs.njk b/views/blogs.njk new file mode 100644 index 0000000..f572c3a --- /dev/null +++ b/views/blogs.njk @@ -0,0 +1,205 @@ +{% extends "document.njk" %} + +{% block content %} + + + + +{% for message in request.session.messages %} +
+

{{ message.content }}

+
+{% endfor %} + +
+
+
+ + + {% if filterCategory or filterStatus %} + {{ __("blogroll.blogs.clearFilters") }} + {% endif %} +
+
+ + + + {% if blogs.length > 0 %} +
    + {% for blog in blogs %} +
  • +
    +

    + {% if blog.siteUrl %} + {{ blog.title }} + {% else %} + {{ blog.title }} + {% endif %} +

    +

    + + {{ blog.status }} + + {% if blog.category %} + {{ blog.category }} + {% endif %} + • {{ blog.itemCount or 0 }} items + {% if blog.pinned %} + • {{ __("blogroll.blogs.pinned") }} + {% endif %} + {% if blog.hidden %} + • {{ __("blogroll.blogs.hidden") }} + {% endif %} +

    +

    {{ blog.feedUrl }}

    + {% if blog.lastError %} +

    {{ blog.lastError }}

    + {% endif %} +
    +
    +
    + +
    + + {{ icon("updatePost") }} {{ __("blogroll.edit") }} + +
    + +
    +
    +
  • + {% endfor %} +
+ {% else %} +
+

{{ __("blogroll.blogs.empty") }}

+

{{ __("blogroll.blogs.add") }}

+
+ {% endif %} +
+{% endblock %} diff --git a/views/dashboard.njk b/views/dashboard.njk new file mode 100644 index 0000000..0074eeb --- /dev/null +++ b/views/dashboard.njk @@ -0,0 +1,273 @@ +{% extends "document.njk" %} + +{% block content %} + + + + +{% for message in request.session.messages %} +
+

{{ message.content }}

+
+{% endfor %} + +
+
+

{{ __("blogroll.stats.title") }}

+
+
+
{{ __("blogroll.stats.sources") }}
+
{{ stats.sources }}
+
+
+
{{ __("blogroll.stats.blogs") }}
+
{{ stats.blogs }}
+
+
+
{{ __("blogroll.stats.items") }}
+
{{ stats.items }}
+
+
+
{{ __("blogroll.stats.errors") }}
+
{{ stats.errors }}
+
+
+
{{ __("blogroll.stats.lastSync") }}
+
{% if syncStatus.lastSync %}{{ syncStatus.lastSync | date("PPpp") }}{% else %}{{ __("blogroll.never") }}{% endif %}
+
+
+ + +
+ +
+

{{ __("blogroll.actions.title") }}

+
+
+ +
+
+ +
+
+
+ + {% if blogsWithErrors.length > 0 %} +
+

{{ __("blogroll.errors.title") }}

+ + {% if stats.errors > blogsWithErrors.length %} +

+ {{ __("blogroll.errors.seeAll", { count: stats.errors }) }} +

+ {% endif %} +
+ {% endif %} + +
+

{{ __("blogroll.sources.recent") }}

+ {% if sources.length > 0 %} +
    + {% for source in sources %} +
  • +
    + {{ source.name }} + {{ source.type }} • {% if source.lastSyncAt %}{{ source.lastSyncAt | date("PP") }}{% else %}{{ __("blogroll.never") }}{% endif %} +
    + + {% if source.enabled %}{{ __("blogroll.enabled") }}{% else %}{{ __("blogroll.disabled") }}{% endif %} + +
  • + {% endfor %} +
+ {% else %} +

{{ __("blogroll.sources.empty") }}

+ {% endif %} + +
+ +
+

{{ __("blogroll.blogs.recent") }}

+ {% if recentBlogs.length > 0 %} +
    + {% for blog in recentBlogs %} +
  • +
    + {{ blog.title }} + {{ blog.category or "Uncategorized" }} • {{ blog.itemCount }} items +
    + + {{ blog.status }} + +
  • + {% endfor %} +
+ {% else %} +

{{ __("blogroll.blogs.empty") }}

+ {% endif %} + +
+ +
+

{{ __("blogroll.api.title") }}

+
    +
  • GET {{ baseUrl }}/api/blogs - {{ __("blogroll.api.blogs") }}
  • +
  • GET {{ baseUrl }}/api/items - {{ __("blogroll.api.items") }}
  • +
  • GET {{ baseUrl }}/api/categories - {{ __("blogroll.api.categories") }}
  • +
  • GET {{ baseUrl }}/api/opml - {{ __("blogroll.api.opml") }}
  • +
  • GET {{ baseUrl }}/api/status - {{ __("blogroll.api.status") }}
  • +
+
+
+{% endblock %} diff --git a/views/source-edit.njk b/views/source-edit.njk new file mode 100644 index 0000000..595e697 --- /dev/null +++ b/views/source-edit.njk @@ -0,0 +1,146 @@ +{% extends "document.njk" %} + +{% block content %} + + + + +{% for message in request.session.messages %} +
+

{{ message.content }}

+
+{% endfor %} + +
+
+ + +
+ +
+ + + {{ __("blogroll.sources.form.typeHint") }} +
+ +
+ + + {{ __("blogroll.sources.form.urlHint") }} +
+ + + +
+ + +
+ +
+ + +
+ +
+ + {{ __("blogroll.cancel") }} +
+
+ + +{% endblock %} diff --git a/views/sources.njk b/views/sources.njk new file mode 100644 index 0000000..978a467 --- /dev/null +++ b/views/sources.njk @@ -0,0 +1,139 @@ +{% extends "document.njk" %} + +{% block content %} + + + + +{% for message in request.session.messages %} +
+

{{ message.content }}

+
+{% endfor %} + +
+ + + {% if sources.length > 0 %} +
    + {% for source in sources %} +
  • +
    +

    {{ source.name }}

    +

    + + {% if source.enabled %}{{ __("blogroll.enabled") }}{% else %}{{ __("blogroll.disabled") }}{% endif %} + + {{ source.type }} + • {{ __("blogroll.sources.interval", { minutes: source.syncInterval }) }} +

    + {% if source.url %} +

    {{ source.url }}

    + {% endif %} + {% if source.lastSyncError %} +

    {{ source.lastSyncError }}

    + {% endif %} +

    + {{ __("blogroll.sources.lastSync") }}: + {% if source.lastSyncAt %}{{ source.lastSyncAt | date("PPpp") }}{% else %}{{ __("blogroll.never") }}{% endif %} +

    +
    +
    +
    + +
    + + {{ icon("updatePost") }} {{ __("blogroll.edit") }} + +
    + +
    +
    +
  • + {% endfor %} +
+ {% else %} +
+

{{ __("blogroll.sources.empty") }}

+

{{ __("blogroll.sources.add") }}

+
+ {% endif %} +
+{% endblock %}