feat: draft-in-db, publishd-in-file workflow
This commit is contained in:
15
VISION.md
15
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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[]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user