Feature/post media translations (#42)

* chore: updated todo with translation ideas

* feat: first take at the implementation of translations

* fix: small addition for the translation feature

* feat: support language switching in the editor and preview

* feat: better handling of long bodies by not running them through a json envelope

* fix: unknown macros have better fallback

* feat: api for python to get translations

* fix: strip dumb prefix of content in translation

* feat: extend meta diff for translations

* feat: hook up translations to rebuild-from-disk

* feat: generation of the website prefers project language, falling back to canonical language

* fix: crashes during rendering

* feat: translation validation report

* fix: made the translation validation actually work

* chore: reorganization of menu

* fix: some topics cleanup

* chore: updated doc

* feat: translations for media

* feat: more aligned in UI/UX

* feat: edit translations possible

* chore: added full multi-language todo

* chore: updated todo for clarity

* feat: implementation of full multi-linguality

* fix: page creation creates pages

* fix: flags on every page

* fix: better prompt

* feat: made MCP server aware of language content

* feat: python tools for translations

* fix: better fill-in-translations

* fix: better prompt for translation. maybe.

* fix: losing posts from search due to translation process

* fix: translation validation handles in-db content and fill-in of missing translations fixed to flush

* fix: faster scanning for infilling of missing translations

* chore: updated agent instructions

* feat: calendar and tag cloud respect current language now

* fix: retries going up

* fix: got metadata-diff and rebuild into sync

* fix: extended meta-diff for timestamps

* fix: made website validation look at translated content, too

* fix: multi-lingual search

* chore: refactor Editor.tsx into two separate editors

* feat: do language detection when no explicit language given

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-09 14:43:18 +01:00
committed by GitHub
parent f1c9038803
commit b855d61524
116 changed files with 19954 additions and 2094 deletions

View File

@@ -4,6 +4,7 @@ import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import { loadTabsForProject, saveTabsForProject } from './utils';
import { openSingletonToolTab } from './navigation/tabPolicy';
import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
import { persistTranslationValidationReport } from './navigation/translationValidationPersistence';
import { persistDuplicatesResult } from './navigation/duplicatesPersistence';
import { executeActivityClick } from './navigation/activityExecution';
import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling';
@@ -416,7 +417,9 @@ const App: React.FC = () => {
window.electronAPI?.posts.rebuildFromFiles(),
window.electronAPI?.media.rebuildFromFiles(),
window.electronAPI?.scripts.rebuildFromFiles(),
window.electronAPI?.templates.rebuildFromFiles(),
]);
await window.electronAPI?.posts.rebuildLinks();
await window.electronAPI?.media.regenerateMissingThumbnails();
} catch (error) {
console.error('Database rebuild failed:', error);
@@ -508,6 +511,49 @@ const App: React.FC = () => {
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:validateTranslations', () => {
const validateAndOpen = async () => {
try {
const report = await window.electronAPI?.blog.validateTranslations();
const projectId = useAppStore.getState().activeProject?.id;
if (projectId && report) {
persistTranslationValidationReport(projectId, report);
window.dispatchEvent(new CustomEvent('bds:translation-validation-updated', {
detail: { projectId },
}));
}
openSingletonToolTab(openTab, 'translation-validation');
} catch (error) {
console.error('Translation validation failed:', error);
showToast.error(tr('translationValidation.error.validate'));
}
};
void validateAndOpen();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:fillMissingTranslations', () => {
const fillMissing = async () => {
try {
const result = await window.electronAPI?.blog.fillMissingTranslations();
if (result) {
if (!result.taskStarted) {
showToast.info(tr('blog.fillMissing.nothingToDo'));
} else {
showToast.success(tr('blog.fillMissing.started'));
}
}
} catch (error) {
console.error('Fill missing translations failed:', error);
showToast.error(tr('blog.fillMissing.error'));
}
};
void fillMissing();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:previewPost', async () => {
try {

View File

@@ -115,11 +115,16 @@
gap: 16px;
}
.metadata-toggle-header {
display: flex;
align-items: center;
gap: 8px;
}
.metadata-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 4px;
background: none;
border: none;
@@ -130,6 +135,7 @@
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.15s;
flex-shrink: 0;
}
.metadata-toggle:hover {
@@ -202,10 +208,12 @@
display: flex;
gap: 6px;
align-items: center;
flex-wrap: nowrap;
}
.editor-language-row select {
flex: 1;
min-width: 0;
}
.editor-language-row button.compact {
@@ -215,6 +223,53 @@
line-height: 1;
}
.editor-translations-flags {
display: flex;
gap: 4px;
align-items: center;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.editor-translation-flag {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: 24px;
height: 24px;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
font-size: 14px;
line-height: 1;
cursor: pointer;
flex: 0 0 auto;
}
.editor-translation-flag.status-published {
opacity: 1;
}
.editor-translation-flag.status-draft {
opacity: 0.82;
}
.editor-translation-flag.status-archived {
opacity: 0.45;
filter: grayscale(0.35);
}
.editor-translation-flag.active {
border-color: var(--vscode-testing-iconQueued, #cca700);
background: color-mix(in srgb, var(--vscode-testing-iconQueued, #cca700) 14%, transparent);
}
.editor-translation-flag:hover {
background: color-mix(in srgb, var(--vscode-list-hoverBackground) 75%, transparent);
}
.editor-body {
flex: 1;
display: flex;
@@ -1037,6 +1092,11 @@
overflow: hidden;
}
.quick-actions-divider {
height: 1px;
background: var(--vscode-dropdown-border, #454545);
}
.quick-action-item {
display: flex;
align-items: flex-start;
@@ -1081,3 +1141,115 @@
font-size: 11px;
opacity: 0.7;
}
.translation-modal-backdrop {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
z-index: 10000;
}
.translation-modal {
width: min(460px, calc(100vw - 32px));
background: var(--vscode-editor-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
}
.translation-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.translation-modal-header h2 {
margin: 0;
font-size: 16px;
}
.translation-modal-close {
background: none;
border: none;
color: var(--vscode-descriptionForeground);
cursor: pointer;
font-size: 20px;
line-height: 1;
padding: 4px 6px;
}
.translation-modal-close:hover {
color: var(--vscode-foreground);
}
.translation-modal-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
}
.translation-modal-copy {
margin: 0;
font-size: 13px;
color: var(--vscode-descriptionForeground);
}
.translation-modal-label {
font-size: 12px;
font-weight: 600;
}
.translation-modal-select {
width: 100%;
}
.translation-modal-status-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--vscode-editorWidget-background) 88%, transparent);
border: 1px solid var(--vscode-panel-border);
}
.translation-modal-flag {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 999px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
font-size: 16px;
}
.translation-modal-status-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.translation-modal-status-copy strong {
font-size: 13px;
}
.translation-modal-status-copy small {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.translation-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 18px 18px;
border-top: 1px solid var(--vscode-panel-border);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,863 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import { AISuggestionsModal } from '../AISuggestionsModal/AISuggestionsModal';
import { openEntityTab } from '../../navigation/tabPolicy';
import { useI18n } from '../../i18n';
import { SUPPORTED_POST_LANGUAGES, POST_LANGUAGE_FLAGS } from '../../../main/shared/i18n';
import { getMediaDisplayName } from './editorUtils';
export const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const { t: tr } = useI18n();
const { media, updateMedia, showErrorModal, showConfirmDeleteModal, openTab } = useAppStore();
const activeProjectId = useAppStore((s) => s.activeProject?.id ?? null);
const item = media.find(m => m.id === mediaId);
const [title, setTitle] = useState(item?.title || '');
const [alt, setAlt] = useState(item?.alt || '');
const [caption, setCaption] = useState(item?.caption || '');
const [author, setAuthor] = useState(item?.author || '');
const [tags, setTags] = useState(item?.tags.join(', ') || '');
const [linkedPosts, setLinkedPosts] = useState<{ postId: string; sortOrder: number }[]>([]);
const [postTitles, setPostTitles] = useState<Map<string, string>>(new Map());
const [showPostPicker, setShowPostPicker] = useState(false);
const [postSearchQuery, setPostSearchQuery] = useState('');
const [pickerPosts, setPickerPosts] = useState<{ id: string; title: string }[]>([]);
// Quick action menu state
const [showQuickActions, setShowQuickActions] = useState(false);
const [projectLanguage, setProjectLanguage] = useState('en');
const quickActionsRef = useRef<HTMLDivElement>(null);
// AI suggestions modal state
const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [aiSuggestionFields, setAISuggestionFields] = useState<Array<{ key: string; label: string; currentValue: string; suggestedValue?: string }>>([]);
const [aiError, setAIError] = useState<string | undefined>(undefined);
// Translation state
const [mediaLanguage, setMediaLanguage] = useState(item?.language || '');
const [mediaTranslations, setMediaTranslations] = useState<import('../../../main/shared/electronApi').MediaTranslationData[]>([]);
const [isTranslating, setIsTranslating] = useState(false);
const [isDetectingLanguage, setIsDetectingLanguage] = useState(false);
const [showMediaTranslationModal, setShowMediaTranslationModal] = useState(false);
const [translationTargetLanguage, setTranslationTargetLanguage] = useState('');
const [editingTranslation, setEditingTranslation] = useState<{ language: string; title: string; alt: string; caption: string } | null>(null);
// Load project language setting
useEffect(() => {
if (!activeProjectId) return;
window.electronAPI?.meta.getProjectMetadata().then(metadata => {
if (metadata?.mainLanguage) {
setProjectLanguage(metadata.mainLanguage);
}
});
}, [activeProjectId]);
// Load media translations
const loadMediaTranslations = useCallback(async () => {
if (!mediaId) return;
const result = await window.electronAPI?.media.getTranslations?.(mediaId);
setMediaTranslations(result || []);
}, [mediaId]);
useEffect(() => {
loadMediaTranslations();
}, [loadMediaTranslations]);
// Handle language change on canonical media
const handleLanguageChange = async (newLanguage: string) => {
setMediaLanguage(newLanguage);
try {
const updated = await window.electronAPI?.media.update(item!.id, { language: newLanguage || undefined });
if (updated) {
updateMedia(item!.id, updated as Partial<typeof item>);
}
} catch (error) {
console.error('Failed to update media language:', error);
}
};
// Detect media language from metadata
const handleDetectLanguage = async () => {
if (!item || isDetectingLanguage) return;
setIsDetectingLanguage(true);
try {
const result = await window.electronAPI?.chat.detectMediaLanguage(
title || item.title || '',
alt || item.alt || '',
caption || item.caption || '',
);
if (result?.success && result.language) {
setMediaLanguage(result.language);
const updated = await window.electronAPI?.media.update(item.id, { language: result.language });
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
}
showToast.success(tr('editor.media.toast.languageDetected', { language: tr(`language.${result.language}`) }));
} else {
showToast.error(result?.error || tr('editor.media.error.detectLanguage'));
}
} catch (error) {
console.error('Failed to detect media language:', error);
showToast.error(tr('editor.media.error.detectLanguage'));
} finally {
setIsDetectingLanguage(false);
}
};
// Translate media metadata with AI
const handleTranslateMedia = async (targetLanguage: string) => {
if (!item || isTranslating) return;
setIsTranslating(true);
try {
const result = await window.electronAPI?.chat.translateMediaMetadata(item.id, targetLanguage);
if (result?.success) {
await loadMediaTranslations();
showToast.success(tr('editor.media.translations.translateSuccess', { language: tr(`language.${targetLanguage}`) }));
} else {
showToast.error(result?.error || tr('editor.media.translations.translateFailed'));
}
} catch (error) {
console.error('Failed to translate media metadata:', error);
showToast.error(tr('editor.media.translations.translateFailed'));
} finally {
setIsTranslating(false);
}
};
// Open translation modal (like posts)
const handleOpenMediaTranslationModal = () => {
const preferred = translationTargetLanguage
|| availableTranslationLanguages[0]
|| '';
setShowQuickActions(false);
setTranslationTargetLanguage(preferred);
setShowMediaTranslationModal(true);
};
const handleCloseMediaTranslationModal = () => {
setShowMediaTranslationModal(false);
};
const handleConfirmMediaTranslation = () => {
if (!translationTargetLanguage) return;
setShowMediaTranslationModal(false);
void handleTranslateMedia(translationTargetLanguage);
};
// Open edit modal for an existing translation
const handleOpenEditTranslation = (translation: import('../../../main/shared/electronApi').MediaTranslationData) => {
setEditingTranslation({
language: translation.language,
title: translation.title || '',
alt: translation.alt || '',
caption: translation.caption || '',
});
};
// Save edits to a translation
const handleSaveEditTranslation = async () => {
if (!item || !editingTranslation) return;
try {
await window.electronAPI?.media.upsertTranslation(item.id, editingTranslation.language, {
title: editingTranslation.title || undefined,
alt: editingTranslation.alt || undefined,
caption: editingTranslation.caption || undefined,
});
await loadMediaTranslations();
setEditingTranslation(null);
showToast.success(tr('editor.media.translations.saved', { language: tr(`language.${editingTranslation.language}`) }));
} catch (error) {
console.error('Failed to save media translation:', error);
showToast.error(tr('editor.media.translations.saveFailed'));
}
};
// Delete a media translation
const handleDeleteTranslation = async (language: string) => {
if (!item) return;
try {
await window.electronAPI?.media.deleteTranslation?.(item.id, language);
await loadMediaTranslations();
showToast.success(tr('editor.media.translations.deleted', { language: tr(`language.${language}`) }));
} catch (error) {
console.error('Failed to delete media translation:', error);
showToast.error(tr('editor.media.translations.deleteFailed'));
}
};
// Available languages for translation (exclude canonical)
const availableTranslationLanguages = SUPPORTED_POST_LANGUAGES.filter(
lang => lang !== mediaLanguage && !mediaTranslations.find(t => t.language === lang)
);
// Close quick actions menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (quickActionsRef.current && !quickActionsRef.current.contains(event.target as Node)) {
setShowQuickActions(false);
}
};
if (showQuickActions) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showQuickActions]);
// Handle AI image analysis for alt text and caption
const handleAIAnalysis = async () => {
if (!item || isAnalyzing) return;
setShowQuickActions(false);
setShowAISuggestionsModal(true);
setIsAnalyzing(true);
setAISuggestionFields([]);
setAIError(undefined);
try {
const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage);
if (result?.success) {
setAISuggestionFields([
{ key: 'title', label: tr('aiSuggestions.titleField'), currentValue: title, suggestedValue: result.title },
{ key: 'alt', label: tr('aiSuggestions.altField'), currentValue: alt, suggestedValue: result.alt },
{ key: 'caption', label: tr('aiSuggestions.captionField'), currentValue: caption, suggestedValue: result.caption },
]);
} else {
setAIError(result?.error || tr('editor.media.error.analyzeImage'));
}
} catch (error) {
console.error('Failed to analyze image:', error);
setAIError((error as Error).message || tr('editor.media.error.analyzeImage'));
} finally {
setIsAnalyzing(false);
}
};
// Handle applying AI suggestions
const handleApplyAISuggestions = (values: Record<string, string>) => {
if (values.title) setTitle(values.title);
if (values.alt) setAlt(values.alt);
if (values.caption) setCaption(values.caption);
setShowAISuggestionsModal(false);
if (Object.keys(values).length > 0) {
showToast.success(tr('editor.media.toast.aiApplied'));
}
};
// Close AI suggestions modal
const handleCloseAISuggestionsModal = () => {
setShowAISuggestionsModal(false);
setAISuggestions(null);
setAIError(undefined);
};
// Load linked posts for this media and fetch their titles
useEffect(() => {
const loadLinkedPosts = async () => {
if (!mediaId || !activeProjectId) return;
try {
const links = await window.electronAPI?.postMedia.getForMedia(mediaId);
if (links) {
setLinkedPosts(links.map(l => ({ postId: l.postId, sortOrder: l.sortOrder })));
// Fetch titles for linked posts
const titles = new Map<string, string>();
for (const link of links) {
const post = await window.electronAPI?.posts.get(link.postId);
if (post) {
titles.set(link.postId, post.title || tr('editor.untitled'));
}
}
setPostTitles(titles);
}
} catch (error) {
console.error('Failed to load linked posts:', error);
}
};
loadLinkedPosts();
}, [mediaId, activeProjectId]);
// Fetch posts for the picker when it opens
useEffect(() => {
if (!showPostPicker) return;
const loadPickerPosts = async () => {
try {
const result = await window.electronAPI?.posts.getAll({ limit: 100, offset: 0 });
if (result?.items) {
setPickerPosts(result.items.map(p => ({ id: p.id, title: p.title || tr('editor.untitled') })));
}
} catch (error) {
console.error('Failed to load posts for picker:', error);
}
};
loadPickerPosts();
}, [showPostPicker]);
// Get post titles for display
const getPostTitle = (postId: string): string => {
return postTitles.get(postId) || tr('sidebar.loading');
};
// Handle linking to a new post
const handleLinkToPost = async (postId: string, postTitle: string) => {
try {
await window.electronAPI?.postMedia.link(postId, mediaId);
setLinkedPosts([...linkedPosts, { postId, sortOrder: linkedPosts.length }]);
setPostTitles(prev => new Map(prev).set(postId, postTitle));
setShowPostPicker(false);
setPostSearchQuery('');
showToast.success(tr('editor.media.toast.linkedToPost'));
} catch (error) {
console.error('Failed to link to post:', error);
showToast.error(tr('editor.media.toast.linkFailed'));
}
};
// Handle unlinking from a post
const handleUnlinkFromPost = async (postId: string) => {
try {
await window.electronAPI?.postMedia.unlink(postId, mediaId);
setLinkedPosts(linkedPosts.filter(l => l.postId !== postId));
showToast.success(tr('editor.media.toast.unlinkedFromPost'));
} catch (error) {
console.error('Failed to unlink from post:', error);
showToast.error(tr('editor.media.toast.unlinkFailed'));
}
};
// Handle click on a post to navigate to it
const handlePostClick = (postId: string) => {
openEntityTab(openTab, 'post', postId, 'preview');
};
// Get unlinked posts for picker, filtered by search
const unlinkedPosts = pickerPosts.filter(
p => !linkedPosts.find(l => l.postId === p.id)
).filter(
p => !postSearchQuery || p.title.toLowerCase().includes(postSearchQuery.toLowerCase())
);
useEffect(() => {
if (item) {
setTitle(item.title || '');
setAlt(item.alt || '');
setCaption(item.caption || '');
setAuthor(item.author || '');
setTags(item.tags.join(', '));
setMediaLanguage(item.language || '');
}
}, [item?.id]);
if (!item) {
return <div className="editor-empty">{tr('editor.media.notFound')}</div>;
}
const handleSave = async () => {
try {
const updated = await window.electronAPI?.media.update(item.id, {
title,
alt,
caption,
author: author || undefined,
language: mediaLanguage || undefined,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
});
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
showToast.success(tr('editor.media.toast.updated'));
}
} catch (error) {
console.error('Failed to update media:', error);
const err = error as Error;
showErrorModal({
title: tr('editor.media.error.updateTitle'),
message: err.message || tr('editor.media.error.updateMessage'),
stack: err.stack,
});
}
};
const handleReplaceFile = async () => {
try {
const updated = await window.electronAPI?.media.replaceFileDialog(item.id);
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
showToast.success(tr('editor.media.toast.fileReplaced'));
}
// null means user cancelled or file unchanged - no action needed
} catch (error) {
console.error('Failed to replace media file:', error);
const err = error as Error;
showErrorModal({
title: tr('editor.media.error.replaceTitle'),
message: err.message || tr('editor.media.error.replaceMessage'),
stack: err.stack,
});
}
};
const handleDelete = async () => {
try {
// Fetch posts that link to this media
const linkedPostsList = await window.electronAPI?.postMedia.getForMedia(mediaId);
// Build references array
const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = [];
// Add posts that use this media - fetch titles from database
if (linkedPostsList && linkedPostsList.length > 0) {
for (const link of linkedPostsList) {
const post = await window.electronAPI?.posts.get(link.postId);
if (post) {
references.push({
id: post.id,
title: post.title || tr('editor.untitled'),
type: 'post',
});
}
}
}
// Show confirmation modal
showConfirmDeleteModal({
itemType: 'media',
itemTitle: getMediaDisplayName(item),
references,
onConfirm: async () => {
try {
await window.electronAPI?.media.delete(item.id);
useAppStore.getState().removeMedia(item.id);
showToast.success(tr('editor.media.toast.deleted'));
} catch (error) {
console.error('Failed to delete media:', error);
const err = error as Error;
showErrorModal({
title: tr('editor.error.deleteTitle'),
message: err.message || tr('editor.media.error.deleteMessage'),
stack: err.stack,
});
}
},
});
} catch (error) {
console.error('Failed to fetch media references:', error);
const err = error as Error;
showErrorModal({
title: tr('errorModal.error'),
message: err.message || tr('editor.media.error.fetchReferencesMessage'),
stack: err.stack,
});
}
};
return (
<div className="editor">
<div className="editor-header">
<div className="editor-tabs">
<div className="editor-tab active">
<span className="editor-tab-title">{getMediaDisplayName(item)}</span>
</div>
</div>
<div className="editor-actions">
{/* Quick Actions Dropdown */}
<div className="quick-actions-wrapper" ref={quickActionsRef}>
<button
className="secondary quick-actions-btn"
onClick={() => setShowQuickActions(!showQuickActions)}
disabled={isAnalyzing || isDetectingLanguage || isTranslating}
title={tr('editor.media.quickActions.title')}
>
{(isAnalyzing || isDetectingLanguage || isTranslating) ? tr('editor.media.quickActions.analyzing') : tr('editor.media.quickActions.button')}
</button>
{showQuickActions && (
<div className="quick-actions-menu">
{item.mimeType.startsWith('image/') && (
<button
className="quick-action-item"
onClick={handleAIAnalysis}
disabled={isAnalyzing}
>
<span className="quick-action-icon">🤖</span>
<span className="quick-action-text">
<strong>{tr('editor.media.quickActions.aiTitle')}</strong>
<small>{tr('editor.media.quickActions.aiDescription')}</small>
</span>
</button>
)}
{item.mimeType.startsWith('image/') && <div className="quick-actions-divider" />}
<button
className="quick-action-item"
onClick={() => { setShowQuickActions(false); void handleDetectLanguage(); }}
disabled={isDetectingLanguage || (!title && !alt && !caption)}
>
<span className="quick-action-icon">🔍</span>
<span className="quick-action-text">
<strong>{tr('editor.media.quickActions.detectLanguageTitle')}</strong>
<small>{tr('editor.media.quickActions.detectLanguageDescription')}</small>
</span>
</button>
<div className="quick-actions-divider" />
<button
className="quick-action-item"
onClick={handleOpenMediaTranslationModal}
disabled={isTranslating || !mediaLanguage || availableTranslationLanguages.length === 0}
>
<span className="quick-action-icon">🌍</span>
<span className="quick-action-text">
<strong>{tr('editor.media.quickActions.translateTitle')}</strong>
<small>{tr('editor.media.quickActions.translateDescription')}</small>
</span>
</button>
</div>
)}
</div>
<button onClick={handleReplaceFile} className="secondary">{tr('editor.media.replaceFile')}</button>
<button onClick={handleSave}>{tr('common.save')}</button>
<button onClick={handleDelete} className="secondary danger">{tr('editor.delete')}</button>
</div>
</div>
<div className="editor-content media-editor">
<div className="media-preview">
{item.mimeType.startsWith('image/') ? (
<div className="media-preview-image">
<img
src={`bds-media://${item.id}?t=${item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt}`}
alt={item.alt || item.originalName}
onError={(e) => {
// Fallback to placeholder if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.parentElement?.classList.add('has-error');
}}
/>
</div>
) : (
<div className="media-preview-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
</svg>
<span>{item.originalName}</span>
</div>
)}
</div>
<div className="media-details">
<div className="editor-field">
<label>{tr('editor.media.field.fileName')}</label>
<input type="text" value={item.originalName} disabled className="disabled" />
</div>
<div className="editor-field">
<label>{tr('editor.media.field.type')}</label>
<input type="text" value={item.mimeType} disabled className="disabled" />
</div>
<div className="editor-field-row">
<div className="editor-field">
<label>{tr('editor.media.field.size')}</label>
<input type="text" value={`${(item.size / 1024).toFixed(1)} KB`} disabled className="disabled" />
</div>
{item.width && item.height && (
<div className="editor-field">
<label>{tr('editor.media.field.dimensions')}</label>
<input type="text" value={`${item.width} × ${item.height}`} disabled className="disabled" />
</div>
)}
</div>
<div className="editor-field">
<label>{tr('editor.media.field.title')}</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={tr('editor.media.placeholder.title')}
/>
</div>
<div className="editor-field">
<label>{tr('editor.media.field.altText')}</label>
<input
type="text"
value={alt}
onChange={(e) => setAlt(e.target.value)}
placeholder={tr('editor.media.placeholder.altText')}
/>
</div>
<div className="editor-field">
<label>{tr('editor.media.field.caption')}</label>
<textarea
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder={tr('editor.media.placeholder.caption')}
rows={3}
/>
</div>
<div className="editor-field">
<label>{tr('editor.media.field.tags')}</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder={tr('editor.media.placeholder.tags')}
/>
</div>
<div className="editor-field">
<label>{tr('editor.media.field.author')}</label>
<input
type="text"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder={tr('editor.media.placeholder.author')}
/>
</div>
{/* Language & Translations Section */}
<div className="editor-field">
<label>{tr('editor.media.field.language')}</label>
<select
value={mediaLanguage}
onChange={(e) => handleLanguageChange(e.target.value)}
>
<option value="">{tr('editor.media.field.languageNone')}</option>
{SUPPORTED_POST_LANGUAGES.map((lang) => (
<option key={lang} value={lang}>{tr(`language.${lang}`)}</option>
))}
</select>
</div>
{mediaLanguage && (
<div className="editor-field media-translations-section">
<label>{tr('editor.media.translations.title')}</label>
{mediaTranslations.length === 0 ? (
<div className="no-linked-posts">{tr('editor.media.translations.none')}</div>
) : (
<div className="linked-posts-list">
{mediaTranslations.map((translation) => (
<div key={translation.language} className="linked-post-item">
<span
className="linked-post-title"
style={{ cursor: 'pointer' }}
onClick={() => handleOpenEditTranslation(translation)}
title={tr('editor.media.translations.editTitle', { language: tr(`language.${translation.language}`) })}
>
{POST_LANGUAGE_FLAGS[translation.language as keyof typeof POST_LANGUAGE_FLAGS] || '🏳️'}{' '}
{tr(`language.${translation.language}`)}
{translation.title && `${translation.title}`}
</span>
<button
className="secondary"
onClick={() => handleTranslateMedia(translation.language)}
disabled={isTranslating}
title={tr('editor.media.translations.refreshTitle')}
style={{ marginRight: '4px', fontSize: '0.8em', padding: '2px 6px' }}
>
{tr('editor.media.translations.refresh')}
</button>
<button
className="unlink-btn"
onClick={() => handleDeleteTranslation(translation.language)}
title={tr('editor.media.translations.deleteTitle')}
>
×
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Linked Posts Section */}
<div className="editor-field linked-posts-section">
<label>
{tr('editor.media.linkedPosts')}
<button
className="add-link-btn"
onClick={() => setShowPostPicker(!showPostPicker)}
title={tr('editor.media.linkToPostTitle')}
>
{tr('editor.media.linkAction')}
</button>
</label>
{showPostPicker && (
<div className="post-picker">
<div className="post-picker-search">
<input
type="text"
placeholder={tr('editor.media.searchPosts')}
value={postSearchQuery}
onChange={(e) => setPostSearchQuery(e.target.value)}
autoFocus
/>
</div>
{unlinkedPosts.length === 0 ? (
<div className="no-posts">{postSearchQuery ? tr('editor.media.noMatchingPosts') : tr('editor.media.noPostsToLink')}</div>
) : (
<div className="post-picker-list">
{unlinkedPosts.slice(0, 10).map(post => (
<div
key={post.id}
className="post-picker-item"
onClick={() => handleLinkToPost(post.id, post.title)}
>
{post.title}
</div>
))}
{unlinkedPosts.length > 10 && (
<div className="post-picker-more">
{tr('editor.media.morePosts', { count: unlinkedPosts.length - 10 })}
</div>
)}
</div>
)}
</div>
)}
{linkedPosts.length === 0 ? (
<div className="no-linked-posts">{tr('editor.media.notLinked')}</div>
) : (
<div className="linked-posts-list">
{linkedPosts.map(({ postId }) => (
<div key={postId} className="linked-post-item">
<span
className="linked-post-title"
onClick={() => handlePostClick(postId)}
title={tr('editor.media.openPost')}
>
📄 {getPostTitle(postId)}
</span>
<button
className="unlink-btn"
onClick={() => handleUnlinkFromPost(postId)}
title={tr('editor.media.unlinkFromPost')}
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* AI Suggestions Modal */}
<AISuggestionsModal
isOpen={showAISuggestionsModal}
isLoading={isAnalyzing}
fields={aiSuggestionFields}
modalTitle={tr('aiSuggestions.title')}
loadingText={tr('aiSuggestions.analyzing')}
emptyText={tr('aiSuggestions.empty')}
error={aiError}
onConfirm={handleApplyAISuggestions}
onClose={handleCloseAISuggestionsModal}
/>
{/* Translation Modal */}
{showMediaTranslationModal && (
<div className="translation-modal-backdrop" onClick={handleCloseMediaTranslationModal}>
<div className="translation-modal" onClick={(event) => event.stopPropagation()}>
<div className="translation-modal-header">
<h2>{tr('editor.media.translations.title')}</h2>
<button className="translation-modal-close" onClick={handleCloseMediaTranslationModal} title={tr('common.cancel')}>×</button>
</div>
<div className="translation-modal-body">
<label className="translation-modal-label" htmlFor="media-translation-target-language">{tr('editor.media.translations.selectTarget')}</label>
<p className="translation-modal-copy">{tr('editor.media.translations.currentLanguage', { language: tr(`language.${mediaLanguage}`) })}</p>
<select
id="media-translation-target-language"
className="translation-modal-select"
value={translationTargetLanguage}
onChange={(e) => setTranslationTargetLanguage(e.target.value)}
>
{SUPPORTED_POST_LANGUAGES
.filter(lang => lang !== mediaLanguage)
.map((lang) => {
const existing = mediaTranslations.find(t => t.language === lang);
return (
<option key={lang} value={lang}>
{tr(`language.${lang}`)}{existing ? ` (${tr('editor.media.translations.refresh')})` : ''}
</option>
);
})}
</select>
{translationTargetLanguage && (
<div className="translation-modal-status-row">
<span className="translation-modal-flag" aria-hidden="true">{POST_LANGUAGE_FLAGS[translationTargetLanguage as keyof typeof POST_LANGUAGE_FLAGS] || '🏳️'}</span>
<span className="translation-modal-status-copy">
<strong>{tr(`language.${translationTargetLanguage}`)}</strong>
<small>
{mediaTranslations.find(t => t.language === translationTargetLanguage)
? tr('editor.media.translations.refresh')
: tr('editor.media.translations.none')}
</small>
</span>
</div>
)}
</div>
<div className="translation-modal-footer">
<button className="secondary" onClick={handleCloseMediaTranslationModal}>{tr('common.cancel')}</button>
<button
onClick={handleConfirmMediaTranslation}
disabled={!translationTargetLanguage || isTranslating}
title={tr('editor.media.quickActions.translateDescription')}
>
{isTranslating ? tr('editor.media.translations.translating') : tr('editor.media.translations.translateButton')}
</button>
</div>
</div>
</div>
)}
{/* Edit Translation Modal */}
{editingTranslation && (
<div className="translation-modal-backdrop" onClick={() => setEditingTranslation(null)}>
<div className="translation-modal" onClick={(event) => event.stopPropagation()}>
<div className="translation-modal-header">
<h2>{tr('editor.media.translations.editTitle', { language: tr(`language.${editingTranslation.language}`) })}</h2>
<button className="translation-modal-close" onClick={() => setEditingTranslation(null)} title={tr('common.cancel')}>×</button>
</div>
<div className="translation-modal-body">
<div className="editor-field">
<label htmlFor="edit-translation-title">{tr('editor.media.field.title')}</label>
<input
id="edit-translation-title"
type="text"
value={editingTranslation.title}
onChange={(e) => setEditingTranslation({ ...editingTranslation, title: e.target.value })}
placeholder={tr('editor.media.placeholder.title')}
/>
</div>
<div className="editor-field">
<label htmlFor="edit-translation-alt">{tr('editor.media.field.altText')}</label>
<input
id="edit-translation-alt"
type="text"
value={editingTranslation.alt}
onChange={(e) => setEditingTranslation({ ...editingTranslation, alt: e.target.value })}
placeholder={tr('editor.media.placeholder.altText')}
/>
</div>
<div className="editor-field">
<label htmlFor="edit-translation-caption">{tr('editor.media.field.caption')}</label>
<textarea
id="edit-translation-caption"
value={editingTranslation.caption}
onChange={(e) => setEditingTranslation({ ...editingTranslation, caption: e.target.value })}
placeholder={tr('editor.media.placeholder.caption')}
rows={3}
/>
</div>
</div>
<div className="translation-modal-footer">
<button className="secondary" onClick={() => setEditingTranslation(null)}>{tr('common.cancel')}</button>
<button onClick={() => void handleSaveEditTranslation()}>{tr('common.save')}</button>
</div>
</div>
</div>
)}
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
export const UI_DATE_LOCALE: Record<string, string> = {
en: 'en-US',
de: 'de-DE',
fr: 'fr-FR',
it: 'it-IT',
es: 'es-ES',
};
/** Get display name for media: prefer title over originalName */
export function getMediaDisplayName(media: { title?: string; originalName: string }): string {
return media.title || media.originalName;
}

View File

@@ -223,6 +223,7 @@ export const SettingsView: React.FC = () => {
const [defaultProjectPath, setDefaultProjectPath] = useState('');
const [projectMainLanguage, setProjectMainLanguage] = useState<SupportedLanguage>('en');
const [projectDefaultAuthor, setProjectDefaultAuthor] = useState('');
const [projectBlogLanguages, setProjectBlogLanguages] = useState<string[]>([]);
const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50);
const [projectBlogmarkCategory, setProjectBlogmarkCategory] = useState('article');
const [projectPythonRuntimeMode, setProjectPythonRuntimeMode] = useState<'webworker' | 'main-thread'>('webworker');
@@ -318,6 +319,11 @@ export const SettingsView: React.FC = () => {
const incomingSemanticSimilarity = (metadata as { semanticSimilarityEnabled?: unknown } | null)?.semanticSimilarityEnabled;
setSemanticSimilarityEnabled(incomingSemanticSimilarity === true);
const incomingBlogLanguages = (metadata as { blogLanguages?: unknown } | null)?.blogLanguages;
setProjectBlogLanguages(Array.isArray(incomingBlogLanguages)
? incomingBlogLanguages.filter((l): l is string => typeof l === 'string')
: []);
const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record<string, CategoryMetadata> | undefined;
const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record<string, { renderInLists: boolean; showTitle: boolean }> | undefined;
setCategoryMetadata((current) => {
@@ -550,6 +556,7 @@ export const SettingsView: React.FC = () => {
blogmarkCategory: normalizeBlogmarkCategory(projectBlogmarkCategory) || undefined,
pythonRuntimeMode: projectPythonRuntimeMode,
semanticSimilarityEnabled,
blogLanguages: projectBlogLanguages.length > 0 ? projectBlogLanguages : undefined,
categoryMetadata,
});
}
@@ -691,6 +698,37 @@ export const SettingsView: React.FC = () => {
</select>
</SettingRow>
<SettingRow
id="project-blog-languages"
label={t('settings.project.blogLanguagesLabel')}
description={t('settings.project.blogLanguagesDescription')}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{SUPPORTED_RENDER_LANGUAGES.map((language) => {
const isMain = language === projectMainLanguage;
const isChecked = isMain || projectBlogLanguages.includes(language);
return (
<label key={language} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', opacity: isMain ? 0.7 : 1 }}>
<input
type="checkbox"
checked={isChecked}
disabled={isMain}
onChange={(e) => {
if (isMain) return;
setProjectBlogLanguages((prev) =>
e.target.checked
? [...prev.filter((l) => l !== language), language]
: prev.filter((l) => l !== language),
);
}}
/>
{t(RENDER_LANGUAGE_LABEL_KEY[language])}
</label>
);
})}
</div>
</SettingRow>
<SettingRow
id="project-author"
label={t('settings.project.defaultAuthorLabel')}

View File

@@ -125,6 +125,24 @@
text-overflow: ellipsis;
}
.sidebar-item-title-row {
display: flex;
align-items: center;
gap: 6px;
}
.sidebar-item-language-badge {
flex-shrink: 0;
min-width: 18px;
padding: 1px 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--vscode-badge-background) 82%, transparent);
color: var(--vscode-badge-foreground);
font-size: 10px;
font-weight: 700;
text-align: center;
}
.sidebar-item-meta {
font-size: 11px;
color: var(--vscode-descriptionForeground);

View File

@@ -736,6 +736,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
await createAndFocusPost({
createPost: async (input) => (await window.electronAPI?.posts.create(input)) as { id: string } | null | undefined,
setSelectedPost: selectPost,
categories: isPagesMode ? [PAGE_CATEGORY] : [],
onError: (error) => {
console.error('Failed to create post:', error);
},
@@ -876,7 +877,14 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
<div className="sidebar-item-title-row">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
{post.availableLanguages?.length > 1 && (
<span className="sidebar-item-language-badge" title={t('sidebar.languagesAvailable', { count: post.availableLanguages.length })}>
{post.availableLanguages.length}
</span>
)}
</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt, uiLocale)}</div>
</div>
</div>
@@ -904,7 +912,14 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
<div className="sidebar-item-title-row">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
{post.availableLanguages?.length > 1 && (
<span className="sidebar-item-language-badge" title={t('sidebar.languagesAvailable', { count: post.availableLanguages.length })}>
{post.availableLanguages.length}
</span>
)}
</div>
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt, uiLocale)}</div>
</div>
</div>
@@ -932,7 +947,14 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
<div className="sidebar-item-title-row">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
{post.availableLanguages?.length > 1 && (
<span className="sidebar-item-language-badge" title={t('sidebar.languagesAvailable', { count: post.availableLanguages.length })}>
{post.availableLanguages.length}
</span>
)}
</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt, uiLocale)}</div>
</div>
</div>

View File

@@ -87,6 +87,10 @@ const getTabTitle = (
return tr('siteValidation.tabTitle');
}
if (tab.type === 'translation-validation') {
return tr('translationValidation.tabTitle');
}
if (tab.type === 'find-duplicates') {
return tr('duplicatesView.tabTitle');
}
@@ -184,6 +188,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
<path d="M8 1.5a6.5 6.5 0 1 0 6.5 6.5A6.5 6.5 0 0 0 8 1.5zm0 1a5.5 5.5 0 0 1 4.39 8.82l-.88-.88a.5.5 0 0 0-.7.7l.8.8A5.5 5.5 0 1 1 8 2.5zm2.35 3.15L7 9 5.65 7.65a.5.5 0 1 0-.7.7l1.7 1.7a.5.5 0 0 0 .7 0l3.7-3.7a.5.5 0 1 0-.7-.7z"/>
</svg>
);
case 'translation-validation':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 2.5A1.5 1.5 0 0 1 3.5 1h5A1.5 1.5 0 0 1 10 2.5v1h2.5A1.5 1.5 0 0 1 14 5v7.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12.5v-10zM3.5 2a.5.5 0 0 0-.5.5v10a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5V5a.5.5 0 0 0-.5-.5H10v1.15a.5.5 0 0 1-.85.35L7.5 4.35 5.85 6A.5.5 0 0 1 5 5.65V2.5a.5.5 0 0 0-.5-.5h-1zm2.5 1.71v.73l1.15-1.14a.5.5 0 0 1 .7 0L9 4.44v-.73a.5.5 0 0 0-.5-.5h-2a.5.5 0 0 0-.5.5zM5.5 8h5v1h-5V8zm0 2h5v1h-5v-1z"/>
</svg>
);
case 'find-duplicates':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">

View File

@@ -0,0 +1,103 @@
.translation-validation-view {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
height: 100%;
overflow: auto;
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
}
.translation-validation-summary h2 {
margin: 0 0 8px 0;
font-size: 1.1rem;
}
.translation-validation-summary p {
margin: 0;
color: var(--vscode-descriptionForeground);
}
.translation-validation-section h3 {
margin: 0 0 8px 0;
font-size: 1rem;
}
.translation-validation-empty,
.translation-validation-status {
margin: 0;
color: var(--vscode-descriptionForeground);
}
.translation-validation-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.translation-validation-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 12px;
background: color-mix(in srgb, var(--vscode-editor-background) 82%, var(--vscode-editorWidget-background) 18%);
}
.translation-validation-card.translation-validation-card-db {
border-left: 4px solid var(--vscode-notificationsWarningIcon-foreground);
}
.translation-validation-card.translation-validation-card-file {
border-left: 4px solid var(--vscode-notificationsErrorIcon-foreground);
}
.translation-validation-card-title {
margin: 0 0 6px 0;
font-weight: 600;
}
.translation-validation-card-meta {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 10px;
font-size: 12px;
}
.translation-validation-actions {
margin-top: auto;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.translation-validation-revalidate,
.translation-validation-fix {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
.translation-validation-revalidate:hover:not(:disabled),
.translation-validation-fix:hover:not(:disabled) {
background-color: var(--vscode-button-hoverBackground);
}
.translation-validation-revalidate:disabled,
.translation-validation-fix:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.translation-validation-card-meta dt {
color: var(--vscode-descriptionForeground);
}
.translation-validation-card-meta dd {
margin: 0;
word-break: break-word;
font-family: var(--vscode-editor-font-family);
}

View File

@@ -0,0 +1,240 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { TranslationValidationIssue, TranslationValidationReport, TranslationValidationFixResult } from '../../../main/shared/electronApi';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import { useI18n } from '../../i18n';
import { getPersistedTranslationValidationReport, persistTranslationValidationReport } from '../../navigation/translationValidationPersistence';
import './TranslationValidationView.css';
function getIssueLabel(
issue: TranslationValidationIssue['issue'],
tr: (key: string, vars?: Record<string, string | number>) => string,
): string {
if (issue === 'same-language-as-canonical') {
return tr('translationValidation.issue.sameLanguage');
}
if (issue === 'do-not-translate-has-translations') {
return tr('translationValidation.issue.doNotTranslate');
}
if (issue === 'content-in-database') {
return tr('translationValidation.issue.contentInDatabase');
}
return tr('translationValidation.issue.missingSource');
}
function ValidationIssueCard({
issue,
kind,
}: {
issue: TranslationValidationIssue;
kind: 'db' | 'file';
}): React.JSX.Element {
const { t: tr } = useI18n();
return (
<article className={`translation-validation-card translation-validation-card-${kind}`}>
<p className="translation-validation-card-title">{getIssueLabel(issue.issue, tr)}</p>
<dl className="translation-validation-card-meta">
<dt>{tr('translationValidation.field.translationFor')}</dt>
<dd>{issue.translationFor}</dd>
{issue.translationId ? (
<>
<dt>{tr('translationValidation.field.translationId')}</dt>
<dd>{issue.translationId}</dd>
</>
) : null}
{issue.title ? (
<>
<dt>{tr('translationValidation.field.title')}</dt>
<dd>{issue.title}</dd>
</>
) : null}
<dt>{tr('translationValidation.field.languages')}</dt>
<dd>
{issue.canonicalLanguage
? tr('translationValidation.languagesWithCanonical', {
canonical: issue.canonicalLanguage,
translation: issue.translationLanguage,
})
: issue.translationLanguage}
</dd>
{issue.filePath ? (
<>
<dt>{tr('translationValidation.field.filePath')}</dt>
<dd>{issue.filePath}</dd>
</>
) : null}
</dl>
</article>
);
}
export const TranslationValidationView: React.FC = () => {
const { t: tr } = useI18n();
const { activeProject } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const [isRevalidating, setIsRevalidating] = useState(false);
const [isFixing, setIsFixing] = useState(false);
const [report, setReport] = useState<TranslationValidationReport | null>(null);
const loadPersistedReport = () => {
setIsLoading(true);
try {
const projectId = activeProject?.id;
if (!projectId) {
setReport(null);
return;
}
setReport(getPersistedTranslationValidationReport(projectId));
} finally {
setIsLoading(false);
}
};
const handleRevalidate = async () => {
setIsRevalidating(true);
try {
const freshReport = await window.electronAPI?.blog.validateTranslations();
const projectId = activeProject?.id;
if (projectId && freshReport) {
persistTranslationValidationReport(projectId, freshReport);
window.dispatchEvent(new CustomEvent('bds:translation-validation-updated', {
detail: { projectId },
}));
}
} catch (error) {
console.error('Translation revalidation failed:', error);
showToast.error(tr('translationValidation.error.validate'));
} finally {
setIsRevalidating(false);
}
};
const handleFix = async () => {
if (!report) return;
setIsFixing(true);
try {
const result = await window.electronAPI.blog.fixInvalidTranslations(report) as TranslationValidationFixResult;
showToast.success(tr('translationValidation.toast.fixSuccess', {
dbRows: result.deletedDatabaseRows,
files: result.deletedFiles,
flushed: result.flushedTranslations,
}));
// Re-validate after fixing to refresh the report
await handleRevalidate();
} catch (error) {
console.error('Fixing invalid translations failed:', error);
showToast.error(tr('translationValidation.error.fix'));
} finally {
setIsFixing(false);
}
};
useEffect(() => {
loadPersistedReport();
}, [activeProject?.id]);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ projectId?: string }>).detail;
if (!activeProject?.id || detail?.projectId !== activeProject.id) {
return;
}
loadPersistedReport();
};
window.addEventListener('bds:translation-validation-updated', handler);
return () => window.removeEventListener('bds:translation-validation-updated', handler);
}, [activeProject?.id]);
const summary = useMemo(() => {
if (!report) {
return null;
}
return tr('translationValidation.summary', {
dbRows: report.checkedDatabaseRowCount,
files: report.checkedFilesystemFileCount,
invalidDb: report.invalidDatabaseRows.length,
invalidFiles: report.invalidFilesystemFiles.length,
});
}, [report, tr]);
const canFix = useMemo(() => {
if (!report) return false;
return report.invalidDatabaseRows.length > 0 || report.invalidFilesystemFiles.length > 0;
}, [report]);
if (isLoading) {
return (
<div className="translation-validation-view">
<p className="translation-validation-status">{tr('translationValidation.loading')}</p>
</div>
);
}
if (!report) {
return (
<div className="translation-validation-view">
<p className="translation-validation-status">{tr('translationValidation.empty')}</p>
</div>
);
}
return (
<div className="translation-validation-view">
<div className="translation-validation-summary">
<h2>{tr('translationValidation.title')}</h2>
<p>{summary}</p>
</div>
<section className="translation-validation-section">
<h3>{tr('translationValidation.databaseTitle')}</h3>
{report.invalidDatabaseRows.length === 0 ? (
<p className="translation-validation-empty">{tr('translationValidation.noneDatabase')}</p>
) : (
<div className="translation-validation-list">
{report.invalidDatabaseRows.map((issue, index) => (
<ValidationIssueCard key={`db:${issue.translationId || issue.translationFor}:${index}`} issue={issue} kind="db" />
))}
</div>
)}
</section>
<section className="translation-validation-section">
<h3>{tr('translationValidation.filesystemTitle')}</h3>
{report.invalidFilesystemFiles.length === 0 ? (
<p className="translation-validation-empty">{tr('translationValidation.noneFilesystem')}</p>
) : (
<div className="translation-validation-list">
{report.invalidFilesystemFiles.map((issue, index) => (
<ValidationIssueCard key={`file:${issue.filePath || issue.translationFor}:${index}`} issue={issue} kind="file" />
))}
</div>
)}
</section>
<div className="translation-validation-actions">
<button
type="button"
className="translation-validation-revalidate"
onClick={handleRevalidate}
disabled={isRevalidating || isFixing}
>
{isRevalidating ? tr('translationValidation.revalidating') : tr('translationValidation.revalidate')}
</button>
<button
type="button"
className="translation-validation-fix"
onClick={handleFix}
disabled={!canFix || isFixing || isRevalidating}
>
{isFixing ? tr('translationValidation.fixing') : tr('translationValidation.fix')}
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { TranslationValidationView } from './TranslationValidationView';

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "Website-Validierung fehlgeschlagen",
"siteValidation.error.apply": "Anwenden der Validierung fehlgeschlagen",
"siteValidation.toast.applySuccess": "Validierung angewendet: {rendered} gerendert, {deleted} gelöscht",
"menu.item.validateTranslations": "Übersetzungen validieren",
"translationValidation.tabTitle": "Übersetzungsvalidierung",
"translationValidation.title": "Übersetzungen validieren",
"translationValidation.summary": "Geprüfte DB-Zeilen: {dbRows} · Geprüfte Dateien: {files} · Ungültige DB-Zeilen: {invalidDb} · Ungültige Dateien: {invalidFiles}",
"translationValidation.loading": "Übersetzungen werden validiert...",
"translationValidation.empty": "Führe Blog -> Übersetzungen validieren aus, um die Übersetzungsintegrität zu prüfen.",
"translationValidation.databaseTitle": "Ungültige Übersetzungszeilen in der Datenbank",
"translationValidation.filesystemTitle": "Ungültige Übersetzungsdateien auf dem Datenträger",
"translationValidation.noneDatabase": "Keine ungültigen Übersetzungszeilen gefunden.",
"translationValidation.noneFilesystem": "Keine ungültigen Übersetzungsdateien gefunden.",
"translationValidation.error.validate": "Übersetzungsvalidierung fehlgeschlagen",
"translationValidation.issue.sameLanguage": "Übersetzungssprache entspricht der kanonischen Beitragssprache",
"translationValidation.issue.missingSource": "Übersetzung verweist auf einen fehlenden Quellbeitrag",
"translationValidation.issue.doNotTranslate": "Beitrag ist als nicht-übersetzen markiert, hat aber Übersetzungen",
"translationValidation.issue.contentInDatabase": "Veröffentlichte Übersetzung hat Inhalt in der DB statt im Dateisystem",
"translationValidation.field.translationFor": "Quellbeitrag",
"translationValidation.field.translationId": "Übersetzungszeile",
"translationValidation.field.title": "Titel",
"translationValidation.field.languages": "Sprachen",
"translationValidation.field.filePath": "Datei",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Erneut validieren",
"translationValidation.revalidating": "Wird validiert…",
"translationValidation.fix": "Probleme beheben",
"translationValidation.fixing": "Wird behoben…",
"translationValidation.toast.fixSuccess": "{dbRows} DB-Zeilen und {files} Dateien gelöscht, {flushed} Übersetzungen auf Disk geschrieben",
"translationValidation.error.fix": "Fehler beim Beheben ungültiger Übersetzungen",
"menuEditor.tabTitle": "Blog-Menü",
"menuEditor.title": "Blog-Menü-Editor",
"menuEditor.description": "Verwalte die zentrale Blog-Navigationsstruktur und speichere sie in meta/menu.opml.",
@@ -419,18 +446,18 @@
"metadataDiff.orphanFiles.badge": "Verwaiste Datei",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Pfad",
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
"metadataDiff.orphanFiles.importButton": "D 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.short": "DBD",
"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.short": "DDB",
"metadataDiff.sync.fileToDb.success": "{success} Dateien in die Datenbank synchronisiert{fehlgeschlagen}",
"metadataDiff.sync.fileToDb.error": "Synchronisierung in die Datenbank fehlgeschlagen",
"metadataDiff.value.database": "Datenbank",
@@ -461,6 +488,7 @@
"sidebar.published": "Veröffentlicht",
"sidebar.archived": "Archiviert",
"sidebar.untitled": "Ohne Titel",
"sidebar.languagesAvailable": "{count} Sprachen verfugbar",
"sidebar.noMatchingPosts": "Keine passenden Beiträge",
"sidebar.createFirstPost": "Ersten Beitrag erstellen",
"sidebar.loadMore": "Mehr laden ({loaded} von {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Hauptsprache",
"settings.project.mainLanguageDescription": "Die primäre Sprache für deine Blog-Inhalte. KI-generierte Titel, Alt-Texte und Bildunterschriften nutzen diese Sprache.",
"settings.project.blogLanguagesLabel": "Blog-Sprachen",
"settings.project.blogLanguagesDescription": "Sprachen, in denen der Blog gerendert wird. Die Hauptsprache ist immer enthalten. Zusätzliche Sprachen erzeugen übersetzte Unterbäume.",
"settings.project.defaultAuthorLabel": "Standardautor",
"settings.project.defaultAuthorDescription": "Der Standard-Autorname für neue Beiträge und Medien. Kann pro Element überschrieben werden.",
"settings.project.defaultAuthorPlaceholder": "Autorenname",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Beitragsvorschau",
"editor.previewLoading": "Vorschau wird geladen...",
"editor.metadata.toggle": "Metadaten",
"editor.translations.title": "Ubersetzungen",
"editor.translations.currentLanguage": "Aktuelle Sprache: {language}",
"editor.translations.none": "Noch keine Ubersetzungen.",
"editor.translations.selectTarget": "Zielsprache auswahlen",
"editor.translations.translateButton": "Ubersetzen nach...",
"editor.translations.translateTitle": "Ubersetzung per KI erstellen oder aktualisieren",
"editor.translations.translating": "Wird ubersetzt...",
"editor.translations.refresh": "Aktualisieren",
"editor.translations.refreshTitle": "Diese Ubersetzung per KI neu erzeugen",
"editor.translations.publish": "Veroffentlichen",
"editor.translations.publishTitle": "Diese Ubersetzung in ihre Markdown-Datei veroffentlichen",
"editor.translations.publishing": "Wird veroffentlicht...",
"editor.translations.missing": "Fehlend: {languages}",
"editor.translations.complete": "Alle unterstutzten Ubersetzungssprachen sind verfugbar.",
"editor.translations.translateSuccess": "Ubersetzung fur {language} aktualisiert",
"editor.translations.translateFailed": "Ubersetzung fehlgeschlagen",
"editor.translations.publishSuccess": "Ubersetzung fur {language} veroffentlicht",
"editor.translations.publishFailed": "Ubersetzung konnte nicht veroffentlicht werden",
"editor.translations.status.draft": "Entwurf",
"editor.translations.status.published": "Veroffentlicht",
"editor.translations.status.archived": "Archiviert",
"editor.excerpt.toggle": "Auszug",
"editor.footer.created": "Erstellt",
"editor.footer.updated": "Aktualisiert",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "⚡ Schnellaktionen",
"editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen",
"editor.media.quickActions.aiDescription": "Analysiert das Bild und schlägt Metadaten vor",
"editor.media.quickActions.detectLanguageTitle": "Sprache erkennen",
"editor.media.quickActions.detectLanguageDescription": "Sprache aus Metadaten per KI erkennen",
"editor.media.quickActions.translateTitle": "Übersetzen in…",
"editor.media.quickActions.translateDescription": "Übersetzung per KI erstellen oder aktualisieren",
"editor.media.translations.currentLanguage": "Aktuelle Sprache: {language}",
"editor.media.translations.selectTarget": "Zielsprache wählen",
"editor.post.quickActions.title": "Schnellaktionen",
"editor.post.quickActions.analyzing": "⏳ Wird analysiert…",
"editor.post.quickActions.button": "⚡ Schnellaktionen",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Bildunterschrift",
"editor.media.field.tags": "Tags (kommagetrennt)",
"editor.media.field.author": "Autor",
"editor.media.field.language": "Sprache",
"editor.media.field.languageNone": "Nicht festgelegt",
"editor.media.translations.title": "Übersetzungen",
"editor.media.translations.none": "Noch keine Übersetzungen.",
"editor.media.translations.translateButton": "Übersetzen in…",
"editor.media.translations.translating": "Übersetze…",
"editor.media.translations.translateSuccess": "Übersetzung aktualisiert für {language}",
"editor.media.translations.translateFailed": "Übersetzung fehlgeschlagen",
"editor.media.translations.refresh": "Aktualisieren",
"editor.media.translations.refreshTitle": "Diese Übersetzung per KI neu generieren",
"editor.media.translations.deleteTitle": "Diese Übersetzung löschen",
"editor.media.translations.deleted": "Übersetzung gelöscht für {language}",
"editor.media.translations.deleteFailed": "Löschen der Übersetzung fehlgeschlagen",
"editor.media.translations.editTitle": "Übersetzung bearbeiten — {language}",
"editor.media.translations.saved": "Übersetzung gespeichert für {language}",
"editor.media.translations.saveFailed": "Speichern der Übersetzung fehlgeschlagen",
"editor.media.toast.languageDetected": "Sprache erkannt: {language}",
"editor.media.error.detectLanguage": "Spracherkennung fehlgeschlagen",
"editor.media.placeholder.title": "Titel für Listen und Suchergebnisse",
"editor.media.placeholder.altText": "Bild für Barrierefreiheit beschreiben",
"editor.media.placeholder.caption": "Bildunterschrift",
@@ -1097,9 +1172,7 @@
"importAnalysis.usedIn": "Verwendet in: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} weitere",
"importAnalysis.noParameters": "(keine Parameter)",
"sidebar.nav.mcp": "MCP-Server",
"settings.mcp.title": "MCP-Server",
"settings.mcp.description": "Konfigurieren Sie den Model Context Protocol Server, der KI-Programmieragenten die Interaktion mit Ihrem Blog ermöglicht.",
"settings.mcp.statusLabel": "Serverstatus",
@@ -1132,5 +1205,9 @@
"duplicatesView.checkAll": "Alle auswählen",
"duplicatesView.uncheckAll": "Alle abwählen",
"duplicatesView.dismissChecked": "Ausgewählte ignorieren ({count})",
"duplicatesView.notEnabled": "Semantische Ähnlichkeit ist nicht aktiviert. Aktivieren Sie sie unter Einstellungen → Technologie."
"duplicatesView.notEnabled": "Semantische Ähnlichkeit ist nicht aktiviert. Aktivieren Sie sie unter Einstellungen → Technologie.",
"editor.doNotTranslateLabel": "Nicht übersetzen",
"blog.fillMissing.nothingToDo": "Alle Übersetzungen sind aktuell.",
"blog.fillMissing.started": "Übersetzungsaufgabe gestartet. Fortschritt im Aufgabenbereich.",
"blog.fillMissing.error": "Fehlende Übersetzungen konnten nicht erstellt werden."
}

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "Site validation failed",
"siteValidation.error.apply": "Applying validation failed",
"siteValidation.toast.applySuccess": "Validation applied: {rendered} rendered, {deleted} deleted",
"menu.item.validateTranslations": "Validate Translations",
"translationValidation.tabTitle": "Translation Validation",
"translationValidation.title": "Validate Translations",
"translationValidation.summary": "Checked DB rows: {dbRows} · Checked files: {files} · Invalid DB rows: {invalidDb} · Invalid files: {invalidFiles}",
"translationValidation.loading": "Validating translations...",
"translationValidation.empty": "Run Blog -> Validate Translations to inspect translation integrity.",
"translationValidation.databaseTitle": "Invalid database translation rows",
"translationValidation.filesystemTitle": "Invalid translation files on disk",
"translationValidation.noneDatabase": "No invalid translation rows found.",
"translationValidation.noneFilesystem": "No invalid translation files found.",
"translationValidation.error.validate": "Translation validation failed",
"translationValidation.issue.sameLanguage": "Translation language matches canonical post language",
"translationValidation.issue.missingSource": "Translation points to a missing source post",
"translationValidation.issue.doNotTranslate": "Post is marked as do-not-translate but has translations",
"translationValidation.issue.contentInDatabase": "Published translation has content stuck in DB instead of filesystem",
"translationValidation.field.translationFor": "Source post",
"translationValidation.field.translationId": "Translation row",
"translationValidation.field.title": "Title",
"translationValidation.field.languages": "Languages",
"translationValidation.field.filePath": "File",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Revalidate",
"translationValidation.revalidating": "Revalidating…",
"translationValidation.fix": "Fix Issues",
"translationValidation.fixing": "Fixing…",
"translationValidation.toast.fixSuccess": "Deleted {dbRows} DB rows and {files} files, flushed {flushed} translations to disk",
"translationValidation.error.fix": "Failed to fix invalid translations",
"menuEditor.tabTitle": "Blog Menu",
"menuEditor.title": "Blog Menu Editor",
"menuEditor.description": "Manage the central blog navigation outline and save it to meta/menu.opml.",
@@ -412,11 +439,11 @@
"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.short": "DBF",
"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.short": "FDB",
"metadataDiff.sync.fileToDb.success": "Synced {success} files to database{failed}",
"metadataDiff.sync.fileToDb.error": "Failed to sync to database",
"metadataDiff.value.database": "Database",
@@ -433,7 +460,7 @@
"metadataDiff.orphanFiles.badge": "Orphan file",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Path",
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
"metadataDiff.orphanFiles.importButton": "D DB",
"metadataDiff.orphanFiles.importTitle": "Import all orphan files into the database",
"metadataDiff.orphanFiles.importing": "Importing…",
"metadataDiff.orphanFiles.importSuccess": "{success} orphan files imported{failed}",
@@ -461,6 +488,7 @@
"sidebar.published": "Published",
"sidebar.archived": "Archived",
"sidebar.untitled": "Untitled",
"sidebar.languagesAvailable": "{count} languages available",
"sidebar.noMatchingPosts": "No matching posts",
"sidebar.createFirstPost": "Create your first post",
"sidebar.loadMore": "Load more ({loaded} of {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Main Language",
"settings.project.mainLanguageDescription": "The primary language for your blog content. AI-generated titles, alt text, and captions will use this language.",
"settings.project.blogLanguagesLabel": "Blog Languages",
"settings.project.blogLanguagesDescription": "Languages the blog is rendered in. The main language is always included. Additional languages generate translated subtrees.",
"settings.project.defaultAuthorLabel": "Default Author",
"settings.project.defaultAuthorDescription": "The default author name for new posts and media. Can be overridden per item.",
"settings.project.defaultAuthorPlaceholder": "Author Name",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Post preview",
"editor.previewLoading": "Loading preview...",
"editor.metadata.toggle": "Metadata",
"editor.translations.title": "Translations",
"editor.translations.currentLanguage": "Current language: {language}",
"editor.translations.none": "No translations yet.",
"editor.translations.selectTarget": "Select target language",
"editor.translations.translateButton": "Translate to...",
"editor.translations.translateTitle": "Create or refresh a translation using AI",
"editor.translations.translating": "Translating...",
"editor.translations.refresh": "Refresh",
"editor.translations.refreshTitle": "Regenerate this translation using AI",
"editor.translations.publish": "Publish",
"editor.translations.publishTitle": "Publish this translation to its markdown file",
"editor.translations.publishing": "Publishing...",
"editor.translations.missing": "Missing: {languages}",
"editor.translations.complete": "All supported translation languages are available.",
"editor.translations.translateSuccess": "Translation updated for {language}",
"editor.translations.translateFailed": "Translation failed",
"editor.translations.publishSuccess": "Published translation for {language}",
"editor.translations.publishFailed": "Publishing translation failed",
"editor.translations.status.draft": "Draft",
"editor.translations.status.published": "Published",
"editor.translations.status.archived": "Archived",
"editor.excerpt.toggle": "Excerpt",
"editor.footer.created": "Created",
"editor.footer.updated": "Updated",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "⚡ Quick Actions",
"editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption",
"editor.media.quickActions.aiDescription": "Analyzes the image to suggest metadata",
"editor.media.quickActions.detectLanguageTitle": "Detect Language",
"editor.media.quickActions.detectLanguageDescription": "Detect language from metadata using AI",
"editor.media.quickActions.translateTitle": "Translate to...",
"editor.media.quickActions.translateDescription": "Create or refresh a translation using AI",
"editor.media.translations.currentLanguage": "Current language: {language}",
"editor.media.translations.selectTarget": "Select target language",
"editor.post.quickActions.title": "Quick Actions",
"editor.post.quickActions.analyzing": "⏳ Analyzing…",
"editor.post.quickActions.button": "⚡ Quick Actions",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Caption",
"editor.media.field.tags": "Tags (comma-separated)",
"editor.media.field.author": "Author",
"editor.media.field.language": "Language",
"editor.media.field.languageNone": "Not set",
"editor.media.translations.title": "Translations",
"editor.media.translations.none": "No translations yet.",
"editor.media.translations.translateButton": "Translate to…",
"editor.media.translations.translating": "Translating…",
"editor.media.translations.translateSuccess": "Translation updated for {language}",
"editor.media.translations.translateFailed": "Translation failed",
"editor.media.translations.refresh": "Refresh",
"editor.media.translations.refreshTitle": "Regenerate this translation using AI",
"editor.media.translations.deleteTitle": "Delete this translation",
"editor.media.translations.deleted": "Translation deleted for {language}",
"editor.media.translations.deleteFailed": "Failed to delete translation",
"editor.media.translations.editTitle": "Edit Translation — {language}",
"editor.media.translations.saved": "Translation saved for {language}",
"editor.media.translations.saveFailed": "Failed to save translation",
"editor.media.toast.languageDetected": "Language detected: {language}",
"editor.media.error.detectLanguage": "Failed to detect language",
"editor.media.placeholder.title": "Title for lists and search results",
"editor.media.placeholder.altText": "Describe the image for accessibility",
"editor.media.placeholder.caption": "Image caption",
@@ -1097,9 +1172,7 @@
"importAnalysis.usedIn": "Used in: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} more",
"importAnalysis.noParameters": "(no parameters)",
"sidebar.nav.mcp": "MCP Server",
"settings.mcp.title": "MCP Server",
"settings.mcp.description": "Configure the Model Context Protocol server that allows AI coding agents to interact with your blog.",
"settings.mcp.statusLabel": "Server Status",
@@ -1132,5 +1205,9 @@
"duplicatesView.checkAll": "Check All",
"duplicatesView.uncheckAll": "Uncheck All",
"duplicatesView.dismissChecked": "Dismiss Checked ({count})",
"duplicatesView.notEnabled": "Semantic similarity is not enabled. Enable it in Settings → Technology."
"duplicatesView.notEnabled": "Semantic similarity is not enabled. Enable it in Settings → Technology.",
"editor.doNotTranslateLabel": "Do not translate",
"blog.fillMissing.nothingToDo": "All translations are up to date.",
"blog.fillMissing.started": "Translation task started. Check the task panel for progress.",
"blog.fillMissing.error": "Failed to fill missing translations."
}

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "La validación del sitio falló",
"siteValidation.error.apply": "La aplicación de la validación falló",
"siteValidation.toast.applySuccess": "Validación aplicada: {rendered} renderizadas, {deleted} eliminadas",
"menu.item.validateTranslations": "Validar traducciones",
"translationValidation.tabTitle": "Validación de traducciones",
"translationValidation.title": "Validar traducciones",
"translationValidation.summary": "Filas de BD revisadas: {dbRows} · Archivos revisados: {files} · Filas de BD inválidas: {invalidDb} · Archivos inválidos: {invalidFiles}",
"translationValidation.loading": "Validando traducciones...",
"translationValidation.empty": "Ejecuta Blog -> Validar traducciones para inspeccionar la integridad de las traducciones.",
"translationValidation.databaseTitle": "Filas de traducción inválidas en la base de datos",
"translationValidation.filesystemTitle": "Archivos de traducción inválidos en disco",
"translationValidation.noneDatabase": "No se encontraron filas de traducción inválidas.",
"translationValidation.noneFilesystem": "No se encontraron archivos de traducción inválidos.",
"translationValidation.error.validate": "La validación de traducciones falló",
"translationValidation.issue.sameLanguage": "El idioma de la traducción coincide con el idioma canónico de la entrada",
"translationValidation.issue.missingSource": "La traducción apunta a una entrada de origen inexistente",
"translationValidation.issue.doNotTranslate": "La entrada está marcada como no-traducir pero tiene traducciones",
"translationValidation.issue.contentInDatabase": "Traducción publicada con contenido en la BD en lugar del sistema de archivos",
"translationValidation.field.translationFor": "Entrada de origen",
"translationValidation.field.translationId": "Fila de traducción",
"translationValidation.field.title": "Título",
"translationValidation.field.languages": "Idiomas",
"translationValidation.field.filePath": "Archivo",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Revalidar",
"translationValidation.revalidating": "Revalidando…",
"translationValidation.fix": "Corregir problemas",
"translationValidation.fixing": "Corrigiendo…",
"translationValidation.toast.fixSuccess": "{dbRows} filas de BD y {files} archivos eliminados, {flushed} traducciones escritas a disco",
"translationValidation.error.fix": "Error al corregir traducciones inválidas",
"menuEditor.tabTitle": "Menú del blog",
"menuEditor.title": "Editor del menú del blog",
"menuEditor.description": "Gestiona la estructura central de navegación del blog y guárdala en meta/menu.opml.",
@@ -412,7 +439,7 @@
"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.short": "BDA",
"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",
@@ -433,7 +460,7 @@
"metadataDiff.orphanFiles.badge": "Archivo huérfano",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Ruta",
"metadataDiff.orphanFiles.importButton": "D \u2192 BD",
"metadataDiff.orphanFiles.importButton": "D 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}",
@@ -461,6 +488,7 @@
"sidebar.published": "Publicadas",
"sidebar.archived": "Archivadas",
"sidebar.untitled": "Sin título",
"sidebar.languagesAvailable": "{count} idiomas disponibles",
"sidebar.noMatchingPosts": "No hay entradas coincidentes",
"sidebar.createFirstPost": "Crea tu primera entrada",
"sidebar.loadMore": "Cargar más ({loaded} de {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Idioma principal",
"settings.project.mainLanguageDescription": "Idioma principal del contenido del blog. Los títulos, textos alternativos y pies generados por IA usarán este idioma.",
"settings.project.blogLanguagesLabel": "Idiomas del blog",
"settings.project.blogLanguagesDescription": "Idiomas en los que se genera el blog. El idioma principal siempre está incluido. Los idiomas adicionales generan subárboles traducidos.",
"settings.project.defaultAuthorLabel": "Autor predeterminado",
"settings.project.defaultAuthorDescription": "Nombre de autor predeterminado para nuevas entradas y medios. Se puede reemplazar por elemento.",
"settings.project.defaultAuthorPlaceholder": "Nombre del autor",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Vista previa de la entrada",
"editor.previewLoading": "Cargando vista previa...",
"editor.metadata.toggle": "Metadatos",
"editor.translations.title": "Traducciones",
"editor.translations.currentLanguage": "Idioma actual: {language}",
"editor.translations.none": "Todavía no hay traducciones.",
"editor.translations.selectTarget": "Selecciona el idioma de destino",
"editor.translations.translateButton": "Traducir a...",
"editor.translations.translateTitle": "Crear o actualizar una traducción con IA",
"editor.translations.translating": "Traduciendo...",
"editor.translations.refresh": "Actualizar",
"editor.translations.refreshTitle": "Regenerar esta traducción con IA",
"editor.translations.publish": "Publicar",
"editor.translations.publishTitle": "Publicar esta traducción en su archivo Markdown",
"editor.translations.publishing": "Publicando...",
"editor.translations.missing": "Faltan: {languages}",
"editor.translations.complete": "Todos los idiomas de traducción compatibles están disponibles.",
"editor.translations.translateSuccess": "Traducción actualizada para {language}",
"editor.translations.translateFailed": "La traducción falló",
"editor.translations.publishSuccess": "Traducción publicada para {language}",
"editor.translations.publishFailed": "No se pudo publicar la traducción",
"editor.translations.status.draft": "Borrador",
"editor.translations.status.published": "Publicada",
"editor.translations.status.archived": "Archivada",
"editor.excerpt.toggle": "Extracto",
"editor.footer.created": "Creado",
"editor.footer.updated": "Actualizado",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "✨ Analizar con IA",
"editor.media.quickActions.aiTitle": "Título sugerido por IA",
"editor.media.quickActions.aiDescription": "Genera automáticamente título, texto alternativo y pie de foto.",
"editor.media.quickActions.detectLanguageTitle": "Detectar idioma",
"editor.media.quickActions.detectLanguageDescription": "Detectar el idioma de los metadatos con IA",
"editor.media.quickActions.translateTitle": "Traducir a…",
"editor.media.quickActions.translateDescription": "Crear o actualizar una traducción con IA",
"editor.media.translations.currentLanguage": "Idioma actual: {language}",
"editor.media.translations.selectTarget": "Seleccionar idioma de destino",
"editor.post.quickActions.title": "Acciones rápidas",
"editor.post.quickActions.analyzing": "⏳ Analizando…",
"editor.post.quickActions.button": "⚡ Acciones rápidas",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Pie de foto",
"editor.media.field.tags": "Etiquetas",
"editor.media.field.author": "Autor",
"editor.media.field.language": "Idioma",
"editor.media.field.languageNone": "No definido",
"editor.media.translations.title": "Traducciones",
"editor.media.translations.none": "Aún no hay traducciones.",
"editor.media.translations.translateButton": "Traducir a…",
"editor.media.translations.translating": "Traduciendo…",
"editor.media.translations.translateSuccess": "Traducción actualizada para {language}",
"editor.media.translations.translateFailed": "Error en la traducción",
"editor.media.translations.refresh": "Actualizar",
"editor.media.translations.refreshTitle": "Regenerar esta traducción con IA",
"editor.media.translations.deleteTitle": "Eliminar esta traducción",
"editor.media.translations.deleted": "Traducción eliminada para {language}",
"editor.media.translations.deleteFailed": "Error al eliminar la traducción",
"editor.media.translations.editTitle": "Editar traducción — {language}",
"editor.media.translations.saved": "Traducción guardada para {language}",
"editor.media.translations.saveFailed": "Error al guardar la traducción",
"editor.media.toast.languageDetected": "Idioma detectado: {language}",
"editor.media.error.detectLanguage": "Error al detectar el idioma",
"editor.media.placeholder.title": "Introduce un título",
"editor.media.placeholder.altText": "Describe la imagen para accesibilidad",
"editor.media.placeholder.caption": "Añadir pie de foto",
@@ -1097,9 +1172,7 @@
"importAnalysis.usedIn": "Usado en: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} más",
"importAnalysis.noParameters": "(sin parámetros)",
"sidebar.nav.mcp": "Servidor MCP",
"settings.mcp.title": "Servidor MCP",
"settings.mcp.description": "Configure el servidor Model Context Protocol que permite a los agentes de programación IA interactuar con su blog.",
"settings.mcp.statusLabel": "Estado del servidor",
@@ -1132,5 +1205,9 @@
"duplicatesView.checkAll": "Seleccionar todo",
"duplicatesView.uncheckAll": "Deseleccionar todo",
"duplicatesView.dismissChecked": "Descartar seleccionados ({count})",
"duplicatesView.notEnabled": "La similitud semántica no está activada. Actívela en Configuración → Tecnología."
"duplicatesView.notEnabled": "La similitud semántica no está activada. Actívela en Configuración → Tecnología.",
"editor.doNotTranslateLabel": "No traducir",
"blog.fillMissing.nothingToDo": "Todas las traducciones están al día.",
"blog.fillMissing.started": "Tarea de traducción iniciada. Consulte el panel de tareas para ver el progreso.",
"blog.fillMissing.error": "Error al rellenar las traducciones faltantes."
}

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "Échec de la validation du site",
"siteValidation.error.apply": "Échec de lapplication de la validation",
"siteValidation.toast.applySuccess": "Validation appliquée : {rendered} rendues, {deleted} supprimées",
"menu.item.validateTranslations": "Valider les traductions",
"translationValidation.tabTitle": "Validation des traductions",
"translationValidation.title": "Valider les traductions",
"translationValidation.summary": "Lignes BD vérifiées : {dbRows} · Fichiers vérifiés : {files} · Lignes BD invalides : {invalidDb} · Fichiers invalides : {invalidFiles}",
"translationValidation.loading": "Validation des traductions en cours...",
"translationValidation.empty": "Exécutez Blog -> Valider les traductions pour inspecter lintégrité des traductions.",
"translationValidation.databaseTitle": "Lignes de traduction invalides dans la base de données",
"translationValidation.filesystemTitle": "Fichiers de traduction invalides sur le disque",
"translationValidation.noneDatabase": "Aucune ligne de traduction invalide trouvée.",
"translationValidation.noneFilesystem": "Aucun fichier de traduction invalide trouvé.",
"translationValidation.error.validate": "Échec de la validation des traductions",
"translationValidation.issue.sameLanguage": "La langue de traduction correspond à la langue canonique de larticle",
"translationValidation.issue.missingSource": "La traduction pointe vers un article source manquant",
"translationValidation.issue.doNotTranslate": "L'article est marqué ne-pas-traduire mais a des traductions",
"translationValidation.issue.contentInDatabase": "Traduction publiée avec contenu encore en base au lieu du système de fichiers",
"translationValidation.field.translationFor": "Article source",
"translationValidation.field.translationId": "Ligne de traduction",
"translationValidation.field.title": "Titre",
"translationValidation.field.languages": "Langues",
"translationValidation.field.filePath": "Fichier",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Revalider",
"translationValidation.revalidating": "Revalidation…",
"translationValidation.fix": "Corriger les problèmes",
"translationValidation.fixing": "Correction…",
"translationValidation.toast.fixSuccess": "{dbRows} lignes DB et {files} fichiers supprimés, {flushed} traductions écrites sur disque",
"translationValidation.error.fix": "Échec de la correction des traductions invalides",
"menuEditor.tabTitle": "Menu du blog",
"menuEditor.title": "Éditeur du menu du blog",
"menuEditor.description": "Gérez la structure centrale de navigation du blog et enregistrez-la dans meta/menu.opml.",
@@ -419,7 +446,7 @@
"metadataDiff.orphanFiles.badge": "Fichier orphelin",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Chemin",
"metadataDiff.orphanFiles.importButton": "D \u2192 BD",
"metadataDiff.orphanFiles.importButton": "D 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}",
@@ -461,6 +488,7 @@
"sidebar.published": "Publiés",
"sidebar.archived": "Archivés",
"sidebar.untitled": "Sans titre",
"sidebar.languagesAvailable": "{count} langues disponibles",
"sidebar.noMatchingPosts": "Aucun article correspondant",
"sidebar.createFirstPost": "Créer votre premier article",
"sidebar.loadMore": "Charger plus ({loaded} sur {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Langue principale",
"settings.project.mainLanguageDescription": "Langue principale de votre contenu. Les titres, textes alternatifs et légendes générés par lIA utiliseront cette langue.",
"settings.project.blogLanguagesLabel": "Langues du blog",
"settings.project.blogLanguagesDescription": "Langues dans lesquelles le blog est rendu. La langue principale est toujours incluse. Les langues supplémentaires génèrent des sous-arborescences traduites.",
"settings.project.defaultAuthorLabel": "Auteur par défaut",
"settings.project.defaultAuthorDescription": "Nom dauteur par défaut pour les nouveaux articles et médias. Peut être remplacé par élément.",
"settings.project.defaultAuthorPlaceholder": "Nom de lauteur",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Aperçu de larticle",
"editor.previewLoading": "Chargement de l'aperçu...",
"editor.metadata.toggle": "Métadonnées",
"editor.translations.title": "Traductions",
"editor.translations.currentLanguage": "Langue actuelle : {language}",
"editor.translations.none": "Aucune traduction pour le moment.",
"editor.translations.selectTarget": "Sélectionner la langue cible",
"editor.translations.translateButton": "Traduire vers...",
"editor.translations.translateTitle": "Créer ou actualiser une traduction avec lIA",
"editor.translations.translating": "Traduction...",
"editor.translations.refresh": "Actualiser",
"editor.translations.refreshTitle": "Regénérer cette traduction avec lIA",
"editor.translations.publish": "Publier",
"editor.translations.publishTitle": "Publier cette traduction dans son fichier Markdown",
"editor.translations.publishing": "Publication...",
"editor.translations.missing": "Manquantes : {languages}",
"editor.translations.complete": "Toutes les langues de traduction prises en charge sont disponibles.",
"editor.translations.translateSuccess": "Traduction mise à jour pour {language}",
"editor.translations.translateFailed": "La traduction a échoué",
"editor.translations.publishSuccess": "Traduction publiée pour {language}",
"editor.translations.publishFailed": "Échec de la publication de la traduction",
"editor.translations.status.draft": "Brouillon",
"editor.translations.status.published": "Publié",
"editor.translations.status.archived": "Archivé",
"editor.excerpt.toggle": "Extrait",
"editor.footer.created": "Créé",
"editor.footer.updated": "Mis à jour",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "✨ Analyser avec lIA",
"editor.media.quickActions.aiTitle": "Titre suggéré par lIA",
"editor.media.quickActions.aiDescription": "Générez automatiquement un titre, un texte alternatif et une légende.",
"editor.media.quickActions.detectLanguageTitle": "Détecter la langue",
"editor.media.quickActions.detectLanguageDescription": "Détecter la langue des métadonnées avec lIA",
"editor.media.quickActions.translateTitle": "Traduire en…",
"editor.media.quickActions.translateDescription": "Créer ou actualiser une traduction avec lIA",
"editor.media.translations.currentLanguage": "Langue actuelle : {language}",
"editor.media.translations.selectTarget": "Sélectionner la langue cible",
"editor.post.quickActions.title": "Actions rapides",
"editor.post.quickActions.analyzing": "⏳ Analyse…",
"editor.post.quickActions.button": "⚡ Actions rapides",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Légende",
"editor.media.field.tags": "Tags",
"editor.media.field.author": "Auteur",
"editor.media.field.language": "Langue",
"editor.media.field.languageNone": "Non défini",
"editor.media.translations.title": "Traductions",
"editor.media.translations.none": "Aucune traduction pour le moment.",
"editor.media.translations.translateButton": "Traduire en…",
"editor.media.translations.translating": "Traduction…",
"editor.media.translations.translateSuccess": "Traduction mise à jour pour {language}",
"editor.media.translations.translateFailed": "Échec de la traduction",
"editor.media.translations.refresh": "Actualiser",
"editor.media.translations.refreshTitle": "Régénérer cette traduction par IA",
"editor.media.translations.deleteTitle": "Supprimer cette traduction",
"editor.media.translations.deleted": "Traduction supprimée pour {language}",
"editor.media.translations.deleteFailed": "Échec de la suppression de la traduction",
"editor.media.translations.editTitle": "Modifier la traduction — {language}",
"editor.media.translations.saved": "Traduction enregistrée pour {language}",
"editor.media.translations.saveFailed": "Échec de lenregistrement de la traduction",
"editor.media.toast.languageDetected": "Langue détectée : {language}",
"editor.media.error.detectLanguage": "Échec de la détection de la langue",
"editor.media.placeholder.title": "Saisissez un titre",
"editor.media.placeholder.altText": "Décrivez limage pour laccessibilité",
"editor.media.placeholder.caption": "Ajouter une légende",
@@ -1130,5 +1205,9 @@
"duplicatesView.checkAll": "Tout cocher",
"duplicatesView.uncheckAll": "Tout décocher",
"duplicatesView.dismissChecked": "Ignorer cochés ({count})",
"duplicatesView.notEnabled": "La similarité sémantique n'est pas activée. Activez-la dans Paramètres → Technologie."
"duplicatesView.notEnabled": "La similarité sémantique n'est pas activée. Activez-la dans Paramètres → Technologie.",
"editor.doNotTranslateLabel": "Ne pas traduire",
"blog.fillMissing.nothingToDo": "Toutes les traductions sont à jour.",
"blog.fillMissing.started": "Tâche de traduction démarrée. Consultez le panneau des tâches pour le progrès.",
"blog.fillMissing.error": "Échec du remplissage des traductions manquantes."
}

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "Validazione del sito non riuscita",
"siteValidation.error.apply": "Applicazione della validazione non riuscita",
"siteValidation.toast.applySuccess": "Validazione applicata: {rendered} renderizzati, {deleted} eliminati",
"menu.item.validateTranslations": "Valida traduzioni",
"translationValidation.tabTitle": "Validazione traduzioni",
"translationValidation.title": "Valida traduzioni",
"translationValidation.summary": "Righe DB controllate: {dbRows} · File controllati: {files} · Righe DB non valide: {invalidDb} · File non validi: {invalidFiles}",
"translationValidation.loading": "Validazione traduzioni in corso...",
"translationValidation.empty": "Esegui Blog -> Valida traduzioni per controllare lintegrità delle traduzioni.",
"translationValidation.databaseTitle": "Righe di traduzione non valide nel database",
"translationValidation.filesystemTitle": "File di traduzione non validi sul disco",
"translationValidation.noneDatabase": "Nessuna riga di traduzione non valida trovata.",
"translationValidation.noneFilesystem": "Nessun file di traduzione non valido trovato.",
"translationValidation.error.validate": "Validazione traduzioni non riuscita",
"translationValidation.issue.sameLanguage": "La lingua della traduzione coincide con la lingua canonica del post",
"translationValidation.issue.missingSource": "La traduzione punta a un post sorgente mancante",
"translationValidation.issue.doNotTranslate": "Il post è contrassegnato come non-tradurre ma ha traduzioni",
"translationValidation.issue.contentInDatabase": "Traduzione pubblicata con contenuto nel DB invece del filesystem",
"translationValidation.field.translationFor": "Post sorgente",
"translationValidation.field.translationId": "Riga traduzione",
"translationValidation.field.title": "Titolo",
"translationValidation.field.languages": "Lingue",
"translationValidation.field.filePath": "File",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Rivalidare",
"translationValidation.revalidating": "Rivalidazione…",
"translationValidation.fix": "Correggi problemi",
"translationValidation.fixing": "Correzione…",
"translationValidation.toast.fixSuccess": "{dbRows} righe DB e {files} file eliminati, {flushed} traduzioni scritte su disco",
"translationValidation.error.fix": "Correzione delle traduzioni non valide fallita",
"menuEditor.tabTitle": "Menu blog",
"menuEditor.title": "Editor del menu blog",
"menuEditor.description": "Gestisci la struttura centrale di navigazione del blog e salvala in meta/menu.opml.",
@@ -419,18 +446,18 @@
"metadataDiff.orphanFiles.badge": "File orfano",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Percorso",
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
"metadataDiff.orphanFiles.importButton": "D 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.short": "DBF",
"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.short": "FDB",
"metadataDiff.sync.fileToDb.success": "Sincronizzati {success} file nel database{fallito}",
"metadataDiff.sync.fileToDb.error": "Impossibile sincronizzare nel database",
"metadataDiff.value.database": "Database locale",
@@ -461,6 +488,7 @@
"sidebar.published": "Pubblicati",
"sidebar.archived": "Archiviati",
"sidebar.untitled": "Senza titolo",
"sidebar.languagesAvailable": "{count} lingue disponibili",
"sidebar.noMatchingPosts": "Nessun post corrispondente",
"sidebar.createFirstPost": "Crea il tuo primo post",
"sidebar.loadMore": "Carica altro ({loaded} di {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Lingua principale",
"settings.project.mainLanguageDescription": "Lingua principale dei contenuti del blog. Titoli, alt text e didascalie generate dallIA useranno questa lingua.",
"settings.project.blogLanguagesLabel": "Lingue del blog",
"settings.project.blogLanguagesDescription": "Lingue in cui viene generato il blog. La lingua principale è sempre inclusa. Le lingue aggiuntive generano sottocartelle tradotte.",
"settings.project.defaultAuthorLabel": "Autore predefinito",
"settings.project.defaultAuthorDescription": "Nome autore predefinito per nuovi post e media. Può essere modificato per singolo elemento.",
"settings.project.defaultAuthorPlaceholder": "Nome autore",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Anteprima post",
"editor.previewLoading": "Caricamento anteprima...",
"editor.metadata.toggle": "Metadati",
"editor.translations.title": "Traduzioni",
"editor.translations.currentLanguage": "Lingua corrente: {language}",
"editor.translations.none": "Nessuna traduzione disponibile.",
"editor.translations.selectTarget": "Seleziona lingua di destinazione",
"editor.translations.translateButton": "Traduci in...",
"editor.translations.translateTitle": "Crea o aggiorna una traduzione con l'IA",
"editor.translations.translating": "Traduzione in corso...",
"editor.translations.refresh": "Aggiorna",
"editor.translations.refreshTitle": "Rigenera questa traduzione con l'IA",
"editor.translations.publish": "Pubblica",
"editor.translations.publishTitle": "Pubblica questa traduzione nel suo file Markdown",
"editor.translations.publishing": "Pubblicazione...",
"editor.translations.missing": "Mancanti: {languages}",
"editor.translations.complete": "Tutte le lingue di traduzione supportate sono disponibili.",
"editor.translations.translateSuccess": "Traduzione aggiornata per {language}",
"editor.translations.translateFailed": "Traduzione non riuscita",
"editor.translations.publishSuccess": "Traduzione pubblicata per {language}",
"editor.translations.publishFailed": "Pubblicazione della traduzione non riuscita",
"editor.translations.status.draft": "Bozza",
"editor.translations.status.published": "Pubblicato",
"editor.translations.status.archived": "Archiviato",
"editor.excerpt.toggle": "Estratto",
"editor.footer.created": "Creato",
"editor.footer.updated": "Aggiornato",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "✨ Analizza con IA",
"editor.media.quickActions.aiTitle": "Titolo suggerito dallIA",
"editor.media.quickActions.aiDescription": "Genera automaticamente titolo, testo alternativo e didascalia.",
"editor.media.quickActions.detectLanguageTitle": "Rileva lingua",
"editor.media.quickActions.detectLanguageDescription": "Rileva la lingua dai metadati con lIA",
"editor.media.quickActions.translateTitle": "Traduci in…",
"editor.media.quickActions.translateDescription": "Crea o aggiorna una traduzione con lIA",
"editor.media.translations.currentLanguage": "Lingua corrente: {language}",
"editor.media.translations.selectTarget": "Seleziona la lingua di destinazione",
"editor.post.quickActions.title": "Azioni rapide",
"editor.post.quickActions.analyzing": "⏳ Analisi…",
"editor.post.quickActions.button": "⚡ Azioni rapide",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Didascalia",
"editor.media.field.tags": "Tag",
"editor.media.field.author": "Autore",
"editor.media.field.language": "Lingua",
"editor.media.field.languageNone": "Non impostata",
"editor.media.translations.title": "Traduzioni",
"editor.media.translations.none": "Nessuna traduzione ancora.",
"editor.media.translations.translateButton": "Traduci in…",
"editor.media.translations.translating": "Traduzione…",
"editor.media.translations.translateSuccess": "Traduzione aggiornata per {language}",
"editor.media.translations.translateFailed": "Traduzione fallita",
"editor.media.translations.refresh": "Aggiorna",
"editor.media.translations.refreshTitle": "Rigenera questa traduzione tramite IA",
"editor.media.translations.deleteTitle": "Elimina questa traduzione",
"editor.media.translations.deleted": "Traduzione eliminata per {language}",
"editor.media.translations.deleteFailed": "Eliminazione della traduzione fallita",
"editor.media.translations.editTitle": "Modifica traduzione — {language}",
"editor.media.translations.saved": "Traduzione salvata per {language}",
"editor.media.translations.saveFailed": "Salvataggio della traduzione fallito",
"editor.media.toast.languageDetected": "Lingua rilevata: {language}",
"editor.media.error.detectLanguage": "Rilevamento della lingua fallito",
"editor.media.placeholder.title": "Inserisci un titolo",
"editor.media.placeholder.altText": "Descrivi limmagine per laccessibilità",
"editor.media.placeholder.caption": "Aggiungi una didascalia",
@@ -1130,5 +1205,9 @@
"duplicatesView.checkAll": "Seleziona tutto",
"duplicatesView.uncheckAll": "Deseleziona tutto",
"duplicatesView.dismissChecked": "Ignora selezionati ({count})",
"duplicatesView.notEnabled": "La similarità semantica non è abilitata. Abilitala in Impostazioni → Tecnologia."
"duplicatesView.notEnabled": "La similarità semantica non è abilitata. Abilitala in Impostazioni → Tecnologia.",
"editor.doNotTranslateLabel": "Non tradurre",
"blog.fillMissing.nothingToDo": "Tutte le traduzioni sono aggiornate.",
"blog.fillMissing.started": "Attività di traduzione avviata. Controlla il pannello attività per il progresso.",
"blog.fillMissing.error": "Impossibile completare le traduzioni mancanti."
}

