diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 76c0ceb..e3e7d67 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(npm run build:*)", "Bash(npx tsc:*)", - "Bash(node ./node_modules/typescript/bin/tsc:*)" + "Bash(node ./node_modules/typescript/bin/tsc:*)", + "Bash(npm run build:main:*)" ] } } diff --git a/src/main/database/connection.ts b/src/main/database/connection.ts index 8818913..d412e25 100644 --- a/src/main/database/connection.ts +++ b/src/main/database/connection.ts @@ -15,6 +15,7 @@ export class DatabaseConnection { private localDb: DrizzleDB | null = null; private localClient: Client | null = null; private config: DatabaseConfig; + private _closing = false; constructor(config?: Partial) { const userDataPath = app.getPath('userData'); @@ -58,9 +59,15 @@ export class DatabaseConnection { return this.localDb; } + get isClosing(): boolean { + return this._closing; + } + getLocal(): DrizzleDB { if (!this.localDb) { - throw new Error('Local database not initialized. Call initializeLocal() first.'); + throw new Error(this._closing + ? 'Database is closing' + : 'Local database not initialized. Call initializeLocal() first.'); } return this.localDb; } @@ -449,6 +456,7 @@ export class DatabaseConnection { } async close(): Promise { + this._closing = true; if (this.localClient) { this.localClient.close(); this.localClient = null; diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index 7fbb436..079cbdc 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -49,20 +49,28 @@ export interface MediaMetadata { export class MediaEngine extends EventEmitter { private currentProjectId: string = 'default'; - private projectBaseDir: string | null = null; + private dataDir: string | null = null; // For media files (may be external) + private internalDir: string | null = null; // For thumbnails (always local) constructor() { super(); } - private getProjectBaseDir(): string { - if (this.projectBaseDir) return this.projectBaseDir; + private getDefaultBaseDir(): string { const userDataPath = app.getPath('userData'); return path.join(userDataPath, 'projects', this.currentProjectId); } + private getDataDir(): string { + return this.dataDir || this.getDefaultBaseDir(); + } + + private getInternalDir(): string { + return this.internalDir || this.getDefaultBaseDir(); + } + private getMediaBaseDir(): string { - return path.join(this.getProjectBaseDir(), 'media'); + return path.join(this.getDataDir(), 'media'); } private getMediaDir(): string { @@ -91,9 +99,11 @@ export class MediaEngine extends EventEmitter { return path.join(dir, `${id}${extension}`); } - setProjectContext(projectId: string, baseDir?: string): void { + setProjectContext(projectId: string, dataDir?: string, internalDir?: string): void { this.currentProjectId = projectId; - this.projectBaseDir = baseDir || null; + this.dataDir = dataDir || null; + this.internalDir = internalDir || null; + console.log(`[MediaEngine] setProjectContext: projectId=${projectId}, dataDir=${this.dataDir}, internalDir=${this.internalDir}`); } getProjectContext(): string { @@ -108,7 +118,7 @@ export class MediaEngine extends EventEmitter { * Get the thumbnails directory for the current project */ private getThumbnailsDir(): string { - return path.join(this.getProjectBaseDir(), 'thumbnails'); + return path.join(this.getInternalDir(), 'thumbnails'); } /** @@ -543,6 +553,7 @@ export class MediaEngine extends EventEmitter { async rebuildDatabaseFromFiles(): Promise { const mediaBaseDir = this.getMediaBaseDir(); + console.log(`[MediaEngine] rebuildDatabaseFromFiles: scanning mediaBaseDir=${mediaBaseDir}`); const task: Task = { id: uuidv4(), name: 'Rebuild database from media files', diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 3335445..6a11850 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -32,7 +32,6 @@ export const DEFAULT_CATEGORIES = ['article', 'picture', 'aside', 'page']; */ export class MetaEngine extends EventEmitter { private currentProjectId: string = 'default'; - private projectBaseDir: string | null = null; private tags: Set = new Set(); private categories: Set = new Set(); private projectMetadata: ProjectMetadata | null = null; @@ -42,18 +41,21 @@ export class MetaEngine extends EventEmitter { super(); } - private getProjectBaseDir(): string { - if (this.projectBaseDir) return this.projectBaseDir; + /** + * Always returns the internal project directory (in userData). + * Meta files never live in an external dataPath. + */ + private getInternalBaseDir(): string { const userDataPath = app.getPath('userData'); return path.join(userDataPath, 'projects', this.currentProjectId); } /** * Get the meta directory path for the current project. - * Format: {baseDir}/meta/ + * Always in the internal directory (userData), never external. */ getMetaDir(): string { - return path.join(this.getProjectBaseDir(), 'meta'); + return path.join(this.getInternalBaseDir(), 'meta'); } private getTagsFilePath(): string { @@ -68,9 +70,8 @@ export class MetaEngine extends EventEmitter { return path.join(this.getMetaDir(), 'project.json'); } - setProjectContext(projectId: string, baseDir?: string): void { + setProjectContext(projectId: string): void { this.currentProjectId = projectId; - this.projectBaseDir = baseDir || null; // Reset in-memory cache when project changes this.tags.clear(); this.categories.clear(); diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 48d48fb..3323e94 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -142,16 +142,16 @@ export class PostEngine extends EventEmitter { await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] }); } - private projectBaseDir: string | null = null; + private dataDir: string | null = null; - private getProjectBaseDir(): string { - if (this.projectBaseDir) return this.projectBaseDir; + private getDataDir(): string { + if (this.dataDir) return this.dataDir; const userDataPath = app.getPath('userData'); return path.join(userDataPath, 'projects', this.currentProjectId); } private getPostsBaseDir(): string { - return path.join(this.getProjectBaseDir(), 'posts'); + return path.join(this.getDataDir(), 'posts'); } private getPostsDir(): string { @@ -179,9 +179,9 @@ export class PostEngine extends EventEmitter { return path.join(dir, `${slug}.md`); } - setProjectContext(projectId: string, baseDir?: string): void { + setProjectContext(projectId: string, dataDir?: string): void { this.currentProjectId = projectId; - this.projectBaseDir = baseDir || null; + this.dataDir = dataDir || null; } getProjectContext(): string { diff --git a/src/main/engine/ProjectEngine.ts b/src/main/engine/ProjectEngine.ts index f6e5e3a..fd76ad6 100644 --- a/src/main/engine/ProjectEngine.ts +++ b/src/main/engine/ProjectEngine.ts @@ -31,37 +31,42 @@ export class ProjectEngine extends EventEmitter { } /** - * Get the base directory for a project's data. - * If the project has a custom dataPath, use that; otherwise use the default. + * Get the internal base directory for a project (always in userData). + * This is where meta, thumbnails, tags, and project.json live. */ - getProjectBaseDir(projectId: string, dataPath?: string | null): string { - if (dataPath) { - return dataPath; - } + getInternalBaseDir(projectId: string): string { const userDataPath = app.getPath('userData'); return path.join(userDataPath, 'projects', projectId); } /** - * Get the default base directory (in userData) for a project. + * Get the data directory for posts and media. + * If a custom dataPath is set, posts/media live there; otherwise in the internal dir. + */ + getDataDir(projectId: string, dataPath?: string | null): string { + if (dataPath) { + return dataPath; + } + return this.getInternalBaseDir(projectId); + } + + /** + * Alias kept for backward compatibility — returns the internal base dir. */ getDefaultProjectBaseDir(projectId: string): string { - const userDataPath = app.getPath('userData'); - return path.join(userDataPath, 'projects', projectId); + return this.getInternalBaseDir(projectId); } private async ensureProjectDirectories(projectId: string, dataPath?: string | null): Promise { - const projectDir = this.getProjectBaseDir(projectId, dataPath); - const postsDir = path.join(projectDir, 'posts'); - const mediaDir = path.join(projectDir, 'media'); - const thumbnailsDir = path.join(projectDir, 'thumbnails'); - const metaDir = path.join(projectDir, 'meta'); + // Internal directories (always in userData) + const internalDir = this.getInternalBaseDir(projectId); + await fs.mkdir(path.join(internalDir, 'thumbnails'), { recursive: true }); + await fs.mkdir(path.join(internalDir, 'meta'), { recursive: true }); - await fs.mkdir(projectDir, { recursive: true }); - await fs.mkdir(postsDir, { recursive: true }); - await fs.mkdir(mediaDir, { recursive: true }); - await fs.mkdir(thumbnailsDir, { recursive: true }); - await fs.mkdir(metaDir, { recursive: true }); + // Data directories (may be external) + const dataDir = this.getDataDir(projectId, dataPath); + await fs.mkdir(path.join(dataDir, 'posts'), { recursive: true }); + await fs.mkdir(path.join(dataDir, 'media'), { recursive: true }); } async createProject(data: { name: string; description?: string; slug?: string; dataPath?: string }): Promise { @@ -194,14 +199,13 @@ export class ProjectEngine extends EventEmitter { // Delete associated media from database await db.delete(media).where(eq(media.projectId, id)); - // Delete project files and directories - const paths = this.getProjectPaths(id); + // Delete the internal project directory (meta, thumbnails, and posts/media if stored internally). + // If a custom dataPath is set, external posts/media are NOT deleted — the user manages that storage. + const internalDir = this.getInternalBaseDir(id); try { - // Delete posts directory - await fs.rm(path.dirname(paths.posts), { recursive: true, force: true }); + await fs.rm(internalDir, { recursive: true, force: true }); } catch (error) { - // Directory may not exist, that's okay - console.warn(`Could not delete project directory for ${id}:`, error); + console.warn(`Could not delete internal project directory for ${id}:`, error); } // Delete project from database @@ -287,10 +291,10 @@ export class ProjectEngine extends EventEmitter { } getProjectPaths(projectId: string, dataPath?: string | null): { posts: string; media: string } { - const baseDir = this.getProjectBaseDir(projectId, dataPath); + const dataDir = this.getDataDir(projectId, dataPath); return { - posts: path.join(baseDir, 'posts'), - media: path.join(baseDir, 'media'), + posts: path.join(dataDir, 'posts'), + media: path.join(dataDir, 'media'), }; } diff --git a/src/main/engine/TagEngine.ts b/src/main/engine/TagEngine.ts index a719dbf..668a9bc 100644 --- a/src/main/engine/TagEngine.ts +++ b/src/main/engine/TagEngine.ts @@ -110,14 +110,16 @@ function isValidHexColor(color: string): boolean { */ export class TagEngine extends EventEmitter { private currentProjectId: string = 'default'; - private projectBaseDir: string | null = null; constructor() { super(); } - private getProjectBaseDir(): string { - if (this.projectBaseDir) return this.projectBaseDir; + /** + * Always returns the internal project directory (in userData). + * Tag metadata never lives in an external dataPath. + */ + private getInternalBaseDir(): string { const userDataPath = app.getPath('userData'); return path.join(userDataPath, 'projects', this.currentProjectId); } @@ -125,9 +127,8 @@ export class TagEngine extends EventEmitter { /** * Set the current project context */ - setProjectContext(projectId: string, baseDir?: string): void { + setProjectContext(projectId: string): void { this.currentProjectId = projectId; - this.projectBaseDir = baseDir || null; } /** @@ -141,7 +142,7 @@ export class TagEngine extends EventEmitter { * Get the tags file path for filesystem persistence */ private getTagsFilePath(): string { - return path.join(this.getProjectBaseDir(), 'meta', 'tags-metadata.json'); + return path.join(this.getInternalBaseDir(), 'meta', 'tags-metadata.json'); } /** diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 3e49fbe..de1cb8e 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -11,54 +11,72 @@ import { taskManager, TaskProgress } from '../engine/TaskManager'; import { getDatabase } from '../database'; import { media } from '../database/schema'; +/** + * 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 + } + }); +} + export function registerIpcHandlers(): void { // ============ Project Handlers ============ - ipcMain.handle('projects:create', async (_, data: { name: string; description?: string; slug?: string }) => { + safeHandle('projects:create', async (_, data: { name: string; description?: string; slug?: string }) => { const engine = getProjectEngine(); return engine.createProject(data); }); - ipcMain.handle('projects:update', async (_, id: string, data: Partial) => { + safeHandle('projects:update', async (_, id: string, data: Partial) => { const engine = getProjectEngine(); return engine.updateProject(id, data); }); - ipcMain.handle('projects:delete', async (_, id: string) => { + safeHandle('projects:delete', async (_, id: string) => { const engine = getProjectEngine(); return engine.deleteProject(id); }); - ipcMain.handle('projects:deleteWithData', async (_, id: string) => { + safeHandle('projects:deleteWithData', async (_, id: string) => { const engine = getProjectEngine(); return engine.deleteProjectWithData(id); }); - ipcMain.handle('projects:get', async (_, id: string) => { + safeHandle('projects:get', async (_, id: string) => { const engine = getProjectEngine(); return engine.getProject(id); }); - ipcMain.handle('projects:getAll', async () => { + safeHandle('projects:getAll', async () => { const engine = getProjectEngine(); return engine.getAllProjects(); }); - ipcMain.handle('projects:getActive', async () => { + safeHandle('projects:getActive', async () => { const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); // Ensure all engines have the correct project context if (project) { - const baseDir = projectEngine.getProjectBaseDir(project.id, project.dataPath); + const internalDir = projectEngine.getInternalBaseDir(project.id); + const dataDir = projectEngine.getDataDir(project.id, project.dataPath); const postEngine = getPostEngine(); const mediaEngine = getMediaEngine(); const metaEngine = getMetaEngine(); const tagEngine = getTagEngine(); - postEngine.setProjectContext(project.id, baseDir); - mediaEngine.setProjectContext(project.id, baseDir); - metaEngine.setProjectContext(project.id, baseDir); - tagEngine.setProjectContext(project.id, baseDir); + postEngine.setProjectContext(project.id, dataDir); + mediaEngine.setProjectContext(project.id, dataDir, internalDir); + metaEngine.setProjectContext(project.id); + tagEngine.setProjectContext(project.id); // Sync meta on startup await metaEngine.syncOnStartup(); @@ -67,21 +85,22 @@ export function registerIpcHandlers(): void { return project; }); - ipcMain.handle('projects:setActive', async (_, id: string) => { + 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 baseDir = projectEngine.getProjectBaseDir(project.id, project.dataPath); + const internalDir = projectEngine.getInternalBaseDir(project.id); + const dataDir = projectEngine.getDataDir(project.id, project.dataPath); const postEngine = getPostEngine(); const mediaEngine = getMediaEngine(); const metaEngine = getMetaEngine(); const tagEngine = getTagEngine(); - postEngine.setProjectContext(project.id, baseDir); - mediaEngine.setProjectContext(project.id, baseDir); - metaEngine.setProjectContext(project.id, baseDir); - tagEngine.setProjectContext(project.id, baseDir); + postEngine.setProjectContext(project.id, dataDir); + mediaEngine.setProjectContext(project.id, dataDir, internalDir); + metaEngine.setProjectContext(project.id); + tagEngine.setProjectContext(project.id); // Sync meta on project switch await metaEngine.syncOnStartup(); @@ -92,68 +111,69 @@ export function registerIpcHandlers(): void { // ============ Post Handlers ============ - ipcMain.handle('posts:create', async (_, data: Partial) => { + safeHandle('posts:create', async (_, data: Partial) => { const engine = getPostEngine(); return engine.createPost(data); }); - ipcMain.handle('posts:isSlugAvailable', async (_, slug: string, excludePostId?: string) => { + safeHandle('posts:isSlugAvailable', async (_, slug: string, excludePostId?: string) => { const engine = getPostEngine(); return engine.isSlugAvailable(slug, excludePostId); }); - ipcMain.handle('posts:generateUniqueSlug', async (_, title: string, excludePostId?: string) => { + safeHandle('posts:generateUniqueSlug', async (_, title: string, excludePostId?: string) => { const engine = getPostEngine(); return engine.generateUniqueSlug(title, excludePostId); }); - ipcMain.handle('posts:update', async (_, id: string, data: Partial) => { + safeHandle('posts:update', async (_, id: string, data: Partial) => { const engine = getPostEngine(); return engine.updatePost(id, data); }); - ipcMain.handle('posts:delete', async (_, id: string) => { + safeHandle('posts:delete', async (_, id: string) => { const engine = getPostEngine(); return engine.deletePost(id); }); - ipcMain.handle('posts:get', async (_, id: string) => { + safeHandle('posts:get', async (_, id: string) => { const engine = getPostEngine(); return engine.getPost(id); }); - ipcMain.handle('posts:getAll', async (_, options?: PaginationOptions) => { + safeHandle('posts:getAll', async (_, options?: PaginationOptions) => { const engine = getPostEngine(); return engine.getAllPosts(options); }); - ipcMain.handle('posts:getByStatus', async (_, status: 'draft' | 'published' | 'archived') => { + safeHandle('posts:getByStatus', async (_, status: 'draft' | 'published' | 'archived') => { const engine = getPostEngine(); return engine.getPostsByStatus(status); }); - ipcMain.handle('posts:publish', async (_, id: string) => { + safeHandle('posts:publish', async (_, id: string) => { const engine = getPostEngine(); return engine.publishPost(id); }); - ipcMain.handle('posts:discard', async (_, id: string) => { + safeHandle('posts:discard', async (_, id: string) => { const engine = getPostEngine(); return engine.discardChanges(id); }); - ipcMain.handle('posts:hasPublishedVersion', async (_, id: string) => { + safeHandle('posts:hasPublishedVersion', async (_, id: string) => { const engine = getPostEngine(); return engine.hasPublishedVersion(id); }); - ipcMain.handle('posts:rebuildFromFiles', async () => { + safeHandle('posts:rebuildFromFiles', async () => { // Ensure project context is current before rebuilding const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); const engine = getPostEngine(); if (project) { - engine.setProjectContext(project.id); + const dataDir = projectEngine.getDataDir(project.id, project.dataPath); + engine.setProjectContext(project.id, dataDir); } // Fire and forget - don't await, let it run in background engine.rebuildDatabaseFromFiles().catch(err => { @@ -161,67 +181,68 @@ export function registerIpcHandlers(): void { }); }); - ipcMain.handle('posts:search', async (_, query: string) => { + safeHandle('posts:search', async (_, query: string) => { const engine = getPostEngine(); return engine.searchPosts(query); }); - ipcMain.handle('posts:filter', async (_, filter: PostFilter) => { + safeHandle('posts:filter', async (_, filter: PostFilter) => { const engine = getPostEngine(); return engine.getPostsFiltered(filter); }); - ipcMain.handle('posts:getTags', async () => { + safeHandle('posts:getTags', async () => { const engine = getPostEngine(); return engine.getAvailableTags(); }); - ipcMain.handle('posts:getCategories', async () => { + safeHandle('posts:getCategories', async () => { const engine = getPostEngine(); return engine.getAvailableCategories(); }); - ipcMain.handle('posts:getByYearMonth', async () => { + safeHandle('posts:getByYearMonth', async () => { const engine = getPostEngine(); return engine.getPostsByYearMonth(); }); - ipcMain.handle('posts:getTagsWithCounts', async () => { + safeHandle('posts:getTagsWithCounts', async () => { const engine = getPostEngine(); return engine.getTagsWithCounts(); }); - ipcMain.handle('posts:getCategoriesWithCounts', async () => { + safeHandle('posts:getCategoriesWithCounts', async () => { const engine = getPostEngine(); return engine.getCategoriesWithCounts(); }); - ipcMain.handle('posts:getDashboardStats', async () => { + safeHandle('posts:getDashboardStats', async () => { const engine = getPostEngine(); return engine.getDashboardStats(); }); - ipcMain.handle('posts:getLinksTo', async (_, id: string) => { + safeHandle('posts:getLinksTo', async (_, id: string) => { const engine = getPostEngine(); return engine.getLinksTo(id); }); - ipcMain.handle('posts:getLinkedBy', async (_, id: string) => { + safeHandle('posts:getLinkedBy', async (_, id: string) => { const engine = getPostEngine(); return engine.getLinkedBy(id); }); - ipcMain.handle('posts:rebuildLinks', async () => { + safeHandle('posts:rebuildLinks', async () => { const engine = getPostEngine(); return engine.rebuildAllPostLinks(); }); - ipcMain.handle('posts:reindexText', async () => { + safeHandle('posts:reindexText', async () => { const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); const engine = getPostEngine(); if (project) { - engine.setProjectContext(project.id); + const dataDir = projectEngine.getDataDir(project.id, project.dataPath); + engine.setProjectContext(project.id, dataDir); } // Fire and forget - let it run as a background task engine.reindexText().catch(err => { @@ -231,12 +252,12 @@ export function registerIpcHandlers(): void { // ============ Media Handlers ============ - ipcMain.handle('media:import', async (_, sourcePath: string, metadata?: Partial) => { + safeHandle('media:import', async (_, sourcePath: string, metadata?: Partial) => { const engine = getMediaEngine(); return engine.importMedia(sourcePath, metadata); }); - ipcMain.handle('media:importDialog', async () => { + safeHandle('media:importDialog', async () => { const result = await dialog.showOpenDialog({ title: 'Import Media', filters: [ @@ -265,45 +286,47 @@ export function registerIpcHandlers(): void { return imported; }); - ipcMain.handle('media:update', async (_, id: string, data: Partial) => { + safeHandle('media:update', async (_, id: string, data: Partial) => { const engine = getMediaEngine(); return engine.updateMedia(id, data); }); - ipcMain.handle('media:delete', async (_, id: string) => { + safeHandle('media:delete', async (_, id: string) => { const engine = getMediaEngine(); return engine.deleteMedia(id); }); - ipcMain.handle('media:get', async (_, id: string) => { + safeHandle('media:get', async (_, id: string) => { const engine = getMediaEngine(); return engine.getMedia(id); }); - ipcMain.handle('media:getUrl', async (_, id: string) => { + safeHandle('media:getUrl', async (_, id: string) => { // Returns the bds-media:// protocol URL for a media item return `bds-media://${id}`; }); - ipcMain.handle('media:getFilePath', async (_, id: string) => { + 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; }); - ipcMain.handle('media:getAll', async () => { + safeHandle('media:getAll', async () => { const engine = getMediaEngine(); return engine.getAllMedia(); }); - ipcMain.handle('media:rebuildFromFiles', async () => { + safeHandle('media:rebuildFromFiles', async () => { // Ensure project context is current before rebuilding const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); const engine = getMediaEngine(); if (project) { - engine.setProjectContext(project.id); + const internalDir = projectEngine.getInternalBaseDir(project.id); + const dataDir = projectEngine.getDataDir(project.id, project.dataPath); + engine.setProjectContext(project.id, dataDir, internalDir); } // Fire and forget - don't await, let it run in background engine.rebuildDatabaseFromFiles().catch(err => { @@ -311,12 +334,12 @@ export function registerIpcHandlers(): void { }); }); - ipcMain.handle('media:getThumbnail', async (_, id: string, size?: 'small' | 'medium' | 'large') => { + safeHandle('media:getThumbnail', async (_, id: string, size?: 'small' | 'medium' | 'large') => { const engine = getMediaEngine(); return engine.getThumbnailDataUrl(id, size || 'small'); }); - ipcMain.handle('media:regenerateThumbnails', async (_, id: string) => { + safeHandle('media:regenerateThumbnails', async (_, id: string) => { const engine = getMediaEngine(); const mediaItem = await engine.getMedia(id); if (mediaItem && mediaItem.mimeType.startsWith('image/')) { @@ -329,56 +352,58 @@ export function registerIpcHandlers(): void { return null; }); - ipcMain.handle('media:regenerateMissingThumbnails', async () => { + safeHandle('media:regenerateMissingThumbnails', async () => { const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); const engine = getMediaEngine(); if (project) { - engine.setProjectContext(project.id); + const internalDir = projectEngine.getInternalBaseDir(project.id); + const dataDir = projectEngine.getDataDir(project.id, project.dataPath); + engine.setProjectContext(project.id, dataDir, internalDir); } return engine.regenerateMissingThumbnails(); }); // ============ Sync Handlers ============ - ipcMain.handle('sync:configure', async (_, config: SyncConfig) => { + safeHandle('sync:configure', async (_, config: SyncConfig) => { const engine = getSyncEngine(); return engine.configure(config); }); - ipcMain.handle('sync:start', async (_, direction: SyncDirection = 'bidirectional') => { + safeHandle('sync:start', async (_, direction: SyncDirection = 'bidirectional') => { const engine = getSyncEngine(); return engine.fullSync(direction); }); - ipcMain.handle('sync:getStatus', async () => { + safeHandle('sync:getStatus', async () => { const engine = getSyncEngine(); return engine.getSyncStatus(); }); - ipcMain.handle('sync:isConfigured', async () => { + safeHandle('sync:isConfigured', async () => { const engine = getSyncEngine(); return engine.isConfigured(); }); - ipcMain.handle('sync:getPendingCount', async () => { + safeHandle('sync:getPendingCount', async () => { const engine = getSyncEngine(); return engine.getPendingChangesCount(); }); - ipcMain.handle('sync:getLog', async (_, limit?: number) => { + safeHandle('sync:getLog', async (_, limit?: number) => { const engine = getSyncEngine(); return engine.getSyncLog(limit); }); - ipcMain.handle('sync:stopAutoSync', async () => { + safeHandle('sync:stopAutoSync', async () => { const engine = getSyncEngine(); return engine.stopAutoSync(); }); // ============ Dropbox Sync Handlers ============ - ipcMain.handle('dropbox:configure', async (_, config: Partial) => { + safeHandle('dropbox:configure', async (_, config: Partial) => { const engine = getDropboxSyncEngine(); // Inject local project paths so the engine knows where files live @@ -402,47 +427,47 @@ export function registerIpcHandlers(): void { return engine.configure(fullConfig); }); - ipcMain.handle('dropbox:isConfigured', async () => { + safeHandle('dropbox:isConfigured', async () => { const engine = getDropboxSyncEngine(); return engine.isConfigured(); }); - ipcMain.handle('dropbox:getStatus', async () => { + safeHandle('dropbox:getStatus', async () => { const engine = getDropboxSyncEngine(); return engine.getStatus(); }); - ipcMain.handle('dropbox:syncAll', async () => { + safeHandle('dropbox:syncAll', async () => { const engine = getDropboxSyncEngine(); return engine.syncAll(); }); - ipcMain.handle('dropbox:startWatching', async () => { + safeHandle('dropbox:startWatching', async () => { const engine = getDropboxSyncEngine(); engine.startWatching(); }); - ipcMain.handle('dropbox:stopWatching', async () => { + safeHandle('dropbox:stopWatching', async () => { const engine = getDropboxSyncEngine(); engine.stopWatching(); }); - ipcMain.handle('dropbox:startPolling', async () => { + safeHandle('dropbox:startPolling', async () => { const engine = getDropboxSyncEngine(); engine.startPolling(); }); - ipcMain.handle('dropbox:stopPolling', async () => { + safeHandle('dropbox:stopPolling', async () => { const engine = getDropboxSyncEngine(); engine.stopPolling(); }); - ipcMain.handle('dropbox:getConflicts', async () => { + safeHandle('dropbox:getConflicts', async () => { const engine = getDropboxSyncEngine(); return engine.getPendingConflicts(); }); - ipcMain.handle('dropbox:resolveConflict', async (_, conflictId: string, resolution: ConflictResolution) => { + safeHandle('dropbox:resolveConflict', async (_, conflictId: string, resolution: ConflictResolution) => { const engine = getDropboxSyncEngine(); const conflicts = engine.getPendingConflicts(); const conflict = conflicts.find(c => c.id === conflictId); @@ -452,32 +477,32 @@ export function registerIpcHandlers(): void { return engine.resolveConflict(conflict, resolution); }); - ipcMain.handle('dropbox:getLastSyncTime', async () => { + safeHandle('dropbox:getLastSyncTime', async () => { const engine = getDropboxSyncEngine(); return engine.getLastSyncTime(); }); // ============ Task Handlers ============ - ipcMain.handle('tasks:getAll', async () => { + safeHandle('tasks:getAll', async () => { return taskManager.getAllTasks(); }); - ipcMain.handle('tasks:getRunning', async () => { + safeHandle('tasks:getRunning', async () => { return taskManager.getRunningTasks(); }); - ipcMain.handle('tasks:cancel', async (_, taskId: string) => { + safeHandle('tasks:cancel', async (_, taskId: string) => { return taskManager.cancelTask(taskId); }); - ipcMain.handle('tasks:clearCompleted', async () => { + safeHandle('tasks:clearCompleted', async () => { return taskManager.clearCompletedTasks(); }); // ============ App Handlers ============ - ipcMain.handle('app:getDataPaths', async () => { + safeHandle('app:getDataPaths', async () => { // Get paths for the active project const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); @@ -490,11 +515,11 @@ export function registerIpcHandlers(): void { }; }); - ipcMain.handle('app:openFolder', async (_, folderPath: string) => { + safeHandle('app:openFolder', async (_, folderPath: string) => { return shell.openPath(folderPath); }); - ipcMain.handle('app:selectFolder', async (_, title?: string) => { + safeHandle('app:selectFolder', async (_, title?: string) => { const result = await dialog.showOpenDialog({ title: title || 'Select Folder', properties: ['openDirectory', 'createDirectory'], @@ -505,52 +530,52 @@ export function registerIpcHandlers(): void { return result.filePaths[0]; }); - ipcMain.handle('app:getDefaultProjectPath', async (_, projectId: string) => { + safeHandle('app:getDefaultProjectPath', async (_, projectId: string) => { const projectEngine = getProjectEngine(); return projectEngine.getDefaultProjectBaseDir(projectId); }); - ipcMain.handle('app:showItemInFolder', async (_, itemPath: string) => { + safeHandle('app:showItemInFolder', async (_, itemPath: string) => { return shell.showItemInFolder(itemPath); }); // ============ Meta Handlers ============ - ipcMain.handle('meta:getTags', async () => { + safeHandle('meta:getTags', async () => { const engine = getMetaEngine(); return engine.getTags(); }); - ipcMain.handle('meta:getCategories', async () => { + safeHandle('meta:getCategories', async () => { const engine = getMetaEngine(); return engine.getCategories(); }); - ipcMain.handle('meta:addTag', async (_, tag: string) => { + safeHandle('meta:addTag', async (_, tag: string) => { const engine = getMetaEngine(); await engine.addTag(tag); return engine.getTags(); }); - ipcMain.handle('meta:removeTag', async (_, tag: string) => { + safeHandle('meta:removeTag', async (_, tag: string) => { const engine = getMetaEngine(); await engine.removeTag(tag); return engine.getTags(); }); - ipcMain.handle('meta:addCategory', async (_, category: string) => { + safeHandle('meta:addCategory', async (_, category: string) => { const engine = getMetaEngine(); await engine.addCategory(category); return engine.getCategories(); }); - ipcMain.handle('meta:removeCategory', async (_, category: string) => { + safeHandle('meta:removeCategory', async (_, category: string) => { const engine = getMetaEngine(); await engine.removeCategory(category); return engine.getCategories(); }); - ipcMain.handle('meta:syncOnStartup', async () => { + safeHandle('meta:syncOnStartup', async () => { const engine = getMetaEngine(); await engine.syncOnStartup(); return { @@ -560,18 +585,18 @@ export function registerIpcHandlers(): void { }; }); - ipcMain.handle('meta:getProjectMetadata', async () => { + safeHandle('meta:getProjectMetadata', async () => { const engine = getMetaEngine(); return engine.getProjectMetadata(); }); - ipcMain.handle('meta:setProjectMetadata', async (_, metadata: { name: string; description?: string }) => { + safeHandle('meta:setProjectMetadata', async (_, metadata: { name: string; description?: string }) => { const engine = getMetaEngine(); await engine.setProjectMetadata(metadata); return engine.getProjectMetadata(); }); - ipcMain.handle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string }) => { + safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string }) => { const engine = getMetaEngine(); await engine.updateProjectMetadata(updates); return engine.getProjectMetadata(); @@ -579,57 +604,57 @@ export function registerIpcHandlers(): void { // ============ Tag Management Handlers ============ - ipcMain.handle('tags:getAll', async () => { + safeHandle('tags:getAll', async () => { const engine = getTagEngine(); return engine.getAllTags(); }); - ipcMain.handle('tags:getWithCounts', async () => { + safeHandle('tags:getWithCounts', async () => { const engine = getTagEngine(); return engine.getTagsWithCounts(); }); - ipcMain.handle('tags:get', async (_, id: string) => { + safeHandle('tags:get', async (_, id: string) => { const engine = getTagEngine(); return engine.getTag(id); }); - ipcMain.handle('tags:getByName', async (_, name: string) => { + safeHandle('tags:getByName', async (_, name: string) => { const engine = getTagEngine(); return engine.getTagByName(name); }); - ipcMain.handle('tags:create', async (_, data: { name: string; color?: string }) => { + safeHandle('tags:create', async (_, data: { name: string; color?: string }) => { const engine = getTagEngine(); return engine.createTag(data); }); - ipcMain.handle('tags:update', async (_, id: string, data: { name?: string; color?: string | null }) => { + safeHandle('tags:update', async (_, id: string, data: { name?: string; color?: string | null }) => { const engine = getTagEngine(); return engine.updateTag(id, data); }); - ipcMain.handle('tags:delete', async (_, id: string) => { + safeHandle('tags:delete', async (_, id: string) => { const engine = getTagEngine(); return engine.deleteTag(id); }); - ipcMain.handle('tags:merge', async (_, sourceTagIds: string[], targetTagId: string) => { + safeHandle('tags:merge', async (_, sourceTagIds: string[], targetTagId: string) => { const engine = getTagEngine(); return engine.mergeTags(sourceTagIds, targetTagId); }); - ipcMain.handle('tags:rename', async (_, id: string, newName: string) => { + safeHandle('tags:rename', async (_, id: string, newName: string) => { const engine = getTagEngine(); return engine.renameTag(id, newName); }); - ipcMain.handle('tags:getPostsWithTag', async (_, tagId: string) => { + safeHandle('tags:getPostsWithTag', async (_, tagId: string) => { const engine = getTagEngine(); return engine.getPostsWithTag(tagId); }); - ipcMain.handle('tags:syncFromPosts', async () => { + safeHandle('tags:syncFromPosts', async () => { const engine = getTagEngine(); return engine.syncTagsFromPosts(); }); diff --git a/src/main/main.ts b/src/main/main.ts index 450ecf3..27b2390 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -381,14 +381,20 @@ async function initialize(): Promise { } if (mediaItem && mediaItem.filePath) { + // Check if file exists before attempting to fetch + if (!fs.existsSync(mediaItem.filePath)) { + console.error(`[bds-media] File not found at path: ${mediaItem.filePath} (media id: ${mediaIdentifier})`); + return new Response(`Media file not found at: ${mediaItem.filePath}`, { status: 404 }); + } // Use net.fetch to get the file - this handles the file protocol properly return net.fetch(`file://${mediaItem.filePath}`); } // Return a 404 response if media not found - return new Response('Media not found', { status: 404 }); + console.error(`[bds-media] No media record found for identifier: ${mediaIdentifier}`); + return new Response('Media not found in database', { status: 404 }); } catch (error) { - console.error('Error serving media:', error); + console.error('[bds-media] Error serving media:', error); return new Response('Internal server error', { status: 500 }); } }); @@ -404,7 +410,13 @@ async function initialize(): Promise { const thumbnails = await engine.getThumbnailPaths(mediaId); if (thumbnails.small) { - return net.fetch(`file://${thumbnails.small}`); + // Check if thumbnail file exists + if (!fs.existsSync(thumbnails.small)) { + console.error(`[bds-thumb] Thumbnail not found at path: ${thumbnails.small} (media id: ${mediaId})`); + // Fall through to try full image + } else { + return net.fetch(`file://${thumbnails.small}`); + } } // Fallback to full image if thumbnail doesn't exist @@ -416,12 +428,18 @@ async function initialize(): Promise { .get(); if (mediaItem && mediaItem.filePath) { + // Check if file exists before attempting to fetch + if (!fs.existsSync(mediaItem.filePath)) { + console.error(`[bds-thumb] Media file not found at path: ${mediaItem.filePath} (media id: ${mediaId})`); + return new Response(`Media file not found at: ${mediaItem.filePath}`, { status: 404 }); + } return net.fetch(`file://${mediaItem.filePath}`); } + console.error(`[bds-thumb] No media record found for id: ${mediaId}`); return new Response('Thumbnail not found', { status: 404 }); } catch (error) { - console.error('Error serving thumbnail:', error); + console.error('[bds-thumb] Error serving thumbnail:', error); return new Response('Internal server error', { status: 500 }); } }); @@ -455,7 +473,7 @@ app.on('window-all-closed', () => { app.on('before-quit', async () => { // Cleanup chat resources await cleanupChatHandlers(); - + const db = getDatabase(); await db.close(); }); diff --git a/src/renderer/components/ChatPanel/ChatPanel.tsx b/src/renderer/components/ChatPanel/ChatPanel.tsx index b43bedf..d65d22b 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel/ChatPanel.tsx @@ -77,9 +77,9 @@ export const ChatPanel: React.FC = ({ conversationId }) => { const unsubToolCall = window.electronAPI?.chat.onToolCall((data) => { console.log('[ChatPanel] Tool call received:', data); if (data.conversationId === conversationId) { - const toolCall = data.toolCall as { name: string; args: unknown }; - toolEventsRef.current.push({ name: toolCall.name, args: toolCall.args }); - setToolEvents(prev => [...prev, { type: 'call', name: toolCall.name, args: toolCall.args, timestamp: Date.now() }]); + const toolCall = data.toolCall as { name: string; arguments: Record }; + toolEventsRef.current.push({ name: toolCall.name, args: toolCall.arguments }); + setToolEvents(prev => [...prev, { type: 'call', name: toolCall.name, args: toolCall.arguments, timestamp: Date.now() }]); scrollToBottom(); } });