feat: media sidebar with filters
This commit is contained in:
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, and, gte, lte, desc } from 'drizzle-orm';
|
||||
import { app } from 'electron';
|
||||
import { getDatabase } from '../database';
|
||||
import { media, Media, NewMedia } from '../database/schema';
|
||||
@@ -49,6 +49,21 @@ export interface MediaMetadata {
|
||||
linkedPostIds?: string[]; // Posts this media is linked to (persisted in sidecar)
|
||||
}
|
||||
|
||||
export interface MediaFilter {
|
||||
tags?: string[];
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
year?: number;
|
||||
month?: number;
|
||||
}
|
||||
|
||||
export interface MediaSearchResult {
|
||||
id: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class MediaEngine extends EventEmitter {
|
||||
private currentProjectId: string = 'default';
|
||||
private dataDir: string | null = null; // For media files (may be external)
|
||||
@@ -546,8 +561,13 @@ export class MediaEngine extends EventEmitter {
|
||||
|
||||
async getAllMedia(): Promise<MediaData[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbMediaList = await db.select().from(media).all();
|
||||
|
||||
const dbMediaList = await db
|
||||
.select()
|
||||
.from(media)
|
||||
.where(eq(media.projectId, this.currentProjectId))
|
||||
.orderBy(desc(media.createdAt))
|
||||
.all();
|
||||
|
||||
return dbMediaList.map(dbMedia => ({
|
||||
id: dbMedia.id,
|
||||
filename: dbMedia.filename,
|
||||
@@ -564,6 +584,154 @@ export class MediaEngine extends EventEmitter {
|
||||
}));
|
||||
}
|
||||
|
||||
async getMediaFiltered(filter: MediaFilter): Promise<MediaData[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
const conditions = [eq(media.projectId, this.currentProjectId)];
|
||||
|
||||
if (filter.startDate) {
|
||||
conditions.push(gte(media.createdAt, filter.startDate));
|
||||
}
|
||||
|
||||
if (filter.endDate) {
|
||||
conditions.push(lte(media.createdAt, filter.endDate));
|
||||
}
|
||||
|
||||
if (filter.year !== undefined) {
|
||||
const startOfYear = new Date(filter.year, 0, 1);
|
||||
const endOfYear = new Date(filter.year + 1, 0, 1);
|
||||
conditions.push(gte(media.createdAt, startOfYear));
|
||||
conditions.push(lte(media.createdAt, endOfYear));
|
||||
}
|
||||
|
||||
if (filter.month !== undefined && filter.year !== undefined) {
|
||||
const startOfMonth = new Date(filter.year, filter.month, 1);
|
||||
const endOfMonth = new Date(filter.year, filter.month + 1, 1);
|
||||
conditions.push(gte(media.createdAt, startOfMonth));
|
||||
conditions.push(lte(media.createdAt, endOfMonth));
|
||||
}
|
||||
|
||||
const dbMediaList = await db
|
||||
.select()
|
||||
.from(media)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(media.createdAt))
|
||||
.all();
|
||||
|
||||
let result: MediaData[] = [];
|
||||
|
||||
for (const dbMedia of dbMediaList) {
|
||||
const mediaData: MediaData = {
|
||||
id: dbMedia.id,
|
||||
filename: dbMedia.filename,
|
||||
originalName: dbMedia.originalName,
|
||||
mimeType: dbMedia.mimeType,
|
||||
size: dbMedia.size,
|
||||
width: dbMedia.width || undefined,
|
||||
height: dbMedia.height || undefined,
|
||||
alt: dbMedia.alt || undefined,
|
||||
caption: dbMedia.caption || undefined,
|
||||
createdAt: dbMedia.createdAt,
|
||||
updatedAt: dbMedia.updatedAt,
|
||||
tags: JSON.parse(dbMedia.tags || '[]'),
|
||||
};
|
||||
|
||||
// Client-side filtering for tags (JSON array)
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
const hasAllTags = filter.tags.every(tag => mediaData.tags.includes(tag));
|
||||
if (!hasAllTags) continue;
|
||||
}
|
||||
|
||||
result.push(mediaData);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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 lowerQuery = query.toLowerCase();
|
||||
const searchResults: MediaSearchResult[] = [];
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
async getMediaByYearMonth(): Promise<{ year: number; month: number; count: number }[]> {
|
||||
const allMedia = await this.getAllMedia();
|
||||
const counts = new Map<string, { year: number; month: number; count: number }>();
|
||||
|
||||
for (const item of allMedia) {
|
||||
const year = item.createdAt.getFullYear();
|
||||
const month = item.createdAt.getMonth();
|
||||
const key = `${year}-${month}`;
|
||||
const current = counts.get(key) || { year, month, count: 0 };
|
||||
current.count++;
|
||||
counts.set(key, current);
|
||||
}
|
||||
|
||||
return Array.from(counts.values()).sort((a, b) => {
|
||||
if (a.year !== b.year) return b.year - a.year;
|
||||
return b.month - a.month;
|
||||
});
|
||||
}
|
||||
|
||||
async getAvailableTags(): Promise<string[]> {
|
||||
const allMedia = await this.getAllMedia();
|
||||
const tags = new Set<string>();
|
||||
for (const item of allMedia) {
|
||||
for (const tag of item.tags) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return Array.from(tags).sort();
|
||||
}
|
||||
|
||||
async getTagsWithCounts(): Promise<{ tag: string; count: number }[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbMediaList = await db
|
||||
.select({ tags: media.tags })
|
||||
.from(media)
|
||||
.where(eq(media.projectId, this.currentProjectId))
|
||||
.all();
|
||||
|
||||
const tagCounts = new Map<string, number>();
|
||||
for (const row of dbMediaList) {
|
||||
const parsed: string[] = JSON.parse(row.tags || '[]');
|
||||
for (const tag of parsed) {
|
||||
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(tagCounts.entries())
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||
}
|
||||
|
||||
getMediaPath(id: string): string {
|
||||
return path.join(this.getMediaDir(), id);
|
||||
}
|
||||
|
||||
@@ -332,6 +332,31 @@ export function registerIpcHandlers(): void {
|
||||
return engine.getAllMedia();
|
||||
});
|
||||
|
||||
safeHandle('media:filter', async (_, filter: import('../engine/MediaEngine').MediaFilter) => {
|
||||
const engine = getMediaEngine();
|
||||
return engine.getMediaFiltered(filter);
|
||||
});
|
||||
|
||||
safeHandle('media:search', async (_, query: string) => {
|
||||
const engine = getMediaEngine();
|
||||
return engine.searchMedia(query);
|
||||
});
|
||||
|
||||
safeHandle('media:getByYearMonth', async () => {
|
||||
const engine = getMediaEngine();
|
||||
return engine.getMediaByYearMonth();
|
||||
});
|
||||
|
||||
safeHandle('media:getTags', async () => {
|
||||
const engine = getMediaEngine();
|
||||
return engine.getAvailableTags();
|
||||
});
|
||||
|
||||
safeHandle('media:getTagsWithCounts', async () => {
|
||||
const engine = getMediaEngine();
|
||||
return engine.getTagsWithCounts();
|
||||
});
|
||||
|
||||
safeHandle('media:rebuildFromFiles', async () => {
|
||||
// Ensure project context is current before rebuilding
|
||||
const projectEngine = getProjectEngine();
|
||||
|
||||
@@ -53,6 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getUrl: (id: string) => ipcRenderer.invoke('media:getUrl', id),
|
||||
getFilePath: (id: string) => ipcRenderer.invoke('media:getFilePath', id),
|
||||
getAll: () => ipcRenderer.invoke('media:getAll'),
|
||||
filter: (filter: unknown) => ipcRenderer.invoke('media:filter', filter),
|
||||
search: (query: string) => ipcRenderer.invoke('media:search', query),
|
||||
getByYearMonth: () => ipcRenderer.invoke('media:getByYearMonth'),
|
||||
getTags: () => ipcRenderer.invoke('media:getTags'),
|
||||
getTagsWithCounts: () => ipcRenderer.invoke('media:getTagsWithCounts'),
|
||||
rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'),
|
||||
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size),
|
||||
regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id),
|
||||
|
||||
Reference in New Issue
Block a user