feat: meta data sync to files

This commit is contained in:
2026-02-11 08:54:19 +01:00
parent f4ff91180d
commit 4e2f6d4d08
7 changed files with 851 additions and 22 deletions

View File

@@ -0,0 +1,368 @@
import { EventEmitter } from 'events';
import * as fs from 'fs/promises';
import * as path from 'path';
import { app } from 'electron';
import { eq } from 'drizzle-orm';
import { getDatabase } from '../database';
import { posts } from '../database/schema';
/**
* MetaEngine manages project metadata like available tags and categories.
*
* It keeps metadata in sync between:
* - The database (derived from posts)
* - The filesystem (meta/tags.json, meta/categories.json)
*
* This enables offline-first operation where all metadata is available
* from the local filesystem per project.
*/
export class MetaEngine extends EventEmitter {
private currentProjectId: string = 'default';
private tags: Set<string> = new Set();
private categories: Set<string> = new Set();
private initialized: boolean = false;
constructor() {
super();
}
/**
* Get the meta directory path for the current project.
* Format: {userData}/projects/{projectId}/meta/
*/
getMetaDir(): string {
const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'projects', this.currentProjectId, 'meta');
}
private getTagsFilePath(): string {
return path.join(this.getMetaDir(), 'tags.json');
}
private getCategoriesFilePath(): string {
return path.join(this.getMetaDir(), 'categories.json');
}
setProjectContext(projectId: string): void {
this.currentProjectId = projectId;
// Reset in-memory cache when project changes
this.tags.clear();
this.categories.clear();
this.initialized = false;
}
getProjectContext(): string {
return this.currentProjectId;
}
/**
* Get all available tags.
*/
async getTags(): Promise<string[]> {
return Array.from(this.tags).sort();
}
/**
* Get all available categories.
*/
async getCategories(): Promise<string[]> {
return Array.from(this.categories).sort();
}
/**
* Add a new tag to the available tags list.
*/
async addTag(tag: string): Promise<void> {
const normalizedTag = tag.trim().toLowerCase();
if (normalizedTag && !this.tags.has(normalizedTag)) {
this.tags.add(normalizedTag);
this.emit('tagsChanged', await this.getTags());
await this.saveTags();
}
}
/**
* Remove a tag from the available tags list.
*/
async removeTag(tag: string): Promise<void> {
const normalizedTag = tag.trim().toLowerCase();
if (this.tags.delete(normalizedTag)) {
this.emit('tagsChanged', await this.getTags());
await this.saveTags();
}
}
/**
* Add a new category to the available categories list.
*/
async addCategory(category: string): Promise<void> {
const normalizedCategory = category.trim().toLowerCase();
if (normalizedCategory && !this.categories.has(normalizedCategory)) {
this.categories.add(normalizedCategory);
this.emit('categoriesChanged', await this.getCategories());
await this.saveCategories();
}
}
/**
* Remove a category from the available categories list.
*/
async removeCategory(category: string): Promise<void> {
const normalizedCategory = category.trim().toLowerCase();
if (this.categories.delete(normalizedCategory)) {
this.emit('categoriesChanged', await this.getCategories());
await this.saveCategories();
}
}
/**
* Save tags to the filesystem.
*/
async saveTags(): Promise<void> {
try {
await this.ensureMetaDirExists();
const filePath = this.getTagsFilePath();
const content = JSON.stringify(Array.from(this.tags).sort(), null, 2);
await fs.writeFile(filePath, content, 'utf-8');
} catch (error) {
console.error('[MetaEngine] Failed to save tags:', error);
throw error;
}
}
/**
* Save categories to the filesystem.
*/
async saveCategories(): Promise<void> {
try {
await this.ensureMetaDirExists();
const filePath = this.getCategoriesFilePath();
const content = JSON.stringify(Array.from(this.categories).sort(), null, 2);
await fs.writeFile(filePath, content, 'utf-8');
} catch (error) {
console.error('[MetaEngine] Failed to save categories:', error);
throw error;
}
}
/**
* Load tags from the filesystem.
*/
async loadTags(): Promise<void> {
try {
const filePath = this.getTagsFilePath();
const content = await fs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(content) as string[];
this.tags.clear();
for (const tag of parsed) {
this.tags.add(tag.trim().toLowerCase());
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[MetaEngine] Failed to load tags:', error);
throw error;
}
// File doesn't exist, that's OK
}
}
/**
* Load categories from the filesystem.
*/
async loadCategories(): Promise<void> {
try {
const filePath = this.getCategoriesFilePath();
const content = await fs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(content) as string[];
this.categories.clear();
for (const cat of parsed) {
this.categories.add(cat.trim().toLowerCase());
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[MetaEngine] Failed to load categories:', error);
throw error;
}
// File doesn't exist, that's OK
}
}
/**
* Collect all unique tags from posts in the database.
*/
async collectTagsFromPosts(): Promise<string[]> {
const db = getDatabase().getLocal();
const dbPosts = await db
.select({ tags: posts.tags })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId))
.all();
const allTags = new Set<string>();
for (const row of dbPosts) {
if (row.tags) {
try {
const parsed: string[] = JSON.parse(row.tags);
for (const tag of parsed) {
allTags.add(tag.trim().toLowerCase());
}
} catch {
// Invalid JSON, skip
}
}
}
return Array.from(allTags).sort();
}
/**
* Collect all unique categories from posts in the database.
*/
async collectCategoriesFromPosts(): Promise<string[]> {
const db = getDatabase().getLocal();
const dbPosts = await db
.select({ categories: posts.categories })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId))
.all();
const allCategories = new Set<string>();
for (const row of dbPosts) {
if (row.categories) {
try {
const parsed: string[] = JSON.parse(row.categories);
for (const cat of parsed) {
allCategories.add(cat.trim().toLowerCase());
}
} catch {
// Invalid JSON, skip
}
}
}
return Array.from(allCategories).sort();
}
/**
* Ensure the meta directory exists.
*/
private async ensureMetaDirExists(): Promise<void> {
const metaDir = this.getMetaDir();
try {
await fs.access(metaDir);
} catch {
await fs.mkdir(metaDir, { recursive: true });
}
}
/**
* Check if a file exists.
*/
private async fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Sync tags and categories on startup.
*
* Logic:
* 1. If files don't exist: export from database (posts) to files
* 2. If files exist: read from files, merge with database, save any changes
*/
async syncOnStartup(): Promise<void> {
console.log(`[MetaEngine] Syncing metadata for project: ${this.currentProjectId}`);
await this.ensureMetaDirExists();
const tagsFilePath = this.getTagsFilePath();
const categoriesFilePath = this.getCategoriesFilePath();
const tagsFileExists = await this.fileExists(tagsFilePath);
const categoriesFileExists = await this.fileExists(categoriesFilePath);
// Collect tags/categories from database (posts)
const dbTags = await this.collectTagsFromPosts();
const dbCategories = await this.collectCategoriesFromPosts();
// Handle tags
if (tagsFileExists) {
// Load from file
await this.loadTags();
const fileTags = new Set(this.tags);
// Merge: add any tags from DB that aren't in file
let changed = false;
for (const tag of dbTags) {
if (!fileTags.has(tag)) {
this.tags.add(tag);
changed = true;
}
}
// Save if there were changes
if (changed) {
await this.saveTags();
}
} else {
// No file exists, create from database
this.tags.clear();
for (const tag of dbTags) {
this.tags.add(tag);
}
await this.saveTags();
}
// Handle categories
if (categoriesFileExists) {
// Load from file
await this.loadCategories();
const fileCategories = new Set(this.categories);
// Merge: add any categories from DB that aren't in file
let changed = false;
for (const cat of dbCategories) {
if (!fileCategories.has(cat)) {
this.categories.add(cat);
changed = true;
}
}
// Save if there were changes
if (changed) {
await this.saveCategories();
}
} else {
// No file exists, create from database
this.categories.clear();
for (const cat of dbCategories) {
this.categories.add(cat);
}
await this.saveCategories();
}
this.initialized = true;
console.log(`[MetaEngine] Sync complete. Tags: ${this.tags.size}, Categories: ${this.categories.size}`);
}
/**
* Check if the engine has been initialized (synced on startup).
*/
isInitialized(): boolean {
return this.initialized;
}
}
// Singleton instance
let metaEngineInstance: MetaEngine | null = null;
export function getMetaEngine(): MetaEngine {
if (!metaEngineInstance) {
metaEngineInstance = new MetaEngine();
}
return metaEngineInstance;
}

