fix: rebuild from files now works

This commit is contained in:
2026-02-10 22:34:06 +01:00
parent e4cf0d333f
commit 7e4457c15d
5 changed files with 216 additions and 95 deletions

View File

@@ -137,7 +137,7 @@ export class DatabaseConnection {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
project_id TEXT NOT NULL DEFAULT 'default', project_id TEXT NOT NULL DEFAULT 'default',
title TEXT NOT NULL, title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE, slug TEXT NOT NULL,
excerpt TEXT, excerpt TEXT,
status TEXT NOT NULL DEFAULT 'draft', status TEXT NOT NULL DEFAULT 'draft',
author TEXT, author TEXT,
@@ -211,6 +211,7 @@ export class DatabaseConnection {
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status); CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
CREATE INDEX IF NOT EXISTS idx_post_links_source ON post_links(source_post_id); CREATE INDEX IF NOT EXISTS idx_post_links_source ON post_links(source_post_id);
CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id); CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id);
CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx ON posts(project_id, slug);
`); `);
// Check if project_id column exists in posts table, add if missing (migration) // Check if project_id column exists in posts table, add if missing (migration)
@@ -267,6 +268,83 @@ export class DatabaseConnection {
await this.localClient.execute("ALTER TABLE posts ADD COLUMN content TEXT"); await this.localClient.execute("ALTER TABLE posts ADD COLUMN content TEXT");
} }
// Migration: Update slug unique constraint to be project-scoped
// SQLite doesn't allow dropping column-level UNIQUE constraints, so we must recreate the table
// Check if the posts table has a column-level UNIQUE on slug (from the table definition)
const tableInfo = await this.localClient.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='posts'"
);
const tableSql = tableInfo.rows[0]?.sql as string || '';
const hasColumnLevelUnique = tableSql.includes('slug TEXT NOT NULL UNIQUE') ||
tableSql.includes('slug TEXT UNIQUE') ||
/slug\s+TEXT[^,]*UNIQUE/i.test(tableSql);
if (hasColumnLevelUnique) {
console.log('Migrating posts table to remove column-level UNIQUE constraint on slug...');
// Create new table without the UNIQUE constraint
await this.localClient.execute(`
CREATE TABLE IF NOT EXISTS posts_new (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL DEFAULT 'default',
title TEXT NOT NULL,
slug TEXT NOT NULL,
excerpt TEXT,
content TEXT,
status TEXT NOT NULL DEFAULT 'draft',
author TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
published_at INTEGER,
file_path TEXT NOT NULL DEFAULT '',
sync_status TEXT NOT NULL DEFAULT 'pending',
synced_at INTEGER,
checksum TEXT,
tags TEXT,
categories TEXT,
published_title TEXT,
published_content TEXT,
published_tags TEXT,
published_categories TEXT,
published_excerpt TEXT
)
`);
// Copy data
await this.localClient.execute(`
INSERT INTO posts_new
SELECT id, project_id, title, slug, excerpt, content, status, author,
created_at, updated_at, published_at, file_path, sync_status,
synced_at, checksum, tags, categories, published_title,
published_content, published_tags, published_categories, published_excerpt
FROM posts
`);
// Drop old table and rename new one
await this.localClient.execute('DROP TABLE posts');
await this.localClient.execute('ALTER TABLE posts_new RENAME TO posts');
// Recreate indexes
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)');
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status)');
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status)');
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at)');
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_project_id ON posts(project_id)');
await this.localClient.execute('CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx ON posts(project_id, slug)');
console.log('Posts table migration complete');
} else {
// Just ensure the composite unique index exists
const compositeSlugIndex = await this.localClient.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND name='posts_project_slug_idx' AND tbl_name='posts'"
);
if (compositeSlugIndex.rows.length === 0) {
await this.localClient.execute(
"CREATE UNIQUE INDEX posts_project_slug_idx ON posts(project_id, slug)"
);
}
}
// Create FTS5 virtual table for full-text search // Create FTS5 virtual table for full-text search
await this.localClient.execute(` await this.localClient.execute(`
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(

View File

@@ -1,4 +1,4 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';
// Projects table - stores blog projects/websites // Projects table - stores blog projects/websites
export const projects = sqliteTable('projects', { export const projects = sqliteTable('projects', {
@@ -20,7 +20,7 @@ export const posts = sqliteTable('posts', {
projectId: text('project_id').notNull(), projectId: text('project_id').notNull(),
id: text('id').primaryKey(), id: text('id').primaryKey(),
title: text('title').notNull(), title: text('title').notNull(),
slug: text('slug').notNull().unique(), slug: text('slug').notNull(),
excerpt: text('excerpt'), excerpt: text('excerpt'),
content: text('content'), // Draft body text (null/empty when published — content is in the file) content: text('content'), // Draft body text (null/empty when published — content is in the file)
status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'), status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
@@ -40,7 +40,10 @@ export const posts = sqliteTable('posts', {
publishedTags: text('published_tags'), publishedTags: text('published_tags'),
publishedCategories: text('published_categories'), publishedCategories: text('published_categories'),
publishedExcerpt: text('published_excerpt'), publishedExcerpt: text('published_excerpt'),
}); }, (table) => ({
// Composite unique index: slug must be unique within each project
projectSlugIdx: uniqueIndex('posts_project_slug_idx').on(table.projectId, table.slug),
}));
// Media table - stores metadata for images and other media // Media table - stores metadata for images and other media
export const media = sqliteTable('media', { export const media = sqliteTable('media', {

View File

@@ -238,6 +238,14 @@ export class MediaEngine extends EventEmitter {
private async readSidecarFile(sidecarPath: string): Promise<MediaMetadata | null> { private async readSidecarFile(sidecarPath: string): Promise<MediaMetadata | null> {
try { try {
// Check if file exists first to avoid noisy errors
try {
await fs.access(sidecarPath);
} catch {
// File doesn't exist - this is expected when DB has stale paths
return null;
}
const content = await fs.readFile(sidecarPath, 'utf-8'); const content = await fs.readFile(sidecarPath, 'utf-8');
const lines = content.split('\n'); const lines = content.split('\n');
@@ -309,7 +317,7 @@ export class MediaEngine extends EventEmitter {
return metadata as MediaMetadata; return metadata as MediaMetadata;
} catch (error) { } catch (error) {
console.error(`Failed to read sidecar file: ${sidecarPath}`, error); console.error(`Failed to parse sidecar file: ${sidecarPath}`, error);
return null; return null;
} }
} }
@@ -535,7 +543,16 @@ export class MediaEngine extends EventEmitter {
execute: async (onProgress) => { execute: async (onProgress) => {
const db = getDatabase().getLocal(); const db = getDatabase().getLocal();
onProgress(0, 'Scanning media directory...'); onProgress(0, 'Deleting existing media for project...');
// Delete all media for the current project - clean slate rebuild
const existingMedia = await db.select({ id: media.id }).from(media).where(eq(media.projectId, this.currentProjectId)).all();
if (existingMedia.length > 0) {
await db.delete(media).where(eq(media.projectId, this.currentProjectId));
console.log(`Deleted ${existingMedia.length} existing media record(s) for project ${this.currentProjectId}`);
}
onProgress(5, 'Scanning media directory...');
// Recursively find all .meta files in the media directory tree // Recursively find all .meta files in the media directory tree
const metaFiles: string[] = []; const metaFiles: string[] = [];
@@ -580,44 +597,26 @@ export class MediaEngine extends EventEmitter {
const checksum = this.calculateChecksum(buffer); const checksum = this.calculateChecksum(buffer);
const filename = path.basename(mediaFilePath); const filename = path.basename(mediaFilePath);
const existing = await db.select().from(media).where(eq(media.id, metadata.id)).get(); // Insert fresh - we deleted all records at the start
await db.insert(media).values({
if (existing) { id: metadata.id,
await db.update(media) projectId: this.currentProjectId,
.set({ filename,
originalName: metadata.originalName, originalName: metadata.originalName,
mimeType: metadata.mimeType, mimeType: metadata.mimeType,
size: stats.size, size: stats.size,
width: metadata.width, width: metadata.width,
height: metadata.height, height: metadata.height,
alt: metadata.alt, alt: metadata.alt,
caption: metadata.caption, caption: metadata.caption,
updatedAt: new Date(metadata.updatedAt), filePath: mediaFilePath,
checksum, sidecarPath,
tags: JSON.stringify(metadata.tags), createdAt: new Date(metadata.createdAt),
}) updatedAt: new Date(metadata.updatedAt),
.where(eq(media.id, metadata.id)); syncStatus: 'pending',
} else { checksum,
await db.insert(media).values({ tags: JSON.stringify(metadata.tags),
id: metadata.id, });
projectId: this.currentProjectId,
filename,
originalName: metadata.originalName,
mimeType: metadata.mimeType,
size: stats.size,
width: metadata.width,
height: metadata.height,
alt: metadata.alt,
caption: metadata.caption,
filePath: mediaFilePath,
sidecarPath,
createdAt: new Date(metadata.createdAt),
updatedAt: new Date(metadata.updatedAt),
syncStatus: 'pending',
checksum,
tags: JSON.stringify(metadata.tags),
});
}
} catch (error) { } catch (error) {
console.error(`Media file not found for sidecar: ${sidecarPath}`, error); console.error(`Media file not found for sidecar: ${sidecarPath}`, error);
} }

View File

@@ -4,7 +4,7 @@ import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import matter from 'gray-matter'; import matter from 'gray-matter';
import { eq, and, desc, gte, lte, like } from 'drizzle-orm'; import { eq, and, desc, gte, lte, like, inArray } from 'drizzle-orm';
import { app } from 'electron'; import { app } from 'electron';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { posts, Post, NewPost, postLinks } from '../database/schema'; import { posts, Post, NewPost, postLinks } from '../database/schema';
@@ -193,6 +193,14 @@ export class PostEngine extends EventEmitter {
private async readPostFile(filePath: string): Promise<PostData | null> { private async readPostFile(filePath: string): Promise<PostData | null> {
try { try {
// Check if file exists first to avoid noisy errors
try {
await fs.access(filePath);
} catch {
// File doesn't exist - this is expected when DB has stale paths
return null;
}
const content = await fs.readFile(filePath, 'utf-8'); const content = await fs.readFile(filePath, 'utf-8');
const { data, content: body } = matter(content); const { data, content: body } = matter(content);
const metadata = data as PostMetadata; const metadata = data as PostMetadata;
@@ -213,7 +221,7 @@ export class PostEngine extends EventEmitter {
categories: metadata.categories || [], categories: metadata.categories || [],
}; };
} catch (error) { } catch (error) {
console.error(`Failed to read post file: ${filePath}`, error); console.error(`Failed to parse post file: ${filePath}`, error);
return null; return null;
} }
} }
@@ -834,7 +842,27 @@ export class PostEngine extends EventEmitter {
const db = getDatabase().getLocal(); const db = getDatabase().getLocal();
const client = getDatabase().getLocalClient(); const client = getDatabase().getLocalClient();
onProgress(0, 'Scanning posts directory...'); onProgress(0, 'Deleting existing posts for project...');
// Delete all posts for the current project - clean slate rebuild
const existingPosts = await db.select({ id: posts.id }).from(posts).where(eq(posts.projectId, this.currentProjectId)).all();
if (existingPosts.length > 0) {
const postIds = existingPosts.map(p => p.id);
// Delete FTS entries first
if (client) {
for (const post of existingPosts) {
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [post.id] });
}
}
// Delete post links where source or target is in the posts being deleted
await db.delete(postLinks).where(inArray(postLinks.sourcePostId, postIds));
await db.delete(postLinks).where(inArray(postLinks.targetPostId, postIds));
// Delete posts
await db.delete(posts).where(eq(posts.projectId, this.currentProjectId));
console.log(`Deleted ${existingPosts.length} existing post(s) for project ${this.currentProjectId}`);
}
onProgress(5, 'Scanning posts directory...');
// Recursively find all .md files in the posts directory tree // Recursively find all .md files in the posts directory tree
const mdFiles: string[] = []; const mdFiles: string[] = [];
@@ -863,6 +891,9 @@ export class PostEngine extends EventEmitter {
onProgress(10, `Found ${mdFiles.length} post files`); onProgress(10, `Found ${mdFiles.length} post files`);
// Track slugs to detect duplicates
const insertedSlugs = new Map<string, string>(); // slug -> filePath
for (let i = 0; i < mdFiles.length; i++) { for (let i = 0; i < mdFiles.length; i++) {
const filePath = mdFiles[i]; const filePath = mdFiles[i];
const fileName = path.basename(filePath); const fileName = path.basename(filePath);
@@ -872,33 +903,22 @@ export class PostEngine extends EventEmitter {
const postData = await this.readPostFile(filePath); const postData = await this.readPostFile(filePath);
if (postData) { if (postData) {
const existing = await db.select().from(posts).where(eq(posts.id, postData.id)).get(); try {
const checksum = this.calculateChecksum(postData.content); const projectId = postData.projectId || this.currentProjectId;
const slugKey = `${projectId}:${postData.slug}`;
if (existing) { // Check for duplicate slugs
// Only update if no active draft content in DB (don't overwrite edits) if (insertedSlugs.has(slugKey)) {
if (!existing.content) { console.error(`Duplicate slug "${postData.slug}" found. File "${filePath}" duplicates "${insertedSlugs.get(slugKey)}". Skipping.`);
await db.update(posts) continue;
.set({
title: postData.title,
slug: postData.slug,
excerpt: postData.excerpt,
content: null, // Content lives in the file, not DB
status: 'published', // Files on disk = published
author: postData.author,
updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt,
filePath,
checksum,
tags: JSON.stringify(postData.tags),
categories: JSON.stringify(postData.categories),
})
.where(eq(posts.id, postData.id));
} }
} else {
const checksum = this.calculateChecksum(postData.content);
// Insert fresh - we deleted all records at the start
await db.insert(posts).values({ await db.insert(posts).values({
id: postData.id, id: postData.id,
projectId: postData.projectId || this.currentProjectId, projectId,
title: postData.title, title: postData.title,
slug: postData.slug, slug: postData.slug,
excerpt: postData.excerpt, excerpt: postData.excerpt,
@@ -914,15 +934,24 @@ export class PostEngine extends EventEmitter {
tags: JSON.stringify(postData.tags), tags: JSON.stringify(postData.tags),
categories: JSON.stringify(postData.categories), categories: JSON.stringify(postData.categories),
}); });
}
// Update FTS index (use file content for search) insertedSlugs.set(slugKey, filePath);
if (client) {
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [postData.id] }); // Update FTS index (use file content for search)
await client.execute({ if (client) {
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)', await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [postData.id] });
args: [postData.id, postData.title, postData.content, postData.excerpt || '', postData.tags.join(' '), postData.categories.join(' ')], await client.execute({
}); sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
args: [postData.id, postData.title, postData.content, postData.excerpt || '', postData.tags.join(' '), postData.categories.join(' ')],
});
}
} catch (error: any) {
// Handle constraint violations and other errors gracefully
if (error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
console.error(`Failed to insert post "${postData.title}" from ${filePath}: Unique constraint violation (likely slug conflict)`);
} else {
console.error(`Failed to process post from ${filePath}:`, error);
}
} }
} }
} }

View File

@@ -1241,6 +1241,9 @@ const code = 'example';
mockDirent('other.txt'), mockDirent('other.txt'),
] as any); ] as any);
// Mock access to allow file reads
vi.mocked(fs.access).mockResolvedValue(undefined);
// Mock readFile to return valid post content // Mock readFile to return valid post content
vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => { vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
if (filePath.includes('post1.md')) { if (filePath.includes('post1.md')) {
@@ -1313,11 +1316,14 @@ Content 2`;
expect(fs.mkdir).toHaveBeenCalled(); expect(fs.mkdir).toHaveBeenCalled();
}); });
it('should update existing posts in database', async () => { it('should delete all existing posts before inserting fresh from files', async () => {
const fs = await import('fs/promises'); const fs = await import('fs/promises');
vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('existing.md')] as any); vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('existing.md')] as any);
// Mock access to allow file reads
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockResolvedValueOnce(`--- vi.mocked(fs.readFile).mockResolvedValueOnce(`---
id: existing-id id: existing-id
projectId: default projectId: default
@@ -1331,22 +1337,22 @@ categories: []
--- ---
Updated content`); Updated content`);
// Mock that post exists in database // Mock that project has existing posts to delete
vi.mocked(mockLocalDb.select).mockImplementation(() => { vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain(); const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({ chain.where = vi.fn().mockReturnValue({
...chain, ...chain,
get: vi.fn().mockResolvedValue({ all: vi.fn().mockResolvedValue([{ id: 'existing-id' }]),
id: 'existing-id', get: vi.fn().mockResolvedValue(null),
title: 'Old Title',
}),
}); });
return chain; return chain;
}); });
await postEngine.rebuildDatabaseFromFiles(); await postEngine.rebuildDatabaseFromFiles();
expect(mockLocalDb.update).toHaveBeenCalled(); // Should delete existing posts first, then insert fresh
expect(mockLocalDb.delete).toHaveBeenCalled();
expect(mockLocalDb.insert).toHaveBeenCalled();
}); });
it('should insert new posts not in database', async () => { it('should insert new posts not in database', async () => {
@@ -1354,6 +1360,9 @@ Updated content`);
vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('new-post.md')] as any); vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('new-post.md')] as any);
// Mock access to allow file reads
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockResolvedValueOnce(`--- vi.mocked(fs.readFile).mockResolvedValueOnce(`---
id: new-post-id id: new-post-id
projectId: default projectId: default
@@ -1387,6 +1396,9 @@ New content`);
vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('fts-test.md')] as any); vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('fts-test.md')] as any);
// Mock access to allow file reads
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockResolvedValueOnce(`--- vi.mocked(fs.readFile).mockResolvedValueOnce(`---
id: fts-test-id id: fts-test-id
projectId: default projectId: default