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:
@@ -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 {
|
||||
|
||||
@@ -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
863
src/renderer/components/Editor/MediaEditor.tsx
Normal file
863
src/renderer/components/Editor/MediaEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1668
src/renderer/components/Editor/PostEditor.tsx
Normal file
1668
src/renderer/components/Editor/PostEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
12
src/renderer/components/Editor/editorUtils.ts
Normal file
12
src/renderer/components/Editor/editorUtils.ts
Normal 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;
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { TranslationValidationView } from './TranslationValidationView';
|
||||
@@ -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": "DB→D",
|
||||
"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": "D→DB",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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": "DB→F",
|
||||
"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": "F→DB",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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": "BD→A",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -54,6 +54,33 @@
|
||||
"siteValidation.error.validate": "Échec de la validation du site",
|
||||
"siteValidation.error.apply": "Échec de l’application 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 l’inté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 l’article",
|
||||
"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 l’IA 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 d’auteur par défaut pour les nouveaux articles et médias. Peut être remplacé par élément.",
|
||||
"settings.project.defaultAuthorPlaceholder": "Nom de l’auteur",
|
||||
@@ -578,6 +608,27 @@
|
||||
"editor.previewFrameTitle": "Aperçu de l’article",
|
||||
"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 l’IA",
|
||||
"editor.translations.translating": "Traduction...",
|
||||
"editor.translations.refresh": "Actualiser",
|
||||
"editor.translations.refreshTitle": "Regénérer cette traduction avec l’IA",
|
||||
"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 l’IA",
|
||||
"editor.media.quickActions.aiTitle": "Titre suggéré par l’IA",
|
||||
"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 l’IA",
|
||||
"editor.media.quickActions.translateTitle": "Traduire en…",
|
||||
"editor.media.quickActions.translateDescription": "Créer ou actualiser une traduction avec l’IA",
|
||||
"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 l’enregistrement 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 l’image pour l’accessibilité",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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 l’integrità 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": "DB→F",
|
||||
"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": "F→DB",
|
||||
"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 dall’IA 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 dall’IA",
|
||||
"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 l’IA",
|
||||
"editor.media.quickActions.translateTitle": "Traduci in…",
|
||||
"editor.media.quickActions.translateDescription": "Crea o aggiorna una traduzione con l’IA",
|
||||
"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 l’immagine per l’accessibilità",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
24
src/renderer/navigation/translationValidationPersistence.ts
Normal file
24
src/renderer/navigation/translationValidationPersistence.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user