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:
@@ -126,6 +126,53 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Entity Tabs */
|
||||
.diff-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
.diff-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--descriptionForeground);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.diff-tab:hover {
|
||||
color: var(--editor-foreground);
|
||||
}
|
||||
|
||||
.diff-tab.active {
|
||||
color: var(--editor-foreground);
|
||||
border-bottom-color: var(--button-background);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: var(--badge-background, #e5a100);
|
||||
color: var(--badge-foreground, #fff);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Results Section */
|
||||
.diff-results {
|
||||
flex: 1;
|
||||
@@ -151,128 +198,177 @@
|
||||
border-color: var(--testing-iconFailed);
|
||||
}
|
||||
|
||||
/* Collapsible Groups */
|
||||
.diff-group {
|
||||
/* Field summary pills */
|
||||
.diff-field-summaries {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.field-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--input-border);
|
||||
background: var(--sidebar-background);
|
||||
color: var(--editor-foreground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.field-pill:hover {
|
||||
background: var(--list-hoverBackground);
|
||||
}
|
||||
|
||||
.field-pill.active {
|
||||
background: color-mix(in srgb, var(--button-background) 20%, var(--sidebar-background));
|
||||
border-color: var(--button-background);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field-pill.clear-filter {
|
||||
padding: 5px 8px;
|
||||
border-color: var(--testing-iconFailed);
|
||||
color: var(--testing-iconFailed);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field-pill-label {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.field-pill-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: var(--badge-background, #e5a100);
|
||||
color: var(--badge-foreground, #fff);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.field-pill-actions {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.pill-sync {
|
||||
padding: 1px 5px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 3px;
|
||||
background: var(--input-background);
|
||||
color: var(--editor-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.pill-sync:hover:not(:disabled) {
|
||||
background: var(--list-hoverBackground);
|
||||
}
|
||||
|
||||
.pill-sync:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pill-sync.db-to-file {
|
||||
border-color: var(--button-background);
|
||||
color: var(--button-background);
|
||||
}
|
||||
|
||||
.pill-sync.file-to-db {
|
||||
border-color: var(--testing-iconQueued);
|
||||
color: var(--testing-iconQueued);
|
||||
}
|
||||
|
||||
/* Item cards */
|
||||
.diff-item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diff-item-card {
|
||||
background: var(--sidebar-background);
|
||||
border: 1px solid var(--sidebar-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diff-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: var(--list-hoverBackground);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.diff-group-header:hover {
|
||||
background: var(--list-activeSelectionBackground);
|
||||
}
|
||||
|
||||
.diff-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.diff-item-header {
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diff-group-title .chevron {
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.diff-group-title .chevron.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.diff-group-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diff-group-count .badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--badge-background);
|
||||
color: var(--badge-foreground);
|
||||
}
|
||||
|
||||
.diff-group-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.diff-group-actions button {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 3px;
|
||||
background: var(--input-background);
|
||||
color: var(--editor-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.diff-group-actions button:hover:not(:disabled) {
|
||||
font-size: 13px;
|
||||
background: var(--list-hoverBackground);
|
||||
}
|
||||
|
||||
.diff-group-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.diff-group-actions button.db-to-file {
|
||||
border-color: var(--button-background);
|
||||
color: var(--button-background);
|
||||
}
|
||||
|
||||
.diff-group-actions button.file-to-db {
|
||||
border-color: var(--testing-iconQueued);
|
||||
color: var(--testing-iconQueued);
|
||||
}
|
||||
|
||||
.diff-group-content {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--sidebar-border);
|
||||
}
|
||||
|
||||
.diff-group-content.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Post Items */
|
||||
.diff-post-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--editor-background);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diff-post-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.diff-post-title {
|
||||
font-weight: 500;
|
||||
color: var(--editor-foreground);
|
||||
border-bottom: 1px solid var(--sidebar-border);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diff-value {
|
||||
.file-missing-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--editorWarning-foreground) 18%, transparent);
|
||||
color: var(--editorWarning-foreground);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.diff-item-card.file-missing {
|
||||
border-color: color-mix(in srgb, var(--editorWarning-foreground) 30%, var(--sidebar-border));
|
||||
}
|
||||
|
||||
.diff-item-fields {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.diff-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diff-field-name {
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
color: var(--descriptionForeground);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.diff-field-values {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diff-field-value {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--editor-font-family);
|
||||
@@ -282,20 +378,27 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.diff-value.db-value {
|
||||
background: color-mix(in srgb, var(--button-background) 15%, transparent);
|
||||
.diff-field-value.db-value {
|
||||
background: color-mix(in srgb, var(--button-background) 12%, transparent);
|
||||
border: 1px solid var(--button-background);
|
||||
}
|
||||
|
||||
.diff-value.file-value {
|
||||
background: color-mix(in srgb, var(--testing-iconQueued) 15%, transparent);
|
||||
.diff-field-value.file-value {
|
||||
background: color-mix(in srgb, var(--testing-iconQueued) 12%, transparent);
|
||||
border: 1px solid var(--testing-iconQueued);
|
||||
}
|
||||
|
||||
.diff-value-label {
|
||||
font-size: 10px;
|
||||
.diff-field-value.file-value.missing {
|
||||
font-style: italic;
|
||||
color: var(--descriptionForeground);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.diff-source-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--descriptionForeground);
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
@@ -340,3 +443,49 @@
|
||||
font-size: 32px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Orphan Files */
|
||||
.orphan-files-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.orphan-files-section h3 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--editorInfo-foreground, var(--editor-foreground));
|
||||
}
|
||||
|
||||
.orphan-files-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.orphan-files-description {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 12px;
|
||||
color: var(--descriptionForeground);
|
||||
}
|
||||
|
||||
.orphan-file-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--editorInfo-foreground, #3794ff) 18%, transparent);
|
||||
color: var(--editorInfo-foreground, #3794ff);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.diff-item-card.orphan-file {
|
||||
border-color: color-mix(in srgb, var(--editorInfo-foreground, #3794ff) 30%, var(--sidebar-border));
|
||||
}
|
||||
|
||||
.orphan-path {
|
||||
word-break: break-all;
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { useI18n } from '../../i18n';
|
||||
@@ -9,47 +9,123 @@ interface TableStats {
|
||||
publishedPosts: number;
|
||||
draftPosts: number;
|
||||
totalMedia: number;
|
||||
totalScripts: number;
|
||||
publishedScripts: number;
|
||||
totalTemplates: number;
|
||||
publishedTemplates: number;
|
||||
}
|
||||
|
||||
interface DiffPost {
|
||||
postId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
// ── Generic diff types (item-first, showing all field diffs per item) ──
|
||||
|
||||
interface FieldDiff {
|
||||
dbValue: unknown;
|
||||
fileValue: unknown;
|
||||
}
|
||||
|
||||
interface DiffGroup {
|
||||
interface GenericDiffItem {
|
||||
id: string;
|
||||
label: string;
|
||||
fileMissing?: boolean;
|
||||
fields: Record<string, FieldDiff>;
|
||||
}
|
||||
|
||||
interface GenericOrphanFile {
|
||||
filePath: string;
|
||||
slug: string;
|
||||
title?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface FieldSummary {
|
||||
field: string;
|
||||
label: string;
|
||||
posts: DiffPost[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
interface GenericScanResult {
|
||||
totalScanned: number;
|
||||
postsWithDifferences: number;
|
||||
differences: Array<{
|
||||
postId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
filePath?: string;
|
||||
hasDifferences: boolean;
|
||||
differences: Record<string, { dbValue: unknown; fileValue: unknown }>;
|
||||
}>;
|
||||
groups: DiffGroup[];
|
||||
itemsWithDifferences: number;
|
||||
items: GenericDiffItem[];
|
||||
fieldSummaries: FieldSummary[];
|
||||
orphanFiles: GenericOrphanFile[];
|
||||
}
|
||||
|
||||
type EntityTab = 'posts' | 'media' | 'scripts' | 'templates';
|
||||
type ScanPhase = 'idle' | 'loading-stats' | 'scanning' | 'complete';
|
||||
|
||||
// ── Adapters: use differences array (item-first) + groups for field labels ──
|
||||
|
||||
function adaptPostScanResult(raw: Awaited<ReturnType<NonNullable<typeof window.electronAPI>['metadataDiff']['scan']>>): GenericScanResult {
|
||||
return {
|
||||
totalScanned: raw.totalScanned,
|
||||
itemsWithDifferences: raw.postsWithDifferences,
|
||||
items: raw.differences.filter(d => d.hasDifferences).map(d => ({
|
||||
id: d.postId,
|
||||
label: d.title || d.slug,
|
||||
fileMissing: d.fileMissing,
|
||||
fields: d.differences as Record<string, FieldDiff>,
|
||||
})),
|
||||
fieldSummaries: raw.groups.map(g => ({ field: g.field, label: g.label, count: g.posts.length })),
|
||||
orphanFiles: raw.orphanFiles ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function adaptMediaScanResult(raw: Awaited<ReturnType<NonNullable<typeof window.electronAPI>['metadataDiff']['scanMedia']>>): GenericScanResult {
|
||||
return {
|
||||
totalScanned: raw.totalScanned,
|
||||
itemsWithDifferences: raw.itemsWithDifferences,
|
||||
items: raw.differences.filter(d => d.hasDifferences).map(d => ({
|
||||
id: d.mediaId,
|
||||
label: d.originalName,
|
||||
fileMissing: d.fileMissing,
|
||||
fields: d.differences as Record<string, FieldDiff>,
|
||||
})),
|
||||
fieldSummaries: raw.groups.map(g => ({ field: g.field, label: g.label, count: g.items.length })),
|
||||
orphanFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
function adaptScriptScanResult(raw: Awaited<ReturnType<NonNullable<typeof window.electronAPI>['metadataDiff']['scanScripts']>>): GenericScanResult {
|
||||
return {
|
||||
totalScanned: raw.totalScanned,
|
||||
itemsWithDifferences: raw.itemsWithDifferences,
|
||||
items: raw.differences.filter(d => d.hasDifferences).map(d => ({
|
||||
id: d.scriptId,
|
||||
label: d.title || d.slug,
|
||||
fileMissing: d.fileMissing,
|
||||
fields: d.differences as Record<string, FieldDiff>,
|
||||
})),
|
||||
fieldSummaries: raw.groups.map(g => ({ field: g.field, label: g.label, count: g.items.length })),
|
||||
orphanFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
function adaptTemplateScanResult(raw: Awaited<ReturnType<NonNullable<typeof window.electronAPI>['metadataDiff']['scanTemplates']>>): GenericScanResult {
|
||||
return {
|
||||
totalScanned: raw.totalScanned,
|
||||
itemsWithDifferences: raw.itemsWithDifferences,
|
||||
items: raw.differences.filter(d => d.hasDifferences).map(d => ({
|
||||
id: d.templateId,
|
||||
label: d.title || d.slug,
|
||||
fileMissing: d.fileMissing,
|
||||
fields: d.differences as Record<string, FieldDiff>,
|
||||
})),
|
||||
fieldSummaries: raw.groups.map(g => ({ field: g.field, label: g.label, count: g.items.length })),
|
||||
orphanFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const MetadataDiffPanel: React.FC = () => {
|
||||
const { t: tr } = useI18n();
|
||||
const activeProjectId = useAppStore((s) => s.activeProject?.id ?? null);
|
||||
const [stats, setStats] = useState<TableStats | null>(null);
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<EntityTab>('posts');
|
||||
const [scanResults, setScanResults] = useState<Record<EntityTab, GenericScanResult | null>>({ posts: null, media: null, scripts: null, templates: null });
|
||||
const [scanPhase, setScanPhase] = useState<ScanPhase>('idle');
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0, message: '' });
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [syncingGroups, setSyncingGroups] = useState<Set<string>>(new Set());
|
||||
const [activeFieldFilter, setActiveFieldFilter] = useState<string | null>(null);
|
||||
const [syncingFields, setSyncingFields] = useState<Set<string>>(new Set());
|
||||
const [importingOrphans, setImportingOrphans] = useState(false);
|
||||
|
||||
// Load initial stats
|
||||
useEffect(() => {
|
||||
@@ -58,9 +134,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
setScanPhase('loading-stats');
|
||||
try {
|
||||
const result = await window.electronAPI?.metadataDiff.getStats();
|
||||
if (result) {
|
||||
setStats(result);
|
||||
}
|
||||
if (result) setStats(result as TableStats);
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error);
|
||||
showToast.error(tr('metadataDiff.error.loadStats'));
|
||||
@@ -73,34 +147,35 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
// Subscribe to task progress
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electronAPI?.on('task:progress', (data: unknown) => {
|
||||
const progress = data as { id: string; progress: number; message?: string };
|
||||
if (progress.id.startsWith('metadata-diff-scan')) {
|
||||
setProgress({
|
||||
current: Math.round(progress.progress),
|
||||
total: 100,
|
||||
message: progress.message || '',
|
||||
});
|
||||
const p = data as { taskId: string; progress: number; message?: string };
|
||||
if (p.taskId?.startsWith('metadata-')) {
|
||||
setProgress({ current: Math.round(p.progress), total: 100, message: p.message || '' });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
return () => { unsubscribe?.(); };
|
||||
}, []);
|
||||
|
||||
const handleScan = useCallback(async () => {
|
||||
setScanPhase('scanning');
|
||||
setProgress({ current: 0, total: 100, message: tr('metadataDiff.progress.starting') });
|
||||
setScanResult(null);
|
||||
setScanResults({ posts: null, media: null, scripts: null, templates: null });
|
||||
setActiveFieldFilter(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI?.metadataDiff.scan();
|
||||
if (result) {
|
||||
setScanResult(result);
|
||||
// Auto-expand groups with differences
|
||||
const groupsWithDiffs = new Set(result.groups.map(g => g.field));
|
||||
setExpandedGroups(groupsWithDiffs);
|
||||
}
|
||||
const [postResult, mediaResult, scriptResult, templateResult] = await Promise.all([
|
||||
window.electronAPI?.metadataDiff.scan(),
|
||||
window.electronAPI?.metadataDiff.scanMedia(),
|
||||
window.electronAPI?.metadataDiff.scanScripts(),
|
||||
window.electronAPI?.metadataDiff.scanTemplates(),
|
||||
]);
|
||||
|
||||
const results: Record<EntityTab, GenericScanResult | null> = {
|
||||
posts: postResult ? adaptPostScanResult(postResult) : null,
|
||||
media: mediaResult ? adaptMediaScanResult(mediaResult) : null,
|
||||
scripts: scriptResult ? adaptScriptScanResult(scriptResult) : null,
|
||||
templates: templateResult ? adaptTemplateScanResult(templateResult) : null,
|
||||
};
|
||||
setScanResults(results);
|
||||
setScanPhase('complete');
|
||||
} catch (error) {
|
||||
console.error('Scan failed:', error);
|
||||
@@ -109,74 +184,121 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
}
|
||||
}, [tr]);
|
||||
|
||||
const toggleGroup = (field: string) => {
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(field)) {
|
||||
next.delete(field);
|
||||
} else {
|
||||
next.add(field);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const handleTabChange = (tab: EntityTab) => {
|
||||
setActiveTab(tab);
|
||||
setActiveFieldFilter(null);
|
||||
};
|
||||
|
||||
const handleSyncDbToFile = useCallback(async (group: DiffGroup) => {
|
||||
const postIds = group.posts.map(p => p.postId);
|
||||
setSyncingGroups(prev => new Set(prev).add(group.field));
|
||||
const toggleFieldFilter = (field: string) => {
|
||||
setActiveFieldFilter(prev => prev === field ? null : field);
|
||||
};
|
||||
|
||||
// Filter items: if a field filter is active, show only items that have that field diff,
|
||||
// but still show ALL fields for those items
|
||||
const currentResult = scanResults[activeTab];
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!currentResult) return [];
|
||||
if (!activeFieldFilter) return currentResult.items;
|
||||
return currentResult.items.filter(item => activeFieldFilter in item.fields);
|
||||
}, [currentResult, activeFieldFilter]);
|
||||
|
||||
const handleSyncDbToFile = useCallback(async (field: string, fieldLabel: string) => {
|
||||
// Get IDs of items that have this field diff (from filtered or all)
|
||||
const ids = (currentResult?.items ?? [])
|
||||
.filter(item => field in item.fields)
|
||||
.map(item => item.id);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
setSyncingFields(prev => new Set(prev).add(field));
|
||||
try {
|
||||
const result = await window.electronAPI?.metadataDiff.syncDbToFile(postIds, group.label);
|
||||
let result: { success: number; failed: number } | undefined;
|
||||
switch (activeTab) {
|
||||
case 'posts': result = await window.electronAPI?.metadataDiff.syncDbToFile(ids, fieldLabel); break;
|
||||
case 'media': result = await window.electronAPI?.metadataDiff.syncMediaDbToFile(ids, fieldLabel); break;
|
||||
case 'scripts': result = await window.electronAPI?.metadataDiff.syncScriptDbToFile(ids, fieldLabel); break;
|
||||
case 'templates': result = await window.electronAPI?.metadataDiff.syncTemplateDbToFile(ids, fieldLabel); break;
|
||||
}
|
||||
if (result) {
|
||||
showToast.success(tr('metadataDiff.sync.dbToFile.success', { success: result.success, failed: result.failed > 0 ? `, ${result.failed} ${tr('metadataDiff.sync.failed')}` : '' }));
|
||||
// Re-scan to update the view
|
||||
handleScan();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error);
|
||||
showToast.error(tr('metadataDiff.sync.dbToFile.error'));
|
||||
} finally {
|
||||
setSyncingGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(group.field);
|
||||
return next;
|
||||
});
|
||||
setSyncingFields(prev => { const next = new Set(prev); next.delete(field); return next; });
|
||||
}
|
||||
}, [handleScan, tr]);
|
||||
}, [activeTab, currentResult, handleScan, tr]);
|
||||
|
||||
const handleSyncFileToDb = useCallback(async (group: DiffGroup) => {
|
||||
const postIds = group.posts.map(p => p.postId);
|
||||
setSyncingGroups(prev => new Set(prev).add(group.field));
|
||||
const handleSyncFileToDb = useCallback(async (field: string, fieldLabel: string) => {
|
||||
const ids = (currentResult?.items ?? [])
|
||||
.filter(item => field in item.fields)
|
||||
.map(item => item.id);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
setSyncingFields(prev => new Set(prev).add(field));
|
||||
try {
|
||||
const result = await window.electronAPI?.metadataDiff.syncFileToDb(postIds, group.field, group.label);
|
||||
let result: { success: number; failed: number } | undefined;
|
||||
switch (activeTab) {
|
||||
case 'posts': result = await window.electronAPI?.metadataDiff.syncFileToDb(ids, field, fieldLabel); break;
|
||||
case 'media': result = await window.electronAPI?.metadataDiff.syncMediaFileToDb(ids, field, fieldLabel); break;
|
||||
case 'scripts': result = await window.electronAPI?.metadataDiff.syncScriptFileToDb(ids, field, fieldLabel); break;
|
||||
case 'templates': result = await window.electronAPI?.metadataDiff.syncTemplateFileToDb(ids, field, fieldLabel); break;
|
||||
}
|
||||
if (result) {
|
||||
showToast.success(tr('metadataDiff.sync.fileToDb.success', { success: result.success, failed: result.failed > 0 ? `, ${result.failed} ${tr('metadataDiff.sync.failed')}` : '' }));
|
||||
// Re-scan to update the view
|
||||
handleScan();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error);
|
||||
showToast.error(tr('metadataDiff.sync.fileToDb.error'));
|
||||
} finally {
|
||||
setSyncingGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(group.field);
|
||||
return next;
|
||||
});
|
||||
setSyncingFields(prev => { const next = new Set(prev); next.delete(field); return next; });
|
||||
}
|
||||
}, [handleScan, tr]);
|
||||
}, [activeTab, currentResult, handleScan, tr]);
|
||||
|
||||
const handleImportOrphanFiles = useCallback(async () => {
|
||||
const orphanPaths = currentResult?.orphanFiles.map(o => o.filePath) ?? [];
|
||||
if (orphanPaths.length === 0) return;
|
||||
|
||||
setImportingOrphans(true);
|
||||
try {
|
||||
const result = await window.electronAPI?.metadataDiff.importOrphanFiles(orphanPaths);
|
||||
if (result) {
|
||||
showToast.success(tr('metadataDiff.orphanFiles.importSuccess', { success: result.success, failed: result.failed > 0 ? `, ${result.failed} ${tr('metadataDiff.sync.failed')}` : '' }));
|
||||
handleScan();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Orphan import failed:', error);
|
||||
showToast.error(tr('metadataDiff.orphanFiles.importError'));
|
||||
} finally {
|
||||
setImportingOrphans(false);
|
||||
}
|
||||
}, [currentResult, handleScan, tr]);
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value.join(', ') : '(empty)';
|
||||
}
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '(empty)';
|
||||
}
|
||||
if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '(empty)';
|
||||
if (value === null || value === undefined || value === '') return '(empty)';
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const summaryKey = (tab: EntityTab, hasDiffs: boolean): string => {
|
||||
const map: Record<EntityTab, [string, string]> = {
|
||||
posts: ['metadataDiff.summary.noDiffs', 'metadataDiff.summary.withDiffs'],
|
||||
media: ['metadataDiff.summary.mediaNoDiffs', 'metadataDiff.summary.mediaWithDiffs'],
|
||||
scripts: ['metadataDiff.summary.scriptNoDiffs', 'metadataDiff.summary.scriptWithDiffs'],
|
||||
templates: ['metadataDiff.summary.templateNoDiffs', 'metadataDiff.summary.templateWithDiffs'],
|
||||
};
|
||||
return hasDiffs ? map[tab][1] : map[tab][0];
|
||||
};
|
||||
|
||||
const tabBadge = (tab: EntityTab): number => {
|
||||
const result = scanResults[tab];
|
||||
if (!result) return 0;
|
||||
return result.itemsWithDifferences + result.orphanFiles.length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="metadata-diff-panel">
|
||||
<h2>{tr('metadataDiff.title')}</h2>
|
||||
@@ -203,6 +325,14 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
<span className="stat-label">{tr('metadataDiff.stats.mediaFiles')}</span>
|
||||
<span className="stat-value">{stats.totalMedia}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">{tr('metadataDiff.stats.scripts')}</span>
|
||||
<span className="stat-value">{stats.totalScripts}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">{tr('metadataDiff.stats.templates')}</span>
|
||||
<span className="stat-value">{stats.totalTemplates}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -211,10 +341,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
<div className="diff-progress">
|
||||
<h3>{tr('metadataDiff.progress.scanningPublished')}</h3>
|
||||
<div className="progress-bar-container">
|
||||
<div
|
||||
className="progress-bar"
|
||||
style={{ width: `${progress.current}%` }}
|
||||
/>
|
||||
<div className="progress-bar" style={{ width: `${progress.current}%` }} />
|
||||
</div>
|
||||
<div className="progress-text">{progress.message}</div>
|
||||
</div>
|
||||
@@ -232,7 +359,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
<span className="spinner" style={{ width: 14, height: 14 }} />
|
||||
{tr('metadataDiff.progress.scanning')}
|
||||
</>
|
||||
) : scanResult ? (
|
||||
) : currentResult ? (
|
||||
`🔄 ${tr('metadataDiff.action.rescan')}`
|
||||
) : (
|
||||
`🔍 ${tr('metadataDiff.action.scan')}`
|
||||
@@ -241,81 +368,162 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Results Section */}
|
||||
{scanPhase === 'complete' && scanResult && (
|
||||
<div className="diff-results">
|
||||
<div className={`diff-summary ${scanResult.postsWithDifferences > 0 ? 'has-differences' : 'no-differences'}`}>
|
||||
{scanResult.postsWithDifferences === 0 ? (
|
||||
<>{tr('metadataDiff.summary.noDiffs', { total: scanResult.totalScanned })}</>
|
||||
) : (
|
||||
<>
|
||||
{tr('metadataDiff.summary.withDiffs', { count: scanResult.postsWithDifferences, total: scanResult.totalScanned })}
|
||||
</>
|
||||
)}
|
||||
{scanPhase === 'complete' && (
|
||||
<>
|
||||
{/* Entity Tabs */}
|
||||
<div className="diff-tabs">
|
||||
{(['posts', 'media', 'scripts', 'templates'] as EntityTab[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`diff-tab ${activeTab === tab ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab)}
|
||||
>
|
||||
{tr(`metadataDiff.tab.${tab}`)}
|
||||
{tabBadge(tab) > 0 && <span className="tab-badge">{tabBadge(tab)}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
{scanResult.groups.map(group => (
|
||||
<div key={group.field} className="diff-group">
|
||||
<div
|
||||
className="diff-group-header"
|
||||
onClick={() => toggleGroup(group.field)}
|
||||
>
|
||||
<div className="diff-group-title">
|
||||
<span className={`chevron ${expandedGroups.has(group.field) ? 'expanded' : ''}`}>
|
||||
▶
|
||||
</span>
|
||||
{tr('metadataDiff.group.differences', { label: group.label })}
|
||||
</div>
|
||||
<div className="diff-group-count">
|
||||
<span className="badge">{tr('metadataDiff.group.postsCount', { count: group.posts.length })}</span>
|
||||
<div className="diff-group-actions" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="db-to-file"
|
||||
onClick={() => handleSyncDbToFile(group)}
|
||||
disabled={syncingGroups.has(group.field)}
|
||||
title={tr('metadataDiff.sync.dbToFile.title')}
|
||||
>
|
||||
DB → File
|
||||
</button>
|
||||
<button
|
||||
className="file-to-db"
|
||||
onClick={() => handleSyncFileToDb(group)}
|
||||
disabled={syncingGroups.has(group.field)}
|
||||
title={tr('metadataDiff.sync.fileToDb.title')}
|
||||
>
|
||||
File → DB
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{currentResult && (
|
||||
<div className="diff-results">
|
||||
<div className={`diff-summary ${currentResult.itemsWithDifferences > 0 ? 'has-differences' : 'no-differences'}`}>
|
||||
{tr(summaryKey(activeTab, currentResult.itemsWithDifferences > 0), {
|
||||
total: currentResult.totalScanned,
|
||||
count: currentResult.itemsWithDifferences,
|
||||
})}
|
||||
</div>
|
||||
<div className={`diff-group-content ${!expandedGroups.has(group.field) ? 'collapsed' : ''}`}>
|
||||
{group.posts.map(post => (
|
||||
<div key={post.postId} className="diff-post-item">
|
||||
<div className="diff-post-title" title={post.title}>
|
||||
{post.title || post.slug}
|
||||
|
||||
{/* Field summaries — clickable pills that filter by field */}
|
||||
{currentResult.fieldSummaries.length > 0 && (
|
||||
<div className="diff-field-summaries">
|
||||
{currentResult.fieldSummaries.map(fs => (
|
||||
<div
|
||||
key={fs.field}
|
||||
className={`field-pill ${activeFieldFilter === fs.field ? 'active' : ''}`}
|
||||
onClick={() => toggleFieldFilter(fs.field)}
|
||||
title={tr('metadataDiff.fieldFilter.toggle', { field: fs.label })}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') toggleFieldFilter(fs.field); }}
|
||||
>
|
||||
<span className="field-pill-label">{fs.label}</span>
|
||||
<span className="field-pill-count">{fs.count}</span>
|
||||
{/* Sync actions on field pills */}
|
||||
<span className="field-pill-actions" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="pill-sync db-to-file"
|
||||
onClick={() => handleSyncDbToFile(fs.field, fs.label)}
|
||||
disabled={syncingFields.has(fs.field)}
|
||||
title={tr('metadataDiff.sync.dbToFile.title')}
|
||||
>
|
||||
{tr('metadataDiff.sync.dbToFile.short')}
|
||||
</button>
|
||||
<button
|
||||
className="pill-sync file-to-db"
|
||||
onClick={() => handleSyncFileToDb(fs.field, fs.label)}
|
||||
disabled={syncingFields.has(fs.field)}
|
||||
title={tr('metadataDiff.sync.fileToDb.title')}
|
||||
>
|
||||
{tr('metadataDiff.sync.fileToDb.short')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="diff-value-label">{tr('metadataDiff.value.database')}</div>
|
||||
<div className="diff-value db-value" title={formatValue(post.dbValue)}>
|
||||
{formatValue(post.dbValue)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="diff-value-label">{tr('metadataDiff.value.file')}</div>
|
||||
<div className="diff-value file-value" title={formatValue(post.fileValue)}>
|
||||
{formatValue(post.fileValue)}
|
||||
))}
|
||||
{activeFieldFilter && (
|
||||
<button className="field-pill clear-filter" onClick={() => setActiveFieldFilter(null)}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Item list — each item shows all its field diffs */}
|
||||
{filteredItems.length > 0 && (
|
||||
<div className="diff-item-list">
|
||||
{filteredItems.map(item => (
|
||||
<div key={item.id} className={`diff-item-card ${item.fileMissing ? 'file-missing' : ''}`}>
|
||||
<div className="diff-item-header">
|
||||
{item.label}
|
||||
{item.fileMissing && <span className="file-missing-badge">{tr('metadataDiff.fileMissing')}</span>}
|
||||
</div>
|
||||
<div className="diff-item-fields">
|
||||
{Object.entries(item.fields).map(([field, diff]) => (
|
||||
<div key={field} className="diff-field-row">
|
||||
<div className="diff-field-name">{field}</div>
|
||||
<div className="diff-field-values">
|
||||
<div className="diff-field-value db-value" title={formatValue(diff.dbValue)}>
|
||||
<span className="diff-source-label">{tr('metadataDiff.value.database')}</span>
|
||||
{formatValue(diff.dbValue)}
|
||||
</div>
|
||||
<div className={`diff-field-value file-value ${item.fileMissing && diff.fileValue === null ? 'missing' : ''}`} title={item.fileMissing && diff.fileValue === null ? tr('metadataDiff.value.fileMissing') : formatValue(diff.fileValue)}>
|
||||
<span className="diff-source-label">{tr('metadataDiff.value.file')}</span>
|
||||
{item.fileMissing && diff.fileValue === null ? tr('metadataDiff.value.fileMissing') : formatValue(diff.fileValue)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Orphan files — files on disk with no DB entry */}
|
||||
{currentResult.orphanFiles.length > 0 && !activeFieldFilter && (
|
||||
<div className="orphan-files-section">
|
||||
<div className="orphan-files-header">
|
||||
<h3>{tr('metadataDiff.orphanFiles.title', { count: currentResult.orphanFiles.length })}</h3>
|
||||
<button
|
||||
className="pill-sync file-to-db"
|
||||
onClick={handleImportOrphanFiles}
|
||||
disabled={importingOrphans}
|
||||
title={tr('metadataDiff.orphanFiles.importTitle')}
|
||||
>
|
||||
{importingOrphans ? tr('metadataDiff.orphanFiles.importing') : tr('metadataDiff.orphanFiles.importButton')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="orphan-files-description">{tr('metadataDiff.orphanFiles.description')}</p>
|
||||
<div className="diff-item-list">
|
||||
{currentResult.orphanFiles.map(orphan => (
|
||||
<div key={orphan.filePath} className="diff-item-card orphan-file">
|
||||
<div className="diff-item-header">
|
||||
{orphan.title || orphan.slug}
|
||||
<span className="orphan-file-badge">{tr('metadataDiff.orphanFiles.badge')}</span>
|
||||
</div>
|
||||
<div className="diff-item-fields">
|
||||
<div className="diff-field-row">
|
||||
<div className="diff-field-name">{tr('metadataDiff.orphanFiles.slug')}</div>
|
||||
<div className="diff-field-values">
|
||||
<div className="diff-field-value file-value">{orphan.slug}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="diff-field-row">
|
||||
<div className="diff-field-name">{tr('metadataDiff.orphanFiles.path')}</div>
|
||||
<div className="diff-field-values">
|
||||
<div className="diff-field-value file-value orphan-path" title={orphan.filePath}>{orphan.filePath}</div>
|
||||
</div>
|
||||
</div>
|
||||
{orphan.id && (
|
||||
<div className="diff-field-row">
|
||||
<div className="diff-field-name">ID</div>
|
||||
<div className="diff-field-values">
|
||||
<div className="diff-field-value file-value">{orphan.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{scanPhase === 'idle' && !scanResult && (
|
||||
{scanPhase === 'idle' && !currentResult && (
|
||||
<div className="diff-empty">
|
||||
<div className="icon">📊</div>
|
||||
<div>{tr('metadataDiff.empty')}</div>
|
||||
|
||||
@@ -374,7 +374,7 @@
|
||||
"tabBar.error.fetchTemplateTitle": "Vorlagen-Titel konnte nicht geladen werden:",
|
||||
"tabBar.error.fetchCommitTitle": "Commit-Titel konnten nicht geladen werden:",
|
||||
"metadataDiff.title": "Metadaten-Diff-Werkzeug",
|
||||
"metadataDiff.description": "Vergleicht Beitragsmetadaten zwischen Datenbank und Markdown-Dateien. Behebt Abweichungen durch Bugs oder manuelle Änderungen.",
|
||||
"metadataDiff.description": "Vergleicht Metadaten zwischen Datenbank und Dateien für Beiträge, Medien, Skripte und Vorlagen. Behebt Abweichungen durch Bugs oder manuelle Änderungen.",
|
||||
"metadataDiff.error.loadStats": "Datenbankstatistiken konnten nicht geladen werden",
|
||||
"metadataDiff.error.scan": "Unterschiede konnten nicht gescannt werden",
|
||||
"metadataDiff.progress.starting": "Scan wird gestartet...",
|
||||
@@ -386,19 +386,47 @@
|
||||
"metadataDiff.stats.published": "Veröffentlicht",
|
||||
"metadataDiff.stats.drafts": "Entwürfe",
|
||||
"metadataDiff.stats.mediaFiles": "Mediendateien",
|
||||
"metadataDiff.stats.scripts": "Skripte",
|
||||
"metadataDiff.stats.templates": "Vorlagen",
|
||||
"metadataDiff.summary.noDiffs": "✅ Keine Unterschiede gefunden! Alle {total} veröffentlichten Beiträge sind synchron.",
|
||||
"metadataDiff.summary.withDiffs": "⚠️ {count} Beiträge mit Unterschieden gefunden, von insgesamt {total} veröffentlichten Beiträgen.",
|
||||
"metadataDiff.summary.mediaNoDiffs": "✅ Keine Unterschiede gefunden! Alle {total} Mediendateien sind synchron.",
|
||||
"metadataDiff.summary.mediaWithDiffs": "⚠️ {count} Mediendateien mit Unterschieden gefunden, von insgesamt {total}.",
|
||||
"metadataDiff.summary.scriptNoDiffs": "✅ Keine Unterschiede gefunden! Alle {total} Skripte sind synchron.",
|
||||
"metadataDiff.summary.scriptWithDiffs": "⚠️ {count} Skripte mit Unterschieden gefunden, von insgesamt {total}.",
|
||||
"metadataDiff.summary.templateNoDiffs": "✅ Keine Unterschiede gefunden! Alle {total} Vorlagen sind synchron.",
|
||||
"metadataDiff.summary.templateWithDiffs": "⚠️ {count} Vorlagen mit Unterschieden gefunden, von insgesamt {total}.",
|
||||
"metadataDiff.group.differences": "{label}-Unterschiede",
|
||||
"metadataDiff.group.postsCount": "{count} Beiträge",
|
||||
"metadataDiff.group.itemsCount": "{count} Elemente",
|
||||
"metadataDiff.fieldFilter.toggle": "Nach {field} filtern",
|
||||
"metadataDiff.tab.posts": "Beiträge",
|
||||
"metadataDiff.tab.media": "Medien",
|
||||
"metadataDiff.tab.scripts": "Skripte",
|
||||
"metadataDiff.tab.templates": "Vorlagen",
|
||||
"metadataDiff.orphanFiles.title": "Verwaiste Dateien ({count})",
|
||||
"metadataDiff.orphanFiles.description": "Diese Dateien existieren auf der Festplatte, haben aber keinen passenden Datenbankeintrag. Sie könnten von Slug-Änderungen oder manuellen Bearbeitungen übrig sein.",
|
||||
"metadataDiff.orphanFiles.badge": "Verwaiste Datei",
|
||||
"metadataDiff.orphanFiles.slug": "Slug",
|
||||
"metadataDiff.orphanFiles.path": "Pfad",
|
||||
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
|
||||
"metadataDiff.orphanFiles.importTitle": "Alle verwaisten Dateien in die Datenbank importieren",
|
||||
"metadataDiff.orphanFiles.importing": "Importiere…",
|
||||
"metadataDiff.orphanFiles.importSuccess": "{success} verwaiste Dateien importiert{failed}",
|
||||
"metadataDiff.orphanFiles.importError": "Import der verwaisten Dateien fehlgeschlagen",
|
||||
"metadataDiff.sync.failed": "fehlgeschlagen",
|
||||
"metadataDiff.sync.dbToFile.title": "Dateien mit Datenbankwerten aktualisieren",
|
||||
"metadataDiff.sync.dbToFile.short": "DB\u2192D",
|
||||
"metadataDiff.sync.dbToFile.success": "{success} Beiträge in Dateien synchronisiert{fehlgeschlagen}",
|
||||
"metadataDiff.sync.dbToFile.error": "Synchronisierung in Dateien fehlgeschlagen",
|
||||
"metadataDiff.sync.fileToDb.title": "Datenbank mit Dateiwerten aktualisieren",
|
||||
"metadataDiff.sync.fileToDb.short": "D\u2192DB",
|
||||
"metadataDiff.sync.fileToDb.success": "{success} Dateien in die Datenbank synchronisiert{fehlgeschlagen}",
|
||||
"metadataDiff.sync.fileToDb.error": "Synchronisierung in die Datenbank fehlgeschlagen",
|
||||
"metadataDiff.value.database": "Datenbank",
|
||||
"metadataDiff.value.file": "Datei",
|
||||
"metadataDiff.fileMissing": "Datei fehlt",
|
||||
"metadataDiff.value.fileMissing": "(fehlt)",
|
||||
"metadataDiff.empty": "Klicke auf „Nach Unterschieden suchen“, um Datenbank-Metadaten mit Datei-Metadaten zu vergleichen.",
|
||||
"sidebar.archive": "Archiv",
|
||||
"sidebar.clearFilter": "Filter löschen",
|
||||
|
||||
@@ -374,7 +374,7 @@
|
||||
"tabBar.error.fetchTemplateTitle": "Failed to fetch template title:",
|
||||
"tabBar.error.fetchCommitTitle": "Failed to fetch commit titles:",
|
||||
"metadataDiff.title": "Metadata Diff Tool",
|
||||
"metadataDiff.description": "Compare post metadata between database and markdown files. Fix inconsistencies caused by bugs or manual edits.",
|
||||
"metadataDiff.description": "Compare metadata between database and files for posts, media, scripts, and templates. Fix inconsistencies caused by bugs or manual edits.",
|
||||
"metadataDiff.error.loadStats": "Failed to load database statistics",
|
||||
"metadataDiff.error.scan": "Failed to scan for differences",
|
||||
"metadataDiff.progress.starting": "Starting scan...",
|
||||
@@ -386,20 +386,48 @@
|
||||
"metadataDiff.stats.published": "Published",
|
||||
"metadataDiff.stats.drafts": "Drafts",
|
||||
"metadataDiff.stats.mediaFiles": "Media Files",
|
||||
"metadataDiff.stats.scripts": "Scripts",
|
||||
"metadataDiff.stats.templates": "Templates",
|
||||
"metadataDiff.summary.noDiffs": "✅ No differences found! All {total} published posts are in sync.",
|
||||
"metadataDiff.summary.withDiffs": "⚠️ Found {count} posts with differences out of {total} published posts.",
|
||||
"metadataDiff.summary.mediaNoDiffs": "✅ No differences found! All {total} media items are in sync.",
|
||||
"metadataDiff.summary.mediaWithDiffs": "⚠️ Found {count} media items with differences out of {total}.",
|
||||
"metadataDiff.summary.scriptNoDiffs": "✅ No differences found! All {total} scripts are in sync.",
|
||||
"metadataDiff.summary.scriptWithDiffs": "⚠️ Found {count} scripts with differences out of {total}.",
|
||||
"metadataDiff.summary.templateNoDiffs": "✅ No differences found! All {total} templates are in sync.",
|
||||
"metadataDiff.summary.templateWithDiffs": "⚠️ Found {count} templates with differences out of {total}.",
|
||||
"metadataDiff.group.differences": "{label} Differences",
|
||||
"metadataDiff.group.postsCount": "{count} posts",
|
||||
"metadataDiff.group.itemsCount": "{count} items",
|
||||
"metadataDiff.fieldFilter.toggle": "Filter by {field}",
|
||||
"metadataDiff.sync.failed": "failed",
|
||||
"metadataDiff.sync.dbToFile.title": "Update files with database values",
|
||||
"metadataDiff.sync.dbToFile.short": "DB\u2192F",
|
||||
"metadataDiff.sync.dbToFile.success": "Synced {success} posts to files{failed}",
|
||||
"metadataDiff.sync.dbToFile.error": "Failed to sync to files",
|
||||
"metadataDiff.sync.fileToDb.title": "Update database with file values",
|
||||
"metadataDiff.sync.fileToDb.short": "F\u2192DB",
|
||||
"metadataDiff.sync.fileToDb.success": "Synced {success} files to database{failed}",
|
||||
"metadataDiff.sync.fileToDb.error": "Failed to sync to database",
|
||||
"metadataDiff.value.database": "Database",
|
||||
"metadataDiff.value.file": "File",
|
||||
"metadataDiff.fileMissing": "File missing",
|
||||
"metadataDiff.value.fileMissing": "(missing)",
|
||||
"metadataDiff.empty": "Click \"Scan for Differences\" to compare database metadata with file metadata.",
|
||||
"metadataDiff.tab.posts": "Posts",
|
||||
"metadataDiff.tab.media": "Media",
|
||||
"metadataDiff.tab.scripts": "Scripts",
|
||||
"metadataDiff.tab.templates": "Templates",
|
||||
"metadataDiff.orphanFiles.title": "Orphan Files ({count})",
|
||||
"metadataDiff.orphanFiles.description": "These files exist on disk but have no matching database entry. They may be leftovers from slug changes or manual edits.",
|
||||
"metadataDiff.orphanFiles.badge": "Orphan file",
|
||||
"metadataDiff.orphanFiles.slug": "Slug",
|
||||
"metadataDiff.orphanFiles.path": "Path",
|
||||
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
|
||||
"metadataDiff.orphanFiles.importTitle": "Import all orphan files into the database",
|
||||
"metadataDiff.orphanFiles.importing": "Importing…",
|
||||
"metadataDiff.orphanFiles.importSuccess": "{success} orphan files imported{failed}",
|
||||
"metadataDiff.orphanFiles.importError": "Orphan file import failed",
|
||||
"sidebar.archive": "Archive",
|
||||
"sidebar.clearFilter": "Clear filter",
|
||||
"sidebar.tags": "Tags",
|
||||
|
||||
@@ -374,7 +374,7 @@
|
||||
"tabBar.error.fetchTemplateTitle": "No se pudo cargar el título de la plantilla:",
|
||||
"tabBar.error.fetchCommitTitle": "No se pudieron cargar los títulos de los commits:",
|
||||
"metadataDiff.title": "Herramienta diff de metadatos",
|
||||
"metadataDiff.description": "Compara los metadatos de las entradas entre la base de datos y los archivos Markdown. Corrige inconsistencias causadas por errores o ediciones manuales.",
|
||||
"metadataDiff.description": "Compara los metadatos entre la base de datos y los archivos para entradas, multimedia, scripts y plantillas. Corrige inconsistencias causadas por errores o ediciones manuales.",
|
||||
"metadataDiff.error.loadStats": "No se pudieron cargar las estadísticas de la base de datos",
|
||||
"metadataDiff.error.scan": "No se pudieron analizar las diferencias",
|
||||
"metadataDiff.progress.starting": "Iniciando escaneo...",
|
||||
@@ -386,20 +386,48 @@
|
||||
"metadataDiff.stats.published": "Publicadas",
|
||||
"metadataDiff.stats.drafts": "Borradores",
|
||||
"metadataDiff.stats.mediaFiles": "Archivos multimedia",
|
||||
"metadataDiff.stats.scripts": "Scripts",
|
||||
"metadataDiff.stats.templates": "Plantillas",
|
||||
"metadataDiff.summary.noDiffs": "✅ ¡No se encontraron diferencias! Todas las {total} entradas publicadas están sincronizadas.",
|
||||
"metadataDiff.summary.withDiffs": "⚠️ Se encontraron {count} entradas con diferencias de un total de {total} entradas publicadas.",
|
||||
"metadataDiff.summary.mediaNoDiffs": "✅ ¡No se encontraron diferencias! Los {total} archivos multimedia están sincronizados.",
|
||||
"metadataDiff.summary.mediaWithDiffs": "⚠️ Se encontraron {count} archivos multimedia con diferencias de un total de {total}.",
|
||||
"metadataDiff.summary.scriptNoDiffs": "✅ ¡No se encontraron diferencias! Los {total} scripts están sincronizados.",
|
||||
"metadataDiff.summary.scriptWithDiffs": "⚠️ Se encontraron {count} scripts con diferencias de un total de {total}.",
|
||||
"metadataDiff.summary.templateNoDiffs": "✅ ¡No se encontraron diferencias! Las {total} plantillas están sincronizadas.",
|
||||
"metadataDiff.summary.templateWithDiffs": "⚠️ Se encontraron {count} plantillas con diferencias de un total de {total}.",
|
||||
"metadataDiff.group.differences": "Diferencias de {label}",
|
||||
"metadataDiff.group.postsCount": "{count} entradas",
|
||||
"metadataDiff.group.itemsCount": "{count} elementos",
|
||||
"metadataDiff.fieldFilter.toggle": "Filtrar por {field}",
|
||||
"metadataDiff.sync.failed": "falló",
|
||||
"metadataDiff.sync.dbToFile.title": "Actualizar archivos con valores de la base de datos",
|
||||
"metadataDiff.sync.dbToFile.short": "BD\u2192A",
|
||||
"metadataDiff.sync.dbToFile.success": "Se sincronizaron {success} entradas a archivos{falló}",
|
||||
"metadataDiff.sync.dbToFile.error": "No se pudo sincronizar a archivos",
|
||||
"metadataDiff.sync.fileToDb.title": "Actualizar base de datos con valores de archivos",
|
||||
"metadataDiff.sync.fileToDb.short": "A→BD",
|
||||
"metadataDiff.sync.fileToDb.success": "Se sincronizaron {success} archivos a la base de datos{falló}",
|
||||
"metadataDiff.sync.fileToDb.error": "No se pudo sincronizar a la base de datos",
|
||||
"metadataDiff.value.database": "Base de datos",
|
||||
"metadataDiff.value.file": "Archivo",
|
||||
"metadataDiff.fileMissing": "Archivo faltante",
|
||||
"metadataDiff.value.fileMissing": "(faltante)",
|
||||
"metadataDiff.empty": "Haz clic en \"Buscar diferencias\" para comparar metadatos de base de datos con metadatos de archivos.",
|
||||
"metadataDiff.tab.posts": "Entradas",
|
||||
"metadataDiff.tab.media": "Multimedia",
|
||||
"metadataDiff.tab.scripts": "Scripts",
|
||||
"metadataDiff.tab.templates": "Plantillas",
|
||||
"metadataDiff.orphanFiles.title": "Archivos huérfanos ({count})",
|
||||
"metadataDiff.orphanFiles.description": "Estos archivos existen en el disco pero no tienen una entrada correspondiente en la base de datos. Pueden ser restos de cambios de slug o ediciones manuales.",
|
||||
"metadataDiff.orphanFiles.badge": "Archivo huérfano",
|
||||
"metadataDiff.orphanFiles.slug": "Slug",
|
||||
"metadataDiff.orphanFiles.path": "Ruta",
|
||||
"metadataDiff.orphanFiles.importButton": "D \u2192 BD",
|
||||
"metadataDiff.orphanFiles.importTitle": "Importar todos los archivos huérfanos a la base de datos",
|
||||
"metadataDiff.orphanFiles.importing": "Importando…",
|
||||
"metadataDiff.orphanFiles.importSuccess": "{success} archivos huérfanos importados{failed}",
|
||||
"metadataDiff.orphanFiles.importError": "Error al importar archivos huérfanos",
|
||||
"sidebar.archive": "Archivo",
|
||||
"sidebar.clearFilter": "Limpiar filtro",
|
||||
"sidebar.tags": "Etiquetas",
|
||||
|
||||
@@ -374,7 +374,7 @@
|
||||
"tabBar.error.fetchTemplateTitle": "Impossible de charger le titre du modèle :",
|
||||
"tabBar.error.fetchCommitTitle": "Impossible de charger les titres des commits :",
|
||||
"metadataDiff.title": "Outil de diff des métadonnées",
|
||||
"metadataDiff.description": "Compare les métadonnées des articles entre la base de données et les fichiers Markdown. Corrige les incohérences causées par des bugs ou des modifications manuelles.",
|
||||
"metadataDiff.description": "Compare les métadonnées entre la base de données et les fichiers pour les articles, médias, scripts et modèles. Corrige les incohérences causées par des bugs ou des modifications manuelles.",
|
||||
"metadataDiff.error.loadStats": "Impossible de charger les statistiques de la base de données",
|
||||
"metadataDiff.error.scan": "Impossible d’analyser les différences",
|
||||
"metadataDiff.progress.starting": "Démarrage de l’analyse...",
|
||||
@@ -386,19 +386,47 @@
|
||||
"metadataDiff.stats.published": "Publiés",
|
||||
"metadataDiff.stats.drafts": "Brouillons",
|
||||
"metadataDiff.stats.mediaFiles": "Fichiers média",
|
||||
"metadataDiff.stats.scripts": "Scripts",
|
||||
"metadataDiff.stats.templates": "Modèles",
|
||||
"metadataDiff.summary.noDiffs": "✅ Aucune différence trouvée ! Les {total} articles publiés sont synchronisés.",
|
||||
"metadataDiff.summary.withDiffs": "⚠️ {count} articles présentent des différences sur {total} articles publiés.",
|
||||
"metadataDiff.summary.mediaNoDiffs": "✅ Aucune différence trouvée ! Les {total} fichiers média sont synchronisés.",
|
||||
"metadataDiff.summary.mediaWithDiffs": "⚠️ {count} fichiers média présentent des différences sur {total}.",
|
||||
"metadataDiff.summary.scriptNoDiffs": "✅ Aucune différence trouvée ! Les {total} scripts sont synchronisés.",
|
||||
"metadataDiff.summary.scriptWithDiffs": "⚠️ {count} scripts présentent des différences sur {total}.",
|
||||
"metadataDiff.summary.templateNoDiffs": "✅ Aucune différence trouvée ! Les {total} modèles sont synchronisés.",
|
||||
"metadataDiff.summary.templateWithDiffs": "⚠️ {count} modèles présentent des différences sur {total}.",
|
||||
"metadataDiff.group.differences": "Différences de {label}",
|
||||
"metadataDiff.group.postsCount": "{count} articles",
|
||||
"metadataDiff.group.itemsCount": "{count} éléments",
|
||||
"metadataDiff.fieldFilter.toggle": "Filtrer par {field}",
|
||||
"metadataDiff.tab.posts": "Articles",
|
||||
"metadataDiff.tab.media": "Médias",
|
||||
"metadataDiff.tab.scripts": "Scripts",
|
||||
"metadataDiff.tab.templates": "Modèles",
|
||||
"metadataDiff.orphanFiles.title": "Fichiers orphelins ({count})",
|
||||
"metadataDiff.orphanFiles.description": "Ces fichiers existent sur le disque mais n'ont pas d'entrée correspondante dans la base de données. Ils peuvent provenir de changements de slug ou de modifications manuelles.",
|
||||
"metadataDiff.orphanFiles.badge": "Fichier orphelin",
|
||||
"metadataDiff.orphanFiles.slug": "Slug",
|
||||
"metadataDiff.orphanFiles.path": "Chemin",
|
||||
"metadataDiff.orphanFiles.importButton": "D \u2192 BD",
|
||||
"metadataDiff.orphanFiles.importTitle": "Importer tous les fichiers orphelins dans la base de données",
|
||||
"metadataDiff.orphanFiles.importing": "Importation…",
|
||||
"metadataDiff.orphanFiles.importSuccess": "{success} fichiers orphelins importés{failed}",
|
||||
"metadataDiff.orphanFiles.importError": "Échec de l'importation des fichiers orphelins",
|
||||
"metadataDiff.sync.failed": "échoué",
|
||||
"metadataDiff.sync.dbToFile.title": "Mettre à jour les fichiers avec les valeurs de la base",
|
||||
"metadataDiff.sync.dbToFile.short": "BD→F",
|
||||
"metadataDiff.sync.dbToFile.success": "{success} articles synchronisés vers les fichiers{échoué}",
|
||||
"metadataDiff.sync.dbToFile.error": "Échec de la synchronisation vers les fichiers",
|
||||
"metadataDiff.sync.fileToDb.title": "Mettre à jour la base avec les valeurs des fichiers",
|
||||
"metadataDiff.sync.fileToDb.short": "F→BD",
|
||||
"metadataDiff.sync.fileToDb.success": "{success} fichiers synchronisés vers la base de données{échoué}",
|
||||
"metadataDiff.sync.fileToDb.error": "Échec de la synchronisation vers la base de données",
|
||||
"metadataDiff.value.database": "Base de données",
|
||||
"metadataDiff.value.file": "Fichier",
|
||||
"metadataDiff.fileMissing": "Fichier manquant",
|
||||
"metadataDiff.value.fileMissing": "(manquant)",
|
||||
"metadataDiff.empty": "Cliquez sur « Rechercher les différences » pour comparer les métadonnées de la base et celles des fichiers.",
|
||||
"sidebar.archive": "Archive",
|
||||
"sidebar.clearFilter": "Effacer le filtre",
|
||||
|
||||
@@ -374,7 +374,7 @@
|
||||
"tabBar.error.fetchTemplateTitle": "Impossibile caricare il titolo del modello:",
|
||||
"tabBar.error.fetchCommitTitle": "Impossibile caricare i titoli dei commit:",
|
||||
"metadataDiff.title": "Strumento diff metadati",
|
||||
"metadataDiff.description": "Confronta i metadati dei post tra database e file markdown. Correggi incongruenze causate da bug o modifiche manuali.",
|
||||
"metadataDiff.description": "Confronta i metadati tra database e file per post, media, script e modelli. Correggi incongruenze causate da bug o modifiche manuali.",
|
||||
"metadataDiff.error.loadStats": "Impossibile caricare le statistiche del database",
|
||||
"metadataDiff.error.scan": "Impossibile analizzare le differenze",
|
||||
"metadataDiff.progress.starting": "Avvio scansione...",
|
||||
@@ -386,19 +386,47 @@
|
||||
"metadataDiff.stats.published": "Pubblicati",
|
||||
"metadataDiff.stats.drafts": "Bozze",
|
||||
"metadataDiff.stats.mediaFiles": "File multimediali",
|
||||
"metadataDiff.stats.scripts": "Script",
|
||||
"metadataDiff.stats.templates": "Modelli",
|
||||
"metadataDiff.summary.noDiffs": "✅ Nessuna differenza trovata! Tutti i {total} post pubblicati sono sincronizzati.",
|
||||
"metadataDiff.summary.withDiffs": "⚠️ Trovati {count} post con differenze su {total} post pubblicati.",
|
||||
"metadataDiff.summary.mediaNoDiffs": "✅ Nessuna differenza trovata! Tutti i {total} file multimediali sono sincronizzati.",
|
||||
"metadataDiff.summary.mediaWithDiffs": "⚠️ Trovati {count} file multimediali con differenze su {total}.",
|
||||
"metadataDiff.summary.scriptNoDiffs": "✅ Nessuna differenza trovata! Tutti i {total} script sono sincronizzati.",
|
||||
"metadataDiff.summary.scriptWithDiffs": "⚠️ Trovati {count} script con differenze su {total}.",
|
||||
"metadataDiff.summary.templateNoDiffs": "✅ Nessuna differenza trovata! Tutti i {total} modelli sono sincronizzati.",
|
||||
"metadataDiff.summary.templateWithDiffs": "⚠️ Trovati {count} modelli con differenze su {total}.",
|
||||
"metadataDiff.group.differences": "Differenze {label}",
|
||||
"metadataDiff.group.postsCount": "{count} post",
|
||||
"metadataDiff.group.itemsCount": "{count} elementi",
|
||||
"metadataDiff.fieldFilter.toggle": "Filtra per {field}",
|
||||
"metadataDiff.tab.posts": "Post",
|
||||
"metadataDiff.tab.media": "Media",
|
||||
"metadataDiff.tab.scripts": "Script",
|
||||
"metadataDiff.tab.templates": "Modelli",
|
||||
"metadataDiff.orphanFiles.title": "File orfani ({count})",
|
||||
"metadataDiff.orphanFiles.description": "Questi file esistono sul disco ma non hanno una voce corrispondente nel database. Potrebbero essere residui di modifiche allo slug o modifiche manuali.",
|
||||
"metadataDiff.orphanFiles.badge": "File orfano",
|
||||
"metadataDiff.orphanFiles.slug": "Slug",
|
||||
"metadataDiff.orphanFiles.path": "Percorso",
|
||||
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
|
||||
"metadataDiff.orphanFiles.importTitle": "Importa tutti i file orfani nel database",
|
||||
"metadataDiff.orphanFiles.importing": "Importazione…",
|
||||
"metadataDiff.orphanFiles.importSuccess": "{success} file orfani importati{failed}",
|
||||
"metadataDiff.orphanFiles.importError": "Impossibile importare i file orfani",
|
||||
"metadataDiff.sync.failed": "fallito",
|
||||
"metadataDiff.sync.dbToFile.title": "Aggiorna i file con i valori del database",
|
||||
"metadataDiff.sync.dbToFile.short": "DB\u2192F",
|
||||
"metadataDiff.sync.dbToFile.success": "Sincronizzati {success} post nei file{fallito}",
|
||||
"metadataDiff.sync.dbToFile.error": "Impossibile sincronizzare nei file",
|
||||
"metadataDiff.sync.fileToDb.title": "Aggiorna il database con i valori dei file",
|
||||
"metadataDiff.sync.fileToDb.short": "F\u2192DB",
|
||||
"metadataDiff.sync.fileToDb.success": "Sincronizzati {success} file nel database{fallito}",
|
||||
"metadataDiff.sync.fileToDb.error": "Impossibile sincronizzare nel database",
|
||||
"metadataDiff.value.database": "Database locale",
|
||||
"metadataDiff.value.file": "File sorgente",
|
||||
"metadataDiff.fileMissing": "File mancante",
|
||||
"metadataDiff.value.fileMissing": "(mancante)",
|
||||
"metadataDiff.empty": "Fai clic su \"Scansiona differenze\" per confrontare i metadati del database con quelli dei file.",
|
||||
"sidebar.archive": "Archivio",
|
||||
"sidebar.clearFilter": "Cancella filtro",
|
||||
|
||||
Reference in New Issue
Block a user