feat: next phase of basic work
This commit is contained in:
@@ -4,13 +4,15 @@ import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import matter from 'gray-matter';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, and, desc, gte, lte, like } from 'drizzle-orm';
|
||||
import { app } from 'electron';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, Post, NewPost } from '../database/schema';
|
||||
import { taskManager, Task } from './TaskManager';
|
||||
|
||||
export interface PostData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
@@ -25,6 +27,8 @@ export interface PostData {
|
||||
}
|
||||
|
||||
export interface PostMetadata {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
@@ -35,15 +39,45 @@ export interface PostMetadata {
|
||||
publishedAt?: string;
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
matchSnippet?: string;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export interface PostFilter {
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
tags?: string[];
|
||||
categories?: string[];
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
year?: number;
|
||||
month?: number;
|
||||
}
|
||||
|
||||
export class PostEngine extends EventEmitter {
|
||||
private postsDir: string;
|
||||
private currentProjectId: string = 'default';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.postsDir = getDatabase().getDataPaths().posts;
|
||||
}
|
||||
|
||||
private getPostsDir(): string {
|
||||
const userDataPath = app.getPath('userData');
|
||||
return path.join(userDataPath, 'projects', this.currentProjectId, 'posts');
|
||||
}
|
||||
|
||||
setProjectContext(projectId: string): void {
|
||||
this.currentProjectId = projectId;
|
||||
}
|
||||
|
||||
getProjectContext(): string {
|
||||
return this.currentProjectId;
|
||||
}
|
||||
|
||||
private generateSlug(title: string): string {
|
||||
@@ -60,6 +94,7 @@ export class PostEngine extends EventEmitter {
|
||||
private async writePostFile(post: PostData): Promise<string> {
|
||||
const metadata: PostMetadata = {
|
||||
id: post.id,
|
||||
projectId: post.projectId,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
@@ -72,8 +107,11 @@ export class PostEngine extends EventEmitter {
|
||||
categories: post.categories,
|
||||
};
|
||||
|
||||
const postsDir = this.getPostsDir();
|
||||
await fs.mkdir(postsDir, { recursive: true });
|
||||
|
||||
const fileContent = matter.stringify(post.content, metadata);
|
||||
const filePath = path.join(this.postsDir, `${post.slug}.md`);
|
||||
const filePath = path.join(postsDir, `${post.slug}.md`);
|
||||
|
||||
await fs.writeFile(filePath, fileContent, 'utf-8');
|
||||
return filePath;
|
||||
@@ -87,6 +125,7 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
return {
|
||||
id: metadata.id,
|
||||
projectId: metadata.projectId || this.currentProjectId,
|
||||
title: metadata.title,
|
||||
slug: metadata.slug,
|
||||
excerpt: metadata.excerpt,
|
||||
@@ -107,12 +146,14 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
async createPost(data: Partial<PostData>): Promise<PostData> {
|
||||
const db = getDatabase().getLocal();
|
||||
const client = getDatabase().getLocalClient();
|
||||
const now = new Date();
|
||||
const id = uuidv4();
|
||||
const slug = data.slug || this.generateSlug(data.title || 'untitled');
|
||||
|
||||
const post: PostData = {
|
||||
id,
|
||||
projectId: data.projectId || this.currentProjectId,
|
||||
title: data.title || 'Untitled',
|
||||
slug,
|
||||
excerpt: data.excerpt,
|
||||
@@ -133,6 +174,7 @@ export class PostEngine extends EventEmitter {
|
||||
// Then update database
|
||||
const dbPost: NewPost = {
|
||||
id: post.id,
|
||||
projectId: post.projectId,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
@@ -150,12 +192,21 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
await db.insert(posts).values(dbPost);
|
||||
|
||||
// Update FTS index
|
||||
if (client) {
|
||||
await client.execute({
|
||||
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
args: [post.id, post.title, post.content, post.excerpt || '', post.tags.join(' '), post.categories.join(' ')],
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -166,12 +217,14 @@ export class PostEngine extends EventEmitter {
|
||||
...existing,
|
||||
...data,
|
||||
id, // Ensure ID doesn't change
|
||||
projectId: existing.projectId, // Ensure projectId doesn't change
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Handle slug change - need to rename file
|
||||
const postsDir = this.getPostsDir();
|
||||
if (data.slug && data.slug !== existing.slug) {
|
||||
const oldPath = path.join(this.postsDir, `${existing.slug}.md`);
|
||||
const oldPath = path.join(postsDir, `${existing.slug}.md`);
|
||||
try {
|
||||
await fs.unlink(oldPath);
|
||||
} catch {
|
||||
@@ -199,12 +252,22 @@ export class PostEngine extends EventEmitter {
|
||||
})
|
||||
.where(eq(posts.id, id));
|
||||
|
||||
// Update FTS index
|
||||
if (client) {
|
||||
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] });
|
||||
await client.execute({
|
||||
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
args: [updated.id, updated.title, updated.content, updated.excerpt || '', updated.tags.join(' '), updated.categories.join(' ')],
|
||||
});
|
||||
}
|
||||
|
||||
this.emit('postUpdated', updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async 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) {
|
||||
@@ -221,6 +284,11 @@ export class PostEngine extends EventEmitter {
|
||||
// Delete from database
|
||||
await db.delete(posts).where(eq(posts.id, id));
|
||||
|
||||
// Delete from FTS index
|
||||
if (client) {
|
||||
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] });
|
||||
}
|
||||
|
||||
this.emit('postDeleted', id);
|
||||
return true;
|
||||
}
|
||||
@@ -240,6 +308,7 @@ export class PostEngine extends EventEmitter {
|
||||
// File doesn't exist, reconstruct from database
|
||||
return {
|
||||
id: dbPost.id,
|
||||
projectId: dbPost.projectId,
|
||||
title: dbPost.title,
|
||||
slug: dbPost.slug,
|
||||
excerpt: dbPost.excerpt || undefined,
|
||||
@@ -259,7 +328,12 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
async getAllPosts(): Promise<PostData[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbPosts = await db.select().from(posts).all();
|
||||
const dbPosts = await db
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, this.currentProjectId))
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.all();
|
||||
|
||||
const result: PostData[] = [];
|
||||
|
||||
@@ -275,7 +349,15 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
async getPostsByStatus(status: 'draft' | 'published' | 'archived'): Promise<PostData[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbPosts = await db.select().from(posts).where(eq(posts.status, status)).all();
|
||||
const dbPosts = await db
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(and(
|
||||
eq(posts.projectId, this.currentProjectId),
|
||||
eq(posts.status, status)
|
||||
))
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.all();
|
||||
|
||||
const result: PostData[] = [];
|
||||
|
||||
@@ -289,6 +371,140 @@ export class PostEngine extends EventEmitter {
|
||||
return result;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
const dbPosts = await db
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.all();
|
||||
|
||||
let result: PostData[] = [];
|
||||
|
||||
for (const dbPost of dbPosts) {
|
||||
const postData = await this.getPost(dbPost.id);
|
||||
if (postData) {
|
||||
// Client-side filtering for tags/categories (JSON array)
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
const hasAllTags = filter.tags.every(tag => postData.tags.includes(tag));
|
||||
if (!hasAllTags) continue;
|
||||
}
|
||||
|
||||
if (filter.categories && filter.categories.length > 0) {
|
||||
const hasAnyCategory = filter.categories.some(cat => postData.categories.includes(cat));
|
||||
if (!hasAnyCategory) continue;
|
||||
}
|
||||
|
||||
result.push(postData);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async searchPosts(query: string): Promise<SearchResult[]> {
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return [];
|
||||
|
||||
try {
|
||||
const result = await client.execute({
|
||||
sql: `SELECT id, title, excerpt, snippet(posts_fts, 2, '<mark>', '</mark>', '...', 32) as snippet, rank
|
||||
FROM posts_fts
|
||||
WHERE posts_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT 50`,
|
||||
args: [query],
|
||||
});
|
||||
|
||||
const projectPosts = await this.getAllPosts();
|
||||
const projectPostIds = new Set(projectPosts.map(p => p.id));
|
||||
|
||||
return result.rows
|
||||
.filter(row => projectPostIds.has(row.id as string))
|
||||
.map(row => ({
|
||||
id: row.id as string,
|
||||
title: row.title as string,
|
||||
slug: '', // Will be filled in by caller if needed
|
||||
excerpt: row.excerpt as string | undefined,
|
||||
matchSnippet: row.snippet as string | undefined,
|
||||
rank: row.rank as number | undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableTags(): Promise<string[]> {
|
||||
const allPosts = await this.getAllPosts();
|
||||
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.getAllPosts();
|
||||
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 getPostsByYearMonth(): Promise<{ year: number; month: number; count: number }[]> {
|
||||
const allPosts = await this.getAllPosts();
|
||||
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> {
|
||||
return this.updatePost(id, {
|
||||
status: 'published',
|
||||
@@ -304,22 +520,30 @@ export class PostEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
async rebuildDatabaseFromFiles(): Promise<void> {
|
||||
const postsDir = this.getPostsDir();
|
||||
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, 'Scanning posts directory...');
|
||||
|
||||
const files = await fs.readdir(this.postsDir);
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = await fs.readdir(postsDir);
|
||||
} catch {
|
||||
// Directory might not exist
|
||||
await fs.mkdir(postsDir, { recursive: true });
|
||||
}
|
||||
const mdFiles = files.filter(f => f.endsWith('.md'));
|
||||
|
||||
onProgress(10, `Found ${mdFiles.length} post files`);
|
||||
|
||||
for (let i = 0; i < mdFiles.length; i++) {
|
||||
const file = mdFiles[i];
|
||||
const filePath = path.join(this.postsDir, file);
|
||||
const filePath = path.join(postsDir, file);
|
||||
|
||||
onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${file}...`);
|
||||
|
||||
@@ -348,6 +572,7 @@ export class PostEngine extends EventEmitter {
|
||||
} else {
|
||||
await db.insert(posts).values({
|
||||
id: postData.id,
|
||||
projectId: postData.projectId || this.currentProjectId,
|
||||
title: postData.title,
|
||||
slug: postData.slug,
|
||||
excerpt: postData.excerpt,
|
||||
@@ -363,6 +588,15 @@ export class PostEngine extends EventEmitter {
|
||||
categories: JSON.stringify(postData.categories),
|
||||
});
|
||||
}
|
||||
|
||||
// Update FTS index
|
||||
if (client) {
|
||||
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [postData.id] });
|
||||
await client.execute({
|
||||
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
args: [postData.id, postData.title, postData.content, postData.excerpt || '', postData.tags.join(' '), postData.categories.join(' ')],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user