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
|
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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user