Feature/python api image discovery (#34)

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

* Fix updateMedia losing linkedPostIds by reading existing sidecar before overwriting

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

* Extend MetadataDiffEngine to cover media, scripts, and templates

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

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

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

* feat: extended meta diff

* feat: meta diff als reconstructs orphans

* chore: updated documentation

---------

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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",