From ce94d22d300dd876103200a3a75f693b09263466 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 14 Feb 2026 15:33:02 +0100 Subject: [PATCH] feat: linking to images via ui --- src/main/database/connection.ts | 12 + src/main/engine/MediaEngine.ts | 227 ++++++++++-- src/main/ipc/handlers.ts | 8 + src/main/preload.ts | 1 + src/renderer/App.tsx | 3 +- .../components/InsertModal/InsertModal.css | 229 +++++++++++++ .../components/InsertModal/InsertModal.tsx | 323 ++++++++++++++++++ src/renderer/components/InsertModal/index.ts | 1 + .../MilkdownEditor/MilkdownEditor.tsx | 87 +++-- src/renderer/components/index.ts | 1 + src/renderer/types/electron.d.ts | 1 + tests/engine/MediaEngine.test.ts | 63 +++- 12 files changed, 891 insertions(+), 65 deletions(-) create mode 100644 src/renderer/components/InsertModal/InsertModal.css create mode 100644 src/renderer/components/InsertModal/InsertModal.tsx create mode 100644 src/renderer/components/InsertModal/index.ts diff --git a/src/main/database/connection.ts b/src/main/database/connection.ts index 09f4b72..f659fd5 100644 --- a/src/main/database/connection.ts +++ b/src/main/database/connection.ts @@ -399,6 +399,18 @@ export class DatabaseConnection { console.log('FTS table migrated - rebuild index required'); } + // Create FTS5 virtual table for media full-text search + // Stores: id (unindexed, for lookups), project_id (unindexed, for filtering), + // content (stemmed text from original_name, alt, caption, tags) + await this.localClient.execute(` + CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5( + id UNINDEXED, + project_id UNINDEXED, + content, + content_rowid=rowid + ); + `); + // Migration: Ensure tags table exists (for databases created before tags feature) const tagsTableExists = await this.localClient.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='tags'" diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index e04c49a..7eba4e1 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -7,6 +7,7 @@ import { eq, and, gte, lte, lt, desc } from 'drizzle-orm'; import { app } from 'electron'; import { getDatabase } from '../database'; import { media, Media, NewMedia, postMedia } from '../database/schema'; +import { stemText, stemQuery, SupportedLanguage } from './stemmer'; // Thumbnail sizes const THUMBNAIL_SIZES = { @@ -68,11 +69,70 @@ export class MediaEngine extends EventEmitter { private currentProjectId: string = 'default'; private dataDir: string | null = null; // For media files (may be external) private internalDir: string | null = null; // For thumbnails (always local) + private searchLanguage: SupportedLanguage = 'english'; constructor() { super(); } + /** + * Set the language used for full-text search stemming. + */ + setSearchLanguage(language: SupportedLanguage): void { + this.searchLanguage = language; + } + + /** + * Get the current search language. + */ + getSearchLanguage(): SupportedLanguage { + return this.searchLanguage; + } + + /** + * Update the FTS index for a media item. + * Stores stemmed content from original_name, alt, caption, and tags. + */ + private async updateFTSIndex(item: { + id: string; + projectId: string; + originalName: string; + alt?: string; + caption?: string; + tags: string[]; + }): Promise { + const client = getDatabase().getLocalClient(); + if (!client) return; + + // Delete existing entry + await client.execute({ sql: 'DELETE FROM media_fts WHERE id = ?', args: [item.id] }); + + // Combine all searchable fields and stem them + const allText = [ + item.originalName, + item.alt || '', + item.caption || '', + item.tags.join(' '), + ].join(' '); + + const stemmedContent = stemText(allText, this.searchLanguage); + + // Insert with id, project_id, and stemmed content + await client.execute({ + sql: 'INSERT INTO media_fts (id, project_id, content) VALUES (?, ?, ?)', + args: [item.id, item.projectId, stemmedContent], + }); + } + + /** + * Delete a media item from the FTS index. + */ + private async deleteFTSIndex(id: string): Promise { + const client = getDatabase().getLocalClient(); + if (!client) return; + await client.execute({ sql: 'DELETE FROM media_fts WHERE id = ?', args: [id] }); + } + private getDefaultBaseDir(): string { const userDataPath = app.getPath('userData'); return path.join(userDataPath, 'projects', this.currentProjectId); @@ -466,6 +526,16 @@ export class MediaEngine extends EventEmitter { await db.insert(media).values(dbMedia); + // Update FTS index + await this.updateFTSIndex({ + id: mediaData.id, + projectId: this.currentProjectId, + originalName: mediaData.originalName, + alt: mediaData.alt, + caption: mediaData.caption, + tags: mediaData.tags, + }); + this.emit('mediaImported', mediaData); return mediaData; } @@ -500,6 +570,16 @@ export class MediaEngine extends EventEmitter { }) .where(eq(media.id, id)); + // Update FTS index + await this.updateFTSIndex({ + id: updated.id, + projectId: this.currentProjectId, + originalName: updated.originalName, + alt: updated.alt, + caption: updated.caption, + tags: updated.tags, + }); + this.emit('mediaUpdated', updated); return updated; } @@ -535,6 +615,9 @@ export class MediaEngine extends EventEmitter { await db.delete(media).where(eq(media.id, id)); + // Delete from FTS index + await this.deleteFTSIndex(id); + this.emit('mediaDeleted', id); return true; } @@ -660,37 +743,41 @@ export class MediaEngine extends EventEmitter { } async searchMedia(query: string): Promise { - const db = getDatabase().getLocal(); - const allMedia = await db - .select() - .from(media) - .where(eq(media.projectId, this.currentProjectId)) - .orderBy(desc(media.createdAt)) - .all(); + const client = getDatabase().getLocalClient(); + if (!client) return []; - const lowerQuery = query.toLowerCase(); - const searchResults: MediaSearchResult[] = []; + try { + // Stem the query for multilingual matching + const stemmedQuery = stemQuery(query, this.searchLanguage); + + // Search the stemmed content, filtered by project_id for project isolation + const result = await client.execute({ + sql: `SELECT id FROM media_fts WHERE project_id = ? AND media_fts MATCH ? ORDER BY rank LIMIT 50`, + args: [this.currentProjectId, stemmedQuery], + }); - for (const item of allMedia) { - // Search in originalName, alt, caption, and tags - const searchableText = [ - item.originalName, - item.alt || '', - item.caption || '', - ...(JSON.parse(item.tags || '[]') as string[]), - ].join(' ').toLowerCase(); - - if (searchableText.includes(lowerQuery)) { - searchResults.push({ - id: item.id, - originalName: item.originalName, - mimeType: item.mimeType, - createdAt: item.createdAt, - }); + // Fetch actual media data for results + const db = getDatabase().getLocal(); + const searchResults: MediaSearchResult[] = []; + + for (const row of result.rows) { + const mediaId = row.id as string; + const item = await db.select().from(media).where(eq(media.id, mediaId)).get(); + if (item) { + searchResults.push({ + id: item.id, + originalName: item.originalName, + mimeType: item.mimeType, + createdAt: item.createdAt, + }); + } } - } - return searchResults; + return searchResults; + } catch (error) { + console.error('Media search failed:', error); + return []; + } } async getMediaByYearMonth(): Promise<{ year: number; month: number; count: number }[]> { @@ -773,6 +860,16 @@ export class MediaEngine extends EventEmitter { await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId)); console.log(`Deleted post-media links for project ${this.currentProjectId}`); + // Delete all FTS entries for the current project + const client = getDatabase().getLocalClient(); + if (client) { + await client.execute({ + sql: 'DELETE FROM media_fts WHERE project_id = ?', + args: [this.currentProjectId], + }); + console.log(`Deleted media FTS entries for project ${this.currentProjectId}`); + } + onProgress(5, 'Scanning media directory...'); // Recursively find all .meta files in the media directory tree @@ -839,6 +936,16 @@ export class MediaEngine extends EventEmitter { tags: JSON.stringify(metadata.tags), }); + // Update FTS index + await this.updateFTSIndex({ + id: metadata.id, + projectId: this.currentProjectId, + originalName: metadata.originalName, + alt: metadata.alt, + caption: metadata.caption, + tags: metadata.tags, + }); + // Insert post-media links based on linkedPostIds from sidecar const linkedPostIds = metadata.linkedPostIds || []; for (let j = 0; j < linkedPostIds.length; j++) { @@ -960,6 +1067,72 @@ export class MediaEngine extends EventEmitter { return await taskManager.runTask(task); } + + /** + * Reindex all text for full-text search. + * Runs as a background task with progress updates. + * Call this when search algorithms change or to fix search issues. + */ + async reindexText(): Promise { + const task: Task = { + id: uuidv4(), + name: 'Reindex media search text', + execute: async (onProgress) => { + const client = getDatabase().getLocalClient(); + if (!client) { + throw new Error('Database client not available'); + } + + onProgress(0, 'Clearing existing media search index...'); + + // Clear the FTS entries for current project + await client.execute({ + sql: 'DELETE FROM media_fts WHERE project_id = ?', + args: [this.currentProjectId], + }); + + onProgress(5, 'Loading media...'); + + const allMedia = await this.getAllMedia(); + const total = allMedia.length; + + if (total === 0) { + onProgress(100, 'No media to index'); + return; + } + + onProgress(10, `Indexing ${total} media items...`); + + for (let i = 0; i < allMedia.length; i++) { + const item = allMedia[i]; + await this.updateFTSIndex({ + id: item.id, + projectId: this.currentProjectId, + originalName: item.originalName, + alt: item.alt, + caption: item.caption, + tags: item.tags, + }); + + // Update progress (10% to 100%) + const progress = 10 + Math.round((i + 1) / total * 90); + if (i % 10 === 0 || i === allMedia.length - 1) { + onProgress(progress, `Indexed ${i + 1} of ${total} media items`); + } + + // Yield to event loop periodically + if (i % 20 === 0) { + await new Promise(resolve => setImmediate(resolve)); + } + } + + onProgress(100, `Reindexed ${total} media items`); + console.log(`Reindexed search text for ${total} media items`); + }, + }; + + await taskManager.runTask(task); + } } // Singleton instance diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index da51140..c4e9a48 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -376,6 +376,14 @@ export function registerIpcHandlers(): void { }); }); + safeHandle('media:reindexText', async () => { + const engine = getMediaEngine(); + // Fire and forget - don't await, let it run in background + engine.reindexText().catch(err => { + console.error('Media text reindex failed:', err); + }); + }); + safeHandle('media:getThumbnail', async (_, id: string, size?: 'small' | 'medium' | 'large') => { const engine = getMediaEngine(); return engine.getThumbnailDataUrl(id, size || 'small'); diff --git a/src/main/preload.ts b/src/main/preload.ts index 7d0a404..6f6cd9f 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -59,6 +59,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getTags: () => ipcRenderer.invoke('media:getTags'), getTagsWithCounts: () => ipcRenderer.invoke('media:getTagsWithCounts'), rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'), + reindexText: () => ipcRenderer.invoke('media:reindexText'), getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size), regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id), regenerateMissingThumbnails: () => ipcRenderer.invoke('media:regenerateMissingThumbnails'), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 049dac9..112671b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -332,8 +332,9 @@ const App: React.FC = () => { unsubscribers.push( window.electronAPI?.on('menu:reindexText', () => { - // Fire and forget - runs as a background task + // Fire and forget - runs as background tasks window.electronAPI?.posts.reindexText(); + window.electronAPI?.media.reindexText(); }) || (() => {}) ); diff --git a/src/renderer/components/InsertModal/InsertModal.css b/src/renderer/components/InsertModal/InsertModal.css new file mode 100644 index 0000000..de7bb1e --- /dev/null +++ b/src/renderer/components/InsertModal/InsertModal.css @@ -0,0 +1,229 @@ +.insert-modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.insert-modal { + background: var(--color-bg-secondary, #1e1e1e); + border: 1px solid var(--color-border, #3c3c3c); + border-radius: 8px; + width: 600px; + max-height: 500px; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.insert-modal-header { + padding: 16px 20px 0; + border-bottom: 1px solid var(--color-border, #3c3c3c); +} + +.insert-modal-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text, #fff); + margin: 0 0 12px 0; +} + +.insert-modal-tabs { + display: flex; + gap: 0; + margin: 0 -20px; + border-bottom: none; +} + +.insert-modal-tab { + flex: 1; + padding: 10px 16px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--color-text-muted, #888); + font-size: 13px; + cursor: pointer; + transition: all 0.15s ease; +} + +.insert-modal-tab:hover { + color: var(--color-text, #ccc); + background: var(--color-bg-tertiary, #252526); +} + +.insert-modal-tab.active { + color: var(--color-text, #fff); + border-bottom-color: var(--color-primary, #0e639c); + background: var(--color-bg-tertiary, #252526); +} + +.insert-modal-search { + border-bottom: 1px solid var(--color-border, #3c3c3c); +} + +.insert-modal-input { + width: 100%; + padding: 14px 20px; + font-size: 14px; + background: transparent; + border: none; + color: var(--color-text, #ccc); + outline: none; + font-family: inherit; + box-sizing: border-box; +} + +.insert-modal-input::placeholder { + color: var(--color-text-muted, #888); +} + +.insert-modal-results { + flex: 1; + overflow-y: auto; + padding: 8px; + min-height: 200px; +} + +.insert-modal-status { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--color-text-muted, #888); + font-size: 14px; + text-align: center; +} + +.insert-modal-result-item { + padding: 12px 16px; + border-radius: 4px; + cursor: pointer; + margin-bottom: 4px; + transition: background-color 0.15s ease; +} + +.insert-modal-result-item:hover, +.insert-modal-result-item.selected { + background: var(--color-bg-tertiary, #2a2a2a); +} + +.insert-modal-result-item.selected { + border-left: 3px solid var(--color-primary, #0e639c); + padding-left: 13px; +} + +.insert-modal-result-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text, #fff); + margin-bottom: 4px; +} + +.insert-modal-result-excerpt { + font-size: 12px; + color: var(--color-text-muted, #888); + line-height: 1.4; + margin-bottom: 4px; +} + +.insert-modal-result-path, +.insert-modal-result-meta { + font-size: 11px; + color: var(--color-text-muted, #666); + font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace; +} + +.insert-modal-external { + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + min-height: 200px; +} + +.insert-modal-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.insert-modal-label { + font-size: 12px; + font-weight: 500; + color: var(--color-text-muted, #888); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.insert-modal-field .insert-modal-input { + padding: 10px 12px; + border: 1px solid var(--color-border, #3c3c3c); + border-radius: 4px; + background: var(--color-bg-primary, #1a1a1a); +} + +.insert-modal-field .insert-modal-input:focus { + border-color: var(--color-primary, #0e639c); +} + +.insert-modal-submit { + margin-top: 8px; + padding: 10px 20px; + background: var(--color-primary, #0e639c); + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s ease; + align-self: flex-start; +} + +.insert-modal-submit:hover:not(:disabled) { + background: var(--color-primary-hover, #1177bb); +} + +.insert-modal-submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.insert-modal-footer { + border-top: 1px solid var(--color-border, #3c3c3c); + padding: 8px 16px; + display: flex; + justify-content: center; +} + +.insert-modal-hint { + font-size: 11px; + color: var(--color-text-muted, #888); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Scrollbar styling */ +.insert-modal-results::-webkit-scrollbar { + width: 8px; +} + +.insert-modal-results::-webkit-scrollbar-track { + background: var(--color-bg-secondary, #1e1e1e); +} + +.insert-modal-results::-webkit-scrollbar-thumb { + background: var(--color-border, #3c3c3c); + border-radius: 4px; +} + +.insert-modal-results::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted, #555); +} diff --git a/src/renderer/components/InsertModal/InsertModal.tsx b/src/renderer/components/InsertModal/InsertModal.tsx new file mode 100644 index 0000000..cebdd26 --- /dev/null +++ b/src/renderer/components/InsertModal/InsertModal.tsx @@ -0,0 +1,323 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import './InsertModal.css'; + +interface PostSearchResult { + id: string; + title: string; + slug: string; + excerpt?: string; +} + +interface MediaSearchResult { + id: string; + originalName: string; + mimeType: string; + createdAt: string; +} + +type SearchResult = PostSearchResult | MediaSearchResult; + +type InsertMode = 'link' | 'image'; +type Tab = 'external' | 'internal'; + +interface InsertModalProps { + mode: InsertMode; + onInsertLink: (url: string, text?: string) => void; + onInsertImage: (url: string, alt: string) => void; + onClose: () => void; + initialText?: string; // Selected text in editor +} + +function isPostResult(result: SearchResult): result is PostSearchResult { + return 'title' in result; +} + +function isMediaResult(result: SearchResult): result is MediaSearchResult { + return 'originalName' in result; +} + +export const InsertModal: React.FC = ({ + mode, + onInsertLink, + onInsertImage, + onClose, + initialText = '', +}) => { + const [activeTab, setActiveTab] = useState('internal'); + const [query, setQuery] = useState(''); + const [externalUrl, setExternalUrl] = useState(''); + const [externalText, setExternalText] = useState(initialText); + const [externalAlt, setExternalAlt] = useState(''); + const [results, setResults] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [isSearching, setIsSearching] = useState(false); + const inputRef = useRef(null); + const externalUrlRef = useRef(null); + + // Focus appropriate input on mount and tab change + useEffect(() => { + if (activeTab === 'internal') { + inputRef.current?.focus(); + } else { + externalUrlRef.current?.focus(); + } + }, [activeTab]); + + // Debounced search effect + useEffect(() => { + if (activeTab !== 'internal' || query.length < 2) { + setResults([]); + setSelectedIndex(0); + return; + } + + const timeoutId = setTimeout(async () => { + setIsSearching(true); + try { + if (mode === 'link') { + const searchResults = await window.electronAPI.posts.search(query); + setResults(searchResults || []); + } else { + const searchResults = await window.electronAPI.media.search(query); + setResults(searchResults || []); + } + setSelectedIndex(0); + } catch (error) { + console.error('Search failed:', error); + setResults([]); + } finally { + setIsSearching(false); + } + }, 300); + + return () => clearTimeout(timeoutId); + }, [query, mode, activeTab]); + + // Keyboard navigation handler + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + switch (e.key) { + case 'Escape': + e.preventDefault(); + onClose(); + break; + case 'ArrowDown': + if (activeTab === 'internal') { + e.preventDefault(); + setSelectedIndex(prev => Math.min(prev + 1, results.length - 1)); + } + break; + case 'ArrowUp': + if (activeTab === 'internal') { + e.preventDefault(); + setSelectedIndex(prev => Math.max(prev - 1, 0)); + } + break; + case 'Enter': + e.preventDefault(); + if (activeTab === 'internal' && results[selectedIndex]) { + handleSelectResult(results[selectedIndex]); + } else if (activeTab === 'external' && externalUrl) { + handleExternalSubmit(); + } + break; + case 'Tab': + // Allow tab switching with Tab key when on the tab buttons + break; + } + }, [activeTab, results, selectedIndex, externalUrl, onClose]); + + // Handle selecting a search result + const handleSelectResult = useCallback(async (result: SearchResult) => { + if (mode === 'link' && isPostResult(result)) { + const linkUrl = `/posts/${result.slug}`; + const linkText = initialText || result.title; + onInsertLink(linkUrl, linkText); + } else if (mode === 'image' && isMediaResult(result)) { + // Get the media URL + const url = await window.electronAPI.media.getUrl(result.id); + if (url) { + // Extract filename without extension for alt text + const altText = result.originalName.replace(/\.[^.]+$/, ''); + onInsertImage(url, altText); + } + } + onClose(); + }, [mode, initialText, onInsertLink, onInsertImage, onClose]); + + // Handle external URL submission + const handleExternalSubmit = useCallback(() => { + if (!externalUrl) return; + + if (mode === 'link') { + onInsertLink(externalUrl, externalText || undefined); + } else { + onInsertImage(externalUrl, externalAlt || 'Image'); + } + onClose(); + }, [mode, externalUrl, externalText, externalAlt, onInsertLink, onInsertImage, onClose]); + + // Backdrop click handler + const handleBackdropClick = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }, [onClose]); + + // Scroll selected item into view + useEffect(() => { + const selectedElement = document.querySelector('.insert-modal-result-item.selected'); + if (selectedElement) { + selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, [selectedIndex]); + + const title = mode === 'link' ? 'Insert Link' : 'Insert Image'; + const internalLabel = mode === 'link' ? 'Link to Post' : 'Media Library'; + const externalLabel = mode === 'link' ? 'External URL' : 'External Image'; + const searchPlaceholder = mode === 'link' + ? 'Search posts by title or content...' + : 'Search media by name, caption, or alt text...'; + + return ( +
+
+
+

