import { app, BrowserWindow, ipcMain, dialog, shell, clipboard } from 'electron'; import * as path from 'path'; import * as fsPromises from 'fs/promises'; import { eq } from 'drizzle-orm'; import { getPostEngine, PostData, PostFilter, PaginationOptions } from '../engine/PostEngine'; import { getMediaEngine, MediaData } from '../engine/MediaEngine'; import { getProjectEngine, ProjectData } from '../engine/ProjectEngine'; import { getMetaEngine } from '../engine/MetaEngine'; import { getMenuEngine, type MenuDocument } from '../engine/MenuEngine'; import { getTagEngine } from '../engine/TagEngine'; import { getPostMediaEngine } from '../engine/PostMediaEngine'; import { getGitEngine } from '../engine/GitEngine'; 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 { generateBlogmarkBookmarkletSource } from '../shared/blogmark'; import { registerMetadataDiffHandlers } from './metadataDiffHandlers'; import { registerBlogHandlers } from './blogHandlers'; /** * Wrap an IPC handler so that "Database is closing" errors during shutdown * are silently swallowed instead of being logged as scary red error messages. */ function safeHandle(channel: string, handler: (...args: any[]) => Promise): void { ipcMain.handle(channel, async (...args) => { try { return await handler(...args); } catch (error: any) { if (error?.message === 'Database is closing') { return null; // Silently ignore during shutdown } throw error; // Re-throw all other errors } }); } 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 runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean { if (!sender) { return false; } if (!APP_MENU_WEB_CONTENTS_ACTIONS.has(action)) { return false; } switch (action) { case 'undo': sender.undo?.(); return true; case 'redo': sender.redo?.(); return true; case 'cut': sender.cut?.(); return true; case 'copy': sender.copy?.(); return true; case 'paste': sender.paste?.(); return true; case 'delete': sender.delete?.(); return true; case 'selectAll': sender.selectAll?.(); return true; case 'toggleDevTools': sender.toggleDevTools?.(); return true; case 'reload': sender.reload?.(); return true; case 'forceReload': sender.reloadIgnoringCache?.(); return true; case 'resetZoom': sender.setZoomLevel?.(0); return true; case 'zoomIn': { const currentZoomLevel = sender.getZoomLevel?.() ?? 0; sender.setZoomLevel?.(currentZoomLevel + 0.5); return true; } case 'zoomOut': { const currentZoomLevel = sender.getZoomLevel?.() ?? 0; sender.setZoomLevel?.(currentZoomLevel - 0.5); return true; } case 'toggleFullScreen': { const ownerWindow = BrowserWindow.fromWebContents(sender); if (!ownerWindow) { return false; } ownerWindow.setFullScreen(!ownerWindow.isFullScreen()); return true; } default: return false; } } export function registerIpcHandlers(): void { // ============ Git Handlers ============ safeHandle('git:checkAvailability', async () => { const engine = getGitEngine(); return engine.checkAvailability(); }); safeHandle('git:getRepoState', async (_, projectPath: string) => { const engine = getGitEngine(); return engine.getRepoState(projectPath); }); safeHandle('git:status', async (_, projectPath: string) => { const engine = getGitEngine(); return engine.getStatus(projectPath); }); safeHandle('git:diff', async (_, projectPath: string, filePath: string) => { const engine = getGitEngine(); return engine.getDiff(projectPath, filePath); }); safeHandle('git:diffContent', async (_, projectPath: string, filePath: string) => { const engine = getGitEngine(); return engine.getDiffContent(projectPath, filePath); }); safeHandle('git:commitDiffContent', async (_, projectPath: string, commitHash: string) => { const engine = getGitEngine(); return engine.getCommitDiffContent(projectPath, commitHash); }); safeHandle('git:history', async (_, projectPath: string, limit?: number) => { const engine = getGitEngine(); return engine.getHistory(projectPath, limit); }); safeHandle('git:fileHistory', async (_, projectPath: string, filePath: string, limit?: number) => { const engine = getGitEngine(); return engine.getFileHistory(projectPath, filePath, limit); }); safeHandle('git:remoteState', async (_, projectPath: string) => { const engine = getGitEngine(); return engine.getRemoteState(projectPath); }); safeHandle('git:fetch', async (_, projectPath: string) => { const engine = getGitEngine(); return engine.fetch(projectPath); }); safeHandle('git:pull', async (_, projectPath: string) => { const engine = getGitEngine(); const beforeHead = await engine.getHeadCommit(projectPath); const pullResult = await engine.pull(projectPath); if (!pullResult.success) { return pullResult; } const afterHead = await engine.getHeadCommit(projectPath); if (!beforeHead || !afterHead || beforeHead === afterHead) { return pullResult; } const changedPostFiles = await engine.getChangedPostFilesBetween(projectPath, beforeHead, afterHead); if (changedPostFiles.length === 0) { return pullResult; } try { const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); const postEngine = getPostEngine(); if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); postEngine.setProjectContext(project.id, dataDir); } await postEngine.reconcilePublishedPostsFromGitChanges(projectPath, changedPostFiles); } catch (error) { console.error('Failed to reconcile published posts after git pull:', error); } return pullResult; }); safeHandle('git:push', async (_, projectPath: string) => { const engine = getGitEngine(); return engine.push(projectPath); }); safeHandle('git:commitAll', async (_, projectPath: string, message: string) => { const engine = getGitEngine(); return engine.commitAll(projectPath, message); }); safeHandle('git:init', async (event, projectPath: string, remoteUrl?: string) => { const engine = getGitEngine(); return engine.initializeRepo(projectPath, remoteUrl, (progress) => { event.sender.send('git:initProgress', progress); }); }); safeHandle('git:ensureGitignore', async (_, projectPath: string) => { const engine = getGitEngine(); return engine.ensureGitignore(projectPath); }); safeHandle('git:pruneLfs', async (_, projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean }) => { const engine = getGitEngine(); return engine.pruneLfsCache(projectPath, options); }); // ============ Project Handlers ============ safeHandle('projects:create', async (_, data: { name: string; description?: string; slug?: string; dataPath?: string }) => { const engine = getProjectEngine(); return engine.createProject(data); }); safeHandle('projects:update', async (_, id: string, data: Partial) => { const engine = getProjectEngine(); return engine.updateProject(id, data); }); safeHandle('projects:delete', async (_, id: string) => { const engine = getProjectEngine(); return engine.deleteProject(id); }); safeHandle('projects:deleteWithData', async (_, id: string) => { const engine = getProjectEngine(); return engine.deleteProjectWithData(id); }); safeHandle('app:getSystemLanguage', async () => { return app.getLocale(); }); safeHandle('projects:get', async (_, id: string) => { const engine = getProjectEngine(); return engine.getProject(id); }); safeHandle('projects:getAll', async () => { const engine = getProjectEngine(); return engine.getAllProjects(); }); safeHandle('projects:getActive', async () => { const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); // Ensure all engines have the correct project context if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); // For thumbnails and meta: use dataDir (whether custom or internal) // This ensures all project data lives in the same location for backup const postEngine = getPostEngine(); const mediaEngine = getMediaEngine(); const metaEngine = getMetaEngine(); const menuEngine = getMenuEngine(); const tagEngine = getTagEngine(); postEngine.setProjectContext(project.id, dataDir); mediaEngine.setProjectContext(project.id, dataDir, dataDir); metaEngine.setProjectContext(project.id, dataDir); menuEngine.setProjectContext(project.id, dataDir); tagEngine.setProjectContext(project.id, dataDir); const postMediaEngine = getPostMediaEngine(); postMediaEngine.setProjectContext(project.id); // Sync meta on startup await metaEngine.syncOnStartup(); // Set search language from project metadata const { isoToStemmerLanguage } = await import('../engine/stemmer'); const metadata = await metaEngine.getProjectMetadata(); if (metadata?.mainLanguage) { const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage); postEngine.setSearchLanguage(stemmerLang); mediaEngine.setSearchLanguage(stemmerLang); } } return project; }); safeHandle('projects:setActive', async (_, id: string) => { const projectEngine = getProjectEngine(); const project = await projectEngine.setActiveProject(id); // Update all engines to use the new project context if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); // For thumbnails and meta: use dataDir (whether custom or internal) // This ensures all project data lives in the same location for backup const postEngine = getPostEngine(); const mediaEngine = getMediaEngine(); const metaEngine = getMetaEngine(); const menuEngine = getMenuEngine(); const tagEngine = getTagEngine(); postEngine.setProjectContext(project.id, dataDir); mediaEngine.setProjectContext(project.id, dataDir, dataDir); metaEngine.setProjectContext(project.id, dataDir); menuEngine.setProjectContext(project.id, dataDir); tagEngine.setProjectContext(project.id, dataDir); const postMediaEngine = getPostMediaEngine(); postMediaEngine.setProjectContext(project.id); // Sync meta on project switch await metaEngine.syncOnStartup(); // Set search language from project metadata const { isoToStemmerLanguage } = await import('../engine/stemmer'); const metadata = await metaEngine.getProjectMetadata(); if (metadata?.mainLanguage) { const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage); postEngine.setSearchLanguage(stemmerLang); mediaEngine.setSearchLanguage(stemmerLang); } } return project; }); // ============ Post Handlers ============ safeHandle('posts:create', async (_, data: Partial) => { const engine = getPostEngine(); // If no author provided, use default author from project settings if (!data.author) { const metaEngine = getMetaEngine(); const metadata = await metaEngine.getProjectMetadata(); if (metadata?.defaultAuthor) { data.author = metadata.defaultAuthor; } } return engine.createPost(data); }); safeHandle('posts:isSlugAvailable', async (_, slug: string, excludePostId?: string) => { const engine = getPostEngine(); return engine.isSlugAvailable(slug, excludePostId); }); safeHandle('posts:generateUniqueSlug', async (_, title: string, excludePostId?: string) => { const engine = getPostEngine(); return engine.generateUniqueSlug(title, excludePostId); }); safeHandle('posts:update', async (_, id: string, data: Partial) => { const engine = getPostEngine(); return engine.updatePost(id, data); }); safeHandle('posts:delete', async (_, id: string) => { const engine = getPostEngine(); return engine.deletePost(id); }); safeHandle('posts:get', async (_, id: string) => { const engine = getPostEngine(); return engine.getPost(id); }); safeHandle('posts:getPreviewUrl', async (_, id: string, options?: { draft?: boolean }) => { const engine = getPostEngine(); const post = await engine.getPost(id); if (!post) { return null; } const createdAt = resolvePostCreatedAt(post); const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); if (options?.draft) { return `http://127.0.0.1:4123${canonicalPath}?draft=true&postId=${encodeURIComponent(id)}`; } return `http://127.0.0.1:4123${canonicalPath}`; }); safeHandle('posts:getAll', async (_, options?: PaginationOptions) => { const engine = getPostEngine(); return engine.getAllPosts(options); }); safeHandle('posts:getByStatus', async (_, status: 'draft' | 'published' | 'archived') => { const engine = getPostEngine(); return engine.getPostsByStatus(status); }); safeHandle('posts:publish', async (_, id: string) => { const engine = getPostEngine(); return engine.publishPost(id); }); safeHandle('posts:discard', async (_, id: string) => { const engine = getPostEngine(); return engine.discardChanges(id); }); safeHandle('posts:hasPublishedVersion', async (_, id: string) => { const engine = getPostEngine(); return engine.hasPublishedVersion(id); }); safeHandle('posts:rebuildFromFiles', async () => { // Ensure project context is current before rebuilding const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); const engine = getPostEngine(); const metaEngine = getMetaEngine(); if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); engine.setProjectContext(project.id, dataDir); metaEngine.setProjectContext(project.id, dataDir); } await engine.rebuildDatabaseFromFiles(); await metaEngine.syncOnStartup(); return true; }); safeHandle('posts:search', async (_, query: string) => { const engine = getPostEngine(); return engine.searchPosts(query); }); safeHandle('posts:filter', async (_, filter: PostFilter) => { const engine = getPostEngine(); return engine.getPostsFiltered(filter); }); safeHandle('posts:getTags', async () => { const engine = getPostEngine(); return engine.getAvailableTags(); }); safeHandle('posts:getCategories', async () => { const engine = getPostEngine(); return engine.getAvailableCategories(); }); safeHandle('posts:getByYearMonth', async () => { const engine = getPostEngine(); return engine.getPostsByYearMonth(); }); safeHandle('posts:getTagsWithCounts', async () => { const engine = getPostEngine(); return engine.getTagsWithCounts(); }); safeHandle('posts:getCategoriesWithCounts', async () => { const engine = getPostEngine(); return engine.getCategoriesWithCounts(); }); safeHandle('posts:getDashboardStats', async () => { const engine = getPostEngine(); return engine.getDashboardStats(); }); safeHandle('posts:getLinksTo', async (_, id: string) => { const engine = getPostEngine(); return engine.getLinksTo(id); }); safeHandle('posts:getLinkedBy', async (_, id: string) => { const engine = getPostEngine(); return engine.getLinkedBy(id); }); safeHandle('posts:rebuildLinks', async () => { const engine = getPostEngine(); return engine.rebuildAllPostLinks(); }); safeHandle('posts:reindexText', async () => { const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); const engine = getPostEngine(); if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); engine.setProjectContext(project.id, dataDir); } return engine.reindexText(); }); // ============ Media Handlers ============ safeHandle('media:import', async (_, sourcePath: string, metadata?: Partial) => { const engine = getMediaEngine(); // If no author provided, use default author from project settings if (!metadata?.author) { const metaEngine = getMetaEngine(); const projectMetadata = await metaEngine.getProjectMetadata(); if (projectMetadata?.defaultAuthor) { metadata = metadata || {}; metadata.author = projectMetadata.defaultAuthor; } } return engine.importMedia(sourcePath, metadata); }); safeHandle('media:importDialog', async () => { const result = await dialog.showOpenDialog({ title: 'Import Media', filters: [ { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'] }, { name: 'All Files', extensions: ['*'] }, ], properties: ['openFile', 'multiSelections'], }); if (result.canceled || result.filePaths.length === 0) { return []; } // Ensure project context is current before importing const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); const engine = getMediaEngine(); if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); // For thumbnails and meta: use dataDir (whether custom or internal) // This ensures all project data lives in the same location for backup engine.setProjectContext(project.id, dataDir, dataDir); } const imported: MediaData[] = []; // Get default author from project settings const metaEngine = getMetaEngine(); const projectMetadata = await metaEngine.getProjectMetadata(); const defaultAuthor = projectMetadata?.defaultAuthor; for (const filePath of result.filePaths) { try { const media = await engine.importMedia(filePath, defaultAuthor ? { author: defaultAuthor } : undefined); imported.push(media); } catch (error) { console.error(`Failed to import ${filePath}:`, error); } } return imported; }); safeHandle('media:update', async (_, id: string, data: Partial) => { const engine = getMediaEngine(); return engine.updateMedia(id, data); }); safeHandle('media:replaceFile', async (_, id: string, newSourcePath: string) => { const engine = getMediaEngine(); return engine.replaceMediaFile(id, newSourcePath); }); safeHandle('media:replaceFileDialog', async (_, id: string) => { // Get the current media to determine file type filter const engine = getMediaEngine(); const currentMedia = await engine.getMedia(id); if (!currentMedia) { return null; } // Build filter based on current media type let filters: { name: string; extensions: string[] }[] = []; if (currentMedia.mimeType.startsWith('image/')) { filters = [ { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'] }, { name: 'All Files', extensions: ['*'] }, ]; } else { filters = [{ name: 'All Files', extensions: ['*'] }]; } const result = await dialog.showOpenDialog({ title: 'Replace Media File', filters, properties: ['openFile'], }); if (result.canceled || result.filePaths.length === 0) { return null; } return engine.replaceMediaFile(id, result.filePaths[0]); }); safeHandle('media:delete', async (_, id: string) => { const engine = getMediaEngine(); return engine.deleteMedia(id); }); safeHandle('media:get', async (_, id: string) => { const engine = getMediaEngine(); return engine.getMedia(id); }); safeHandle('media:getUrl', async (_, id: string) => { // Returns the relative path for a media item (e.g. media/2025/01/uuid.jpg) // and exposes it as an absolute preview path (e.g. /media/2025/01/uuid.jpg) // so inserted markdown uses root-absolute URLs. const engine = getMediaEngine(); const relativePath = await engine.getRelativePath(id); const normalized = relativePath ?? `media/${id}`; return normalized.startsWith('/') ? normalized : `/${normalized}`; }); safeHandle('media:getFilePath', async (_, id: string) => { // Returns the actual file path for a media item (for debugging/advanced use) const db = getDatabase().getLocal(); const mediaItem = await db.select().from(media).where(eq(media.id, id)).get(); return mediaItem?.filePath ?? null; }); safeHandle('media:getAll', async () => { const engine = getMediaEngine(); return engine.getAllMedia(); }); safeHandle('media:filter', async (_, filter: import('../engine/MediaEngine').MediaFilter) => { const engine = getMediaEngine(); return engine.getMediaFiltered(filter); }); safeHandle('media:search', async (_, query: string) => { const engine = getMediaEngine(); return engine.searchMedia(query); }); safeHandle('media:getByYearMonth', async () => { const engine = getMediaEngine(); return engine.getMediaByYearMonth(); }); safeHandle('media:getTags', async () => { const engine = getMediaEngine(); return engine.getAvailableTags(); }); safeHandle('media:getTagsWithCounts', async () => { const engine = getMediaEngine(); return engine.getTagsWithCounts(); }); safeHandle('media:rebuildFromFiles', async () => { // Ensure project context is current before rebuilding const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); const engine = getMediaEngine(); if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); // For thumbnails and meta: use dataDir (whether custom or internal) // This ensures all project data lives in the same location for backup engine.setProjectContext(project.id, dataDir, dataDir); } return engine.rebuildDatabaseFromFiles(); }); safeHandle('media:reindexText', async () => { const engine = getMediaEngine(); return engine.reindexText(); }); safeHandle('media:getThumbnail', async (_, id: string, size?: 'small' | 'medium' | 'large') => { const engine = getMediaEngine(); return engine.getThumbnailDataUrl(id, size || 'small'); }); safeHandle('media:regenerateThumbnails', async (_, id: string) => { const engine = getMediaEngine(); const mediaItem = await engine.getMedia(id); if (mediaItem && mediaItem.mimeType.startsWith('image/')) { const db = getDatabase().getLocal(); const dbMedia = await db.select().from(media).where(eq(media.id, id)).get(); if (dbMedia) { return engine.generateThumbnails(id, dbMedia.filePath); } } return null; }); safeHandle('media:regenerateMissingThumbnails', async () => { const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); const engine = getMediaEngine(); if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); // For thumbnails and meta: use dataDir (whether custom or internal) // This ensures all project data lives in the same location for backup engine.setProjectContext(project.id, dataDir, dataDir); } return engine.regenerateMissingThumbnails(); }); // ============ Task Handlers ============ safeHandle('tasks:getAll', async () => { return taskManager.getAllTasks(); }); safeHandle('tasks:getRunning', async () => { return taskManager.getRunningTasks(); }); safeHandle('tasks:cancel', async (_, taskId: string) => { return taskManager.cancelTask(taskId); }); safeHandle('tasks:clearCompleted', async () => { return taskManager.clearCompletedTasks(); }); // ============ App Handlers ============ safeHandle('app:getDataPaths', async () => { // Get paths for the active project const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); const projectId = activeProject?.id || 'default'; const paths = projectEngine.getProjectPaths(projectId, activeProject?.dataPath); return { database: getDatabase().getDataPaths().database, posts: paths.posts, media: paths.media, }; }); safeHandle('app:openFolder', async (_, folderPath: string) => { return shell.openPath(folderPath); }); safeHandle('app:selectFolder', async (_, title?: string) => { const result = await dialog.showOpenDialog({ title: title || 'Select Folder', properties: ['openDirectory', 'createDirectory'], }); if (result.canceled || result.filePaths.length === 0) { return null; } return result.filePaths[0]; }); safeHandle('app:getDefaultProjectPath', async (_, projectId: string) => { const projectEngine = getProjectEngine(); return projectEngine.getDefaultProjectBaseDir(projectId); }); safeHandle('app:getBlogmarkBookmarklet', async () => { return generateBlogmarkBookmarkletSource(); }); safeHandle('app:copyToClipboard', async (_, text: string) => { clipboard.writeText(String(text ?? '')); return true; }); safeHandle('app:getTitleBarMetrics', async (event) => { const ownerWindow = BrowserWindow.fromWebContents(event.sender); const buttonPosition = ownerWindow?.getWindowButtonPosition?.(); if (!buttonPosition) { return null; } const estimatedClusterWidth = Math.max(52, Math.round(buttonPosition.y * 4)); const trailingPadding = Math.max(8, Math.round(buttonPosition.y * 0.6)); const macosLeftInset = Math.max(0, Math.round(buttonPosition.x + estimatedClusterWidth + trailingPadding)); return { macosLeftInset }; }); safeHandle('app:showItemInFolder', async (_, itemPath: string) => { return shell.showItemInFolder(itemPath); }); safeHandle('app:readProjectMetadata', async (_, folderPath: string) => { const metaPath = path.join(folderPath, 'meta', 'project.json'); try { const content = await fsPromises.readFile(metaPath, 'utf-8'); const metadata = JSON.parse(content); // Return metadata but exclude dataPath (will be set to selected folder) return { name: metadata.name || undefined, description: metadata.description || undefined, publicUrl: metadata.publicUrl || undefined, mainLanguage: metadata.mainLanguage || undefined, }; } catch { // File doesn't exist or is invalid - return null return null; } }); safeHandle('app:triggerMenuAction', async (event, action: string) => { const typedAction = action as AppMenuAction; if (typedAction === 'quit') { app.quit(); return; } if (typedAction === 'viewOnGitHub') { await shell.openExternal('https://github.com/rfc1437/bDS'); return; } if (typedAction === 'openInBrowser') { await shell.openExternal('http://localhost:4123/'); return; } if (typedAction === 'openDataFolder') { const paths = getDatabase().getDataPaths(); await shell.openPath(path.dirname(paths.database)); return; } if (typedAction === 'reportIssue') { await shell.openExternal('https://github.com/rfc1437/bDS/issues'); return; } const handledByWebContents = runWebContentsMenuAction((event as any)?.sender, typedAction); if (handledByWebContents) { return; } const channel = APP_MENU_ACTION_EVENT_MAP[typedAction]; if (!channel) { return; } event.sender.send(channel); }); // ============ Meta Handlers ============ const ensureMetaContext = async (engine: ReturnType) => { const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); if (!activeProject) { return; } const dataDir = projectEngine.getDataDir(activeProject.id, activeProject.dataPath); engine.setProjectContext(activeProject.id, dataDir); }; const ensureMetaReady = async (engine: ReturnType) => { await ensureMetaContext(engine); if (!engine.isInitialized()) { await engine.syncOnStartup(); } }; safeHandle('menu:get', async () => { const projectEngine = getProjectEngine(); const menuEngine = getMenuEngine(); const project = await projectEngine.getActiveProject(); if (!project) { throw new Error('No active project'); } const dataDir = projectEngine.getDataDir(project.id, project.dataPath); menuEngine.setProjectContext(project.id, dataDir); return menuEngine.getMenu(); }); safeHandle('menu:save', async (_, menu: MenuDocument) => { const projectEngine = getProjectEngine(); const menuEngine = getMenuEngine(); const project = await projectEngine.getActiveProject(); if (!project) { throw new Error('No active project'); } const dataDir = projectEngine.getDataDir(project.id, project.dataPath); menuEngine.setProjectContext(project.id, dataDir); return menuEngine.saveMenu(menu); }); safeHandle('meta:getTags', async () => { const engine = getMetaEngine(); await ensureMetaReady(engine); return engine.getTags(); }); safeHandle('meta:getCategories', async () => { const engine = getMetaEngine(); await ensureMetaReady(engine); return engine.getCategories(); }); safeHandle('meta:addTag', async (_, tag: string) => { const engine = getMetaEngine(); await ensureMetaReady(engine); await engine.addTag(tag); return engine.getTags(); }); safeHandle('meta:removeTag', async (_, tag: string) => { const engine = getMetaEngine(); await ensureMetaReady(engine); await engine.removeTag(tag); return engine.getTags(); }); safeHandle('meta:addCategory', async (_, category: string) => { const engine = getMetaEngine(); await ensureMetaReady(engine); await engine.addCategory(category); return engine.getCategories(); }); safeHandle('meta:removeCategory', async (_, category: string) => { const engine = getMetaEngine(); await ensureMetaReady(engine); await engine.removeCategory(category); return engine.getCategories(); }); safeHandle('meta:syncOnStartup', async () => { const engine = getMetaEngine(); await ensureMetaContext(engine); await engine.syncOnStartup(); return { tags: await engine.getTags(), categories: await engine.getCategories(), projectMetadata: await engine.getProjectMetadata(), }; }); safeHandle('meta:getProjectMetadata', async () => { const engine = getMetaEngine(); await ensureMetaReady(engine); return engine.getProjectMetadata(); }); safeHandle('meta:setProjectMetadata', async (_, metadata: { name: string; description?: string }) => { const engine = getMetaEngine(); await ensureMetaContext(engine); await engine.setProjectMetadata(metadata); return engine.getProjectMetadata(); }); safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('../shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => { const engine = getMetaEngine(); await ensureMetaContext(engine); await engine.updateProjectMetadata(updates); return engine.getProjectMetadata(); }); // ============ Tag Management Handlers ============ safeHandle('tags:getAll', async () => { const engine = getTagEngine(); return engine.getAllTags(); }); safeHandle('tags:getWithCounts', async () => { const engine = getTagEngine(); return engine.getTagsWithCounts(); }); safeHandle('tags:get', async (_, id: string) => { const engine = getTagEngine(); return engine.getTag(id); }); safeHandle('tags:getByName', async (_, name: string) => { const engine = getTagEngine(); return engine.getTagByName(name); }); safeHandle('tags:create', async (_, data: { name: string; color?: string }) => { const engine = getTagEngine(); return engine.createTag(data); }); safeHandle('tags:update', async (_, id: string, data: { name?: string; color?: string | null }) => { const engine = getTagEngine(); return engine.updateTag(id, data); }); safeHandle('tags:delete', async (_, id: string) => { const engine = getTagEngine(); return engine.deleteTag(id); }); safeHandle('tags:merge', async (_, sourceTagIds: string[], targetTagId: string) => { const engine = getTagEngine(); return engine.mergeTags(sourceTagIds, targetTagId); }); safeHandle('tags:rename', async (_, id: string, newName: string) => { const engine = getTagEngine(); return engine.renameTag(id, newName); }); safeHandle('tags:getPostsWithTag', async (_, tagId: string) => { const engine = getTagEngine(); return engine.getPostsWithTag(tagId); }); safeHandle('tags:syncFromPosts', async () => { const engine = getTagEngine(); return engine.syncTagsFromPosts(); }); // ============ Post-Media Link Handlers ============ safeHandle('postMedia:link', async (_, postId: string, mediaId: string) => { const engine = getPostMediaEngine(); return engine.linkMediaToPost(postId, mediaId); }); safeHandle('postMedia:unlink', async (_, postId: string, mediaId: string) => { const engine = getPostMediaEngine(); return engine.unlinkMediaFromPost(postId, mediaId); }); safeHandle('postMedia:linkMany', async (_, postId: string, mediaIds: string[]) => { const engine = getPostMediaEngine(); return engine.linkManyToPost(postId, mediaIds); }); safeHandle('postMedia:unlinkMany', async (_, postId: string, mediaIds: string[]) => { const engine = getPostMediaEngine(); return engine.unlinkManyFromPost(postId, mediaIds); }); safeHandle('postMedia:getForPost', async (_, postId: string) => { const engine = getPostMediaEngine(); return engine.getLinkedMediaForPost(postId); }); safeHandle('postMedia:getForMedia', async (_, mediaId: string) => { const engine = getPostMediaEngine(); return engine.getLinkedPostsForMedia(mediaId); }); safeHandle('postMedia:getMediaDataForPost', async (_, postId: string) => { const engine = getPostMediaEngine(); return engine.getLinkedMediaDataForPost(postId); }); safeHandle('postMedia:reorder', async (_, postId: string, mediaIds: string[]) => { const engine = getPostMediaEngine(); return engine.reorderMediaForPost(postId, mediaIds); }); safeHandle('postMedia:isLinked', async (_, postId: string, mediaId: string) => { const engine = getPostMediaEngine(); return engine.isMediaLinkedToPost(postId, mediaId); }); safeHandle('postMedia:import', async (_, postId: string, filePath: string) => { const engine = getPostMediaEngine(); return engine.importMediaForPost(postId, filePath); }); safeHandle('postMedia:rebuild', async () => { const engine = getPostMediaEngine(); return engine.rebuildFromSidecars(); }); // ============ Import Analysis Handlers ============ // Helper to emit progress events const emitImportProgress = (step: string, detail?: string) => { ipcMain.emit('forward-to-renderer', 'import:progress', { step, detail }); }; safeHandle('import:selectAndAnalyze', async (_, uploadsFolder?: string) => { emitImportProgress('Selecting file...'); const result = await dialog.showOpenDialog({ title: 'Select WordPress Export File (WXR)', filters: [ { name: 'WordPress Export', extensions: ['xml'] }, { name: 'All Files', extensions: ['*'] }, ], properties: ['openFile'], }); if (result.canceled || result.filePaths.length === 0) { return null; } const filePath = result.filePaths[0]; const fileName = filePath.split(/[/\\]/).pop() || filePath; emitImportProgress('Parsing WXR file...', fileName); const { WxrParser } = await import('../engine/WxrParser'); const { ImportAnalysisEngine } = await import('../engine/ImportAnalysisEngine'); const parser = new WxrParser(); const wxrData = await parser.parseFile(filePath); emitImportProgress('Loading project data...', `Found ${wxrData.posts.length} posts, ${wxrData.media.length} media`); const analysisEngine = new ImportAnalysisEngine(); const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); if (activeProject) { analysisEngine.setProjectContext(activeProject.id); } emitImportProgress('Analyzing posts...', `${wxrData.posts.length} posts`); // Add progress callback to engine analysisEngine.onProgress = (step: string, detail?: string) => { emitImportProgress(step, detail); }; const report = await analysisEngine.analyzeWxr(wxrData, filePath, uploadsFolder || undefined); emitImportProgress('Analysis complete'); return report; }); safeHandle('import:analyzeFile', async (_, filePath: string, uploadsFolder?: string) => { const fileName = filePath.split(/[/\\]/).pop() || filePath; emitImportProgress('Parsing WXR file...', fileName); const { WxrParser } = await import('../engine/WxrParser'); const { ImportAnalysisEngine } = await import('../engine/ImportAnalysisEngine'); const parser = new WxrParser(); const wxrData = await parser.parseFile(filePath); emitImportProgress('Loading project data...', `Found ${wxrData.posts.length} posts, ${wxrData.media.length} media`); const analysisEngine = new ImportAnalysisEngine(); const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); if (activeProject) { analysisEngine.setProjectContext(activeProject.id); } emitImportProgress('Analyzing posts...'); // Add progress callback to engine analysisEngine.onProgress = (step: string, detail?: string) => { emitImportProgress(step, detail); }; const report = await analysisEngine.analyzeWxr(wxrData, filePath, uploadsFolder || undefined); emitImportProgress('Analysis complete'); return report; }); safeHandle('import:selectUploadsFolder', async () => { const result = await dialog.showOpenDialog({ title: 'Select WordPress Uploads Folder', properties: ['openDirectory'], }); if (result.canceled || result.filePaths.length === 0) { return null; } return result.filePaths[0]; }); // Helper to emit import execution progress events const emitImportExecutionProgress = ( taskId: string, phase: string, current: number, total: number, detail?: string, eta?: number ) => { ipcMain.emit('forward-to-renderer', 'import:executionProgress', { taskId, phase, current, total, detail, eta, }); }; safeHandle('import:execute', async (_, reportJson: string, uploadsFolder?: string) => { const { ImportExecutionEngine } = await import('../engine/ImportExecutionEngine'); // Parse the report const report = JSON.parse(reportJson) as import('../engine/ImportAnalysisEngine').ImportAnalysisReport; // Set up project context const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); // Calculate total items for ETA // Note: 'update' and 'content-duplicate' statuses are SKIPPED during import, only 'new' and resolved conflicts are imported const totalItems = report.tags.filter(t => !t.existsInProject).length + report.categories.filter(c => !c.existsInProject).length + report.posts.items.filter(p => p.status === 'new' || (p.status === 'conflict' && p.conflictResolution && p.conflictResolution !== 'ignore')).length + report.media.items.filter(m => m.status === 'new').length + report.pages.items.filter(p => p.status === 'new' || (p.status === 'conflict' && p.conflictResolution && p.conflictResolution !== 'ignore')).length; // Create a task for the import const taskId = `import-${Date.now()}`; let processedItems = 0; let startTime = Date.now(); const task = { id: taskId, name: `Import from ${report.site.title || 'WordPress'}`, execute: async (onProgress: (progress: number, message: string) => void) => { const executionEngine = new ImportExecutionEngine(); if (activeProject) { executionEngine.setProjectContext(activeProject.id, activeProject.dataPath); } // Get default author from project settings const metaEngine = getMetaEngine(); const projectMetadata = await metaEngine.getProjectMetadata(); const defaultAuthor = projectMetadata?.defaultAuthor; const result = await executionEngine.executeImport(report, { uploadsFolder, defaultAuthor, onProgress: (phase, current, total, detail) => { // Update processed items count based on phase progress processedItems++; // Calculate ETA const elapsed = Date.now() - startTime; const itemsPerMs = processedItems / elapsed; const remainingItems = totalItems - processedItems; const etaMs = itemsPerMs > 0 ? remainingItems / itemsPerMs : 0; // Calculate overall progress percentage const overallProgress = totalItems > 0 ? Math.round((processedItems / totalItems) * 100) : 0; // Report to TaskManager onProgress(overallProgress, `${phase}: ${detail || `${current}/${total}`}`); // Also emit detailed progress for UI emitImportExecutionProgress(taskId, phase, current, total, detail, etaMs); }, }); // Convert Map to plain object for serialization const serializedResult = { ...result, wpIdToPostId: Object.fromEntries(result.wpIdToPostId), }; // Emit import:complete event to notify UI to refresh ipcMain.emit('forward-to-renderer', 'import:complete', { taskId, success: result.success, posts: result.posts, media: result.media, pages: result.pages, tags: result.tags, }); return serializedResult; }, }; // Run the task - this returns immediately with a promise const resultPromise = taskManager.runTask(task); // Return task ID so UI can track it return { taskId, totalItems }; }); // ============ Import Definition CRUD Handlers ============ safeHandle('importDefinitions:create', async (_, name?: string) => { const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); const engine = new ImportDefinitionEngine(); const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); } return engine.createDefinition(name || undefined); }); safeHandle('importDefinitions:get', async (_, id: string) => { const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); const engine = new ImportDefinitionEngine(); const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); } return engine.getDefinition(id); }); safeHandle('importDefinitions:getAll', async () => { const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); const engine = new ImportDefinitionEngine(); const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); } return engine.getAllForProject(); }); safeHandle('importDefinitions:update', async (event, id: string, updates: any) => { const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); const engine = new ImportDefinitionEngine(); const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); } const result = await engine.updateDefinition(id, updates); // Notify renderer of name changes for sidebar/tab updates if (result && updates.name !== undefined) { event.sender.send('importDefinition-name-updated', { definitionId: id, name: result.name }); } return result; }); safeHandle('importDefinitions:delete', async (_, id: string) => { const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); const engine = new ImportDefinitionEngine(); const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); } return engine.deleteDefinition(id); }); registerMetadataDiffHandlers(safeHandle); registerBlogHandlers(safeHandle); // ============ Event Forwarding ============ // Forward engine events to renderer const postEngine = getPostEngine(); const mediaEngine = getMediaEngine(); const projectEngine = getProjectEngine(); const metaEngine = getMetaEngine(); const tagEngine = getTagEngine(); const postMediaEngine = getPostMediaEngine(); const forwardEvent = (eventName: string) => { return (...args: unknown[]) => { // Will be sent to renderer via webContents when window is available ipcMain.emit('forward-to-renderer', eventName, ...args); }; }; projectEngine.on('projectCreated', forwardEvent('project:created')); projectEngine.on('projectUpdated', forwardEvent('project:updated')); projectEngine.on('projectDeleted', forwardEvent('project:deleted')); projectEngine.on('activeProjectChanged', forwardEvent('project:activeChanged')); postEngine.on('postCreated', forwardEvent('post:created')); postEngine.on('postUpdated', forwardEvent('post:updated')); postEngine.on('postDeleted', forwardEvent('post:deleted')); postEngine.on('rebuildStarted', forwardEvent('posts:rebuildStarted')); postEngine.on('databaseRebuilt', forwardEvent('posts:databaseRebuilt')); mediaEngine.on('mediaImported', forwardEvent('media:imported')); mediaEngine.on('mediaUpdated', forwardEvent('media:updated')); mediaEngine.on('mediaDeleted', forwardEvent('media:deleted')); mediaEngine.on('mediaFileReplaced', forwardEvent('media:fileReplaced')); mediaEngine.on('rebuildStarted', forwardEvent('media:rebuildStarted')); mediaEngine.on('databaseRebuilt', forwardEvent('media:databaseRebuilt')); metaEngine.on('tagsChanged', forwardEvent('meta:tagsChanged')); metaEngine.on('categoriesChanged', forwardEvent('meta:categoriesChanged')); metaEngine.on('projectMetadataChanged', forwardEvent('meta:projectMetadataChanged')); tagEngine.on('tagCreated', forwardEvent('tag:created')); tagEngine.on('tagUpdated', forwardEvent('tag:updated')); tagEngine.on('tagDeleted', forwardEvent('tag:deleted')); tagEngine.on('tagRenamed', forwardEvent('tag:renamed')); tagEngine.on('tagsMerged', forwardEvent('tags:merged')); tagEngine.on('tagsSynced', forwardEvent('tags:synced')); postMediaEngine.on('mediaLinked', forwardEvent('postMedia:linked')); postMediaEngine.on('mediaUnlinked', forwardEvent('postMedia:unlinked')); postMediaEngine.on('mediaBatchLinked', forwardEvent('postMedia:batchLinked')); postMediaEngine.on('mediaBatchUnlinked', forwardEvent('postMedia:batchUnlinked')); postMediaEngine.on('mediaReordered', forwardEvent('postMedia:reordered')); postMediaEngine.on('rebuilt', forwardEvent('postMedia:rebuilt')); taskManager.on('taskCreated', forwardEvent('task:created')); taskManager.on('taskStarted', forwardEvent('task:started')); taskManager.on('taskProgress', forwardEvent('task:progress')); taskManager.on('taskCompleted', forwardEvent('task:completed')); taskManager.on('taskFailed', forwardEvent('task:failed')); }