import { EventEmitter } from 'events'; import { v4 as uuidv4 } from 'uuid'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; import matter from 'gray-matter'; import { eq, and, desc, gte, lte, like, inArray, ne, sql } from 'drizzle-orm'; import { app } from 'electron'; import { getDatabase } from '../database'; import { posts, Post, NewPost, postLinks } from '../database/schema'; import { taskManager, Task } from './TaskManager'; import { stemText, stemQuery, SupportedLanguage } from './stemmer'; import { readPostFile as readPostFileShared, type PostFileData } from './postFileUtils'; export interface PostData { id: string; projectId: string; title: string; slug: string; excerpt?: string; content: string; status: 'draft' | 'published' | 'archived'; author?: string; createdAt: Date; updatedAt: Date; publishedAt?: Date; tags: string[]; categories: string[]; } export interface PostMetadata { id: string; projectId: string; title: string; slug: string; excerpt?: string; status: 'draft' | 'published' | 'archived'; author?: string; createdAt: string; updatedAt: string; publishedAt?: string; tags: string[]; categories: string[]; } export interface SearchResult { id: string; title: string; slug: string; excerpt?: string; } export interface PostFilter { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[]; excludeCategories?: string[]; startDate?: Date; endDate?: Date; year?: number; month?: number; } export interface PaginatedResult { items: T[]; hasMore: boolean; total: number; } export interface PaginationOptions { limit?: number; offset?: number; } export type GitPostFileChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed'; export interface GitPostFileChange { status: GitPostFileChangeStatus; path: string; previousPath?: string; } export interface PublishedPostReconcileResult { created: number; updated: number; deleted: number; processedFiles: number; } export class PostEngine extends EventEmitter { private currentProjectId: string = 'default'; private searchLanguage: SupportedLanguage = 'english'; constructor() { super(); } /** * Set the language used for full-text search stemming. * Affects both indexing and query processing. */ setSearchLanguage(language: SupportedLanguage): void { this.searchLanguage = language; } /** * Get the current search language. */ getSearchLanguage(): SupportedLanguage { return this.searchLanguage; } /** * Update the FTS index for a post. * Updates the FTS index for a post. * Stores the stemmed content (combining title, excerpt, content, tags, categories). * Includes project_id for project-scoped search. * Only the post ID is returned from searches - actual post data comes from DB/files. * Public to allow ImportExecutionEngine to index imported posts directly. */ async updateFTSIndex(post: { id: string; projectId: string; title: string; content: string; excerpt?: string; tags: string[]; categories: string[]; }): Promise { const client = getDatabase().getLocalClient(); if (!client) return; // Delete existing entry await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [post.id] }); // Combine all searchable fields and stem them const allText = [ post.title, post.excerpt || '', post.content, post.tags.join(' '), post.categories.join(' '), ].join(' '); const stemmedContent = stemText(allText, this.searchLanguage); // Insert with id, project_id, and stemmed content await client.execute({ sql: 'INSERT INTO posts_fts (id, project_id, content) VALUES (?, ?, ?)', args: [post.id, post.projectId, stemmedContent], }); } /** * Delete a post from the FTS index. */ private async deleteFTSIndex(id: string): Promise { const client = getDatabase().getLocalClient(); if (!client) return; await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] }); } private dataDir: string | null = null; private getDataDir(): string { if (this.dataDir) return this.dataDir; const userDataPath = app.getPath('userData'); return path.join(userDataPath, 'projects', this.currentProjectId); } private getPostsBaseDir(): string { return path.join(this.getDataDir(), 'posts'); } private getPostsDir(): string { // Kept for backwards compatibility - returns base posts directory return this.getPostsBaseDir(); } /** * Get the date-based directory for a post based on its creation date. * Format: posts/YYYY/MM/ */ private getPostsDirForDate(date: Date): string { const baseDir = this.getPostsBaseDir(); const year = date.getFullYear().toString(); const month = (date.getMonth() + 1).toString().padStart(2, '0'); return path.join(baseDir, year, month); } /** * Get the full path for a post file based on slug and date. * Returns: posts/YYYY/MM/{slug}.md */ getPostPath(slug: string, date: Date): string { const dir = this.getPostsDirForDate(date); return path.join(dir, `${slug}.md`); } setProjectContext(projectId: string, dataDir?: string): void { this.currentProjectId = projectId; this.dataDir = dataDir || null; } getProjectContext(): string { return this.currentProjectId; } private generateSlug(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); } /** * Check if a slug is available (not used by any existing post) * @param slug The slug to check * @param excludePostId Optional post ID to exclude (for updates) */ async isSlugAvailable(slug: string, excludePostId?: string): Promise { const db = getDatabase().getLocal(); const existing = await db .select({ id: posts.id }) .from(posts) .where(and( eq(posts.slug, slug), eq(posts.projectId, this.currentProjectId) )) .get(); if (!existing) return true; if (excludePostId && existing.id === excludePostId) return true; return false; } /** * Generate a unique slug based on a title * If the slug already exists, appends -2, -3, etc. */ async generateUniqueSlug(title: string, excludePostId?: string): Promise { const baseSlug = this.generateSlug(title || 'untitled'); if (await this.isSlugAvailable(baseSlug, excludePostId)) { return baseSlug; } // Find next available number let counter = 2; while (counter < 1000) { const candidateSlug = `${baseSlug}-${counter}`; if (await this.isSlugAvailable(candidateSlug, excludePostId)) { return candidateSlug; } counter++; } // Fallback: add timestamp return `${baseSlug}-${Date.now()}`; } private calculateChecksum(content: string): string { return crypto.createHash('md5').update(content).digest('hex'); } private normalizePathForCompare(filePath: string): string { return path.resolve(filePath).replace(/\\/g, '/'); } private isMarkdownPostPath(value: string): boolean { const normalized = value.replace(/\\/g, '/').replace(/^\.\//, ''); if (!normalized.startsWith('posts/')) { return false; } const extension = path.extname(normalized).toLowerCase(); return extension === '.md' || extension === '.markdown' || extension === '.mdx'; } private async ensureUniquePostIdentity(id: string, slug: string): Promise<{ id: string; slug: string }> { const uniqueId = id.trim().length > 0 ? id.trim() : uuidv4(); const safeSlug = slug.trim().length > 0 ? slug.trim() : await this.generateUniqueSlug('untitled'); const db = getDatabase().getLocal(); const existingById = await db .select({ id: posts.id }) .from(posts) .where(eq(posts.id, uniqueId)) .get(); const finalId = existingById ? uuidv4() : uniqueId; const slugAvailable = await this.isSlugAvailable(safeSlug); const finalSlug = slugAvailable ? safeSlug : await this.generateUniqueSlug(safeSlug); return { id: finalId, slug: finalSlug }; } private async writePostFile(post: PostData): Promise { const metadata: Record = { id: post.id, title: post.title, slug: post.slug, status: post.status, createdAt: post.createdAt.toISOString(), updatedAt: post.updatedAt.toISOString(), tags: post.tags, categories: post.categories, }; // Only add optional fields if they have values (gray-matter can't serialize undefined) if (post.excerpt) metadata.excerpt = post.excerpt; if (post.author) metadata.author = post.author; if (post.publishedAt) metadata.publishedAt = post.publishedAt.toISOString(); // Use date-based directory structure (posts/YYYY/MM/) const postsDir = this.getPostsDirForDate(post.createdAt); await fs.mkdir(postsDir, { recursive: true }); const fileContent = matter.stringify(post.content, metadata); const filePath = path.join(postsDir, `${post.slug}.md`); await fs.writeFile(filePath, fileContent, 'utf-8'); return filePath; } private async readPostFile(filePath: string): Promise { const data = await readPostFileShared(filePath); if (!data) return null; const fileStem = path.parse(filePath).name; const normalizedTitle = typeof data.title === 'string' && data.title.trim().length > 0 ? data.title.trim() : fileStem; const baseSlugSource = typeof data.slug === 'string' && data.slug.trim().length > 0 ? data.slug.trim() : normalizedTitle; const normalizedSlug = this.generateSlug(baseSlugSource) || this.generateSlug(fileStem) || uuidv4(); const createdAt = data.createdAt instanceof Date && !Number.isNaN(data.createdAt.getTime()) ? data.createdAt : (data.updatedAt instanceof Date && !Number.isNaN(data.updatedAt.getTime()) ? data.updatedAt : new Date()); const updatedAt = data.updatedAt instanceof Date && !Number.isNaN(data.updatedAt.getTime()) ? data.updatedAt : createdAt; const normalizedTags = Array.isArray(data.tags) ? data.tags.filter((tag): tag is string => typeof tag === 'string') : []; const normalizedCategories = Array.isArray(data.categories) ? data.categories.filter((category): category is string => typeof category === 'string') : []; return { ...data, id: typeof data.id === 'string' && data.id.trim().length > 0 ? data.id : uuidv4(), projectId: this.currentProjectId, title: normalizedTitle, slug: normalizedSlug, createdAt, updatedAt, tags: normalizedTags, categories: normalizedCategories, }; } async createPost(data: Partial): Promise { const db = getDatabase().getLocal(); const client = getDatabase().getLocalClient(); const now = new Date(); const id = uuidv4(); // Use provided slug or generate a unique one from title const slug = data.slug ? (await this.isSlugAvailable(data.slug) ? data.slug : await this.generateUniqueSlug(data.title || 'untitled')) : await this.generateUniqueSlug(data.title || 'untitled'); const post: PostData = { id, projectId: data.projectId || this.currentProjectId, title: data.title ?? '', slug, excerpt: data.excerpt, content: data.content || '', status: data.status || 'draft', author: data.author, createdAt: now, updatedAt: now, publishedAt: data.publishedAt, tags: data.tags || [], categories: data.categories || [], }; const checksum = this.calculateChecksum(post.content); // 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: '', checksum, tags: JSON.stringify(post.tags), categories: JSON.stringify(post.categories), }; await db.insert(posts).values(dbPost); // Update FTS index await this.updateFTSIndex(post); this.emit('postCreated', post); return post; } async updatePost(id: string, data: Partial): Promise { const db = getDatabase().getLocal(); const client = getDatabase().getLocalClient(); const existing = await this.getPost(id); if (!existing) { 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'; } // Auto-update slug when title changes, but only if post was never published let newSlug = data.slug ?? existing.slug; if (data.title !== undefined && data.title !== existing.title && !existing.publishedAt) { newSlug = await this.generateUniqueSlug(data.title || 'untitled', id); } const updated: PostData = { ...existing, ...data, id, // Ensure ID doesn't change projectId: existing.projectId, // Ensure projectId doesn't change slug: newSlug, status: newStatus as 'draft' | 'published' | 'archived', updatedAt: new Date(), }; 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, checksum, tags: JSON.stringify(updated.tags), categories: JSON.stringify(updated.categories), }) .where(eq(posts.id, id)); // Update FTS index await this.updateFTSIndex(updated); // Update post links if content changed if (data.content) { await this.updatePostLinks(id, updated.content); } this.emit('postUpdated', updated); return updated; } async deletePost(id: string): Promise { const db = getDatabase().getLocal(); const client = getDatabase().getLocalClient(); const existing = await db.select().from(posts).where(eq(posts.id, id)).get(); if (!existing) { return false; } // 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 post-media links and update media sidecars const { postMedia } = await import('../database/schema'); const { getMediaEngine } = await import('./MediaEngine'); const linkedMediaResult = await db.select().from(postMedia).where(eq(postMedia.postId, id)); const linkedMedia = Array.isArray(linkedMediaResult) ? linkedMediaResult : []; // Remove this post from each linked media's sidecar const mediaEngine = getMediaEngine(); for (const link of linkedMedia) { const media = await mediaEngine.getMedia(link.mediaId); if (media && media.linkedPostIds) { const updatedLinkedPostIds = media.linkedPostIds.filter(pid => pid !== id); await mediaEngine.updateMedia(link.mediaId, { linkedPostIds: updatedLinkedPostIds }); } } // Delete post-media junction entries await db.delete(postMedia).where(eq(postMedia.postId, id)); // Delete from database await db.delete(posts).where(eq(posts.id, id)); // Delete from FTS index await this.deleteFTSIndex(id); this.emit('postDeleted', id); return true; } /** * Build a PostData object from a DB row, using the given body content. */ private dbRowToPostData(dbPost: Post, body: string): PostData { return { id: dbPost.id, projectId: dbPost.projectId, title: dbPost.title, slug: dbPost.slug, excerpt: dbPost.excerpt || undefined, content: body, status: dbPost.status as 'draft' | 'published' | 'archived', author: dbPost.author || undefined, createdAt: dbPost.createdAt, updatedAt: dbPost.updatedAt, publishedAt: dbPost.publishedAt || undefined, tags: JSON.parse(dbPost.tags || '[]'), categories: JSON.parse(dbPost.categories || '[]'), }; } async getPost(id: string): Promise { const db = getDatabase().getLocal(); const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); if (!dbPost) { return null; } // Draft content lives in the DB if (dbPost.content) { return this.dbRowToPostData(dbPost, dbPost.content); } // 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, ''); } /** * Sync a published post's file with current database metadata (e.g., tags). * This is needed when metadata changes outside of normal post editing flow, * such as tag merge or rename operations. * * @param postId - The post ID to sync * @returns true if file was updated, false if post is not published or doesn't exist */ async syncPublishedPostFile(postId: string): Promise { const db = getDatabase().getLocal(); const dbPost = await db.select().from(posts).where(eq(posts.id, postId)).get(); if (!dbPost || !dbPost.filePath) { // Not a published post or doesn't exist return false; } // Read content from the existing file const fileData = await this.readPostFile(dbPost.filePath); if (!fileData) { return false; } // Build the full post data with DB metadata (tags) and file content const postData = this.dbRowToPostData(dbPost, fileData.content); // Re-write the file with updated metadata await this.writePostFile(postData); return true; } async getAllPosts(options?: PaginationOptions): Promise> { const db = getDatabase().getLocal(); const limit = options?.limit ?? 500; const offset = options?.offset ?? 0; // Get total count for hasMore calculation const countResult = await db .select({ count: posts.id }) .from(posts) .where(eq(posts.projectId, this.currentProjectId)) .all(); const total = countResult.length; // Drafts must ALWAYS be included regardless of pagination. // On the first page (offset=0), fetch all drafts and fill remaining slots with non-drafts. // On subsequent pages, only paginate non-draft posts (drafts were already returned). if (offset === 0) { // Fetch ALL drafts (typically few) const draftPosts = await db .select() .from(posts) .where(and( eq(posts.projectId, this.currentProjectId), eq(posts.status, 'draft') )) .orderBy(desc(posts.createdAt)) .all(); // Fill remaining slots with non-draft posts const remainingSlots = Math.max(0, limit - draftPosts.length); const nonDraftPosts = remainingSlots > 0 ? await db .select() .from(posts) .where(and( eq(posts.projectId, this.currentProjectId), ne(posts.status, 'draft') )) .orderBy(desc(posts.createdAt)) .limit(remainingSlots) .all() : []; const allDbPosts = [...draftPosts, ...nonDraftPosts]; const items: PostData[] = allDbPosts.map(dbPost => this.dbRowToPostData(dbPost, dbPost.content || '') ); return { items, hasMore: allDbPosts.length < total, total, }; } // Subsequent pages: only paginate non-draft posts // Count drafts to calculate correct offset into non-draft posts const draftCount = await db .select({ count: posts.id }) .from(posts) .where(and( eq(posts.projectId, this.currentProjectId), eq(posts.status, 'draft') )) .all(); const numDrafts = draftCount.length; // Adjust offset: the first page returned numDrafts + (limit - numDrafts) non-draft posts // So for page 2+, offset into non-draft posts = offset - numDrafts const nonDraftOffset = offset - numDrafts; const dbPosts = await db .select() .from(posts) .where(and( eq(posts.projectId, this.currentProjectId), ne(posts.status, 'draft') )) .orderBy(desc(posts.createdAt)) .limit(limit) .offset(nonDraftOffset) .all(); const items: PostData[] = dbPosts.map(dbPost => this.dbRowToPostData(dbPost, dbPost.content || '') ); return { items, hasMore: offset + items.length < total, total, }; } /** * Internal method to get all posts without pagination. * Used by methods that need to iterate over all posts (search, tags, categories, etc.) */ private async getAllPostsUnpaginated(): Promise { const db = getDatabase().getLocal(); const dbPosts = await db .select() .from(posts) .where(eq(posts.projectId, this.currentProjectId)) .orderBy(desc(posts.createdAt)) .all(); // Use DB content for drafts, empty string for published posts. // This avoids expensive filesystem reads. return dbPosts.map(dbPost => this.dbRowToPostData(dbPost, dbPost.content || '')); } async getPostsByStatus(status: 'draft' | 'published' | 'archived'): Promise { const db = getDatabase().getLocal(); const dbPosts = await db .select() .from(posts) .where(and( eq(posts.projectId, this.currentProjectId), eq(posts.status, status) )) .orderBy(desc(posts.createdAt)) .all(); // Use DB content for drafts, empty string for published posts. // This avoids expensive filesystem reads. return dbPosts.map(dbPost => this.dbRowToPostData(dbPost, dbPost.content || '')); } async getPostsFiltered(filter: PostFilter): Promise { const db = getDatabase().getLocal(); const conditions = [eq(posts.projectId, this.currentProjectId)]; if (filter.status) { conditions.push(eq(posts.status, filter.status)); } if (filter.startDate) { conditions.push(gte(posts.createdAt, filter.startDate)); } if (filter.endDate) { conditions.push(lte(posts.createdAt, filter.endDate)); } if (filter.year !== undefined) { const startOfYear = new Date(filter.year, 0, 1); const endOfYear = new Date(filter.year + 1, 0, 1); conditions.push(gte(posts.createdAt, startOfYear)); conditions.push(lte(posts.createdAt, endOfYear)); } if (filter.month !== undefined && filter.year !== undefined) { const startOfMonth = new Date(filter.year, filter.month, 1); const endOfMonth = new Date(filter.year, filter.month + 1, 1); conditions.push(gte(posts.createdAt, startOfMonth)); conditions.push(lte(posts.createdAt, endOfMonth)); } if (filter.categories && filter.categories.length > 0) { const includePredicates = filter.categories.map((category) => sql`exists ( select 1 from json_each(${posts.categories}) as included_category where included_category.value = ${category} )` ); conditions.push(sql`(${sql.join(includePredicates, sql` OR `)})`); } if (filter.excludeCategories && filter.excludeCategories.length > 0) { const excludePredicates = filter.excludeCategories.map((category) => sql`exists ( select 1 from json_each(${posts.categories}) as excluded_category where excluded_category.value = ${category} )` ); conditions.push(sql`NOT (${sql.join(excludePredicates, sql` OR `)})`); } const dbPosts = await db .select() .from(posts) .where(and(...conditions)) .orderBy(desc(posts.createdAt)) .all(); let result: PostData[] = []; for (const dbPost of dbPosts) { // Use DB data directly instead of reading from filesystem const postData = this.dbRowToPostData(dbPost, dbPost.content || ''); // Client-side filtering for tags only (category filtering is done in SQL) if (filter.tags && filter.tags.length > 0) { const hasAllTags = filter.tags.every(tag => postData.tags.includes(tag)); if (!hasAllTags) continue; } result.push(postData); } return result; } async searchPosts(query: string): Promise { const client = getDatabase().getLocalClient(); if (!client) return []; try { // Stem the query for multilingual matching const stemmedQuery = stemQuery(query, this.searchLanguage); // Search the stemmed content, filtered by project_id for project isolation const result = await client.execute({ sql: `SELECT id FROM posts_fts WHERE project_id = ? AND posts_fts MATCH ? ORDER BY rank LIMIT 500`, args: [this.currentProjectId, stemmedQuery], }); // Fetch actual post data for results const db = getDatabase().getLocal(); const searchResults: SearchResult[] = []; for (const row of result.rows) { const postId = row.id as string; const post = await db.select().from(posts).where(eq(posts.id, postId)).get(); if (post) { searchResults.push({ id: post.id, title: post.title, slug: post.slug, excerpt: post.excerpt ?? undefined, }); } } return searchResults; } catch (error) { console.error('Search failed:', error); return []; } } async getAvailableTags(): Promise { const allPosts = await this.getAllPostsUnpaginated(); const tags = new Set(); for (const post of allPosts) { for (const tag of post.tags) { tags.add(tag); } } return Array.from(tags).sort(); } async getAvailableCategories(): Promise { const allPosts = await this.getAllPostsUnpaginated(); const categories = new Set(); for (const post of allPosts) { for (const cat of post.categories) { categories.add(cat); } } return Array.from(categories).sort(); } async getTagsWithCounts(): Promise<{ tag: string; count: number }[]> { const db = getDatabase().getLocal(); const dbPosts = await db .select({ tags: posts.tags }) .from(posts) .where(eq(posts.projectId, this.currentProjectId)) .all(); const tagCounts = new Map(); for (const row of dbPosts) { const parsed: string[] = JSON.parse(row.tags || '[]'); for (const tag of parsed) { tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); } } return Array.from(tagCounts.entries()) .map(([tag, count]) => ({ tag, count })) .sort((a, b) => b.count - a.count); } async getCategoriesWithCounts(): Promise<{ category: string; count: number }[]> { const db = getDatabase().getLocal(); const dbPosts = await db .select({ categories: posts.categories }) .from(posts) .where(eq(posts.projectId, this.currentProjectId)) .all(); const catCounts = new Map(); for (const row of dbPosts) { const parsed: string[] = JSON.parse(row.categories || '[]'); for (const cat of parsed) { catCounts.set(cat, (catCounts.get(cat) || 0) + 1); } } return Array.from(catCounts.entries()) .map(([category, count]) => ({ category, count })) .sort((a, b) => b.count - a.count); } async getDashboardStats(): Promise<{ totalPosts: number; draftCount: number; publishedCount: number; archivedCount: number; }> { const db = getDatabase().getLocal(); const dbPosts = await db .select({ status: posts.status }) .from(posts) .where(eq(posts.projectId, this.currentProjectId)) .all(); let draftCount = 0; let publishedCount = 0; let archivedCount = 0; for (const row of dbPosts) { switch (row.status) { case 'draft': draftCount++; break; case 'published': publishedCount++; break; case 'archived': archivedCount++; break; } } return { totalPosts: dbPosts.length, draftCount, publishedCount, archivedCount, }; } async getBlogStats(): Promise<{ totalPosts: number; draftCount: number; publishedCount: number; archivedCount: number; oldestPostDate: Date | null; newestPostDate: Date | null; postsPerYear: Record; tagCount: number; categoryCount: number; }> { const db = getDatabase().getLocal(); const dbPosts = await db .select({ status: posts.status, createdAt: posts.createdAt, tags: posts.tags, categories: posts.categories }) .from(posts) .where(eq(posts.projectId, this.currentProjectId)) .all(); let draftCount = 0; let publishedCount = 0; let archivedCount = 0; let oldestPostDate: Date | null = null; let newestPostDate: Date | null = null; const postsPerYear: Record = {}; const uniqueTags = new Set(); const uniqueCategories = new Set(); for (const row of dbPosts) { switch (row.status) { case 'draft': draftCount++; break; case 'published': publishedCount++; break; case 'archived': archivedCount++; break; } const created = row.createdAt; if (!oldestPostDate || created < oldestPostDate) oldestPostDate = created; if (!newestPostDate || created > newestPostDate) newestPostDate = created; const year = created.getFullYear(); postsPerYear[year] = (postsPerYear[year] || 0) + 1; const parsedTags: string[] = JSON.parse(row.tags || '[]'); for (const tag of parsedTags) uniqueTags.add(tag); const parsedCategories: string[] = JSON.parse(row.categories || '[]'); for (const cat of parsedCategories) uniqueCategories.add(cat); } return { totalPosts: dbPosts.length, draftCount, publishedCount, archivedCount, oldestPostDate, newestPostDate, postsPerYear, tagCount: uniqueTags.size, categoryCount: uniqueCategories.size, }; } async getPostsByYearMonth(): Promise<{ year: number; month: number; count: number }[]> { const allPosts = await this.getAllPostsUnpaginated(); const counts = new Map(); for (const post of allPosts) { const year = post.createdAt.getFullYear(); const month = post.createdAt.getMonth(); const key = `${year}-${month}`; const current = counts.get(key) || { year, month, count: 0 }; current.count++; counts.set(key, current); } return Array.from(counts.values()).sort((a, b) => { if (a.year !== b.year) return b.year - a.year; return b.month - a.month; }); } async publishPost(id: string): Promise { const db = getDatabase().getLocal(); const client = getDatabase().getLocalClient(); const existing = await this.getPost(id); if (!existing) { return null; } const now = new Date(); const publishedAt = existing.publishedAt || now; 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 } } 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, checksum, tags: JSON.stringify(published.tags), categories: JSON.stringify(published.categories), }) .where(eq(posts.id, id)); // Update FTS index await this.updateFTSIndex(published); // Update post links based on published content await this.updatePostLinks(id, published.content); this.emit('postUpdated', published); return published; } async discardChanges(id: string): Promise { const db = getDatabase().getLocal(); const client = getDatabase().getLocalClient(); const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); if (!dbPost) { return null; } // 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 await this.updateFTSIndex(reverted); this.emit('postUpdated', reverted); return reverted; } async hasPublishedVersion(id: string): Promise { const db = getDatabase().getLocal(); const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); return !!(dbPost && dbPost.filePath && dbPost.filePath !== ''); } async getPublishedVersion(id: string): Promise { const db = getDatabase().getLocal(); const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); if (!dbPost || !dbPost.filePath) { return null; } const fileData = await this.readPostFile(dbPost.filePath); if (!fileData) { return null; } return { id: dbPost.id, projectId: dbPost.projectId, title: fileData.title, slug: fileData.slug, excerpt: fileData.excerpt, content: fileData.content, status: 'published', author: fileData.author, createdAt: fileData.createdAt, updatedAt: fileData.updatedAt, publishedAt: fileData.publishedAt ?? dbPost.publishedAt ?? undefined, tags: fileData.tags, categories: fileData.categories, }; } /** * Rebuild the FTS index for all posts in the current project. * Call this after changing the search language or after migration. */ async rebuildFTSIndex(): Promise { const client = getDatabase().getLocalClient(); if (!client) return; const allPosts = await this.getAllPostsUnpaginated(); for (const post of allPosts) { await this.updateFTSIndex(post); } console.log(`Rebuilt FTS index for ${allPosts.length} posts`); } async reconcilePublishedPostsFromGitChanges( projectPath: string, changes: GitPostFileChange[], ): Promise { const db = getDatabase().getLocal(); const normalizedProjectPath = path.resolve(projectPath); const relevantChanges = changes.filter((change) => { if (!this.isMarkdownPostPath(change.path)) { return false; } if (change.status === 'renamed' && change.previousPath && !this.isMarkdownPostPath(change.previousPath) && !this.isMarkdownPostPath(change.path)) { return false; } return true; }); if (relevantChanges.length === 0) { return { created: 0, updated: 0, deleted: 0, processedFiles: 0 }; } const projectPosts = await db .select() .from(posts) .where(eq(posts.projectId, this.currentProjectId)) .all(); const publishedRows = projectPosts.filter((row) => row.status === 'published' && Boolean(row.filePath)); const publishedByFilePath = new Map(); for (const row of publishedRows) { if (!row.filePath) { continue; } publishedByFilePath.set(this.normalizePathForCompare(row.filePath), row); } let created = 0; let updated = 0; let deleted = 0; let processedFiles = 0; for (const change of relevantChanges) { const absolutePath = this.normalizePathForCompare(path.resolve(normalizedProjectPath, change.path)); const previousAbsolutePath = change.previousPath ? this.normalizePathForCompare(path.resolve(normalizedProjectPath, change.previousPath)) : null; if (change.status === 'deleted') { const existingPublished = publishedByFilePath.get(absolutePath); if (!existingPublished) { continue; } await db.delete(postLinks).where(eq(postLinks.sourcePostId, existingPublished.id)); await db.delete(postLinks).where(eq(postLinks.targetPostId, existingPublished.id)); await db.delete(posts).where(eq(posts.id, existingPublished.id)); await this.deleteFTSIndex(existingPublished.id); this.emit('postDeleted', existingPublished.id); publishedByFilePath.delete(absolutePath); deleted += 1; processedFiles += 1; continue; } const existingPublished = previousAbsolutePath ? (publishedByFilePath.get(previousAbsolutePath) || publishedByFilePath.get(absolutePath)) : publishedByFilePath.get(absolutePath); const fileData = await this.readPostFile(absolutePath); if (!fileData) { continue; } if (existingPublished) { const nextSlugCandidate = fileData.slug || existingPublished.slug; const nextSlug = await this.isSlugAvailable(nextSlugCandidate, existingPublished.id) ? nextSlugCandidate : await this.generateUniqueSlug(nextSlugCandidate, existingPublished.id); const checksum = this.calculateChecksum(fileData.content); const nextPublishedAt = fileData.publishedAt || existingPublished.publishedAt || fileData.updatedAt; await db.update(posts) .set({ title: fileData.title, slug: nextSlug, excerpt: fileData.excerpt, content: null, status: 'published', author: fileData.author, createdAt: fileData.createdAt, updatedAt: fileData.updatedAt, publishedAt: nextPublishedAt, filePath: absolutePath, checksum, tags: JSON.stringify(fileData.tags), categories: JSON.stringify(fileData.categories), }) .where(eq(posts.id, existingPublished.id)); await this.updateFTSIndex({ id: existingPublished.id, projectId: existingPublished.projectId, title: fileData.title, content: fileData.content, excerpt: fileData.excerpt, tags: fileData.tags, categories: fileData.categories, }); const updatedPost: PostData = { id: existingPublished.id, projectId: existingPublished.projectId, title: fileData.title, slug: nextSlug, excerpt: fileData.excerpt || undefined, content: fileData.content, status: 'published', author: fileData.author || undefined, createdAt: fileData.createdAt, updatedAt: fileData.updatedAt, publishedAt: nextPublishedAt || undefined, tags: fileData.tags, categories: fileData.categories, }; this.emit('postUpdated', updatedPost); if (previousAbsolutePath) { publishedByFilePath.delete(previousAbsolutePath); } publishedByFilePath.set(absolutePath, { ...existingPublished, title: updatedPost.title, slug: updatedPost.slug, excerpt: updatedPost.excerpt ?? null, content: null, status: 'published', author: updatedPost.author ?? null, createdAt: updatedPost.createdAt, updatedAt: updatedPost.updatedAt, publishedAt: updatedPost.publishedAt ?? null, filePath: absolutePath, checksum, tags: JSON.stringify(updatedPost.tags), categories: JSON.stringify(updatedPost.categories), }); updated += 1; processedFiles += 1; continue; } if (change.status !== 'added') { continue; } const identity = await this.ensureUniquePostIdentity(fileData.id, fileData.slug); const checksum = this.calculateChecksum(fileData.content); const publishedAt = fileData.publishedAt || fileData.updatedAt; const newPostRow: NewPost = { id: identity.id, projectId: this.currentProjectId, title: fileData.title, slug: identity.slug, excerpt: fileData.excerpt, content: null, status: 'published', author: fileData.author, createdAt: fileData.createdAt, updatedAt: fileData.updatedAt, publishedAt, filePath: absolutePath, checksum, tags: JSON.stringify(fileData.tags), categories: JSON.stringify(fileData.categories), }; await db.insert(posts).values(newPostRow); await this.updateFTSIndex({ id: identity.id, projectId: this.currentProjectId, title: fileData.title, content: fileData.content, excerpt: fileData.excerpt, tags: fileData.tags, categories: fileData.categories, }); const createdPost: PostData = { id: identity.id, projectId: this.currentProjectId, title: fileData.title, slug: identity.slug, excerpt: fileData.excerpt || undefined, content: fileData.content, status: 'published', author: fileData.author || undefined, createdAt: fileData.createdAt, updatedAt: fileData.updatedAt, publishedAt: publishedAt || undefined, tags: fileData.tags, categories: fileData.categories, }; this.emit('postCreated', createdPost); publishedByFilePath.set(absolutePath, { ...newPostRow, excerpt: newPostRow.excerpt ?? null, content: null, author: newPostRow.author ?? null, } as Post); created += 1; processedFiles += 1; } return { created, updated, deleted, processedFiles, }; } /** * Reindex all text for full-text search. * Runs as a background task with progress updates. * Call this when search algorithms change or to fix search issues. */ async reindexText(): Promise { const task: Task = { id: uuidv4(), name: 'Reindex search text', execute: async (onProgress) => { const client = getDatabase().getLocalClient(); if (!client) { throw new Error('Database client not available'); } onProgress(0, 'Clearing existing search index...'); // Clear the entire FTS table await client.execute('DELETE FROM posts_fts'); onProgress(5, 'Loading posts...'); const allPosts = await this.getAllPostsUnpaginated(); const total = allPosts.length; if (total === 0) { onProgress(100, 'No posts to index'); return; } onProgress(10, `Indexing ${total} posts...`); for (let i = 0; i < allPosts.length; i++) { const post = allPosts[i]; await this.updateFTSIndex(post); // Update progress (10% to 100%) const progress = 10 + Math.round((i + 1) / total * 90); if (i % 10 === 0 || i === allPosts.length - 1) { onProgress(progress, `Indexed ${i + 1} of ${total} posts`); } // Yield to event loop periodically if (i % 20 === 0) { await new Promise(resolve => setImmediate(resolve)); } } onProgress(100, `Reindexed ${total} posts`); console.log(`Reindexed search text for ${total} posts`); }, }; await taskManager.runTask(task); } async rebuildDatabaseFromFiles(): Promise { const postsBaseDir = this.getPostsBaseDir(); const task: Task = { id: uuidv4(), name: 'Rebuild database from post files', execute: async (onProgress) => { const db = getDatabase().getLocal(); const client = getDatabase().getLocalClient(); onProgress(0, 'Deleting existing posts for project...'); // Notify UI that rebuild is starting so it can clear the list this.emit('rebuildStarted'); // Delete all posts for the current project - clean slate rebuild const existingPosts = await db.select({ id: posts.id }).from(posts).where(eq(posts.projectId, this.currentProjectId)).all(); if (existingPosts.length > 0) { const postIds = existingPosts.map(p => p.id); // Delete FTS entries first for (const post of existingPosts) { await this.deleteFTSIndex(post.id); } // Delete post links where source or target is in the posts being deleted await db.delete(postLinks).where(inArray(postLinks.sourcePostId, postIds)); await db.delete(postLinks).where(inArray(postLinks.targetPostId, postIds)); // Delete posts await db.delete(posts).where(eq(posts.projectId, this.currentProjectId)); console.log(`Deleted ${existingPosts.length} existing post(s) for project ${this.currentProjectId}`); } onProgress(5, 'Scanning posts directory...'); // Recursively find markdown files in the posts directory tree const markdownFiles: string[] = []; const markdownExtensions = new Set(['.md', '.markdown', '.mdx']); 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 { const extension = path.extname(entry.name).toLowerCase(); if (markdownExtensions.has(extension)) { markdownFiles.push(fullPath); } } } } catch { // Directory might not exist } }; try { await fs.mkdir(postsBaseDir, { recursive: true }); } catch { // Already exists } await scanDir(postsBaseDir); onProgress(10, `Found ${markdownFiles.length} post files`); // Track slugs and ids to avoid collisions while still importing all files const insertedSlugs = new Set(); // projectId:slug const insertedIds = new Set(); let importedCount = 0; let parseFailedCount = 0; let deduplicatedSlugCount = 0; let deduplicatedIdCount = 0; let insertFailedCount = 0; for (let i = 0; i < markdownFiles.length; i++) { const filePath = markdownFiles[i]; const fileName = path.basename(filePath); onProgress(10 + (80 * (i / markdownFiles.length)), `Processing ${i + 1}/${markdownFiles.length}: ${fileName}`); const postData = await this.readPostFile(filePath); if (!postData) { parseFailedCount++; continue; } try { const projectId = this.currentProjectId; let postId = postData.id; while (insertedIds.has(postId)) { postId = uuidv4(); deduplicatedIdCount++; } let slug = postData.slug; const baseSlug = slug; let slugAttempt = 2; while (insertedSlugs.has(`${projectId}:${slug}`)) { slug = `${baseSlug}-${slugAttempt}`; slugAttempt++; deduplicatedSlugCount++; } const checksum = this.calculateChecksum(postData.content); await db.insert(posts).values({ id: postId, projectId, title: postData.title, slug, excerpt: postData.excerpt, content: null, status: 'published', author: postData.author, createdAt: postData.createdAt, updatedAt: postData.updatedAt, publishedAt: postData.publishedAt || postData.updatedAt, filePath, checksum, tags: JSON.stringify(postData.tags), categories: JSON.stringify(postData.categories), }); insertedIds.add(postId); insertedSlugs.add(`${projectId}:${slug}`); importedCount++; await this.updateFTSIndex({ id: postId, projectId, title: postData.title, content: postData.content, excerpt: postData.excerpt, tags: postData.tags, categories: postData.categories, }); } catch (error: any) { insertFailedCount++; if (error?.code === 'SQLITE_CONSTRAINT_UNIQUE') { console.error(`Failed to insert post "${postData.title}" from ${filePath}: Unique constraint violation`); } else { console.error(`Failed to process post from ${filePath}:`, error); } } // Yield to event loop periodically so the window stays responsive if (i % 10 === 0) { await new Promise(resolve => setImmediate(resolve)); } } onProgress(100, `Database rebuild complete: imported ${importedCount}/${markdownFiles.length} files`); console.log(`[PostEngine] rebuildDatabaseFromFiles complete. scanned=${markdownFiles.length}, imported=${importedCount}, parseFailed=${parseFailedCount}, insertFailed=${insertFailedCount}, deduplicatedSlugs=${deduplicatedSlugCount}, deduplicatedIds=${deduplicatedIdCount}`); this.emit('databaseRebuilt'); }, }; await taskManager.runTask(task); } /** * Extract internal post links from content (links to other posts in the blog) */ extractInternalLinks(content: string): { slug: string; text: string }[] { const links: { slug: string; text: string }[] = []; // Match markdown links: [text](/posts/slug) or [text](/year/month/slug) const markdownLinkRegex = /\[([^\]]+)\]\(\/(?:posts\/)?(?:\d{4}\/\d{2}\/)?([a-z0-9-]+)(?:\.html?)?\)/gi; let match; while ((match = markdownLinkRegex.exec(content)) !== null) { links.push({ text: match[1], slug: match[2] }); } // Match HTML links: text const htmlLinkRegex = /]+href=["']\/(?:posts\/)?(?:\d{4}\/\d{2}\/)?([a-z0-9-]+)(?:\.html?)?["'][^>]*>([^<]+)<\/a>/gi; while ((match = htmlLinkRegex.exec(content)) !== null) { links.push({ text: match[2], slug: match[1] }); } return links; } /** * Update post links in the database based on content analysis */ async updatePostLinks(postId: string, content: string): Promise { const db = getDatabase().getLocal(); const extractedLinks = this.extractInternalLinks(content); // Delete existing links from this post await db.delete(postLinks).where(eq(postLinks.sourcePostId, postId)); if (extractedLinks.length === 0) return; // Get all posts to resolve slugs to IDs const allPosts = await db.select({ id: posts.id, slug: posts.slug }) .from(posts) .where(eq(posts.projectId, this.currentProjectId)); const slugToId = new Map(allPosts.map(p => [p.slug, p.id])); // Insert new links for (const link of extractedLinks) { const targetId = slugToId.get(link.slug); if (targetId && targetId !== postId) { await db.insert(postLinks).values({ id: uuidv4(), sourcePostId: postId, targetPostId: targetId, linkText: link.text, createdAt: new Date(), }); } } } /** * Get posts that link TO the specified post ("linked by") */ async getLinkedBy(postId: string): Promise<{ id: string; title: string; slug: string }[]> { const db = getDatabase().getLocal(); const links = await db .select({ sourcePostId: postLinks.sourcePostId, linkText: postLinks.linkText, }) .from(postLinks) .where(eq(postLinks.targetPostId, postId)); if (links.length === 0) return []; const sourceIds = links.map(l => l.sourcePostId); const sourcePosts = await db .select({ id: posts.id, title: posts.title, slug: posts.slug }) .from(posts) .where(eq(posts.projectId, this.currentProjectId)); return sourcePosts.filter(p => sourceIds.includes(p.id)); } /** * Get posts that the specified post links TO ("links to") */ async getLinksTo(postId: string): Promise<{ id: string; title: string; slug: string }[]> { const db = getDatabase().getLocal(); const links = await db .select({ targetPostId: postLinks.targetPostId, linkText: postLinks.linkText, }) .from(postLinks) .where(eq(postLinks.sourcePostId, postId)); if (links.length === 0) return []; const targetIds = links.map(l => l.targetPostId); const targetPosts = await db .select({ id: posts.id, title: posts.title, slug: posts.slug }) .from(posts) .where(eq(posts.projectId, this.currentProjectId)); return targetPosts.filter(p => targetIds.includes(p.id)); } /** * Rebuild all post links from content analysis */ async rebuildAllPostLinks(): Promise { const db = getDatabase().getLocal(); // Clear all existing links await db.delete(postLinks); // Get all posts with content source info const allPosts = await db .select({ id: posts.id, filePath: posts.filePath, content: posts.content }) .from(posts) .where(eq(posts.projectId, this.currentProjectId)); for (const post of allPosts) { try { 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); } } this.emit('postLinksRebuilt'); } } // Singleton instance let postEngine: PostEngine | null = null; export function getPostEngine(): PostEngine { if (!postEngine) { postEngine = new PostEngine(); } return postEngine; }