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', () => {