diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts
new file mode 100644
index 0000000..d814fc6
--- /dev/null
+++ b/src/main/engine/BlogGenerationEngine.ts
@@ -0,0 +1,430 @@
+import * as path from 'path';
+import * as fs from 'fs/promises';
+import * as crypto from 'crypto';
+import { getDatabase } from '../database';
+import { getPostEngine, type PostData } from './PostEngine';
+
+const DEFAULT_MAX_POSTS_PER_PAGE = 50;
+const MIN_MAX_POSTS_PER_PAGE = 1;
+const MAX_MAX_POSTS_PER_PAGE = 500;
+
+export interface BlogGenerationOptions {
+ projectId: string;
+ projectName: string;
+ projectDescription?: string;
+ dataDir: string;
+ baseUrl: string;
+ maxPostsPerPage?: number;
+}
+
+export interface BlogGenerationResult {
+ path: string;
+ urlCount: number;
+ postCount: number;
+ feedPostCount: number;
+ tagCount: number;
+ categoryCount: number;
+ archiveCount: number;
+ feeds: {
+ rssPath: string;
+ atomPath: string;
+ };
+ changed: {
+ sitemap: boolean;
+ rss: boolean;
+ atom: boolean;
+ };
+}
+
+export 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;
+ }
+}
+
+function clampMaxPostsPerPage(value: unknown): number {
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return DEFAULT_MAX_POSTS_PER_PAGE;
+ }
+
+ const normalized = Math.floor(value);
+ if (normalized < MIN_MAX_POSTS_PER_PAGE) return DEFAULT_MAX_POSTS_PER_PAGE;
+ if (normalized > MAX_MAX_POSTS_PER_PAGE) return MAX_MAX_POSTS_PER_PAGE;
+ return normalized;
+}
+
+function buildCanonicalPreviewPath(createdAt: Date, slug: string): string {
+ const year = createdAt.getFullYear();
+ const month = String(createdAt.getMonth() + 1).padStart(2, '0');
+ const day = String(createdAt.getDate()).padStart(2, '0');
+ return `/${year}/${month}/${day}/${slug}`;
+}
+
+function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
+ if (post.createdAt instanceof Date) {
+ return post.createdAt;
+ }
+
+ const parsed = new Date(post.createdAt);
+ return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
+}
+
+function escapeXml(value: unknown): string {
+ const str = typeof value === 'string' ? value : value == null ? '' : String(value);
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function buildSitemapUrl(
+ loc: string,
+ lastmod: string,
+ changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
+ priority: string,
+): string {
+ return [
+ ' ',
+ ` ${escapeXml(loc)}`,
+ ` ${escapeXml(lastmod)}`,
+ ` ${changefreq}`,
+ ` ${priority}`,
+ ' ',
+ ].join('\n');
+}
+
+function splitParagraphs(markdown: string | null | undefined): string[] {
+ const normalizedMarkdown = typeof markdown === 'string' ? markdown : '';
+ return normalizedMarkdown
+ .replace(/\r\n/g, '\n')
+ .split(/\n{2,}/)
+ .map((paragraph) => paragraph.trim())
+ .filter((paragraph) => paragraph.length > 0);
+}
+
+function paragraphToXhtml(paragraph: string): string {
+ const escaped = escapeXml(paragraph).replace(/\n/g, '
');
+ return `
${escaped}
`;
+}
+
+function markdownToXhtml(markdown: string): string {
+ const paragraphs = splitParagraphs(markdown);
+ if (paragraphs.length === 0) {
+ return '';
+ }
+ return paragraphs.map(paragraphToXhtml).join('');
+}
+
+function excerptToXhtml(post: PostData): string {
+ if (typeof post.excerpt === 'string' && post.excerpt.trim().length > 0) {
+ return paragraphToXhtml(post.excerpt.trim());
+ }
+ const firstParagraph = splitParagraphs(post.content)[0] || '';
+ return paragraphToXhtml(firstParagraph);
+}
+
+function escapeCdata(value: string): string {
+ return value.replace(/]]>/g, ']]]]>');
+}
+
+function computeContentHash(content: string): string {
+ return crypto.createHash('sha256').update(content).digest('hex');
+}
+
+async function getHashSettingValue(key: string): Promise {
+ const client = getDatabase().getLocalClient();
+ if (!client) {
+ throw new Error('Database client not available');
+ }
+
+ const result = await client.execute({
+ sql: 'SELECT value FROM settings WHERE key = ? LIMIT 1',
+ args: [key],
+ });
+
+ if (!result.rows[0] || typeof result.rows[0].value !== 'string') {
+ return null;
+ }
+ return result.rows[0].value;
+}
+
+async function setHashSettingValue(key: string, value: string): Promise {
+ const client = getDatabase().getLocalClient();
+ if (!client) {
+ throw new Error('Database client not available');
+ }
+
+ await client.execute({
+ sql: 'INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
+ args: [key, value, new Date()],
+ });
+}
+
+async function writeFileIfHashChanged(filePath: string, content: string, hashKey: string): Promise {
+ const hash = computeContentHash(content);
+ const previousHash = await getHashSettingValue(hashKey);
+ if (previousHash === hash) {
+ return false;
+ }
+ await fs.writeFile(filePath, content, 'utf-8');
+ await setHashSettingValue(hashKey, hash);
+ return true;
+}
+
+export class BlogGenerationEngine {
+ private readonly postEngine = getPostEngine();
+
+ async generate(options: BlogGenerationOptions, onProgress: (progress: number, message?: string) => void): Promise {
+ onProgress(0, 'Loading posts...');
+
+ const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
+ const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' });
+ const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' });
+
+ const publishedSnapshots = await Promise.all(
+ publishedCandidates.map(async (post) => {
+ const snapshot = await this.postEngine.getPublishedVersion(post.id);
+ return snapshot || post;
+ }),
+ );
+ const draftPublishedSnapshots = await Promise.all(
+ draftCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)),
+ );
+
+ const publishedPostById = new Map();
+ for (const post of publishedSnapshots) {
+ 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());
+ const feedPosts = publishedPosts.slice(0, maxPostsPerPage);
+
+ onProgress(10, `Found ${publishedPosts.length} published posts`);
+
+ const now = new Date().toISOString();
+ const allTags = new Set();
+ const allCategories = new Set();
+ const yearMonths = new Map();
+ const years = new Map();
+ const yearMonthDays = new Map();
+ const postUrls: Array<{ loc: string; lastmod: string }> = [];
+
+ for (const post of publishedPosts) {
+ for (const tag of post.tags || []) allTags.add(tag);
+ for (const category of post.categories || []) allCategories.add(category);
+
+ const createdAt = resolvePostCreatedAt(post);
+ const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
+ const postUrl = `${options.baseUrl}${canonicalPath}`;
+ const updatedAt = post.updatedAt;
+ postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
+
+ 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);
+ }
+ }
+
+ const latestPostUpdatedAt = publishedPosts[0]?.updatedAt.toISOString() || now;
+
+ onProgress(40, 'Building sitemap XML...');
+
+ const urls: string[] = [];
+ urls.push(buildSitemapUrl(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
+ for (const post of postUrls) {
+ urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
+ }
+
+ onProgress(55, 'Adding archive pages...');
+ for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
+ urls.push(buildSitemapUrl(`${options.baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
+ }
+ for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) {
+ urls.push(buildSitemapUrl(`${options.baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5'));
+ }
+ for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) {
+ urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
+ }
+
+ onProgress(70, 'Adding category pages...');
+ for (const category of Array.from(allCategories).sort()) {
+ urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6'));
+ }
+
+ onProgress(80, 'Adding tag pages...');
+ for (const tag of Array.from(allTags).sort()) {
+ urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6'));
+ }
+
+ onProgress(85, 'Building RSS and Atom feeds...');
+
+ const sitemapXml = [
+ '',
+ '',
+ ...urls,
+ '',
+ '',
+ ].join('\n');
+
+ const feedUpdatedAt = feedPosts[0]?.updatedAt || new Date();
+ const baseLink = `${options.baseUrl}/`;
+ const feedTitle = options.projectName;
+ const feedDescription = options.projectDescription?.trim() || feedTitle;
+
+ const rssItems = feedPosts.map((post) => {
+ const createdAt = resolvePostCreatedAt(post);
+ const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
+ const permalink = `${options.baseUrl}${canonicalPath}`;
+ const excerptXhtml = excerptToXhtml(post);
+ const contentXhtml = markdownToXhtml(post.content || '');
+ const categories = [
+ ...(post.categories || []).map((category) => `${escapeXml(category)}`),
+ ...(post.tags || []).map((tag) => `${escapeXml(tag)}`),
+ ];
+
+ return [
+ ' - ',
+ ` ${escapeXml(post.title)}`,
+ ` ${escapeXml(permalink)}`,
+ ` ${escapeXml(permalink)}`,
+ ` ${(post.publishedAt || post.updatedAt).toUTCString()}`,
+ post.author ? ` ${escapeXml(post.author)}` : null,
+ ` `,
+ ` `,
+ ...categories.map((entry) => ` ${entry}`),
+ '
',
+ ].filter(Boolean).join('\n');
+ });
+
+ const rssXml = [
+ '',
+ '',
+ ' ',
+ ` ${escapeXml(feedTitle)}`,
+ ` ${escapeXml(baseLink)}`,
+ ` ${escapeXml(feedDescription)}`,
+ ` ${feedUpdatedAt.toUTCString()}`,
+ ' bDS',
+ ...rssItems,
+ ' ',
+ '',
+ '',
+ ].join('\n');
+
+ const atomEntries = feedPosts.map((post) => {
+ const createdAt = resolvePostCreatedAt(post);
+ const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
+ const permalink = `${options.baseUrl}${canonicalPath}`;
+ const excerptXhtml = excerptToXhtml(post);
+ const contentXhtml = markdownToXhtml(post.content || '');
+ const categories = [
+ ...(post.tags || []).map((tag) => ``),
+ ...(post.categories || []).map((category) => ``),
+ ];
+
+ return [
+ ' ',
+ ` ${escapeXml(post.title)}`,
+ ` ${escapeXml(permalink)}`,
+ ` `,
+ ` ${post.updatedAt.toISOString()}`,
+ ` ${(post.publishedAt || post.updatedAt).toISOString()}`,
+ post.author ? ` ${escapeXml(post.author)}` : null,
+ ` ${excerptXhtml}
`,
+ ` ${contentXhtml}
`,
+ ...categories.map((entry) => ` ${entry}`),
+ ' ',
+ ].filter(Boolean).join('\n');
+ });
+
+ const atomXml = [
+ '',
+ '',
+ ` ${escapeXml(feedTitle)}`,
+ ` ${escapeXml(feedDescription)}`,
+ ` ${escapeXml(baseLink)}`,
+ ` `,
+ ` `,
+ ` ${feedUpdatedAt.toISOString()}`,
+ ...atomEntries,
+ '',
+ '',
+ ].join('\n');
+
+ onProgress(92, 'Writing sitemap and feeds...');
+
+ const htmlDir = path.join(options.dataDir, 'html');
+ await fs.mkdir(htmlDir, { recursive: true });
+ const sitemapPath = path.join(htmlDir, 'sitemap.xml');
+ const rssPath = path.join(htmlDir, 'rss.xml');
+ const atomPath = path.join(htmlDir, 'atom.xml');
+ const hashKeyPrefix = `project:${options.projectId}:generation-hash`;
+
+ const [sitemapWritten, rssWritten, atomWritten] = await Promise.all([
+ writeFileIfHashChanged(sitemapPath, sitemapXml, `${hashKeyPrefix}:sitemap.xml`),
+ writeFileIfHashChanged(rssPath, rssXml, `${hashKeyPrefix}:rss.xml`),
+ writeFileIfHashChanged(atomPath, atomXml, `${hashKeyPrefix}:atom.xml`),
+ ]);
+
+ onProgress(100, `Sitemap and feeds generated (${feedPosts.length} feed posts)`);
+
+ return {
+ path: sitemapPath,
+ urlCount: urls.length,
+ postCount: postUrls.length,
+ feedPostCount: feedPosts.length,
+ tagCount: allTags.size,
+ categoryCount: allCategories.size,
+ archiveCount: years.size + yearMonths.size + yearMonthDays.size,
+ feeds: {
+ rssPath,
+ atomPath,
+ },
+ changed: {
+ sitemap: sitemapWritten,
+ rss: rssWritten,
+ atom: atomWritten,
+ },
+ };
+ }
+}
+
+let blogGenerationEngine: BlogGenerationEngine | null = null;
+
+export function getBlogGenerationEngine(): BlogGenerationEngine {
+ if (!blogGenerationEngine) {
+ blogGenerationEngine = new BlogGenerationEngine();
+ }
+ return blogGenerationEngine;
+}
diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts
index 75957af..6c1d220 100644
--- a/src/main/engine/index.ts
+++ b/src/main/engine/index.ts
@@ -85,4 +85,11 @@ export {
type GitStatusFile,
type GitStatusCounts,
type GitInitResult,
-} from './GitEngine';
\ No newline at end of file
+} from './GitEngine';
+export {
+ BlogGenerationEngine,
+ getBlogGenerationEngine,
+ resolvePublicBaseUrl,
+ type BlogGenerationOptions,
+ type BlogGenerationResult,
+} from './BlogGenerationEngine';
diff --git a/src/main/ipc/blogHandlers.ts b/src/main/ipc/blogHandlers.ts
new file mode 100644
index 0000000..b2e855a
--- /dev/null
+++ b/src/main/ipc/blogHandlers.ts
@@ -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) => 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 || ''));
+ },
+ });
+ });
+}
diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts
index 5ce95c2..b60f9b7 100644
--- a/src/main/ipc/handlers.ts
+++ b/src/main/ipc/handlers.ts
@@ -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, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-}
-
-function buildSitemapUrl(
- loc: string,
- lastmod: string,
- changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
- priority: string,
-): string {
- return [
- ' ',
- ` ${escapeXml(loc)}`,
- ` ${escapeXml(lastmod)}`,
- ` ${changefreq}`,
- ` ${priority}`,
- ' ',
- ].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();
- 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();
- const allCategories = new Set();
- const yearMonths = new Map(); // key -> most recent post date
- const years = new Map(); // year -> most recent post date
- const yearMonthDays = new Map(); // 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 = [
- '',
- '',
- ...urls,
- '',
- '',
- ].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 ============
diff --git a/src/main/ipc/metadataDiffHandlers.ts b/src/main/ipc/metadataDiffHandlers.ts
new file mode 100644
index 0000000..10dbc5f
--- /dev/null
+++ b/src/main/ipc/metadataDiffHandlers.ts
@@ -0,0 +1,61 @@
+import { getProjectEngine } from '../engine/ProjectEngine';
+import { taskManager } from '../engine/TaskManager';
+
+type SafeHandle = (channel: string, handler: (...args: any[]) => Promise) => 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);
+ });
+}
diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts
index 039a250..0dd8230 100644
--- a/tests/ipc/handlers.test.ts
+++ b/tests/ipc/handlers.test.ts
@@ -175,6 +175,8 @@ const mockTaskManager = {
off: vi.fn(),
};
+const mockSettingsStore = new Map();
+
const mockDatabase = {
getLocal: vi.fn(() => ({
select: vi.fn(() => ({
@@ -185,6 +187,27 @@ const mockDatabase = {
})),
})),
})),
+ getLocalClient: vi.fn(() => ({
+ execute: vi.fn(async ({ sql, args }: { sql: string; args?: any[] }) => {
+ if (sql.startsWith('SELECT value FROM settings WHERE key = ?')) {
+ const key = String(args?.[0] ?? '');
+ return {
+ rows: mockSettingsStore.has(key)
+ ? [{ value: mockSettingsStore.get(key) as string }]
+ : [],
+ };
+ }
+
+ if (sql.startsWith('INSERT INTO settings')) {
+ const key = String(args?.[0] ?? '');
+ const value = String(args?.[1] ?? '');
+ mockSettingsStore.set(key, value);
+ return { rowsAffected: 1 };
+ }
+
+ return { rows: [] };
+ }),
+ })),
getDataPaths: vi.fn(() => ({
database: '/mock/data/bds.db',
posts: '/mock/data/posts',
@@ -271,6 +294,7 @@ describe('IPC Handlers', () => {
// Clear all mocks
vi.clearAllMocks();
registeredHandlers.clear();
+ mockSettingsStore.clear();
resetMockCounters();
// Import and register handlers fresh for each test
@@ -1544,6 +1568,175 @@ describe('IPC Handlers', () => {
);
});
+ it('should generate rss and atom feeds with newest maxPostsPerPage published snapshots', async () => {
+ const mockProject = createMockProject({
+ id: 'test-project',
+ dataPath: '/mock/data',
+ });
+ mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
+ mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
+ mockMetaEngine.getProjectMetadata.mockResolvedValue({
+ name: 'Test Project',
+ description: 'Test Description',
+ publicUrl: 'https://blog.example.com',
+ maxPostsPerPage: 1,
+ });
+
+ mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
+ if (filter.status === 'published') {
+ return [
+ {
+ id: 'post-new',
+ projectId: 'test-project',
+ title: 'Newest ',
+ slug: 'newest-post',
+ excerpt: '',
+ content: '',
+ status: 'published',
+ createdAt: new Date('2024-03-05T10:00:00Z'),
+ updatedAt: new Date('2024-03-05T11:00:00Z'),
+ publishedAt: new Date('2024-03-05T10:00:00Z'),
+ tags: ['tag-one'],
+ categories: ['category-one'],
+ },
+ {
+ id: 'post-old',
+ projectId: 'test-project',
+ title: 'Old Post',
+ slug: 'old-post',
+ excerpt: '',
+ content: '',
+ status: 'published',
+ createdAt: new Date('2024-02-01T10:00:00Z'),
+ updatedAt: new Date('2024-02-01T11:00:00Z'),
+ publishedAt: new Date('2024-02-01T10:00:00Z'),
+ tags: ['tag-two'],
+ categories: ['category-two'],
+ },
+ ];
+ }
+ if (filter.status === 'draft') {
+ return [];
+ }
+ return [];
+ });
+ mockPostEngine.getPublishedVersion.mockImplementation(async (id: string) => {
+ if (id !== 'post-new') return null;
+ return {
+ id: 'post-new',
+ projectId: 'test-project',
+ title: 'Newest ',
+ slug: 'newest-post',
+ excerpt: undefined,
+ content: 'First paragraph with & symbol.\n\nSecond paragraph.',
+ status: 'published',
+ author: 'Author A',
+ createdAt: new Date('2024-03-05T10:00:00Z'),
+ updatedAt: new Date('2024-03-05T11:00:00Z'),
+ publishedAt: new Date('2024-03-05T10:00:00Z'),
+ tags: ['tag-one'],
+ categories: ['category-one'],
+ };
+ });
+
+ const { writeFile, mkdir } = await import('fs/promises');
+ vi.mocked(mkdir).mockResolvedValue(undefined);
+ vi.mocked(writeFile).mockResolvedValue(undefined);
+
+ mockTaskManager.runTask.mockImplementation(async (task: any) => {
+ const onProgress = vi.fn();
+ return await task.execute(onProgress);
+ });
+
+ await invokeHandler('blog:generateSitemap');
+
+ const writtenFiles = vi.mocked(writeFile).mock.calls.map(([filePath, body]) => ({
+ filePath: filePath as string,
+ body: body as string,
+ }));
+ const rss = writtenFiles.find((entry) => entry.filePath.endsWith('/rss.xml'))?.body;
+ const atom = writtenFiles.find((entry) => entry.filePath.endsWith('/atom.xml'))?.body;
+
+ expect(rss).toBeTruthy();
+ expect(atom).toBeTruthy();
+ expect(rss).toContain('newest-post');
+ expect(rss).not.toContain('old-post');
+ expect(atom).toContain('newest-post');
+ expect(atom).not.toContain('old-post');
+ expect(rss).toContain('Newest <Post>');
+ expect(rss).toContain('First paragraph with <tag> & symbol.');
+ expect(atom).toContain(' {
+ const mockProject = createMockProject({
+ id: 'test-project',
+ dataPath: '/mock/data',
+ });
+ mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
+ mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
+ mockMetaEngine.getProjectMetadata.mockResolvedValue({
+ name: 'Test Project',
+ publicUrl: 'https://blog.example.com',
+ maxPostsPerPage: 5,
+ });
+
+ mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
+ if (filter.status === 'published') {
+ return [
+ {
+ id: 'post-1',
+ projectId: 'test-project',
+ title: 'Hash test',
+ slug: 'hash-test',
+ excerpt: 'Hash excerpt',
+ content: '',
+ status: 'published',
+ createdAt: new Date('2024-01-15T10:00:00Z'),
+ updatedAt: new Date('2024-01-20T15:00:00Z'),
+ publishedAt: new Date('2024-01-15T10:00:00Z'),
+ tags: [],
+ categories: [],
+ },
+ ];
+ }
+ if (filter.status === 'draft') {
+ return [];
+ }
+ return [];
+ });
+ mockPostEngine.getPublishedVersion.mockImplementation(async () => ({
+ id: 'post-1',
+ projectId: 'test-project',
+ title: 'Hash test',
+ slug: 'hash-test',
+ excerpt: 'Hash excerpt',
+ content: 'Hash content',
+ status: 'published',
+ createdAt: new Date('2024-01-15T10:00:00Z'),
+ updatedAt: new Date('2024-01-20T15:00:00Z'),
+ publishedAt: new Date('2024-01-15T10:00:00Z'),
+ tags: [],
+ categories: [],
+ }));
+
+ const { writeFile, mkdir } = await import('fs/promises');
+ vi.mocked(mkdir).mockResolvedValue(undefined);
+ vi.mocked(writeFile).mockResolvedValue(undefined);
+
+ mockTaskManager.runTask.mockImplementation(async (task: any) => {
+ const onProgress = vi.fn();
+ return await task.execute(onProgress);
+ });
+
+ await invokeHandler('blog:generateSitemap');
+ vi.mocked(writeFile).mockClear();
+ await invokeHandler('blog:generateSitemap');
+
+ expect(writeFile).not.toHaveBeenCalled();
+ });
+
it('should throw error when no active project', async () => {
mockProjectEngine.getActiveProject.mockResolvedValue(null);
diff --git a/tests/renderer/documentationStructure.test.ts b/tests/renderer/documentationStructure.test.ts
index e85d927..d488487 100644
--- a/tests/renderer/documentationStructure.test.ts
+++ b/tests/renderer/documentationStructure.test.ts
@@ -9,10 +9,11 @@ describe('documentation structure and presentation', () => {
const docPath = path.resolve(process.cwd(), 'DOCUMENTATION.md');
const markdown = readFileSync(docPath, 'utf8');
- expect(markdown).toContain('## Index');
+ expect(markdown).toContain('## In this article');
expect(markdown).not.toMatch(/^\s{2,}-\s+\[/m);
expect(markdown).not.toMatch(/^##\s+\d+\)/m);
- expect(markdown).toMatch(/^###\s+1\)/m);
+ expect(markdown).toContain('[Who this guide is for](#who-this-guide-is-for)');
+ expect(markdown).toContain('## Who this guide is for');
});
it('scopes Pico conditional styling to the documentation view', () => {