View File

@@ -3,6 +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 {
DropboxSyncEngine,
getDropboxSyncEngine,

View File

@@ -5,6 +5,7 @@ import { getMediaEngine, MediaData } from '../engine/MediaEngine';
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
import { getDropboxSyncEngine, DropboxSyncConfig, ConflictResolution } from '../engine/DropboxSyncEngine';
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
import { getMetaEngine } from '../engine/MetaEngine';
import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database';
import { media } from '../database/schema';
@@ -41,12 +42,17 @@ export function registerIpcHandlers(): void {
const projectEngine = getProjectEngine();
const project = await projectEngine.getActiveProject();
// Ensure post and media engines have the correct project context
// Ensure all engines have the correct project context
if (project) {
const postEngine = getPostEngine();
const mediaEngine = getMediaEngine();
const metaEngine = getMetaEngine();
postEngine.setProjectContext(project.id);
mediaEngine.setProjectContext(project.id);
metaEngine.setProjectContext(project.id);
// Sync meta on startup
await metaEngine.syncOnStartup();
}
return project;
@@ -56,12 +62,17 @@ export function registerIpcHandlers(): void {
const projectEngine = getProjectEngine();
const project = await projectEngine.setActiveProject(id);
// Update post and media engines to use the new project context
// Update all engines to use the new project context
if (project) {
const postEngine = getPostEngine();
const mediaEngine = getMediaEngine();
const metaEngine = getMetaEngine();
postEngine.setProjectContext(project.id);
mediaEngine.setProjectContext(project.id);
metaEngine.setProjectContext(project.id);
// Sync meta on project switch
await metaEngine.syncOnStartup();
}
return project;
@@ -457,6 +468,51 @@ export function registerIpcHandlers(): void {
return shell.showItemInFolder(itemPath);
});
// ============ Meta Handlers ============
ipcMain.handle('meta:getTags', async () => {
const engine = getMetaEngine();
return engine.getTags();
});
ipcMain.handle('meta:getCategories', async () => {
const engine = getMetaEngine();
return engine.getCategories();
});
ipcMain.handle('meta:addTag', async (_, tag: string) => {
const engine = getMetaEngine();
await engine.addTag(tag);
return engine.getTags();
});
ipcMain.handle('meta:removeTag', async (_, tag: string) => {
const engine = getMetaEngine();
await engine.removeTag(tag);
return engine.getTags();
});
ipcMain.handle('meta:addCategory', async (_, category: string) => {
const engine = getMetaEngine();
await engine.addCategory(category);
return engine.getCategories();
});
ipcMain.handle('meta:removeCategory', async (_, category: string) => {
const engine = getMetaEngine();
await engine.removeCategory(category);
return engine.getCategories();
});
ipcMain.handle('meta:syncOnStartup', async () => {
const engine = getMetaEngine();
await engine.syncOnStartup();
return {
tags: await engine.getTags(),
categories: await engine.getCategories(),
};
});
// ============ Event Forwarding ============
// Forward engine events to renderer
@@ -464,6 +520,7 @@ export function registerIpcHandlers(): void {
const mediaEngine = getMediaEngine();
const syncEngine = getSyncEngine();
const projectEngine = getProjectEngine();
const metaEngine = getMetaEngine();
const forwardEvent = (eventName: string) => {
return (...args: unknown[]) => {
@@ -489,6 +546,9 @@ export function registerIpcHandlers(): void {
mediaEngine.on('rebuildStarted', forwardEvent('media:rebuildStarted'));
mediaEngine.on('databaseRebuilt', forwardEvent('media:databaseRebuilt'));
metaEngine.on('tagsChanged', forwardEvent('meta:tagsChanged'));
metaEngine.on('categoriesChanged', forwardEvent('meta:categoriesChanged'));
syncEngine.on('syncStarted', forwardEvent('sync:started'));
syncEngine.on('syncCompleted', forwardEvent('sync:completed'));
syncEngine.on('syncFailed', forwardEvent('sync:failed'));

View File

@@ -99,6 +99,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),
},
// Meta (tags and categories)
meta: {
getTags: () => ipcRenderer.invoke('meta:getTags'),
getCategories: () => ipcRenderer.invoke('meta:getCategories'),
addTag: (tag: string) => ipcRenderer.invoke('meta:addTag', tag),
removeTag: (tag: string) => ipcRenderer.invoke('meta:removeTag', tag),
addCategory: (category: string) => ipcRenderer.invoke('meta:addCategory', category),
removeCategory: (category: string) => ipcRenderer.invoke('meta:removeCategory', category),
syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'),
},
// Event listeners
on: (channel: string, callback: (...args: unknown[]) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args);
@@ -186,6 +197,15 @@ export interface ElectronAPI {
openFolder: (folderPath: string) => Promise<string>;
showItemInFolder: (itemPath: string) => Promise<void>;
};
meta: {
getTags: () => Promise<string[]>;
getCategories: () => Promise<string[]>;
addTag: (tag: string) => Promise<string[]>;
removeTag: (tag: string) => Promise<string[]>;
addCategory: (category: string) => Promise<string[]>;
removeCategory: (category: string) => Promise<string[]>;
syncOnStartup: () => Promise<{ tags: string[]; categories: string[] }>;
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void;
}

View File

@@ -955,12 +955,34 @@ export const Editor: React.FC = () => {
selectedPostId,
selectedMediaId,
posts,
media,
errorModal,
hideErrorModal,
isLoading,
setSelectedPost,
setSelectedMedia,
} = useAppStore();
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
useEffect(() => {
if (activeView === 'posts' && selectedPostId && !isLoading) {
const postExists = posts.some(p => p.id === selectedPostId);
if (!postExists) {
setSelectedPost(null);
}
}
}, [activeView, selectedPostId, posts, isLoading, setSelectedPost]);
// Clear selectedMediaId if the media doesn't exist (e.g., after project switch)
useEffect(() => {
if (activeView === 'media' && selectedMediaId && !isLoading) {
const mediaExists = media.some(m => m.id === selectedMediaId);
if (!mediaExists) {
setSelectedMedia(null);
}
}
}, [activeView, selectedMediaId, media, isLoading, setSelectedMedia]);
// Show error modal if present
const renderErrorModal = () => (
<ErrorModal error={errorModal} onClose={hideErrorModal} />
@@ -986,22 +1008,17 @@ export const Editor: React.FC = () => {
);
}
// Post not found - show loading if still loading, otherwise clear selection
if (isLoading) {
return (
<>
<div className="editor-empty">
<div className="welcome-content">
<p className="text-muted">Loading post...</p>
</div>
// Post not found - show loading or empty state while useEffect clears selection
return (
<>
<div className="editor-empty">
<div className="welcome-content">
<p className="text-muted">{isLoading ? 'Loading post...' : ''}</p>
</div>
{renderErrorModal()}
</>
);
}
// Post truly not found - clear selection and fall through to welcome screen
setSelectedPost(null);
</div>
{renderErrorModal()}
</>
);
}
if (activeView === 'media' && selectedMediaId) {