feat: added feed generation

This commit is contained in:
2026-02-19 22:30:04 +01:00
parent cfe5c37c5e
commit 7e593b587b
7 changed files with 758 additions and 293 deletions

View File

@@ -0,0 +1,59 @@
import { dialog } from 'electron';
import { getPostEngine } from '../engine/PostEngine';
import { getProjectEngine } from '../engine/ProjectEngine';
import { getMetaEngine } from '../engine/MetaEngine';
import { taskManager } from '../engine/TaskManager';
import { getBlogGenerationEngine, resolvePublicBaseUrl } from '../engine/BlogGenerationEngine';
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
export function registerBlogHandlers(safeHandle: SafeHandle): void {
safeHandle('blog:generateSitemap', async () => {
const projectEngine = getProjectEngine();
const postEngine = getPostEngine();
const metaEngine = getMetaEngine();
const blogGenerationEngine = getBlogGenerationEngine();
const project = await projectEngine.getActiveProject();
if (!project) {
throw new Error('No active project');
}
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
postEngine.setProjectContext(project.id, dataDir);
metaEngine.setProjectContext(project.id, dataDir);
if (!metaEngine.isInitialized()) {
await metaEngine.syncOnStartup();
}
const metadata = await metaEngine.getProjectMetadata();
const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl);
if (!baseUrl) {
await dialog.showMessageBox({
type: 'warning',
title: 'Public URL Required',
message: 'Sitemap generation requires a public URL.',
detail: 'Set Project → Public URL in Settings before generating a sitemap.',
});
throw new Error('Project public URL is not configured');
}
const taskId = `sitemap-generate-${Date.now()}`;
return taskManager.runTask({
id: taskId,
name: 'Generate Sitemap',
execute: async (onProgress) => {
return blogGenerationEngine.generate({
projectId: project.id,
projectName: metadata?.name?.trim() || project.name,
projectDescription: metadata?.description,
dataDir,
baseUrl,
maxPostsPerPage: metadata?.maxPostsPerPage,
}, (progress, message) => onProgress(progress, message || ''));
},
});
});
}

View File

