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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user