mirror of
https://github.com/svemagie/indiekit-endpoint-blogroll.git
synced 2026-04-02 15:34:59 +02:00
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:
138
index.js
Normal file
138
index.js
Normal 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
240
lib/controllers/api.js
Normal 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
298
lib/controllers/blogs.js
Normal 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,
|
||||||
|
};
|
||||||
149
lib/controllers/dashboard.js
Normal file
149
lib/controllers/dashboard.js
Normal 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
269
lib/controllers/sources.js
Normal 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
277
lib/storage/blogs.js
Normal 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
180
lib/storage/items.js
Normal 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
174
lib/storage/sources.js
Normal 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
318
lib/sync/feed.js
Normal 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
208
lib/sync/opml.js
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
232
lib/sync/scheduler.js
Normal file
232
lib/sync/scheduler.js
Normal 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
122
locales/en.json
Normal 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
56
package.json
Normal 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
199
views/blog-edit.njk
Normal 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
205
views/blogs.njk
Normal 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
273
views/dashboard.njk
Normal 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
146
views/source-edit.njk
Normal 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="1.0"?>...">{{ 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
139
views/sources.njk
Normal 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 %}
|
||||||
Reference in New Issue
Block a user