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);
// 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<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'),