feat: meta data sync to files
This commit is contained in:
23
VISION.md
23
VISION.md
@@ -18,6 +18,13 @@ The main area of the window must be a tabbled view, where multiple tabs can be o
|
||||
retained over program runs. The tabs can be different tabs like media file tabs, post tabs for multiple
|
||||
posts and setting tabs or whatever will come later.
|
||||
|
||||
Blog post metadata should be managed in the SQLite database in the user local folder, so it persists
|
||||
application runs properly. for blog posts, create a subfolder /posts/ there where each post is stored as a
|
||||
markdown file with a properties segment in the top of the file with YAML like property definitions, so all
|
||||
metadata can always be reconstructed from posts. Do the same with images, keeping them in /media/ under the
|
||||
user local path, in that case storing the image file sand for each image file a properties sidecar file that
|
||||
uses the same header structure as for posts.
|
||||
|
||||
The application must be offline-first, everything must work in airplane mode (except publishing of course).
|
||||
It must be fully self-contained during editing and previewing and managing content. Every internal structure
|
||||
must have reflections in the filesystem, so available tags, available categories, all those things must be
|
||||
@@ -30,20 +37,26 @@ to sync blog projects. This also should be on a per-project level and the UI sho
|
||||
git init and github auth with device flow properly when setting up git syncing. git syncing should also be
|
||||
handled asynchronously when online and should use auto-resolve of merge conflicts.
|
||||
|
||||
Blog post metadata should be managed in the SQLite database in the user local folder, so it persists application runs properly. for blog posts, create a subfolder /posts/ there where each post is stored as a markdown file with a properties segment in the top of the file with YAML like property definitions, so all metadata can always be reconstructed from posts. Do the same with images, keeping them in /media/ under the user local path, in that case storing the image file sand for each image file a properties sidecar file that uses the same header structure as for posts.
|
||||
|
||||
The application must be able to support multiple projects (ie web sites), so there must be a way to create
|
||||
new projects and select current project. The UI is only showing all data of the current selected project and
|
||||
all tools are only working against the selected project. I can imagine that projects will be separate
|
||||
databases and folders for post and media.
|
||||
|
||||
I want proper full-text search for posts based on the integrated sqlite database using fts5, so that I can quickly find posts. So build proper text search index update into the core model right away, so that regardless how posts come into the system, they are always properly indexed.
|
||||
I want proper full-text search for posts based on the integrated sqlite database using fts5, so that I can
|
||||
quickly find posts. So build proper text search index update into the core model right away, so that
|
||||
regardless how posts come into the system, they are always properly indexed.
|
||||
|
||||
Additionally bring in a good markdown library, because all posts will be formatted in markdown for easy portability to future systems. Media files can be attached to posts and can be referenced with standard markdown notation with a post-relative path, so it is easy for the user to include images. The post editor should support both a wysiwyg editor and raw markdown editor, so the user does not have to know markdown, but everything is handled by the editor, but for complex parts, markdown is available for power users.
|
||||
Additionally bring in a good markdown library, because all posts will be formatted in markdown for easy
|
||||
portability to future systems. Media files can be attached to posts and can be referenced with standard
|
||||
markdown notation with a post-relative path, so it is easy for the user to include images. The post editor
|
||||
should support both a wysiwyg editor and raw markdown editor, so the user does not have to know markdown,
|
||||
but everything is handled by the editor, but for complex parts, markdown is available for power users.
|
||||
|
||||
Integrate toasts as notification mechanism that will be used whenever anything has to communicate success/failure to the user.
|
||||
|
||||
Integrated images in posts should be shown with a lightbox effect als galleries when there are multiple photos, or just as single images with lightbox when there is only one. The wysiwyg editor should support this at least on a basic level.
|
||||
Integrated images in posts should be shown with a lightbox effect als galleries when there are multiple
|
||||
photos, or just as single images with lightbox when there is only one. The wysiwyg editor should support
|
||||
this at least on a basic level.
|
||||
|
||||
## Posting life-cycle
|
||||
|
||||
|
||||
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) {
|
||||
|
||||
350
tests/engine/MetaEngine.test.ts
Normal file
350
tests/engine/MetaEngine.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* MetaEngine Unit Tests
|
||||
*
|
||||
* Tests the REAL MetaEngine class with mocked dependencies.
|
||||
* Following TDD best practices: mock external dependencies, test real implementation.
|
||||
*
|
||||
* MetaEngine manages project metadata like available tags and categories,
|
||||
* keeping them in sync between the database (derived from posts) and
|
||||
* filesystem (meta/tags.json, meta/categories.json).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
// Mock data stores
|
||||
const mockFiles = new Map<string, string>();
|
||||
const mockDirs = new Set<string>();
|
||||
let mockPosts: any[] = [];
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(async (filePath: string) => {
|
||||
if (mockFiles.has(filePath)) {
|
||||
return mockFiles.get(filePath);
|
||||
}
|
||||
const err = new Error(`ENOENT: no such file or directory, open '${filePath}'`) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}),
|
||||
writeFile: vi.fn(async (filePath: string, content: string) => {
|
||||
mockFiles.set(filePath, content);
|
||||
}),
|
||||
mkdir: vi.fn(async (dirPath: string) => {
|
||||
mockDirs.add(dirPath);
|
||||
}),
|
||||
access: vi.fn(async (filePath: string) => {
|
||||
if (!mockFiles.has(filePath) && !mockDirs.has(filePath)) {
|
||||
const err = new Error(`ENOENT: no such file or directory, access '${filePath}'`) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock electron app
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/mock/userData'),
|
||||
},
|
||||
}));
|
||||
|
||||
// Create chainable mock for Drizzle ORM
|
||||
function createSelectChain() {
|
||||
return {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
all: vi.fn().mockImplementation(() => Promise.resolve(mockPosts)),
|
||||
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
|
||||
};
|
||||
}
|
||||
|
||||
const mockLocalDb = {
|
||||
select: vi.fn(() => createSelectChain()),
|
||||
};
|
||||
|
||||
// Mock the database module
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
getLocal: vi.fn(() => mockLocalDb),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { MetaEngine } from '../../src/main/engine/MetaEngine';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
describe('MetaEngine', () => {
|
||||
let metaEngine: MetaEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFiles.clear();
|
||||
mockDirs.clear();
|
||||
mockPosts = [];
|
||||
metaEngine = new MetaEngine();
|
||||
metaEngine.setProjectContext('test-project');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Project Context', () => {
|
||||
it('should set and get project context', () => {
|
||||
metaEngine.setProjectContext('my-project');
|
||||
expect(metaEngine.getProjectContext()).toBe('my-project');
|
||||
});
|
||||
|
||||
it('should return correct meta directory path', () => {
|
||||
metaEngine.setProjectContext('blog-project');
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
expect(metaDir).toContain('projects');
|
||||
expect(metaDir).toContain('blog-project');
|
||||
expect(metaDir).toContain('meta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tags Management', () => {
|
||||
it('should return empty array when no tags exist', async () => {
|
||||
const tags = await metaEngine.getTags();
|
||||
expect(tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('should add a new tag', async () => {
|
||||
await metaEngine.addTag('javascript');
|
||||
const tags = await metaEngine.getTags();
|
||||
expect(tags).toContain('javascript');
|
||||
});
|
||||
|
||||
it('should not add duplicate tags', async () => {
|
||||
await metaEngine.addTag('typescript');
|
||||
await metaEngine.addTag('typescript');
|
||||
const tags = await metaEngine.getTags();
|
||||
expect(tags.filter(t => t === 'typescript')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should remove a tag', async () => {
|
||||
await metaEngine.addTag('react');
|
||||
await metaEngine.addTag('vue');
|
||||
await metaEngine.removeTag('react');
|
||||
const tags = await metaEngine.getTags();
|
||||
expect(tags).not.toContain('react');
|
||||
expect(tags).toContain('vue');
|
||||
});
|
||||
|
||||
it('should persist tags to filesystem', async () => {
|
||||
await metaEngine.addTag('node');
|
||||
await metaEngine.saveTags();
|
||||
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
const tagsPath = `${metaDir}\\tags.json`;
|
||||
expect(mockFiles.has(tagsPath) || mockFiles.has(tagsPath.replace(/\\/g, '/'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should load tags from filesystem', async () => {
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
const tagsPath = `${metaDir}\\tags.json`;
|
||||
mockFiles.set(tagsPath, JSON.stringify(['saved-tag-1', 'saved-tag-2']));
|
||||
|
||||
await metaEngine.loadTags();
|
||||
const tags = await metaEngine.getTags();
|
||||
expect(tags).toContain('saved-tag-1');
|
||||
expect(tags).toContain('saved-tag-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Categories Management', () => {
|
||||
it('should return empty array when no categories exist', async () => {
|
||||
const categories = await metaEngine.getCategories();
|
||||
expect(categories).toEqual([]);
|
||||
});
|
||||
|
||||
it('should add a new category', async () => {
|
||||
await metaEngine.addCategory('tutorials');
|
||||
const categories = await metaEngine.getCategories();
|
||||
expect(categories).toContain('tutorials');
|
||||
});
|
||||
|
||||
it('should not add duplicate categories', async () => {
|
||||
await metaEngine.addCategory('news');
|
||||
await metaEngine.addCategory('news');
|
||||
const categories = await metaEngine.getCategories();
|
||||
expect(categories.filter(c => c === 'news')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should remove a category', async () => {
|
||||
await metaEngine.addCategory('reviews');
|
||||
await metaEngine.addCategory('guides');
|
||||
await metaEngine.removeCategory('reviews');
|
||||
const categories = await metaEngine.getCategories();
|
||||
expect(categories).not.toContain('reviews');
|
||||
expect(categories).toContain('guides');
|
||||
});
|
||||
|
||||
it('should persist categories to filesystem', async () => {
|
||||
await metaEngine.addCategory('tech');
|
||||
await metaEngine.saveCategories();
|
||||
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
const catPath = `${metaDir}\\categories.json`;
|
||||
expect(mockFiles.has(catPath) || mockFiles.has(catPath.replace(/\\/g, '/'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should load categories from filesystem', async () => {
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
const catPath = `${metaDir}\\categories.json`;
|
||||
mockFiles.set(catPath, JSON.stringify(['cat-1', 'cat-2']));
|
||||
|
||||
await metaEngine.loadCategories();
|
||||
const categories = await metaEngine.getCategories();
|
||||
expect(categories).toContain('cat-1');
|
||||
expect(categories).toContain('cat-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sync on Startup', () => {
|
||||
it('should export tags from posts to file if file does not exist', async () => {
|
||||
// Setup: posts have tags but no meta file exists
|
||||
mockPosts = [
|
||||
{ tags: JSON.stringify(['tag1', 'tag2']) },
|
||||
{ tags: JSON.stringify(['tag2', 'tag3']) },
|
||||
];
|
||||
|
||||
await metaEngine.syncOnStartup();
|
||||
|
||||
const tags = await metaEngine.getTags();
|
||||
expect(tags).toContain('tag1');
|
||||
expect(tags).toContain('tag2');
|
||||
expect(tags).toContain('tag3');
|
||||
});
|
||||
|
||||
it('should export categories from posts to file if file does not exist', async () => {
|
||||
mockPosts = [
|
||||
{ categories: JSON.stringify(['cat1', 'cat2']) },
|
||||
{ categories: JSON.stringify(['cat2', 'cat3']) },
|
||||
];
|
||||
|
||||
await metaEngine.syncOnStartup();
|
||||
|
||||
const categories = await metaEngine.getCategories();
|
||||
expect(categories).toContain('cat1');
|
||||
expect(categories).toContain('cat2');
|
||||
expect(categories).toContain('cat3');
|
||||
});
|
||||
|
||||
it('should merge file tags with database tags', async () => {
|
||||
// File has some tags
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
mockFiles.set(`${metaDir}\\tags.json`, JSON.stringify(['file-tag']));
|
||||
|
||||
// Posts have different tags
|
||||
mockPosts = [
|
||||
{ tags: JSON.stringify(['db-tag']) },
|
||||
];
|
||||
|
||||
await metaEngine.syncOnStartup();
|
||||
|
||||
const tags = await metaEngine.getTags();
|
||||
expect(tags).toContain('file-tag');
|
||||
expect(tags).toContain('db-tag');
|
||||
});
|
||||
|
||||
it('should merge file categories with database categories', async () => {
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
mockFiles.set(`${metaDir}\\categories.json`, JSON.stringify(['file-cat']));
|
||||
|
||||
mockPosts = [
|
||||
{ categories: JSON.stringify(['db-cat']) },
|
||||
];
|
||||
|
||||
await metaEngine.syncOnStartup();
|
||||
|
||||
const categories = await metaEngine.getCategories();
|
||||
expect(categories).toContain('file-cat');
|
||||
expect(categories).toContain('db-cat');
|
||||
});
|
||||
|
||||
it('should create meta directory if it does not exist', async () => {
|
||||
mockPosts = [{ tags: JSON.stringify(['test']), categories: JSON.stringify([]) }];
|
||||
|
||||
await metaEngine.syncOnStartup();
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save merged results back to file', async () => {
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
mockFiles.set(`${metaDir}\\tags.json`, JSON.stringify(['existing']));
|
||||
mockPosts = [{ tags: JSON.stringify(['new-from-db']), categories: JSON.stringify([]) }];
|
||||
|
||||
await metaEngine.syncOnStartup();
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tags/Categories from Posts', () => {
|
||||
it('should collect all unique tags from posts', async () => {
|
||||
mockPosts = [
|
||||
{ tags: JSON.stringify(['a', 'b']) },
|
||||
{ tags: JSON.stringify(['b', 'c']) },
|
||||
{ tags: JSON.stringify(['c', 'd']) },
|
||||
];
|
||||
|
||||
const tags = await metaEngine.collectTagsFromPosts();
|
||||
expect(tags).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('should collect all unique categories from posts', async () => {
|
||||
mockPosts = [
|
||||
{ categories: JSON.stringify(['x', 'y']) },
|
||||
{ categories: JSON.stringify(['y', 'z']) },
|
||||
];
|
||||
|
||||
const categories = await metaEngine.collectCategoriesFromPosts();
|
||||
expect(categories).toEqual(['x', 'y', 'z']);
|
||||
});
|
||||
|
||||
it('should handle posts with null/empty tags', async () => {
|
||||
mockPosts = [
|
||||
{ tags: null },
|
||||
{ tags: '' },
|
||||
{ tags: JSON.stringify(['valid']) },
|
||||
];
|
||||
|
||||
const tags = await metaEngine.collectTagsFromPosts();
|
||||
expect(tags).toEqual(['valid']);
|
||||
});
|
||||
|
||||
it('should handle posts with null/empty categories', async () => {
|
||||
mockPosts = [
|
||||
{ categories: null },
|
||||
{ categories: '' },
|
||||
{ categories: JSON.stringify(['valid']) },
|
||||
];
|
||||
|
||||
const categories = await metaEngine.collectCategoriesFromPosts();
|
||||
expect(categories).toEqual(['valid']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Emission', () => {
|
||||
it('should emit tagsChanged event when tags are modified', async () => {
|
||||
const handler = vi.fn();
|
||||
metaEngine.on('tagsChanged', handler);
|
||||
|
||||
await metaEngine.addTag('new-tag');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.arrayContaining(['new-tag']));
|
||||
});
|
||||
|
||||
it('should emit categoriesChanged event when categories are modified', async () => {
|
||||
const handler = vi.fn();
|
||||
metaEngine.on('categoriesChanged', handler);
|
||||
|
||||
await metaEngine.addCategory('new-category');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.arrayContaining(['new-category']));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user