diff --git a/src/main/engine/MetadataDiffEngine.ts b/src/main/engine/MetadataDiffEngine.ts new file mode 100644 index 0000000..8481e26 --- /dev/null +++ b/src/main/engine/MetadataDiffEngine.ts @@ -0,0 +1,469 @@ +/** + * MetadataDiffEngine + * + * Compares metadata between database records and filesystem files for posts and media. + * Used to detect and resolve differences that may have accumulated due to bugs or + * manual edits. + */ + +import { EventEmitter } from 'events'; +import { eq, and } from 'drizzle-orm'; +import { getDatabase } from '../database'; +import { posts, media } from '../database/schema'; +import { readPostFile, PostFileData } from './postFileUtils'; +import { getPostEngine } from './PostEngine'; +import { taskManager } from './TaskManager'; + +/** + * A difference in a specific metadata field + */ +export interface FieldDifference { + dbValue: T; + fileValue: T; +} + +/** + * The fields that can have differences + */ +export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author'; + +/** + * Metadata differences for a single post + */ +export interface PostMetadataDiff { + postId: string; + title: string; + slug: string; + filePath?: string; + hasDifferences: boolean; + differences: Partial>; +} + +/** + * A group of posts with the same type of difference + */ +export interface DiffGroup { + field: DiffField; + label: string; + posts: Array<{ + postId: string; + title: string; + slug: string; + dbValue: unknown; + fileValue: unknown; + }>; +} + +/** + * Result of scanning all published posts + */ +export interface ScanResult { + totalScanned: number; + postsWithDifferences: number; + differences: PostMetadataDiff[]; + groups: DiffGroup[]; +} + +/** + * Statistics about posts/media tables + */ +export interface TableStats { + totalPosts: number; + publishedPosts: number; + draftPosts: number; + totalMedia: number; +} + +export class MetadataDiffEngine extends EventEmitter { + private currentProjectId = 'default'; + + setProjectContext(projectId: string): void { + this.currentProjectId = projectId; + } + + getProjectContext(): string { + return this.currentProjectId; + } + + private getDb() { + return getDatabase().getLocal(); + } + + private getClient() { + return getDatabase().getLocalClient(); + } + + /** + * Get statistics about the posts and media tables + */ + async getTableStats(): Promise { + const db = this.getDb(); + const client = this.getClient(); + if (!client) throw new Error('Database not initialized'); + + // Get post counts + const allPostsResult = await client.execute({ + sql: `SELECT COUNT(*) as count FROM posts WHERE project_id = ?`, + args: [this.currentProjectId], + }); + const totalPosts = Number(allPostsResult.rows[0]?.count ?? 0); + + const publishedResult = await client.execute({ + sql: `SELECT COUNT(*) as count FROM posts WHERE project_id = ? AND status = 'published' AND file_path IS NOT NULL AND file_path != ''`, + args: [this.currentProjectId], + }); + const publishedPosts = Number(publishedResult.rows[0]?.count ?? 0); + + const draftResult = await client.execute({ + sql: `SELECT COUNT(*) as count FROM posts WHERE project_id = ? AND status = 'draft'`, + args: [this.currentProjectId], + }); + const draftPosts = Number(draftResult.rows[0]?.count ?? 0); + + // Get media count + const mediaResult = await client.execute({ + sql: `SELECT COUNT(*) as count FROM media WHERE project_id = ?`, + args: [this.currentProjectId], + }); + const totalMedia = Number(mediaResult.rows[0]?.count ?? 0); + + return { + totalPosts, + publishedPosts, + draftPosts, + totalMedia, + }; + } + + /** + * Compare metadata for a single post between database and file + */ + async comparePostMetadata(postId: string): Promise { + const db = this.getDb(); + + // Get post from database + const dbPost = await db + .select() + .from(posts) + .where(and(eq(posts.id, postId), eq(posts.projectId, this.currentProjectId))) + .get(); + + if (!dbPost) { + return null; + } + + // Skip drafts - they don't have files + if (!dbPost.filePath || dbPost.status === 'draft') { + return null; + } + + // Read file metadata + const fileData = await readPostFile(dbPost.filePath); + if (!fileData) { + // File doesn't exist or can't be read + return { + postId: dbPost.id, + title: dbPost.title, + slug: dbPost.slug, + filePath: dbPost.filePath, + hasDifferences: true, + differences: {}, // File missing entirely + }; + } + + // Compare fields + const differences: Partial> = {}; + + // Parse JSON arrays from database + const dbTags: string[] = JSON.parse(dbPost.tags || '[]'); + const dbCategories: string[] = JSON.parse(dbPost.categories || '[]'); + const fileTags = fileData.tags || []; + const fileCategories = fileData.categories || []; + + // Compare tags (order-independent) + if (!this.arraysEqual(dbTags, fileTags)) { + differences.tags = { dbValue: dbTags, fileValue: fileTags }; + } + + // Compare categories (order-independent) + if (!this.arraysEqual(dbCategories, fileCategories)) { + differences.categories = { dbValue: dbCategories, fileValue: fileCategories }; + } + + // Compare title + if (dbPost.title !== fileData.title) { + differences.title = { dbValue: dbPost.title, fileValue: fileData.title }; + } + + // Compare excerpt + if ((dbPost.excerpt || '') !== (fileData.excerpt || '')) { + differences.excerpt = { dbValue: dbPost.excerpt || '', fileValue: fileData.excerpt || '' }; + } + + // Compare author + if ((dbPost.author || '') !== (fileData.author || '')) { + differences.author = { dbValue: dbPost.author || '', fileValue: fileData.author || '' }; + } + + return { + postId: dbPost.id, + title: dbPost.title, + slug: dbPost.slug, + filePath: dbPost.filePath, + hasDifferences: Object.keys(differences).length > 0, + differences, + }; + } + + /** + * Compare arrays for equality (order-independent) + */ + private arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((val, idx) => val === sortedB[idx]); + } + + /** + * Scan all published posts and find metadata differences + */ + async scanAllPublishedPosts( + onProgress: (current: number, total: number, message: string) => void + ): Promise { + const client = this.getClient(); + if (!client) throw new Error('Database not initialized'); + + // Get all published posts with file paths + const result = await client.execute({ + sql: `SELECT id, title, slug, file_path, tags, categories, excerpt, author + FROM posts + WHERE project_id = ? + AND status = 'published' + AND file_path IS NOT NULL + AND file_path != ''`, + args: [this.currentProjectId], + }); + + const publishedPosts = result.rows; + const total = publishedPosts.length; + const differences: PostMetadataDiff[] = []; + + onProgress(0, total, `Scanning ${total} published posts...`); + + for (let i = 0; i < publishedPosts.length; i++) { + const row = publishedPosts[i]; + const postId = row.id as string; + + const diff = await this.comparePostMetadata(postId); + if (diff && diff.hasDifferences) { + differences.push(diff); + } + + if ((i + 1) % 10 === 0 || i === total - 1) { + onProgress(i + 1, total, `Scanned ${i + 1}/${total} posts, found ${differences.length} with differences`); + } + } + + // Group the differences + const groups = this.groupDifferencesByField(differences); + + return { + totalScanned: total, + postsWithDifferences: differences.length, + differences, + groups, + }; + } + + /** + * Group differences by field type for easier display and bulk actions + */ + groupDifferencesByField(diffs: PostMetadataDiff[]): DiffGroup[] { + const groupMap = new Map(); + + const fieldLabels: Record = { + tags: 'Tags', + categories: 'Categories', + title: 'Title', + excerpt: 'Excerpt', + author: 'Author', + }; + + for (const diff of diffs) { + for (const [field, fieldDiff] of Object.entries(diff.differences)) { + const fieldKey = field as DiffField; + if (!fieldDiff) continue; + + if (!groupMap.has(fieldKey)) { + groupMap.set(fieldKey, { + field: fieldKey, + label: fieldLabels[fieldKey], + posts: [], + }); + } + + groupMap.get(fieldKey)!.posts.push({ + postId: diff.postId, + title: diff.title, + slug: diff.slug, + dbValue: fieldDiff.dbValue, + fileValue: fieldDiff.fileValue, + }); + } + } + + return Array.from(groupMap.values()).sort((a, b) => b.posts.length - a.posts.length); + } + + /** + * Sync database metadata to files for the given posts + * (DB -> File: writes current DB metadata to markdown files) + */ + async syncDbToFile(postIds: string[]): Promise<{ success: number; failed: number }> { + const postEngine = getPostEngine(); + let success = 0; + let failed = 0; + + for (const postId of postIds) { + try { + const synced = await postEngine.syncPublishedPostFile(postId); + if (synced) { + success++; + } else { + failed++; + } + } catch (error) { + console.error(`[MetadataDiffEngine] Failed to sync post ${postId} to file:`, error); + failed++; + } + } + + return { success, failed }; + } + + /** + * Sync file metadata to database for the given posts + * (File -> DB: reads file metadata and updates DB) + */ + async syncFileToDb(postIds: string[], field?: DiffField): Promise<{ success: number; failed: number }> { + const db = this.getDb(); + let success = 0; + let failed = 0; + + for (const postId of postIds) { + try { + // Get the post from DB to get file path + const dbPost = await db + .select() + .from(posts) + .where(and(eq(posts.id, postId), eq(posts.projectId, this.currentProjectId))) + .get(); + + if (!dbPost || !dbPost.filePath) { + failed++; + continue; + } + + // Read file metadata + const fileData = await readPostFile(dbPost.filePath); + if (!fileData) { + failed++; + continue; + } + + // Build update object based on field or all fields + const updateData: Record = { + updatedAt: new Date(), + }; + + if (!field || field === 'tags') { + updateData.tags = JSON.stringify(fileData.tags || []); + } + if (!field || field === 'categories') { + updateData.categories = JSON.stringify(fileData.categories || []); + } + if (!field || field === 'title') { + updateData.title = fileData.title; + } + if (!field || field === 'excerpt') { + updateData.excerpt = fileData.excerpt || null; + } + if (!field || field === 'author') { + updateData.author = fileData.author || null; + } + + // Update database + await db + .update(posts) + .set(updateData) + .where(eq(posts.id, postId)); + + success++; + } catch (error) { + console.error(`[MetadataDiffEngine] Failed to sync post ${postId} to DB:`, error); + failed++; + } + } + + return { success, failed }; + } + + /** + * Run a full scan as a background task + */ + async runScanTask(): Promise { + return taskManager.runTask({ + id: `metadata-diff-scan-${Date.now()}`, + name: 'Scanning for metadata differences', + execute: async (onProgress) => { + return this.scanAllPublishedPosts((current, total, message) => { + const percent = total > 0 ? (current / total) * 100 : 0; + onProgress(percent, message); + }); + }, + }); + } + + /** + * Run sync DB to File as a background task + */ + async runSyncDbToFileTask(postIds: string[], groupLabel: string): Promise<{ success: number; failed: number }> { + return taskManager.runTask({ + id: `metadata-sync-db-to-file-${Date.now()}`, + name: `Syncing ${groupLabel} from DB to files`, + execute: async (onProgress) => { + onProgress(0, `Syncing ${postIds.length} posts...`); + const result = await this.syncDbToFile(postIds); + onProgress(100, `Completed: ${result.success} synced, ${result.failed} failed`); + return result; + }, + }); + } + + /** + * Run sync File to DB as a background task + */ + async runSyncFileToDbTask(postIds: string[], field: DiffField, groupLabel: string): Promise<{ success: number; failed: number }> { + return taskManager.runTask({ + id: `metadata-sync-file-to-db-${Date.now()}`, + name: `Syncing ${groupLabel} from files to DB`, + execute: async (onProgress) => { + onProgress(0, `Syncing ${postIds.length} posts...`); + const result = await this.syncFileToDb(postIds, field); + onProgress(100, `Completed: ${result.success} synced, ${result.failed} failed`); + return result; + }, + }); + } +} + +// Singleton instance +let metadataDiffEngineInstance: MetadataDiffEngine | null = null; + +export function getMetadataDiffEngine(): MetadataDiffEngine { + if (!metadataDiffEngineInstance) { + metadataDiffEngineInstance = new MetadataDiffEngine(); + } + return metadataDiffEngineInstance; +} diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index 495e8c6..97b28f5 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -59,7 +59,17 @@ export { export { ImportDefinitionEngine, type ImportDefinitionData, -} from './ImportDefinitionEngine';export { +} from './ImportDefinitionEngine'; +export { readPostFile, type PostFileData, -} from './postFileUtils'; \ No newline at end of file +} from './postFileUtils'; +export { + MetadataDiffEngine, + getMetadataDiffEngine, + type PostMetadataDiff, + type DiffGroup, + type DiffField, + type ScanResult, + type TableStats, +} from './MetadataDiffEngine'; \ No newline at end of file diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 66bf2cd..05786a8 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1027,6 +1027,65 @@ 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); + }); + // ============ Event Forwarding ============ // Forward engine events to renderer diff --git a/src/main/main.ts b/src/main/main.ts index 6198a2e..9ff0f3d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -253,6 +253,13 @@ function createApplicationMenu(): Menu { mainWindow?.webContents.send('menu:reindexText'); }, }, + { type: 'separator' }, + { + label: 'Metadata Diff Tool', + click: () => { + mainWindow?.webContents.send('menu:metadataDiff'); + }, + }, ], }, { diff --git a/src/main/preload.ts b/src/main/preload.ts index 83482fc..c16734b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -194,6 +194,14 @@ contextBridge.exposeInMainWorld('electronAPI', { }, }, + // Metadata Diff Tool + metadataDiff: { + getStats: () => ipcRenderer.invoke('metadataDiff:getStats'), + scan: () => ipcRenderer.invoke('metadataDiff:scan'), + syncDbToFile: (postIds: string[], groupLabel: string) => ipcRenderer.invoke('metadataDiff:syncDbToFile', postIds, groupLabel), + syncFileToDb: (postIds: string[], field: string, groupLabel: string) => ipcRenderer.invoke('metadataDiff:syncFileToDb', postIds, field, groupLabel), + }, + // AI Chat (OpenCode Zen API integration) chat: { // API Key Management @@ -375,6 +383,39 @@ export interface ElectronAPI { update: (id: string, updates: unknown) => Promise; delete: (id: string) => Promise; }; + metadataDiff: { + getStats: () => Promise<{ + totalPosts: number; + publishedPosts: number; + draftPosts: number; + totalMedia: number; + }>; + scan: () => Promise<{ + totalScanned: number; + postsWithDifferences: number; + differences: Array<{ + postId: string; + title: string; + slug: string; + filePath?: string; + hasDifferences: boolean; + differences: Record; + }>; + groups: Array<{ + field: string; + label: string; + posts: Array<{ + postId: string; + title: string; + slug: string; + dbValue: unknown; + fileValue: unknown; + }>; + }>; + }>; + syncDbToFile: (postIds: string[], groupLabel: string) => Promise<{ success: number; failed: number }>; + syncFileToDb: (postIds: string[], field: string, groupLabel: string) => Promise<{ success: number; failed: number }>; + }; chat: { // API Key Management checkReady: () => Promise<{ ready: boolean; error?: string; backend?: string }>; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5a9381b..80418ac 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -276,6 +276,13 @@ const App: React.FC = () => { }) || (() => {}) ); + unsubscribers.push( + window.electronAPI?.on('menu:metadataDiff', () => { + // Open metadata diff tool tab + openTab({ id: 'metadata-diff', type: 'metadata-diff', title: 'Metadata Diff' }); + }) || (() => {}) + ); + // Import completion event - refresh posts and media stores unsubscribers.push( window.electronAPI?.import.onComplete(async (data) => { diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 941bcf7..b6d44db 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -13,6 +13,7 @@ import { TagsView } from '../TagsView'; import { TagInput } from '../TagInput'; import { ChatPanel } from '../ChatPanel'; import { ImportAnalysisView } from '../ImportAnalysisView'; +import { MetadataDiffPanel } from '../MetadataDiffPanel'; import { AutoSaveManager } from '../../utils'; import { parseMacros, getMacro } from '../../macros/registry'; import { InsertModal } from '../InsertModal'; @@ -2319,6 +2320,7 @@ export const Editor: React.FC = () => { const showTags = activeTab?.type === 'tags'; const showChat = activeTab?.type === 'chat'; const showImport = activeTab?.type === 'import'; + const showMetadataDiff = activeTab?.type === 'metadata-diff'; // Clear selectedPostId if the post doesn't exist (e.g., after project switch) useEffect(() => { @@ -2407,6 +2409,17 @@ export const Editor: React.FC = () => { ); } + // Show metadata diff if metadata-diff tab is active + if (showMetadataDiff) { + return ( +
+ + {renderErrorModal()} + {renderConfirmDeleteModal()} +
+ ); + } + // Show post editor if a post tab is active if (showPost && activeTabId) { return ( diff --git a/src/renderer/components/MetadataDiffPanel/MetadataDiffPanel.css b/src/renderer/components/MetadataDiffPanel/MetadataDiffPanel.css new file mode 100644 index 0000000..b519a8b --- /dev/null +++ b/src/renderer/components/MetadataDiffPanel/MetadataDiffPanel.css @@ -0,0 +1,342 @@ +.metadata-diff-panel { + display: flex; + flex-direction: column; + height: 100%; + padding: 16px; + overflow: auto; + background: var(--editor-background); + color: var(--editor-foreground); +} + +.metadata-diff-panel h2 { + margin: 0 0 16px 0; + font-size: 18px; + font-weight: 600; + color: var(--editor-foreground); +} + +.metadata-diff-panel h3 { + margin: 0 0 8px 0; + font-size: 14px; + font-weight: 600; + color: var(--editor-foreground); +} + +/* Stats Section */ +.diff-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; + margin-bottom: 20px; + padding: 12px; + background: var(--sidebar-background); + border-radius: 6px; + border: 1px solid var(--sidebar-border); +} + +.stat-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.stat-label { + font-size: 11px; + color: var(--descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.stat-value { + font-size: 20px; + font-weight: 600; + color: var(--editor-foreground); +} + +/* Progress Section */ +.diff-progress { + margin-bottom: 20px; + padding: 16px; + background: var(--sidebar-background); + border-radius: 6px; + border: 1px solid var(--sidebar-border); +} + +.progress-bar-container { + height: 6px; + background: var(--input-background); + border-radius: 3px; + overflow: hidden; + margin-top: 8px; +} + +.progress-bar { + height: 100%; + background: var(--button-background); + border-radius: 3px; + transition: width 0.2s ease; +} + +.progress-text { + font-size: 12px; + color: var(--descriptionForeground); + margin-top: 8px; +} + +/* Actions Section */ +.diff-actions { + display: flex; + gap: 8px; + margin-bottom: 20px; +} + +.diff-actions button { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + transition: background 0.2s; +} + +.diff-actions button.primary { + background: var(--button-background); + color: var(--button-foreground); +} + +.diff-actions button.primary:hover:not(:disabled) { + background: var(--button-hoverBackground); +} + +.diff-actions button.secondary { + background: var(--input-background); + color: var(--editor-foreground); + border: 1px solid var(--input-border); +} + +.diff-actions button.secondary:hover:not(:disabled) { + background: var(--list-hoverBackground); +} + +.diff-actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Results Section */ +.diff-results { + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; +} + +.diff-summary { + padding: 12px; + background: var(--sidebar-background); + border-radius: 6px; + border: 1px solid var(--sidebar-border); +} + +.diff-summary.no-differences { + background: color-mix(in srgb, var(--testing-iconPassed) 10%, var(--sidebar-background)); + border-color: var(--testing-iconPassed); +} + +.diff-summary.has-differences { + background: color-mix(in srgb, var(--testing-iconFailed) 10%, var(--sidebar-background)); + border-color: var(--testing-iconFailed); +} + +/* Collapsible Groups */ +.diff-group { + background: var(--sidebar-background); + border: 1px solid var(--sidebar-border); + border-radius: 6px; + overflow: hidden; +} + +.diff-group-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background: var(--list-hoverBackground); + cursor: pointer; + user-select: none; +} + +.diff-group-header:hover { + background: var(--list-activeSelectionBackground); +} + +.diff-group-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; +} + +.diff-group-title .chevron { + font-size: 10px; + transition: transform 0.2s; +} + +.diff-group-title .chevron.expanded { + transform: rotate(90deg); +} + +.diff-group-count { + display: flex; + align-items: center; + gap: 8px; +} + +.diff-group-count .badge { + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + background: var(--badge-background); + color: var(--badge-foreground); +} + +.diff-group-actions { + display: flex; + gap: 4px; +} + +.diff-group-actions button { + padding: 4px 8px; + font-size: 11px; + border: 1px solid var(--input-border); + border-radius: 3px; + background: var(--input-background); + color: var(--editor-foreground); + cursor: pointer; + transition: background 0.2s; +} + +.diff-group-actions button:hover:not(:disabled) { + background: var(--list-hoverBackground); +} + +.diff-group-actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.diff-group-actions button.db-to-file { + border-color: var(--button-background); + color: var(--button-background); +} + +.diff-group-actions button.file-to-db { + border-color: var(--testing-iconQueued); + color: var(--testing-iconQueued); +} + +.diff-group-content { + padding: 12px; + border-top: 1px solid var(--sidebar-border); +} + +.diff-group-content.collapsed { + display: none; +} + +/* Post Items */ +.diff-post-item { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px; + padding: 8px; + margin-bottom: 8px; + background: var(--editor-background); + border-radius: 4px; + font-size: 12px; +} + +.diff-post-item:last-child { + margin-bottom: 0; +} + +.diff-post-title { + font-weight: 500; + color: var(--editor-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.diff-value { + padding: 4px 8px; + border-radius: 3px; + font-family: var(--editor-font-family); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.diff-value.db-value { + background: color-mix(in srgb, var(--button-background) 15%, transparent); + border: 1px solid var(--button-background); +} + +.diff-value.file-value { + background: color-mix(in srgb, var(--testing-iconQueued) 15%, transparent); + border: 1px solid var(--testing-iconQueued); +} + +.diff-value-label { + font-size: 10px; + color: var(--descriptionForeground); + margin-bottom: 2px; +} + +/* Loading State */ +.diff-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 40px; + color: var(--descriptionForeground); +} + +.diff-loading .spinner { + width: 24px; + height: 24px; + border: 2px solid var(--input-border); + border-top-color: var(--button-background); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Empty State */ +.diff-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 40px; + color: var(--descriptionForeground); + text-align: center; +} + +.diff-empty .icon { + font-size: 32px; + opacity: 0.5; +} diff --git a/src/renderer/components/MetadataDiffPanel/MetadataDiffPanel.tsx b/src/renderer/components/MetadataDiffPanel/MetadataDiffPanel.tsx new file mode 100644 index 0000000..09064e7 --- /dev/null +++ b/src/renderer/components/MetadataDiffPanel/MetadataDiffPanel.tsx @@ -0,0 +1,322 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { showToast } from '../Toast'; +import './MetadataDiffPanel.css'; + +interface TableStats { + totalPosts: number; + publishedPosts: number; + draftPosts: number; + totalMedia: number; +} + +interface DiffPost { + postId: string; + title: string; + slug: string; + dbValue: unknown; + fileValue: unknown; +} + +interface DiffGroup { + field: string; + label: string; + posts: DiffPost[]; +} + +interface ScanResult { + totalScanned: number; + postsWithDifferences: number; + differences: Array<{ + postId: string; + title: string; + slug: string; + filePath?: string; + hasDifferences: boolean; + differences: Record; + }>; + groups: DiffGroup[]; +} + +type ScanPhase = 'idle' | 'loading-stats' | 'scanning' | 'complete'; + +export const MetadataDiffPanel: React.FC = () => { + const [stats, setStats] = useState(null); + const [scanResult, setScanResult] = useState(null); + const [scanPhase, setScanPhase] = useState('idle'); + const [progress, setProgress] = useState({ current: 0, total: 0, message: '' }); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [syncingGroups, setSyncingGroups] = useState>(new Set()); + + // Load initial stats + useEffect(() => { + const loadStats = async () => { + setScanPhase('loading-stats'); + try { + const result = await window.electronAPI?.metadataDiff.getStats(); + if (result) { + setStats(result); + } + } catch (error) { + console.error('Failed to load stats:', error); + showToast.error('Failed to load database statistics'); + } + setScanPhase('idle'); + }; + loadStats(); + }, []); + + // Subscribe to task progress + useEffect(() => { + const unsubscribe = window.electronAPI?.on('task:progress', (data: unknown) => { + const progress = data as { id: string; progress: number; message?: string }; + if (progress.id.startsWith('metadata-diff-scan')) { + setProgress({ + current: Math.round(progress.progress), + total: 100, + message: progress.message || '', + }); + } + }); + + return () => { + unsubscribe?.(); + }; + }, []); + + const handleScan = useCallback(async () => { + setScanPhase('scanning'); + setProgress({ current: 0, total: 100, message: 'Starting scan...' }); + setScanResult(null); + + try { + const result = await window.electronAPI?.metadataDiff.scan(); + if (result) { + setScanResult(result); + // Auto-expand groups with differences + const groupsWithDiffs = new Set(result.groups.map(g => g.field)); + setExpandedGroups(groupsWithDiffs); + } + setScanPhase('complete'); + } catch (error) { + console.error('Scan failed:', error); + showToast.error('Failed to scan for differences'); + setScanPhase('idle'); + } + }, []); + + const toggleGroup = (field: string) => { + setExpandedGroups(prev => { + const next = new Set(prev); + if (next.has(field)) { + next.delete(field); + } else { + next.add(field); + } + return next; + }); + }; + + const handleSyncDbToFile = useCallback(async (group: DiffGroup) => { + const postIds = group.posts.map(p => p.postId); + setSyncingGroups(prev => new Set(prev).add(group.field)); + + try { + const result = await window.electronAPI?.metadataDiff.syncDbToFile(postIds, group.label); + if (result) { + showToast.success(`Synced ${result.success} posts to files${result.failed > 0 ? `, ${result.failed} failed` : ''}`); + // Re-scan to update the view + handleScan(); + } + } catch (error) { + console.error('Sync failed:', error); + showToast.error('Failed to sync to files'); + } finally { + setSyncingGroups(prev => { + const next = new Set(prev); + next.delete(group.field); + return next; + }); + } + }, [handleScan]); + + const handleSyncFileToDb = useCallback(async (group: DiffGroup) => { + const postIds = group.posts.map(p => p.postId); + setSyncingGroups(prev => new Set(prev).add(group.field)); + + try { + const result = await window.electronAPI?.metadataDiff.syncFileToDb(postIds, group.field, group.label); + if (result) { + showToast.success(`Synced ${result.success} files to database${result.failed > 0 ? `, ${result.failed} failed` : ''}`); + // Re-scan to update the view + handleScan(); + } + } catch (error) { + console.error('Sync failed:', error); + showToast.error('Failed to sync to database'); + } finally { + setSyncingGroups(prev => { + const next = new Set(prev); + next.delete(group.field); + return next; + }); + } + }, [handleScan]); + + const formatValue = (value: unknown): string => { + if (Array.isArray(value)) { + return value.length > 0 ? value.join(', ') : '(empty)'; + } + if (value === null || value === undefined || value === '') { + return '(empty)'; + } + return String(value); + }; + + return ( +
+

