fix: rebuild database for media also rebuilds post-to-media linkage

This commit is contained in:
2026-02-14 13:02:54 +01:00
parent 3068c8fd5e
commit 43d7bc96e7
2 changed files with 184 additions and 5 deletions

View File

@@ -6,7 +6,7 @@ import * as crypto from 'crypto';
import { eq, and, gte, lte, lt, desc } from 'drizzle-orm'; import { eq, and, gte, lte, lt, desc } from 'drizzle-orm';
import { app } from 'electron'; import { app } from 'electron';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { media, Media, NewMedia } from '../database/schema'; import { media, Media, NewMedia, postMedia } from '../database/schema';
// Thumbnail sizes // Thumbnail sizes
const 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}`); 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...'); onProgress(5, 'Scanning media directory...');
// Recursively find all .meta files in the media directory tree // Recursively find all .meta files in the media directory tree
@@ -834,6 +838,20 @@ export class MediaEngine extends EventEmitter {
checksum, checksum,
tags: JSON.stringify(metadata.tags), 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) { } catch (error) {
console.error(`Media file not found for sidecar: ${sidecarPath}`, error); console.error(`Media file not found for sidecar: ${sidecarPath}`, error);
} }

View File

@@ -27,8 +27,14 @@ import { MediaEngine, MediaData } from '../../src/main/engine/MediaEngine';
// Create mock data stores // Create mock data stores
const mockMedia = new Map<string, any>(); const mockMedia = new Map<string, any>();
const mockPostMedia = new Map<string, any>();
const mockFiles = new Map<string, Buffer | string>(); const mockFiles = new Map<string, Buffer | string>();
// Track database operations for testing
let mediaDeleteCalled = false;
let postMediaDeleteCalled = false;
let postMediaInserts: any[] = [];
// Create chainable mock for Drizzle ORM // Create chainable mock for Drizzle ORM
function createSelectChain() { function createSelectChain() {
return { return {
@@ -47,9 +53,15 @@ function createSelectChain() {
function createDrizzleMock() { function createDrizzleMock() {
return { return {
select: vi.fn(() => createSelectChain()), select: vi.fn(() => createSelectChain()),
insert: vi.fn(() => ({ insert: vi.fn((table: any) => ({
values: vi.fn((data: 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); mockMedia.set(data.id, data);
} }
return Promise.resolve(); return Promise.resolve();
@@ -60,8 +72,20 @@ function createDrizzleMock() {
where: vi.fn(() => Promise.resolve()), where: vi.fn(() => Promise.resolve()),
})), })),
})), })),
delete: vi.fn(() => ({ delete: vi.fn((table: any) => ({
where: vi.fn(() => Promise.resolve()), 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(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockMedia.clear(); mockMedia.clear();
mockPostMedia.clear();
mockFiles.clear(); mockFiles.clear();
mediaDeleteCalled = false;
postMediaDeleteCalled = false;
postMediaInserts = [];
resetMockCounters(); resetMockCounters();
// Reset the mock implementations // Reset the mock implementations
@@ -519,4 +547,137 @@ describe('MediaEngine', () => {
expect(decemberPath).toMatch(/[/\\]2024[/\\]12[/\\]/); 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);
});
});
}); });