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

50
API.md
View File

@@ -1,6 +1,6 @@
# API Documentation
Contract version: 1.10.0
Contract version: 1.11.0
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
@@ -3462,8 +3462,42 @@ result = await bds.tags.sync_from_posts()
**Module APIs**
- [chat.analyzeMediaImage](#chatanalyzemediaimage)
- [chat.detectPostLanguage](#chatdetectpostlanguage)
### chat.analyzeMediaImage
Analyze an image and generate title, alt text, and caption using AI.
**Parameters**
- mediaId (str, required)
- language (str, optional)
**Response specification**
- Return type: `ImageAnalysisResult`
- Data structures: `ImageAnalysisResult`
**Example call**
```python
from bds_api import bds
result = await bds.chat.analyze_media_image(media_id='media-1')
```
**Example response**
```python
{
'success': False,
'title': 'value',
'alt': 'value',
'caption': 'value',
'error': 'value'
}
```
### chat.detectPostLanguage
Detect the language of a post from its title and content.
@@ -4065,6 +4099,20 @@ Aggregate result from uploading the rendered site.
[↑ Back to Table of contents](#table-of-contents)
### ImageAnalysisResult
Result from AI image analysis containing generated title, alt text, and caption.
**Fields**
- success (`boolean`, required): Whether the analysis succeeded.
- title (`string`, optional): Generated image title (3-8 words).
- alt (`string`, optional): Generated alt text (5-12 words).
- caption (`string`, optional): Generated blog caption (5-20 words).
- error (`string`, optional): Error message when analysis failed.
[↑ Back to Table of contents](#table-of-contents)
---
Generated from contract at 2026-02-27T00:00:00.000Z.

View File

@@ -17,6 +17,7 @@
- [Importing from WordPress (WXR)](#importing-from-wordpress-wxr)
- [Using Git (Source Control)](#using-git-source-control)
- [Configuring settings](#configuring-settings)
- [Checking and repairing metadata](#checking-and-repairing-metadata)
- [Managing templates](#managing-templates)
- [Generating and publishing](#generating-and-publishing)
- [Typical editorial workflows](#typical-editorial-workflows)
@@ -458,6 +459,65 @@ Data maintenance actions are repair tools for specific situations, such as exter
---
## Checking and repairing metadata
Over time, metadata stored in the database and metadata stored in post files on disk can drift apart. This happens when files are edited outside bDS, when slugs change, or when manual file operations move or rename content. The Metadata Diff tool detects these inconsistencies and lets you resolve them without rebuilding the entire posts table.
### Opening the tool
Open **Settings**, then click **Metadata Diff** under the data maintenance section. The tool shows summary statistics for your project (total posts, published, drafts, media, scripts, templates) and a **Scan** button.
### Running a scan
Click **Scan** to compare every published entity against its corresponding file on disk. The scan covers four entity types — posts, media, scripts, and templates — and runs them in parallel. Results appear in four tabs, each showing a badge with the number of items that have differences.
For each item with differences, the tool shows every mismatched field side by side: the database value and the file value. Typical fields include title, tags, categories, excerpt, author, and language.
### Understanding field pills
Above the item list, clickable field pills summarize how many items have a particular type of difference (for example, "Tags: 12" or "Title: 3"). Clicking a pill filters the list to show only items with that specific field difference, which helps when resolving one type of issue at a time.
### Repairing differences
Each field pill has two sync buttons:
- **DB→D** updates the files on disk to match the database values. Use this when you trust the database as the source of truth — for example, after correcting metadata in the editor.
- **D→DB** (called F→DB for some entity types) updates the database to match the file values. Use this when you trust the files — for example, after editing frontmatter by hand or importing corrected files from a collaborator.
Both operations process all affected items for that field at once. After syncing, the tool automatically rescans to confirm the differences are resolved.
### File-missing posts
If a post exists in the database but its file is missing from disk, the item appears with a **File missing** badge. All fields show the database value against "(file missing)" on the file side. Using **DB→D** on these items recreates the file from the database content and metadata. If the post's slug changed since the file was originally written, the recreated file uses the current slug and the database file path is updated to match.
### Orphan files
If markdown files exist in the posts directory but have no matching database entry, they appear in the **Orphan Files** section below the item list. These typically result from slug changes, manual file copies, or partial imports.
Each orphan card shows the file's slug, path, and any frontmatter ID found in the file. To bring all orphan files back into the database, click the **D→DB** button in the orphan section header. This reads each file's frontmatter and content, creates a new database entry as a published post, and assigns a unique slug if the original slug conflicts with an existing post. The tool rescans automatically afterward.
### When to use this tool
- After editing post files outside bDS (text editor, script, Git merge)
- After a Git pull that changed post files from another contributor
- When the sidebar shows unexpected titles, tags, or categories
- When you suspect slug changes left behind stale files
- As a preflight check before generating or publishing the site
This tool is not needed during normal editing workflows inside bDS, where database and file state are kept in sync automatically.
### Key takeaways
- Metadata Diff compares database records against files on disk for posts, media, scripts, and templates.
- Field pills let you filter and bulk-repair one type of difference at a time.
- DB→D rewrites files from the database; D→DB updates the database from files.
- File-missing posts can be recreated; orphan files can be imported.
- Use this tool after external changes, not as part of routine editing.
[↑ Back to In this article](#in-this-article)
---
## Managing templates
Templates control the Liquid layout used when bDS generates your blog's HTML pages. bDS ships with built-in templates, but you can create and manage your own through the Templates view in the Activity Bar.
@@ -483,20 +543,48 @@ Templates follow the same draft/published workflow as scripts. You can iterate o
Publishing in bDS is a two-stage process: first you generate the static site locally, then you optionally deploy it to a remote server.
**Generation** produces a complete static blog from your published content. This includes individual post pages, paginated category, tag, and date archive routes, standalone pages, plus `sitemap.xml`, `rss.xml`, `atom.xml`, and `calendar.json`. Generation uses content-hash-based incremental writes, so only changed pages are rewritten. Before generating, ensure the Public Base URL is configured in project settings — sitemap and feed URLs depend on it.
### Full generation
After generation, you can run **site validation** to compare the sitemap against generated HTML files. Validation detects missing, extra, or stale pages and can auto-repair by re-rendering only the affected routes.
**Generation** produces a complete static blog from your published content. This includes individual post pages, paginated category, tag, and date archive routes, standalone pages, plus `sitemap.xml`, `rss.xml`, `atom.xml`, and `calendar.json`. Generation uses content-hash-based incremental writes, so only pages whose content actually changed are rewritten on disk. Before generating, ensure the Public Base URL is configured in project settings — sitemap and feed URLs depend on it.
Full generation is appropriate when you first set up your site, after major template changes, or when you want a clean rebuild. For day-to-day content additions, site validation offers a faster alternative.
### Site validation and incremental publishing
After generating a site at least once, you can use **site validation** to detect what changed and re-render only the affected routes — without regenerating the entire site.
Click **Validate Site** to run a comparison between the sitemap and the generated HTML directory. Validation detects three types of issues:
- **Missing pages** — URLs listed in the sitemap that have no corresponding HTML file. This happens when you publish new posts or add new tags/categories since the last generation.
- **Extra pages** — HTML files that exist on disk but are no longer in the sitemap. This happens when you unpublish, delete, or recategorize posts.
- **Updated posts** — Posts whose source file on disk has been modified since its HTML page was last generated. This catches content edits, tag changes, or metadata updates that require the page to be re-rendered.
After validation completes, click **Apply** to let bDS resolve all detected issues automatically. Missing and updated pages are re-rendered using the current templates, and extra pages are deleted along with any empty parent directories. The apply step uses targeted rendering — it identifies exactly which individual posts, archive pages, category routes, tag routes, and date routes are affected, and re-renders only those. If the affected routes span too many sections, it falls back to a section-by-section render which is still faster than a full generation.
This makes site validation the practical tool for incremental publishing. The typical workflow after creating or editing a few posts is:
1. Publish the posts (mark as published in the editor)
2. Click **Validate Site** to see what needs updating
3. Click **Apply** to re-render only the affected pages
4. Commit the changes in Source Control
5. Deploy via SSH when ready
This is significantly faster than full generation, especially for large blogs with hundreds or thousands of posts.
### SSH publishing
**SSH publishing** uploads generated files to a remote server via `scp` or `rsync`. Configure your SSH connection details in project settings, then publish from the application. bDS uploads HTML, thumbnails, and media in parallel for efficiency.
The recommended lifecycle is: publish content locally (mark as published), generate the site, validate, commit the generated output, and then deploy via SSH when ready.
The recommended lifecycle is: publish content locally (mark as published), generate or validate+apply, commit the generated output, and then deploy via SSH when ready.
### Key takeaways
- Generation produces a full static site with incremental writes.
- Public Base URL must be set before generation.
- Site validation catches inconsistencies between sitemap and generated files.
- Full generation produces a complete static site; use it for initial builds or major changes.
- Site validation detects missing, extra, and updated pages by comparing the sitemap to generated HTML.
- Apply resolves all validation issues by targeted re-rendering — much faster than full generation.
- Use validate+apply as the standard incremental publishing workflow after creating or editing posts.
- SSH publishing deploys via `scp` or `rsync` with parallel uploads.
- Public Base URL must be set before generation.
- Commit generated output before deploying for recoverability.
[↑ Back to In this article](#in-this-article)
@@ -543,7 +631,7 @@ When network access returns, synchronize in a controlled order: pull if needed,
If content appears published locally but not visible to collaborators, the most common cause is that changes were published but not committed and pushed. In this case, confirm repository status, create a commit, and then push to the expected remote branch.
If content lists or references seem inconsistent after manual file operations outside bDS, run the rebuild tools in Settings to re-align database/index state with filesystem reality. After each rebuild, verify a small set of representative posts and media items rather than assuming full correctness immediately.
If content lists or references seem inconsistent after manual file operations outside bDS, start with a **Metadata Diff scan** in Settings to identify specific differences between database and file state. Repair individual fields or bulk-sync as needed. If broader inconsistency remains, use the full rebuild tools to re-align database and index state with filesystem reality. After any repair action, verify a small set of representative posts and media items rather than assuming full correctness immediately.
If you are concerned about losing work, increase commit frequency at meaningful milestones, especially after publish actions. Frequent, focused commits are the most reliable and practical recovery strategy for editorial teams.

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);

View File

@@ -1,56 +1,110 @@
import type { EngineBundle } from '../engine/EngineBundle';
import type { MediaDiffField, ScriptDiffField, TemplateDiffField } from '../engine/MetadataDiffEngine';
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
/** Helper: set project context on the MetadataDiffEngine from the active project */
async function withProjectContext(bundle: EngineBundle): Promise<void> {
const activeProject = await bundle.projectEngine.getActiveProject();
if (activeProject) {
bundle.metadataDiffEngine.setProjectContext(activeProject.id);
}
}
export function registerMetadataDiffHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void {
const engine = () => bundle.metadataDiffEngine;
// ── Posts ──
safeHandle('metadataDiff:getStats', async () => {
const engine = bundle.metadataDiffEngine;
const projectEngine = bundle.projectEngine;
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.getTableStats();
await withProjectContext(bundle);
return engine().getTableStats();
});
safeHandle('metadataDiff:scan', async () => {
const engine = bundle.metadataDiffEngine;
const projectEngine = bundle.projectEngine;
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
await withProjectContext(bundle);
const taskId = `metadata-diff-scan-${Date.now()}`;
// Resolve the posts directory so the scanner can detect orphan files
const activeProject = await bundle.projectEngine.getActiveProject();
const projectId = activeProject?.id || 'default';
const paths = bundle.projectEngine.getProjectPaths(projectId, activeProject?.dataPath);
return bundle.taskManager.runTask({
id: taskId,
name: 'Scanning for metadata differences',
execute: async (onProgress) => {
return engine.scanAllPublishedPosts((current, total, message) => {
return engine().scanAllPublishedPosts((current, total, message) => {
const percent = total > 0 ? (current / total) * 100 : 0;
onProgress(percent, message);
});
}, paths.posts);
},
});
});
safeHandle('metadataDiff:syncDbToFile', async (_, postIds: string[], groupLabel: string) => {
const engine = bundle.metadataDiffEngine;
const projectEngine = bundle.projectEngine;
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.runSyncDbToFileTask(postIds, groupLabel);
await withProjectContext(bundle);
return engine().runSyncDbToFileTask(postIds, groupLabel);
});
safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => {
const engine = bundle.metadataDiffEngine;
const projectEngine = bundle.projectEngine;
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
await withProjectContext(bundle);
return engine().runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
});
}
// ── Media ──
safeHandle('metadataDiff:scanMedia', async () => {
await withProjectContext(bundle);
return engine().runMediaScanTask();
});
safeHandle('metadataDiff:syncMediaDbToFile', async (_, mediaIds: string[], groupLabel: string) => {
await withProjectContext(bundle);
return engine().runMediaSyncDbToFileTask(mediaIds, groupLabel);
});
safeHandle('metadataDiff:syncMediaFileToDb', async (_, mediaIds: string[], field: string, groupLabel: string) => {
await withProjectContext(bundle);
return engine().runMediaSyncFileToDbTask(mediaIds, field as MediaDiffField, groupLabel);
});
// ── Scripts ──
safeHandle('metadataDiff:scanScripts', async () => {
await withProjectContext(bundle);
return engine().runScriptScanTask();
});
safeHandle('metadataDiff:syncScriptDbToFile', async (_, scriptIds: string[], groupLabel: string) => {
await withProjectContext(bundle);
return engine().runScriptSyncDbToFileTask(scriptIds, groupLabel);
});
safeHandle('metadataDiff:syncScriptFileToDb', async (_, scriptIds: string[], field: string, groupLabel: string) => {
await withProjectContext(bundle);
return engine().runScriptSyncFileToDbTask(scriptIds, field as ScriptDiffField, groupLabel);
});
// ── Templates ──
safeHandle('metadataDiff:scanTemplates', async () => {
await withProjectContext(bundle);
return engine().runTemplateScanTask();
});
safeHandle('metadataDiff:syncTemplateDbToFile', async (_, templateIds: string[], groupLabel: string) => {
await withProjectContext(bundle);
return engine().runTemplateSyncDbToFileTask(templateIds, groupLabel);
});
safeHandle('metadataDiff:syncTemplateFileToDb', async (_, templateIds: string[], field: string, groupLabel: string) => {
await withProjectContext(bundle);
return engine().runTemplateSyncFileToDbTask(templateIds, field as TemplateDiffField, groupLabel);
});
// ── Orphan file import ──
safeHandle('metadataDiff:importOrphanFiles', async (_, filePaths: string[]) => {
await withProjectContext(bundle);
return engine().runImportOrphanFilesTask(filePaths);
});
}

View File

@@ -920,7 +920,7 @@ app.whenReady().then(async () => {
const tagEngine = new TagEngine(postEngine);
const scriptEngine = new ScriptEngine(noopNotifier);
const templateEngine = new TemplateEngine(noopNotifier);
const metadataDiffEngine = new MetadataDiffEngine(postEngine);
const metadataDiffEngine = new MetadataDiffEngine(postEngine, mediaEngine, scriptEngine, templateEngine);
const publishEngine = new PublishEngine();
const gitEngine = new GitEngine();
const gitApiAdapter = new GitApiAdapter(gitEngine, projectEngine);

View File

@@ -283,6 +283,16 @@ export const electronAPI: ElectronAPI = {
scan: () => ipcRenderer.invoke('metadataDiff:scan'),
syncDbToFile: (postIds: string[], groupLabel: string) => ipcRenderer.invoke('metadataDiff:syncDbToFile', postIds, groupLabel),
syncFileToDb: (postIds: string[], field: string, groupLabel: string) => ipcRenderer.invoke('metadataDiff:syncFileToDb', postIds, field, groupLabel),
scanMedia: () => ipcRenderer.invoke('metadataDiff:scanMedia'),
syncMediaDbToFile: (mediaIds: string[], groupLabel: string) => ipcRenderer.invoke('metadataDiff:syncMediaDbToFile', mediaIds, groupLabel),
syncMediaFileToDb: (mediaIds: string[], field: string, groupLabel: string) => ipcRenderer.invoke('metadataDiff:syncMediaFileToDb', mediaIds, field, groupLabel),
scanScripts: () => ipcRenderer.invoke('metadataDiff:scanScripts'),
syncScriptDbToFile: (scriptIds: string[], groupLabel: string) => ipcRenderer.invoke('metadataDiff:syncScriptDbToFile', scriptIds, groupLabel),
syncScriptFileToDb: (scriptIds: string[], field: string, groupLabel: string) => ipcRenderer.invoke('metadataDiff:syncScriptFileToDb', scriptIds, field, groupLabel),
scanTemplates: () => ipcRenderer.invoke('metadataDiff:scanTemplates'),
syncTemplateDbToFile: (templateIds: string[], groupLabel: string) => ipcRenderer.invoke('metadataDiff:syncTemplateDbToFile', templateIds, groupLabel),
syncTemplateFileToDb: (templateIds: string[], field: string, groupLabel: string) => ipcRenderer.invoke('metadataDiff:syncTemplateFileToDb', templateIds, field, groupLabel),
importOrphanFiles: (filePaths: string[]) => ipcRenderer.invoke('metadataDiff:importOrphanFiles', filePaths),
},
// Blog operations

View File

@@ -769,6 +769,10 @@ export interface ElectronAPI {
publishedPosts: number;
draftPosts: number;
totalMedia: number;
totalScripts: number;
publishedScripts: number;
totalTemplates: number;
publishedTemplates: number;
}>;
scan: () => Promise<{
totalScanned: number;
@@ -779,6 +783,7 @@ export interface ElectronAPI {
slug: string;
filePath?: string;
hasDifferences: boolean;
fileMissing?: boolean;
differences: Record<string, { dbValue: unknown; fileValue: unknown }>;
}>;
groups: Array<{
@@ -792,9 +797,75 @@ export interface ElectronAPI {
fileValue: unknown;
}>;
}>;
orphanFiles: Array<{
filePath: string;
slug: string;
title?: string;
id?: string;
}>;
}>;
syncDbToFile: (postIds: string[], groupLabel: string) => Promise<{ success: number; failed: number }>;
syncFileToDb: (postIds: string[], field: string, groupLabel: string) => Promise<{ success: number; failed: number }>;
scanMedia: () => Promise<{
totalScanned: number;
itemsWithDifferences: number;
differences: Array<{
mediaId: string;
originalName: string;
filePath?: string;
hasDifferences: boolean;
fileMissing?: boolean;
differences: Record<string, { dbValue: unknown; fileValue: unknown }>;
}>;
groups: Array<{
field: string;
label: string;
items: Array<{ mediaId: string; originalName: string; dbValue: unknown; fileValue: unknown }>;
}>;
}>;
syncMediaDbToFile: (mediaIds: string[], groupLabel: string) => Promise<{ success: number; failed: number }>;
syncMediaFileToDb: (mediaIds: string[], field: string, groupLabel: string) => Promise<{ success: number; failed: number }>;
scanScripts: () => Promise<{
totalScanned: number;
itemsWithDifferences: number;
differences: Array<{
scriptId: string;
title: string;
slug: string;
filePath?: string;
hasDifferences: boolean;
fileMissing?: boolean;
differences: Record<string, { dbValue: unknown; fileValue: unknown }>;
}>;
groups: Array<{
field: string;
label: string;
items: Array<{ scriptId: string; title: string; slug: string; dbValue: unknown; fileValue: unknown }>;
}>;
}>;
syncScriptDbToFile: (scriptIds: string[], groupLabel: string) => Promise<{ success: number; failed: number }>;
syncScriptFileToDb: (scriptIds: string[], field: string, groupLabel: string) => Promise<{ success: number; failed: number }>;
scanTemplates: () => Promise<{
totalScanned: number;
itemsWithDifferences: number;
differences: Array<{
templateId: string;
title: string;
slug: string;
filePath?: string;
hasDifferences: boolean;
fileMissing?: boolean;
differences: Record<string, { dbValue: unknown; fileValue: unknown }>;
}>;
groups: Array<{
field: string;
label: string;
items: Array<{ templateId: string; title: string; slug: string; dbValue: unknown; fileValue: unknown }>;
}>;
}>;
syncTemplateDbToFile: (templateIds: string[], groupLabel: string) => Promise<{ success: number; failed: number }>;
syncTemplateFileToDb: (templateIds: string[], field: string, groupLabel: string) => Promise<{ success: number; failed: number }>;
importOrphanFiles: (filePaths: string[]) => Promise<{ success: number; failed: number }>;
};
blog: {
generateSitemap: () => Promise<{

View File

@@ -183,13 +183,12 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('tags.getPostsWithTag', 'Get posts using a tag.', [requiredString('tagId')], 'string[]'),
method('tags.syncFromPosts', 'Sync tag index from posts.', [], 'SyncTagsResult'),
// NOTE: chat namespace intentionally excluded from Python API.
// AI/chat features (sendMessage, analyzeTaxonomy, analyzeMediaImage, etc.) are
// expensive external API calls that require user oversight and interactive streaming.
// This namespace can be re-added in a future version if AI-from-Python becomes a
// supported use case with proper rate limiting and cost controls.
// Exception: detectPostLanguage is exposed as a lightweight one-shot task.
// NOTE: most chat namespace methods intentionally excluded from Python API.
// AI/chat features (sendMessage, analyzeTaxonomy, etc.) are expensive external
// API calls that require user oversight and interactive streaming.
// Exceptions: lightweight one-shot tasks are exposed individually.
method('chat.analyzeMediaImage', 'Analyze an image and generate title, alt text, and caption using AI.', [requiredString('mediaId'), optionalString('language')], 'ImageAnalysisResult'),
method('chat.detectPostLanguage', 'Detect the language of a post from its title and content.', [requiredString('title'), requiredString('content')], '{ success: boolean; language?: string; error?: string }'),
method('sync.checkAvailability', 'Check if git is available.', [], 'GitAvailability'),
@@ -405,10 +404,21 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'filesSkipped', type: 'number', required: true, description: 'Total files skipped (already up-to-date).' },
],
},
{
name: 'ImageAnalysisResult',
description: 'Result from AI image analysis containing generated title, alt text, and caption.',
fields: [
{ name: 'success', type: 'boolean', required: true, description: 'Whether the analysis succeeded.' },
{ name: 'title', type: 'string', required: false, description: 'Generated image title (3-8 words).' },
{ name: 'alt', type: 'string', required: false, description: 'Generated alt text (5-12 words).' },
{ name: 'caption', type: 'string', required: false, description: 'Generated blog caption (5-20 words).' },
{ name: 'error', type: 'string', required: false, description: 'Error message when analysis failed.' },
],
},
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.10.0',
version: '1.11.0',
generatedAt: '2026-02-27T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,

View File

@@ -126,6 +126,53 @@
cursor: not-allowed;
}
/* Entity Tabs */
.diff-tabs {
display: flex;
gap: 2px;
margin-bottom: 16px;
border-bottom: 1px solid var(--input-border);
}
.diff-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--descriptionForeground);
font-size: 13px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.diff-tab:hover {
color: var(--editor-foreground);
}
.diff-tab.active {
color: var(--editor-foreground);
border-bottom-color: var(--button-background);
font-weight: 600;
}
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--badge-background, #e5a100);
color: var(--badge-foreground, #fff);
font-size: 11px;
font-weight: 600;
line-height: 1;
}
/* Results Section */
.diff-results {
flex: 1;
@@ -151,128 +198,177 @@
border-color: var(--testing-iconFailed);
}
/* Collapsible Groups */
.diff-group {
/* Field summary pills */
.diff-field-summaries {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 4px;
}
.field-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 16px;
border: 1px solid var(--input-border);
background: var(--sidebar-background);
color: var(--editor-foreground);
font-size: 12px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
user-select: none;
}
.field-pill:hover {
background: var(--list-hoverBackground);
}
.field-pill.active {
background: color-mix(in srgb, var(--button-background) 20%, var(--sidebar-background));
border-color: var(--button-background);
font-weight: 600;
}
.field-pill.clear-filter {
padding: 5px 8px;
border-color: var(--testing-iconFailed);
color: var(--testing-iconFailed);
font-weight: 600;
}
.field-pill-label {
text-transform: capitalize;
}
.field-pill-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--badge-background, #e5a100);
color: var(--badge-foreground, #fff);
font-size: 10px;
font-weight: 600;
line-height: 1;
}
.field-pill-actions {
display: inline-flex;
gap: 2px;
margin-left: 2px;
}
.pill-sync {
padding: 1px 5px;
font-size: 9px;
font-weight: 600;
border: 1px solid var(--input-border);
border-radius: 3px;
background: var(--input-background);
color: var(--editor-foreground);
cursor: pointer;
transition: background 0.15s;
}
.pill-sync:hover:not(:disabled) {
background: var(--list-hoverBackground);
}
.pill-sync:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pill-sync.db-to-file {
border-color: var(--button-background);
color: var(--button-background);
}
.pill-sync.file-to-db {
border-color: var(--testing-iconQueued);
color: var(--testing-iconQueued);
}
/* Item cards */
.diff-item-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.diff-item-card {
background: var(--sidebar-background);
border: 1px solid var(--sidebar-border);
border-radius: 6px;
overflow: hidden;
}
.diff-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: var(--list-hoverBackground);
cursor: pointer;
user-select: none;
}
.diff-group-header:hover {
background: var(--list-activeSelectionBackground);
}
.diff-group-title {
display: flex;
align-items: center;
gap: 8px;
.diff-item-header {
padding: 8px 12px;
font-weight: 600;
}
.diff-group-title .chevron {
font-size: 10px;
transition: transform 0.2s;
}
.diff-group-title .chevron.expanded {
transform: rotate(90deg);
}
.diff-group-count {
display: flex;
align-items: center;
gap: 8px;
}
.diff-group-count .badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: var(--badge-background);
color: var(--badge-foreground);
}
.diff-group-actions {
display: flex;
gap: 4px;
}
.diff-group-actions button {
padding: 4px 8px;
font-size: 11px;
border: 1px solid var(--input-border);
border-radius: 3px;
background: var(--input-background);
color: var(--editor-foreground);
cursor: pointer;
transition: background 0.2s;
}
.diff-group-actions button:hover:not(:disabled) {
font-size: 13px;
background: var(--list-hoverBackground);
}
.diff-group-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.diff-group-actions button.db-to-file {
border-color: var(--button-background);
color: var(--button-background);
}
.diff-group-actions button.file-to-db {
border-color: var(--testing-iconQueued);
color: var(--testing-iconQueued);
}
.diff-group-content {
padding: 12px;
border-top: 1px solid var(--sidebar-border);
}
.diff-group-content.collapsed {
display: none;
}
/* Post Items */
.diff-post-item {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
padding: 8px;
margin-bottom: 8px;
background: var(--editor-background);
border-radius: 4px;
font-size: 12px;
}
.diff-post-item:last-child {
margin-bottom: 0;
}
.diff-post-title {
font-weight: 500;
color: var(--editor-foreground);
border-bottom: 1px solid var(--sidebar-border);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
}
.diff-value {
.file-missing-badge {
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: 3px;
background: color-mix(in srgb, var(--editorWarning-foreground) 18%, transparent);
color: var(--editorWarning-foreground);
white-space: nowrap;
flex-shrink: 0;
}
.diff-item-card.file-missing {
border-color: color-mix(in srgb, var(--editorWarning-foreground) 30%, var(--sidebar-border));
}
.diff-item-fields {
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.diff-field-row {
display: grid;
grid-template-columns: 100px 1fr;
gap: 8px;
align-items: start;
font-size: 12px;
}
.diff-field-name {
font-weight: 600;
text-transform: capitalize;
color: var(--descriptionForeground);
padding-top: 4px;
}
.diff-field-values {
display: flex;
gap: 8px;
}
.diff-field-value {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 8px;
border-radius: 3px;
font-family: var(--editor-font-family);
@@ -282,20 +378,27 @@
white-space: nowrap;
}
.diff-value.db-value {
background: color-mix(in srgb, var(--button-background) 15%, transparent);
.diff-field-value.db-value {
background: color-mix(in srgb, var(--button-background) 12%, transparent);
border: 1px solid var(--button-background);
}
.diff-value.file-value {
background: color-mix(in srgb, var(--testing-iconQueued) 15%, transparent);
.diff-field-value.file-value {
background: color-mix(in srgb, var(--testing-iconQueued) 12%, transparent);
border: 1px solid var(--testing-iconQueued);
}
.diff-value-label {
font-size: 10px;
.diff-field-value.file-value.missing {
font-style: italic;
color: var(--descriptionForeground);
margin-bottom: 2px;
}
.diff-source-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--descriptionForeground);
margin-bottom: 1px;
}
/* Loading State */
@@ -340,3 +443,49 @@
font-size: 32px;
opacity: 0.5;
}
/* Orphan Files */
.orphan-files-section {
margin-top: 16px;
}
.orphan-files-section h3 {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 600;
color: var(--editorInfo-foreground, var(--editor-foreground));
}
.orphan-files-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.orphan-files-description {
margin: 0 0 12px 0;
font-size: 12px;
color: var(--descriptionForeground);
}
.orphan-file-badge {
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: 3px;
background: color-mix(in srgb, var(--editorInfo-foreground, #3794ff) 18%, transparent);
color: var(--editorInfo-foreground, #3794ff);
white-space: nowrap;
flex-shrink: 0;
}
.diff-item-card.orphan-file {
border-color: color-mix(in srgb, var(--editorInfo-foreground, #3794ff) 30%, var(--sidebar-border));
}
.orphan-path {
word-break: break-all;
font-family: var(--vscode-editor-font-family, monospace);
font-size: 11px;
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import { useI18n } from '../../i18n';
@@ -9,47 +9,123 @@ interface TableStats {
publishedPosts: number;
draftPosts: number;
totalMedia: number;
totalScripts: number;
publishedScripts: number;
totalTemplates: number;
publishedTemplates: number;
}
interface DiffPost {
postId: string;
title: string;
slug: string;
// ── Generic diff types (item-first, showing all field diffs per item) ──
interface FieldDiff {
dbValue: unknown;
fileValue: unknown;
}
interface DiffGroup {
interface GenericDiffItem {
id: string;
label: string;
fileMissing?: boolean;
fields: Record<string, FieldDiff>;
}
interface GenericOrphanFile {
filePath: string;
slug: string;
title?: string;
id?: string;
}
interface FieldSummary {
field: string;
label: string;
posts: DiffPost[];
count: number;
}
interface ScanResult {
interface GenericScanResult {
totalScanned: number;
postsWithDifferences: number;
differences: Array<{
postId: string;
title: string;
slug: string;
filePath?: string;
hasDifferences: boolean;
differences: Record<string, { dbValue: unknown; fileValue: unknown }>;
}>;
groups: DiffGroup[];
itemsWithDifferences: number;
items: GenericDiffItem[];
fieldSummaries: FieldSummary[];
orphanFiles: GenericOrphanFile[];
}
type EntityTab = 'posts' | 'media' | 'scripts' | 'templates';
type ScanPhase = 'idle' | 'loading-stats' | 'scanning' | 'complete';
// ── Adapters: use differences array (item-first) + groups for field labels ──
function adaptPostScanResult(raw: Awaited<ReturnType<NonNullable<typeof window.electronAPI>['metadataDiff']['scan']>>): GenericScanResult {
return {
totalScanned: raw.totalScanned,
itemsWithDifferences: raw.postsWithDifferences,
items: raw.differences.filter(d => d.hasDifferences).map(d => ({
id: d.postId,
label: d.title || d.slug,
fileMissing: d.fileMissing,
fields: d.differences as Record<string, FieldDiff>,
})),
fieldSummaries: raw.groups.map(g => ({ field: g.field, label: g.label, count: g.posts.length })),
orphanFiles: raw.orphanFiles ?? [],
};
}
function adaptMediaScanResult(raw: Awaited<ReturnType<NonNullable<typeof window.electronAPI>['metadataDiff']['scanMedia']>>): GenericScanResult {
return {
totalScanned: raw.totalScanned,
itemsWithDifferences: raw.itemsWithDifferences,
items: raw.differences.filter(d => d.hasDifferences).map(d => ({
id: d.mediaId,
label: d.originalName,
fileMissing: d.fileMissing,
fields: d.differences as Record<string, FieldDiff>,
})),
fieldSummaries: raw.groups.map(g => ({ field: g.field, label: g.label, count: g.items.length })),
orphanFiles: [],
};
}
function adaptScriptScanResult(raw: Awaited<ReturnType<NonNullable<typeof window.electronAPI>['metadataDiff']['scanScripts']>>): GenericScanResult {
return {
totalScanned: raw.totalScanned,
itemsWithDifferences: raw.itemsWithDifferences,
items: raw.differences.filter(d => d.hasDifferences).map(d => ({
id: d.scriptId,
label: d.title || d.slug,
fileMissing: d.fileMissing,
fields: d.differences as Record<string, FieldDiff>,
})),
fieldSummaries: raw.groups.map(g => ({ field: g.field, label: g.label, count: g.items.length })),
orphanFiles: [],
};
}
function adaptTemplateScanResult(raw: Awaited<ReturnType<NonNullable<typeof window.electronAPI>['metadataDiff']['scanTemplates']>>): GenericScanResult {
return {
totalScanned: raw.totalScanned,
itemsWithDifferences: raw.itemsWithDifferences,
items: raw.differences.filter(d => d.hasDifferences).map(d => ({
id: d.templateId,
label: d.title || d.slug,
fileMissing: d.fileMissing,
fields: d.differences as Record<string, FieldDiff>,
})),
fieldSummaries: raw.groups.map(g => ({ field: g.field, label: g.label, count: g.items.length })),
orphanFiles: [],
};
}
export const MetadataDiffPanel: React.FC = () => {
const { t: tr } = useI18n();
const activeProjectId = useAppStore((s) => s.activeProject?.id ?? null);
const [stats, setStats] = useState<TableStats | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [activeTab, setActiveTab] = useState<EntityTab>('posts');
const [scanResults, setScanResults] = useState<Record<EntityTab, GenericScanResult | null>>({ posts: null, media: null, scripts: null, templates: null });
const [scanPhase, setScanPhase] = useState<ScanPhase>('idle');
const [progress, setProgress] = useState({ current: 0, total: 0, message: '' });
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [syncingGroups, setSyncingGroups] = useState<Set<string>>(new Set());
const [activeFieldFilter, setActiveFieldFilter] = useState<string | null>(null);
const [syncingFields, setSyncingFields] = useState<Set<string>>(new Set());
const [importingOrphans, setImportingOrphans] = useState(false);
// Load initial stats
useEffect(() => {
@@ -58,9 +134,7 @@ export const MetadataDiffPanel: React.FC = () => {
setScanPhase('loading-stats');
try {
const result = await window.electronAPI?.metadataDiff.getStats();
if (result) {
setStats(result);
}
if (result) setStats(result as TableStats);
} catch (error) {
console.error('Failed to load stats:', error);
showToast.error(tr('metadataDiff.error.loadStats'));
@@ -73,34 +147,35 @@ export const MetadataDiffPanel: React.FC = () => {
// Subscribe to task progress
useEffect(() => {
const unsubscribe = window.electronAPI?.on('task:progress', (data: unknown) => {
const progress = data as { id: string; progress: number; message?: string };
if (progress.id.startsWith('metadata-diff-scan')) {
setProgress({
current: Math.round(progress.progress),
total: 100,
message: progress.message || '',
});
const p = data as { taskId: string; progress: number; message?: string };
if (p.taskId?.startsWith('metadata-')) {
setProgress({ current: Math.round(p.progress), total: 100, message: p.message || '' });
}
});
return () => {
unsubscribe?.();
};
return () => { unsubscribe?.(); };
}, []);
const handleScan = useCallback(async () => {
setScanPhase('scanning');
setProgress({ current: 0, total: 100, message: tr('metadataDiff.progress.starting') });
setScanResult(null);
setScanResults({ posts: null, media: null, scripts: null, templates: null });
setActiveFieldFilter(null);
try {
const result = await window.electronAPI?.metadataDiff.scan();
if (result) {
setScanResult(result);
// Auto-expand groups with differences
const groupsWithDiffs = new Set(result.groups.map(g => g.field));
setExpandedGroups(groupsWithDiffs);
}
const [postResult, mediaResult, scriptResult, templateResult] = await Promise.all([
window.electronAPI?.metadataDiff.scan(),
window.electronAPI?.metadataDiff.scanMedia(),
window.electronAPI?.metadataDiff.scanScripts(),
window.electronAPI?.metadataDiff.scanTemplates(),
]);
const results: Record<EntityTab, GenericScanResult | null> = {
posts: postResult ? adaptPostScanResult(postResult) : null,
media: mediaResult ? adaptMediaScanResult(mediaResult) : null,
scripts: scriptResult ? adaptScriptScanResult(scriptResult) : null,
templates: templateResult ? adaptTemplateScanResult(templateResult) : null,
};
setScanResults(results);
setScanPhase('complete');
} catch (error) {
console.error('Scan failed:', error);
@@ -109,74 +184,121 @@ export const MetadataDiffPanel: React.FC = () => {
}
}, [tr]);
const toggleGroup = (field: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(field)) {
next.delete(field);
} else {
next.add(field);
}
return next;
});
const handleTabChange = (tab: EntityTab) => {
setActiveTab(tab);
setActiveFieldFilter(null);
};
const handleSyncDbToFile = useCallback(async (group: DiffGroup) => {
const postIds = group.posts.map(p => p.postId);
setSyncingGroups(prev => new Set(prev).add(group.field));
const toggleFieldFilter = (field: string) => {
setActiveFieldFilter(prev => prev === field ? null : field);
};
// Filter items: if a field filter is active, show only items that have that field diff,
// but still show ALL fields for those items
const currentResult = scanResults[activeTab];
const filteredItems = useMemo(() => {
if (!currentResult) return [];
if (!activeFieldFilter) return currentResult.items;
return currentResult.items.filter(item => activeFieldFilter in item.fields);
}, [currentResult, activeFieldFilter]);
const handleSyncDbToFile = useCallback(async (field: string, fieldLabel: string) => {
// Get IDs of items that have this field diff (from filtered or all)
const ids = (currentResult?.items ?? [])
.filter(item => field in item.fields)
.map(item => item.id);
if (ids.length === 0) return;
setSyncingFields(prev => new Set(prev).add(field));
try {
const result = await window.electronAPI?.metadataDiff.syncDbToFile(postIds, group.label);
let result: { success: number; failed: number } | undefined;
switch (activeTab) {
case 'posts': result = await window.electronAPI?.metadataDiff.syncDbToFile(ids, fieldLabel); break;
case 'media': result = await window.electronAPI?.metadataDiff.syncMediaDbToFile(ids, fieldLabel); break;
case 'scripts': result = await window.electronAPI?.metadataDiff.syncScriptDbToFile(ids, fieldLabel); break;
case 'templates': result = await window.electronAPI?.metadataDiff.syncTemplateDbToFile(ids, fieldLabel); break;
}
if (result) {
showToast.success(tr('metadataDiff.sync.dbToFile.success', { success: result.success, failed: result.failed > 0 ? `, ${result.failed} ${tr('metadataDiff.sync.failed')}` : '' }));
// Re-scan to update the view
handleScan();
}
} catch (error) {
console.error('Sync failed:', error);
showToast.error(tr('metadataDiff.sync.dbToFile.error'));
} finally {
setSyncingGroups(prev => {
const next = new Set(prev);
next.delete(group.field);
return next;
});
setSyncingFields(prev => { const next = new Set(prev); next.delete(field); return next; });
}
}, [handleScan, tr]);
}, [activeTab, currentResult, handleScan, tr]);
const handleSyncFileToDb = useCallback(async (group: DiffGroup) => {
const postIds = group.posts.map(p => p.postId);
setSyncingGroups(prev => new Set(prev).add(group.field));
const handleSyncFileToDb = useCallback(async (field: string, fieldLabel: string) => {
const ids = (currentResult?.items ?? [])
.filter(item => field in item.fields)
.map(item => item.id);
if (ids.length === 0) return;
setSyncingFields(prev => new Set(prev).add(field));
try {
const result = await window.electronAPI?.metadataDiff.syncFileToDb(postIds, group.field, group.label);
let result: { success: number; failed: number } | undefined;
switch (activeTab) {
case 'posts': result = await window.electronAPI?.metadataDiff.syncFileToDb(ids, field, fieldLabel); break;
case 'media': result = await window.electronAPI?.metadataDiff.syncMediaFileToDb(ids, field, fieldLabel); break;
case 'scripts': result = await window.electronAPI?.metadataDiff.syncScriptFileToDb(ids, field, fieldLabel); break;
case 'templates': result = await window.electronAPI?.metadataDiff.syncTemplateFileToDb(ids, field, fieldLabel); break;
}
if (result) {
showToast.success(tr('metadataDiff.sync.fileToDb.success', { success: result.success, failed: result.failed > 0 ? `, ${result.failed} ${tr('metadataDiff.sync.failed')}` : '' }));
// Re-scan to update the view
handleScan();
}
} catch (error) {
console.error('Sync failed:', error);
showToast.error(tr('metadataDiff.sync.fileToDb.error'));
} finally {
setSyncingGroups(prev => {
const next = new Set(prev);
next.delete(group.field);
return next;
});
setSyncingFields(prev => { const next = new Set(prev); next.delete(field); return next; });
}
}, [handleScan, tr]);
}, [activeTab, currentResult, handleScan, tr]);
const handleImportOrphanFiles = useCallback(async () => {
const orphanPaths = currentResult?.orphanFiles.map(o => o.filePath) ?? [];
if (orphanPaths.length === 0) return;
setImportingOrphans(true);
try {
const result = await window.electronAPI?.metadataDiff.importOrphanFiles(orphanPaths);
if (result) {
showToast.success(tr('metadataDiff.orphanFiles.importSuccess', { success: result.success, failed: result.failed > 0 ? `, ${result.failed} ${tr('metadataDiff.sync.failed')}` : '' }));
handleScan();
}
} catch (error) {
console.error('Orphan import failed:', error);
showToast.error(tr('metadataDiff.orphanFiles.importError'));
} finally {
setImportingOrphans(false);
}
}, [currentResult, handleScan, tr]);
const formatValue = (value: unknown): string => {
if (Array.isArray(value)) {
return value.length > 0 ? value.join(', ') : '(empty)';
}
if (value === null || value === undefined || value === '') {
return '(empty)';
}
if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '(empty)';
if (value === null || value === undefined || value === '') return '(empty)';
if (typeof value === 'boolean') return value ? 'true' : 'false';
return String(value);
};
const summaryKey = (tab: EntityTab, hasDiffs: boolean): string => {
const map: Record<EntityTab, [string, string]> = {
posts: ['metadataDiff.summary.noDiffs', 'metadataDiff.summary.withDiffs'],
media: ['metadataDiff.summary.mediaNoDiffs', 'metadataDiff.summary.mediaWithDiffs'],
scripts: ['metadataDiff.summary.scriptNoDiffs', 'metadataDiff.summary.scriptWithDiffs'],
templates: ['metadataDiff.summary.templateNoDiffs', 'metadataDiff.summary.templateWithDiffs'],
};
return hasDiffs ? map[tab][1] : map[tab][0];
};
const tabBadge = (tab: EntityTab): number => {
const result = scanResults[tab];
if (!result) return 0;
return result.itemsWithDifferences + result.orphanFiles.length;
};
return (
<div className="metadata-diff-panel">
<h2>{tr('metadataDiff.title')}</h2>
@@ -203,6 +325,14 @@ export const MetadataDiffPanel: React.FC = () => {
<span className="stat-label">{tr('metadataDiff.stats.mediaFiles')}</span>
<span className="stat-value">{stats.totalMedia}</span>
</div>
<div className="stat-item">
<span className="stat-label">{tr('metadataDiff.stats.scripts')}</span>
<span className="stat-value">{stats.totalScripts}</span>
</div>
<div className="stat-item">
<span className="stat-label">{tr('metadataDiff.stats.templates')}</span>
<span className="stat-value">{stats.totalTemplates}</span>
</div>
</div>
)}
@@ -211,10 +341,7 @@ export const MetadataDiffPanel: React.FC = () => {
<div className="diff-progress">
<h3>{tr('metadataDiff.progress.scanningPublished')}</h3>
<div className="progress-bar-container">
<div
className="progress-bar"
style={{ width: `${progress.current}%` }}
/>
<div className="progress-bar" style={{ width: `${progress.current}%` }} />
</div>
<div className="progress-text">{progress.message}</div>
</div>
@@ -232,7 +359,7 @@ export const MetadataDiffPanel: React.FC = () => {
<span className="spinner" style={{ width: 14, height: 14 }} />
{tr('metadataDiff.progress.scanning')}
</>
) : scanResult ? (
) : currentResult ? (
`🔄 ${tr('metadataDiff.action.rescan')}`
) : (
`🔍 ${tr('metadataDiff.action.scan')}`
@@ -241,81 +368,162 @@ export const MetadataDiffPanel: React.FC = () => {
</div>
{/* Results Section */}
{scanPhase === 'complete' && scanResult && (
<div className="diff-results">
<div className={`diff-summary ${scanResult.postsWithDifferences > 0 ? 'has-differences' : 'no-differences'}`}>
{scanResult.postsWithDifferences === 0 ? (
<>{tr('metadataDiff.summary.noDiffs', { total: scanResult.totalScanned })}</>
) : (
<>
{tr('metadataDiff.summary.withDiffs', { count: scanResult.postsWithDifferences, total: scanResult.totalScanned })}
</>
)}
{scanPhase === 'complete' && (
<>
{/* Entity Tabs */}
<div className="diff-tabs">
{(['posts', 'media', 'scripts', 'templates'] as EntityTab[]).map(tab => (
<button
key={tab}
className={`diff-tab ${activeTab === tab ? 'active' : ''}`}
onClick={() => handleTabChange(tab)}
>
{tr(`metadataDiff.tab.${tab}`)}
{tabBadge(tab) > 0 && <span className="tab-badge">{tabBadge(tab)}</span>}
</button>
))}
</div>
{/* Groups */}
{scanResult.groups.map(group => (
<div key={group.field} className="diff-group">
<div
className="diff-group-header"
onClick={() => toggleGroup(group.field)}
>
<div className="diff-group-title">
<span className={`chevron ${expandedGroups.has(group.field) ? 'expanded' : ''}`}>
</span>
{tr('metadataDiff.group.differences', { label: group.label })}
</div>
<div className="diff-group-count">
<span className="badge">{tr('metadataDiff.group.postsCount', { count: group.posts.length })}</span>
<div className="diff-group-actions" onClick={e => e.stopPropagation()}>
<button
className="db-to-file"
onClick={() => handleSyncDbToFile(group)}
disabled={syncingGroups.has(group.field)}
title={tr('metadataDiff.sync.dbToFile.title')}
>
DB File
</button>
<button
className="file-to-db"
onClick={() => handleSyncFileToDb(group)}
disabled={syncingGroups.has(group.field)}
title={tr('metadataDiff.sync.fileToDb.title')}
>
File DB
</button>
</div>
</div>
{currentResult && (
<div className="diff-results">
<div className={`diff-summary ${currentResult.itemsWithDifferences > 0 ? 'has-differences' : 'no-differences'}`}>
{tr(summaryKey(activeTab, currentResult.itemsWithDifferences > 0), {
total: currentResult.totalScanned,
count: currentResult.itemsWithDifferences,
})}
</div>
<div className={`diff-group-content ${!expandedGroups.has(group.field) ? 'collapsed' : ''}`}>
{group.posts.map(post => (
<div key={post.postId} className="diff-post-item">
<div className="diff-post-title" title={post.title}>
{post.title || post.slug}
{/* Field summaries — clickable pills that filter by field */}
{currentResult.fieldSummaries.length > 0 && (
<div className="diff-field-summaries">
{currentResult.fieldSummaries.map(fs => (
<div
key={fs.field}
className={`field-pill ${activeFieldFilter === fs.field ? 'active' : ''}`}
onClick={() => toggleFieldFilter(fs.field)}
title={tr('metadataDiff.fieldFilter.toggle', { field: fs.label })}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') toggleFieldFilter(fs.field); }}
>
<span className="field-pill-label">{fs.label}</span>
<span className="field-pill-count">{fs.count}</span>
{/* Sync actions on field pills */}
<span className="field-pill-actions" onClick={e => e.stopPropagation()}>
<button
className="pill-sync db-to-file"
onClick={() => handleSyncDbToFile(fs.field, fs.label)}
disabled={syncingFields.has(fs.field)}
title={tr('metadataDiff.sync.dbToFile.title')}
>
{tr('metadataDiff.sync.dbToFile.short')}
</button>
<button
className="pill-sync file-to-db"
onClick={() => handleSyncFileToDb(fs.field, fs.label)}
disabled={syncingFields.has(fs.field)}
title={tr('metadataDiff.sync.fileToDb.title')}
>
{tr('metadataDiff.sync.fileToDb.short')}
</button>
</span>
</div>
<div>
<div className="diff-value-label">{tr('metadataDiff.value.database')}</div>
<div className="diff-value db-value" title={formatValue(post.dbValue)}>
{formatValue(post.dbValue)}
</div>
</div>
<div>
<div className="diff-value-label">{tr('metadataDiff.value.file')}</div>
<div className="diff-value file-value" title={formatValue(post.fileValue)}>
{formatValue(post.fileValue)}
))}
{activeFieldFilter && (
<button className="field-pill clear-filter" onClick={() => setActiveFieldFilter(null)}>
</button>
)}
</div>
)}
{/* Item list — each item shows all its field diffs */}
{filteredItems.length > 0 && (
<div className="diff-item-list">
{filteredItems.map(item => (
<div key={item.id} className={`diff-item-card ${item.fileMissing ? 'file-missing' : ''}`}>
<div className="diff-item-header">
{item.label}
{item.fileMissing && <span className="file-missing-badge">{tr('metadataDiff.fileMissing')}</span>}
</div>
<div className="diff-item-fields">
{Object.entries(item.fields).map(([field, diff]) => (
<div key={field} className="diff-field-row">
<div className="diff-field-name">{field}</div>
<div className="diff-field-values">
<div className="diff-field-value db-value" title={formatValue(diff.dbValue)}>
<span className="diff-source-label">{tr('metadataDiff.value.database')}</span>
{formatValue(diff.dbValue)}
</div>
<div className={`diff-field-value file-value ${item.fileMissing && diff.fileValue === null ? 'missing' : ''}`} title={item.fileMissing && diff.fileValue === null ? tr('metadataDiff.value.fileMissing') : formatValue(diff.fileValue)}>
<span className="diff-source-label">{tr('metadataDiff.value.file')}</span>
{item.fileMissing && diff.fileValue === null ? tr('metadataDiff.value.fileMissing') : formatValue(diff.fileValue)}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Orphan files — files on disk with no DB entry */}
{currentResult.orphanFiles.length > 0 && !activeFieldFilter && (
<div className="orphan-files-section">
<div className="orphan-files-header">
<h3>{tr('metadataDiff.orphanFiles.title', { count: currentResult.orphanFiles.length })}</h3>
<button
className="pill-sync file-to-db"
onClick={handleImportOrphanFiles}
disabled={importingOrphans}
title={tr('metadataDiff.orphanFiles.importTitle')}
>
{importingOrphans ? tr('metadataDiff.orphanFiles.importing') : tr('metadataDiff.orphanFiles.importButton')}
</button>
</div>
))}
</div>
<p className="orphan-files-description">{tr('metadataDiff.orphanFiles.description')}</p>
<div className="diff-item-list">
{currentResult.orphanFiles.map(orphan => (
<div key={orphan.filePath} className="diff-item-card orphan-file">
<div className="diff-item-header">
{orphan.title || orphan.slug}
<span className="orphan-file-badge">{tr('metadataDiff.orphanFiles.badge')}</span>
</div>
<div className="diff-item-fields">
<div className="diff-field-row">
<div className="diff-field-name">{tr('metadataDiff.orphanFiles.slug')}</div>
<div className="diff-field-values">
<div className="diff-field-value file-value">{orphan.slug}</div>
</div>
</div>
<div className="diff-field-row">
<div className="diff-field-name">{tr('metadataDiff.orphanFiles.path')}</div>
<div className="diff-field-values">
<div className="diff-field-value file-value orphan-path" title={orphan.filePath}>{orphan.filePath}</div>
</div>
</div>
{orphan.id && (
<div className="diff-field-row">
<div className="diff-field-name">ID</div>
<div className="diff-field-values">
<div className="diff-field-value file-value">{orphan.id}</div>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
)}
</>
)}
{/* Empty state */}
{scanPhase === 'idle' && !scanResult && (
{scanPhase === 'idle' && !currentResult && (
<div className="diff-empty">
<div className="icon">📊</div>
<div>{tr('metadataDiff.empty')}</div>

View File

@@ -374,7 +374,7 @@
"tabBar.error.fetchTemplateTitle": "Vorlagen-Titel konnte nicht geladen werden:",
"tabBar.error.fetchCommitTitle": "Commit-Titel konnten nicht geladen werden:",
"metadataDiff.title": "Metadaten-Diff-Werkzeug",
"metadataDiff.description": "Vergleicht Beitragsmetadaten zwischen Datenbank und Markdown-Dateien. Behebt Abweichungen durch Bugs oder manuelle Änderungen.",
"metadataDiff.description": "Vergleicht Metadaten zwischen Datenbank und Dateien für Beiträge, Medien, Skripte und Vorlagen. Behebt Abweichungen durch Bugs oder manuelle Änderungen.",
"metadataDiff.error.loadStats": "Datenbankstatistiken konnten nicht geladen werden",
"metadataDiff.error.scan": "Unterschiede konnten nicht gescannt werden",
"metadataDiff.progress.starting": "Scan wird gestartet...",
@@ -386,19 +386,47 @@
"metadataDiff.stats.published": "Veröffentlicht",
"metadataDiff.stats.drafts": "Entwürfe",
"metadataDiff.stats.mediaFiles": "Mediendateien",
"metadataDiff.stats.scripts": "Skripte",
"metadataDiff.stats.templates": "Vorlagen",
"metadataDiff.summary.noDiffs": "✅ Keine Unterschiede gefunden! Alle {total} veröffentlichten Beiträge sind synchron.",
"metadataDiff.summary.withDiffs": "⚠️ {count} Beiträge mit Unterschieden gefunden, von insgesamt {total} veröffentlichten Beiträgen.",
"metadataDiff.summary.mediaNoDiffs": "✅ Keine Unterschiede gefunden! Alle {total} Mediendateien sind synchron.",
"metadataDiff.summary.mediaWithDiffs": "⚠️ {count} Mediendateien mit Unterschieden gefunden, von insgesamt {total}.",
"metadataDiff.summary.scriptNoDiffs": "✅ Keine Unterschiede gefunden! Alle {total} Skripte sind synchron.",
"metadataDiff.summary.scriptWithDiffs": "⚠️ {count} Skripte mit Unterschieden gefunden, von insgesamt {total}.",
"metadataDiff.summary.templateNoDiffs": "✅ Keine Unterschiede gefunden! Alle {total} Vorlagen sind synchron.",
"metadataDiff.summary.templateWithDiffs": "⚠️ {count} Vorlagen mit Unterschieden gefunden, von insgesamt {total}.",
"metadataDiff.group.differences": "{label}-Unterschiede",
"metadataDiff.group.postsCount": "{count} Beiträge",
"metadataDiff.group.itemsCount": "{count} Elemente",
"metadataDiff.fieldFilter.toggle": "Nach {field} filtern",
"metadataDiff.tab.posts": "Beiträge",
"metadataDiff.tab.media": "Medien",
"metadataDiff.tab.scripts": "Skripte",
"metadataDiff.tab.templates": "Vorlagen",
"metadataDiff.orphanFiles.title": "Verwaiste Dateien ({count})",
"metadataDiff.orphanFiles.description": "Diese Dateien existieren auf der Festplatte, haben aber keinen passenden Datenbankeintrag. Sie könnten von Slug-Änderungen oder manuellen Bearbeitungen übrig sein.",
"metadataDiff.orphanFiles.badge": "Verwaiste Datei",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Pfad",
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
"metadataDiff.orphanFiles.importTitle": "Alle verwaisten Dateien in die Datenbank importieren",
"metadataDiff.orphanFiles.importing": "Importiere…",
"metadataDiff.orphanFiles.importSuccess": "{success} verwaiste Dateien importiert{failed}",
"metadataDiff.orphanFiles.importError": "Import der verwaisten Dateien fehlgeschlagen",
"metadataDiff.sync.failed": "fehlgeschlagen",
"metadataDiff.sync.dbToFile.title": "Dateien mit Datenbankwerten aktualisieren",
"metadataDiff.sync.dbToFile.short": "DB\u2192D",
"metadataDiff.sync.dbToFile.success": "{success} Beiträge in Dateien synchronisiert{fehlgeschlagen}",
"metadataDiff.sync.dbToFile.error": "Synchronisierung in Dateien fehlgeschlagen",
"metadataDiff.sync.fileToDb.title": "Datenbank mit Dateiwerten aktualisieren",
"metadataDiff.sync.fileToDb.short": "D\u2192DB",
"metadataDiff.sync.fileToDb.success": "{success} Dateien in die Datenbank synchronisiert{fehlgeschlagen}",
"metadataDiff.sync.fileToDb.error": "Synchronisierung in die Datenbank fehlgeschlagen",
"metadataDiff.value.database": "Datenbank",
"metadataDiff.value.file": "Datei",
"metadataDiff.fileMissing": "Datei fehlt",
"metadataDiff.value.fileMissing": "(fehlt)",
"metadataDiff.empty": "Klicke auf „Nach Unterschieden suchen“, um Datenbank-Metadaten mit Datei-Metadaten zu vergleichen.",
"sidebar.archive": "Archiv",
"sidebar.clearFilter": "Filter löschen",

View File

@@ -374,7 +374,7 @@
"tabBar.error.fetchTemplateTitle": "Failed to fetch template title:",
"tabBar.error.fetchCommitTitle": "Failed to fetch commit titles:",
"metadataDiff.title": "Metadata Diff Tool",
"metadataDiff.description": "Compare post metadata between database and markdown files. Fix inconsistencies caused by bugs or manual edits.",
"metadataDiff.description": "Compare metadata between database and files for posts, media, scripts, and templates. Fix inconsistencies caused by bugs or manual edits.",
"metadataDiff.error.loadStats": "Failed to load database statistics",
"metadataDiff.error.scan": "Failed to scan for differences",
"metadataDiff.progress.starting": "Starting scan...",
@@ -386,20 +386,48 @@
"metadataDiff.stats.published": "Published",
"metadataDiff.stats.drafts": "Drafts",
"metadataDiff.stats.mediaFiles": "Media Files",
"metadataDiff.stats.scripts": "Scripts",
"metadataDiff.stats.templates": "Templates",
"metadataDiff.summary.noDiffs": "✅ No differences found! All {total} published posts are in sync.",
"metadataDiff.summary.withDiffs": "⚠️ Found {count} posts with differences out of {total} published posts.",
"metadataDiff.summary.mediaNoDiffs": "✅ No differences found! All {total} media items are in sync.",
"metadataDiff.summary.mediaWithDiffs": "⚠️ Found {count} media items with differences out of {total}.",
"metadataDiff.summary.scriptNoDiffs": "✅ No differences found! All {total} scripts are in sync.",
"metadataDiff.summary.scriptWithDiffs": "⚠️ Found {count} scripts with differences out of {total}.",
"metadataDiff.summary.templateNoDiffs": "✅ No differences found! All {total} templates are in sync.",
"metadataDiff.summary.templateWithDiffs": "⚠️ Found {count} templates with differences out of {total}.",
"metadataDiff.group.differences": "{label} Differences",
"metadataDiff.group.postsCount": "{count} posts",
"metadataDiff.group.itemsCount": "{count} items",
"metadataDiff.fieldFilter.toggle": "Filter by {field}",
"metadataDiff.sync.failed": "failed",
"metadataDiff.sync.dbToFile.title": "Update files with database values",
"metadataDiff.sync.dbToFile.short": "DB\u2192F",
"metadataDiff.sync.dbToFile.success": "Synced {success} posts to files{failed}",
"metadataDiff.sync.dbToFile.error": "Failed to sync to files",
"metadataDiff.sync.fileToDb.title": "Update database with file values",
"metadataDiff.sync.fileToDb.short": "F\u2192DB",
"metadataDiff.sync.fileToDb.success": "Synced {success} files to database{failed}",
"metadataDiff.sync.fileToDb.error": "Failed to sync to database",
"metadataDiff.value.database": "Database",
"metadataDiff.value.file": "File",
"metadataDiff.fileMissing": "File missing",
"metadataDiff.value.fileMissing": "(missing)",
"metadataDiff.empty": "Click \"Scan for Differences\" to compare database metadata with file metadata.",
"metadataDiff.tab.posts": "Posts",
"metadataDiff.tab.media": "Media",
"metadataDiff.tab.scripts": "Scripts",
"metadataDiff.tab.templates": "Templates",
"metadataDiff.orphanFiles.title": "Orphan Files ({count})",
"metadataDiff.orphanFiles.description": "These files exist on disk but have no matching database entry. They may be leftovers from slug changes or manual edits.",
"metadataDiff.orphanFiles.badge": "Orphan file",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Path",
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
"metadataDiff.orphanFiles.importTitle": "Import all orphan files into the database",
"metadataDiff.orphanFiles.importing": "Importing…",
"metadataDiff.orphanFiles.importSuccess": "{success} orphan files imported{failed}",
"metadataDiff.orphanFiles.importError": "Orphan file import failed",
"sidebar.archive": "Archive",
"sidebar.clearFilter": "Clear filter",
"sidebar.tags": "Tags",

View File

@@ -374,7 +374,7 @@
"tabBar.error.fetchTemplateTitle": "No se pudo cargar el título de la plantilla:",
"tabBar.error.fetchCommitTitle": "No se pudieron cargar los títulos de los commits:",
"metadataDiff.title": "Herramienta diff de metadatos",
"metadataDiff.description": "Compara los metadatos de las entradas entre la base de datos y los archivos Markdown. Corrige inconsistencias causadas por errores o ediciones manuales.",
"metadataDiff.description": "Compara los metadatos entre la base de datos y los archivos para entradas, multimedia, scripts y plantillas. Corrige inconsistencias causadas por errores o ediciones manuales.",
"metadataDiff.error.loadStats": "No se pudieron cargar las estadísticas de la base de datos",
"metadataDiff.error.scan": "No se pudieron analizar las diferencias",
"metadataDiff.progress.starting": "Iniciando escaneo...",
@@ -386,20 +386,48 @@
"metadataDiff.stats.published": "Publicadas",
"metadataDiff.stats.drafts": "Borradores",
"metadataDiff.stats.mediaFiles": "Archivos multimedia",
"metadataDiff.stats.scripts": "Scripts",
"metadataDiff.stats.templates": "Plantillas",
"metadataDiff.summary.noDiffs": "✅ ¡No se encontraron diferencias! Todas las {total} entradas publicadas están sincronizadas.",
"metadataDiff.summary.withDiffs": "⚠️ Se encontraron {count} entradas con diferencias de un total de {total} entradas publicadas.",
"metadataDiff.summary.mediaNoDiffs": "✅ ¡No se encontraron diferencias! Los {total} archivos multimedia están sincronizados.",
"metadataDiff.summary.mediaWithDiffs": "⚠️ Se encontraron {count} archivos multimedia con diferencias de un total de {total}.",
"metadataDiff.summary.scriptNoDiffs": "✅ ¡No se encontraron diferencias! Los {total} scripts están sincronizados.",
"metadataDiff.summary.scriptWithDiffs": "⚠️ Se encontraron {count} scripts con diferencias de un total de {total}.",
"metadataDiff.summary.templateNoDiffs": "✅ ¡No se encontraron diferencias! Las {total} plantillas están sincronizadas.",
"metadataDiff.summary.templateWithDiffs": "⚠️ Se encontraron {count} plantillas con diferencias de un total de {total}.",
"metadataDiff.group.differences": "Diferencias de {label}",
"metadataDiff.group.postsCount": "{count} entradas",
"metadataDiff.group.itemsCount": "{count} elementos",
"metadataDiff.fieldFilter.toggle": "Filtrar por {field}",
"metadataDiff.sync.failed": "falló",
"metadataDiff.sync.dbToFile.title": "Actualizar archivos con valores de la base de datos",
"metadataDiff.sync.dbToFile.short": "BD\u2192A",
"metadataDiff.sync.dbToFile.success": "Se sincronizaron {success} entradas a archivos{falló}",
"metadataDiff.sync.dbToFile.error": "No se pudo sincronizar a archivos",
"metadataDiff.sync.fileToDb.title": "Actualizar base de datos con valores de archivos",
"metadataDiff.sync.fileToDb.short": "A→BD",
"metadataDiff.sync.fileToDb.success": "Se sincronizaron {success} archivos a la base de datos{falló}",
"metadataDiff.sync.fileToDb.error": "No se pudo sincronizar a la base de datos",
"metadataDiff.value.database": "Base de datos",
"metadataDiff.value.file": "Archivo",
"metadataDiff.fileMissing": "Archivo faltante",
"metadataDiff.value.fileMissing": "(faltante)",
"metadataDiff.empty": "Haz clic en \"Buscar diferencias\" para comparar metadatos de base de datos con metadatos de archivos.",
"metadataDiff.tab.posts": "Entradas",
"metadataDiff.tab.media": "Multimedia",
"metadataDiff.tab.scripts": "Scripts",
"metadataDiff.tab.templates": "Plantillas",
"metadataDiff.orphanFiles.title": "Archivos huérfanos ({count})",
"metadataDiff.orphanFiles.description": "Estos archivos existen en el disco pero no tienen una entrada correspondiente en la base de datos. Pueden ser restos de cambios de slug o ediciones manuales.",
"metadataDiff.orphanFiles.badge": "Archivo huérfano",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Ruta",
"metadataDiff.orphanFiles.importButton": "D \u2192 BD",
"metadataDiff.orphanFiles.importTitle": "Importar todos los archivos huérfanos a la base de datos",
"metadataDiff.orphanFiles.importing": "Importando…",
"metadataDiff.orphanFiles.importSuccess": "{success} archivos huérfanos importados{failed}",
"metadataDiff.orphanFiles.importError": "Error al importar archivos huérfanos",
"sidebar.archive": "Archivo",
"sidebar.clearFilter": "Limpiar filtro",
"sidebar.tags": "Etiquetas",

View File

@@ -374,7 +374,7 @@
"tabBar.error.fetchTemplateTitle": "Impossible de charger le titre du modèle :",
"tabBar.error.fetchCommitTitle": "Impossible de charger les titres des commits :",
"metadataDiff.title": "Outil de diff des métadonnées",
"metadataDiff.description": "Compare les métadonnées des articles entre la base de données et les fichiers Markdown. Corrige les incohérences causées par des bugs ou des modifications manuelles.",
"metadataDiff.description": "Compare les métadonnées entre la base de données et les fichiers pour les articles, médias, scripts et modèles. Corrige les incohérences causées par des bugs ou des modifications manuelles.",
"metadataDiff.error.loadStats": "Impossible de charger les statistiques de la base de données",
"metadataDiff.error.scan": "Impossible danalyser les différences",
"metadataDiff.progress.starting": "Démarrage de lanalyse...",
@@ -386,19 +386,47 @@
"metadataDiff.stats.published": "Publiés",
"metadataDiff.stats.drafts": "Brouillons",
"metadataDiff.stats.mediaFiles": "Fichiers média",
"metadataDiff.stats.scripts": "Scripts",
"metadataDiff.stats.templates": "Modèles",
"metadataDiff.summary.noDiffs": "✅ Aucune différence trouvée ! Les {total} articles publiés sont synchronisés.",
"metadataDiff.summary.withDiffs": "⚠️ {count} articles présentent des différences sur {total} articles publiés.",
"metadataDiff.summary.mediaNoDiffs": "✅ Aucune différence trouvée ! Les {total} fichiers média sont synchronisés.",
"metadataDiff.summary.mediaWithDiffs": "⚠️ {count} fichiers média présentent des différences sur {total}.",
"metadataDiff.summary.scriptNoDiffs": "✅ Aucune différence trouvée ! Les {total} scripts sont synchronisés.",
"metadataDiff.summary.scriptWithDiffs": "⚠️ {count} scripts présentent des différences sur {total}.",
"metadataDiff.summary.templateNoDiffs": "✅ Aucune différence trouvée ! Les {total} modèles sont synchronisés.",
"metadataDiff.summary.templateWithDiffs": "⚠️ {count} modèles présentent des différences sur {total}.",
"metadataDiff.group.differences": "Différences de {label}",
"metadataDiff.group.postsCount": "{count} articles",
"metadataDiff.group.itemsCount": "{count} éléments",
"metadataDiff.fieldFilter.toggle": "Filtrer par {field}",
"metadataDiff.tab.posts": "Articles",
"metadataDiff.tab.media": "Médias",
"metadataDiff.tab.scripts": "Scripts",
"metadataDiff.tab.templates": "Modèles",
"metadataDiff.orphanFiles.title": "Fichiers orphelins ({count})",
"metadataDiff.orphanFiles.description": "Ces fichiers existent sur le disque mais n'ont pas d'entrée correspondante dans la base de données. Ils peuvent provenir de changements de slug ou de modifications manuelles.",
"metadataDiff.orphanFiles.badge": "Fichier orphelin",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Chemin",
"metadataDiff.orphanFiles.importButton": "D \u2192 BD",
"metadataDiff.orphanFiles.importTitle": "Importer tous les fichiers orphelins dans la base de données",
"metadataDiff.orphanFiles.importing": "Importation…",
"metadataDiff.orphanFiles.importSuccess": "{success} fichiers orphelins importés{failed}",
"metadataDiff.orphanFiles.importError": "Échec de l'importation des fichiers orphelins",
"metadataDiff.sync.failed": "échoué",
"metadataDiff.sync.dbToFile.title": "Mettre à jour les fichiers avec les valeurs de la base",
"metadataDiff.sync.dbToFile.short": "BD→F",
"metadataDiff.sync.dbToFile.success": "{success} articles synchronisés vers les fichiers{échoué}",
"metadataDiff.sync.dbToFile.error": "Échec de la synchronisation vers les fichiers",
"metadataDiff.sync.fileToDb.title": "Mettre à jour la base avec les valeurs des fichiers",
"metadataDiff.sync.fileToDb.short": "F→BD",
"metadataDiff.sync.fileToDb.success": "{success} fichiers synchronisés vers la base de données{échoué}",
"metadataDiff.sync.fileToDb.error": "Échec de la synchronisation vers la base de données",
"metadataDiff.value.database": "Base de données",
"metadataDiff.value.file": "Fichier",
"metadataDiff.fileMissing": "Fichier manquant",
"metadataDiff.value.fileMissing": "(manquant)",
"metadataDiff.empty": "Cliquez sur « Rechercher les différences » pour comparer les métadonnées de la base et celles des fichiers.",
"sidebar.archive": "Archive",
"sidebar.clearFilter": "Effacer le filtre",

View File

@@ -374,7 +374,7 @@
"tabBar.error.fetchTemplateTitle": "Impossibile caricare il titolo del modello:",
"tabBar.error.fetchCommitTitle": "Impossibile caricare i titoli dei commit:",
"metadataDiff.title": "Strumento diff metadati",
"metadataDiff.description": "Confronta i metadati dei post tra database e file markdown. Correggi incongruenze causate da bug o modifiche manuali.",
"metadataDiff.description": "Confronta i metadati tra database e file per post, media, script e modelli. Correggi incongruenze causate da bug o modifiche manuali.",
"metadataDiff.error.loadStats": "Impossibile caricare le statistiche del database",
"metadataDiff.error.scan": "Impossibile analizzare le differenze",
"metadataDiff.progress.starting": "Avvio scansione...",
@@ -386,19 +386,47 @@
"metadataDiff.stats.published": "Pubblicati",
"metadataDiff.stats.drafts": "Bozze",
"metadataDiff.stats.mediaFiles": "File multimediali",
"metadataDiff.stats.scripts": "Script",
"metadataDiff.stats.templates": "Modelli",
"metadataDiff.summary.noDiffs": "✅ Nessuna differenza trovata! Tutti i {total} post pubblicati sono sincronizzati.",
"metadataDiff.summary.withDiffs": "⚠️ Trovati {count} post con differenze su {total} post pubblicati.",
"metadataDiff.summary.mediaNoDiffs": "✅ Nessuna differenza trovata! Tutti i {total} file multimediali sono sincronizzati.",
"metadataDiff.summary.mediaWithDiffs": "⚠️ Trovati {count} file multimediali con differenze su {total}.",
"metadataDiff.summary.scriptNoDiffs": "✅ Nessuna differenza trovata! Tutti i {total} script sono sincronizzati.",
"metadataDiff.summary.scriptWithDiffs": "⚠️ Trovati {count} script con differenze su {total}.",
"metadataDiff.summary.templateNoDiffs": "✅ Nessuna differenza trovata! Tutti i {total} modelli sono sincronizzati.",
"metadataDiff.summary.templateWithDiffs": "⚠️ Trovati {count} modelli con differenze su {total}.",
"metadataDiff.group.differences": "Differenze {label}",
"metadataDiff.group.postsCount": "{count} post",
"metadataDiff.group.itemsCount": "{count} elementi",
"metadataDiff.fieldFilter.toggle": "Filtra per {field}",
"metadataDiff.tab.posts": "Post",
"metadataDiff.tab.media": "Media",
"metadataDiff.tab.scripts": "Script",
"metadataDiff.tab.templates": "Modelli",
"metadataDiff.orphanFiles.title": "File orfani ({count})",
"metadataDiff.orphanFiles.description": "Questi file esistono sul disco ma non hanno una voce corrispondente nel database. Potrebbero essere residui di modifiche allo slug o modifiche manuali.",
"metadataDiff.orphanFiles.badge": "File orfano",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Percorso",
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
"metadataDiff.orphanFiles.importTitle": "Importa tutti i file orfani nel database",
"metadataDiff.orphanFiles.importing": "Importazione…",
"metadataDiff.orphanFiles.importSuccess": "{success} file orfani importati{failed}",
"metadataDiff.orphanFiles.importError": "Impossibile importare i file orfani",
"metadataDiff.sync.failed": "fallito",
"metadataDiff.sync.dbToFile.title": "Aggiorna i file con i valori del database",
"metadataDiff.sync.dbToFile.short": "DB\u2192F",
"metadataDiff.sync.dbToFile.success": "Sincronizzati {success} post nei file{fallito}",
"metadataDiff.sync.dbToFile.error": "Impossibile sincronizzare nei file",
"metadataDiff.sync.fileToDb.title": "Aggiorna il database con i valori dei file",
"metadataDiff.sync.fileToDb.short": "F\u2192DB",
"metadataDiff.sync.fileToDb.success": "Sincronizzati {success} file nel database{fallito}",
"metadataDiff.sync.fileToDb.error": "Impossibile sincronizzare nel database",
"metadataDiff.value.database": "Database locale",
"metadataDiff.value.file": "File sorgente",
"metadataDiff.fileMissing": "File mancante",
"metadataDiff.value.fileMissing": "(mancante)",
"metadataDiff.empty": "Fai clic su \"Scansiona differenze\" per confrontare i metadati del database con quelli dei file.",
"sidebar.archive": "Archivio",
"sidebar.clearFilter": "Cancella filtro",

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();

View File

@@ -6,7 +6,13 @@
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { MetadataDiffEngine, PostMetadataDiff, DiffGroup, DiffField } from '../../src/main/engine/MetadataDiffEngine';
import {
MetadataDiffEngine, PostMetadataDiff, DiffGroup, DiffField,
MediaMetadataDiff, MediaDiffField,
ScriptMetadataDiff, ScriptDiffField,
TemplateMetadataDiff, TemplateDiffField,
OrphanFile,
} from '../../src/main/engine/MetadataDiffEngine';
import { resetMockCounters } from '../utils/factories';
// Mock posts data store - used for single-item .get() queries
@@ -154,11 +160,13 @@ vi.mock('../../src/main/engine/TaskManager', () => ({
// Track the mock function for PostEngine.syncPublishedPostFile
const mockSyncPublishedPostFile = vi.fn(async () => true);
const mockImportOrphanFile = vi.fn(async () => ({ id: 'imported-id', title: 'Imported' }));
// Mock PostEngine
vi.mock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({
syncPublishedPostFile: mockSyncPublishedPostFile,
importOrphanFile: mockImportOrphanFile,
})),
}));
@@ -172,8 +180,9 @@ describe('MetadataDiffEngine', () => {
mockFileData.clear();
mockAllPostsRows = [];
mockSyncPublishedPostFile.mockClear();
mockImportOrphanFile.mockClear();
resetMockCounters();
engine = new MetadataDiffEngine({ syncPublishedPostFile: mockSyncPublishedPostFile } as any);
engine = new MetadataDiffEngine({ syncPublishedPostFile: mockSyncPublishedPostFile, importOrphanFile: mockImportOrphanFile } as any);
engine.setProjectContext('test-project');
});
@@ -382,6 +391,73 @@ Content here`);
expect(result?.differences.language?.fileValue).toBe('');
});
it('should populate differences with DB values when file is missing', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/non-existent.md',
tags: '["tag1", "tag2"]',
categories: '["cat1"]',
excerpt: 'Some excerpt',
author: 'Author Name',
language: 'de',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
// File intentionally NOT added to mockFileData → readPostFile returns null
mockPosts.set('post-1', dbPost);
const result = await engine.comparePostMetadata('post-1');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(true);
expect(result?.fileMissing).toBe(true);
// DB values should appear in differences so the UI shows what fields exist
expect(result?.differences.tags).toEqual({ dbValue: ['tag1', 'tag2'], fileValue: null });
expect(result?.differences.categories).toEqual({ dbValue: ['cat1'], fileValue: null });
expect(result?.differences.title).toEqual({ dbValue: 'Published Post', fileValue: null });
expect(result?.differences.excerpt).toEqual({ dbValue: 'Some excerpt', fileValue: null });
expect(result?.differences.author).toEqual({ dbValue: 'Author Name', fileValue: null });
expect(result?.differences.language).toEqual({ dbValue: 'de', fileValue: null });
});
it('should omit empty DB fields from differences when file is missing', async () => {
const dbPost = {
id: 'post-2',
projectId: 'test-project',
title: 'Minimal Post',
slug: 'minimal-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/gone.md',
tags: '[]',
categories: '[]',
excerpt: null,
author: null,
language: null,
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
};
mockPosts.set('post-2', dbPost);
const result = await engine.comparePostMetadata('post-2');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(true);
expect(result?.fileMissing).toBe(true);
// Title always present since it's non-null
expect(result?.differences.title).toEqual({ dbValue: 'Minimal Post', fileValue: null });
// Empty arrays / nulls should be omitted
expect(result?.differences.tags).toBeUndefined();
expect(result?.differences.categories).toBeUndefined();
expect(result?.differences.excerpt).toBeUndefined();
expect(result?.differences.author).toBeUndefined();
expect(result?.differences.language).toBeUndefined();
});
it('should return hasDifferences=false when metadata matches', async () => {
const dbPost = {
id: 'post-1',
@@ -448,6 +524,14 @@ Content here`);
],
});
// Mock the second query that gets ALL post file paths (for orphan detection)
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ file_path: '/mock/userData/posts/2024/01/post-1.md' },
{ file_path: '/mock/userData/posts/2024/01/post-2.md' },
],
});
// Queue the posts for sequential .get() calls in comparePostMetadata
mockPostsGetQueue = [
{
@@ -512,6 +596,187 @@ Content`);
expect(result.postsWithDifferences).toBe(1);
expect(result.differences.length).toBe(1);
expect(result.differences[0].postId).toBe('post-1');
expect(result.orphanFiles).toEqual([]);
});
it('should detect orphan files when postsBaseDir is provided', async () => {
const { readdir } = await import('fs/promises');
const mockReaddir = vi.mocked(readdir);
// Mock published posts query - one post in DB
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{
id: 'post-1',
title: 'Post 1',
slug: 'post-1',
file_path: '/mock/posts/2024/01/post-1.md',
tags: '[]',
categories: '[]',
excerpt: null,
author: null,
},
],
});
// Mock the all-posts query
mockLocalClient.execute.mockResolvedValueOnce({
rows: [{ file_path: '/mock/posts/2024/01/post-1.md' }],
});
// Queue the post for comparePostMetadata
mockPostsGetQueue = [
{
id: 'post-1',
projectId: 'test-project',
title: 'Post 1',
slug: 'post-1',
status: 'published',
filePath: '/mock/posts/2024/01/post-1.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
},
];
// File matches DB (no differences)
mockFileData.set('/mock/posts/2024/01/post-1.md', `---
id: post-1
title: "Post 1"
slug: post-1
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
---
Content`);
// Orphan file exists on disk
mockFileData.set('/mock/posts/2024/01/orphan-post.md', `---
id: orphan-1
title: "Orphan Post"
slug: orphan-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
---
Content`);
// Mock readdir to return directory structure
mockReaddir
.mockResolvedValueOnce([
{ name: '2024', isDirectory: () => true, isFile: () => false } as any,
] as any)
.mockResolvedValueOnce([
{ name: '01', isDirectory: () => true, isFile: () => false } as any,
] as any)
.mockResolvedValueOnce([
{ name: 'post-1.md', isDirectory: () => false, isFile: () => true } as any,
{ name: 'orphan-post.md', isDirectory: () => false, isFile: () => true } as any,
] as any);
const result = await engine.scanAllPublishedPosts(
(current, total) => {},
'/mock/posts',
);
expect(result.orphanFiles).toHaveLength(1);
expect(result.orphanFiles[0].slug).toBe('orphan-post');
expect(result.orphanFiles[0].title).toBe('Orphan Post');
expect(result.orphanFiles[0].id).toBe('orphan-1');
expect(result.orphanFiles[0].filePath).toBe('/mock/posts/2024/01/orphan-post.md');
});
it('should return empty orphanFiles when no postsBaseDir is provided', async () => {
// Mock published posts query - empty
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
// Mock the all-posts query
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
const result = await engine.scanAllPublishedPosts((current, total) => {});
expect(result.orphanFiles).toEqual([]);
});
it('should not flag draft posts as orphans', async () => {
const { readdir } = await import('fs/promises');
const mockReaddir = vi.mocked(readdir);
// No published posts
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
// All posts query returns a draft that has a file
mockLocalClient.execute.mockResolvedValueOnce({
rows: [{ file_path: '/mock/posts/2024/01/draft-post.md' }],
});
// Mock readdir to find the draft file
mockReaddir
.mockResolvedValueOnce([
{ name: '2024', isDirectory: () => true, isFile: () => false } as any,
] as any)
.mockResolvedValueOnce([
{ name: '01', isDirectory: () => true, isFile: () => false } as any,
] as any)
.mockResolvedValueOnce([
{ name: 'draft-post.md', isDirectory: () => false, isFile: () => true } as any,
] as any);
const result = await engine.scanAllPublishedPosts(
(current, total) => {},
'/mock/posts',
);
// Draft post file should NOT be flagged as orphan since it's in the DB
expect(result.orphanFiles).toEqual([]);
});
});
describe('importOrphanFiles', () => {
it('should import orphan files and report success/failed counts', async () => {
mockImportOrphanFile
.mockResolvedValueOnce({ id: 'id-1', title: 'First' })
.mockResolvedValueOnce(null) // parse failure
.mockResolvedValueOnce({ id: 'id-3', title: 'Third' });
const result = await engine.importOrphanFiles([
'/posts/2024/01/first.md',
'/posts/2024/01/bad.md',
'/posts/2024/01/third.md',
]);
expect(result.success).toBe(2);
expect(result.failed).toBe(1);
expect(mockImportOrphanFile).toHaveBeenCalledTimes(3);
});
it('should handle exceptions from importOrphanFile gracefully', async () => {
mockImportOrphanFile
.mockRejectedValueOnce(new Error('DB constraint error'));
const result = await engine.importOrphanFiles(['/posts/2024/01/crash.md']);
expect(result.success).toBe(0);
expect(result.failed).toBe(1);
});
it('should report progress during import', async () => {
mockImportOrphanFile.mockResolvedValue({ id: 'id', title: 'Post' });
const progressCalls: [number, number, string][] = [];
await engine.importOrphanFiles(
['/a.md', '/b.md', '/c.md', '/d.md', '/e.md'],
(current, total, message) => progressCalls.push([current, total, message]),
);
// Progress should be reported at i=4 (5th item, i+1=5 is divisible by 5)
expect(progressCalls.length).toBe(1);
expect(progressCalls[0][0]).toBe(5);
expect(progressCalls[0][1]).toBe(5);
});
});
@@ -755,4 +1020,367 @@ Content here`);
expect(mockLocalDb.update).toHaveBeenCalledTimes(1);
});
});
// ── Media diff tests ──
describe('compareMediaMetadata', () => {
let mediaEngine: MetadataDiffEngine;
const mockReadSidecarFile = vi.fn();
beforeEach(() => {
mediaEngine = new MetadataDiffEngine(
undefined,
{ readSidecarFile: mockReadSidecarFile, getMedia: vi.fn(), updateMedia: vi.fn() } as any,
);
mediaEngine.setProjectContext('test-project');
});
it('should return null when media not found in DB', async () => {
// (mock DB returns undefined for .get())
const result = await mediaEngine.compareMediaMetadata('nonexistent');
expect(result).toBeNull();
});
it('should detect title difference between DB and sidecar', async () => {
const dbMedia = {
id: 'media-1',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: 'DB Title',
alt: 'alt text',
caption: '',
author: '',
tags: '[]',
};
mockPosts.set('media-1', dbMedia);
// The select chain's .get() will return this via mockPosts
// Override: media uses same mock DB, so route DB response:
mockPostsGetQueue = [dbMedia];
mockReadSidecarFile.mockResolvedValueOnce({
id: 'media-1',
originalName: 'photo.jpg',
title: 'File Title',
alt: 'alt text',
caption: '',
author: '',
tags: [],
});
const result = await mediaEngine.compareMediaMetadata('media-1');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.title).toEqual({ dbValue: 'DB Title', fileValue: 'File Title' });
});
it('should detect tag differences between DB and sidecar', async () => {
const dbMedia = {
id: 'media-2',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: '',
alt: '',
caption: '',
author: '',
tags: '["tag1","tag2"]',
};
mockPostsGetQueue = [dbMedia];
mockReadSidecarFile.mockResolvedValueOnce({
id: 'media-2',
originalName: 'photo.jpg',
title: '',
alt: '',
caption: '',
author: '',
tags: ['tag1'],
});
const result = await mediaEngine.compareMediaMetadata('media-2');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.tags).toEqual({ dbValue: ['tag1', 'tag2'], fileValue: ['tag1'] });
});
it('should return hasDifferences=false when metadata matches', async () => {
const dbMedia = {
id: 'media-3',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: 'Same',
alt: 'Same alt',
caption: '',
author: '',
tags: '["t1"]',
};
mockPostsGetQueue = [dbMedia];
mockReadSidecarFile.mockResolvedValueOnce({
title: 'Same',
alt: 'Same alt',
caption: '',
author: '',
tags: ['t1'],
});
const result = await mediaEngine.compareMediaMetadata('media-3');
expect(result?.hasDifferences).toBe(false);
});
it('should flag when sidecar file is missing', async () => {
const dbMedia = {
id: 'media-4',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: '',
alt: '',
caption: '',
author: '',
tags: '[]',
};
mockPostsGetQueue = [dbMedia];
mockReadSidecarFile.mockResolvedValueOnce(null);
const result = await mediaEngine.compareMediaMetadata('media-4');
expect(result?.hasDifferences).toBe(true);
});
});
// ── Script diff tests ──
describe('compareScriptMetadata', () => {
let scriptEngine: MetadataDiffEngine;
const mockReadScriptFileWithMetadata = vi.fn();
beforeEach(() => {
scriptEngine = new MetadataDiffEngine(
undefined,
undefined,
{ readScriptFileWithMetadata: mockReadScriptFileWithMetadata, getScript: vi.fn(), updateScript: vi.fn() } as any,
);
scriptEngine.setProjectContext('test-project');
});
it('should skip draft scripts', async () => {
mockPostsGetQueue = [{
id: 'script-1',
projectId: 'test-project',
title: 'Draft Script',
slug: 'draft-script',
status: 'draft',
kind: 'utility',
entrypoint: 'render',
enabled: true,
version: 1,
filePath: '/mock/scripts/draft.py',
}];
const result = await scriptEngine.compareScriptMetadata('script-1');
expect(result).toBeNull();
});
it('should detect title difference between DB and file', async () => {
mockPostsGetQueue = [{
id: 'script-2',
projectId: 'test-project',
title: 'DB Title',
slug: 'my-script',
status: 'published',
kind: 'macro',
entrypoint: 'render',
enabled: true,
version: 3,
filePath: '/mock/scripts/my-script.py',
}];
mockReadScriptFileWithMetadata.mockResolvedValueOnce({
metadata: { title: 'File Title', kind: 'macro', entrypoint: 'render', enabled: true, version: 3 },
body: '',
});
const result = await scriptEngine.compareScriptMetadata('script-2');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.title).toEqual({ dbValue: 'DB Title', fileValue: 'File Title' });
});
it('should detect version difference', async () => {
mockPostsGetQueue = [{
id: 'script-3',
projectId: 'test-project',
title: 'Script',
slug: 'script',
status: 'published',
kind: 'utility',
entrypoint: 'render',
enabled: true,
version: 5,
filePath: '/mock/scripts/script.py',
}];
mockReadScriptFileWithMetadata.mockResolvedValueOnce({
metadata: { title: 'Script', kind: 'utility', entrypoint: 'render', enabled: true, version: 3 },
body: '',
});
const result = await scriptEngine.compareScriptMetadata('script-3');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.version).toEqual({ dbValue: 5, fileValue: 3 });
});
it('should return hasDifferences=false when metadata matches', async () => {
mockPostsGetQueue = [{
id: 'script-4',
projectId: 'test-project',
title: 'Same',
slug: 'same',
status: 'published',
kind: 'utility',
entrypoint: 'render',
enabled: true,
version: 1,
filePath: '/mock/scripts/same.py',
}];
mockReadScriptFileWithMetadata.mockResolvedValueOnce({
metadata: { title: 'Same', kind: 'utility', entrypoint: 'render', enabled: true, version: 1 },
body: '',
});
const result = await scriptEngine.compareScriptMetadata('script-4');
expect(result?.hasDifferences).toBe(false);
});
});
// ── Template diff tests ──
describe('compareTemplateMetadata', () => {
let templateEngine: MetadataDiffEngine;
const mockReadTemplateFileWithMetadata = vi.fn();
beforeEach(() => {
templateEngine = new MetadataDiffEngine(
undefined,
undefined,
undefined,
{ readTemplateFileWithMetadata: mockReadTemplateFileWithMetadata, getTemplate: vi.fn(), updateTemplate: vi.fn() } as any,
);
templateEngine.setProjectContext('test-project');
});
it('should skip draft templates', async () => {
mockPostsGetQueue = [{
id: 'tpl-1',
projectId: 'test-project',
title: 'Draft Template',
slug: 'draft-tpl',
status: 'draft',
kind: 'post',
enabled: true,
version: 1,
filePath: '/mock/templates/draft.liquid',
}];
const result = await templateEngine.compareTemplateMetadata('tpl-1');
expect(result).toBeNull();
});
it('should detect kind difference between DB and file', async () => {
mockPostsGetQueue = [{
id: 'tpl-2',
projectId: 'test-project',
title: 'Template',
slug: 'template',
status: 'published',
kind: 'post',
enabled: true,
version: 1,
filePath: '/mock/templates/template.liquid',
}];
mockReadTemplateFileWithMetadata.mockResolvedValueOnce({
metadata: { title: 'Template', kind: 'list', enabled: true, version: 1 },
body: '',
});
const result = await templateEngine.compareTemplateMetadata('tpl-2');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.kind).toEqual({ dbValue: 'post', fileValue: 'list' });
});
it('should detect enabled difference', async () => {
mockPostsGetQueue = [{
id: 'tpl-3',
projectId: 'test-project',
title: 'Tpl',
slug: 'tpl',
status: 'published',
kind: 'post',
enabled: true,
version: 1,
filePath: '/mock/templates/tpl.liquid',
}];
mockReadTemplateFileWithMetadata.mockResolvedValueOnce({
metadata: { title: 'Tpl', kind: 'post', enabled: false, version: 1 },
body: '',
});
const result = await templateEngine.compareTemplateMetadata('tpl-3');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.enabled).toEqual({ dbValue: true, fileValue: false });
});
it('should return hasDifferences=false when metadata matches', async () => {
mockPostsGetQueue = [{
id: 'tpl-4',
projectId: 'test-project',
title: 'Same',
slug: 'same',
status: 'published',
kind: 'partial',
enabled: true,
version: 2,
filePath: '/mock/templates/same.liquid',
}];
mockReadTemplateFileWithMetadata.mockResolvedValueOnce({
metadata: { title: 'Same', kind: 'partial', enabled: true, version: 2 },
body: '',
});
const result = await templateEngine.compareTemplateMetadata('tpl-4');
expect(result?.hasDifferences).toBe(false);
});
});
// ── getTableStats with expanded counts ──
describe('getTableStats (expanded)', () => {
it('should include script and template counts', async () => {
mockLocalClient.execute
.mockResolvedValueOnce({ rows: [{ count: 10 }] }) // total posts
.mockResolvedValueOnce({ rows: [{ count: 8 }] }) // published posts
.mockResolvedValueOnce({ rows: [{ count: 2 }] }) // draft posts
.mockResolvedValueOnce({ rows: [{ count: 50 }] }) // total media
.mockResolvedValueOnce({ rows: [{ count: 5 }] }) // total scripts
.mockResolvedValueOnce({ rows: [{ count: 4 }] }) // published scripts
.mockResolvedValueOnce({ rows: [{ count: 7 }] }) // total templates
.mockResolvedValueOnce({ rows: [{ count: 6 }] }); // published templates
const stats = await engine.getTableStats();
expect(stats).toEqual({
totalPosts: 10,
publishedPosts: 8,
draftPosts: 2,
totalMedia: 50,
totalScripts: 5,
publishedScripts: 4,
totalTemplates: 7,
publishedTemplates: 6,
});
});
});
});

View File

@@ -3507,4 +3507,180 @@ Content with [link](/posts/other-post)`);
expect(post!.language).toBe('it');
});
});
describe('syncPublishedPostFile', () => {
it('should recreate the file when it is missing', async () => {
const filePath = '/mock/userData/projects/default/posts/2024/01/my-post.md';
// Mock: DB returns a published post, but the file does NOT exist on disk
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: 'post-missing-file',
projectId: 'default',
title: 'My Post',
slug: 'my-post',
content: 'Body from database',
status: 'published',
filePath,
tags: '["tag1"]',
categories: '["cat1"]',
createdAt: new Date('2024-01-15T10:00:00.000Z'),
updatedAt: new Date('2024-01-20T10:00:00.000Z'),
}),
});
return chain;
});
// File does NOT exist (not in mockFiles)
const result = await postEngine.syncPublishedPostFile('post-missing-file');
expect(result).toBe(true);
// Verify the file was recreated via writeFile
expect(fs.writeFile).toHaveBeenCalled();
// Check the file content was written with DB body
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
const writtenContent = writeCall[1] as string;
expect(writtenContent).toContain('Body from database');
expect(writtenContent).toContain('title: My Post');
expect(writtenContent).toContain('tag1');
});
it('should update DB filePath when slug changed causes different path', async () => {
const oldFilePath = '/mock/userData/projects/default/posts/2024/01/old-slug.md';
// Mock: DB post has the new slug but old filePath
const mockUpdate = vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
}));
vi.mocked(mockLocalDb.update).mockImplementation(mockUpdate as any);
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: 'post-slug-changed',
projectId: 'default',
title: 'New Title',
slug: 'new-slug',
content: 'Some content',
status: 'published',
filePath: oldFilePath,
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T10:00:00.000Z'),
updatedAt: new Date('2024-01-20T10:00:00.000Z'),
}),
});
return chain;
});
// Old file does not exist (slug changed)
const result = await postEngine.syncPublishedPostFile('post-slug-changed');
expect(result).toBe(true);
// writePostFile writes to new-slug.md, which differs from oldFilePath
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
const writtenPath = writeCall[0] as string;
expect(writtenPath).toContain('new-slug.md');
expect(writtenPath).not.toContain('old-slug.md');
// DB filePath should be updated
expect(mockUpdate).toHaveBeenCalled();
});
});
describe('importOrphanFile', () => {
it('should import an orphan file into the database as published', async () => {
const orphanPath = '/mock/userData/posts/2024/03/orphan-post.md';
mockFiles.set(orphanPath, `---
id: orphan-id-123
title: "Orphan Post Title"
slug: orphan-post
createdAt: "2024-03-10T12:00:00.000Z"
updatedAt: "2024-03-10T12:00:00.000Z"
tags:
- imported
categories:
- blog
---
This is the orphan body content.`);
// select → get returns undefined (no existing post with that id/slug)
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue(undefined),
});
return chain;
});
const result = await postEngine.importOrphanFile(orphanPath);
expect(result).not.toBeNull();
expect(result!.title).toBe('Orphan Post Title');
expect(result!.slug).toBe('orphan-post');
expect(result!.status).toBe('published');
expect(result!.tags).toEqual(['imported']);
expect(result!.categories).toEqual(['blog']);
// Should have inserted into DB
expect(mockLocalDb.insert).toHaveBeenCalled();
});
it('should return null when the file cannot be parsed', async () => {
// File does not exist at all
const result = await postEngine.importOrphanFile('/nonexistent/path.md');
expect(result).toBeNull();
});
it('should deduplicate slug when it already exists', async () => {
const orphanPath = '/mock/userData/posts/2024/01/existing-slug.md';
mockFiles.set(orphanPath, `---
title: "Duplicate Slug Post"
slug: existing-slug
createdAt: "2024-01-01T00:00:00.000Z"
updatedAt: "2024-01-01T00:00:00.000Z"
tags: []
categories: []
---
Body.`);
// ensureUniquePostIdentity flow:
// 1. select → get: id check → undefined (available)
// 2. isSlugAvailable('existing-slug') → found (taken)
// 3. generateUniqueSlug → isSlugAvailable('existing-slug') again → found (taken)
// 4. isSlugAvailable('existing-slug-2') → undefined (available)
let selectCallCount = 0;
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockImplementation(() => {
selectCallCount++;
// id check: available
if (selectCallCount === 1) return Promise.resolve(undefined);
// slug check: taken (both the direct check and generateUniqueSlug re-check)
if (selectCallCount <= 3) return Promise.resolve({ id: 'other-post' });
// slug-2 check: available
return Promise.resolve(undefined);
}),
});
return chain;
});
const result = await postEngine.importOrphanFile(orphanPath);
expect(result).not.toBeNull();
// Should have been deduplicated
expect(result!.slug).toBe('existing-slug-2');
});
});
});

View File

@@ -59,15 +59,27 @@ describe('pythonApiContractV1', () => {
});
});
it('only exposes detectPostLanguage from chat namespace', () => {
it('exposes analyzeMediaImage and detectPostLanguage from chat namespace', () => {
const methodNames = listPythonApiMethodNames();
const chatMethods = methodNames.filter((m) => m.startsWith('chat.'));
expect(chatMethods).toEqual(['chat.detectPostLanguage']);
expect(chatMethods).toEqual(['chat.analyzeMediaImage', 'chat.detectPostLanguage']);
});
it('documents chat.analyzeMediaImage contract with mediaId and language params', () => {
expect(getPythonApiMethodContract('chat.analyzeMediaImage')).toEqual({
method: 'chat.analyzeMediaImage',
description: 'Analyze an image and generate title, alt text, and caption using AI.',
params: [
{ name: 'mediaId', type: 'string', required: true },
{ name: 'language', type: 'string', required: false },
],
returns: 'ImageAnalysisResult',
});
});
it('contains semantic version metadata for compatibility checks', () => {
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
version: '1.10.0',
version: '1.11.0',
generatedAt: expect.any(String),
});
});
@@ -77,6 +89,7 @@ describe('pythonApiContractV1', () => {
expect.objectContaining({ name: 'PostData' }),
expect.objectContaining({ name: 'MediaData' }),
expect.objectContaining({ name: 'ProjectData' }),
expect.objectContaining({ name: 'ImageAnalysisResult' }),
]));
});
});
@@ -101,6 +114,7 @@ describe('generatePythonApiModuleV1', () => {
expect(moduleCode).toContain('class BdsApi:');
expect(moduleCode).toContain('bds = BdsApi(_transport)');
expect(moduleCode).toContain('class ChatApi:');
expect(moduleCode).toContain('async def analyze_media_image(self, media_id, language=None):');
expect(moduleCode).toContain('async def detect_post_language(self, title, content):');
});