feat: meta data sync to files
This commit is contained in:
368
src/main/engine/MetaEngine.ts
Normal file
368
src/main/engine/MetaEngine.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user