feat: draft-in-db, publishd-in-file workflow

This commit is contained in:
2026-02-10 16:05:36 +01:00
parent 0a6710b684
commit 8c118b8b38
9 changed files with 528 additions and 311 deletions

View File

@@ -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 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. 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. 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 ## UI and UX specifics
The UI and UX should be aligned with modern applications like vscode. I want iconbar and left sidebar and the The UI and UX should be aligned with modern applications like vscode. I want iconbar and left sidebar and the

View File

@@ -5,14 +5,16 @@
"description": "A desktop blogging application with offline-first capabilities and cloud sync", "description": "A desktop blogging application with offline-first capabilities and cloud sync",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"scripts": { "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:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json --watch",
"dev:renderer": "node ./node_modules/vite/bin/vite.js", "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 .", "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": "npm run build:main && npm run build:renderer",
"build:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json", "build:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json",
"build:renderer": "node ./node_modules/vite/bin/vite.js build", "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 .", "start:dev": "cross-env NODE_ENV=development node ./node_modules/electron/cli.js .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",

View File

@@ -259,6 +259,14 @@ export class DatabaseConnection {
await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_excerpt TEXT"); 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 // 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

@@ -12,28 +12,33 @@ export const projects = sqliteTable('projects', {
}); });
// Posts table - stores metadata for blog posts // 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', { 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().unique(),
excerpt: text('excerpt'), 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'), status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
author: text('author'), author: text('author'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
publishedAt: integer('published_at', { mode: 'timestamp' }), 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'), syncStatus: text('sync_status', { enum: ['pending', 'synced', 'conflict'] }).notNull().default('pending'),
syncedAt: integer('synced_at', { mode: 'timestamp' }), syncedAt: integer('synced_at', { mode: 'timestamp' }),
checksum: text('checksum'), checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text tags: text('tags'), // JSON array stored as text
categories: text('categories'), // 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'), publishedTitle: text('published_title'),
publishedContent: text('published_content'), publishedContent: text('published_content'),
publishedTags: text('published_tags'), // JSON array stored as text publishedTags: text('published_tags'),
publishedCategories: text('published_categories'), // JSON array stored as text publishedCategories: text('published_categories'),
publishedExcerpt: text('published_excerpt'), publishedExcerpt: text('published_excerpt'),
}); });

View File

