diff --git a/VISION.md b/VISION.md index a606251..a6c4c6c 100644 --- a/VISION.md +++ b/VISION.md @@ -54,8 +54,23 @@ Posts in draft are automatically saved during edit every 20 seconds and a dot in information about its state as unsaved. The user can force the save with the standard hotkey for that purpose or just wait. Switching to another post will also save a draft automatically. +Important for the handling of posts is that draft content is always kept in the database and only when +the user uses the publish button the content is moved to the filesystem and the content in the database +is set to empty again. This is meant to keep draft content in the database, as it is volatile, and only +published content in filesystem, where it is then later used by the publishing pipeline. + +So only posts in state draft have content in the database, but whenever something goes to state published, +the draft content is set to empty. draft content contains text content as well as metadata content that +was changed. So even if the user changes tags or the category or the title or whatnot, the actual data +is first only kept in the database and only on publish moved to the file. + +Database rebuild at startup is not overwriting draft content, it is only recreating missing posts. + Published content is only ever updated when the publish action is done by the user. +Deletion warns if some media file or post is dependent on the one you are about to delete, because that +will break the relation. + ## UI and UX specifics The UI and UX should be aligned with modern applications like vscode. I want iconbar and left sidebar and the diff --git a/package.json b/package.json index e6701d0..3cb4805 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,16 @@ "description": "A desktop blogging application with offline-first capabilities and cloud sync", "main": "dist/main/main.js", "scripts": { - "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"", + "dev": "concurrently --kill-others \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"", "dev:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json --watch", "dev:renderer": "node ./node_modules/vite/bin/vite.js", "dev:electron": "wait-on http://localhost:5173 && cross-env NODE_ENV=development node ./node_modules/electron/cli.js .", + "start": "concurrently --kill-others \"npm run dev:renderer\" \"npm run start:electron\"", + "start:electron": "wait-on http://localhost:5173 && cross-env NODE_ENV=development node ./node_modules/electron/cli.js .", "build": "npm run build:main && npm run build:renderer", "build:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json", "build:renderer": "node ./node_modules/vite/bin/vite.js build", - "start": "node ./node_modules/electron/cli.js .", + "start:prod": "node ./node_modules/electron/cli.js .", "start:dev": "cross-env NODE_ENV=development node ./node_modules/electron/cli.js .", "test": "vitest run", "test:watch": "vitest", diff --git a/src/main/database/connection.ts b/src/main/database/connection.ts index 77b05c6..eb4fcd3 100644 --- a/src/main/database/connection.ts +++ b/src/main/database/connection.ts @@ -259,6 +259,14 @@ export class DatabaseConnection { await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_excerpt TEXT"); } + // Migration: Add content column for draft body text stored in DB + const contentCol = await this.localClient.execute( + "SELECT name FROM pragma_table_info('posts') WHERE name = 'content'" + ); + if (contentCol.rows.length === 0) { + await this.localClient.execute("ALTER TABLE posts ADD COLUMN content TEXT"); + } + // Create FTS5 virtual table for full-text search await this.localClient.execute(` CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5( diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 14878f6..0770766 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -12,28 +12,33 @@ export const projects = sqliteTable('projects', { }); // Posts table - stores metadata for blog posts +// Draft content is stored in the `content` column. +// Published content lives on the filesystem; `filePath` points to the .md file. +// When a post is published, `content` is cleared (moved to file). +// When a published post is edited, `content` holds draft changes and status becomes 'draft'. export const posts = sqliteTable('posts', { projectId: text('project_id').notNull(), id: text('id').primaryKey(), title: text('title').notNull(), slug: text('slug').notNull().unique(), excerpt: text('excerpt'), + 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'), author: text('author'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), publishedAt: integer('published_at', { mode: 'timestamp' }), - filePath: text('file_path').notNull(), + filePath: text('file_path').notNull().default(''), // Empty for never-published drafts syncStatus: text('sync_status', { enum: ['pending', 'synced', 'conflict'] }).notNull().default('pending'), syncedAt: integer('synced_at', { mode: 'timestamp' }), checksum: text('checksum'), tags: text('tags'), // JSON array stored as text categories: text('categories'), // JSON array stored as text - // Published snapshot - stores the last published version for discard functionality + // Legacy columns (kept for migration compatibility, no longer written) publishedTitle: text('published_title'), publishedContent: text('published_content'), - publishedTags: text('published_tags'), // JSON array stored as text - publishedCategories: text('published_categories'), // JSON array stored as text + publishedTags: text('published_tags'), + publishedCategories: text('published_categories'), publishedExcerpt: text('published_excerpt'), }); diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 718c863..49fa87a 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -245,23 +245,22 @@ export class PostEngine extends EventEmitter { categories: data.categories || [], }; - // Write to filesystem first - const filePath = await this.writePostFile(post); const checksum = this.calculateChecksum(post.content); - // Then update database + // Draft content lives in the database only — no file written const dbPost: NewPost = { id: post.id, projectId: post.projectId, title: post.title, slug: post.slug, excerpt: post.excerpt, + content: post.content, status: post.status, author: post.author, createdAt: post.createdAt, updatedAt: post.updatedAt, publishedAt: post.publishedAt, - filePath, + filePath: '', syncStatus: 'pending', checksum, tags: JSON.stringify(post.tags), @@ -291,38 +290,41 @@ export class PostEngine extends EventEmitter { return null; } + // If post is currently published and content/metadata is changing, + // automatically transition to draft status (content moves from file to DB) + const isContentOrMetadataChange = data.content !== undefined || + data.title !== undefined || + data.tags !== undefined || + data.categories !== undefined || + data.excerpt !== undefined; + + let newStatus = data.status || existing.status; + if (existing.status === 'published' && isContentOrMetadataChange && !data.status) { + newStatus = 'draft'; + } + const updated: PostData = { ...existing, ...data, id, // Ensure ID doesn't change projectId: existing.projectId, // Ensure projectId doesn't change + status: newStatus as 'draft' | 'published' | 'archived', updatedAt: new Date(), }; - // Handle slug change - need to rename file - const postsDir = this.getPostsDir(); - if (data.slug && data.slug !== existing.slug) { - const oldPath = path.join(postsDir, `${existing.slug}.md`); - try { - await fs.unlink(oldPath); - } catch { - // Old file might not exist - } - } - - const filePath = await this.writePostFile(updated); const checksum = this.calculateChecksum(updated.content); + // All updates go to DB only — no file writes await db.update(posts) .set({ title: updated.title, slug: updated.slug, excerpt: updated.excerpt, + content: updated.content, status: updated.status, author: updated.author, updatedAt: updated.updatedAt, publishedAt: updated.publishedAt, - filePath, syncStatus: 'pending', checksum, tags: JSON.stringify(updated.tags), @@ -357,13 +359,19 @@ export class PostEngine extends EventEmitter { return false; } - // Delete file - try { - await fs.unlink(existing.filePath); - } catch { - // File might not exist + // Only delete file if the post was published (has a file on disk) + if (existing.filePath) { + try { + await fs.unlink(existing.filePath); + } catch { + // File might not exist + } } + // Delete post links + await db.delete(postLinks).where(eq(postLinks.sourcePostId, id)); + await db.delete(postLinks).where(eq(postLinks.targetPostId, id)); + // Delete from database await db.delete(posts).where(eq(posts.id, id)); @@ -376,6 +384,27 @@ export class PostEngine extends EventEmitter { return true; } + /** + * Build a PostData object from a DB row, using the given body content. + */ + private dbRowToPostData(dbPost: Post, body: string): PostData { + return { + id: dbPost.id, + projectId: dbPost.projectId, + title: dbPost.title, + slug: dbPost.slug, + excerpt: dbPost.excerpt || undefined, + content: body, + status: dbPost.status as 'draft' | 'published' | 'archived', + author: dbPost.author || undefined, + createdAt: dbPost.createdAt, + updatedAt: dbPost.updatedAt, + publishedAt: dbPost.publishedAt || undefined, + tags: JSON.parse(dbPost.tags || '[]'), + categories: JSON.parse(dbPost.categories || '[]'), + }; + } + async getPost(id: string): Promise { const db = getDatabase().getLocal(); const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); @@ -384,29 +413,21 @@ export class PostEngine extends EventEmitter { return null; } - // Read content from file - const postData = await this.readPostFile(dbPost.filePath); - - if (!postData) { - // File doesn't exist, reconstruct from database - return { - id: dbPost.id, - projectId: dbPost.projectId, - title: dbPost.title, - slug: dbPost.slug, - excerpt: dbPost.excerpt || undefined, - content: '', - status: dbPost.status as 'draft' | 'published' | 'archived', - author: dbPost.author || undefined, - createdAt: dbPost.createdAt, - updatedAt: dbPost.updatedAt, - publishedAt: dbPost.publishedAt || undefined, - tags: JSON.parse(dbPost.tags || '[]'), - categories: JSON.parse(dbPost.categories || '[]'), - }; + // Draft content lives in the DB + if (dbPost.content) { + return this.dbRowToPostData(dbPost, dbPost.content); } - return postData; + // Published content lives in the filesystem + if (dbPost.filePath) { + const fileData = await this.readPostFile(dbPost.filePath); + if (fileData) { + return this.dbRowToPostData(dbPost, fileData.content); + } + } + + // Fallback: no content available + return this.dbRowToPostData(dbPost, ''); } async getAllPosts(): Promise { @@ -590,68 +611,205 @@ export class PostEngine extends EventEmitter { async publishPost(id: string): Promise { const db = getDatabase().getLocal(); + const client = getDatabase().getLocalClient(); const existing = await this.getPost(id); if (!existing) { return null; } - // First update the post with published status - const result = await this.updatePost(id, { - status: 'published', - publishedAt: new Date(), - }); + const now = new Date(); + const publishedAt = existing.publishedAt || now; - if (result) { - // Save the published snapshot for discard functionality - await db.update(posts) - .set({ - publishedTitle: result.title, - publishedContent: result.content, - publishedExcerpt: result.excerpt, - publishedTags: JSON.stringify(result.tags), - publishedCategories: JSON.stringify(result.categories), - }) - .where(eq(posts.id, id)); + const published: PostData = { + ...existing, + status: 'published', + publishedAt, + updatedAt: now, + }; + + // Write content + metadata to the filesystem + const newFilePath = await this.writePostFile(published); + + // If there was a previous file with a different path (slug changed), remove it + const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); + if (dbPost && dbPost.filePath && dbPost.filePath !== newFilePath && dbPost.filePath !== '') { + try { + await fs.unlink(dbPost.filePath); + } catch { + // Old file might not exist + } } - return result; + const checksum = this.calculateChecksum(published.content); + + // Update DB: clear draft content (it lives in the file now), set filePath + await db.update(posts) + .set({ + title: published.title, + slug: published.slug, + excerpt: published.excerpt, + content: null, + status: 'published', + author: published.author, + updatedAt: published.updatedAt, + publishedAt: published.publishedAt, + filePath: newFilePath, + syncStatus: 'pending', + checksum, + tags: JSON.stringify(published.tags), + categories: JSON.stringify(published.categories), + }) + .where(eq(posts.id, id)); + + // Update FTS index + if (client) { + await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] }); + await client.execute({ + sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)', + args: [published.id, published.title, published.content, published.excerpt || '', published.tags.join(' '), published.categories.join(' ')], + }); + } + + // Update post links based on published content + await this.updatePostLinks(id, published.content); + + this.emit('postUpdated', published); + return published; } async discardChanges(id: string): Promise { const db = getDatabase().getLocal(); + const client = getDatabase().getLocalClient(); const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); - if (!dbPost || !dbPost.publishedContent) { - // No published version to revert to + if (!dbPost) { return null; } - // Revert to the published snapshot - return this.updatePost(id, { - title: dbPost.publishedTitle || dbPost.title, - content: dbPost.publishedContent, - excerpt: dbPost.publishedExcerpt || undefined, - tags: JSON.parse(dbPost.publishedTags || '[]'), - categories: JSON.parse(dbPost.publishedCategories || '[]'), - }); + // Can only discard if there's a published file to revert to + if (!dbPost.filePath) { + return null; + } + + // Read the published version from the filesystem + const publishedData = await this.readPostFile(dbPost.filePath); + if (!publishedData) { + return null; + } + + const now = new Date(); + + // Restore DB metadata from the published file, clear draft content + await db.update(posts) + .set({ + title: publishedData.title, + slug: publishedData.slug, + excerpt: publishedData.excerpt, + content: null, + status: 'published', + author: publishedData.author, + updatedAt: now, + publishedAt: publishedData.publishedAt, + tags: JSON.stringify(publishedData.tags), + categories: JSON.stringify(publishedData.categories), + }) + .where(eq(posts.id, id)); + + const reverted: PostData = { + id: dbPost.id, + projectId: dbPost.projectId, + title: publishedData.title, + slug: publishedData.slug, + excerpt: publishedData.excerpt, + content: publishedData.content, + status: 'published', + author: publishedData.author, + createdAt: dbPost.createdAt, + updatedAt: now, + publishedAt: publishedData.publishedAt, + tags: publishedData.tags || [], + categories: publishedData.categories || [], + }; + + // Update FTS index + if (client) { + await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] }); + await client.execute({ + sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)', + args: [reverted.id, reverted.title, reverted.content, reverted.excerpt || '', reverted.tags.join(' '), reverted.categories.join(' ')], + }); + } + + this.emit('postUpdated', reverted); + return reverted; } async hasPublishedVersion(id: string): Promise { const db = getDatabase().getLocal(); const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); - return !!(dbPost && dbPost.publishedContent); + return !!(dbPost && dbPost.filePath && dbPost.filePath !== ''); } async unpublishPost(id: string): Promise { - return this.updatePost(id, { + const db = getDatabase().getLocal(); + const client = getDatabase().getLocalClient(); + const existing = await this.getPost(id); + + if (!existing) { + return null; + } + + const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); + if (!dbPost) { + return null; + } + + // Delete the published file (content moves to DB) + if (dbPost.filePath) { + try { + await fs.unlink(dbPost.filePath); + } catch { + // File might not exist + } + } + + const updated: PostData = { + ...existing, status: 'draft', - publishedAt: undefined, - }); + updatedAt: new Date(), + }; + + const checksum = this.calculateChecksum(updated.content); + + // Store content in DB, clear filePath + await db.update(posts) + .set({ + content: updated.content, + status: 'draft', + filePath: '', + updatedAt: updated.updatedAt, + publishedAt: null, + syncStatus: 'pending', + checksum, + }) + .where(eq(posts.id, id)); + + // Update FTS index + if (client) { + await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] }); + await client.execute({ + sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)', + args: [updated.id, updated.title, updated.content, updated.excerpt || '', updated.tags.join(' '), updated.categories.join(' ')], + }); + } + + this.emit('postUpdated', updated); + return updated; } async rebuildDatabaseFromFiles(): Promise { - const postsDir = this.getPostsDir(); + const postsBaseDir = this.getPostsBaseDir(); const task: Task = { id: uuidv4(), name: 'Rebuild database from post files', @@ -661,22 +819,38 @@ export class PostEngine extends EventEmitter { onProgress(0, 'Scanning posts directory...'); - let files: string[] = []; + // Recursively find all .md files in the posts directory tree + const mdFiles: string[] = []; + const scanDir = async (dir: string) => { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await scanDir(fullPath); + } else if (entry.name.endsWith('.md')) { + mdFiles.push(fullPath); + } + } + } catch { + // Directory might not exist + } + }; + try { - files = await fs.readdir(postsDir); + await fs.mkdir(postsBaseDir, { recursive: true }); } catch { - // Directory might not exist - await fs.mkdir(postsDir, { recursive: true }); + // Already exists } - const mdFiles = files.filter(f => f.endsWith('.md')); + await scanDir(postsBaseDir); onProgress(10, `Found ${mdFiles.length} post files`); for (let i = 0; i < mdFiles.length; i++) { - const file = mdFiles[i]; - const filePath = path.join(postsDir, file); + const filePath = mdFiles[i]; + const fileName = path.basename(filePath); - onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${file}...`); + onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${fileName}...`); const postData = await this.readPostFile(filePath); @@ -685,21 +859,25 @@ export class PostEngine extends EventEmitter { const checksum = this.calculateChecksum(postData.content); if (existing) { - await db.update(posts) - .set({ - title: postData.title, - slug: postData.slug, - excerpt: postData.excerpt, - status: postData.status, - 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)); + // Only update if no active draft content in DB (don't overwrite edits) + if (!existing.content) { + await db.update(posts) + .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 { await db.insert(posts).values({ id: postData.id, @@ -707,11 +885,12 @@ export class PostEngine extends EventEmitter { title: postData.title, slug: postData.slug, excerpt: postData.excerpt, - status: postData.status, + 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, + publishedAt: postData.publishedAt || postData.updatedAt, filePath, syncStatus: 'pending', checksum, @@ -720,7 +899,7 @@ export class PostEngine extends EventEmitter { }); } - // Update FTS index + // Update FTS index (use file content for search) if (client) { await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [postData.id] }); await client.execute({ @@ -854,17 +1033,28 @@ export class PostEngine extends EventEmitter { // Clear all existing links await db.delete(postLinks); - // Get all posts + // Get all posts with content source info const allPosts = await db - .select({ id: posts.id, filePath: posts.filePath }) + .select({ id: posts.id, filePath: posts.filePath, content: posts.content }) .from(posts) .where(eq(posts.projectId, this.currentProjectId)); for (const post of allPosts) { try { - const fileContent = await fs.readFile(post.filePath, 'utf-8'); - const { content } = matter(fileContent); - await this.updatePostLinks(post.id, content); + let postContent: string; + + // Draft content is in DB, published content is in file + if (post.content) { + postContent = post.content; + } else if (post.filePath) { + const fileContent = await fs.readFile(post.filePath, 'utf-8'); + const { content } = matter(fileContent); + postContent = content; + } else { + continue; + } + + await this.updatePostLinks(post.id, postContent); } catch (error) { console.error(`Failed to update links for post ${post.id}:`, error); } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index ff6ec59..88eccbc 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -37,8 +37,18 @@ export function registerIpcHandlers(): void { }); ipcMain.handle('projects:getActive', async () => { - const engine = getProjectEngine(); - return engine.getActiveProject(); + const projectEngine = getProjectEngine(); + const project = await projectEngine.getActiveProject(); + + // Ensure post and media engines have the correct project context + if (project) { + const postEngine = getPostEngine(); + const mediaEngine = getMediaEngine(); + postEngine.setProjectContext(project.id); + mediaEngine.setProjectContext(project.id); + } + + return project; }); ipcMain.handle('projects:setActive', async (_, id: string) => { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7216206..9134aad 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -30,7 +30,10 @@ const App: React.FC = () => { const loadData = async () => { setLoading(true); try { - // Load posts + // First, get active project to set the correct context in backend engines + await window.electronAPI?.projects.getActive(); + + // Load posts (now with correct project context) const posts = await window.electronAPI?.posts.getAll(); if (posts) { setPosts(posts as PostData[]); diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index ea80d71..f5ac3be 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -102,7 +102,9 @@ const PostEditor: React.FC = ({ post }) => { return () => { const pending = pendingChangesRef.current; - if (pending && pending.postId === prevPostId && pending.isDirty) { + // Only auto-save if the post still exists in the store (not deleted/discarded) + const postStillExists = useAppStore.getState().posts.some(p => p.id === prevPostId); + if (pending && pending.postId === prevPostId && pending.isDirty && postStillExists) { // Fire and forget auto-save window.electronAPI?.posts.update(pending.postId, { title: pending.title, @@ -245,6 +247,8 @@ const PostEditor: React.FC = ({ post }) => { } else { // Never published - delete the post entirely await window.electronAPI?.posts.delete(post.id); + // Clear pending ref to prevent auto-save on unmount from resurrecting the post + pendingChangesRef.current = null; useAppStore.getState().removePost(post.id); useAppStore.getState().setSelectedPost(null); showToast.success('Draft deleted'); @@ -264,6 +268,8 @@ const PostEditor: React.FC = ({ post }) => { if (confirm('Are you sure you want to delete this post?')) { try { await window.electronAPI?.posts.delete(post.id); + // Clear pending ref to prevent auto-save on unmount from resurrecting the post + pendingChangesRef.current = null; useAppStore.getState().removePost(post.id); useAppStore.getState().setSelectedPost(null); showToast.success('Post deleted'); @@ -341,18 +347,20 @@ const PostEditor: React.FC = ({ post }) => { Unpublish )} - {hasPublishedVersion && ( + {post.status === 'draft' && ( + )} + {post.status === 'published' && ( + )} - diff --git a/tests/engine/PostEngine.test.ts b/tests/engine/PostEngine.test.ts index c4628fd..c4a9ae2 100644 --- a/tests/engine/PostEngine.test.ts +++ b/tests/engine/PostEngine.test.ts @@ -275,12 +275,33 @@ describe('PostEngine', () => { expect(post.projectId).toBe('my-project'); }); - it('should write post to filesystem', async () => { + it('should NOT write to filesystem (draft content stays in DB)', async () => { const fs = await import('fs/promises'); + vi.mocked(fs.writeFile).mockClear(); await postEngine.createPost({ title: 'File Test' }); - expect(fs.writeFile).toHaveBeenCalled(); - expect(fs.mkdir).toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('should store content in database with empty filePath', async () => { + const insertValues: any[] = []; + vi.mocked(mockLocalDb.insert).mockImplementation(() => ({ + values: vi.fn((data: any) => { + insertValues.push(data); + if (data && data.id) mockPosts.set(data.id, data); + return Promise.resolve(); + }), + })); + + await postEngine.createPost({ + title: 'DB Content Test', + content: '# Hello World', + }); + + const postInsert = insertValues.find(v => v.title === 'DB Content Test'); + expect(postInsert).toBeDefined(); + expect(postInsert.content).toBe('# Hello World'); + expect(postInsert.filePath).toBe(''); }); it('should insert into database', async () => { @@ -357,25 +378,35 @@ describe('PostEngine', () => { }); }); - describe('Post Creation writes correct file format', () => { - it('should write markdown file with YAML frontmatter', async () => { + describe('Post creation stores content in database only', () => { + it('should store draft content and metadata in database, not filesystem', async () => { const fs = await import('fs/promises'); - + vi.mocked(fs.writeFile).mockClear(); + + const insertValues: any[] = []; + vi.mocked(mockLocalDb.insert).mockImplementation(() => ({ + values: vi.fn((data: any) => { + insertValues.push(data); + if (data && data.id) mockPosts.set(data.id, data); + return Promise.resolve(); + }), + })); + await postEngine.createPost({ - title: 'Frontmatter Test', + title: 'DB Store Test', content: '# Hello World', tags: ['test'], }); - expect(fs.writeFile).toHaveBeenCalled(); - const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; - const filePath = writeCall[0] as string; - const content = writeCall[1] as string; + // No file written for drafts + expect(fs.writeFile).not.toHaveBeenCalled(); - expect(filePath).toContain('frontmatter-test.md'); - expect(content).toContain('---'); - expect(content).toContain('title: Frontmatter Test'); - expect(content).toContain('# Hello World'); + // Content saved to DB + const postInsert = insertValues.find(v => v.title === 'DB Store Test'); + expect(postInsert).toBeDefined(); + expect(postInsert.content).toBe('# Hello World'); + expect(postInsert.filePath).toBe(''); + expect(postInsert.tags).toBe('["test"]'); }); }); @@ -632,7 +663,7 @@ Content for retrieval test`); ); }); - it('should handle slug change by deleting old file', async () => { + it('should NOT touch filesystem on slug change (handled at publish time)', async () => { const fs = await import('fs/promises'); const created = await postEngine.createPost({ title: 'Slug Change Test' }); @@ -646,7 +677,8 @@ Content for retrieval test`); title: created.title, slug: created.slug, status: created.status, - filePath: `/mock/userData/projects/default/posts/${created.slug}.md`, + content: created.content || '', + filePath: '', tags: '[]', categories: '[]', createdAt: created.createdAt, @@ -657,10 +689,57 @@ Content for retrieval test`); }); vi.mocked(fs.unlink).mockClear(); + vi.mocked(fs.writeFile).mockClear(); await postEngine.updatePost(created.id, { slug: 'new-slug' }); - // Should try to delete old file - expect(fs.unlink).toHaveBeenCalled(); + // No file operations on update — filesystem is only touched on publish + expect(fs.unlink).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('should auto-transition published post to draft when content changes', async () => { + const created = await postEngine.createPost({ title: 'Auto Draft Test' }); + + 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: 'published', + content: null, + filePath: '/mock/published-file.md', + tags: '[]', + categories: '[]', + createdAt: created.createdAt, + updatedAt: created.updatedAt, + }), + }); + return chain; + }); + + // Mock file read for published content + mockFiles.set('/mock/published-file.md', `--- +id: ${created.id} +projectId: default +title: ${created.title} +slug: ${created.slug} +status: published +createdAt: ${created.createdAt.toISOString()} +updatedAt: ${created.updatedAt.toISOString()} +tags: [] +categories: [] +--- +Original content`); + + const result = await postEngine.updatePost(created.id, { content: 'New draft content' }); + + expect(result).not.toBeNull(); + expect(result?.status).toBe('draft'); + expect(result?.content).toBe('New draft content'); }); it('should update tags and categories', async () => { @@ -873,9 +952,17 @@ Content for retrieval test`); }); }); - describe('Metadata roundtrip (write -> read integrity)', () => { - it('should preserve all fields through write and read cycle', async () => { - const fs = await import('fs/promises'); + describe('Metadata roundtrip (create -> DB storage integrity)', () => { + it('should preserve all fields when storing to database', async () => { + const insertValues: any[] = []; + vi.mocked(mockLocalDb.insert).mockImplementation(() => ({ + values: vi.fn((data: any) => { + insertValues.push(data); + if (data && data.id) mockPosts.set(data.id, data); + return Promise.resolve(); + }), + })); + const publishDate = new Date('2024-03-15T10:30:00.000Z'); const original = await postEngine.createPost({ @@ -890,29 +977,27 @@ Content for retrieval test`); categories: ['testing'], }); - // Get the written file content - const writeCall = vi.mocked(fs.writeFile).mock.calls.find( - (call) => (call[0] as string).includes('roundtrip-test.md') - ); - expect(writeCall).toBeDefined(); - const fileContent = writeCall![1] as string; - - // Verify frontmatter contains all fields - expect(fileContent).toContain('title: Roundtrip Test Post'); - expect(fileContent).toContain('slug: roundtrip-test'); - expect(fileContent).toContain('status: published'); - expect(fileContent).toContain('author: Test Author'); - expect(fileContent).toContain('excerpt: Testing the roundtrip'); - expect(fileContent).toContain('publishedAt:'); - expect(fileContent).toContain('- roundtrip'); - expect(fileContent).toContain('- integrity'); - expect(fileContent).toContain('- test'); - expect(fileContent).toContain('- testing'); - expect(fileContent).toContain('# Roundtrip'); + // Verify data was stored in DB correctly + const postInsert = insertValues.find(v => v.slug === 'roundtrip-test'); + expect(postInsert).toBeDefined(); + expect(postInsert.title).toBe('Roundtrip Test Post'); + expect(postInsert.content).toBe('# Roundtrip\n\nTesting data integrity.'); + expect(postInsert.excerpt).toBe('Testing the roundtrip'); + expect(postInsert.author).toBe('Test Author'); + expect(postInsert.tags).toBe('["roundtrip","integrity","test"]'); + expect(postInsert.categories).toBe('["testing"]'); + expect(postInsert.filePath).toBe(''); }); - it('should handle empty tags and categories', async () => { - const fs = await import('fs/promises'); + it('should handle empty tags and categories in DB', async () => { + const insertValues: any[] = []; + vi.mocked(mockLocalDb.insert).mockImplementation(() => ({ + values: vi.fn((data: any) => { + insertValues.push(data); + if (data && data.id) mockPosts.set(data.id, data); + return Promise.resolve(); + }), + })); await postEngine.createPost({ title: 'No Tags Post', @@ -921,39 +1006,26 @@ Content for retrieval test`); categories: [], }); - const writeCall = vi.mocked(fs.writeFile).mock.calls.find( - (call) => (call[0] as string).includes('no-tags-post.md') - ); - const fileContent = writeCall![1] as string; - - expect(fileContent).toContain('tags: []'); - expect(fileContent).toContain('categories: []'); + const postInsert = insertValues.find(v => v.title === 'No Tags Post'); + expect(postInsert.tags).toBe('[]'); + expect(postInsert.categories).toBe('[]'); }); - it('should not include undefined optional fields in frontmatter', async () => { - const fs = await import('fs/promises'); - - await postEngine.createPost({ + it('should handle optional fields as undefined', async () => { + const post = await postEngine.createPost({ title: 'Minimal Post', content: 'Just content', }); - const writeCall = vi.mocked(fs.writeFile).mock.calls.find( - (call) => (call[0] as string).includes('minimal-post.md') - ); - const fileContent = writeCall![1] as string; - - // These optional fields should NOT appear if not set - expect(fileContent).not.toContain('excerpt:'); - expect(fileContent).not.toContain('author:'); - expect(fileContent).not.toContain('publishedAt:'); + // Optional fields should be undefined + expect(post.excerpt).toBeUndefined(); + expect(post.author).toBeUndefined(); + expect(post.publishedAt).toBeUndefined(); }); }); describe('Edge cases - special characters and unicode', () => { it('should handle unicode characters in content', async () => { - const fs = await import('fs/promises'); - const post = await postEngine.createPost({ title: 'Unicode Test', content: '# 你好世界\n\nЗдравствуй мир\n\nこんにちは\n\nEmoji: 🚀💻📝', @@ -963,14 +1035,6 @@ Content for retrieval test`); expect(post.content).toContain('Здравствуй'); expect(post.content).toContain('こんにちは'); expect(post.content).toContain('🚀💻📝'); - - const writeCall = vi.mocked(fs.writeFile).mock.calls.find( - (call) => (call[0] as string).includes('unicode-test.md') - ); - const fileContent = writeCall![1] as string; - - expect(fileContent).toContain('你好世界'); - expect(fileContent).toContain('🚀💻📝'); }); it('should handle special characters in title', async () => { @@ -985,24 +1049,15 @@ Content for retrieval test`); }); it('should handle YAML special characters in excerpt', async () => { - const fs = await import('fs/promises'); - - await postEngine.createPost({ + const post = await postEngine.createPost({ title: 'YAML Safe Test', excerpt: 'Contains: colons, "quotes", and #hash', }); - const writeCall = vi.mocked(fs.writeFile).mock.calls.find( - (call) => (call[0] as string).includes('yaml-safe-test.md') - ); - const fileContent = writeCall![1] as string; - - // gray-matter should properly escape these - expect(fileContent).toContain('excerpt:'); + expect(post.excerpt).toBe('Contains: colons, "quotes", and #hash'); }); it('should handle multiline content correctly', async () => { - const fs = await import('fs/promises'); const multilineContent = `# Heading 1 Some paragraph text. @@ -1018,20 +1073,14 @@ const code = 'example'; > A blockquote`; - await postEngine.createPost({ + const post = await postEngine.createPost({ title: 'Multiline Test', content: multilineContent, }); - const writeCall = vi.mocked(fs.writeFile).mock.calls.find( - (call) => (call[0] as string).includes('multiline-test.md') - ); - const fileContent = writeCall![1] as string; - - expect(fileContent).toContain('# Heading 1'); - expect(fileContent).toContain('## Heading 2'); - expect(fileContent).toContain('```javascript'); - expect(fileContent).toContain('> A blockquote'); + expect(post.content).toContain('# Heading 1'); + expect(post.content).toContain('## Heading 2'); + expect(post.content).toContain('> A blockquote'); }); it('should handle empty content', async () => { @@ -1052,18 +1101,12 @@ const code = 'example'; }); it('should handle newlines in excerpt', async () => { - const fs = await import('fs/promises'); - - await postEngine.createPost({ + const post = await postEngine.createPost({ title: 'Newline Excerpt', excerpt: 'First line.\nSecond line.', }); - // Should be written without breaking YAML - const writeCall = vi.mocked(fs.writeFile).mock.calls.find( - (call) => (call[0] as string).includes('newline-excerpt.md') - ); - expect(writeCall).toBeDefined(); + expect(post.excerpt).toBe('First line.\nSecond line.'); }); }); @@ -1114,10 +1157,20 @@ const code = 'example'; }); describe('rebuildDatabaseFromFiles', () => { - it('should scan posts directory for markdown files', async () => { + // Helper for Dirent-like objects (readdir with withFileTypes) + const mockDirent = (name: string, isDir = false) => ({ + name, + isDirectory: () => isDir, + }); + + it('should scan posts directory for markdown files recursively', async () => { const fs = await import('fs/promises'); - vi.mocked(fs.readdir).mockResolvedValueOnce(['post1.md', 'post2.md', 'other.txt'] as any); + vi.mocked(fs.readdir).mockResolvedValueOnce([ + mockDirent('post1.md'), + mockDirent('post2.md'), + mockDirent('other.txt'), + ] as any); // Mock readFile to return valid post content vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => { @@ -1127,7 +1180,7 @@ id: post-1-id projectId: default title: Post 1 slug: post1 -status: draft +status: published createdAt: 2024-01-01T00:00:00.000Z updatedAt: 2024-01-01T00:00:00.000Z tags: [] @@ -1163,7 +1216,7 @@ Content 2`; const handler = vi.fn(); postEngine.on('databaseRebuilt', handler); - vi.mocked(fs.readdir).mockResolvedValueOnce([]); + vi.mocked(fs.readdir).mockResolvedValueOnce([] as any); await postEngine.rebuildDatabaseFromFiles(); @@ -1173,7 +1226,7 @@ Content 2`; it('should handle empty posts directory', async () => { const fs = await import('fs/promises'); - vi.mocked(fs.readdir).mockResolvedValueOnce([]); + vi.mocked(fs.readdir).mockResolvedValueOnce([] as any); await postEngine.rebuildDatabaseFromFiles(); @@ -1194,7 +1247,7 @@ Content 2`; it('should update existing posts in database', async () => { const fs = await import('fs/promises'); - vi.mocked(fs.readdir).mockResolvedValueOnce(['existing.md'] as any); + vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('existing.md')] as any); vi.mocked(fs.readFile).mockResolvedValueOnce(`--- id: existing-id @@ -1230,7 +1283,7 @@ Updated content`); it('should insert new posts not in database', async () => { const fs = await import('fs/promises'); - vi.mocked(fs.readdir).mockResolvedValueOnce(['new-post.md'] as any); + vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('new-post.md')] as any); vi.mocked(fs.readFile).mockResolvedValueOnce(`--- id: new-post-id @@ -1263,7 +1316,7 @@ New content`); it('should update FTS index for each processed post', async () => { const fs = await import('fs/promises'); - vi.mocked(fs.readdir).mockResolvedValueOnce(['fts-test.md'] as any); + vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('fts-test.md')] as any); vi.mocked(fs.readFile).mockResolvedValueOnce(`--- id: fts-test-id @@ -1301,7 +1354,7 @@ Searchable content`); it('should skip invalid/corrupted post files', async () => { const fs = await import('fs/promises'); - vi.mocked(fs.readdir).mockResolvedValueOnce(['valid.md', 'corrupted.md'] as any); + vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('valid.md'), mockDirent('corrupted.md')] as any); vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => { if (filePath.includes('valid.md')) { @@ -1339,83 +1392,6 @@ Valid content`; }); describe('Date-based folder structure', () => { - it('should store posts in YYYY/MM folder based on createdAt date', async () => { - const fs = await import('fs/promises'); - - const post = await postEngine.createPost({ - title: 'Date Folder Test', - content: 'Testing date-based folder structure', - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls.find( - (call) => (call[0] as string).includes('date-folder-test.md') - ); - expect(writeCall).toBeDefined(); - - const filePath = writeCall![0] as string; - const year = post.createdAt.getFullYear(); - const month = (post.createdAt.getMonth() + 1).toString().padStart(2, '0'); - - // Path should contain YYYY/MM structure (handle both / and \ separators) - expect(filePath).toMatch(new RegExp(`[/\\\\]${year}[/\\\\]${month}[/\\\\]`)); - expect(filePath).toContain('date-folder-test.md'); - }); - - it('should generate correct path for posts in different months', async () => { - const fs = await import('fs/promises'); - - // Create a post - the current date will be used - await postEngine.createPost({ - title: 'Current Month Post', - content: 'This post should go in current month folder', - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls.find( - (call) => (call[0] as string).includes('current-month-post.md') - ); - - const filePath = writeCall![0] as string; - - // Should have year/month in the path (handle both / and \ separators) - expect(filePath).toMatch(/[/\\]\d{4}[/\\]\d{2}[/\\]/); - }); - - it('should use zero-padded month numbers (01-12)', async () => { - const fs = await import('fs/promises'); - - await postEngine.createPost({ - title: 'Zero Padded Month Test', - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls.find( - (call) => (call[0] as string).includes('zero-padded-month-test.md') - ); - - const filePath = writeCall![0] as string; - - // Month should be zero-padded (01, 02, ..., 09, 10, 11, 12) - expect(filePath).toMatch(/[/\\]\d{4}[/\\](?:0[1-9]|1[0-2])[/\\]/); - }); - - it('should create nested year/month directories on post creation', async () => { - const fs = await import('fs/promises'); - - await postEngine.createPost({ - title: 'Nested Dirs Test', - }); - - // mkdir should be called with recursive: true - expect(fs.mkdir).toHaveBeenCalled(); - const mkdirCalls = vi.mocked(fs.mkdir).mock.calls; - - // Should have created directory containing year/month structure - const yearMonthDirCall = mkdirCalls.find((call) => { - const dirPath = call[0] as string; - return dirPath.match(/[/\\]\d{4}[/\\]\d{2}$/); - }); - expect(yearMonthDirCall).toBeDefined(); - }); - it('should return correct path via getPostPath method', async () => { const now = new Date(); const year = now.getFullYear();