diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 2a37d63..bc77c51 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -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 = new Set(); private categories: Set = 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 { 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 { 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 { - 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 { - 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 { 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 diff --git a/src/main/engine/ProjectEngine.ts b/src/main/engine/ProjectEngine.ts index fd76ad6..da04fb0 100644 --- a/src/main/engine/ProjectEngine.ts +++ b/src/main/engine/ProjectEngine.ts @@ -58,15 +58,16 @@ export class ProjectEngine extends EventEmitter { } private async ensureProjectDirectories(projectId: string, dataPath?: string | null): Promise { - // 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 { diff --git a/src/main/engine/TagEngine.ts b/src/main/engine/TagEngine.ts index 668a9bc..b79efc9 100644 --- a/src/main/engine/TagEngine.ts +++ b/src/main/engine/TagEngine.ts @@ -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 { 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 { 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], }); } } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 92c5f78..619689b 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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): 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 () => { diff --git a/src/main/preload.ts b/src/main/preload.ts index 0b59593..4718c73 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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; + create: (data: { name: string; description?: string; slug?: string; dataPath?: string }) => Promise; update: (id: string, data: unknown) => Promise; delete: (id: string) => Promise; get: (id: string) => Promise; diff --git a/src/renderer/components/ProjectSelector/ProjectSelector.css b/src/renderer/components/ProjectSelector/ProjectSelector.css index b64af99..105b82c 100644 --- a/src/renderer/components/ProjectSelector/ProjectSelector.css +++ b/src/renderer/components/ProjectSelector/ProjectSelector.css @@ -355,3 +355,81 @@ font-size: 12px; color: var(--vscode-foreground); } + +/* Folder picker styles */ +.folder-picker { + display: flex; + flex-direction: column; + gap: 8px; +} + +.folder-path-display { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border); + border-radius: 4px; +} + +.folder-path { + flex: 1; + font-size: 12px; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--vscode-editor-font-family, monospace); +} + +.folder-default-info { + display: flex; + align-items: center; + padding: 6px 10px; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border); + border-radius: 4px; +} + +.default-label { + font-size: 12px; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +.btn-icon { + display: flex; + align-items: center; + justify-content: center; + padding: 2px; + background: transparent; + border: none; + color: var(--vscode-descriptionForeground); + cursor: pointer; + border-radius: 3px; +} + +.btn-icon:hover { + color: var(--vscode-foreground); + background-color: var(--vscode-list-hoverBackground); +} + +.btn-small { + padding: 4px 10px; + font-size: 11px; +} + +.btn-secondary.btn-small { + display: inline-flex; + align-items: center; + gap: 4px; + width: auto; +} + +.form-hint { + margin: 6px 0 0 0; + font-size: 11px; + color: var(--vscode-descriptionForeground); + line-height: 1.4; +} diff --git a/src/renderer/components/ProjectSelector/ProjectSelector.tsx b/src/renderer/components/ProjectSelector/ProjectSelector.tsx index 0c3faa0..3886f51 100644 --- a/src/renderer/components/ProjectSelector/ProjectSelector.tsx +++ b/src/renderer/components/ProjectSelector/ProjectSelector.tsx @@ -35,6 +35,7 @@ export const ProjectSelector: React.FC = () => { const [deleteConfirmText, setDeleteConfirmText] = useState(''); const [newProjectName, setNewProjectName] = useState(''); const [newProjectDescription, setNewProjectDescription] = useState(''); + const [newProjectDataPath, setNewProjectDataPath] = useState(null); const dropdownRef = useRef(null); // Load projects on mount @@ -125,12 +126,14 @@ export const ProjectSelector: React.FC = () => { const newProject = await window.electronAPI?.projects.create({ name: newProjectName.trim(), description: newProjectDescription.trim() || undefined, + dataPath: newProjectDataPath || undefined, }); if (newProject) { setProjects([...projects, newProject as ProjectData]); showToast.success(`Created project "${newProjectName}"`); setNewProjectName(''); setNewProjectDescription(''); + setNewProjectDataPath(null); setShowCreateModal(false); // Optionally switch to the new project @@ -142,6 +145,42 @@ export const ProjectSelector: React.FC = () => { } }; + const handleSelectFolder = async () => { + try { + const selectedPath = await window.electronAPI?.app.selectFolder('Select Project Location'); + if (selectedPath) { + setNewProjectDataPath(selectedPath); + + // Check if the folder has existing project metadata + const existingMetadata = await window.electronAPI?.app.readProjectMetadata(selectedPath); + if (existingMetadata) { + // Pre-populate form fields from existing project.json (overwrite if found) + if (existingMetadata.name) { + setNewProjectName(existingMetadata.name); + } + if (existingMetadata.description) { + setNewProjectDescription(existingMetadata.description); + } + showToast.info('Found existing project settings'); + } + } + } catch (error) { + console.error('Failed to select folder:', error); + showToast.error('Failed to select folder'); + } + }; + + const handleClearFolder = () => { + setNewProjectDataPath(null); + }; + + const handleCloseCreateModal = () => { + setNewProjectName(''); + setNewProjectDescription(''); + setNewProjectDataPath(null); + setShowCreateModal(false); + }; + const openDeleteModal = (e: React.MouseEvent, project: ProjectData) => { e.stopPropagation(); setProjectToDelete(project); @@ -244,11 +283,11 @@ export const ProjectSelector: React.FC = () => { )} {showCreateModal && ( -
setShowCreateModal(false)}> +
e.stopPropagation()}>

Create New Project

-
+
+ +
+ {newProjectDataPath ? ( +
+ {newProjectDataPath} + +
+ ) : ( +
+ Default (internal storage) +
+ )} + +
+

+ Choose a custom folder for cloud storage backup, or use the default internal storage. +

+
-