fix: project got corrupted sometimes
This commit is contained in:
@@ -184,9 +184,19 @@ export class MediaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setProjectContext(projectId: string, dataDir?: string, internalDir?: string): void {
|
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.currentProjectId = projectId;
|
||||||
this.dataDir = dataDir || null;
|
this.dataDir = nextDataDir;
|
||||||
this.internalDir = internalDir || null;
|
this.internalDir = nextInternalDir;
|
||||||
console.log(`[MediaEngine] setProjectContext: projectId=${projectId}, dataDir=${this.dataDir}, internalDir=${this.internalDir}`);
|
console.log(`[MediaEngine] setProjectContext: projectId=${projectId}, dataDir=${this.dataDir}, internalDir=${this.internalDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
private categories: Set<string> = new Set();
|
private categories: Set<string> = new Set();
|
||||||
private projectMetadata: ProjectMetadata | null = null;
|
private projectMetadata: ProjectMetadata | null = null;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
|
private startupSyncPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -218,13 +219,19 @@ export class MetaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setProjectContext(projectId: string, dataDir?: string): void {
|
setProjectContext(projectId: string, dataDir?: string): void {
|
||||||
|
const nextDataDir = dataDir || null;
|
||||||
|
if (this.currentProjectId === projectId && this.dataDir === nextDataDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.currentProjectId = projectId;
|
this.currentProjectId = projectId;
|
||||||
this.dataDir = dataDir || null;
|
this.dataDir = nextDataDir;
|
||||||
// Reset in-memory cache when project changes
|
// Reset in-memory cache when project changes
|
||||||
this.tags.clear();
|
this.tags.clear();
|
||||||
this.categories.clear();
|
this.categories.clear();
|
||||||
this.projectMetadata = null;
|
this.projectMetadata = null;
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
|
this.startupSyncPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getProjectContext(): string {
|
getProjectContext(): string {
|
||||||
@@ -394,8 +401,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
await this.ensureMetaDirExists();
|
await this.ensureMetaDirExists();
|
||||||
const filePath = this.getCategoriesFilePath();
|
const filePath = this.getCategoriesFilePath();
|
||||||
const content = JSON.stringify(Array.from(this.categories).sort(), null, 2);
|
await this.writeJsonFileAtomically(filePath, Array.from(this.categories).sort());
|
||||||
await fs.writeFile(filePath, content, 'utf-8');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MetaEngine] Failed to save categories:', error);
|
console.error('[MetaEngine] Failed to save categories:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -415,8 +421,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
categorySettings: _categorySettings,
|
categorySettings: _categorySettings,
|
||||||
...persistedMetadata
|
...persistedMetadata
|
||||||
} = this.projectMetadata || {};
|
} = this.projectMetadata || {};
|
||||||
const content = JSON.stringify(persistedMetadata, null, 2);
|
await this.writeJsonFileAtomically(filePath, persistedMetadata);
|
||||||
await fs.writeFile(filePath, content, 'utf-8');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MetaEngine] Failed to save project metadata:', error);
|
console.error('[MetaEngine] Failed to save project metadata:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -433,8 +438,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
const metadata = this.ensureCategoryMetadataForKnownCategories(
|
const metadata = this.ensureCategoryMetadataForKnownCategories(
|
||||||
this.projectMetadata?.categoryMetadata,
|
this.projectMetadata?.categoryMetadata,
|
||||||
);
|
);
|
||||||
const content = JSON.stringify(metadata, null, 2);
|
await this.writeJsonFileAtomically(filePath, metadata);
|
||||||
await fs.writeFile(filePath, content, 'utf-8');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MetaEngine] Failed to save category metadata:', error);
|
console.error('[MetaEngine] Failed to save category metadata:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -582,6 +586,24 @@ export class MetaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async writeJsonFileAtomically(filePath: string, value: unknown): Promise<void> {
|
||||||
|
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(
|
private ensureCategoryMetadataForKnownCategories(
|
||||||
categoryMetadata: Record<string, CategoryMetadata> | undefined,
|
categoryMetadata: Record<string, CategoryMetadata> | undefined,
|
||||||
): Record<string, CategoryMetadata> {
|
): Record<string, CategoryMetadata> {
|
||||||
@@ -611,6 +633,24 @@ export class MetaEngine extends EventEmitter {
|
|||||||
* - Project metadata: read from file or create from database
|
* - Project metadata: read from file or create from database
|
||||||
*/
|
*/
|
||||||
async syncOnStartup(): Promise<void> {
|
async syncOnStartup(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
console.log(`[MetaEngine] Syncing metadata for project: ${this.currentProjectId}`);
|
console.log(`[MetaEngine] Syncing metadata for project: ${this.currentProjectId}`);
|
||||||
|
|
||||||
await this.ensureMetaDirExists();
|
await this.ensureMetaDirExists();
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ export class PostMediaEngine extends EventEmitter {
|
|||||||
* Set the current project context
|
* Set the current project context
|
||||||
*/
|
*/
|
||||||
setProjectContext(projectId: string): void {
|
setProjectContext(projectId: string): void {
|
||||||
|
if (this.currentProjectId === projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.currentProjectId = projectId;
|
this.currentProjectId = projectId;
|
||||||
console.log(`[PostMediaEngine] setProjectContext: projectId=${projectId}`);
|
console.log(`[PostMediaEngine] setProjectContext: projectId=${projectId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,15 @@ export class PreviewServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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();
|
const context = await this.getActiveProjectContext();
|
||||||
this.postEngine.setProjectContext(context.projectId, context.dataDir);
|
this.postEngine.setProjectContext(context.projectId, context.dataDir);
|
||||||
this.mediaEngine.setProjectContext?.(context.projectId, context.dataDir, 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 language = metadata?.mainLanguage?.trim() || 'en';
|
||||||
const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription);
|
const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription);
|
||||||
const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage);
|
const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage);
|
||||||
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
|
|
||||||
const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme'));
|
const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme'));
|
||||||
const previewThemeMode = sanitizePicoThemeMode(requestUrl.searchParams.get('mode'));
|
const previewThemeMode = sanitizePicoThemeMode(requestUrl.searchParams.get('mode'));
|
||||||
const useDraftContent = requestUrl.searchParams.get('draft') === 'true';
|
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 appliedTheme = requestTheme ?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme);
|
||||||
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
|
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
|
||||||
const htmlRewriteContext = await this.buildHtmlRewriteContext();
|
const htmlRewriteContext = await this.buildHtmlRewriteContext();
|
||||||
const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/');
|
|
||||||
|
|
||||||
if (pathname === '/__style-preview') {
|
if (pathname === '/__style-preview') {
|
||||||
const stylePreviewHtml = await this.renderStylePreview(htmlRewriteContext, {
|
const stylePreviewHtml = await this.renderStylePreview(htmlRewriteContext, {
|
||||||
@@ -252,12 +259,6 @@ export class PreviewServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const asset = await this.resolveAsset(pathname);
|
|
||||||
if (asset) {
|
|
||||||
this.respondAsset(res, asset.contentType, asset.body);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageAsset = await this.resolveImageAsset(pathname);
|
const imageAsset = await this.resolveImageAsset(pathname);
|
||||||
if (imageAsset) {
|
if (imageAsset) {
|
||||||
this.respondAsset(res, imageAsset.contentType, imageAsset.body);
|
this.respondAsset(res, imageAsset.contentType, imageAsset.body);
|
||||||
|
|||||||
@@ -229,6 +229,16 @@ describe('MediaEngine', () => {
|
|||||||
expect(mediaEngine.getProjectContext()).toBe('my-blog');
|
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', () => {
|
it('should allow changing project context multiple times', () => {
|
||||||
mediaEngine.setProjectContext('blog-1');
|
mediaEngine.setProjectContext('blog-1');
|
||||||
expect(mediaEngine.getProjectContext()).toBe('blog-1');
|
expect(mediaEngine.getProjectContext()).toBe('blog-1');
|
||||||
|
|||||||
@@ -48,6 +48,27 @@ vi.mock('fs/promises', () => ({
|
|||||||
throw err;
|
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
|
// Mock electron app
|
||||||
@@ -986,6 +1007,27 @@ describe('MetaEngine', () => {
|
|||||||
expect(metaEngine.isInitialized()).toBe(false);
|
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<string[]>;
|
||||||
|
}, 'collectTagsFromPosts');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
metaEngine.syncOnStartup(),
|
||||||
|
metaEngine.syncOnStartup(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(collectTagsSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('should use custom dataDir when provided in setProjectContext', () => {
|
it('should use custom dataDir when provided in setProjectContext', () => {
|
||||||
const customDataDir = path.join('custom', 'data', 'path');
|
const customDataDir = path.join('custom', 'data', 'path');
|
||||||
metaEngine.setProjectContext('project-with-custom-dir', customDataDir);
|
metaEngine.setProjectContext('project-with-custom-dir', customDataDir);
|
||||||
|
|||||||
@@ -154,6 +154,16 @@ describe('PostMediaEngine', () => {
|
|||||||
expect(true).toBe(true);
|
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', () => {
|
it('should allow changing project context multiple times', () => {
|
||||||
engine.setProjectContext('blog-1');
|
engine.setProjectContext('blog-1');
|
||||||
engine.setProjectContext('blog-2');
|
engine.setProjectContext('blog-2');
|
||||||
|
|||||||
@@ -339,6 +339,48 @@ describe('PreviewServer', () => {
|
|||||||
expect(lightboxLoadingImageResponse.headers.get('content-type')).toContain('image/gif');
|
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 () => {
|
it('renders tag_cloud macro with normalized tag usage and tag archive links', async () => {
|
||||||
const posts = [
|
const posts = [
|
||||||
makePost({
|
makePost({
|
||||||
|
|||||||
Reference in New Issue
Block a user