diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index 0a213ae..99bc113 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -184,9 +184,19 @@ export class MediaEngine extends EventEmitter { } setProjectContext(projectId: string, dataDir?: string, internalDir?: string): void { + const nextDataDir = dataDir || null; + const nextInternalDir = internalDir || null; + if ( + this.currentProjectId === projectId + && this.dataDir === nextDataDir + && this.internalDir === nextInternalDir + ) { + return; + } + this.currentProjectId = projectId; - this.dataDir = dataDir || null; - this.internalDir = internalDir || null; + this.dataDir = nextDataDir; + this.internalDir = nextInternalDir; console.log(`[MediaEngine] setProjectContext: projectId=${projectId}, dataDir=${this.dataDir}, internalDir=${this.internalDir}`); } diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index d4b5699..332f79b 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -176,6 +176,7 @@ export class MetaEngine extends EventEmitter { private categories: Set = new Set(); private projectMetadata: ProjectMetadata | null = null; private initialized: boolean = false; + private startupSyncPromise: Promise | null = null; constructor() { super(); @@ -218,13 +219,19 @@ export class MetaEngine extends EventEmitter { } setProjectContext(projectId: string, dataDir?: string): void { + const nextDataDir = dataDir || null; + if (this.currentProjectId === projectId && this.dataDir === nextDataDir) { + return; + } + this.currentProjectId = projectId; - this.dataDir = dataDir || null; + this.dataDir = nextDataDir; // Reset in-memory cache when project changes this.tags.clear(); this.categories.clear(); this.projectMetadata = null; this.initialized = false; + this.startupSyncPromise = null; } getProjectContext(): string { @@ -394,8 +401,7 @@ export class MetaEngine extends EventEmitter { try { await this.ensureMetaDirExists(); const filePath = this.getCategoriesFilePath(); - const content = JSON.stringify(Array.from(this.categories).sort(), null, 2); - await fs.writeFile(filePath, content, 'utf-8'); + await this.writeJsonFileAtomically(filePath, Array.from(this.categories).sort()); } catch (error) { console.error('[MetaEngine] Failed to save categories:', error); throw error; @@ -415,8 +421,7 @@ export class MetaEngine extends EventEmitter { categorySettings: _categorySettings, ...persistedMetadata } = this.projectMetadata || {}; - const content = JSON.stringify(persistedMetadata, null, 2); - await fs.writeFile(filePath, content, 'utf-8'); + await this.writeJsonFileAtomically(filePath, persistedMetadata); } catch (error) { console.error('[MetaEngine] Failed to save project metadata:', error); throw error; @@ -433,8 +438,7 @@ export class MetaEngine extends EventEmitter { const metadata = this.ensureCategoryMetadataForKnownCategories( this.projectMetadata?.categoryMetadata, ); - const content = JSON.stringify(metadata, null, 2); - await fs.writeFile(filePath, content, 'utf-8'); + await this.writeJsonFileAtomically(filePath, metadata); } catch (error) { console.error('[MetaEngine] Failed to save category metadata:', error); throw error; @@ -582,6 +586,24 @@ export class MetaEngine extends EventEmitter { } } + private async writeJsonFileAtomically(filePath: string, value: unknown): Promise { + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + const content = JSON.stringify(value, null, 2); + + await fs.writeFile(tempPath, content, 'utf-8'); + + try { + await fs.rename(tempPath, filePath); + } catch (error) { + try { + await fs.unlink(tempPath); + } catch { + // Ignore cleanup errors. + } + throw error; + } + } + private ensureCategoryMetadataForKnownCategories( categoryMetadata: Record | undefined, ): Record { @@ -611,6 +633,24 @@ export class MetaEngine extends EventEmitter { * - Project metadata: read from file or create from database */ async syncOnStartup(): Promise { + if (this.initialized) { + return; + } + + if (this.startupSyncPromise) { + await this.startupSyncPromise; + return; + } + + this.startupSyncPromise = this.performSyncOnStartup(); + try { + await this.startupSyncPromise; + } finally { + this.startupSyncPromise = null; + } + } + + private async performSyncOnStartup(): Promise { console.log(`[MetaEngine] Syncing metadata for project: ${this.currentProjectId}`); await this.ensureMetaDirExists(); diff --git a/src/main/engine/PostMediaEngine.ts b/src/main/engine/PostMediaEngine.ts index aa0f90c..dfba841 100644 --- a/src/main/engine/PostMediaEngine.ts +++ b/src/main/engine/PostMediaEngine.ts @@ -156,6 +156,10 @@ export class PostMediaEngine extends EventEmitter { * Set the current project context */ setProjectContext(projectId: string): void { + if (this.currentProjectId === projectId) { + return; + } + this.currentProjectId = projectId; console.log(`[PostMediaEngine] setProjectContext: projectId=${projectId}`); } diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index 19fe209..6b76bf6 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -210,6 +210,15 @@ export class PreviewServer { } try { + const requestUrl = new URL(req.url || '/', 'http://127.0.0.1'); + const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/'); + + const asset = await this.resolveAsset(pathname); + if (asset) { + this.respondAsset(res, asset.contentType, asset.body); + return; + } + const context = await this.getActiveProjectContext(); this.postEngine.setProjectContext(context.projectId, context.dataDir); this.mediaEngine.setProjectContext?.(context.projectId, context.dataDir, context.dataDir); @@ -230,7 +239,6 @@ export class PreviewServer { const language = metadata?.mainLanguage?.trim() || 'en'; const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription); const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage); - const requestUrl = new URL(req.url || '/', 'http://127.0.0.1'); const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme')); const previewThemeMode = sanitizePicoThemeMode(requestUrl.searchParams.get('mode')); const useDraftContent = requestUrl.searchParams.get('draft') === 'true'; @@ -238,7 +246,6 @@ export class PreviewServer { const appliedTheme = requestTheme ?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme); const picoStylesheetHref = getPicoStylesheetHref(appliedTheme); const htmlRewriteContext = await this.buildHtmlRewriteContext(); - const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/'); if (pathname === '/__style-preview') { const stylePreviewHtml = await this.renderStylePreview(htmlRewriteContext, { @@ -252,12 +259,6 @@ export class PreviewServer { return; } - const asset = await this.resolveAsset(pathname); - if (asset) { - this.respondAsset(res, asset.contentType, asset.body); - return; - } - const imageAsset = await this.resolveImageAsset(pathname); if (imageAsset) { this.respondAsset(res, imageAsset.contentType, imageAsset.body); diff --git a/tests/engine/MediaEngine.test.ts b/tests/engine/MediaEngine.test.ts index e662d7c..1c79c2f 100644 --- a/tests/engine/MediaEngine.test.ts +++ b/tests/engine/MediaEngine.test.ts @@ -229,6 +229,16 @@ describe('MediaEngine', () => { expect(mediaEngine.getProjectContext()).toBe('my-blog'); }); + it('should avoid duplicate context log when context is unchanged', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + mediaEngine.setProjectContext('same-project', '/tmp/data', '/tmp/internal'); + mediaEngine.setProjectContext('same-project', '/tmp/data', '/tmp/internal'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + consoleLogSpy.mockRestore(); + }); + it('should allow changing project context multiple times', () => { mediaEngine.setProjectContext('blog-1'); expect(mediaEngine.getProjectContext()).toBe('blog-1'); diff --git a/tests/engine/MetaEngine.test.ts b/tests/engine/MetaEngine.test.ts index 5dca60f..daa9052 100644 --- a/tests/engine/MetaEngine.test.ts +++ b/tests/engine/MetaEngine.test.ts @@ -48,6 +48,27 @@ vi.mock('fs/promises', () => ({ throw err; } }), + rename: vi.fn(async (oldPath: string, newPath: string) => { + const normalizedOldPath = oldPath.replace(/\\/g, '/'); + const normalizedNewPath = newPath.replace(/\\/g, '/'); + const content = mockFiles.get(normalizedOldPath); + if (content === undefined) { + const err = new Error(`ENOENT: no such file or directory, rename '${oldPath}' -> '${newPath}'`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + mockFiles.set(normalizedNewPath, content); + mockFiles.delete(normalizedOldPath); + }), + unlink: vi.fn(async (filePath: string) => { + const normalizedPath = filePath.replace(/\\/g, '/'); + if (!mockFiles.has(normalizedPath)) { + const err = new Error(`ENOENT: no such file or directory, unlink '${filePath}'`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + mockFiles.delete(normalizedPath); + }), })); // Mock electron app @@ -986,6 +1007,27 @@ describe('MetaEngine', () => { expect(metaEngine.isInitialized()).toBe(false); }); + it('should keep initialized flag when project context is unchanged', async () => { + await metaEngine.syncOnStartup(); + expect(metaEngine.isInitialized()).toBe(true); + + metaEngine.setProjectContext('test-project'); + expect(metaEngine.isInitialized()).toBe(true); + }); + + it('should de-duplicate concurrent syncOnStartup calls', async () => { + const collectTagsSpy = vi.spyOn(metaEngine as unknown as { + collectTagsFromPosts: () => Promise; + }, 'collectTagsFromPosts'); + + await Promise.all([ + metaEngine.syncOnStartup(), + metaEngine.syncOnStartup(), + ]); + + expect(collectTagsSpy).toHaveBeenCalledTimes(1); + }); + it('should use custom dataDir when provided in setProjectContext', () => { const customDataDir = path.join('custom', 'data', 'path'); metaEngine.setProjectContext('project-with-custom-dir', customDataDir); diff --git a/tests/engine/PostMediaEngine.test.ts b/tests/engine/PostMediaEngine.test.ts index 338eb95..feb73a4 100644 --- a/tests/engine/PostMediaEngine.test.ts +++ b/tests/engine/PostMediaEngine.test.ts @@ -154,6 +154,16 @@ describe('PostMediaEngine', () => { expect(true).toBe(true); }); + it('should avoid duplicate context log when context is unchanged', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + engine.setProjectContext('same-project'); + engine.setProjectContext('same-project'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + consoleLogSpy.mockRestore(); + }); + it('should allow changing project context multiple times', () => { engine.setProjectContext('blog-1'); engine.setProjectContext('blog-2'); diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index b0dca04..66043e5 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -339,6 +339,48 @@ describe('PreviewServer', () => { expect(lightboxLoadingImageResponse.headers.get('content-type')).toContain('image/gif'); }); + it('does not set project context or run startup sync for static asset requests', async () => { + const postEngine = makeEngine([makePost()]); + const mediaEngine = { + setProjectContext: vi.fn(), + async getAllMedia() { + return []; + }, + }; + const postMediaEngine = makePostMediaEngine({}); + const syncOnStartup = vi.fn(async () => undefined); + const settingsEngine = { + setProjectContext: vi.fn(), + isInitialized: vi.fn(() => false), + syncOnStartup, + async getProjectMetadata() { + return { maxPostsPerPage: 50 }; + }, + }; + const menuEngine = makeMenuEngine({ items: [] }); + + server = new PreviewServer({ + postEngine, + mediaEngine: mediaEngine as any, + postMediaEngine, + settingsEngine: settingsEngine as any, + menuEngine, + getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }), + }); + + await server.start(0); + + const response = await fetch(`${server.getBaseUrl()}/assets/pico.min.css`); + expect(response.status).toBe(200); + + expect(postEngine.setProjectContext).not.toHaveBeenCalled(); + expect(mediaEngine.setProjectContext).not.toHaveBeenCalled(); + expect(postMediaEngine.setProjectContext).not.toHaveBeenCalled(); + expect(settingsEngine.setProjectContext).not.toHaveBeenCalled(); + expect(menuEngine.setProjectContext).not.toHaveBeenCalled(); + expect(syncOnStartup).not.toHaveBeenCalled(); + }); + it('renders tag_cloud macro with normalized tag usage and tag archive links', async () => { const posts = [ makePost({