fix: really fixed rebuild from filesystem

This commit is contained in:
2026-02-16 10:30:06 +01:00
parent bd964fb284
commit 1ecaae3dbd
3 changed files with 267 additions and 54 deletions

View File

@@ -250,7 +250,6 @@ export class PostEngine extends EventEmitter {
private async writePostFile(post: PostData): Promise<string> { private async writePostFile(post: PostData): Promise<string> {
const metadata: Record<string, unknown> = { const metadata: Record<string, unknown> = {
id: post.id, id: post.id,
projectId: post.projectId,
title: post.title, title: post.title,
slug: post.slug, slug: post.slug,
status: post.status, status: post.status,
@@ -280,9 +279,39 @@ export class PostEngine extends EventEmitter {
const data = await readPostFileShared(filePath); const data = await readPostFileShared(filePath);
if (!data) return null; if (!data) return null;
const fileStem = path.parse(filePath).name;
const normalizedTitle = typeof data.title === 'string' && data.title.trim().length > 0
? data.title.trim()
: fileStem;
const baseSlugSource = typeof data.slug === 'string' && data.slug.trim().length > 0
? data.slug.trim()
: normalizedTitle;
const normalizedSlug = this.generateSlug(baseSlugSource) || this.generateSlug(fileStem) || uuidv4();
const createdAt = data.createdAt instanceof Date && !Number.isNaN(data.createdAt.getTime())
? data.createdAt
: (data.updatedAt instanceof Date && !Number.isNaN(data.updatedAt.getTime()) ? data.updatedAt : new Date());
const updatedAt = data.updatedAt instanceof Date && !Number.isNaN(data.updatedAt.getTime())
? data.updatedAt
: createdAt;
const normalizedTags = Array.isArray(data.tags)
? data.tags.filter((tag): tag is string => typeof tag === 'string')
: [];
const normalizedCategories = Array.isArray(data.categories)
? data.categories.filter((category): category is string => typeof category === 'string')
: [];
return { return {
...data, ...data,
projectId: data.projectId || this.currentProjectId, id: typeof data.id === 'string' && data.id.trim().length > 0 ? data.id : uuidv4(),
projectId: this.currentProjectId,
title: normalizedTitle,
slug: normalizedSlug,
createdAt,
updatedAt,
tags: normalizedTags,
categories: normalizedCategories,
}; };
} }
@@ -1125,8 +1154,9 @@ export class PostEngine extends EventEmitter {
onProgress(5, 'Scanning posts directory...'); onProgress(5, 'Scanning posts directory...');
// Recursively find all .md files in the posts directory tree // Recursively find markdown files in the posts directory tree
const mdFiles: string[] = []; const markdownFiles: string[] = [];
const markdownExtensions = new Set(['.md', '.markdown', '.mdx']);
const scanDir = async (dir: string) => { const scanDir = async (dir: string) => {
try { try {
const entries = await fs.readdir(dir, { withFileTypes: true }); const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -1134,8 +1164,11 @@ export class PostEngine extends EventEmitter {
const fullPath = path.join(dir, entry.name); const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
await scanDir(fullPath); await scanDir(fullPath);
} else if (entry.name.endsWith('.md')) { } else {
mdFiles.push(fullPath); const extension = path.extname(entry.name).toLowerCase();
if (markdownExtensions.has(extension)) {
markdownFiles.push(fullPath);
}
} }
} }
} catch { } catch {
@@ -1150,62 +1183,87 @@ export class PostEngine extends EventEmitter {
} }
await scanDir(postsBaseDir); await scanDir(postsBaseDir);
onProgress(10, `Found ${mdFiles.length} post files`); onProgress(10, `Found ${markdownFiles.length} post files`);
// Track slugs to detect duplicates // Track slugs and ids to avoid collisions while still importing all files
const insertedSlugs = new Map<string, string>(); // slug -> filePath const insertedSlugs = new Set<string>(); // projectId:slug
const insertedIds = new Set<string>();
let importedCount = 0;
let parseFailedCount = 0;
let deduplicatedSlugCount = 0;
let deduplicatedIdCount = 0;
let insertFailedCount = 0;
for (let i = 0; i < mdFiles.length; i++) { for (let i = 0; i < markdownFiles.length; i++) {
const filePath = mdFiles[i]; const filePath = markdownFiles[i];
const fileName = path.basename(filePath); const fileName = path.basename(filePath);
onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${i + 1}/${mdFiles.length}: ${fileName}`); onProgress(10 + (80 * (i / markdownFiles.length)), `Processing ${i + 1}/${markdownFiles.length}: ${fileName}`);
const postData = await this.readPostFile(filePath); const postData = await this.readPostFile(filePath);
if (postData) { if (!postData) {
try { parseFailedCount++;
const projectId = postData.projectId || this.currentProjectId; continue;
const slugKey = `${projectId}:${postData.slug}`; }
// Check for duplicate slugs try {
if (insertedSlugs.has(slugKey)) { const projectId = this.currentProjectId;
console.error(`Duplicate slug "${postData.slug}" found. File "${filePath}" duplicates "${insertedSlugs.get(slugKey)}". Skipping.`);
continue;
}
const checksum = this.calculateChecksum(postData.content); let postId = postData.id;
while (insertedIds.has(postId)) {
postId = uuidv4();
deduplicatedIdCount++;
}
// Insert fresh - we deleted all records at the start let slug = postData.slug;
await db.insert(posts).values({ const baseSlug = slug;
id: postData.id, let slugAttempt = 2;
projectId, while (insertedSlugs.has(`${projectId}:${slug}`)) {
title: postData.title, slug = `${baseSlug}-${slugAttempt}`;
slug: postData.slug, slugAttempt++;
excerpt: postData.excerpt, deduplicatedSlugCount++;
content: null, // Content lives in the file, not DB }
status: 'published', // Files on disk = published
author: postData.author,
createdAt: postData.createdAt,
updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt || postData.updatedAt,
filePath,
checksum,
tags: JSON.stringify(postData.tags),
categories: JSON.stringify(postData.categories),
});
insertedSlugs.set(slugKey, filePath); const checksum = this.calculateChecksum(postData.content);
// Update FTS index (use file content for search) await db.insert(posts).values({
await this.updateFTSIndex(postData); id: postId,
} catch (error: any) { projectId,
// Handle constraint violations and other errors gracefully title: postData.title,
if (error?.code === 'SQLITE_CONSTRAINT_UNIQUE') { slug,
console.error(`Failed to insert post "${postData.title}" from ${filePath}: Unique constraint violation (likely slug conflict)`); excerpt: postData.excerpt,
} else { content: null,
console.error(`Failed to process post from ${filePath}:`, error); status: 'published',
} author: postData.author,
createdAt: postData.createdAt,
updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt || postData.updatedAt,
filePath,
checksum,
tags: JSON.stringify(postData.tags),
categories: JSON.stringify(postData.categories),
});
insertedIds.add(postId);
insertedSlugs.add(`${projectId}:${slug}`);
importedCount++;
await this.updateFTSIndex({
id: postId,
projectId,
title: postData.title,
content: postData.content,
excerpt: postData.excerpt,
tags: postData.tags,
categories: postData.categories,
});
} catch (error: any) {
insertFailedCount++;
if (error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
console.error(`Failed to insert post "${postData.title}" from ${filePath}: Unique constraint violation`);
} else {
console.error(`Failed to process post from ${filePath}:`, error);
} }
} }
@@ -1215,7 +1273,8 @@ export class PostEngine extends EventEmitter {
} }
} }
onProgress(100, 'Database rebuild complete'); onProgress(100, `Database rebuild complete: imported ${importedCount}/${markdownFiles.length} files`);
console.log(`[PostEngine] rebuildDatabaseFromFiles complete. scanned=${markdownFiles.length}, imported=${importedCount}, parseFailed=${parseFailedCount}, insertFailed=${insertFailedCount}, deduplicatedSlugs=${deduplicatedSlugCount}, deduplicatedIds=${deduplicatedIdCount}`);
this.emit('databaseRebuilt'); this.emit('databaseRebuilt');
}, },
}; };

View File

@@ -8,7 +8,6 @@ import matter from 'gray-matter';
export interface PostFileData { export interface PostFileData {
id: string; id: string;
projectId?: string;
title: string; title: string;
slug: string; slug: string;
excerpt?: string; excerpt?: string;
@@ -24,7 +23,6 @@ export interface PostFileData {
interface PostFileMetadata { interface PostFileMetadata {
id: string; id: string;
projectId?: string;
title: string; title: string;
slug: string; slug: string;
excerpt?: string; excerpt?: string;
@@ -58,7 +56,6 @@ export async function readPostFile(filePath: string): Promise<PostFileData | nul
return { return {
id: metadata.id, id: metadata.id,
projectId: metadata.projectId,
title: metadata.title, title: metadata.title,
slug: metadata.slug, slug: metadata.slug,
excerpt: metadata.excerpt, excerpt: metadata.excerpt,

View File

@@ -1324,6 +1324,32 @@ Content 2`;
expect(mockLocalDb.insert).toHaveBeenCalled(); expect(mockLocalDb.insert).toHaveBeenCalled();
}); });
it('should include .markdown files during rebuild', async () => {
const fs = await import('fs/promises');
vi.mocked(fs.readdir).mockResolvedValueOnce([
mockDirent('legacy-post.markdown'),
] as any);
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockResolvedValueOnce(`---
id: legacy-post-id
projectId: default
title: Legacy Post
slug: legacy-post
status: published
createdAt: 2024-01-01T00:00:00.000Z
updatedAt: 2024-01-01T00:00:00.000Z
tags: []
categories: []
---
Legacy content`);
await postEngine.rebuildDatabaseFromFiles();
expect(mockLocalDb.insert).toHaveBeenCalled();
});
it('should emit databaseRebuilt event on completion', async () => { it('should emit databaseRebuilt event on completion', async () => {
const fs = await import('fs/promises'); const fs = await import('fs/promises');
const handler = vi.fn(); const handler = vi.fn();
@@ -1511,6 +1537,99 @@ Valid content`;
// Should not throw // Should not throw
await postEngine.rebuildDatabaseFromFiles(); await postEngine.rebuildDatabaseFromFiles();
}); });
it('should import posts with duplicate slugs by auto-deduplicating slugs', async () => {
const fs = await import('fs/promises');
const insertedSlugs: string[] = [];
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
values: vi.fn((data: any) => {
insertedSlugs.push(data.slug);
if (data?.id) {
mockPosts.set(data.id, data);
}
return Promise.resolve();
}),
}));
vi.mocked(fs.readdir).mockResolvedValueOnce([
mockDirent('post-a.md'),
mockDirent('post-b.md'),
] as any);
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
if (filePath.includes('post-a.md')) {
return `---
id: post-a-id
projectId: default
title: Post A
slug: same-slug
status: published
createdAt: 2024-01-01T00:00:00.000Z
updatedAt: 2024-01-01T00:00:00.000Z
tags: []
categories: []
---
Content A`;
}
return `---
id: post-b-id
projectId: default
title: Post B
slug: same-slug
status: published
createdAt: 2024-01-02T00:00:00.000Z
updatedAt: 2024-01-02T00:00:00.000Z
tags: []
categories: []
---
Content B`;
});
await postEngine.rebuildDatabaseFromFiles();
const uniqueSlugs = new Set(insertedSlugs);
expect(uniqueSlugs.has('same-slug')).toBe(true);
expect(uniqueSlugs.has('same-slug-2')).toBe(true);
});
it('should ignore frontmatter projectId and import into current project', async () => {
const fs = await import('fs/promises');
const insertedProjects: string[] = [];
postEngine.setProjectContext('current-project-id');
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
values: vi.fn((data: any) => {
insertedProjects.push(data.projectId);
if (data?.id) {
mockPosts.set(data.id, data);
}
return Promise.resolve();
}),
}));
vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('post-with-old-project.md')] as any);
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockResolvedValueOnce(`---
id: post-old-project
projectId: old-project-id
title: Old Project Post
slug: old-project-post
status: published
createdAt: 2024-01-01T00:00:00.000Z
updatedAt: 2024-01-01T00:00:00.000Z
tags: []
categories: []
---
Content`);
await postEngine.rebuildDatabaseFromFiles();
expect(insertedProjects).toHaveLength(1);
expect(insertedProjects[0]).toBe('current-project-id');
});
}); });
describe('Date-based folder structure', () => { describe('Date-based folder structure', () => {
@@ -1701,6 +1820,44 @@ Valid content`;
expect(fs.writeFile).toHaveBeenCalled(); expect(fs.writeFile).toHaveBeenCalled();
}); });
it('should not write projectId to frontmatter when publishing', async () => {
const fs = await import('fs/promises');
postEngine.setProjectContext('my-project-id');
const created = await postEngine.createPost({
title: 'No ProjectId Frontmatter',
content: 'Published content',
});
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: created.id,
projectId: created.projectId,
title: created.title,
slug: created.slug,
status: 'draft',
content: created.content,
filePath: '',
tags: '[]',
categories: '[]',
createdAt: created.createdAt,
updatedAt: created.updatedAt,
}),
all: vi.fn().mockResolvedValue([]),
});
return chain;
});
await postEngine.publishPost(created.id);
const writeCalls = vi.mocked(fs.writeFile).mock.calls;
expect(writeCalls.length).toBeGreaterThan(0);
const writtenContent = writeCalls[0][1] as string;
expect(writtenContent).not.toContain('projectId:');
});
it('should emit postUpdated event', async () => { it('should emit postUpdated event', async () => {
const handler = vi.fn(); const handler = vi.fn();
postEngine.on('postUpdated', handler); postEngine.on('postUpdated', handler);