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

@@ -382,7 +382,7 @@ export class MediaEngine extends EventEmitter {
return sidecarPath;
}
private async readSidecarFile(sidecarPath: string): Promise<MediaMetadata | null> {
async readSidecarFile(sidecarPath: string): Promise<MediaMetadata | null> {
try {
// Check if file exists first to avoid noisy errors
try {
@@ -622,6 +622,19 @@ export class MediaEngine extends EventEmitter {
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
if (!dbMedia) return null;
// Read existing sidecar to preserve fields that may only exist there
// (e.g. linkedPostIds is sidecar-only, and author/title may have drifted)
const existingSidecar = await this.readSidecarFile(`${dbMedia.filePath}.meta`);
if (existingSidecar) {
if (existingSidecar.linkedPostIds?.length && !data.linkedPostIds) {
updated.linkedPostIds = existingSidecar.linkedPostIds;
}
// Preserve sidecar values for fields the caller didn't explicitly set
if (existingSidecar.author && !updated.author && !('author' in data)) {
updated.author = existingSidecar.author;
}
}
await this.writeSidecarFile(updated, dbMedia.filePath);
await db.update(media)

File diff suppressed because it is too large Load Diff

View File

@@ -635,21 +635,84 @@ export class PostEngine extends EventEmitter {
return false;
}
// Read content from the existing file
// Read content from the existing file, fall back to DB content if file is missing
const fileData = await this.readPostFile(dbPost.filePath);
if (!fileData) {
return false;
const body = fileData?.content ?? dbPost.content ?? '';
// Build the full post data with DB metadata and content
const postData = this.dbRowToPostData(dbPost, body);
// Write the file (may recreate it if missing, path may change if slug changed)
const newFilePath = await this.writePostFile(postData);
// If the written path differs from DB (e.g. slug changed), update DB
if (newFilePath !== dbPost.filePath) {
await db.update(posts).set({ filePath: newFilePath }).where(eq(posts.id, postId));
}
// Build the full post data with DB metadata (tags) and file content
const postData = this.dbRowToPostData(dbPost, fileData.content);
// Re-write the file with updated metadata
await this.writePostFile(postData);
return true;
}
/**
* Import a single orphan file (exists on disk but not in DB) into the database
* as a published post. Reads frontmatter metadata and content from the file,
* ensures unique ID/slug, and inserts a new DB row pointing to the existing file.
*
* @returns The imported PostData, or null if the file could not be read/parsed.
*/
async importOrphanFile(filePath: string): Promise<PostData | null> {
const db = getDatabase().getLocal();
const postData = await this.readPostFile(filePath);
if (!postData) return null;
// Ensure unique ID and slug within the current project
const { id, slug } = await this.ensureUniquePostIdentity(postData.id, postData.slug);
const checksum = this.calculateChecksum(postData.content);
await db.insert(posts).values({
id,
projectId: this.currentProjectId,
title: postData.title,
slug,
excerpt: postData.excerpt,
content: null,
status: 'published',
author: postData.author,
language: postData.language || null,
createdAt: postData.createdAt,
updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt || postData.updatedAt,
filePath,
checksum,
tags: JSON.stringify(postData.tags),
categories: JSON.stringify(postData.categories),
});
await this.updateFTSIndex({
id,
projectId: this.currentProjectId,
title: postData.title,
content: postData.content,
excerpt: postData.excerpt,
tags: postData.tags,
categories: postData.categories,
});
const imported: PostData = {
...postData,
id,
slug,
status: 'published',
publishedAt: postData.publishedAt || postData.updatedAt,
};
this.emit('postCreated', imported);
await this.notifier.notify('post', id, 'created');
return imported;
}
async getAllPosts(options?: PaginationOptions): Promise<PaginatedResult<PostData>> {
const db = getDatabase().getLocal();
const limit = options?.limit ?? 500;

View File

@@ -64,7 +64,7 @@ export interface ScriptValidationResult {
errors: string[];
}
interface ParsedScriptFile {
export interface ParsedScriptFile {
metadata: {
id?: string;
projectId?: string;
@@ -789,7 +789,7 @@ export class ScriptEngine extends EventEmitter {
return results;
}
private async readScriptFileWithMetadata(filePath: string): Promise<ParsedScriptFile | null> {
async readScriptFileWithMetadata(filePath: string): Promise<ParsedScriptFile | null> {
try {
const rawContent = await fs.readFile(filePath, 'utf-8');
return this.parseScriptFile(rawContent);

View File

@@ -67,7 +67,7 @@ export interface TemplateDeleteResult {
references?: { postIds: string[]; tagIds: string[] };
}
interface ParsedTemplateFile {
export interface ParsedTemplateFile {
metadata: {
id?: string;
projectId?: string;
@@ -889,7 +889,7 @@ export class TemplateEngine extends EventEmitter {
return results;
}
private async readTemplateFileWithMetadata(filePath: string): Promise<ParsedTemplateFile | null> {
async readTemplateFileWithMetadata(filePath: string): Promise<ParsedTemplateFile | null> {
try {
const rawContent = await fs.readFile(filePath, 'utf-8');
return this.parseTemplateFile(rawContent);