diff --git a/VISION.md b/VISION.md index 5ff0afa..27401bc 100644 --- a/VISION.md +++ b/VISION.md @@ -195,6 +195,9 @@ we have all the metadata in our model set up in a similar way as Wordpress handl seamless integration. Posts in Wordpress backups are html, but should be interpreted and transformed into proper markdown in the import. +We should use UpdraftPlus for backups and loading data into the system from those backups, so that we +have full data available from the site, including all meta data and uploads. + Additionally we need another importer to traverse a full website and deduct post structure from that website and rebuild posts in the database based on such a web traversal. To be able to do that, use copilot SDK to integrate copilot directly, so that HTML pages can be directly inspected and turned into actual blog diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 8d73ec3..ccb1d66 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -4,7 +4,20 @@ import * as path from 'path'; import { app } from 'electron'; import { eq } from 'drizzle-orm'; import { getDatabase } from '../database'; -import { posts } from '../database/schema'; +import { posts, projects } from '../database/schema'; + +/** + * Project metadata stored in meta/project.json + */ +export interface ProjectMetadata { + name: string; + description?: string; +} + +/** + * Default categories for new projects (from VISION.md) + */ +export const DEFAULT_CATEGORIES = ['article', 'picture', 'aside', 'page']; /** * MetaEngine manages project metadata like available tags and categories. @@ -20,6 +33,7 @@ export class MetaEngine extends EventEmitter { private currentProjectId: string = 'default'; private tags: Set = new Set(); private categories: Set = new Set(); + private projectMetadata: ProjectMetadata | null = null; private initialized: boolean = false; constructor() { @@ -43,11 +57,16 @@ export class MetaEngine extends EventEmitter { return path.join(this.getMetaDir(), 'categories.json'); } + private getProjectMetadataFilePath(): string { + return path.join(this.getMetaDir(), 'project.json'); + } + setProjectContext(projectId: string): void { this.currentProjectId = projectId; // Reset in-memory cache when project changes this.tags.clear(); this.categories.clear(); + this.projectMetadata = null; this.initialized = false; } @@ -69,6 +88,41 @@ export class MetaEngine extends EventEmitter { return Array.from(this.categories).sort(); } + /** + * Get the project metadata. + */ + async getProjectMetadata(): Promise { + return this.projectMetadata; + } + + /** + * Set the project metadata (replaces existing). + */ + async setProjectMetadata(metadata: ProjectMetadata): Promise { + this.projectMetadata = { ...metadata }; + await this.saveProjectMetadata(); + this.emit('projectMetadataChanged', this.projectMetadata); + } + + /** + * Update specific fields of project metadata. + */ + async updateProjectMetadata(updates: Partial): Promise { + if (!this.projectMetadata) { + this.projectMetadata = { + name: updates.name || '', + description: updates.description, + }; + } else { + this.projectMetadata = { + ...this.projectMetadata, + ...updates, + }; + } + await this.saveProjectMetadata(); + this.emit('projectMetadataChanged', this.projectMetadata); + } + /** * Add a new tag to the available tags list. */ @@ -145,6 +199,40 @@ export class MetaEngine extends EventEmitter { } } + /** + * Save project metadata to the filesystem. + */ + async saveProjectMetadata(): Promise { + try { + await this.ensureMetaDirExists(); + const filePath = this.getProjectMetadataFilePath(); + const content = JSON.stringify(this.projectMetadata, null, 2); + await fs.writeFile(filePath, content, 'utf-8'); + } catch (error) { + console.error('[MetaEngine] Failed to save project metadata:', error); + throw error; + } + } + + /** + * Load project metadata from the filesystem. + */ + async loadProjectMetadata(): Promise { + try { + const filePath = this.getProjectMetadataFilePath(); + const content = await fs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(content) as ProjectMetadata; + this.projectMetadata = parsed; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[MetaEngine] Failed to load project metadata:', error); + throw error; + } + // File doesn't exist, that's OK + this.projectMetadata = null; + } + } + /** * Load tags from the filesystem. */ @@ -243,6 +331,20 @@ export class MetaEngine extends EventEmitter { return Array.from(allCategories).sort(); } + /** + * Fetch the current project's data from the database. + */ + private async fetchProjectFromDatabase(): Promise<{ name: string; description: string | null } | null> { + const db = getDatabase().getLocal(); + const project = await db + .select({ name: projects.name, description: projects.description }) + .from(projects) + .where(eq(projects.id, this.currentProjectId)) + .get(); + + return project || null; + } + /** * Ensure the meta directory exists. */ @@ -281,9 +383,11 @@ export class MetaEngine extends EventEmitter { const tagsFilePath = this.getTagsFilePath(); const categoriesFilePath = this.getCategoriesFilePath(); + const projectMetadataFilePath = this.getProjectMetadataFilePath(); const tagsFileExists = await this.fileExists(tagsFilePath); const categoriesFileExists = await this.fileExists(categoriesFilePath); + const projectMetadataFileExists = await this.fileExists(projectMetadataFilePath); // Collect tags/categories from database (posts) const dbTags = await this.collectTagsFromPosts(); @@ -337,14 +441,37 @@ export class MetaEngine extends EventEmitter { await this.saveCategories(); } } else { - // No file exists, create from database + // No file exists, create from database or use defaults this.categories.clear(); - for (const cat of dbCategories) { - this.categories.add(cat); + if (dbCategories.length > 0) { + for (const cat of dbCategories) { + this.categories.add(cat); + } + } else { + // New project with no posts - use default categories + for (const cat of DEFAULT_CATEGORIES) { + this.categories.add(cat); + } } await this.saveCategories(); } + // Handle project metadata + if (projectMetadataFileExists) { + await this.loadProjectMetadata(); + } else { + // No file exists, fetch project data from database and create file + const projectData = await this.fetchProjectFromDatabase(); + if (!projectData) { + throw new Error(`Project not found in database: ${this.currentProjectId}`); + } + this.projectMetadata = { + name: projectData.name, + description: projectData.description || undefined, + }; + await this.saveProjectMetadata(); + } + this.initialized = true; console.log(`[MetaEngine] Sync complete. Tags: ${this.tags.size}, Categories: ${this.categories.size}`); } diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index c4faab0..1c5a314 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -3,7 +3,7 @@ export { PostEngine, getPostEngine, type PostData, type PostFilter, type SearchR 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'; -export { MetaEngine, getMetaEngine } from './MetaEngine'; +export { MetaEngine, getMetaEngine, type ProjectMetadata, DEFAULT_CATEGORIES } from './MetaEngine'; export { stemText, stemWord, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index da32582..1bf0f2e 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -528,9 +528,27 @@ export function registerIpcHandlers(): void { return { tags: await engine.getTags(), categories: await engine.getCategories(), + projectMetadata: await engine.getProjectMetadata(), }; }); + ipcMain.handle('meta:getProjectMetadata', async () => { + const engine = getMetaEngine(); + return engine.getProjectMetadata(); + }); + + ipcMain.handle('meta:setProjectMetadata', async (_, metadata: { name: string; description?: string }) => { + const engine = getMetaEngine(); + await engine.setProjectMetadata(metadata); + return engine.getProjectMetadata(); + }); + + ipcMain.handle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string }) => { + const engine = getMetaEngine(); + await engine.updateProjectMetadata(updates); + return engine.getProjectMetadata(); + }); + // ============ Event Forwarding ============ // Forward engine events to renderer @@ -566,6 +584,7 @@ export function registerIpcHandlers(): void { metaEngine.on('tagsChanged', forwardEvent('meta:tagsChanged')); metaEngine.on('categoriesChanged', forwardEvent('meta:categoriesChanged')); + metaEngine.on('projectMetadataChanged', forwardEvent('meta:projectMetadataChanged')); syncEngine.on('syncStarted', forwardEvent('sync:started')); syncEngine.on('syncCompleted', forwardEvent('sync:completed')); diff --git a/src/main/preload.ts b/src/main/preload.ts index fc1bd40..fb1cc19 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -101,7 +101,7 @@ contextBridge.exposeInMainWorld('electronAPI', { showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath), }, - // Meta (tags and categories) + // Meta (tags, categories, and project metadata) meta: { getTags: () => ipcRenderer.invoke('meta:getTags'), getCategories: () => ipcRenderer.invoke('meta:getCategories'), @@ -110,6 +110,9 @@ contextBridge.exposeInMainWorld('electronAPI', { addCategory: (category: string) => ipcRenderer.invoke('meta:addCategory', category), removeCategory: (category: string) => ipcRenderer.invoke('meta:removeCategory', category), syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'), + getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'), + setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata), + updateProjectMetadata: (updates: { name?: string; description?: string }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), }, // Event listeners diff --git a/tests/engine/MetaEngine.test.ts b/tests/engine/MetaEngine.test.ts index a57b191..6231675 100644 --- a/tests/engine/MetaEngine.test.ts +++ b/tests/engine/MetaEngine.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; const mockFiles = new Map(); const mockDirs = new Set(); let mockPosts: any[] = []; +let mockProject: any = null; // Mock fs/promises vi.mock('fs/promises', () => ({ @@ -48,14 +49,28 @@ vi.mock('electron', () => ({ }, })); -// Create chainable mock for Drizzle ORM +// Create chainable mock for Drizzle ORM +let lastQueriedTable: string | null = null; + function createSelectChain() { - return { - from: vi.fn().mockReturnThis(), + const chain: any = { + from: vi.fn().mockImplementation((table) => { + // Drizzle table objects have [Symbol.for('drizzle:Name')] or _.name + lastQueriedTable = table?.[Symbol.for('drizzle:Name')] || table?._?.name || null; + return chain; + }), where: vi.fn().mockReturnThis(), all: vi.fn().mockImplementation(() => Promise.resolve(mockPosts)), - get: vi.fn().mockImplementation(() => Promise.resolve(undefined)), + get: vi.fn().mockImplementation(() => { + // Return project data if querying projects table + if (lastQueriedTable === 'projects') { + return Promise.resolve(mockProject); + } + return Promise.resolve(undefined); + }), }; + chain.where = vi.fn().mockReturnValue(chain); + return chain; } const mockLocalDb = { @@ -76,11 +91,24 @@ import * as fs from 'fs/promises'; describe('MetaEngine', () => { let metaEngine: MetaEngine; + // Default project for tests that call syncOnStartup + const defaultMockProject = { + id: 'test-project', + name: 'Test Project', + description: 'A test project', + slug: 'test-project', + createdAt: new Date(), + updatedAt: new Date(), + isActive: true, + }; + beforeEach(() => { vi.clearAllMocks(); mockFiles.clear(); mockDirs.clear(); mockPosts = []; + mockProject = defaultMockProject; // Default to valid project + lastQueriedTable = null; metaEngine = new MetaEngine(); metaEngine.setProjectContext('test-project'); }); @@ -347,4 +375,197 @@ describe('MetaEngine', () => { expect(handler).toHaveBeenCalledWith(expect.arrayContaining(['new-category'])); }); }); + + describe('Project Metadata Management', () => { + it('should return null when no project metadata exists', async () => { + const metadata = await metaEngine.getProjectMetadata(); + expect(metadata).toBeNull(); + }); + + it('should set project metadata', async () => { + await metaEngine.setProjectMetadata({ + name: 'My Blog', + description: 'A personal blog about technology', + }); + + const metadata = await metaEngine.getProjectMetadata(); + expect(metadata).toEqual({ + name: 'My Blog', + description: 'A personal blog about technology', + }); + }); + + it('should update project name only', async () => { + await metaEngine.setProjectMetadata({ + name: 'Original Name', + description: 'Original description', + }); + + await metaEngine.updateProjectMetadata({ name: 'Updated Name' }); + + const metadata = await metaEngine.getProjectMetadata(); + expect(metadata?.name).toBe('Updated Name'); + expect(metadata?.description).toBe('Original description'); + }); + + it('should update project description only', async () => { + await metaEngine.setProjectMetadata({ + name: 'My Blog', + description: 'Old description', + }); + + await metaEngine.updateProjectMetadata({ description: 'New description' }); + + const metadata = await metaEngine.getProjectMetadata(); + expect(metadata?.name).toBe('My Blog'); + expect(metadata?.description).toBe('New description'); + }); + + it('should persist project metadata to filesystem', async () => { + await metaEngine.setProjectMetadata({ + name: 'Test Project', + description: 'Test description', + }); + + const metaDir = metaEngine.getMetaDir(); + const projectPath = `${metaDir}\\project.json`; + expect(mockFiles.has(projectPath) || mockFiles.has(projectPath.replace(/\\/g, '/'))).toBe(true); + + // Verify content + const content = mockFiles.get(projectPath) || mockFiles.get(projectPath.replace(/\\/g, '/')); + const parsed = JSON.parse(content!); + expect(parsed.name).toBe('Test Project'); + expect(parsed.description).toBe('Test description'); + }); + + it('should load project metadata from filesystem', async () => { + const metaDir = metaEngine.getMetaDir(); + const projectPath = `${metaDir}\\project.json`; + mockFiles.set(projectPath, JSON.stringify({ + name: 'Loaded Project', + description: 'Loaded description', + })); + + await metaEngine.loadProjectMetadata(); + + const metadata = await metaEngine.getProjectMetadata(); + expect(metadata?.name).toBe('Loaded Project'); + expect(metadata?.description).toBe('Loaded description'); + }); + + it('should emit projectMetadataChanged event when metadata is modified', async () => { + const handler = vi.fn(); + metaEngine.on('projectMetadataChanged', handler); + + await metaEngine.setProjectMetadata({ + name: 'Event Test', + description: 'Testing events', + }); + + expect(handler).toHaveBeenCalledWith({ + name: 'Event Test', + description: 'Testing events', + }); + }); + + it('should clear project metadata when project context changes', () => { + // Set some metadata first + metaEngine.setProjectContext('project-1'); + + // Change project context + metaEngine.setProjectContext('project-2'); + + // The in-memory cache should be cleared (metadata will be null until loaded) + // This is a synchronous operation, so we test the immediate state + expect(metaEngine.getProjectContext()).toBe('project-2'); + }); + + it('should sync project metadata on startup from database', async () => { + // No file exists, should use default from project database + const metadata = await metaEngine.getProjectMetadata(); + // Initially null before sync + expect(metadata).toBeNull(); + }); + + it('should load project metadata during syncOnStartup if file exists', async () => { + const metaDir = metaEngine.getMetaDir(); + mockFiles.set(`${metaDir}\\project.json`, JSON.stringify({ + name: 'Synced Project', + description: 'Synced description', + })); + + await metaEngine.syncOnStartup(); + + const metadata = await metaEngine.getProjectMetadata(); + expect(metadata?.name).toBe('Synced Project'); + expect(metadata?.description).toBe('Synced description'); + }); + + it('should create project.json with data from database during syncOnStartup if file does not exist', async () => { + const metaDir = metaEngine.getMetaDir(); + const projectPath = `${metaDir}\\project.json`; + + // Setup mock project in database + mockProject = { + id: 'test-project', + name: 'My Awesome Blog', + description: 'A blog about programming', + slug: 'my-awesome-blog', + createdAt: new Date(), + updatedAt: new Date(), + isActive: true, + }; + + // Ensure no file exists + expect(mockFiles.has(projectPath)).toBe(false); + + await metaEngine.syncOnStartup(); + + // File should be created + expect(mockFiles.has(projectPath) || mockFiles.has(projectPath.replace(/\\/g, '/'))).toBe(true); + + // Should have metadata from database + const metadata = await metaEngine.getProjectMetadata(); + expect(metadata).not.toBeNull(); + expect(metadata?.name).toBe('My Awesome Blog'); + expect(metadata?.description).toBe('A blog about programming'); + }); + + it('should throw error if project not found in database during syncOnStartup', async () => { + // No project in database + mockProject = null; + + await expect(metaEngine.syncOnStartup()).rejects.toThrow('Project not found'); + }); + + it('should create categories.json with defaults for new project with no posts', async () => { + const metaDir = metaEngine.getMetaDir(); + const catPath = `${metaDir}\\categories.json`; + + // Setup mock project in database + mockProject = { + id: 'test-project', + name: 'New Blog', + description: 'A new blog', + slug: 'new-blog', + createdAt: new Date(), + updatedAt: new Date(), + isActive: true, + }; + + // No posts (so no categories from database) + mockPosts = []; + + await metaEngine.syncOnStartup(); + + // File should be created with default categories + expect(mockFiles.has(catPath) || mockFiles.has(catPath.replace(/\\/g, '/'))).toBe(true); + + const categories = await metaEngine.getCategories(); + expect(categories).toContain('article'); + expect(categories).toContain('picture'); + expect(categories).toContain('aside'); + expect(categories).toContain('page'); + }); + }); });