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