sync project settings too
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string> = new Set();
|
||||
private categories: Set<string> = 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<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.
|
||||
*/
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
const mockFiles = new Map<string, string>();
|
||||
const mockDirs = new Set<string>();
|
||||
let mockPosts: any[] = [];
|
||||
let mockProject: any = null;
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
@@ -49,13 +50,27 @@ vi.mock('electron', () => ({
|
||||
}));
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user