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

@@ -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>