{title}

+
+ + +
+
+ + {activeTab === 'internal' ? ( + <> +
+ setQuery(e.target.value)} + autoComplete="off" + /> +
+ +
+ {isSearching && ( +
Searching...
+ )} + + {!isSearching && query.length < 2 && ( +
+ Type at least 2 characters to search +
+ )} + + {!isSearching && query.length >= 2 && results.length === 0 && ( +
+ No {mode === 'link' ? 'posts' : 'media'} found for "{query}" +
+ )} + + {!isSearching && results.length > 0 && results.map((result, index) => ( +
handleSelectResult(result)} + onMouseEnter={() => setSelectedIndex(index)} + > + {isPostResult(result) ? ( + <> +
{result.title}
+ {result.excerpt && ( +
+ {result.excerpt.length > 120 + ? result.excerpt.substring(0, 120) + '...' + : result.excerpt} +
+ )} +
/posts/{result.slug}
+ + ) : ( + <> +
{result.originalName}
+
+ {result.mimeType} • {new Date(result.createdAt).toLocaleDateString()} +
+ + )} +
+ ))} +
+ + ) : ( +
+
+ + setExternalUrl(e.target.value)} + autoComplete="off" + /> +
+ + {mode === 'link' ? ( +
+ + setExternalText(e.target.value)} + /> +
+ ) : ( +
+ + setExternalAlt(e.target.value)} + /> +
+ )} + + +
+ )} + +
+ + {activeTab === 'internal' + ? 'Use ↑↓ to navigate, Enter to select, Esc to close' + : 'Enter URL and press Enter or click button, Esc to close'} + +
+
+
+ ); +}; diff --git a/src/renderer/components/InsertModal/index.ts b/src/renderer/components/InsertModal/index.ts new file mode 100644 index 0000000..be2012b --- /dev/null +++ b/src/renderer/components/InsertModal/index.ts @@ -0,0 +1 @@ +export { InsertModal } from './InsertModal'; diff --git a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx index a8caefb..076b485 100644 --- a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx +++ b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx @@ -21,7 +21,7 @@ import { imageResolverPlugin } from '../../plugins/imageResolverPlugin'; // Import macros module to register all macro definitions import '../../macros'; import './MilkdownEditor.css'; -import { PostSearchModal } from '../PostSearchModal'; +import { InsertModal } from '../InsertModal'; import { unescapeMacroSyntax } from '../../utils/markdownEscape'; // Remark plugin to force tight lists (no blank lines between list items) @@ -47,12 +47,7 @@ const remarkTightLists: RemarkPlugin = { options: {}, }; -interface SearchResult { - id: string; - title: string; - slug: string; - excerpt?: string; -} +type InsertModalMode = 'link' | 'image' | null; interface MilkdownEditorProps { content: string; @@ -63,7 +58,8 @@ interface MilkdownEditorProps { // Toolbar component that uses the editor instance const EditorToolbar: React.FC = () => { const [loading, getEditor] = useInstance(); - const [showPostSearch, setShowPostSearch] = useState(false); + const [insertMode, setInsertMode] = useState(null); + const [selectedText, setSelectedText] = useState(''); // eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -101,37 +97,48 @@ const EditorToolbar: React.FC = () => { }); }, [loading, getEditor]); - const insertLink = useCallback(() => { - const url = window.prompt('Enter URL:'); - if (!url) return; - runCommand(toggleLinkCommand.key, { href: url }); - }, [runCommand]); + // Get current selection text from editor + const getSelectionText = useCallback(() => { + const editor = getEditor(); + if (!editor) return ''; + let text = ''; + editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + const { state } = view; + const { selection } = state; + if (!selection.empty) { + text = state.doc.textBetween(selection.from, selection.to); + } + }); + return text; + }, [getEditor]); - const insertImage = useCallback(() => { - const url = window.prompt('Enter image URL:'); - if (!url) return; - const alt = window.prompt('Enter alt text:', 'Image') || 'Image'; - runCommand(insertImageCommand.key, { src: url, alt }); - }, [runCommand]); + const openLinkModal = useCallback(() => { + const text = getSelectionText(); + setSelectedText(text); + setInsertMode('link'); + }, [getSelectionText]); - const insertPostLink = useCallback(() => { - setShowPostSearch(true); + const openImageModal = useCallback(() => { + setSelectedText(''); + setInsertMode('image'); }, []); - // Add keyboard shortcut listener for Ctrl/Cmd+K + // Add keyboard shortcut listener for Ctrl/Cmd+K (link) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); - setShowPostSearch(true); + openLinkModal(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, []); + }, [openLinkModal]); - const handlePostSelected = useCallback((post: SearchResult) => { + // Handle link insertion from modal + const handleInsertLink = useCallback((url: string, text?: string) => { const editor = getEditor(); if (!editor) return; @@ -139,10 +146,10 @@ const EditorToolbar: React.FC = () => { const view = ctx.get(editorViewCtx); const { state, dispatch } = view; const { selection, schema } = state; - const selectedText = selection.empty ? '' : state.doc.textBetween(selection.from, selection.to); + const currentSelectedText = selection.empty ? '' : state.doc.textBetween(selection.from, selection.to); - const linkText = selectedText || post.title; - const linkUrl = `/posts/${post.slug}`; + const linkText = currentSelectedText || text || url; + const linkUrl = url; if (selection.empty) { // No selection - create text node with link mark and insert it @@ -158,9 +165,15 @@ const EditorToolbar: React.FC = () => { } }); - setShowPostSearch(false); + setInsertMode(null); }, [getEditor]); + // Handle image insertion from modal + const handleInsertImage = useCallback((url: string, alt: string) => { + runCommand(insertImageCommand.key, { src: url, alt }); + setInsertMode(null); + }, [runCommand]); + if (loading) return null; return ( @@ -198,9 +211,8 @@ const EditorToolbar: React.FC = () => {
- - - + +
@@ -212,10 +224,13 @@ const EditorToolbar: React.FC = () => {
- {showPostSearch && ( - setShowPostSearch(false)} + {insertMode && ( + setInsertMode(null)} + initialText={selectedText} /> )} diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index 3f96c5c..a8bd8e8 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -20,3 +20,4 @@ export { ErrorModal, type ErrorDetails } from './ErrorModal'; export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal'; export { ChatPanel } from './ChatPanel'; export { ImportAnalysisView } from './ImportAnalysisView'; +export { InsertModal } from './InsertModal'; diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 0079213..e5e6b03 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -292,6 +292,7 @@ export interface ElectronAPI { getFilePath: (id: string) => Promise; getAll: () => Promise; rebuildFromFiles: () => Promise; + reindexText: () => Promise; getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => Promise; regenerateThumbnails: (id: string) => Promise | null>; regenerateMissingThumbnails: () => Promise<{ processed: number; generated: number; failed: number }>; diff --git a/tests/engine/MediaEngine.test.ts b/tests/engine/MediaEngine.test.ts index 4ff9bd2..df6607a 100644 --- a/tests/engine/MediaEngine.test.ts +++ b/tests/engine/MediaEngine.test.ts @@ -92,11 +92,25 @@ function createDrizzleMock() { const mockLocalDb = createDrizzleMock(); +// Track FTS operations +let ftsExecuteCalls: { sql: string; args?: any[] }[] = []; + // Mock the database module vi.mock('../../src/main/database', () => ({ getDatabase: vi.fn(() => ({ getLocal: vi.fn(() => mockLocalDb), - getLocalClient: vi.fn(() => null), + getLocalClient: vi.fn(() => ({ + execute: vi.fn(async (query: { sql: string; args?: any[] } | string) => { + const sqlObj = typeof query === 'string' ? { sql: query } : query; + ftsExecuteCalls.push(sqlObj); + + // Mock FTS search results + if (sqlObj.sql.includes('media_fts MATCH')) { + return { rows: [] }; // Return empty results by default + } + return { rows: [] }; + }), + })), getRemote: vi.fn(() => null), getDataPaths: vi.fn(() => ({ database: '/mock/userData/bds.db', @@ -164,6 +178,7 @@ describe('MediaEngine', () => { mediaDeleteCalled = false; postMediaDeleteCalled = false; postMediaInserts = []; + ftsExecuteCalls = []; resetMockCounters(); // Reset the mock implementations @@ -185,6 +200,25 @@ describe('MediaEngine', () => { it('should have default project context', () => { expect(mediaEngine.getProjectContext()).toBe('default'); }); + + it('should have default search language', () => { + expect(mediaEngine.getSearchLanguage()).toBe('english'); + }); + }); + + describe('Search Language', () => { + it('should set search language', () => { + mediaEngine.setSearchLanguage('german'); + expect(mediaEngine.getSearchLanguage()).toBe('german'); + }); + + it('should allow changing search language multiple times', () => { + mediaEngine.setSearchLanguage('french'); + expect(mediaEngine.getSearchLanguage()).toBe('french'); + + mediaEngine.setSearchLanguage('spanish'); + expect(mediaEngine.getSearchLanguage()).toBe('spanish'); + }); }); describe('Project Context', () => { @@ -680,4 +714,31 @@ linkedPostIds: ["post-a", "post-b", "post-c"]`; expect(postMediaInserts[2].sortOrder).toBe(2); }); }); + + describe('Full-Text Search', () => { + it('should search media using FTS with stemmed query', async () => { + // Search for media + await mediaEngine.searchMedia('beautiful sunset'); + + // Should have called the FTS search + const ftsSearchCall = ftsExecuteCalls.find(c => c.sql.includes('media_fts MATCH')); + expect(ftsSearchCall).toBeDefined(); + expect(ftsSearchCall?.args?.[0]).toBe('default'); // project_id + // The query should be stemmed (e.g., 'beautiful sunset' -> 'beauti sunset') + expect(ftsSearchCall?.args?.[1]).toBeDefined(); + }); + + it('should filter search by project_id', async () => { + mediaEngine.setProjectContext('my-project'); + await mediaEngine.searchMedia('test'); + + const ftsSearchCall = ftsExecuteCalls.find(c => c.sql.includes('media_fts MATCH')); + expect(ftsSearchCall?.args?.[0]).toBe('my-project'); + }); + + it('should return empty array when no results found', async () => { + const results = await mediaEngine.searchMedia('nonexistent'); + expect(results).toEqual([]); + }); + }); });