mirror of
https://github.com/svemagie/indiekit-endpoint-blogroll.git
synced 2026-04-02 15:34:59 +02:00
- Extract ~560 lines of inline CSS to external assets/styles.css - Create intermediate layout (layouts/blogroll.njk) for CSS loading - Use section(), badge(), button(), prose() macros instead of raw HTML - Remove custom page headers (document.njk heading() handles via title/parent) - Add parent breadcrumb navigation to all sub-pages - Add consumeFlashMessage() to dashboard and sources controllers - Rename CSS class prefix from br-* to blogroll-* for clarity - Use upstream CSS custom properties without fallback values - Fix Microsub orphan detection (soft-delete unsubscribed blogs) - Fix upsert to conditionally set microsub fields (avoid path conflicts) - Skip soft-deleted blogs during clear-and-resync Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
370 lines
9.2 KiB
JavaScript
370 lines
9.2 KiB
JavaScript
/**
|
|
* Sources controller
|
|
* @module controllers/sources
|
|
*/
|
|
|
|
import {
|
|
getSources,
|
|
getSource,
|
|
createSource,
|
|
updateSource,
|
|
deleteSource,
|
|
} from "../storage/sources.js";
|
|
import { syncOpmlSource } from "../sync/opml.js";
|
|
import {
|
|
syncMicrosubSource,
|
|
getMicrosubChannels,
|
|
isMicrosubAvailable,
|
|
} from "../sync/microsub.js";
|
|
|
|
/**
|
|
* List sources
|
|
* GET /sources
|
|
*/
|
|
async function list(request, response) {
|
|
const { application } = request.app.locals;
|
|
|
|
try {
|
|
const rawSources = await getSources(application);
|
|
|
|
// Convert Date objects to ISO strings for template date filter compatibility
|
|
const sources = rawSources.map((source) => ({
|
|
...source,
|
|
lastSyncAt: source.lastSyncAt
|
|
? (source.lastSyncAt instanceof Date
|
|
? source.lastSyncAt.toISOString()
|
|
: source.lastSyncAt)
|
|
: null,
|
|
}));
|
|
|
|
// Extract flash messages for native Indiekit notification banner
|
|
const flash = consumeFlashMessage(request);
|
|
|
|
response.render("blogroll-sources", {
|
|
title: request.__("blogroll.sources.title"),
|
|
parent: { text: request.__("blogroll.title"), href: request.baseUrl },
|
|
sources,
|
|
baseUrl: request.baseUrl,
|
|
...flash,
|
|
});
|
|
} 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
|
|
*/
|
|
async function newForm(request, response) {
|
|
const { application } = request.app.locals;
|
|
|
|
// Check if Microsub is available and get channels
|
|
const microsubAvailable = isMicrosubAvailable(application);
|
|
const microsubChannels = microsubAvailable
|
|
? await getMicrosubChannels(application)
|
|
: [];
|
|
|
|
response.render("blogroll-source-edit", {
|
|
title: request.__("blogroll.sources.new"),
|
|
parent: { text: request.__("blogroll.sources.title"), href: `${request.baseUrl}/sources` },
|
|
source: null,
|
|
isNew: true,
|
|
baseUrl: request.baseUrl,
|
|
microsubAvailable,
|
|
microsubChannels,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create source
|
|
* POST /sources
|
|
*/
|
|
async function create(request, response) {
|
|
const { application } = request.app.locals;
|
|
const {
|
|
name,
|
|
type,
|
|
url,
|
|
opmlContent,
|
|
syncInterval,
|
|
enabled,
|
|
channelFilter,
|
|
categoryPrefix,
|
|
} = 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`);
|
|
}
|
|
|
|
if (type === "microsub" && !isMicrosubAvailable(application)) {
|
|
request.session.messages = [
|
|
{ type: "error", content: "Microsub plugin is not available" },
|
|
];
|
|
return response.redirect(`${request.baseUrl}/sources/new`);
|
|
}
|
|
|
|
const sourceData = {
|
|
name,
|
|
type,
|
|
url: url || null,
|
|
opmlContent: opmlContent || null,
|
|
syncInterval: Number(syncInterval) || 60,
|
|
enabled: enabled === "on" || enabled === true,
|
|
};
|
|
|
|
// Add microsub-specific fields
|
|
if (type === "microsub") {
|
|
sourceData.channelFilter = channelFilter || null;
|
|
sourceData.categoryPrefix = categoryPrefix || "";
|
|
}
|
|
|
|
const source = await createSource(application, sourceData);
|
|
|
|
// Trigger initial sync based on source type
|
|
try {
|
|
if (type === "microsub") {
|
|
await syncMicrosubSource(application, source);
|
|
} else {
|
|
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,
|
|
}),
|
|
},
|
|
];
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
// Check if Microsub is available and get channels
|
|
const microsubAvailable = isMicrosubAvailable(application);
|
|
const microsubChannels = microsubAvailable
|
|
? await getMicrosubChannels(application)
|
|
: [];
|
|
|
|
response.render("blogroll-source-edit", {
|
|
title: request.__("blogroll.sources.edit"),
|
|
parent: { text: request.__("blogroll.sources.title"), href: `${request.baseUrl}/sources` },
|
|
source,
|
|
isNew: false,
|
|
baseUrl: request.baseUrl,
|
|
microsubAvailable,
|
|
microsubChannels,
|
|
});
|
|
} 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,
|
|
channelFilter,
|
|
categoryPrefix,
|
|
} = request.body;
|
|
|
|
try {
|
|
const source = await getSource(application, id);
|
|
|
|
if (!source) {
|
|
return response.status(404).render("404");
|
|
}
|
|
|
|
const updateData = {
|
|
name,
|
|
type,
|
|
url: url || null,
|
|
opmlContent: opmlContent || null,
|
|
syncInterval: Number(syncInterval) || 60,
|
|
enabled: enabled === "on" || enabled === true,
|
|
};
|
|
|
|
// Add microsub-specific fields
|
|
if (type === "microsub") {
|
|
updateData.channelFilter = channelFilter || null;
|
|
updateData.categoryPrefix = categoryPrefix || "";
|
|
}
|
|
|
|
await updateSource(application, id, updateData);
|
|
|
|
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");
|
|
}
|
|
|
|
// Use appropriate sync function based on source type
|
|
let result;
|
|
if (source.type === "microsub") {
|
|
result = await syncMicrosubSource(application, source);
|
|
} else {
|
|
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`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract and clear flash messages from session
|
|
* Returns { success, error } for Indiekit's native notificationBanner
|
|
*/
|
|
function consumeFlashMessage(request) {
|
|
const result = {};
|
|
if (request.session?.messages?.length) {
|
|
const msg = request.session.messages[0];
|
|
if (msg.type === "success") result.success = msg.content;
|
|
else if (msg.type === "error" || msg.type === "warning") result.error = msg.content;
|
|
request.session.messages = null;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export const sourcesController = {
|
|
list,
|
|
newForm,
|
|
create,
|
|
edit,
|
|
update,
|
|
remove,
|
|
sync,
|
|
};
|