@@ -245,23 +245,22 @@ export class PostEngine extends EventEmitter {
categories: data.categories || [], categories: data.categories || [],
}; };
// Write to filesystem first
const filePath = await this.writePostFile(post);
const checksum = this.calculateChecksum(post.content); const checksum = this.calculateChecksum(post.content);
// Then update database // Draft content lives in the database only — no file written
const dbPost: NewPost = { const dbPost: NewPost = {
id: post.id, id: post.id,
projectId: post.projectId, projectId: post.projectId,
title: post.title, title: post.title,
slug: post.slug, slug: post.slug,
excerpt: post.excerpt, excerpt: post.excerpt,
content: post.content,
status: post.status, status: post.status,
author: post.author, author: post.author,
createdAt: post.createdAt, createdAt: post.createdAt,
updatedAt: post.updatedAt, updatedAt: post.updatedAt,
publishedAt: post.publishedAt, publishedAt: post.publishedAt,
filePath, filePath: '',
syncStatus: 'pending', syncStatus: 'pending',
checksum, checksum,
tags: JSON.stringify(post.tags), tags: JSON.stringify(post.tags),
@@ -291,38 +290,41 @@ export class PostEngine extends EventEmitter {
return null; 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 = { const updated: PostData = {
...existing, ...existing,
...data, ...data,
id, // Ensure ID doesn't change id, // Ensure ID doesn't change
projectId: existing.projectId, // Ensure projectId doesn't change projectId: existing.projectId, // Ensure projectId doesn't change
status: newStatus as 'draft' | 'published' | 'archived',
updatedAt: new Date(), 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); const checksum = this.calculateChecksum(updated.content);
// All updates go to DB only — no file writes
await db.update(posts) await db.update(posts)
.set({ .set({
title: updated.title, title: updated.title,
slug: updated.slug, slug: updated.slug,
excerpt: updated.excerpt, excerpt: updated.excerpt,
content: updated.content,
status: updated.status, status: updated.status,
author: updated.author, author: updated.author,
updatedAt: updated.updatedAt, updatedAt: updated.updatedAt,
publishedAt: updated.publishedAt, publishedAt: updated.publishedAt,
filePath,
syncStatus: 'pending', syncStatus: 'pending',
checksum, checksum,
tags: JSON.stringify(updated.tags), tags: JSON.stringify(updated.tags),
@@ -357,13 +359,19 @@ export class PostEngine extends EventEmitter {
return false; return false;
} }
// Delete file // Only delete file if the post was published (has a file on disk)
try { if (existing.filePath) {
await fs.unlink(existing.filePath); try {
} catch { await fs.unlink(existing.filePath);
// File might not exist } 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 // Delete from database
await db.delete(posts).where(eq(posts.id, id)); await db.delete(posts).where(eq(posts.id, id));
@@ -376,6 +384,27 @@ export class PostEngine extends EventEmitter {
return true; 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<PostData | null> { async getPost(id: string): Promise<PostData | null> {
const db = getDatabase().getLocal(); const db = getDatabase().getLocal();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
@@ -384,29 +413,21 @@ export class PostEngine extends EventEmitter {
return null; return null;
} }
// Read content from file // Draft content lives in the DB
const postData = await this.readPostFile(dbPost.filePath); if (dbPost.content) {
return this.dbRowToPostData(dbPost, dbPost.content);
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 || '[]'),
};
} }
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<PostData[]> { async getAllPosts(): Promise<PostData[]> {
@@ -590,68 +611,205 @@ export class PostEngine extends EventEmitter {
async publishPost(id: string): Promise<PostData | null> { async publishPost(id: string): Promise<PostData | null> {
const db = getDatabase().getLocal(); const db = getDatabase().getLocal();
const client = getDatabase().getLocalClient();
const existing = await this.getPost(id); const existing = await this.getPost(id);
if (!existing) { if (!existing) {
return null; return null;
} }
// First update the post with published status const now = new Date();
const result = await this.updatePost(id, { const publishedAt = existing.publishedAt || now;
status: 'published',
publishedAt: new Date(),
});
if (result) { const published: PostData = {
// Save the published snapshot for discard functionality ...existing,
await db.update(posts) status: 'published',
.set({ publishedAt,
publishedTitle: result.title, updatedAt: now,
publishedContent: result.content, };
publishedExcerpt: result.excerpt,
publishedTags: JSON.stringify(result.tags), // Write content + metadata to the filesystem
publishedCategories: JSON.stringify(result.categories), const newFilePath = await this.writePostFile(published);
})
.where(eq(posts.id, id)); // 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<PostData | null> { async discardChanges(id: string): Promise<PostData | null> {
const db = getDatabase().getLocal(); const db = getDatabase().getLocal();
const client = getDatabase().getLocalClient();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
if (!dbPost || !dbPost.publishedContent) { if (!dbPost) {
// No published version to revert to
return null; return null;
} }
// Revert to the published snapshot // Can only discard if there's a published file to revert to
return this.updatePost(id, { if (!dbPost.filePath) {
title: dbPost.publishedTitle || dbPost.title, return null;
content: dbPost.publishedContent, }
excerpt: dbPost.publishedExcerpt || undefined,
tags: JSON.parse(dbPost.publishedTags || '[]'), // Read the published version from the filesystem
categories: JSON.parse(dbPost.publishedCategories || '[]'), 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<boolean> { async hasPublishedVersion(id: string): Promise<boolean> {
const db = getDatabase().getLocal(); const db = getDatabase().getLocal();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); 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<PostData | null> { async unpublishPost(id: string): Promise<PostData | null> {
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', 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<void> { async rebuildDatabaseFromFiles(): Promise<void> {
const postsDir = this.getPostsDir(); const postsBaseDir = this.getPostsBaseDir();
const task: Task<void> = { const task: Task<void> = {
id: uuidv4(), id: uuidv4(),
name: 'Rebuild database from post files', name: 'Rebuild database from post files',
@@ -661,22 +819,38 @@ export class PostEngine extends EventEmitter {
onProgress(0, 'Scanning posts directory...'); 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 { try {
files = await fs.readdir(postsDir); await fs.mkdir(postsBaseDir, { recursive: true });
} catch { } catch {
// Directory might not exist // Already exists
await fs.mkdir(postsDir, { recursive: true });
} }
const mdFiles = files.filter(f => f.endsWith('.md')); await scanDir(postsBaseDir);
onProgress(10, `Found ${mdFiles.length} post files`); onProgress(10, `Found ${mdFiles.length} post files`);
for (let i = 0; i < mdFiles.length; i++) { for (let i = 0; i < mdFiles.length; i++) {
const file = mdFiles[i]; const filePath = mdFiles[i];
const filePath = path.join(postsDir, file); 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); const postData = await this.readPostFile(filePath);
@@ -685,21 +859,25 @@ export class PostEngine extends EventEmitter {
const checksum = this.calculateChecksum(postData.content); const checksum = this.calculateChecksum(postData.content);
if (existing) { if (existing) {
await db.update(posts) // Only update if no active draft content in DB (don't overwrite edits)
.set({ if (!existing.content) {
title: postData.title, await db.update(posts)
slug: postData.slug, .set({
excerpt: postData.excerpt, title: postData.title,
status: postData.status, slug: postData.slug,
author: postData.author, excerpt: postData.excerpt,
updatedAt: postData.updatedAt, content: null, // Content lives in the file, not DB
publishedAt: postData.publishedAt, status: 'published', // Files on disk = published
filePath, author: postData.author,
checksum, updatedAt: postData.updatedAt,
tags: JSON.stringify(postData.tags), publishedAt: postData.publishedAt,
categories: JSON.stringify(postData.categories), filePath,
}) checksum,
.where(eq(posts.id, postData.id)); tags: JSON.stringify(postData.tags),
categories: JSON.stringify(postData.categories),
})
.where(eq(posts.id, postData.id));
}
} else { } else {
await db.insert(posts).values({ await db.insert(posts).values({
id: postData.id, id: postData.id,
@@ -707,11 +885,12 @@ export class PostEngine extends EventEmitter {
title: postData.title, title: postData.title,
slug: postData.slug, slug: postData.slug,
excerpt: postData.excerpt, excerpt: postData.excerpt,
status: postData.status, content: null, // Content lives in the file, not DB
status: 'published', // Files on disk = published
author: postData.author, author: postData.author,
createdAt: postData.createdAt, createdAt: postData.createdAt,
updatedAt: postData.updatedAt, updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt, publishedAt: postData.publishedAt || postData.updatedAt,
filePath, filePath,
syncStatus: 'pending', syncStatus: 'pending',
checksum, checksum,
@@ -720,7 +899,7 @@ export class PostEngine extends EventEmitter {
}); });
} }
// Update FTS index // Update FTS index (use file content for search)
if (client) { if (client) {
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [postData.id] }); await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [postData.id] });
await client.execute({ await client.execute({
@@ -854,17 +1033,28 @@ export class PostEngine extends EventEmitter {
// Clear all existing links // Clear all existing links
await db.delete(postLinks); await db.delete(postLinks);
// Get all posts // Get all posts with content source info
const allPosts = await db const allPosts = await db
.select({ id: posts.id, filePath: posts.filePath }) .select({ id: posts.id, filePath: posts.filePath, content: posts.content })
.from(posts) .from(posts)
.where(eq(posts.projectId, this.currentProjectId)); .where(eq(posts.projectId, this.currentProjectId));
for (const post of allPosts) { for (const post of allPosts) {
try { try {
const fileContent = await fs.readFile(post.filePath, 'utf-8'); let postContent: string;
const { content } = matter(fileContent);
await this.updatePostLinks(post.id, content); // 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) { } catch (error) {
console.error(`Failed to update links for post ${post.id}:`, error); console.error(`Failed to update links for post ${post.id}:`, error);
} }

View File

@@ -37,8 +37,18 @@ export function registerIpcHandlers(): void {
}); });
ipcMain.handle('projects:getActive', async () => { ipcMain.handle('projects:getActive', async () => {
const engine = getProjectEngine(); const projectEngine = getProjectEngine();
return engine.getActiveProject(); 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) => { ipcMain.handle('projects:setActive', async (_, id: string) => {

View File

@@ -30,7 +30,10 @@ const App: React.FC = () => {
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
try { 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(); const posts = await window.electronAPI?.posts.getAll();
if (posts) { if (posts) {
setPosts(posts as PostData[]); setPosts(posts as PostData[]);

View File

@@ -102,7 +102,9 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
return () => { return () => {
const pending = pendingChangesRef.current; 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 // Fire and forget auto-save
window.electronAPI?.posts.update(pending.postId, { window.electronAPI?.posts.update(pending.postId, {
title: pending.title, title: pending.title,
@@ -245,6 +247,8 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
} else { } else {
// Never published - delete the post entirely // Never published - delete the post entirely
await window.electronAPI?.posts.delete(post.id); 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().removePost(post.id);
useAppStore.getState().setSelectedPost(null); useAppStore.getState().setSelectedPost(null);
showToast.success('Draft deleted'); showToast.success('Draft deleted');
@@ -264,6 +268,8 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
if (confirm('Are you sure you want to delete this post?')) { if (confirm('Are you sure you want to delete this post?')) {
try { try {
await window.electronAPI?.posts.delete(post.id); 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().removePost(post.id);
useAppStore.getState().setSelectedPost(null); useAppStore.getState().setSelectedPost(null);
showToast.success('Post deleted'); showToast.success('Post deleted');
@@ -341,18 +347,20 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
Unpublish Unpublish
</button> </button>
)} )}
{hasPublishedVersion && ( {post.status === 'draft' && (
<button <button
onClick={handleDiscard} onClick={handleDiscard}
className="secondary" className="secondary danger"
title="Revert to last published version" title={hasPublishedVersion ? "Revert to last published version" : "Delete this draft permanently"}
> >
Discard Changes {hasPublishedVersion ? 'Discard Changes' : 'Discard Draft'}
</button>
)}
{post.status === 'published' && (
<button onClick={handleDelete} className="secondary danger" title="Delete this post permanently">
Delete
</button> </button>
)} )}
<button onClick={handleDelete} className="secondary danger" title="Delete this post permanently">
Delete
</button>
</div> </div>
</div> </div>

View File

@@ -275,12 +275,33 @@ describe('PostEngine', () => {
expect(post.projectId).toBe('my-project'); 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'); const fs = await import('fs/promises');
vi.mocked(fs.writeFile).mockClear();
await postEngine.createPost({ title: 'File Test' }); await postEngine.createPost({ title: 'File Test' });
expect(fs.writeFile).toHaveBeenCalled(); expect(fs.writeFile).not.toHaveBeenCalled();
expect(fs.mkdir).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 () => { it('should insert into database', async () => {
@@ -357,25 +378,35 @@ describe('PostEngine', () => {
}); });
}); });
describe('Post Creation writes correct file format', () => { describe('Post creation stores content in database only', () => {
it('should write markdown file with YAML frontmatter', async () => { it('should store draft content and metadata in database, not filesystem', async () => {
const fs = await import('fs/promises'); 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({ await postEngine.createPost({
title: 'Frontmatter Test', title: 'DB Store Test',
content: '# Hello World', content: '# Hello World',
tags: ['test'], tags: ['test'],
}); });
expect(fs.writeFile).toHaveBeenCalled(); // No file written for drafts
const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; expect(fs.writeFile).not.toHaveBeenCalled();
const filePath = writeCall[0] as string;
const content = writeCall[1] as string;
expect(filePath).toContain('frontmatter-test.md'); // Content saved to DB
expect(content).toContain('---'); const postInsert = insertValues.find(v => v.title === 'DB Store Test');
expect(content).toContain('title: Frontmatter Test'); expect(postInsert).toBeDefined();
expect(content).toContain('# Hello World'); 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 fs = await import('fs/promises');
const created = await postEngine.createPost({ title: 'Slug Change Test' }); const created = await postEngine.createPost({ title: 'Slug Change Test' });
@@ -646,7 +677,8 @@ Content for retrieval test`);
title: created.title, title: created.title,
slug: created.slug, slug: created.slug,
status: created.status, status: created.status,
filePath: `/mock/userData/projects/default/posts/${created.slug}.md`, content: created.content || '',
filePath: '',
tags: '[]', tags: '[]',
categories: '[]', categories: '[]',
createdAt: created.createdAt, createdAt: created.createdAt,
@@ -657,10 +689,57 @@ Content for retrieval test`);
}); });
vi.mocked(fs.unlink).mockClear(); vi.mocked(fs.unlink).mockClear();
vi.mocked(fs.writeFile).mockClear();
await postEngine.updatePost(created.id, { slug: 'new-slug' }); await postEngine.updatePost(created.id, { slug: 'new-slug' });
// Should try to delete old file // No file operations on update — filesystem is only touched on publish
expect(fs.unlink).toHaveBeenCalled(); 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 () => { it('should update tags and categories', async () => {
@@ -873,9 +952,17 @@ Content for retrieval test`);
}); });
}); });
describe('Metadata roundtrip (write -> read integrity)', () => { describe('Metadata roundtrip (create -> DB storage integrity)', () => {
it('should preserve all fields through write and read cycle', async () => { it('should preserve all fields when storing to database', async () => {
const fs = await import('fs/promises'); 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 publishDate = new Date('2024-03-15T10:30:00.000Z');
const original = await postEngine.createPost({ const original = await postEngine.createPost({
@@ -890,29 +977,27 @@ Content for retrieval test`);
categories: ['testing'], categories: ['testing'],
}); });
// Get the written file content // Verify data was stored in DB correctly
const writeCall = vi.mocked(fs.writeFile).mock.calls.find( const postInsert = insertValues.find(v => v.slug === 'roundtrip-test');
(call) => (call[0] as string).includes('roundtrip-test.md') expect(postInsert).toBeDefined();
); expect(postInsert.title).toBe('Roundtrip Test Post');
expect(writeCall).toBeDefined(); expect(postInsert.content).toBe('# Roundtrip\n\nTesting data integrity.');
const fileContent = writeCall![1] as string; expect(postInsert.excerpt).toBe('Testing the roundtrip');
expect(postInsert.author).toBe('Test Author');
// Verify frontmatter contains all fields expect(postInsert.tags).toBe('["roundtrip","integrity","test"]');
expect(fileContent).toContain('title: Roundtrip Test Post'); expect(postInsert.categories).toBe('["testing"]');
expect(fileContent).toContain('slug: roundtrip-test'); expect(postInsert.filePath).toBe('');
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');
}); });
it('should handle empty tags and categories', async () => { it('should handle empty tags and categories in DB', async () => {
const fs = await import('fs/promises'); 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({ await postEngine.createPost({
title: 'No Tags Post', title: 'No Tags Post',
@@ -921,39 +1006,26 @@ Content for retrieval test`);
categories: [], categories: [],
}); });
const writeCall = vi.mocked(fs.writeFile).mock.calls.find( const postInsert = insertValues.find(v => v.title === 'No Tags Post');
(call) => (call[0] as string).includes('no-tags-post.md') expect(postInsert.tags).toBe('[]');
); expect(postInsert.categories).toBe('[]');
const fileContent = writeCall![1] as string;
expect(fileContent).toContain('tags: []');
expect(fileContent).toContain('categories: []');
}); });
it('should not include undefined optional fields in frontmatter', async () => { it('should handle optional fields as undefined', async () => {
const fs = await import('fs/promises'); const post = await postEngine.createPost({
await postEngine.createPost({
title: 'Minimal Post', title: 'Minimal Post',
content: 'Just content', content: 'Just content',
}); });
const writeCall = vi.mocked(fs.writeFile).mock.calls.find( // Optional fields should be undefined
(call) => (call[0] as string).includes('minimal-post.md') expect(post.excerpt).toBeUndefined();
); expect(post.author).toBeUndefined();
const fileContent = writeCall![1] as string; expect(post.publishedAt).toBeUndefined();
// These optional fields should NOT appear if not set
expect(fileContent).not.toContain('excerpt:');
expect(fileContent).not.toContain('author:');
expect(fileContent).not.toContain('publishedAt:');
}); });
}); });
describe('Edge cases - special characters and unicode', () => { describe('Edge cases - special characters and unicode', () => {
it('should handle unicode characters in content', async () => { it('should handle unicode characters in content', async () => {
const fs = await import('fs/promises');
const post = await postEngine.createPost({ const post = await postEngine.createPost({
title: 'Unicode Test', title: 'Unicode Test',
content: '# 你好世界\n\nЗдравствуй мир\n\nこんにちは\n\nEmoji: 🚀💻📝', 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('こんにちは'); 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 () => { 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 () => { it('should handle YAML special characters in excerpt', async () => {
const fs = await import('fs/promises'); const post = await postEngine.createPost({
await postEngine.createPost({
title: 'YAML Safe Test', title: 'YAML Safe Test',
excerpt: 'Contains: colons, "quotes", and #hash', excerpt: 'Contains: colons, "quotes", and #hash',
}); });
const writeCall = vi.mocked(fs.writeFile).mock.calls.find( expect(post.excerpt).toBe('Contains: colons, "quotes", and #hash');
(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:');
}); });
it('should handle multiline content correctly', async () => { it('should handle multiline content correctly', async () => {
const fs = await import('fs/promises');
const multilineContent = `# Heading 1 const multilineContent = `# Heading 1
Some paragraph text. Some paragraph text.
@@ -1018,20 +1073,14 @@ const code = 'example';
> A blockquote`; > A blockquote`;
await postEngine.createPost({ const post = await postEngine.createPost({
title: 'Multiline Test', title: 'Multiline Test',
content: multilineContent, content: multilineContent,
}); });
const writeCall = vi.mocked(fs.writeFile).mock.calls.find( expect(post.content).toContain('# Heading 1');
(call) => (call[0] as string).includes('multiline-test.md') expect(post.content).toContain('## Heading 2');
); expect(post.content).toContain('> A blockquote');
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');
}); });
it('should handle empty content', async () => { it('should handle empty content', async () => {
@@ -1052,18 +1101,12 @@ const code = 'example';
}); });
it('should handle newlines in excerpt', async () => { it('should handle newlines in excerpt', async () => {
const fs = await import('fs/promises'); const post = await postEngine.createPost({
await postEngine.createPost({
title: 'Newline Excerpt', title: 'Newline Excerpt',
excerpt: 'First line.\nSecond line.', excerpt: 'First line.\nSecond line.',
}); });
// Should be written without breaking YAML expect(post.excerpt).toBe('First line.\nSecond line.');
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
(call) => (call[0] as string).includes('newline-excerpt.md')
);
expect(writeCall).toBeDefined();
}); });
}); });
@@ -1114,10 +1157,20 @@ const code = 'example';
}); });
describe('rebuildDatabaseFromFiles', () => { 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'); 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 // Mock readFile to return valid post content
vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => { vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
@@ -1127,7 +1180,7 @@ id: post-1-id
projectId: default projectId: default
title: Post 1 title: Post 1
slug: post1 slug: post1
status: draft status: published
createdAt: 2024-01-01T00:00:00.000Z createdAt: 2024-01-01T00:00:00.000Z
updatedAt: 2024-01-01T00:00:00.000Z updatedAt: 2024-01-01T00:00:00.000Z
tags: [] tags: []
@@ -1163,7 +1216,7 @@ Content 2`;
const handler = vi.fn(); const handler = vi.fn();
postEngine.on('databaseRebuilt', handler); postEngine.on('databaseRebuilt', handler);
vi.mocked(fs.readdir).mockResolvedValueOnce([]); vi.mocked(fs.readdir).mockResolvedValueOnce([] as any);
await postEngine.rebuildDatabaseFromFiles(); await postEngine.rebuildDatabaseFromFiles();
@@ -1173,7 +1226,7 @@ Content 2`;
it('should handle empty posts directory', async () => { it('should handle empty posts directory', async () => {
const fs = await import('fs/promises'); const fs = await import('fs/promises');
vi.mocked(fs.readdir).mockResolvedValueOnce([]); vi.mocked(fs.readdir).mockResolvedValueOnce([] as any);
await postEngine.rebuildDatabaseFromFiles(); await postEngine.rebuildDatabaseFromFiles();
@@ -1194,7 +1247,7 @@ Content 2`;
it('should update existing posts in database', async () => { it('should update existing posts in database', async () => {
const fs = await import('fs/promises'); 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(`--- vi.mocked(fs.readFile).mockResolvedValueOnce(`---
id: existing-id id: existing-id
@@ -1230,7 +1283,7 @@ Updated content`);
it('should insert new posts not in database', async () => { it('should insert new posts not in database', async () => {
const fs = await import('fs/promises'); 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(`--- vi.mocked(fs.readFile).mockResolvedValueOnce(`---
id: new-post-id id: new-post-id
@@ -1263,7 +1316,7 @@ New content`);
it('should update FTS index for each processed post', async () => { it('should update FTS index for each processed post', async () => {
const fs = await import('fs/promises'); 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(`--- vi.mocked(fs.readFile).mockResolvedValueOnce(`---
id: fts-test-id id: fts-test-id
@@ -1301,7 +1354,7 @@ Searchable content`);
it('should skip invalid/corrupted post files', async () => { it('should skip invalid/corrupted post files', async () => {
const fs = await import('fs/promises'); 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) => { vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
if (filePath.includes('valid.md')) { if (filePath.includes('valid.md')) {
@@ -1339,83 +1392,6 @@ Valid content`;
}); });
describe('Date-based folder structure', () => { 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 () => { it('should return correct path via getPostPath method', async () => {
const now = new Date(); const now = new Date();
const year = now.getFullYear(); const year = now.getFullYear();