sync project settings too

This commit is contained in:
2026-02-11 10:37:59 +01:00
parent c7827a2d77
commit 48f7fc16e5
6 changed files with 383 additions and 10 deletions

View File

@@ -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 seamless integration. Posts in Wordpress backups are html, but should be interpreted and transformed into
proper markdown in the import. 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 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 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 to integrate copilot directly, so that HTML pages can be directly inspected and turned into actual blog

View File

@@ -4,7 +4,20 @@ import * as path from 'path';
import { app } from 'electron'; import { app } from 'electron';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getDatabase } from '../database'; 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. * MetaEngine manages project metadata like available tags and categories.
@@ -20,6 +33,7 @@ export class MetaEngine extends EventEmitter {
private currentProjectId: string = 'default'; private currentProjectId: string = 'default';
private tags: Set<string> = new Set(); private tags: Set<string> = new Set();
private categories: Set<string> = new Set(); private categories: Set<string> = new Set();
private projectMetadata: ProjectMetadata | null = null;
private initialized: boolean = false; private initialized: boolean = false;
constructor() { constructor() {
@@ -43,11 +57,16 @@ export class MetaEngine extends EventEmitter {
return path.join(this.getMetaDir(), 'categories.json'); return path.join(this.getMetaDir(), 'categories.json');
} }
private getProjectMetadataFilePath(): string {
return path.join(this.getMetaDir(), 'project.json');
}
setProjectContext(projectId: string): void { setProjectContext(projectId: string): void {
this.currentProjectId = projectId; this.currentProjectId = projectId;
// Reset in-memory cache when project changes // Reset in-memory cache when project changes
this.tags.clear(); this.tags.clear();
this.categories.clear(); this.categories.clear();
this.projectMetadata = null;
this.initialized = false; this.initialized = false;
} }
@@ -69,6 +88,41 @@ export class MetaEngine extends EventEmitter {
return Array.from(this.categories).sort(); return Array.from(this.categories).sort();
} }
/**
* Get the project metadata.
*/
async getProjectMetadata(): Promise<ProjectMetadata | null> {
return this.projectMetadata;
}
/**
* Set the project metadata (replaces existing).
*/
async setProjectMetadata(metadata: ProjectMetadata): Promise<void> {
this.projectMetadata = { ...metadata };
await this.saveProjectMetadata();
this.emit('projectMetadataChanged', this.projectMetadata);
}
/**
* Update specific fields of project metadata.
*/
async updateProjectMetadata(updates: Partial<ProjectMetadata>): Promise<void> {
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. * 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<void> {
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<void> {
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. * Load tags from the filesystem.
*/ */
@@ -243,6 +331,20 @@ export class MetaEngine extends EventEmitter {
return Array.from(allCategories).sort(); 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. * Ensure the meta directory exists.
*/ */
@@ -281,9 +383,11 @@ export class MetaEngine extends EventEmitter {
const tagsFilePath = this.getTagsFilePath(); const tagsFilePath = this.getTagsFilePath();
const categoriesFilePath = this.getCategoriesFilePath(); const categoriesFilePath = this.getCategoriesFilePath();
const projectMetadataFilePath = this.getProjectMetadataFilePath();
const tagsFileExists = await this.fileExists(tagsFilePath); const tagsFileExists = await this.fileExists(tagsFilePath);
const categoriesFileExists = await this.fileExists(categoriesFilePath); const categoriesFileExists = await this.fileExists(categoriesFilePath);
const projectMetadataFileExists = await this.fileExists(projectMetadataFilePath);
// Collect tags/categories from database (posts) // Collect tags/categories from database (posts)
const dbTags = await this.collectTagsFromPosts(); const dbTags = await this.collectTagsFromPosts();
@@ -337,14 +441,37 @@ export class MetaEngine extends EventEmitter {
await this.saveCategories(); await this.saveCategories();
} }
} else { } else {
// No file exists, create from database // No file exists, create from database or use defaults
this.categories.clear(); this.categories.clear();
for (const cat of dbCategories) { if (dbCategories.length > 0) {
this.categories.add(cat); 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(); 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; this.initialized = true;
console.log(`[MetaEngine] Sync complete. Tags: ${this.tags.size}, Categories: ${this.categories.size}`); console.log(`[MetaEngine] Sync complete. Tags: ${this.tags.size}, Categories: ${this.categories.size}`);
} }

View File

@@ -3,7 +3,7 @@ export { PostEngine, getPostEngine, type PostData, type PostFilter, type SearchR
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine'; export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine'; export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';
export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine'; export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine';
export { MetaEngine, getMetaEngine } from './MetaEngine'; export { MetaEngine, getMetaEngine, type ProjectMetadata, DEFAULT_CATEGORIES } from './MetaEngine';
export { export {
stemText, stemText,
stemWord, stemWord,

View File

@@ -528,9 +528,27 @@ export function registerIpcHandlers(): void {
return { return {
tags: await engine.getTags(), tags: await engine.getTags(),
categories: await engine.getCategories(), 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 ============ // ============ Event Forwarding ============
// Forward engine events to renderer // Forward engine events to renderer
@@ -566,6 +584,7 @@ export function registerIpcHandlers(): void {
metaEngine.on('tagsChanged', forwardEvent('meta:tagsChanged')); metaEngine.on('tagsChanged', forwardEvent('meta:tagsChanged'));
metaEngine.on('categoriesChanged', forwardEvent('meta:categoriesChanged')); metaEngine.on('categoriesChanged', forwardEvent('meta:categoriesChanged'));
metaEngine.on('projectMetadataChanged', forwardEvent('meta:projectMetadataChanged'));
syncEngine.on('syncStarted', forwardEvent('sync:started')); syncEngine.on('syncStarted', forwardEvent('sync:started'));
syncEngine.on('syncCompleted', forwardEvent('sync:completed')); syncEngine.on('syncCompleted', forwardEvent('sync:completed'));

View File

@@ -101,7 +101,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath), showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),
}, },
// Meta (tags and categories) // Meta (tags, categories, and project metadata)
meta: { meta: {
getTags: () => ipcRenderer.invoke('meta:getTags'), getTags: () => ipcRenderer.invoke('meta:getTags'),
getCategories: () => ipcRenderer.invoke('meta:getCategories'), getCategories: () => ipcRenderer.invoke('meta:getCategories'),
@@ -110,6 +110,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
addCategory: (category: string) => ipcRenderer.invoke('meta:addCategory', category), addCategory: (category: string) => ipcRenderer.invoke('meta:addCategory', category),
removeCategory: (category: string) => ipcRenderer.invoke('meta:removeCategory', category), removeCategory: (category: string) => ipcRenderer.invoke('meta:removeCategory', category),
syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'), 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 // Event listeners

View File

@@ -15,6 +15,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
const mockFiles = new Map<string, string>(); const mockFiles = new Map<string, string>();
const mockDirs = new Set<string>(); const mockDirs = new Set<string>();
let mockPosts: any[] = []; let mockPosts: any[] = [];
let mockProject: any = null;
// Mock fs/promises // Mock fs/promises
vi.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() { function createSelectChain() {
return { const chain: any = {
from: vi.fn().mockReturnThis(), 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(), where: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(() => Promise.resolve(mockPosts)), 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 = { const mockLocalDb = {
@@ -76,11 +91,24 @@ import * as fs from 'fs/promises';
describe('MetaEngine', () => { describe('MetaEngine', () => {
let metaEngine: 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(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockFiles.clear(); mockFiles.clear();
mockDirs.clear(); mockDirs.clear();
mockPosts = []; mockPosts = [];
mockProject = defaultMockProject; // Default to valid project
lastQueriedTable = null;
metaEngine = new MetaEngine(); metaEngine = new MetaEngine();
metaEngine.setProjectContext('test-project'); metaEngine.setProjectContext('test-project');
}); });
@@ -347,4 +375,197 @@ describe('MetaEngine', () => {
expect(handler).toHaveBeenCalledWith(expect.arrayContaining(['new-category'])); 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');
});
});
}); });