feat: linking to images via ui

This commit is contained in:
2026-02-14 15:33:02 +01:00
parent 02b93ff5c5
commit ce94d22d30
12 changed files with 891 additions and 65 deletions

View File

@@ -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'"

View File

@@ -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<void> {
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<void> {
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<MediaSearchResult[]> {
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);
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();
// 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],
});
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<void> {
const task: Task<void> = {
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

View File

@@ -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');

View File

@@ -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'),

View File

@@ -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();
}) || (() => {})
);

View File

@@ -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);
}

View File

@@ -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<InsertModalProps> = ({
mode,
onInsertLink,
onInsertImage,
onClose,
initialText = '',
}) => {
const [activeTab, setActiveTab] = useState<Tab>('internal');
const [query, setQuery] = useState('');
const [externalUrl, setExternalUrl] = useState('');
const [externalText, setExternalText] = useState(initialText);
const [externalAlt, setExternalAlt] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const externalUrlRef = useRef<HTMLInputElement>(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 (
<div className="insert-modal-backdrop" onClick={handleBackdropClick}>
<div className="insert-modal" onKeyDown={handleKeyDown}>
<div className="insert-modal-header">
<h3 className="insert-modal-title">{title}</h3>
<div className="insert-modal-tabs">
<button
className={`insert-modal-tab ${activeTab === 'internal' ? 'active' : ''}`}
onClick={() => setActiveTab('internal')}
>
{internalLabel}
</button>
<button
className={`insert-modal-tab ${activeTab === 'external' ? 'active' : ''}`}
onClick={() => setActiveTab('external')}
>
{externalLabel}
</button>
</div>
</div>
{activeTab === 'internal' ? (
<>
<div className="insert-modal-search">
<input
ref={inputRef}
type="text"
className="insert-modal-input"
placeholder={searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
/>
</div>
<div className="insert-modal-results">
{isSearching && (
<div className="insert-modal-status">Searching...</div>
)}
{!isSearching && query.length < 2 && (
<div className="insert-modal-status">
Type at least 2 characters to search
</div>
)}
{!isSearching && query.length >= 2 && results.length === 0 && (
<div className="insert-modal-status">
No {mode === 'link' ? 'posts' : 'media'} found for "{query}"
</div>
)}
{!isSearching && results.length > 0 && results.map((result, index) => (
<div
key={isPostResult(result) ? result.id : result.id}
className={`insert-modal-result-item ${index === selectedIndex ? 'selected' : ''}`}
onClick={() => handleSelectResult(result)}
onMouseEnter={() => setSelectedIndex(index)}
>
{isPostResult(result) ? (
<>
<div className="insert-modal-result-title">{result.title}</div>
{result.excerpt && (
<div className="insert-modal-result-excerpt">
{result.excerpt.length > 120
? result.excerpt.substring(0, 120) + '...'
: result.excerpt}
</div>
)}
<div className="insert-modal-result-path">/posts/{result.slug}</div>
</>
) : (
<>
<div className="insert-modal-result-title">{result.originalName}</div>
<div className="insert-modal-result-meta">
{result.mimeType} {new Date(result.createdAt).toLocaleDateString()}
</div>
</>
)}
</div>
))}
</div>
</>
) : (
<div className="insert-modal-external">
<div className="insert-modal-field">
<label className="insert-modal-label">URL</label>
<input
ref={externalUrlRef}
type="text"
className="insert-modal-input"
placeholder={mode === 'link' ? 'https://example.com' : 'https://example.com/image.jpg'}
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
autoComplete="off"
/>
</div>
{mode === 'link' ? (
<div className="insert-modal-field">
<label className="insert-modal-label">Link Text (optional)</label>
<input
type="text"
className="insert-modal-input"
placeholder="Click here"
value={externalText}
onChange={(e) => setExternalText(e.target.value)}
/>
</div>
) : (
<div className="insert-modal-field">
<label className="insert-modal-label">Alt Text</label>
<input
type="text"
className="insert-modal-input"
placeholder="Description of the image"
value={externalAlt}
onChange={(e) => setExternalAlt(e.target.value)}
/>
</div>
)}
<button
className="insert-modal-submit"
onClick={handleExternalSubmit}
disabled={!externalUrl}
>
Insert {mode === 'link' ? 'Link' : 'Image'}
</button>
</div>
)}
<div className="insert-modal-footer">
<span className="insert-modal-hint">
{activeTab === 'internal'
? 'Use ↑↓ to navigate, Enter to select, Esc to close'
: 'Enter URL and press Enter or click button, Esc to close'}
</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { InsertModal } from './InsertModal';

View File

@@ -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<InsertModalMode>(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 = () => {
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={insertLink} title="Insert Link">🔗</button>
<button onClick={insertPostLink} title="Link to Post (Ctrl+K)">📝</button>
<button onClick={insertImage} title="Insert Image">🖼</button>
<button onClick={openLinkModal} title="Insert Link (Ctrl+K)">🔗</button>
<button onClick={openImageModal} title="Insert Image">🖼</button>
<button onClick={() => runCommand(insertHrCommand.key)} title="Horizontal Rule"></button>
</div>
@@ -212,10 +224,13 @@ const EditorToolbar: React.FC = () => {
</div>
</div>
{showPostSearch && (
<PostSearchModal
onSelect={handlePostSelected}
onClose={() => setShowPostSearch(false)}
{insertMode && (
<InsertModal
mode={insertMode}
onInsertLink={handleInsertLink}
onInsertImage={handleInsertImage}
onClose={() => setInsertMode(null)}
initialText={selectedText}
/>
)}
</>

View File

@@ -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';

View File

@@ -292,6 +292,7 @@ export interface ElectronAPI {
getFilePath: (id: string) => Promise<string | null>;
getAll: () => Promise<MediaData[]>;
rebuildFromFiles: () => Promise<void>;
reindexText: () => Promise<void>;
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => Promise<string | null>;
regenerateThumbnails: (id: string) => Promise<Record<string, string> | null>;
regenerateMissingThumbnails: () => Promise<{ processed: number; generated: number; failed: number }>;

View File

@@ -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([]);
});
});
});