@@ -13,6 +13,8 @@ import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database';
import { media } from '../database/schema';
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuAction } from '../shared/menuCommands';
import { registerMetadataDiffHandlers } from './metadataDiffHandlers';
import { registerBlogHandlers } from './blogHandlers';
/**
* Wrap an IPC handler so that "Database is closing" errors during shutdown
@@ -86,50 +88,6 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
}
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function buildSitemapUrl(
loc: string,
lastmod: string,
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
priority: string,
): string {
return [
' <url>',
` <loc>${escapeXml(loc)}</loc>`,
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
` <changefreq>${changefreq}</changefreq>`,
` <priority>${priority}</priority>`,
' </url>',
].join('\n');
}
function resolvePublicBaseUrl(publicUrl?: string): string | null {
const trimmed = (publicUrl || '').trim();
if (!trimmed) {
return null;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}`;
} catch {
return null;
}
}
export function registerIpcHandlers(): void {
// ============ Git Handlers ============
@@ -1263,252 +1221,8 @@ export function registerIpcHandlers(): void {
return engine.deleteDefinition(id);
});
// ============ Metadata Diff Handlers ============
safeHandle('metadataDiff:getStats', async () => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.getTableStats();
});
safeHandle('metadataDiff:scan', async () => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
// Forward progress events to renderer
const taskId = `metadata-diff-scan-${Date.now()}`;
return taskManager.runTask({
id: taskId,
name: 'Scanning for metadata differences',
execute: async (onProgress) => {
return engine.scanAllPublishedPosts((current, total, message) => {
const percent = total > 0 ? (current / total) * 100 : 0;
onProgress(percent, message);
});
},
});
});
safeHandle('metadataDiff:syncDbToFile', async (_, postIds: string[], groupLabel: string) => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.runSyncDbToFileTask(postIds, groupLabel);
});
safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
});
// ============ Sitemap Generation ============
safeHandle('blog:generateSitemap', async () => {
const projectEngine = getProjectEngine();
const postEngine = getPostEngine();
const metaEngine = getMetaEngine();
const project = await projectEngine.getActiveProject();
if (!project) {
throw new Error('No active project');
}
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
postEngine.setProjectContext(project.id, dataDir);
metaEngine.setProjectContext(project.id, dataDir);
if (!metaEngine.isInitialized()) {
await metaEngine.syncOnStartup();
}
const metadata = await metaEngine.getProjectMetadata();
const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl);
if (!baseUrl) {
await dialog.showMessageBox({
type: 'warning',
title: 'Public URL Required',
message: 'Sitemap generation requires a public URL.',
detail: 'Set Project → Public URL in Settings before generating a sitemap.',
});
throw new Error('Project public URL is not configured');
}
const taskId = `sitemap-generate-${Date.now()}`;
return taskManager.runTask({
id: taskId,
name: 'Generate Sitemap',
execute: async (onProgress) => {
onProgress(0, 'Loading posts...');
const publishedCandidates = await postEngine.getPostsFiltered({ status: 'published' });
const draftCandidates = await postEngine.getPostsFiltered({ status: 'draft' });
const draftPublishedSnapshots = await Promise.all(
draftCandidates.map(async (post) => postEngine.getPublishedVersion(post.id)),
);
const publishedPostById = new Map<string, PostData>();
for (const post of publishedCandidates) {
publishedPostById.set(post.id, post);
}
for (const snapshot of draftPublishedSnapshots) {
if (snapshot) {
publishedPostById.set(snapshot.id, snapshot);
}
}
const publishedPosts = Array.from(publishedPostById.values())
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
onProgress(10, `Found ${publishedPosts.length} published posts`);
const now = new Date().toISOString();
// Collect all unique tags, categories, and year/month/day archives
const allTags = new Set<string>();
const allCategories = new Set<string>();
const yearMonths = new Map<string, Date>(); // key -> most recent post date
const years = new Map<number, Date>(); // year -> most recent post date
const yearMonthDays = new Map<string, Date>(); // YYYY/MM/DD -> most recent post date
const postUrls: Array<{ loc: string; lastmod: string }> = [];
for (const post of publishedPosts) {
const tags = post.tags || [];
const categories = post.categories || [];
for (const tag of tags) allTags.add(tag);
for (const cat of categories) allCategories.add(cat);
// Build canonical post URL using shared helpers
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const postUrl = `${baseUrl}${canonicalPath}`;
const updatedAt = post.updatedAt;
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
// Track archives
const year = createdAt.getFullYear();
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).padStart(2, '0');
const ymKey = `${year}/${month}`;
const ymdKey = `${year}/${month}/${day}`;
if (!yearMonths.has(ymKey) || updatedAt > yearMonths.get(ymKey)!) {
yearMonths.set(ymKey, updatedAt);
}
if (!years.has(year) || updatedAt > years.get(year)!) {
years.set(year, updatedAt);
}
if (!yearMonthDays.has(ymdKey) || updatedAt > yearMonthDays.get(ymdKey)!) {
yearMonthDays.set(ymdKey, updatedAt);
}
}
onProgress(40, 'Building sitemap XML...');
// Build XML sitemap
const urls: string[] = [];
// Homepage
urls.push(buildSitemapUrl(baseUrl + '/', now, 'daily', '1.0'));
// Individual posts
for (const post of postUrls) {
urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
}
onProgress(55, 'Adding archive pages...');
// Year archives
for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
urls.push(buildSitemapUrl(`${baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
}
// Year/Month archives
for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) {
urls.push(buildSitemapUrl(`${baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5'));
}
// Year/Month/Day archives
for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) {
urls.push(buildSitemapUrl(`${baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
}
onProgress(70, 'Adding category pages...');
// Category pages
for (const category of Array.from(allCategories).sort()) {
urls.push(buildSitemapUrl(
`${baseUrl}/category/${encodeURIComponent(category)}`,
now,
'weekly',
'0.6',
));
}
onProgress(80, 'Adding tag pages...');
// Tag pages
for (const tag of Array.from(allTags).sort()) {
urls.push(buildSitemapUrl(
`${baseUrl}/tag/${encodeURIComponent(tag)}`,
now,
'weekly',
'0.6',
));
}
onProgress(90, 'Writing sitemap file...');
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...urls,
'</urlset>',
'',
].join('\n');
// Write to html folder in the project data directory
const htmlDir = path.join(dataDir, 'html');
await fsPromises.mkdir(htmlDir, { recursive: true });
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
await fsPromises.writeFile(sitemapPath, xml, 'utf-8');
onProgress(100, `Sitemap generated with ${urls.length} URLs`);
return {
path: sitemapPath,
urlCount: urls.length,
postCount: postUrls.length,
tagCount: allTags.size,
categoryCount: allCategories.size,
archiveCount: years.size + yearMonths.size + yearMonthDays.size,
};
},
});
});
registerMetadataDiffHandlers(safeHandle);
registerBlogHandlers(safeHandle);
// ============ Event Forwarding ============

View File

@@ -0,0 +1,61 @@
import { getProjectEngine } from '../engine/ProjectEngine';
import { taskManager } from '../engine/TaskManager';
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
export function registerMetadataDiffHandlers(safeHandle: SafeHandle): void {
safeHandle('metadataDiff:getStats', async () => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.getTableStats();
});
safeHandle('metadataDiff:scan', async () => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
const taskId = `metadata-diff-scan-${Date.now()}`;
return taskManager.runTask({
id: taskId,
name: 'Scanning for metadata differences',
execute: async (onProgress) => {
return engine.scanAllPublishedPosts((current, total, message) => {
const percent = total > 0 ? (current / total) * 100 : 0;
onProgress(percent, message);
});
},
});
});
safeHandle('metadataDiff:syncDbToFile', async (_, postIds: string[], groupLabel: string) => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.runSyncDbToFileTask(postIds, groupLabel);
});
safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
});
}