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

@@ -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(

View File

@@ -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'),
});

View File

@@ -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<PostData | null> {
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<PostData[]> {
@@ -590,68 +611,205 @@ export class PostEngine extends EventEmitter {
async publishPost(id: string): Promise<PostData | null> {
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<PostData | null> {
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<boolean> {
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<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',
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> {
const postsDir = this.getPostsDir();
const postsBaseDir = this.getPostsBaseDir();
const task: Task<void> = {
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);
}

View File

@@ -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) => {

View File

@@ -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[]);

View File

@@ -102,7 +102,9 @@ const PostEditor: React.FC<PostEditorProps> = ({ 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<PostEditorProps> = ({ 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<PostEditorProps> = ({ 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<PostEditorProps> = ({ post }) => {
Unpublish
</button>
)}
{hasPublishedVersion && (
{post.status === 'draft' && (
<button
onClick={handleDiscard}
className="secondary"
title="Revert to last published version"
className="secondary danger"
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 onClick={handleDelete} className="secondary danger" title="Delete this post permanently">
Delete
</button>
</div>
</div>