fix: proper boundary on the project in the data
This commit is contained in:
@@ -318,36 +318,54 @@ export class DatabaseConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create FTS5 virtual table for full-text search
|
// 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
|
// Post data for display comes from the posts table or filesystem files
|
||||||
await this.localClient.execute(`
|
await this.localClient.execute(`
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||||
id UNINDEXED,
|
id UNINDEXED,
|
||||||
|
project_id UNINDEXED,
|
||||||
content,
|
content,
|
||||||
content_rowid=rowid
|
content_rowid=rowid
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Migration: Check if old FTS schema (with multiple columns) exists and recreate
|
// Migration: Check if old FTS schema exists and recreate with project_id
|
||||||
// Old schema had: id, title, content, excerpt, tags, categories, content_stemmed
|
// Old schema had: id, content (or even older: id, title, content, excerpt, tags, categories)
|
||||||
// New schema has: id, content (stemmed only)
|
// New schema has: id, project_id, content (for project-scoped search)
|
||||||
|
try {
|
||||||
|
// 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 {
|
try {
|
||||||
// Try to query old columns - if they exist, we need to migrate
|
|
||||||
await this.localClient.execute("SELECT title FROM posts_fts LIMIT 0");
|
await this.localClient.execute("SELECT title FROM posts_fts LIMIT 0");
|
||||||
|
// Old multi-column schema exists - recreate
|
||||||
// Old schema exists - recreate with new simple schema
|
console.log('Migrating posts_fts table to new schema with project_id...');
|
||||||
console.log('Migrating posts_fts table to simplified schema...');
|
|
||||||
await this.localClient.execute('DROP TABLE IF EXISTS posts_fts');
|
await this.localClient.execute('DROP TABLE IF EXISTS posts_fts');
|
||||||
await this.localClient.execute(`
|
await this.localClient.execute(`
|
||||||
CREATE VIRTUAL TABLE posts_fts USING fts5(
|
CREATE VIRTUAL TABLE posts_fts USING fts5(
|
||||||
id UNINDEXED,
|
id UNINDEXED,
|
||||||
|
project_id UNINDEXED,
|
||||||
content,
|
content,
|
||||||
content_rowid=rowid
|
content_rowid=rowid
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
console.log('FTS table migrated - rebuild index required');
|
console.log('FTS table migrated - rebuild index required');
|
||||||
} catch {
|
} catch {
|
||||||
// Old columns don't exist - we have the new schema or no data, all good
|
// 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create default project if none exists
|
// Create default project if none exists
|
||||||
|
|||||||
@@ -96,11 +96,13 @@ export class PostEngine extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Update the FTS index for a post.
|
* Update the FTS index for a post.
|
||||||
* Updates 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.
|
* Only the post ID is returned from searches - actual post data comes from DB/files.
|
||||||
*/
|
*/
|
||||||
private async updateFTSIndex(post: {
|
private async updateFTSIndex(post: {
|
||||||
id: string;
|
id: string;
|
||||||
|
projectId: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
@@ -124,10 +126,10 @@ export class PostEngine extends EventEmitter {
|
|||||||
|
|
||||||
const stemmedContent = stemText(allText, this.searchLanguage);
|
const stemmedContent = stemText(allText, this.searchLanguage);
|
||||||
|
|
||||||
// Insert with only id and stemmed content
|
// Insert with id, project_id, and stemmed content
|
||||||
await client.execute({
|
await client.execute({
|
||||||
sql: 'INSERT INTO posts_fts (id, content) VALUES (?, ?)',
|
sql: 'INSERT INTO posts_fts (id, project_id, content) VALUES (?, ?, ?)',
|
||||||
args: [post.id, stemmedContent],
|
args: [post.id, post.projectId, stemmedContent],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,26 +672,25 @@ export class PostEngine extends EventEmitter {
|
|||||||
// Stem the query for multilingual matching
|
// Stem the query for multilingual matching
|
||||||
const stemmedQuery = stemQuery(query, this.searchLanguage);
|
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({
|
const result = await client.execute({
|
||||||
sql: `SELECT id FROM posts_fts WHERE posts_fts MATCH ? ORDER BY rank LIMIT 50`,
|
sql: `SELECT id FROM posts_fts WHERE project_id = ? AND posts_fts MATCH ? ORDER BY rank LIMIT 50`,
|
||||||
args: [stemmedQuery],
|
args: [this.currentProjectId, stemmedQuery],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter to current project and fetch actual post data
|
// Fetch actual post data for results
|
||||||
const projectPosts = await this.getAllPostsUnpaginated();
|
const db = getDatabase().getLocal();
|
||||||
const projectPostMap = new Map(projectPosts.map(p => [p.id, p]));
|
|
||||||
|
|
||||||
const searchResults: SearchResult[] = [];
|
const searchResults: SearchResult[] = [];
|
||||||
|
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
const postId = row.id as string;
|
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) {
|
if (post) {
|
||||||
searchResults.push({
|
searchResults.push({
|
||||||
id: post.id,
|
id: post.id,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,19 +128,19 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
window.electronAPI?.posts.hasPublishedVersion(post.id).then(setHasPublishedVersion);
|
window.electronAPI?.posts.hasPublishedVersion(post.id).then(setHasPublishedVersion);
|
||||||
}, [post.id]);
|
}, [post.id]);
|
||||||
|
|
||||||
// Load available categories from localStorage
|
// Load available categories from backend (project-scoped)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedCategories = localStorage.getItem('bds-categories');
|
const loadCategories = async () => {
|
||||||
if (savedCategories) {
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(savedCategories);
|
const categories = await window.electronAPI?.meta.getCategories();
|
||||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
if (categories && categories.length > 0) {
|
||||||
setAvailableCategories(parsed);
|
setAvailableCategories(categories);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Keep defaults
|
// Keep defaults
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
loadCategories();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Resolve media URLs in content for display
|
// Resolve media URLs in content for display
|
||||||
|
|||||||
@@ -138,13 +138,13 @@ export const SettingsView: React.FC = () => {
|
|||||||
setCredentials({ ...defaultCredentials, ...JSON.parse(savedCreds) });
|
setCredentials({ ...defaultCredentials, ...JSON.parse(savedCreds) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load saved post categories
|
// Load categories from backend (project-scoped)
|
||||||
const savedCategories = localStorage.getItem('bds-categories');
|
const categories = await window.electronAPI?.meta.getCategories();
|
||||||
if (savedCategories) {
|
if (categories && categories.length > 0) {
|
||||||
const categories = JSON.parse(savedCategories);
|
|
||||||
if (Array.isArray(categories) && categories.length > 0) {
|
|
||||||
setPostCategories(categories);
|
setPostCategories(categories);
|
||||||
}
|
} else {
|
||||||
|
// Initialize with defaults if no categories exist
|
||||||
|
setPostCategories(DEFAULT_POST_CATEGORIES);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Dropbox status
|
// Check Dropbox status
|
||||||
@@ -160,7 +160,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}, []);
|
}, [activeProject?.id]); // Reload when project changes
|
||||||
|
|
||||||
const handleSaveDropbox = async () => {
|
const handleSaveDropbox = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -343,20 +343,26 @@ export const SettingsView: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Handlers for post categories management
|
// Handlers for post categories management
|
||||||
const handleAddCategory = () => {
|
const handleAddCategory = async () => {
|
||||||
const trimmed = newCategoryInput.trim().toLowerCase();
|
const trimmed = newCategoryInput.trim().toLowerCase();
|
||||||
if (trimmed && !postCategories.includes(trimmed)) {
|
if (trimmed && !postCategories.includes(trimmed)) {
|
||||||
const updated = [...postCategories, trimmed];
|
try {
|
||||||
setPostCategories(updated);
|
const updatedCategories = await window.electronAPI?.meta.addCategory(trimmed);
|
||||||
localStorage.setItem('bds-categories', JSON.stringify(updated));
|
if (updatedCategories) {
|
||||||
|
setPostCategories(updatedCategories);
|
||||||
|
}
|
||||||
setNewCategoryInput('');
|
setNewCategoryInput('');
|
||||||
showToast.success(`Category "${trimmed}" added`);
|
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)) {
|
} else if (postCategories.includes(trimmed)) {
|
||||||
showToast.error('Category already exists');
|
showToast.error('Category already exists');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveCategory = (categoryToRemove: string) => {
|
const handleRemoveCategory = async (categoryToRemove: string) => {
|
||||||
if (PROTECTED_CATEGORIES.includes(categoryToRemove)) {
|
if (PROTECTED_CATEGORIES.includes(categoryToRemove)) {
|
||||||
showToast.error(`Cannot delete standard category "${categoryToRemove}"`);
|
showToast.error(`Cannot delete standard category "${categoryToRemove}"`);
|
||||||
return;
|
return;
|
||||||
@@ -365,16 +371,39 @@ export const SettingsView: React.FC = () => {
|
|||||||
showToast.error('Must have at least one category');
|
showToast.error('Must have at least one category');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updated = postCategories.filter(c => c !== categoryToRemove);
|
try {
|
||||||
setPostCategories(updated);
|
const updatedCategories = await window.electronAPI?.meta.removeCategory(categoryToRemove);
|
||||||
localStorage.setItem('bds-categories', JSON.stringify(updated));
|
if (updatedCategories) {
|
||||||
|
setPostCategories(updatedCategories);
|
||||||
|
}
|
||||||
showToast.success(`Category "${categoryToRemove}" removed`);
|
showToast.success(`Category "${categoryToRemove}" removed`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove category:', error);
|
||||||
|
showToast.error('Failed to remove category');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetCategories = () => {
|
const handleResetCategories = async () => {
|
||||||
setPostCategories(DEFAULT_POST_CATEGORIES);
|
try {
|
||||||
localStorage.setItem('bds-categories', JSON.stringify(DEFAULT_POST_CATEGORIES));
|
// 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');
|
showToast.success('Categories reset to defaults');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset categories:', error);
|
||||||
|
showToast.error('Failed to reset categories');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderContentSettings = () => (
|
const renderContentSettings = () => (
|
||||||
|
|||||||
17
src/renderer/types/electron.d.ts
vendored
17
src/renderer/types/electron.d.ts
vendored
@@ -1,5 +1,10 @@
|
|||||||
// Type definitions for the Electron API exposed via preload
|
// Type definitions for the Electron API exposed via preload
|
||||||
|
|
||||||
|
export interface ProjectMetadata {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectData {
|
export interface ProjectData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -206,6 +211,18 @@ export interface ElectronAPI {
|
|||||||
openFolder: (folderPath: string) => Promise<string>;
|
openFolder: (folderPath: string) => Promise<string>;
|
||||||
showItemInFolder: (itemPath: string) => Promise<void>;
|
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[]; projectMetadata: ProjectMetadata | null }>;
|
||||||
|
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
||||||
|
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
|
||||||
|
updateProjectMetadata: (updates: { name?: string; description?: string }) => Promise<ProjectMetadata | null>;
|
||||||
|
};
|
||||||
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
|
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
|
||||||
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,55 +126,8 @@ describe('SettingsView Behavior', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Post Categories (localStorage)', () => {
|
// Note: Post categories are now managed via MetaEngine (project-scoped)
|
||||||
it('should save categories to localStorage', () => {
|
// and tested in tests/engine/MetaEngine.test.ts
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('API Integration Patterns', () => {
|
describe('API Integration Patterns', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -88,6 +88,18 @@ Object.defineProperty(globalThis, 'window', {
|
|||||||
resolveConflict: vi.fn(),
|
resolveConflict: vi.fn(),
|
||||||
getLastSyncTime: 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: {
|
tasks: {
|
||||||
getAll: vi.fn(),
|
getAll: vi.fn(),
|
||||||
getRunning: vi.fn(),
|
getRunning: vi.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user