feat: next phase of basic work

This commit is contained in:
2026-02-10 11:33:19 +01:00
parent 5979fa3374
commit 78b2847bad
27 changed files with 2325 additions and 508 deletions

View File

@@ -38,11 +38,25 @@ export interface MediaMetadata {
}
export class MediaEngine extends EventEmitter {
private mediaDir: string;
private currentProjectId: string = 'default';
constructor() {
super();
this.mediaDir = getDatabase().getDataPaths().media;
}
private getMediaDir(): string {
const { app } = require('electron');
const path = require('path');
const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'projects', this.currentProjectId, 'media');
}
setProjectContext(projectId: string): void {
this.currentProjectId = projectId;
}
getProjectContext(): string {
return this.currentProjectId;
}
private calculateChecksum(buffer: Buffer): string {
@@ -191,7 +205,9 @@ export class MediaEngine extends EventEmitter {
const originalName = path.basename(sourcePath);
const ext = path.extname(originalName);
const filename = `${id}${ext}`;
const destPath = path.join(this.mediaDir, filename);
const mediaDir = this.getMediaDir();
await fs.mkdir(mediaDir, { recursive: true });
const destPath = path.join(mediaDir, filename);
// Copy file to media directory
await fs.writeFile(destPath, sourceBuffer);
@@ -216,6 +232,7 @@ export class MediaEngine extends EventEmitter {
const dbMedia: NewMedia = {
id: mediaData.id,
projectId: this.currentProjectId,
filename: mediaData.filename,
originalName: mediaData.originalName,
mimeType: mediaData.mimeType,
@@ -346,7 +363,7 @@ export class MediaEngine extends EventEmitter {
}
getMediaPath(id: string): string {
return path.join(this.mediaDir, id);
return path.join(this.getMediaDir(), id);
}
async rebuildDatabaseFromFiles(): Promise<void> {
@@ -358,14 +375,20 @@ export class MediaEngine extends EventEmitter {
onProgress(0, 'Scanning media directory...');
const files = await fs.readdir(this.mediaDir);
const mediaDir = this.getMediaDir();
let files: string[] = [];
try {
files = await fs.readdir(mediaDir);
} catch {
await fs.mkdir(mediaDir, { recursive: true });
}
const metaFiles = files.filter(f => f.endsWith('.meta'));
onProgress(10, `Found ${metaFiles.length} media sidecar files`);
for (let i = 0; i < metaFiles.length; i++) {
const metaFile = metaFiles[i];
const sidecarPath = path.join(this.mediaDir, metaFile);
const sidecarPath = path.join(mediaDir, metaFile);
const mediaFilePath = sidecarPath.replace('.meta', '');
onProgress(10 + (80 * (i / metaFiles.length)), `Processing ${metaFile}...`);
@@ -399,6 +422,7 @@ export class MediaEngine extends EventEmitter {
} else {
await db.insert(media).values({
id: metadata.id,
projectId: this.currentProjectId,
filename,
originalName: metadata.originalName,
mimeType: metadata.mimeType,

View File

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

View File

@@ -0,0 +1,239 @@
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs/promises';
import * as path from 'path';
import { eq } from 'drizzle-orm';
import { app } from 'electron';
import { getDatabase } from '../database';
import { projects, Project, NewProject } from '../database/schema';
export interface ProjectData {
id: string;
name: string;
slug: string;
description?: string;
createdAt: Date;
updatedAt: Date;
isActive: boolean;
}
export class ProjectEngine extends EventEmitter {
constructor() {
super();
}
private generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
private async ensureProjectDirectories(slug: string): Promise<void> {
const userDataPath = app.getPath('userData');
const projectDir = path.join(userDataPath, 'projects', slug);
const postsDir = path.join(projectDir, 'posts');
const mediaDir = path.join(projectDir, 'media');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(postsDir, { recursive: true });
await fs.mkdir(mediaDir, { recursive: true });
}
async createProject(data: { name: string; description?: string; slug?: string }): Promise<ProjectData> {
const db = getDatabase().getLocal();
const now = new Date();
const id = uuidv4();
const slug = data.slug || this.generateSlug(data.name);
// Ensure unique slug
let finalSlug = slug;
let counter = 1;
const existing = await db.select().from(projects).all();
const existingSlugs = new Set(existing.map(p => p.slug));
while (existingSlugs.has(finalSlug)) {
finalSlug = `${slug}-${counter}`;
counter++;
}
const project: ProjectData = {
id,
name: data.name,
slug: finalSlug,
description: data.description,
createdAt: now,
updatedAt: now,
isActive: false,
};
// Create directories
await this.ensureProjectDirectories(finalSlug);
// Insert into database
const dbProject: NewProject = {
id: project.id,
name: project.name,
slug: project.slug,
description: project.description,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
isActive: project.isActive,
};
await db.insert(projects).values(dbProject);
this.emit('projectCreated', project);
return project;
}
async updateProject(id: string, data: Partial<Omit<ProjectData, 'id' | 'createdAt'>>): Promise<ProjectData | null> {
const db = getDatabase().getLocal();
const existing = await db.select().from(projects).where(eq(projects.id, id)).get();
if (!existing) {
return null;
}
const now = new Date();
const updated = {
...existing,
...data,
updatedAt: now,
};
await db.update(projects)
.set({
name: updated.name,
slug: updated.slug,
description: updated.description,
updatedAt: updated.updatedAt,
isActive: updated.isActive,
})
.where(eq(projects.id, id));
const result: ProjectData = {
id: updated.id,
name: updated.name,
slug: updated.slug,
description: updated.description || undefined,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
isActive: updated.isActive ?? false,
};
this.emit('projectUpdated', result);
return result;
}
async deleteProject(id: string): Promise<boolean> {
// Prevent deleting the default project
if (id === 'default') {
throw new Error('Cannot delete the default project');
}
const db = getDatabase().getLocal();
const existing = await db.select().from(projects).where(eq(projects.id, id)).get();
if (!existing) {
return false;
}
// TODO: Optionally delete project files (posts, media)
// For safety, we'll leave them in place
await db.delete(projects).where(eq(projects.id, id));
this.emit('projectDeleted', id);
return true;
}
async getProject(id: string): Promise<ProjectData | null> {
const db = getDatabase().getLocal();
const dbProject = await db.select().from(projects).where(eq(projects.id, id)).get();
if (!dbProject) {
return null;
}
return {
id: dbProject.id,
name: dbProject.name,
slug: dbProject.slug,
description: dbProject.description || undefined,
createdAt: dbProject.createdAt,
updatedAt: dbProject.updatedAt,
isActive: dbProject.isActive ?? false,
};
}
async getAllProjects(): Promise<ProjectData[]> {
const db = getDatabase().getLocal();
const dbProjects = await db.select().from(projects).all();
return dbProjects.map(p => ({
id: p.id,
name: p.name,
slug: p.slug,
description: p.description || undefined,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
isActive: p.isActive ?? false,
}));
}
async getActiveProject(): Promise<ProjectData | null> {
const db = getDatabase().getLocal();
const dbProject = await db.select().from(projects).where(eq(projects.isActive, true)).get();
if (!dbProject) {
// Return default if no active project
return this.getProject('default');
}
return {
id: dbProject.id,
name: dbProject.name,
slug: dbProject.slug,
description: dbProject.description || undefined,
createdAt: dbProject.createdAt,
updatedAt: dbProject.updatedAt,
isActive: dbProject.isActive ?? false,
};
}
async setActiveProject(id: string): Promise<ProjectData | null> {
const db = getDatabase().getLocal();
// Deactivate all projects
await db.update(projects).set({ isActive: false });
// Activate the selected project
await db.update(projects)
.set({ isActive: true })
.where(eq(projects.id, id));
const project = await this.getProject(id);
if (project) {
this.emit('activeProjectChanged', project);
}
return project;
}
getProjectPaths(projectSlug: string): { posts: string; media: string } {
const userDataPath = app.getPath('userData');
return {
posts: path.join(userDataPath, 'projects', projectSlug, 'posts'),
media: path.join(userDataPath, 'projects', projectSlug, 'media'),
};
}
}
// Singleton instance
let projectEngine: ProjectEngine | null = null;
export function getProjectEngine(): ProjectEngine {
if (!projectEngine) {
projectEngine = new ProjectEngine();
}
return projectEngine;
}

View File

@@ -1,4 +1,5 @@
export { TaskManager, taskManager, type Task, type TaskProgress, type TaskStatus } from './TaskManager';
export { PostEngine, getPostEngine, type PostData } from './PostEngine';
export { PostEngine, getPostEngine, type PostData, type PostFilter, type SearchResult } from './PostEngine';
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';
export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine';