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 <noreply@anthropic.com>
This commit is contained in:
Ricardo
2026-02-07 09:55:53 +01:00
commit 8344a59b76
18 changed files with 3623 additions and 0 deletions

138
index.js Normal file
View File

@@ -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();
}
}

240
lib/controllers/api.js Normal file
View File

@@ -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,
};

298
lib/controllers/blogs.js Normal file
View File

@@ -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,
};

View File

@@ -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,
};

269
lib/controllers/sources.js Normal file
View File

@@ -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,
};

277
lib/storage/blogs.js Normal file
View File

@@ -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<Array>} 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<number>} 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<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 });
}
/**
* 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
* @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 });
// 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<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 },
$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 }, 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();
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,
};
}

180
lib/storage/items.js Normal file
View File

@@ -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<Array>} 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<Array>} 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<number>} 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<object>} Result with upserted flag
*/
export async function upsertItem(application, data) {
const collection = getCollection(application);
const now = new Date();
const result = await collection.updateOne(
{ blogId: new ObjectId(data.blogId), uid: data.uid },
{
$set: {
url: data.url,
title: data.title,
content: data.content,
summary: data.summary,
published: data.published,
updated: data.updated,
author: data.author,
photo: data.photo,
categories: data.categories || [],
fetchedAt: now,
},
$setOnInsert: {
blogId: new ObjectId(data.blogId),
uid: data.uid,
},
},
{ upsert: true }
);
return {
upserted: result.upsertedCount > 0,
modified: result.modifiedCount > 0,
};
}
/**
* Delete items for a blog
* @param {object} application - Application instance
* @param {string|ObjectId} blogId - Blog ID
* @returns {Promise<number>} Deleted count
*/
export async function deleteItemsForBlog(application, blogId) {
const collection = getCollection(application);
const objectId = typeof blogId === "string" ? new ObjectId(blogId) : blogId;
const result = await collection.deleteMany({ blogId: objectId });
return result.deletedCount;
}
/**
* Delete old items beyond retention period
* This encourages discovery by showing only recent content
* @param {object} application - Application instance
* @param {number} maxAgeDays - Max age in days (default 7)
* @returns {Promise<number>} Deleted count
*/
export async function deleteOldItems(application, maxAgeDays = 7) {
const collection = getCollection(application);
const cutoff = new Date(Date.now() - maxAgeDays * 24 * 60 * 60 * 1000);
const result = await collection.deleteMany({
published: { $lt: cutoff },
});
if (result.deletedCount > 0) {
console.log(`[Blogroll] Cleaned up ${result.deletedCount} items older than ${maxAgeDays} days`);
}
return result.deletedCount;
}
/**
* Get item by ID
* @param {object} application - Application instance
* @param {string|ObjectId} id - Item ID
* @returns {Promise<object|null>} Item or null
*/
export async function getItem(application, id) {
const collection = getCollection(application);
const objectId = typeof id === "string" ? new ObjectId(id) : id;
return collection.findOne({ _id: objectId });
}

174
lib/storage/sources.js Normal file
View File

@@ -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<Array>} 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<object|null>} 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<object>} 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<object|null>} 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<boolean>} 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<Array>} 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();
}

318
lib/sync/feed.js Normal file
View File

@@ -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<object>} 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<object>} 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<object>} 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 };
}
}

208
lib/sync/opml.js Normal file
View File

