Feature/python api image discovery (#34)

* Expose chat.analyzeMediaImage in Python API for batch image metadata generation

* Fix updateMedia losing linkedPostIds by reading existing sidecar before overwriting

* Also preserve author from sidecar when DB value is null (data drift)

* Extend MetadataDiffEngine to cover media, scripts, and templates

* Redesign MetadataDiffPanel: item-first view with field pills, filtering, and per-item multi-field diffs

* Fix task:progress startsWith crash (taskId not id) and nested button violation in field pills

* Populate field diffs for file-missing items and show fileMissing badge in UI

* feat: extended meta diff

* feat: meta diff als reconstructs orphans

* chore: updated documentation

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-04 22:37:43 +01:00
committed by GitHub
parent 08ef72a802
commit c4a032346c
23 changed files with 3170 additions and 349 deletions

View File

@@ -1090,6 +1090,114 @@ tags: ["nature", "sunset"]`;
);
});
it('should preserve linkedPostIds from existing sidecar when updating metadata', async () => {
const fs = await import('fs/promises');
const filePath = '/mock/media/linked.jpg';
const sidecarPath = `${filePath}.meta`;
// Pre-populate sidecar with linkedPostIds
mockFiles.set(normalizePath(sidecarPath), `---
id: linked-media-id
originalName: "linked.jpg"
mimeType: image/jpeg
size: 1024
createdAt: 2026-01-01T00:00:00.000Z
updatedAt: 2026-01-01T00:00:00.000Z
author: "Original Author"
tags: ["nature", "photo"]
linkedPostIds: ["post-1", "post-2"]`);
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: 'linked-media-id',
projectId: 'default',
originalName: 'linked.jpg',
mimeType: 'image/jpeg',
size: 1024,
filePath,
title: 'Old title',
author: 'Original Author',
tags: '["nature", "photo"]',
createdAt: new Date(),
updatedAt: new Date(),
}),
});
return chain;
});
vi.mocked(fs.writeFile).mockClear();
await mediaEngine.updateMedia('linked-media-id', { title: 'New title' });
// Verify sidecar was written
expect(fs.writeFile).toHaveBeenCalledWith(
sidecarPath,
expect.any(String),
expect.anything()
);
// The written sidecar content must preserve linkedPostIds AND author
const writtenContent = vi.mocked(fs.writeFile).mock.calls.find(
c => normalizePath(c[0] as string) === normalizePath(sidecarPath)
)?.[1] as string;
expect(writtenContent).toContain('linkedPostIds: ["post-1", "post-2"]');
expect(writtenContent).toContain('author: "Original Author"');
expect(writtenContent).toContain('title: "New title"');
});
it('should preserve author from sidecar when DB has null author', async () => {
const fs = await import('fs/promises');
const filePath = '/mock/media/author-drift.jpg';
const sidecarPath = `${filePath}.meta`;
// Sidecar has author but DB does not (data drift)
mockFiles.set(normalizePath(sidecarPath), `---
id: author-drift-id
originalName: "author-drift.jpg"
mimeType: image/jpeg
size: 2048
createdAt: 2026-01-01T00:00:00.000Z
updatedAt: 2026-01-01T00:00:00.000Z
author: "hugo"
tags: []
linkedPostIds: ["post-x"]`);
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: 'author-drift-id',
projectId: 'default',
originalName: 'author-drift.jpg',
mimeType: 'image/jpeg',
size: 2048,
filePath,
title: 'Old title',
author: null, // DB has null, sidecar has "hugo"
tags: '[]',
createdAt: new Date(),
updatedAt: new Date(),
}),
});
return chain;
});
vi.mocked(fs.writeFile).mockClear();
await mediaEngine.updateMedia('author-drift-id', { alt: 'New alt text' });
const writtenContent = vi.mocked(fs.writeFile).mock.calls.find(
c => normalizePath(c[0] as string) === normalizePath(sidecarPath)
)?.[1] as string;
expect(writtenContent).toContain('author: "hugo"');
expect(writtenContent).toContain('alt: "New alt text"');
expect(writtenContent).toContain('linkedPostIds: ["post-x"]');
});
it('should update FTS index', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();