View File

@@ -16,6 +16,7 @@ export type EditorRoute =
| 'documentation'
| 'api-documentation'
| 'site-validation'
| 'translation-validation'
| 'scripts'
| 'templates'
| 'find-duplicates';
@@ -34,6 +35,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
documentation: 'documentation',
'api-documentation': 'api-documentation',
'site-validation': 'site-validation',
'translation-validation': 'translation-validation',
scripts: 'scripts',
templates: 'templates',
'find-duplicates': 'find-duplicates',

View File

@@ -8,6 +8,7 @@ export interface CreateAndFocusPostOptions {
setSelectedPost: (postId: string) => void;
ensurePostsSidebar?: () => void;
onError?: (error: unknown) => void;
categories?: string[];
}
export async function createAndFocusPost(options: CreateAndFocusPostOptions): Promise<string | null> {
@@ -16,7 +17,7 @@ export async function createAndFocusPost(options: CreateAndFocusPostOptions): Pr
title: '',
content: '',
tags: [],
categories: [],
categories: options.categories ?? [],
});
if (!post) {

View File

@@ -10,6 +10,7 @@ export type SingletonToolTabKey =
| 'api-documentation'
| 'metadata-diff'
| 'site-validation'
| 'translation-validation'
| 'find-duplicates';
export interface CanonicalTabSpec {
@@ -34,6 +35,7 @@ const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec>
'api-documentation': { type: 'api-documentation', id: 'api-documentation', isTransient: false },
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false },
'translation-validation': { type: 'translation-validation', id: 'translation-validation', isTransient: false },
'find-duplicates': { type: 'find-duplicates', id: 'find-duplicates', isTransient: false },
};