Metadata Diff Tool

+

+ Compare post metadata between database and markdown files. Fix inconsistencies caused by bugs or manual edits. +

+ + {/* Stats Section */} + {stats && ( +
+
+ Total Posts + {stats.totalPosts} +
+
+ Published + {stats.publishedPosts} +
+
+ Drafts + {stats.draftPosts} +
+
+ Media Files + {stats.totalMedia} +
+
+ )} + + {/* Progress Section */} + {scanPhase === 'scanning' && ( +
+

Scanning published posts...

+
+
+
+
{progress.message}
+
+ )} + + {/* Actions Section */} +
+ +
+ + {/* Results Section */} + {scanPhase === 'complete' && scanResult && ( +
+
0 ? 'has-differences' : 'no-differences'}`}> + {scanResult.postsWithDifferences === 0 ? ( + <>✅ No differences found! All {scanResult.totalScanned} published posts are in sync. + ) : ( + <> + ⚠️ Found {scanResult.postsWithDifferences} posts with differences + out of {scanResult.totalScanned} published posts. + + )} +
+ + {/* Groups */} + {scanResult.groups.map(group => ( +
+
toggleGroup(group.field)} + > +
+ + ▶ + + {group.label} Differences +
+
+ {group.posts.length} posts +
e.stopPropagation()}> + + +
+
+
+
+ {group.posts.map(post => ( +
+
+ {post.title || post.slug} +
+
+
Database
+
+ {formatValue(post.dbValue)} +
+
+
+
File
+
+ {formatValue(post.fileValue)} +
+
+
+ ))} +
+
+ ))} +
+ )} + + {/* Empty state */} + {scanPhase === 'idle' && !scanResult && ( +
+
📊
+
Click "Scan for Differences" to compare database metadata with file metadata.
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/MetadataDiffPanel/index.ts b/src/renderer/components/MetadataDiffPanel/index.ts new file mode 100644 index 0000000..28f0aa8 --- /dev/null +++ b/src/renderer/components/MetadataDiffPanel/index.ts @@ -0,0 +1 @@ +export { MetadataDiffPanel } from './MetadataDiffPanel'; diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index 80d3720..d5723b1 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -6,7 +6,7 @@ import type { DeleteReference, ConfirmDeleteDetails } from '../components/Confir const STORAGE_KEY = 'bds-app-state'; // Tab types -export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat' | 'import'; +export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat' | 'import' | 'metadata-diff'; export interface Tab { type: TabType; diff --git a/tests/engine/MetadataDiffEngine.test.ts b/tests/engine/MetadataDiffEngine.test.ts new file mode 100644 index 0000000..8ede98a --- /dev/null +++ b/tests/engine/MetadataDiffEngine.test.ts @@ -0,0 +1,533 @@ +/** + * MetadataDiffEngine Unit Tests + * + * Tests the REAL MetadataDiffEngine class with mocked dependencies. + * Following TDD best practices: mock external dependencies, test real implementation. + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { MetadataDiffEngine, PostMetadataDiff, DiffGroup, DiffField } from '../../src/main/engine/MetadataDiffEngine'; +import { resetMockCounters } from '../utils/factories'; + +// Mock posts data store - used for single-item .get() queries +const mockPosts = new Map(); +// Queue of posts for sequential .get() calls (used in scanAllPublishedPosts) +let mockPostsGetQueue: any[] = []; +let mockAllPostsRows: any[] = []; + +// Create chainable mock for Drizzle ORM +function createSelectChain(data: any[] = []) { + const chain: any = { + from: vi.fn().mockImplementation(() => chain), + where: vi.fn().mockImplementation(() => chain), + orderBy: vi.fn().mockImplementation(() => chain), + limit: vi.fn().mockImplementation(() => chain), + offset: vi.fn().mockImplementation(() => chain), + all: vi.fn().mockResolvedValue(data), + get: vi.fn().mockImplementation(() => { + // If there are queued posts, return from queue + if (mockPostsGetQueue.length > 0) { + return Promise.resolve(mockPostsGetQueue.shift()); + } + // Otherwise return from map + return Promise.resolve(mockPosts.size > 0 ? Array.from(mockPosts.values())[0] : undefined); + }), + then: (resolve: (value: any[]) => void, reject?: (reason: any) => void) => { + return Promise.resolve(data).then(resolve, reject); + }, + }; + return chain; +} + +function createDrizzleMock() { + return { + select: vi.fn(() => createSelectChain(mockAllPostsRows)), + insert: vi.fn(() => ({ + values: vi.fn(() => Promise.resolve()), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + }; +} + +const mockLocalDb = createDrizzleMock(); +const mockLocalClient = { + execute: vi.fn(async () => ({ rows: [] })), +}; + +// Mock the database module +vi.mock('../../src/main/database', () => ({ + getDatabase: vi.fn(() => ({ + getLocal: vi.fn(() => mockLocalDb), + getLocalClient: vi.fn(() => mockLocalClient), + getRemote: vi.fn(() => null), + getDataPaths: vi.fn(() => ({ + database: '/mock/userData/bds.db', + posts: '/mock/userData/posts', + media: '/mock/userData/media', + })), + initializeLocal: vi.fn(), + initializeRemote: vi.fn(), + close: vi.fn(), + })), +})); + +// Mock file contents for readPostFile +const mockFileData = new Map(); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + readFile: vi.fn(async (path: string) => { + const data = mockFileData.get(path); + if (!data) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + return data; + }), + writeFile: vi.fn(async () => {}), + unlink: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + readdir: vi.fn(async () => []), + access: vi.fn(async (path: string) => { + if (!mockFileData.has(path)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }), + stat: vi.fn(async () => ({ + isFile: () => true, + isDirectory: () => false, + })), +})); + +// Mock gray-matter +vi.mock('gray-matter', () => ({ + default: vi.fn((content: string) => { + // Simple mock that extracts frontmatter + const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) return { data: {}, content }; + + // Parse YAML-like frontmatter + const frontmatter = match[1]; + const body = match[2]; + const data: any = {}; + + frontmatter.split('\n').forEach(line => { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + let value = line.slice(colonIndex + 1).trim(); + + // Parse arrays + if (value.startsWith('[') && value.endsWith(']')) { + value = JSON.parse(value.replace(/'/g, '"')); + } + // Parse strings + else if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + + data[key] = value; + } + }); + + return { data, content: body }; + }), +})); + +// Mock electron app +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/mock/userData'), + }, +})); + +// Mock TaskManager +vi.mock('../../src/main/engine/TaskManager', () => ({ + taskManager: { + runTask: vi.fn(async (task: any) => { + return task.execute((progress: number, message: string) => {}); + }), + }, +})); + +// Track the mock function for PostEngine.syncPublishedPostFile +const mockSyncPublishedPostFile = vi.fn(async () => true); + +// Mock PostEngine +vi.mock('../../src/main/engine/PostEngine', () => ({ + getPostEngine: vi.fn(() => ({ + syncPublishedPostFile: mockSyncPublishedPostFile, + })), +})); + +describe('MetadataDiffEngine', () => { + let engine: MetadataDiffEngine; + + beforeEach(() => { + vi.clearAllMocks(); + mockPosts.clear(); + mockPostsGetQueue = []; + mockFileData.clear(); + mockAllPostsRows = []; + mockSyncPublishedPostFile.mockClear(); + resetMockCounters(); + engine = new MetadataDiffEngine(); + engine.setProjectContext('test-project'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should create a new MetadataDiffEngine instance', () => { + expect(engine).toBeDefined(); + expect(engine).toBeInstanceOf(MetadataDiffEngine); + }); + }); + + describe('setProjectContext', () => { + it('should set the current project ID', () => { + engine.setProjectContext('project-123'); + expect(engine.getProjectContext()).toBe('project-123'); + }); + }); + + describe('comparePostMetadata', () => { + it('should return null for draft posts (no file)', async () => { + // Set up a draft post in database + const dbPost = { + id: 'post-1', + projectId: 'test-project', + title: 'Draft Post', + slug: 'draft-post', + status: 'draft', + filePath: null, + tags: '["tag1"]', + categories: '["cat1"]', + createdAt: new Date(), + updatedAt: new Date(), + }; + mockPosts.set('post-1', dbPost); + + const result = await engine.comparePostMetadata('post-1'); + + expect(result).toBeNull(); + }); + + it('should detect tag differences between DB and file', async () => { + const dbPost = { + id: 'post-1', + projectId: 'test-project', + title: 'Published Post', + slug: 'published-post', + status: 'published', + filePath: '/mock/userData/posts/2024/01/published-post.md', + tags: '["tag1", "tag2"]', + categories: '["cat1"]', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + publishedAt: new Date('2024-01-15'), + }; + + // DB has tag2 but file doesn't + mockPosts.set('post-1', dbPost); + + // File has different tags + mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- +id: post-1 +projectId: test-project +title: "Published Post" +slug: published-post +status: published +tags: ["tag1", "old-tag"] +categories: ["cat1"] +createdAt: 2024-01-15T00:00:00.000Z +updatedAt: 2024-01-15T00:00:00.000Z +publishedAt: 2024-01-15T00:00:00.000Z +--- +Content here`); + + const result = await engine.comparePostMetadata('post-1'); + + expect(result).not.toBeNull(); + expect(result?.hasDifferences).toBe(true); + expect(result?.differences.tags).toBeDefined(); + expect(result?.differences.tags?.dbValue).toEqual(['tag1', 'tag2']); + expect(result?.differences.tags?.fileValue).toEqual(['tag1', 'old-tag']); + }); + + it('should detect category differences between DB and file', async () => { + const dbPost = { + id: 'post-1', + projectId: 'test-project', + title: 'Published Post', + slug: 'published-post', + status: 'published', + filePath: '/mock/userData/posts/2024/01/published-post.md', + tags: '[]', + categories: '["cat1", "cat2"]', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + publishedAt: new Date('2024-01-15'), + }; + + mockPosts.set('post-1', dbPost); + + mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- +id: post-1 +projectId: test-project +title: "Published Post" +slug: published-post +status: published +tags: [] +categories: ["cat1"] +createdAt: 2024-01-15T00:00:00.000Z +updatedAt: 2024-01-15T00:00:00.000Z +publishedAt: 2024-01-15T00:00:00.000Z +--- +Content here`); + + const result = await engine.comparePostMetadata('post-1'); + + expect(result).not.toBeNull(); + expect(result?.hasDifferences).toBe(true); + expect(result?.differences.categories).toBeDefined(); + expect(result?.differences.categories?.dbValue).toEqual(['cat1', 'cat2']); + expect(result?.differences.categories?.fileValue).toEqual(['cat1']); + }); + + it('should return hasDifferences=false when metadata matches', async () => { + const dbPost = { + id: 'post-1', + projectId: 'test-project', + title: 'Published Post', + slug: 'published-post', + status: 'published', + filePath: '/mock/userData/posts/2024/01/published-post.md', + tags: '["tag1"]', + categories: '["cat1"]', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + publishedAt: new Date('2024-01-15'), + }; + + mockPosts.set('post-1', dbPost); + + mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- +id: post-1 +projectId: test-project +title: "Published Post" +slug: published-post +status: published +tags: ["tag1"] +categories: ["cat1"] +createdAt: 2024-01-15T00:00:00.000Z +updatedAt: 2024-01-15T00:00:00.000Z +publishedAt: 2024-01-15T00:00:00.000Z +--- +Content here`); + + const result = await engine.comparePostMetadata('post-1'); + + expect(result).not.toBeNull(); + expect(result?.hasDifferences).toBe(false); + }); + }); + + describe('scanAllPublishedPosts', () => { + it('should scan all published posts and return differences', async () => { + // Mock the raw SQL query that returns published posts + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [ + { + id: 'post-1', + title: 'Post 1', + slug: 'post-1', + file_path: '/mock/userData/posts/2024/01/post-1.md', + tags: '["new-tag"]', + categories: '["cat1"]', + excerpt: null, + author: null, + }, + { + id: 'post-2', + title: 'Post 2', + slug: 'post-2', + file_path: '/mock/userData/posts/2024/01/post-2.md', + tags: '["tag1"]', + categories: '["cat1"]', + excerpt: null, + author: null, + }, + ], + }); + + // Queue the posts for sequential .get() calls in comparePostMetadata + mockPostsGetQueue = [ + { + id: 'post-1', + projectId: 'test-project', + title: 'Post 1', + slug: 'post-1', + status: 'published', + filePath: '/mock/userData/posts/2024/01/post-1.md', + tags: '["new-tag"]', + categories: '["cat1"]', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + }, + { + id: 'post-2', + projectId: 'test-project', + title: 'Post 2', + slug: 'post-2', + status: 'published', + filePath: '/mock/userData/posts/2024/01/post-2.md', + tags: '["tag1"]', + categories: '["cat1"]', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + }, + ]; + + // Post 1 has tag difference + mockFileData.set('/mock/userData/posts/2024/01/post-1.md', `--- +id: post-1 +projectId: test-project +title: "Post 1" +slug: post-1 +status: published +tags: ["old-tag"] +categories: ["cat1"] +createdAt: 2024-01-15T00:00:00.000Z +updatedAt: 2024-01-15T00:00:00.000Z +publishedAt: 2024-01-15T00:00:00.000Z +--- +Content`); + + // Post 2 matches + mockFileData.set('/mock/userData/posts/2024/01/post-2.md', `--- +id: post-2 +projectId: test-project +title: "Post 2" +slug: post-2 +status: published +tags: ["tag1"] +categories: ["cat1"] +createdAt: 2024-01-15T00:00:00.000Z +updatedAt: 2024-01-15T00:00:00.000Z +publishedAt: 2024-01-15T00:00:00.000Z +--- +Content`); + + const result = await engine.scanAllPublishedPosts((current, total) => {}); + + expect(result.totalScanned).toBe(2); + expect(result.postsWithDifferences).toBe(1); + expect(result.differences.length).toBe(1); + expect(result.differences[0].postId).toBe('post-1'); + }); + }); + + describe('groupDifferencesByField', () => { + it('should group differences by field type', () => { + const diffs: PostMetadataDiff[] = [ + { + postId: 'post-1', + title: 'Post 1', + slug: 'post-1', + hasDifferences: true, + differences: { + tags: { dbValue: ['new-tag'], fileValue: ['old-tag'] }, + }, + }, + { + postId: 'post-2', + title: 'Post 2', + slug: 'post-2', + hasDifferences: true, + differences: { + tags: { dbValue: ['tag1'], fileValue: ['tag2'] }, + categories: { dbValue: ['cat1'], fileValue: ['cat2'] }, + }, + }, + { + postId: 'post-3', + title: 'Post 3', + slug: 'post-3', + hasDifferences: true, + differences: { + categories: { dbValue: ['catA'], fileValue: ['catB'] }, + }, + }, + ]; + + const groups = engine.groupDifferencesByField(diffs); + + expect(groups).toHaveLength(2); + + const tagsGroup = groups.find(g => g.field === 'tags'); + expect(tagsGroup).toBeDefined(); + expect(tagsGroup?.posts).toHaveLength(2); + + const categoriesGroup = groups.find(g => g.field === 'categories'); + expect(categoriesGroup).toBeDefined(); + expect(categoriesGroup?.posts).toHaveLength(2); + }); + }); + + describe('syncDbToFile', () => { + it('should sync database metadata to file for given posts', async () => { + const postIds = ['post-1', 'post-2']; + + // This will call syncPublishedPostFile for each post + await engine.syncDbToFile(postIds); + + // PostEngine.syncPublishedPostFile should have been called twice + expect(mockSyncPublishedPostFile).toHaveBeenCalledTimes(2); + expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('post-1'); + expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('post-2'); + }); + }); + + describe('syncFileToDb', () => { + it('should sync file metadata to database for given posts', async () => { + const dbPost = { + id: 'post-1', + projectId: 'test-project', + title: 'Published Post', + slug: 'published-post', + status: 'published', + filePath: '/mock/userData/posts/2024/01/published-post.md', + tags: '["db-tag"]', + categories: '["db-cat"]', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + publishedAt: new Date('2024-01-15'), + }; + mockPosts.set('post-1', dbPost); + + mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- +id: post-1 +projectId: test-project +title: "Published Post" +slug: published-post +status: published +tags: ["file-tag"] +categories: ["file-cat"] +createdAt: 2024-01-15T00:00:00.000Z +updatedAt: 2024-01-15T00:00:00.000Z +publishedAt: 2024-01-15T00:00:00.000Z +--- +Content here`); + + await engine.syncFileToDb(['post-1'], 'tags'); + + // Verify the database update was called + expect(mockLocalDb.update).toHaveBeenCalled(); + }); + }); +});