fix: really fixed rebuild from filesystem
This commit is contained in:
@@ -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,
|
||||||
@@ -279,10 +278,40 @@ export class PostEngine extends EventEmitter {
|
|||||||
private async readPostFile(filePath: string): Promise<PostData | null> {
|
private async readPostFile(filePath: string): Promise<PostData | null> {
|
||||||
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');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user