View File

@@ -0,0 +1,24 @@
import type { TranslationValidationReport } from '../../main/shared/electronApi';
const TRANSLATION_VALIDATION_REPORT_PREFIX = 'bds-translation-validation-report';
function buildStorageKey(projectId: string): string {
return `${TRANSLATION_VALIDATION_REPORT_PREFIX}:${projectId}`;
}
export function persistTranslationValidationReport(projectId: string, report: TranslationValidationReport): void {
localStorage.setItem(buildStorageKey(projectId), JSON.stringify(report));
}
export function getPersistedTranslationValidationReport(projectId: string): TranslationValidationReport | null {
const raw = localStorage.getItem(buildStorageKey(projectId));
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as TranslationValidationReport;
} catch {
return null;
}
}

View File

@@ -13,7 +13,7 @@ import type {
const STORAGE_KEY = 'bds-app-state';
// Tab types
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts' | 'templates' | 'find-duplicates';
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'translation-validation' | 'scripts' | 'templates' | 'find-duplicates';
export interface Tab {
type: TabType;

View File

@@ -4,7 +4,8 @@ export const BDS_EVENT_TEMPLATES_CHANGED = 'bds:templates-changed' as const;
export type BdsWindowEventName =
| typeof BDS_EVENT_SCRIPTS_CHANGED
| typeof BDS_EVENT_TEMPLATES_CHANGED
| 'bds:site-validation-updated';
| 'bds:site-validation-updated'
| 'bds:translation-validation-updated';
export function addWindowEventListener<TDetail = unknown>(
eventName: BdsWindowEventName,