feat: reworked project location
This commit is contained in:
@@ -33,6 +33,7 @@ export const DEFAULT_CATEGORIES = ['article', 'picture', 'aside', 'page'];
|
||||
*/
|
||||
export class MetaEngine extends EventEmitter {
|
||||
private currentProjectId: string = 'default';
|
||||
private dataDir: string | null = null; // Custom data directory (null = use internal userData)
|
||||
private tags: Set<string> = new Set();
|
||||
private categories: Set<string> = new Set();
|
||||
private projectMetadata: ProjectMetadata | null = null;
|
||||
@@ -43,24 +44,27 @@ export class MetaEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Always returns the internal project directory (in userData).
|
||||
* Meta files never live in an external dataPath.
|
||||
* Returns the default internal project directory (in userData).
|
||||
*/
|
||||
private getInternalBaseDir(): string {
|
||||
private getDefaultBaseDir(): string {
|
||||
const userDataPath = app.getPath('userData');
|
||||
return path.join(userDataPath, 'projects', this.currentProjectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the meta directory path for the current project.
|
||||
* Always in the internal directory (userData), never external.
|
||||
* Returns the base directory for project data.
|
||||
* If a custom dataDir is set, uses that; otherwise uses internal userData.
|
||||
*/
|
||||
getMetaDir(): string {
|
||||
return path.join(this.getInternalBaseDir(), 'meta');
|
||||
private getBaseDir(): string {
|
||||
return this.dataDir || this.getDefaultBaseDir();
|
||||
}
|
||||
|
||||
private getTagsFilePath(): string {
|
||||
return path.join(this.getMetaDir(), 'tags.json');
|
||||
/**
|
||||
* Get the meta directory path for the current project.
|
||||
* Uses custom dataDir if set, otherwise internal userData.
|
||||
*/
|
||||
getMetaDir(): string {
|
||||
return path.join(this.getBaseDir(), 'meta');
|
||||
}
|
||||
|
||||
private getCategoriesFilePath(): string {
|
||||
@@ -71,8 +75,9 @@ export class MetaEngine extends EventEmitter {
|
||||
return path.join(this.getMetaDir(), 'project.json');
|
||||
}
|
||||
|
||||
setProjectContext(projectId: string): void {
|
||||
setProjectContext(projectId: string, dataDir?: string): void {
|
||||
this.currentProjectId = projectId;
|
||||
this.dataDir = dataDir || null;
|
||||
// Reset in-memory cache when project changes
|
||||
this.tags.clear();
|
||||
this.categories.clear();
|
||||
@@ -134,25 +139,25 @@ export class MetaEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new tag to the available tags list.
|
||||
* Add a new tag to the available tags list (in-memory only).
|
||||
* Note: Tag persistence is handled by TagEngine.
|
||||
*/
|
||||
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.
|
||||
* Remove a tag from the available tags list (in-memory only).
|
||||
* Note: Tag persistence is handled by TagEngine.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,21 +184,6 @@ export class MetaEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -243,27 +233,6 @@ export class MetaEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -383,19 +352,18 @@ export class MetaEngine extends EventEmitter {
|
||||
* 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
|
||||
* - Tags: populated from posts (TagEngine handles persistence with colors)
|
||||
* - Categories: read from file, merge with database
|
||||
* - Project metadata: read from file or create from database
|
||||
*/
|
||||
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 projectMetadataFilePath = this.getProjectMetadataFilePath();
|
||||
|
||||
const tagsFileExists = await this.fileExists(tagsFilePath);
|
||||
const categoriesFileExists = await this.fileExists(categoriesFilePath);
|
||||
const projectMetadataFileExists = await this.fileExists(projectMetadataFilePath);
|
||||
|
||||
@@ -403,32 +371,10 @@ export class MetaEngine extends EventEmitter {
|
||||
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 tags - just populate from posts, TagEngine handles persistence
|
||||
this.tags.clear();
|
||||
for (const tag of dbTags) {
|
||||
this.tags.add(tag);
|
||||
}
|
||||
|
||||
// Handle categories
|
||||
|
||||
@@ -58,15 +58,16 @@ export class ProjectEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
private async ensureProjectDirectories(projectId: string, dataPath?: string | null): Promise<void> {
|
||||
// Internal directories (always in userData)
|
||||
const internalDir = this.getInternalBaseDir(projectId);
|
||||
await fs.mkdir(path.join(internalDir, 'thumbnails'), { recursive: true });
|
||||
await fs.mkdir(path.join(internalDir, 'meta'), { recursive: true });
|
||||
|
||||
// Data directories (may be external)
|
||||
// Determine base directory for all project data:
|
||||
// - If custom dataPath is provided, all project data lives there (allows cloud storage backup)
|
||||
// - If no dataPath (default project), use internal userData storage
|
||||
const dataDir = this.getDataDir(projectId, dataPath);
|
||||
|
||||
// Create all project directories in the data directory
|
||||
await fs.mkdir(path.join(dataDir, 'posts'), { recursive: true });
|
||||
await fs.mkdir(path.join(dataDir, 'media'), { recursive: true });
|
||||
await fs.mkdir(path.join(dataDir, 'meta'), { recursive: true });
|
||||
await fs.mkdir(path.join(dataDir, 'thumbnails'), { recursive: true });
|
||||
}
|
||||
|
||||
async createProject(data: { name: string; description?: string; slug?: string; dataPath?: string }): Promise<ProjectData> {
|
||||
|
||||
@@ -99,6 +99,15 @@ function isValidHexColor(color: string): boolean {
|
||||
return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Portable tag format for filesystem serialization.
|
||||
* Only stores name and optional color - no internal IDs.
|
||||
*/
|
||||
interface SerializedTag {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TagEngine manages tag metadata and operations.
|
||||
*
|
||||
@@ -110,25 +119,34 @@ function isValidHexColor(color: string): boolean {
|
||||
*/
|
||||
export class TagEngine extends EventEmitter {
|
||||
private currentProjectId: string = 'default';
|
||||
private dataDir: string | null = null; // Custom data directory (null = use internal userData)
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Always returns the internal project directory (in userData).
|
||||
* Tag metadata never lives in an external dataPath.
|
||||
* Returns the default internal project directory (in userData).
|
||||
*/
|
||||
private getInternalBaseDir(): string {
|
||||
private getDefaultBaseDir(): string {
|
||||
const userDataPath = app.getPath('userData');
|
||||
return path.join(userDataPath, 'projects', this.currentProjectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base directory for project data.
|
||||
* If a custom dataDir is set, uses that; otherwise uses internal userData.
|
||||
*/
|
||||
private getBaseDir(): string {
|
||||
return this.dataDir || this.getDefaultBaseDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current project context
|
||||
*/
|
||||
setProjectContext(projectId: string): void {
|
||||
setProjectContext(projectId: string, dataDir?: string): void {
|
||||
this.currentProjectId = projectId;
|
||||
this.dataDir = dataDir || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +160,7 @@ export class TagEngine extends EventEmitter {
|
||||
* Get the tags file path for filesystem persistence
|
||||
*/
|
||||
private getTagsFilePath(): string {
|
||||
return path.join(this.getInternalBaseDir(), 'meta', 'tags-metadata.json');
|
||||
return path.join(this.getBaseDir(), 'meta', 'tags.json');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -725,7 +743,8 @@ export class TagEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save tags metadata to filesystem for sync
|
||||
* Save tags to filesystem in portable format (no internal IDs).
|
||||
* Format: [{ name: "tag", color?: "#hex" }, ...]
|
||||
*/
|
||||
private async saveTagsToFile(): Promise<void> {
|
||||
try {
|
||||
@@ -733,43 +752,61 @@ export class TagEngine extends EventEmitter {
|
||||
const filePath = this.getTagsFilePath();
|
||||
const dir = path.dirname(filePath);
|
||||
|
||||
// Serialize to portable format - only name and optional color
|
||||
const serialized: SerializedTag[] = tags.map(tag => {
|
||||
const entry: SerializedTag = { name: tag.name };
|
||||
if (tag.color) {
|
||||
entry.color = tag.color;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(filePath, JSON.stringify(tags, null, 2), 'utf-8');
|
||||
await fs.writeFile(filePath, JSON.stringify(serialized, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('[TagEngine] Failed to save tags to file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tags from filesystem (for initial sync)
|
||||
* Load tags from filesystem (for initial sync).
|
||||
* Handles both new portable format and legacy format with IDs.
|
||||
*/
|
||||
async loadTagsFromFile(): Promise<void> {
|
||||
try {
|
||||
const filePath = this.getTagsFilePath();
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const tags: TagData[] = JSON.parse(content);
|
||||
const rawTags: any[] = JSON.parse(content);
|
||||
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return;
|
||||
|
||||
for (const tag of tags) {
|
||||
// Check if tag exists
|
||||
const now = Date.now();
|
||||
|
||||
for (const tag of rawTags) {
|
||||
// Support both portable format { name, color? } and legacy format with id
|
||||
const name = (tag.name || '').trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
|
||||
const color = tag.color || null;
|
||||
|
||||
// Check if tag with this name already exists
|
||||
const existing = await client.execute({
|
||||
sql: 'SELECT id FROM tags WHERE id = ? AND project_id = ?',
|
||||
args: [tag.id, this.currentProjectId],
|
||||
sql: 'SELECT id FROM tags WHERE project_id = ? AND LOWER(name) = LOWER(?)',
|
||||
args: [this.currentProjectId, name],
|
||||
});
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
// Create new tag with fresh ID
|
||||
await client.execute({
|
||||
sql: 'INSERT INTO tags (id, project_id, name, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
args: [
|
||||
tag.id,
|
||||
this.currentProjectId,
|
||||
tag.name,
|
||||
tag.color || null,
|
||||
tag.createdAt instanceof Date ? tag.createdAt.getTime() : tag.createdAt,
|
||||
tag.updatedAt instanceof Date ? tag.updatedAt.getTime() : tag.updatedAt,
|
||||
],
|
||||
args: [uuidv4(), this.currentProjectId, name, color, now, now],
|
||||
});
|
||||
} else if (color) {
|
||||
// Update color if provided and tag exists
|
||||
await client.execute({
|
||||
sql: 'UPDATE tags SET color = ?, updated_at = ? WHERE project_id = ? AND LOWER(name) = LOWER(?)',
|
||||
args: [color, now, this.currentProjectId, name],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ipcMain, dialog, shell } from 'electron';
|
||||
import * as path from 'path';
|
||||
import * as fsPromises from 'fs/promises';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getPostEngine, PostData, PostFilter, PaginationOptions } from '../engine/PostEngine';
|
||||
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
||||
@@ -32,7 +34,7 @@ function safeHandle(channel: string, handler: (...args: any[]) => Promise<any>):
|
||||
export function registerIpcHandlers(): void {
|
||||
// ============ Project Handlers ============
|
||||
|
||||
safeHandle('projects:create', async (_, data: { name: string; description?: string; slug?: string }) => {
|
||||
safeHandle('projects:create', async (_, data: { name: string; description?: string; slug?: string; dataPath?: string }) => {
|
||||
const engine = getProjectEngine();
|
||||
return engine.createProject(data);
|
||||
});
|
||||
@@ -68,16 +70,17 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
// Ensure all engines have the correct project context
|
||||
if (project) {
|
||||
const internalDir = projectEngine.getInternalBaseDir(project.id);
|
||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||
// For thumbnails and meta: use dataDir (whether custom or internal)
|
||||
// This ensures all project data lives in the same location for backup
|
||||
const postEngine = getPostEngine();
|
||||
const mediaEngine = getMediaEngine();
|
||||
const metaEngine = getMetaEngine();
|
||||
const tagEngine = getTagEngine();
|
||||
postEngine.setProjectContext(project.id, dataDir);
|
||||
mediaEngine.setProjectContext(project.id, dataDir, internalDir);
|
||||
metaEngine.setProjectContext(project.id);
|
||||
tagEngine.setProjectContext(project.id);
|
||||
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||
metaEngine.setProjectContext(project.id, dataDir);
|
||||
tagEngine.setProjectContext(project.id, dataDir);
|
||||
const postMediaEngine = getPostMediaEngine();
|
||||
postMediaEngine.setProjectContext(project.id);
|
||||
|
||||
@@ -94,16 +97,17 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
// Update all engines to use the new project context
|
||||
if (project) {
|
||||
const internalDir = projectEngine.getInternalBaseDir(project.id);
|
||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||
// For thumbnails and meta: use dataDir (whether custom or internal)
|
||||
// This ensures all project data lives in the same location for backup
|
||||
const postEngine = getPostEngine();
|
||||
const mediaEngine = getMediaEngine();
|
||||
const metaEngine = getMetaEngine();
|
||||
const tagEngine = getTagEngine();
|
||||
postEngine.setProjectContext(project.id, dataDir);
|
||||
mediaEngine.setProjectContext(project.id, dataDir, internalDir);
|
||||
metaEngine.setProjectContext(project.id);
|
||||
tagEngine.setProjectContext(project.id);
|
||||
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||
metaEngine.setProjectContext(project.id, dataDir);
|
||||
tagEngine.setProjectContext(project.id, dataDir);
|
||||
const postMediaEngine = getPostMediaEngine();
|
||||
postMediaEngine.setProjectContext(project.id);
|
||||
|
||||
@@ -578,6 +582,23 @@ export function registerIpcHandlers(): void {
|
||||
return shell.showItemInFolder(itemPath);
|
||||
});
|
||||
|
||||
safeHandle('app:readProjectMetadata', async (_, folderPath: string) => {
|
||||
const metaPath = path.join(folderPath, 'meta', 'project.json');
|
||||
try {
|
||||
const content = await fsPromises.readFile(metaPath, 'utf-8');
|
||||
const metadata = JSON.parse(content);
|
||||
// Return metadata but exclude dataPath (will be set to selected folder)
|
||||
return {
|
||||
name: metadata.name || undefined,
|
||||
description: metadata.description || undefined,
|
||||
mainLanguage: metadata.mainLanguage || undefined,
|
||||
};
|
||||
} catch {
|
||||
// File doesn't exist or is invalid - return null
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Meta Handlers ============
|
||||
|
||||
safeHandle('meta:getTags', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { contextBridge, ipcRenderer } from 'electron';
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Projects
|
||||
projects: {
|
||||
create: (data: { name: string; description?: string; slug?: string }) => ipcRenderer.invoke('projects:create', data),
|
||||
create: (data: { name: string; description?: string; slug?: string; dataPath?: string }) => ipcRenderer.invoke('projects:create', data),
|
||||
update: (id: string, data: unknown) => ipcRenderer.invoke('projects:update', id, data),
|
||||
delete: (id: string) => ipcRenderer.invoke('projects:delete', id),
|
||||
deleteWithData: (id: string) => ipcRenderer.invoke('projects:deleteWithData', id),
|
||||
@@ -119,6 +119,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),
|
||||
selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title),
|
||||
getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId),
|
||||
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath),
|
||||
},
|
||||
|
||||
// Meta (tags, categories, and project metadata)
|
||||
@@ -248,7 +249,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Type definitions for the exposed API
|
||||
export interface ElectronAPI {
|
||||
projects: {
|
||||
create: (data: { name: string; description?: string; slug?: string }) => Promise<unknown>;
|
||||
create: (data: { name: string; description?: string; slug?: string; dataPath?: string }) => Promise<unknown>;
|
||||
update: (id: string, data: unknown) => Promise<unknown>;
|
||||
delete: (id: string) => Promise<boolean>;
|
||||
get: (id: string) => Promise<unknown>;
|
||||
|
||||
Reference in New Issue
Block a user