Files
bDS/src/main/engine/PostEngine.ts
2026-02-26 11:20:03 +01:00

1815 lines
56 KiB
TypeScript

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<T> {
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<void> {
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<void> {
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<boolean> {
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<string> {
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<string> {
const metadata: Record<string, unknown> = {
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<PostData | null> {
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<PostData>): Promise<PostData> {
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<PostData>): Promise<PostData | null> {
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<boolean> {
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<PostData | null> {
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<boolean> {
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<PaginatedResult<PostData>> {
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<PostData[]> {
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<PostData[]> {
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<PostData[]> {
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<SearchResult[]> {
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<string[]> {
const allPosts = await this.getAllPostsUnpaginated();
const tags = new Set<string>();
for (const post of allPosts) {
for (const tag of post.tags) {
tags.add(tag);
}
}
return Array.from(tags).sort();
}
async getAvailableCategories(): Promise<string[]> {
const allPosts = await this.getAllPostsUnpaginated();
const categories = new Set<string>();
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<string, number>();
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<string, number>();
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<number, number>;
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<number, number> = {};
const uniqueTags = new Set<string>();
const uniqueCategories = new Set<string>();
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<string, { year: number; month: number; count: number }>();
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<PostData | null> {
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<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) {
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<boolean> {
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<PostData | null> {
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<void> {
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<PublishedPostReconcileResult> {
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<string, Post>();
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<void> {
const task: Task<void> = {
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<void> {
const postsBaseDir = this.getPostsBaseDir();
const task: Task<void> = {
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<string>(); // projectId:slug
const insertedIds = new Set<string>();
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: <a href="/posts/slug">text</a>
const htmlLinkRegex = /<a[^>]+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<void> {
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<void> {
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;
}