Feature/python api image discovery (#34)
* Expose chat.analyzeMediaImage in Python API for batch image metadata generation * Fix updateMedia losing linkedPostIds by reading existing sidecar before overwriting * Also preserve author from sidecar when DB value is null (data drift) * Extend MetadataDiffEngine to cover media, scripts, and templates * Redesign MetadataDiffPanel: item-first view with field pills, filtering, and per-item multi-field diffs * Fix task:progress startsWith crash (taskId not id) and nested button violation in field pills * Populate field diffs for file-missing items and show fileMissing badge in UI * feat: extended meta diff * feat: meta diff als reconstructs orphans * chore: updated documentation --------- Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
@@ -382,7 +382,7 @@ export class MediaEngine extends EventEmitter {
|
||||
return sidecarPath;
|
||||
}
|
||||
|
||||
private async readSidecarFile(sidecarPath: string): Promise<MediaMetadata | null> {
|
||||
async readSidecarFile(sidecarPath: string): Promise<MediaMetadata | null> {
|
||||
try {
|
||||
// Check if file exists first to avoid noisy errors
|
||||
try {
|
||||
@@ -622,6 +622,19 @@ export class MediaEngine extends EventEmitter {
|
||||
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
|
||||
if (!dbMedia) return null;
|
||||
|
||||
// Read existing sidecar to preserve fields that may only exist there
|
||||
// (e.g. linkedPostIds is sidecar-only, and author/title may have drifted)
|
||||
const existingSidecar = await this.readSidecarFile(`${dbMedia.filePath}.meta`);
|
||||
if (existingSidecar) {
|
||||
if (existingSidecar.linkedPostIds?.length && !data.linkedPostIds) {
|
||||
updated.linkedPostIds = existingSidecar.linkedPostIds;
|
||||
}
|
||||
// Preserve sidecar values for fields the caller didn't explicitly set
|
||||
if (existingSidecar.author && !updated.author && !('author' in data)) {
|
||||
updated.author = existingSidecar.author;
|
||||
}
|
||||
}
|
||||
|
||||
await this.writeSidecarFile(updated, dbMedia.filePath);
|
||||
|
||||
await db.update(media)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -635,21 +635,84 @@ export class PostEngine extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read content from the existing file
|
||||
// Read content from the existing file, fall back to DB content if file is missing
|
||||
const fileData = await this.readPostFile(dbPost.filePath);
|
||||
if (!fileData) {
|
||||
return false;
|
||||
const body = fileData?.content ?? dbPost.content ?? '';
|
||||
|
||||
// Build the full post data with DB metadata and content
|
||||
const postData = this.dbRowToPostData(dbPost, body);
|
||||
|
||||
// Write the file (may recreate it if missing, path may change if slug changed)
|
||||
const newFilePath = await this.writePostFile(postData);
|
||||
|
||||
// If the written path differs from DB (e.g. slug changed), update DB
|
||||
if (newFilePath !== dbPost.filePath) {
|
||||
await db.update(posts).set({ filePath: newFilePath }).where(eq(posts.id, postId));
|
||||
}
|
||||
|
||||
// Build the full post data with DB metadata (tags) and file content
|
||||
const postData = this.dbRowToPostData(dbPost, fileData.content);
|
||||
|
||||
// Re-write the file with updated metadata
|
||||
await this.writePostFile(postData);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a single orphan file (exists on disk but not in DB) into the database
|
||||
* as a published post. Reads frontmatter metadata and content from the file,
|
||||
* ensures unique ID/slug, and inserts a new DB row pointing to the existing file.
|
||||
*
|
||||
* @returns The imported PostData, or null if the file could not be read/parsed.
|
||||
*/
|
||||
async importOrphanFile(filePath: string): Promise<PostData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
const postData = await this.readPostFile(filePath);
|
||||
if (!postData) return null;
|
||||
|
||||
// Ensure unique ID and slug within the current project
|
||||
const { id, slug } = await this.ensureUniquePostIdentity(postData.id, postData.slug);
|
||||
|
||||
const checksum = this.calculateChecksum(postData.content);
|
||||
|
||||
await db.insert(posts).values({
|
||||
id,
|
||||
projectId: this.currentProjectId,
|
||||
title: postData.title,
|
||||
slug,
|
||||
excerpt: postData.excerpt,
|
||||
content: null,
|
||||
status: 'published',
|
||||
author: postData.author,
|
||||
language: postData.language || null,
|
||||
createdAt: postData.createdAt,
|
||||
updatedAt: postData.updatedAt,
|
||||
publishedAt: postData.publishedAt || postData.updatedAt,
|
||||
filePath,
|
||||
checksum,
|
||||
tags: JSON.stringify(postData.tags),
|
||||
categories: JSON.stringify(postData.categories),
|
||||
});
|
||||
|
||||
await this.updateFTSIndex({
|
||||
id,
|
||||
projectId: this.currentProjectId,
|
||||
title: postData.title,
|
||||
content: postData.content,
|
||||
excerpt: postData.excerpt,
|
||||
tags: postData.tags,
|
||||
categories: postData.categories,
|
||||
});
|
||||
|
||||
const imported: PostData = {
|
||||
...postData,
|
||||
id,
|
||||
slug,
|
||||
status: 'published',
|
||||
publishedAt: postData.publishedAt || postData.updatedAt,
|
||||
};
|
||||
|
||||
this.emit('postCreated', imported);
|
||||
await this.notifier.notify('post', id, 'created');
|
||||
return imported;
|
||||
}
|
||||
|
||||
async getAllPosts(options?: PaginationOptions): Promise<PaginatedResult<PostData>> {
|
||||
const db = getDatabase().getLocal();
|
||||
const limit = options?.limit ?? 500;
|
||||
|
||||
@@ -64,7 +64,7 @@ export interface ScriptValidationResult {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface ParsedScriptFile {
|
||||
export interface ParsedScriptFile {
|
||||
metadata: {
|
||||
id?: string;
|
||||
projectId?: string;
|
||||
@@ -789,7 +789,7 @@ export class ScriptEngine extends EventEmitter {
|
||||
return results;
|
||||
}
|
||||
|
||||
private async readScriptFileWithMetadata(filePath: string): Promise<ParsedScriptFile | null> {
|
||||
async readScriptFileWithMetadata(filePath: string): Promise<ParsedScriptFile | null> {
|
||||
try {
|
||||
const rawContent = await fs.readFile(filePath, 'utf-8');
|
||||
return this.parseScriptFile(rawContent);
|
||||
|
||||
@@ -67,7 +67,7 @@ export interface TemplateDeleteResult {
|
||||
references?: { postIds: string[]; tagIds: string[] };
|
||||
}
|
||||
|
||||
interface ParsedTemplateFile {
|
||||
export interface ParsedTemplateFile {
|
||||
metadata: {
|
||||
id?: string;
|
||||
projectId?: string;
|
||||
@@ -889,7 +889,7 @@ export class TemplateEngine extends EventEmitter {
|
||||
return results;
|
||||
}
|
||||
|
||||
private async readTemplateFileWithMetadata(filePath: string): Promise<ParsedTemplateFile | null> {
|
||||
async readTemplateFileWithMetadata(filePath: string): Promise<ParsedTemplateFile | null> {
|
||||
try {
|
||||
const rawContent = await fs.readFile(filePath, 'utf-8');
|
||||
return this.parseTemplateFile(rawContent);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 d’analyser les différences",
|
||||
"metadataDiff.progress.starting": "Démarrage de l’analyse...",
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user