@@ -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>} 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>} 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<object>} 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) =>
` <outline text="${escapeXml(b.title)}" type="rss" xmlUrl="${escapeXml(b.feedUrl)}" htmlUrl="${escapeXml(b.siteUrl || "")}"/>`
)
.join("\n");
outlines += ` <outline text="${escapeXml(category)}">\n${children}\n </outline>\n`;
}
return `<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
<head>
<title>${escapeXml(title)}</title>
<dateCreated>${new Date().toUTCString()}</dateCreated>
</head>
<body>
${outlines} </body>
</opml>`;
}
/**
* 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

232
lib/sync/scheduler.js Normal file
View File

@@ -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<object>} 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<object>} 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<object>} 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");
}
}

122
locales/en.json Normal file
View File

@@ -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"
}
}
}

56
package.json Normal file
View File

@@ -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"
}
}

199
views/blog-edit.njk Normal file
View File

@@ -0,0 +1,199 @@
{% extends "document.njk" %}
{% block content %}
<style>
.br-form {
max-width: 600px;
}
.br-field {
display: flex;
flex-direction: column;
gap: var(--space-2xs, 0.25rem);
margin-block-end: var(--space-m, 1rem);
}
.br-field label {
font: var(--font-label, bold 0.875rem/1.4 sans-serif);
}
.br-field-hint {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
}
.br-field input,
.br-field select,
.br-field textarea {
appearance: none;
background-color: var(--color-background, #fff);
border: 1px solid var(--color-outline-variant, #ccc);
border-radius: var(--border-radius-small, 0.25rem);
font: var(--font-body, 0.875rem/1.4 sans-serif);
padding: calc(var(--space-s, 0.75rem) / 2) var(--space-s, 0.75rem);
width: 100%;
}
.br-field textarea {
min-height: 100px;
}
.br-field input:focus,
.br-field select:focus,
.br-field textarea:focus {
border-color: var(--color-primary, #0066cc);
outline: 2px solid var(--color-primary, #0066cc);
outline-offset: 1px;
}
.br-field-inline {
flex-direction: row;
align-items: center;
gap: var(--space-s, 0.75rem);
}
.br-field-inline input[type="checkbox"] {
width: auto;
}
.br-section {
margin-block-start: var(--space-l, 1.5rem);
padding-block-start: var(--space-l, 1.5rem);
border-block-start: 1px solid var(--color-outline-variant, #ddd);
}
.br-section h3 {
font: var(--font-subhead, bold 1rem/1.4 sans-serif);
margin-block-end: var(--space-s, 0.75rem);
}
.br-items-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs, 0.5rem);
}
.br-item {
background: var(--color-offset, #f5f5f5);
border-radius: var(--border-radius-small, 0.25rem);
padding: var(--space-xs, 0.5rem) var(--space-s, 0.75rem);
}
.br-item-title {
font: var(--font-body, 0.875rem/1.4 sans-serif);
font-weight: 600;
}
.br-item-title a {
color: inherit;
text-decoration: none;
}
.br-item-title a:hover {
text-decoration: underline;
}
.br-item-meta {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.75rem/1.4 sans-serif);
}
.br-empty {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
text-align: center;
padding: var(--space-m, 1rem);
}
</style>
<header class="page-header">
<a href="{{ baseUrl }}/blogs" class="page-header__back">{{ icon("previous") }} {{ __("blogroll.blogs.title") }}</a>
<h1 class="page-header__title">{{ title }}</h1>
</header>
{% for message in request.session.messages %}
<div class="notice notice--{{ message.type }}">
<p>{{ message.content }}</p>
</div>
{% endfor %}
<form method="post" action="{% if isNew %}{{ baseUrl }}/blogs{% else %}{{ baseUrl }}/blogs/{{ blog._id }}{% endif %}" class="br-form">
<div class="br-field">
<label for="feedUrl">{{ __("blogroll.blogs.form.feedUrl") }}</label>
<input type="url" id="feedUrl" name="feedUrl" value="{{ blog.feedUrl if blog else '' }}" required placeholder="https://example.com/feed.xml">
<span class="br-field-hint">{{ __("blogroll.blogs.form.feedUrlHint") }}</span>
</div>
<div class="br-field">
<label for="title">{{ __("blogroll.blogs.form.title") }}</label>
<input type="text" id="title" name="title" value="{{ blog.title if blog else '' }}" placeholder="{{ __('blogroll.blogs.form.titlePlaceholder') }}">
<span class="br-field-hint">{{ __("blogroll.blogs.form.titleHint") }}</span>
</div>
<div class="br-field">
<label for="siteUrl">{{ __("blogroll.blogs.form.siteUrl") }}</label>
<input type="url" id="siteUrl" name="siteUrl" value="{{ blog.siteUrl if blog else '' }}" placeholder="https://example.com">
<span class="br-field-hint">{{ __("blogroll.blogs.form.siteUrlHint") }}</span>
</div>
<div class="br-field">
<label for="category">{{ __("blogroll.blogs.form.category") }}</label>
<input type="text" id="category" name="category" value="{{ blog.category if blog else '' }}" placeholder="Technology">
<span class="br-field-hint">{{ __("blogroll.blogs.form.categoryHint") }}</span>
</div>
<div class="br-field">
<label for="tags">{{ __("blogroll.blogs.form.tags") }}</label>
<input type="text" id="tags" name="tags" value="{{ blog.tags | join(', ') if blog and blog.tags else '' }}" placeholder="indie, personal, tech">
<span class="br-field-hint">{{ __("blogroll.blogs.form.tagsHint") }}</span>
</div>
<div class="br-field">
<label for="notes">{{ __("blogroll.blogs.form.notes") }}</label>
<textarea id="notes" name="notes" placeholder="{{ __('blogroll.blogs.form.notesPlaceholder') }}">{{ blog.notes if blog else '' }}</textarea>
<span class="br-field-hint">{{ __("blogroll.blogs.form.notesHint") }}</span>
</div>
<div class="br-field br-field-inline">
<input type="checkbox" id="pinned" name="pinned" {% if blog and blog.pinned %}checked{% endif %}>
<label for="pinned">{{ __("blogroll.blogs.form.pinned") }}</label>
</div>
<div class="br-field br-field-inline">
<input type="checkbox" id="hidden" name="hidden" {% if blog and blog.hidden %}checked{% endif %}>
<label for="hidden">{{ __("blogroll.blogs.form.hidden") }}</label>
</div>
<div class="button-group">
<button type="submit" class="button button--primary">
{% if isNew %}{{ __("blogroll.blogs.create") }}{% else %}{{ __("blogroll.blogs.save") }}{% endif %}
</button>
<a href="{{ baseUrl }}/blogs" class="button button--secondary">{{ __("blogroll.cancel") }}</a>
</div>
</form>
{% if not isNew and items %}
<div class="br-section">
<h3>{{ __("blogroll.blogs.recentItems") }}</h3>
{% if items.length > 0 %}
<ul class="br-items-list">
{% for item in items %}
<li class="br-item">
<div class="br-item-title">
<a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.title }}</a>
</div>
<div class="br-item-meta">
{{ item.published | date("PPpp") }}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="br-empty">{{ __("blogroll.blogs.noItems") }}</p>
{% endif %}
</div>
{% endif %}
{% endblock %}

205
views/blogs.njk Normal file
View File

@@ -0,0 +1,205 @@
{% extends "document.njk" %}
{% block content %}
<style>
.br-blogs {
display: flex;
flex-direction: column;
gap: var(--space-m, 1.5rem);
}
.br-filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-s, 0.75rem);
align-items: center;
}
.br-filter-select {
appearance: none;
background-color: var(--color-background, #fff);
border: 1px solid var(--color-outline-variant, #ccc);
border-radius: var(--border-radius-small, 0.25rem);
font: var(--font-body, 0.875rem/1.4 sans-serif);
padding: calc(var(--space-s, 0.75rem) / 2) var(--space-s, 0.75rem);
min-width: 150px;
}
.br-blogs-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-s, 0.75rem);
}
.br-blog-item {
background: var(--color-offset, #f5f5f5);
border-radius: var(--border-radius-small, 0.5rem);
padding: var(--space-m, 1rem);
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-s, 0.75rem);
}
.br-blog-item--pinned {
border-left: 3px solid var(--color-primary, #0066cc);
}
.br-blog-item--hidden {
opacity: 0.6;
}
.br-blog-info {
flex: 1;
min-width: 200px;
}
.br-blog-title {
font: var(--font-subhead, bold 1rem/1.4 sans-serif);
margin-block-end: var(--space-2xs, 0.25rem);
}
.br-blog-title a {
color: inherit;
text-decoration: none;
}
.br-blog-title a:hover {
text-decoration: underline;
}
.br-blog-meta {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
display: flex;
flex-wrap: wrap;
gap: var(--space-xs, 0.5rem);
align-items: center;
}
.br-blog-url {
color: var(--color-primary, #0066cc);
font: var(--font-caption, 0.75rem/1.4 monospace);
word-break: break-all;
margin-block-start: var(--space-2xs, 0.25rem);
}
.br-blog-error {
color: var(--color-error, #dc3545);
font: var(--font-caption, 0.75rem/1.4 sans-serif);
margin-block-start: var(--space-2xs, 0.25rem);
}
.br-blog-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs, 0.5rem);
}
.br-empty {
text-align: center;
padding: var(--space-xl, 2rem);
color: var(--color-on-offset, #666);
}
</style>
<header class="page-header">
<a href="{{ baseUrl }}" class="page-header__back">{{ icon("previous") }} {{ __("blogroll.title") }}</a>
<h1 class="page-header__title">{{ __("blogroll.blogs.title") }}</h1>
</header>
{% for message in request.session.messages %}
<div class="notice notice--{{ message.type }}">
<p>{{ message.content }}</p>
</div>
{% endfor %}
<div class="br-blogs">
<div class="br-filters">
<form method="get" action="{{ baseUrl }}/blogs" style="display: flex; gap: var(--space-s, 0.75rem); flex-wrap: wrap; align-items: center;">
<select name="category" class="br-filter-select" onchange="this.form.submit()">
<option value="">{{ __("blogroll.blogs.allCategories") }}</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if filterCategory == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
<select name="status" class="br-filter-select" onchange="this.form.submit()">
<option value="">{{ __("blogroll.blogs.allStatuses") }}</option>
<option value="active" {% if filterStatus == 'active' %}selected{% endif %}>{{ __("blogroll.blogs.statusActive") }}</option>
<option value="error" {% if filterStatus == 'error' %}selected{% endif %}>{{ __("blogroll.blogs.statusError") }}</option>
<option value="pending" {% if filterStatus == 'pending' %}selected{% endif %}>{{ __("blogroll.blogs.statusPending") }}</option>
</select>
{% if filterCategory or filterStatus %}
<a href="{{ baseUrl }}/blogs" class="button button--small button--secondary">{{ __("blogroll.blogs.clearFilters") }}</a>
{% endif %}
</form>
</div>
<div class="button-group">
<a href="{{ baseUrl }}/blogs/new" class="button button--primary">
{{ __("blogroll.blogs.add") }}
</a>
</div>
{% if blogs.length > 0 %}
<ul class="br-blogs-list">
{% for blog in blogs %}
<li class="br-blog-item {% if blog.pinned %}br-blog-item--pinned{% endif %} {% if blog.hidden %}br-blog-item--hidden{% endif %}">
<div class="br-blog-info">
<h2 class="br-blog-title">
{% if blog.siteUrl %}
<a href="{{ blog.siteUrl }}" target="_blank" rel="noopener">{{ blog.title }}</a>
{% else %}
{{ blog.title }}
{% endif %}
</h2>
<p class="br-blog-meta">
<span class="badge {% if blog.status == 'active' %}badge--green{% elif blog.status == 'error' %}badge--red{% else %}badge--yellow{% endif %}">
{{ blog.status }}
</span>
{% if blog.category %}
<span>{{ blog.category }}</span>
{% endif %}
<span>• {{ blog.itemCount or 0 }} items</span>
{% if blog.pinned %}
<span>• {{ __("blogroll.blogs.pinned") }}</span>
{% endif %}
{% if blog.hidden %}
<span>• {{ __("blogroll.blogs.hidden") }}</span>
{% endif %}
</p>
<p class="br-blog-url">{{ blog.feedUrl }}</p>
{% if blog.lastError %}
<p class="br-blog-error">{{ blog.lastError }}</p>
{% endif %}
</div>
<div class="br-blog-actions">
<form method="post" action="{{ baseUrl }}/blogs/{{ blog._id }}/refresh" style="display: inline;">
<button type="submit" class="button button--small button--secondary">
{{ icon("syndicate") }} {{ __("blogroll.refresh") }}
</button>
</form>
<a href="{{ baseUrl }}/blogs/{{ blog._id }}" class="button button--small button--secondary">
{{ icon("updatePost") }} {{ __("blogroll.edit") }}
</a>
<form method="post" action="{{ baseUrl }}/blogs/{{ blog._id }}/delete" style="display: inline;" onsubmit="return confirm('{{ __("blogroll.blogs.deleteConfirm") }}');">
<button type="submit" class="button button--small button--warning">
{{ icon("delete") }}
</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="br-empty">
<p>{{ __("blogroll.blogs.empty") }}</p>
<p><a href="{{ baseUrl }}/blogs/new" class="button button--primary">{{ __("blogroll.blogs.add") }}</a></p>
</div>
{% endif %}
</div>
{% endblock %}

273
views/dashboard.njk Normal file
View File

@@ -0,0 +1,273 @@
{% extends "document.njk" %}
{% block content %}
<style>
.br-dashboard {
display: flex;
flex-direction: column;
gap: var(--space-xl, 2rem);
}
.br-section {
background: var(--color-offset, #f5f5f5);
border-radius: var(--border-radius-small, 0.5rem);
padding: var(--space-m, 1.5rem);
}
.br-section h2 {
font: var(--font-heading, bold 1.25rem/1.4 sans-serif);
margin-block-end: var(--space-s, 0.75rem);
padding-block-end: var(--space-xs, 0.5rem);
border-block-end: 1px solid var(--color-outline-variant, #ddd);
}
.br-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-s, 0.75rem);
}
.br-stat {
background: var(--color-background, #fff);
border-radius: var(--border-radius-small, 0.5rem);
padding: var(--space-s, 0.75rem);
text-align: center;
}
.br-stat dt {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
margin-block-end: var(--space-2xs, 0.25rem);
}
.br-stat dd {
font: var(--font-subhead, bold 1.125rem/1.4 sans-serif);
margin: 0;
}
.br-stat dd.br-stat--error {
color: var(--color-error, #dc3545);
}
.br-quick-links {
display: flex;
flex-wrap: wrap;
gap: var(--space-s, 0.75rem);
margin-block-start: var(--space-m, 1rem);
}
.br-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs, 0.5rem);
}
.br-list li {
background: var(--color-background, #fff);
border-radius: var(--border-radius-small, 0.25rem);
padding: var(--space-xs, 0.5rem) var(--space-s, 0.75rem);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs, 0.5rem);
}
.br-list-name {
font: var(--font-body, 0.875rem/1.4 sans-serif);
font-weight: 600;
}
.br-list-meta {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.75rem/1.4 sans-serif);
}
.br-list-error {
color: var(--color-error, #dc3545);
font: var(--font-caption, 0.75rem/1.4 sans-serif);
}
.br-api-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs, 0.5rem);
}
.br-api-list li {
background: var(--color-background, #fff);
border-radius: var(--border-radius-small, 0.25rem);
padding: var(--space-xs, 0.5rem) var(--space-s, 0.75rem);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
}
.br-api-list code {
font-weight: 600;
color: var(--color-primary, #0066cc);
}
.br-empty {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
text-align: center;
padding: var(--space-m, 1rem);
}
</style>
<header class="page-header">
<h1 class="page-header__title">{{ __("blogroll.title") }}</h1>
<p class="page-header__description">{{ __("blogroll.description") }}</p>
</header>
{% for message in request.session.messages %}
<div class="notice notice--{{ message.type }}">
<p>{{ message.content }}</p>
</div>
{% endfor %}
<div class="br-dashboard">
<section class="br-section">
<h2>{{ __("blogroll.stats.title") }}</h2>
<dl class="br-stats-grid">
<div class="br-stat">
<dt>{{ __("blogroll.stats.sources") }}</dt>
<dd>{{ stats.sources }}</dd>
</div>
<div class="br-stat">
<dt>{{ __("blogroll.stats.blogs") }}</dt>
<dd>{{ stats.blogs }}</dd>
</div>
<div class="br-stat">
<dt>{{ __("blogroll.stats.items") }}</dt>
<dd>{{ stats.items }}</dd>
</div>
<div class="br-stat">
<dt>{{ __("blogroll.stats.errors") }}</dt>
<dd class="{% if stats.errors > 0 %}br-stat--error{% endif %}">{{ stats.errors }}</dd>
</div>
<div class="br-stat">
<dt>{{ __("blogroll.stats.lastSync") }}</dt>
<dd>{% if syncStatus.lastSync %}{{ syncStatus.lastSync | date("PPpp") }}{% else %}{{ __("blogroll.never") }}{% endif %}</dd>
</div>
</dl>
<div class="br-quick-links">
<a href="{{ baseUrl }}/sources" class="button button--secondary">
{{ __("blogroll.sources.manage") }}
</a>
<a href="{{ baseUrl }}/blogs" class="button button--secondary">
{{ __("blogroll.blogs.manage") }}
</a>
</div>
</section>
<section class="br-section">
<h2>{{ __("blogroll.actions.title") }}</h2>
<div class="button-group">
<form method="post" action="{{ baseUrl }}/sync" style="display: inline;">
<button type="submit" class="button button--primary">
{{ __("blogroll.actions.syncNow") }}
</button>
</form>
<form method="post" action="{{ baseUrl }}/clear-resync" style="display: inline;" onsubmit="return confirm('{{ __("blogroll.actions.clearConfirm") }}');">
<button type="submit" class="button button--secondary">
{{ __("blogroll.actions.clearResync") }}
</button>
</form>
</div>
</section>
{% if blogsWithErrors.length > 0 %}
<section class="br-section">
<h2>{{ __("blogroll.errors.title") }}</h2>
<ul class="br-list">
{% for blog in blogsWithErrors %}
<li>
<div>
<span class="br-list-name">{{ blog.title }}</span>
<span class="br-list-error">{{ blog.lastError }}</span>
</div>
<a href="{{ baseUrl }}/blogs/{{ blog._id }}" class="button button--small button--secondary">
{{ __("blogroll.edit") }}
</a>
</li>
{% endfor %}
</ul>
{% if stats.errors > blogsWithErrors.length %}
<p class="br-empty">
<a href="{{ baseUrl }}/blogs?status=error">{{ __("blogroll.errors.seeAll", { count: stats.errors }) }}</a>
</p>
{% endif %}
</section>
{% endif %}
<section class="br-section">
<h2>{{ __("blogroll.sources.recent") }}</h2>
{% if sources.length > 0 %}
<ul class="br-list">
{% for source in sources %}
<li>
<div>
<span class="br-list-name">{{ source.name }}</span>
<span class="br-list-meta">{{ source.type }} • {% if source.lastSyncAt %}{{ source.lastSyncAt | date("PP") }}{% else %}{{ __("blogroll.never") }}{% endif %}</span>
</div>
<span class="badge {% if source.enabled %}badge--green{% else %}badge--yellow{% endif %}">
{% if source.enabled %}{{ __("blogroll.enabled") }}{% else %}{{ __("blogroll.disabled") }}{% endif %}
</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="br-empty">{{ __("blogroll.sources.empty") }}</p>
{% endif %}
<div class="br-quick-links">
<a href="{{ baseUrl }}/sources/new" class="button button--secondary button--small">
{{ __("blogroll.sources.add") }}
</a>
</div>
</section>
<section class="br-section">
<h2>{{ __("blogroll.blogs.recent") }}</h2>
{% if recentBlogs.length > 0 %}
<ul class="br-list">
{% for blog in recentBlogs %}
<li>
<div>
<span class="br-list-name">{{ blog.title }}</span>
<span class="br-list-meta">{{ blog.category or "Uncategorized" }} • {{ blog.itemCount }} items</span>
</div>
<span class="badge {% if blog.status == 'active' %}badge--green{% elif blog.status == 'error' %}badge--red{% else %}badge--yellow{% endif %}">
{{ blog.status }}
</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="br-empty">{{ __("blogroll.blogs.empty") }}</p>
{% endif %}
<div class="br-quick-links">
<a href="{{ baseUrl }}/blogs/new" class="button button--secondary button--small">
{{ __("blogroll.blogs.add") }}
</a>
</div>
</section>
<section class="br-section">
<h2>{{ __("blogroll.api.title") }}</h2>
<ul class="br-api-list">
<li><code>GET {{ baseUrl }}/api/blogs</code> - {{ __("blogroll.api.blogs") }}</li>
<li><code>GET {{ baseUrl }}/api/items</code> - {{ __("blogroll.api.items") }}</li>
<li><code>GET {{ baseUrl }}/api/categories</code> - {{ __("blogroll.api.categories") }}</li>
<li><code>GET {{ baseUrl }}/api/opml</code> - {{ __("blogroll.api.opml") }}</li>
<li><code>GET {{ baseUrl }}/api/status</code> - {{ __("blogroll.api.status") }}</li>
</ul>
</section>
</div>
{% endblock %}

146
views/source-edit.njk Normal file
View File

@@ -0,0 +1,146 @@
{% extends "document.njk" %}
{% block content %}
<style>
.br-form {
max-width: 600px;
}
.br-field {
display: flex;
flex-direction: column;
gap: var(--space-2xs, 0.25rem);
margin-block-end: var(--space-m, 1rem);
}
.br-field label {
font: var(--font-label, bold 0.875rem/1.4 sans-serif);
}
.br-field-hint {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
}
.br-field input,
.br-field select,
.br-field textarea {
appearance: none;
background-color: var(--color-background, #fff);
border: 1px solid var(--color-outline-variant, #ccc);
border-radius: var(--border-radius-small, 0.25rem);
font: var(--font-body, 0.875rem/1.4 sans-serif);
padding: calc(var(--space-s, 0.75rem) / 2) var(--space-s, 0.75rem);
width: 100%;
}
.br-field textarea {
min-height: 150px;
font-family: monospace;
}
.br-field input:focus,
.br-field select:focus,
.br-field textarea:focus {
border-color: var(--color-primary, #0066cc);
outline: 2px solid var(--color-primary, #0066cc);
outline-offset: 1px;
}
.br-field-inline {
flex-direction: row;
align-items: center;
gap: var(--space-s, 0.75rem);
}
.br-field-inline input[type="checkbox"] {
width: auto;
}
</style>
<header class="page-header">
<a href="{{ baseUrl }}/sources" class="page-header__back">{{ icon("previous") }} {{ __("blogroll.sources.title") }}</a>
<h1 class="page-header__title">{{ title }}</h1>
</header>
{% for message in request.session.messages %}
<div class="notice notice--{{ message.type }}">
<p>{{ message.content }}</p>
</div>
{% endfor %}
<form method="post" action="{% if isNew %}{{ baseUrl }}/sources{% else %}{{ baseUrl }}/sources/{{ source._id }}{% endif %}" class="br-form">
<div class="br-field">
<label for="name">{{ __("blogroll.sources.form.name") }}</label>
<input type="text" id="name" name="name" value="{{ source.name if source else '' }}" required>
</div>
<div class="br-field">
<label for="type">{{ __("blogroll.sources.form.type") }}</label>
<select id="type" name="type" required onchange="toggleTypeFields()">
<option value="opml_url" {% if source.type == 'opml_url' %}selected{% endif %}>OPML URL</option>
<option value="opml_file" {% if source.type == 'opml_file' %}selected{% endif %}>OPML File (paste content)</option>
<option value="manual" {% if source.type == 'manual' %}selected{% endif %}>Manual (add blogs individually)</option>
</select>
<span class="br-field-hint">{{ __("blogroll.sources.form.typeHint") }}</span>
</div>
<div class="br-field" id="urlField">
<label for="url">{{ __("blogroll.sources.form.url") }}</label>
<input type="url" id="url" name="url" value="{{ source.url if source else '' }}" placeholder="https://...">
<span class="br-field-hint">{{ __("blogroll.sources.form.urlHint") }}</span>
</div>
<div class="br-field" id="opmlContentField" style="display: none;">
<label for="opmlContent">{{ __("blogroll.sources.form.opmlContent") }}</label>
<textarea id="opmlContent" name="opmlContent" placeholder="<?xml version=&quot;1.0&quot;?>...">{{ source.opmlContent if source else '' }}</textarea>
<span class="br-field-hint">{{ __("blogroll.sources.form.opmlContentHint") }}</span>
</div>
<div class="br-field">
<label for="syncInterval">{{ __("blogroll.sources.form.syncInterval") }}</label>
<select id="syncInterval" name="syncInterval">
<option value="30" {% if source.syncInterval == 30 %}selected{% endif %}>30 minutes</option>
<option value="60" {% if not source or source.syncInterval == 60 %}selected{% endif %}>1 hour</option>
<option value="180" {% if source.syncInterval == 180 %}selected{% endif %}>3 hours</option>
<option value="360" {% if source.syncInterval == 360 %}selected{% endif %}>6 hours</option>
<option value="720" {% if source.syncInterval == 720 %}selected{% endif %}>12 hours</option>
<option value="1440" {% if source.syncInterval == 1440 %}selected{% endif %}>24 hours</option>
</select>
</div>
<div class="br-field br-field-inline">
<input type="checkbox" id="enabled" name="enabled" {% if not source or source.enabled %}checked{% endif %}>
<label for="enabled">{{ __("blogroll.sources.form.enabled") }}</label>
</div>
<div class="button-group">
<button type="submit" class="button button--primary">
{% if isNew %}{{ __("blogroll.sources.create") }}{% else %}{{ __("blogroll.sources.save") }}{% endif %}
</button>
<a href="{{ baseUrl }}/sources" class="button button--secondary">{{ __("blogroll.cancel") }}</a>
</div>
</form>
<script>
function toggleTypeFields() {
const type = document.getElementById('type').value;
const urlField = document.getElementById('urlField');
const opmlContentField = document.getElementById('opmlContentField');
if (type === 'opml_url') {
urlField.style.display = 'flex';
opmlContentField.style.display = 'none';
} else if (type === 'opml_file') {
urlField.style.display = 'none';
opmlContentField.style.display = 'flex';
} else {
urlField.style.display = 'none';
opmlContentField.style.display = 'none';
}
}
// Initialize on load
toggleTypeFields();
</script>
{% endblock %}

139
views/sources.njk Normal file
View File

@@ -0,0 +1,139 @@
{% extends "document.njk" %}
{% block content %}
<style>
.br-sources {
display: flex;
flex-direction: column;
gap: var(--space-m, 1.5rem);
}
.br-sources-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-s, 0.75rem);
}
.br-source-item {
background: var(--color-offset, #f5f5f5);
border-radius: var(--border-radius-small, 0.5rem);
padding: var(--space-m, 1rem);
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-s, 0.75rem);
}
.br-source-info {
flex: 1;
min-width: 200px;
}
.br-source-name {
font: var(--font-subhead, bold 1rem/1.4 sans-serif);
margin-block-end: var(--space-2xs, 0.25rem);
}
.br-source-meta {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
}
.br-source-url {
color: var(--color-primary, #0066cc);
font: var(--font-caption, 0.75rem/1.4 monospace);
word-break: break-all;
margin-block-start: var(--space-2xs, 0.25rem);
}
.br-source-error {
color: var(--color-error, #dc3545);
font: var(--font-caption, 0.75rem/1.4 sans-serif);
margin-block-start: var(--space-2xs, 0.25rem);
}
.br-source-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs, 0.5rem);
}
.br-empty {
text-align: center;
padding: var(--space-xl, 2rem);
color: var(--color-on-offset, #666);
}
</style>
<header class="page-header">
<a href="{{ baseUrl }}" class="page-header__back">{{ icon("previous") }} {{ __("blogroll.title") }}</a>
<h1 class="page-header__title">{{ __("blogroll.sources.title") }}</h1>
</header>
{% for message in request.session.messages %}
<div class="notice notice--{{ message.type }}">
<p>{{ message.content }}</p>
</div>
{% endfor %}
<div class="br-sources">
<div class="button-group">
<a href="{{ baseUrl }}/sources/new" class="button button--primary">
{{ __("blogroll.sources.add") }}
</a>
</div>
{% if sources.length > 0 %}
<ul class="br-sources-list">
{% for source in sources %}
<li class="br-source-item">
<div class="br-source-info">
<h2 class="br-source-name">{{ source.name }}</h2>
<p class="br-source-meta">
<span class="badge {% if source.enabled %}badge--green{% else %}badge--yellow{% endif %}">
{% if source.enabled %}{{ __("blogroll.enabled") }}{% else %}{{ __("blogroll.disabled") }}{% endif %}
</span>
<span>{{ source.type }}</span>
<span>• {{ __("blogroll.sources.interval", { minutes: source.syncInterval }) }}</span>
</p>
{% if source.url %}
<p class="br-source-url">{{ source.url }}</p>
{% endif %}
{% if source.lastSyncError %}
<p class="br-source-error">{{ source.lastSyncError }}</p>
{% endif %}
<p class="br-source-meta">
{{ __("blogroll.sources.lastSync") }}:
{% if source.lastSyncAt %}{{ source.lastSyncAt | date("PPpp") }}{% else %}{{ __("blogroll.never") }}{% endif %}
</p>
</div>
<div class="br-source-actions">
<form method="post" action="{{ baseUrl }}/sources/{{ source._id }}/sync" style="display: inline;">
<button type="submit" class="button button--small button--secondary">
{{ icon("syndicate") }} {{ __("blogroll.sync") }}
</button>
</form>
<a href="{{ baseUrl }}/sources/{{ source._id }}" class="button button--small button--secondary">
{{ icon("updatePost") }} {{ __("blogroll.edit") }}
</a>
<form method="post" action="{{ baseUrl }}/sources/{{ source._id }}/delete" style="display: inline;" onsubmit="return confirm('{{ __("blogroll.sources.deleteConfirm") }}');">
<button type="submit" class="button button--small button--warning">
{{ icon("delete") }}
</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="br-empty">
<p>{{ __("blogroll.sources.empty") }}</p>
<p><a href="{{ baseUrl }}/sources/new" class="button button--primary">{{ __("blogroll.sources.add") }}</a></p>
</div>
{% endif %}
</div>
{% endblock %}