diff --git a/src/main/database/connection.ts b/src/main/database/connection.ts index 6c3927e..78cc3b8 100644 --- a/src/main/database/connection.ts +++ b/src/main/database/connection.ts @@ -318,36 +318,54 @@ export class DatabaseConnection { } // Create FTS5 virtual table for full-text search - // Only stores: id (unindexed, for lookups) and content (stemmed text for matching) + // Stores: id (unindexed, for lookups), project_id (unindexed, for filtering), content (stemmed text for matching) // Post data for display comes from the posts table or filesystem files await this.localClient.execute(` CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5( id UNINDEXED, + project_id UNINDEXED, content, content_rowid=rowid ); `); - // Migration: Check if old FTS schema (with multiple columns) exists and recreate - // Old schema had: id, title, content, excerpt, tags, categories, content_stemmed - // New schema has: id, content (stemmed only) + // Migration: Check if old FTS schema exists and recreate with project_id + // Old schema had: id, content (or even older: id, title, content, excerpt, tags, categories) + // New schema has: id, project_id, content (for project-scoped search) try { - // Try to query old columns - if they exist, we need to migrate - await this.localClient.execute("SELECT title FROM posts_fts LIMIT 0"); - - // Old schema exists - recreate with new simple schema - console.log('Migrating posts_fts table to simplified schema...'); + // Try to query project_id - if it doesn't exist, we need to migrate + await this.localClient.execute("SELECT project_id FROM posts_fts LIMIT 0"); + // project_id exists, check for old multi-column schema + try { + await this.localClient.execute("SELECT title FROM posts_fts LIMIT 0"); + // Old multi-column schema exists - recreate + console.log('Migrating posts_fts table to new schema with project_id...'); + await this.localClient.execute('DROP TABLE IF EXISTS posts_fts'); + await this.localClient.execute(` + CREATE VIRTUAL TABLE posts_fts USING fts5( + id UNINDEXED, + project_id UNINDEXED, + content, + content_rowid=rowid + ); + `); + console.log('FTS table migrated - rebuild index required'); + } catch { + // No title column - we have the correct new schema + } + } catch { + // project_id doesn't exist - migrate from old schema + console.log('Migrating posts_fts table to add project_id...'); await this.localClient.execute('DROP TABLE IF EXISTS posts_fts'); await this.localClient.execute(` CREATE VIRTUAL TABLE posts_fts USING fts5( id UNINDEXED, + project_id UNINDEXED, content, content_rowid=rowid ); `); console.log('FTS table migrated - rebuild index required'); - } catch { - // Old columns don't exist - we have the new schema or no data, all good } // Create default project if none exists diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 850adfb..2dade04 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -96,11 +96,13 @@ export class PostEngine extends EventEmitter { /** * Update the FTS index for a post. * Updates the FTS index for a post. - * Stores only the stemmed content (combining title, excerpt, content, tags, categories). + * Stores the stemmed content (combining title, excerpt, content, tags, categories). + * Includes project_id for project-scoped search. * Only the post ID is returned from searches - actual post data comes from DB/files. */ private async updateFTSIndex(post: { id: string; + projectId: string; title: string; content: string; excerpt?: string; @@ -124,10 +126,10 @@ export class PostEngine extends EventEmitter { const stemmedContent = stemText(allText, this.searchLanguage); - // Insert with only id and stemmed content + // Insert with id, project_id, and stemmed content await client.execute({ - sql: 'INSERT INTO posts_fts (id, content) VALUES (?, ?)', - args: [post.id, stemmedContent], + sql: 'INSERT INTO posts_fts (id, project_id, content) VALUES (?, ?, ?)', + args: [post.id, post.projectId, stemmedContent], }); } @@ -670,26 +672,25 @@ export class PostEngine extends EventEmitter { // Stem the query for multilingual matching const stemmedQuery = stemQuery(query, this.searchLanguage); - // Search the stemmed content, only return post IDs + // Search the stemmed content, filtered by project_id for project isolation const result = await client.execute({ - sql: `SELECT id FROM posts_fts WHERE posts_fts MATCH ? ORDER BY rank LIMIT 50`, - args: [stemmedQuery], + sql: `SELECT id FROM posts_fts WHERE project_id = ? AND posts_fts MATCH ? ORDER BY rank LIMIT 50`, + args: [this.currentProjectId, stemmedQuery], }); - // Filter to current project and fetch actual post data - const projectPosts = await this.getAllPostsUnpaginated(); - const projectPostMap = new Map(projectPosts.map(p => [p.id, p])); - + // Fetch actual post data for results + const db = getDatabase().getLocal(); const searchResults: SearchResult[] = []; + for (const row of result.rows) { const postId = row.id as string; - const post = projectPostMap.get(postId); + const post = await db.select().from(posts).where(eq(posts.id, postId)).get(); if (post) { searchResults.push({ id: post.id, title: post.title, slug: post.slug, - excerpt: post.excerpt, + excerpt: post.excerpt ?? undefined, }); } } diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 1890ca5..ee0f8fe 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -128,19 +128,19 @@ const PostEditor: React.FC = ({ post }) => { window.electronAPI?.posts.hasPublishedVersion(post.id).then(setHasPublishedVersion); }, [post.id]); - // Load available categories from localStorage + // Load available categories from backend (project-scoped) useEffect(() => { - const savedCategories = localStorage.getItem('bds-categories'); - if (savedCategories) { + const loadCategories = async () => { try { - const parsed = JSON.parse(savedCategories); - if (Array.isArray(parsed) && parsed.length > 0) { - setAvailableCategories(parsed); + const categories = await window.electronAPI?.meta.getCategories(); + if (categories && categories.length > 0) { + setAvailableCategories(categories); } } catch { // Keep defaults } - } + }; + loadCategories(); }, []); // Resolve media URLs in content for display diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index bca1827..a100f67 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -138,13 +138,13 @@ export const SettingsView: React.FC = () => { setCredentials({ ...defaultCredentials, ...JSON.parse(savedCreds) }); } - // Load saved post categories - const savedCategories = localStorage.getItem('bds-categories'); - if (savedCategories) { - const categories = JSON.parse(savedCategories); - if (Array.isArray(categories) && categories.length > 0) { - setPostCategories(categories); - } + // Load categories from backend (project-scoped) + const categories = await window.electronAPI?.meta.getCategories(); + if (categories && categories.length > 0) { + setPostCategories(categories); + } else { + // Initialize with defaults if no categories exist + setPostCategories(DEFAULT_POST_CATEGORIES); } // Check Dropbox status @@ -160,7 +160,7 @@ export const SettingsView: React.FC = () => { } }; loadSettings(); - }, []); + }, [activeProject?.id]); // Reload when project changes const handleSaveDropbox = async () => { try { @@ -343,20 +343,26 @@ export const SettingsView: React.FC = () => { ); // Handlers for post categories management - const handleAddCategory = () => { + const handleAddCategory = async () => { const trimmed = newCategoryInput.trim().toLowerCase(); if (trimmed && !postCategories.includes(trimmed)) { - const updated = [...postCategories, trimmed]; - setPostCategories(updated); - localStorage.setItem('bds-categories', JSON.stringify(updated)); - setNewCategoryInput(''); - showToast.success(`Category "${trimmed}" added`); + try { + const updatedCategories = await window.electronAPI?.meta.addCategory(trimmed); + if (updatedCategories) { + setPostCategories(updatedCategories); + } + setNewCategoryInput(''); + showToast.success(`Category "${trimmed}" added`); + } catch (error) { + console.error('Failed to add category:', error); + showToast.error('Failed to add category'); + } } else if (postCategories.includes(trimmed)) { showToast.error('Category already exists'); } }; - const handleRemoveCategory = (categoryToRemove: string) => { + const handleRemoveCategory = async (categoryToRemove: string) => { if (PROTECTED_CATEGORIES.includes(categoryToRemove)) { showToast.error(`Cannot delete standard category "${categoryToRemove}"`); return; @@ -365,16 +371,39 @@ export const SettingsView: React.FC = () => { showToast.error('Must have at least one category'); return; } - const updated = postCategories.filter(c => c !== categoryToRemove); - setPostCategories(updated); - localStorage.setItem('bds-categories', JSON.stringify(updated)); - showToast.success(`Category "${categoryToRemove}" removed`); + try { + const updatedCategories = await window.electronAPI?.meta.removeCategory(categoryToRemove); + if (updatedCategories) { + setPostCategories(updatedCategories); + } + showToast.success(`Category "${categoryToRemove}" removed`); + } catch (error) { + console.error('Failed to remove category:', error); + showToast.error('Failed to remove category'); + } }; - const handleResetCategories = () => { - setPostCategories(DEFAULT_POST_CATEGORIES); - localStorage.setItem('bds-categories', JSON.stringify(DEFAULT_POST_CATEGORIES)); - showToast.success('Categories reset to defaults'); + const handleResetCategories = async () => { + try { + // Remove non-protected categories + const currentCategories = await window.electronAPI?.meta.getCategories() || []; + for (const cat of currentCategories) { + if (!PROTECTED_CATEGORIES.includes(cat)) { + await window.electronAPI?.meta.removeCategory(cat); + } + } + // Add any missing default categories + for (const cat of DEFAULT_POST_CATEGORIES) { + await window.electronAPI?.meta.addCategory(cat); + } + // Refresh the list + const updatedCategories = await window.electronAPI?.meta.getCategories(); + setPostCategories(updatedCategories || DEFAULT_POST_CATEGORIES); + showToast.success('Categories reset to defaults'); + } catch (error) { + console.error('Failed to reset categories:', error); + showToast.error('Failed to reset categories'); + } }; const renderContentSettings = () => ( diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index df2b5c3..d09f345 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -1,5 +1,10 @@ // Type definitions for the Electron API exposed via preload +export interface ProjectMetadata { + name: string; + description?: string; +} + export interface ProjectData { id: string; name: string; @@ -206,6 +211,18 @@ export interface ElectronAPI { openFolder: (folderPath: string) => Promise; showItemInFolder: (itemPath: string) => Promise; }; + meta: { + getTags: () => Promise; + getCategories: () => Promise; + addTag: (tag: string) => Promise; + removeTag: (tag: string) => Promise; + addCategory: (category: string) => Promise; + removeCategory: (category: string) => Promise; + syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>; + getProjectMetadata: () => Promise; + setProjectMetadata: (metadata: { name: string; description?: string }) => Promise; + updateProjectMetadata: (updates: { name?: string; description?: string }) => Promise; + }; on: (channel: string, callback: (...args: unknown[]) => void) => () => void; once: (channel: string, callback: (...args: unknown[]) => void) => void; } diff --git a/tests/renderer/components/SettingsView.test.ts b/tests/renderer/components/SettingsView.test.ts index 110b414..5aa52ad 100644 --- a/tests/renderer/components/SettingsView.test.ts +++ b/tests/renderer/components/SettingsView.test.ts @@ -126,55 +126,8 @@ describe('SettingsView Behavior', () => { }); }); - describe('Post Categories (localStorage)', () => { - it('should save categories to localStorage', () => { - const categories = ['article', 'picture', 'aside', 'page', 'review']; - - localStorage.setItem('bds-categories', JSON.stringify(categories)); - - const saved = JSON.parse(localStorage.getItem('bds-categories') || '[]'); - expect(saved).toContain('article'); - expect(saved).toContain('review'); - }); - - it('should load categories from localStorage', () => { - const categories = ['custom1', 'custom2', 'custom3']; - localStorage.setItem('bds-categories', JSON.stringify(categories)); - - const loaded = JSON.parse(localStorage.getItem('bds-categories') || '[]'); - expect(loaded).toEqual(['custom1', 'custom2', 'custom3']); - }); - - it('should handle empty categories', () => { - const loaded = JSON.parse(localStorage.getItem('bds-categories') || '[]'); - expect(loaded).toEqual([]); - }); - - it('should add new category', () => { - const categories = ['article', 'picture']; - localStorage.setItem('bds-categories', JSON.stringify(categories)); - - const loaded = JSON.parse(localStorage.getItem('bds-categories') || '[]'); - const updated = [...loaded, 'tutorial']; - localStorage.setItem('bds-categories', JSON.stringify(updated)); - - const result = JSON.parse(localStorage.getItem('bds-categories') || '[]'); - expect(result).toContain('tutorial'); - }); - - it('should remove category', () => { - const categories = ['article', 'picture', 'aside']; - localStorage.setItem('bds-categories', JSON.stringify(categories)); - - const loaded = JSON.parse(localStorage.getItem('bds-categories') || '[]'); - const updated = loaded.filter((c: string) => c !== 'aside'); - localStorage.setItem('bds-categories', JSON.stringify(updated)); - - const result = JSON.parse(localStorage.getItem('bds-categories') || '[]'); - expect(result).not.toContain('aside'); - expect(result).toContain('article'); - }); - }); + // Note: Post categories are now managed via MetaEngine (project-scoped) + // and tested in tests/engine/MetaEngine.test.ts describe('API Integration Patterns', () => { beforeEach(() => { diff --git a/tests/setup.ts b/tests/setup.ts index 14e3401..e8411bf 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -88,6 +88,18 @@ Object.defineProperty(globalThis, 'window', { resolveConflict: vi.fn(), getLastSyncTime: vi.fn(), }, + meta: { + getTags: vi.fn(), + getCategories: vi.fn(), + addTag: vi.fn(), + removeTag: vi.fn(), + addCategory: vi.fn(), + removeCategory: vi.fn(), + syncOnStartup: vi.fn(), + getProjectMetadata: vi.fn(), + setProjectMetadata: vi.fn(), + updateProjectMetadata: vi.fn(), + }, tasks: { getAll: vi.fn(), getRunning: vi.fn(),