From 43d7bc96e75e93b456e14f11a7626e355a8eaf77 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 14 Feb 2026 13:02:54 +0100 Subject: [PATCH] fix: rebuild database for media also rebuilds post-to-media linkage --- src/main/engine/MediaEngine.ts | 20 +++- tests/engine/MediaEngine.test.ts | 169 ++++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 5 deletions(-) diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index 0dc7ffc..e04c49a 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -6,7 +6,7 @@ import * as crypto from 'crypto'; import { eq, and, gte, lte, lt, desc } from 'drizzle-orm'; import { app } from 'electron'; import { getDatabase } from '../database'; -import { media, Media, NewMedia } from '../database/schema'; +import { media, Media, NewMedia, postMedia } from '../database/schema'; // Thumbnail sizes const THUMBNAIL_SIZES = { @@ -769,6 +769,10 @@ export class MediaEngine extends EventEmitter { console.log(`Deleted ${existingMedia.length} existing media record(s) for project ${this.currentProjectId}`); } + // Also delete all post-media links for the current project + await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId)); + console.log(`Deleted post-media links for project ${this.currentProjectId}`); + onProgress(5, 'Scanning media directory...'); // Recursively find all .meta files in the media directory tree @@ -834,6 +838,20 @@ export class MediaEngine extends EventEmitter { checksum, tags: JSON.stringify(metadata.tags), }); + + // Insert post-media links based on linkedPostIds from sidecar + const linkedPostIds = metadata.linkedPostIds || []; + for (let j = 0; j < linkedPostIds.length; j++) { + const postId = linkedPostIds[j]; + await db.insert(postMedia).values({ + id: uuidv4(), + projectId: this.currentProjectId, + postId, + mediaId: metadata.id, + sortOrder: j, + createdAt: new Date(), + }); + } } catch (error) { console.error(`Media file not found for sidecar: ${sidecarPath}`, error); } diff --git a/tests/engine/MediaEngine.test.ts b/tests/engine/MediaEngine.test.ts index b71f295..4ff9bd2 100644 --- a/tests/engine/MediaEngine.test.ts +++ b/tests/engine/MediaEngine.test.ts @@ -27,8 +27,14 @@ import { MediaEngine, MediaData } from '../../src/main/engine/MediaEngine'; // Create mock data stores const mockMedia = new Map(); +const mockPostMedia = new Map(); const mockFiles = new Map(); +// Track database operations for testing +let mediaDeleteCalled = false; +let postMediaDeleteCalled = false; +let postMediaInserts: any[] = []; + // Create chainable mock for Drizzle ORM function createSelectChain() { return { @@ -47,9 +53,15 @@ function createSelectChain() { function createDrizzleMock() { return { select: vi.fn(() => createSelectChain()), - insert: vi.fn(() => ({ + insert: vi.fn((table: any) => ({ values: vi.fn((data: any) => { - if (data && data.id) { + // Check if this is an insert to postMedia table by looking at inserted data structure + if (data && data.postId && data.mediaId && !data.filename) { + // This is a postMedia insert + mockPostMedia.set(data.id, data); + postMediaInserts.push(data); + } else if (data && data.id) { + // This is a media insert mockMedia.set(data.id, data); } return Promise.resolve(); @@ -60,8 +72,20 @@ function createDrizzleMock() { where: vi.fn(() => Promise.resolve()), })), })), - delete: vi.fn(() => ({ - where: vi.fn(() => Promise.resolve()), + delete: vi.fn((table: any) => ({ + where: vi.fn((condition: any) => { + // Track which table is being deleted from + // We detect by the condition - if it involves projectId and no filename, it's likely postMedia + // This is a simplified heuristic for testing + if (table && table.postId !== undefined) { + postMediaDeleteCalled = true; + mockPostMedia.clear(); + } else { + mediaDeleteCalled = true; + mockMedia.clear(); + } + return Promise.resolve(); + }), })), }; } @@ -135,7 +159,11 @@ describe('MediaEngine', () => { beforeEach(() => { vi.clearAllMocks(); mockMedia.clear(); + mockPostMedia.clear(); mockFiles.clear(); + mediaDeleteCalled = false; + postMediaDeleteCalled = false; + postMediaInserts = []; resetMockCounters(); // Reset the mock implementations @@ -519,4 +547,137 @@ describe('MediaEngine', () => { expect(decemberPath).toMatch(/[/\\]2024[/\\]12[/\\]/); }); }); + + describe('rebuildDatabaseFromFiles', () => { + beforeEach(() => { + mediaEngine.setProjectContext('test-project'); + }); + + it('should delete post-media links for the project during rebuild', async () => { + const fs = await import('fs/promises'); + + // Mock readdir to return media with sidecar + vi.mocked(fs.readdir).mockImplementation(async (dir: string, options?: any) => { + if (typeof dir === 'string' && dir.includes('media')) { + return [ + { name: 'media-1.jpg', isFile: () => true, isDirectory: () => false }, + { name: 'media-1.jpg.meta', isFile: () => true, isDirectory: () => false }, + ] as any; + } + return []; + }); + + // Set up sidecar file with linkedPostIds + const sidecarContent = `id: media-1 +originalName: test-image.jpg +mimeType: image/jpeg +size: 1024 +createdAt: 2024-01-15T10:00:00.000Z +updatedAt: 2024-01-15T10:00:00.000Z +linkedPostIds: ["post-1", "post-2"]`; + mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg.meta', sidecarContent); + mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg', Buffer.from('image-data')); + + await mediaEngine.rebuildDatabaseFromFiles(); + + expect(postMediaDeleteCalled).toBe(true); + }); + + it('should insert post-media links based on linkedPostIds from sidecar files', async () => { + const fs = await import('fs/promises'); + + // Mock readdir to simulate directory traversal + vi.mocked(fs.readdir).mockImplementation(async (dir: string, options?: any) => { + if (typeof dir === 'string' && dir.includes('media')) { + return [ + { name: 'media-1.jpg', isFile: () => true, isDirectory: () => false }, + { name: 'media-1.jpg.meta', isFile: () => true, isDirectory: () => false }, + ] as any; + } + return []; + }); + + // Set up sidecar file with linkedPostIds + const sidecarContent = `id: media-1 +originalName: test-image.jpg +mimeType: image/jpeg +size: 1024 +createdAt: 2024-01-15T10:00:00.000Z +updatedAt: 2024-01-15T10:00:00.000Z +linkedPostIds: ["post-1", "post-2"]`; + mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg.meta', sidecarContent); + mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg', Buffer.from('image-data')); + + await mediaEngine.rebuildDatabaseFromFiles(); + + // Should have inserted 2 post-media links + expect(postMediaInserts).toHaveLength(2); + expect(postMediaInserts[0].postId).toBe('post-1'); + expect(postMediaInserts[0].mediaId).toBe('media-1'); + expect(postMediaInserts[1].postId).toBe('post-2'); + expect(postMediaInserts[1].mediaId).toBe('media-1'); + }); + + it('should handle media without linkedPostIds', async () => { + const fs = await import('fs/promises'); + + vi.mocked(fs.readdir).mockImplementation(async (dir: string, options?: any) => { + if (typeof dir === 'string' && dir.includes('media')) { + return [ + { name: 'media-1.jpg', isFile: () => true, isDirectory: () => false }, + { name: 'media-1.jpg.meta', isFile: () => true, isDirectory: () => false }, + ] as any; + } + return []; + }); + + // Sidecar without linkedPostIds + const sidecarContent = `id: media-1 +originalName: test-image.jpg +mimeType: image/jpeg +size: 1024 +createdAt: 2024-01-15T10:00:00.000Z +updatedAt: 2024-01-15T10:00:00.000Z`; + mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg.meta', sidecarContent); + mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg', Buffer.from('image-data')); + + await mediaEngine.rebuildDatabaseFromFiles(); + + // Should not insert any post-media links + expect(postMediaInserts).toHaveLength(0); + }); + + it('should set correct sortOrder for post-media links', async () => { + const fs = await import('fs/promises'); + + vi.mocked(fs.readdir).mockImplementation(async (dir: string, options?: any) => { + if (typeof dir === 'string' && dir.includes('media')) { + return [ + { name: 'media-1.jpg', isFile: () => true, isDirectory: () => false }, + { name: 'media-1.jpg.meta', isFile: () => true, isDirectory: () => false }, + ] as any; + } + return []; + }); + + // Sidecar with multiple linked posts + const sidecarContent = `id: media-1 +originalName: test-image.jpg +mimeType: image/jpeg +size: 1024 +createdAt: 2024-01-15T10:00:00.000Z +updatedAt: 2024-01-15T10:00:00.000Z +linkedPostIds: ["post-a", "post-b", "post-c"]`; + mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg.meta', sidecarContent); + mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg', Buffer.from('image-data')); + + await mediaEngine.rebuildDatabaseFromFiles(); + + // Verify sortOrder is set correctly (0, 1, 2) + expect(postMediaInserts).toHaveLength(3); + expect(postMediaInserts[0].sortOrder).toBe(0); + expect(postMediaInserts[1].sortOrder).toBe(1); + expect(postMediaInserts[2].sortOrder).toBe(2); + }); + }); });