feat(translate): add translation cache with source_hash dedup + enforce dictionary post-processing
- Add source_hash column (SHA256 of source_text+source_data+dict_hash+config_hash) to TranslationRecord for cache dedup - Add Alembic migration b1c2d3e4f5a6 for the new column and composite index (source_hash, status) - Implement _compute_source_hash / _check_translation_cache in executor.py — before LLM call, check if same source+dict+config combo was already translated successfully - Implement cache-aware routing in _process_batch: full cache hit → skip LLM, partial hit → route to LLM - Store source_hash on all new TranslationRecord rows for future cache hits - Strengthen dictionary prompt instructions from 'use when applicable' to 'MUST use — mandatory' - Add _enforce_dictionary() post-processing: force-replace dictionary terms in LLM output if LLM ignored them - Fix CRITICAL: use run.dict_snapshot_hash/config_hash (exist on TranslationRun, not TranslationJob) - Fix MAJOR: verify cached languages cover ALL target_languages before accepting cache hit - Fix MAJOR: prevent regex back-reference injection in dictionary enforcement (use lambda) - Fix: add joinedload to cache lookup to avoid N+1 queries - Fix: remove redundant single-column index (composite index is sufficient) - Fix(frontend): parse paginated /translate/dictionaries response correctly (result.items)
This commit is contained in:
@@ -81,12 +81,17 @@
|
||||
admin: "nav.admin",
|
||||
settings: "nav.settings",
|
||||
git: "nav.git",
|
||||
translate: "translate.jobs.title",
|
||||
};
|
||||
|
||||
if (specialCases[segment]) {
|
||||
return _(specialCases[segment]) || segment;
|
||||
}
|
||||
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) {
|
||||
return _('translate.config.breadcrumb_job') || 'Job';
|
||||
}
|
||||
|
||||
return segment
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
let nextExecutions = $state([]);
|
||||
let hasPriorRun = $state(true);
|
||||
let scheduleExists = $state(false);
|
||||
let isEditing = $derived(uxState === 'editing');
|
||||
|
||||
$effect(() => {
|
||||
if (jobId) loadSchedule();
|
||||
@@ -190,11 +191,13 @@
|
||||
type="text"
|
||||
bind:value={cronExpression}
|
||||
placeholder="0 2 * * *"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500"
|
||||
disabled={!isEditing}
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-500"
|
||||
/>
|
||||
<select
|
||||
bind:value={cronExpression}
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
disabled={!isEditing}
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg text-sm disabled:bg-gray-50 disabled:text-gray-500"
|
||||
>
|
||||
<option value="0 2 * * *">{$t.translate?.schedule?.preset_daily || 'Daily at 02:00'}</option>
|
||||
<option value="0 */6 * * *">{$t.translate?.schedule?.preset_every_6h || 'Every 6 hours'}</option>
|
||||
@@ -213,7 +216,8 @@
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.schedule?.timezone || 'Timezone'}</label>
|
||||
<select
|
||||
bind:value={timezone}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
disabled={!isEditing}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm disabled:bg-gray-50 disabled:text-gray-500"
|
||||
>
|
||||
{#each commonTimezones as tz}
|
||||
<option value={tz}>{tz}</option>
|
||||
@@ -226,15 +230,15 @@
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.schedule?.execution_mode || 'Execution Mode'}</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<input type="radio" bind:group={executionMode} value="full" class="sr-only peer" />
|
||||
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400">
|
||||
<input type="radio" bind:group={executionMode} value="full" class="sr-only peer" disabled={!isEditing} />
|
||||
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400 {isEditing ? '' : 'opacity-70'}">
|
||||
<div class="font-medium">{$t.translate?.schedule?.mode_full || 'Full'}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">{$t.translate?.schedule?.mode_full_desc || 'Translate all rows every run'}</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<input type="radio" bind:group={executionMode} value="new_key_only" class="sr-only peer" />
|
||||
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400">
|
||||
<input type="radio" bind:group={executionMode} value="new_key_only" class="sr-only peer" disabled={!isEditing} />
|
||||
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400 {isEditing ? '' : 'opacity-70'}">
|
||||
<div class="font-medium">{$t.translate?.schedule?.mode_new_keys || 'New Keys Only'}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">{$t.translate?.schedule?.mode_new_keys_desc || 'Only translate rows with new key values'}</div>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,15 @@
|
||||
let pendingCount = $derived(records.filter(r => r.status === 'PENDING').length);
|
||||
let isAllApproved = $derived(pendingCount === 0 && records.length > 0);
|
||||
|
||||
function getRowStatusLabel(status) {
|
||||
const labels = {
|
||||
APPROVED: $t.translate?.preview?.status_approved || 'Approved',
|
||||
REJECTED: $t.translate?.preview?.status_rejected || 'Rejected',
|
||||
PENDING: $t.translate?.preview?.status_pending || 'Pending',
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
/** Get language status for a record and language code */
|
||||
function getLangStatus(record, langCode) {
|
||||
const lang = (record.languages || []).find(l => l.language_code === langCode);
|
||||
@@ -131,6 +140,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} rowId @param {string} langCode */
|
||||
async function handleApproveLanguage(rowId, langCode) {
|
||||
try {
|
||||
const updated = await approveRow(jobId, rowId, langCode);
|
||||
records = records.map(r => r.id === rowId ? {
|
||||
...r,
|
||||
status: updated.status || r.status,
|
||||
target_sql: updated.target_sql || r.target_sql,
|
||||
languages: updated.languages || r.languages,
|
||||
_isEdited: false
|
||||
} : r);
|
||||
} catch (err) {
|
||||
addToast(err?.message || $t.translate?.preview?.approve_failed, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} rowId @param {string} langCode */
|
||||
async function handleRejectLanguage(rowId, langCode) {
|
||||
try {
|
||||
const updated = await rejectRow(jobId, rowId, langCode);
|
||||
records = records.map(r => r.id === rowId ? {
|
||||
...r,
|
||||
status: updated.status || r.status,
|
||||
languages: updated.languages || r.languages,
|
||||
_isEdited: false
|
||||
} : r);
|
||||
} catch (err) {
|
||||
addToast(err?.message || $t.translate?.preview?.reject_failed, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} rowId @param {string} langCode */
|
||||
function startLangEdit(rowId, langCode) {
|
||||
const key = `${rowId}_${langCode}`;
|
||||
@@ -427,7 +467,7 @@
|
||||
<div class="flex gap-1 mt-1 flex-wrap">
|
||||
{#if langStatus !== 'approved' && langStatus !== 'edited'}
|
||||
<button
|
||||
onclick={() => approveRow(jobId, row.id, lang)}
|
||||
onclick={() => handleApproveLanguage(row.id, lang)}
|
||||
class="px-1.5 py-0.5 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors"
|
||||
title={$t.translate?.preview?.approve}
|
||||
>
|
||||
@@ -443,7 +483,7 @@
|
||||
</button>
|
||||
{#if langStatus !== 'rejected'}
|
||||
<button
|
||||
onclick={() => rejectRow(jobId, row.id, lang)}
|
||||
onclick={() => handleRejectLanguage(row.id, lang)}
|
||||
class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
|
||||
title={$t.translate?.preview?.reject}
|
||||
>
|
||||
@@ -461,7 +501,7 @@
|
||||
{row.status === 'REJECTED' ? 'bg-red-100 text-red-700' : ''}
|
||||
{row.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' : ''}
|
||||
">
|
||||
{row.status}
|
||||
{getRowStatusLabel(row.status)}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
{#if row.status !== 'APPROVED'}
|
||||
@@ -528,4 +568,4 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion TranslationPreview -->
|
||||
<!-- #endregion TranslationPreview -->
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
import CorrectionCell from './CorrectionCell.svelte';
|
||||
import TermCorrectionPopup from './TermCorrectionPopup.svelte';
|
||||
import BulkReplaceModal from './BulkReplaceModal.svelte';
|
||||
import BulkCorrectionSidebar from './BulkCorrectionSidebar.svelte';
|
||||
|
||||
/** @type {{ runId: string, onRefresh: Function }} */
|
||||
let { runId, onRefresh = () => {} } = $props();
|
||||
@@ -65,7 +64,17 @@
|
||||
let targetLanguages = $state([]);
|
||||
let correctionPopupData = $state(null);
|
||||
let showBulkReplace = $state(false);
|
||||
let bulkCorrectionItems = $state([]);
|
||||
|
||||
function getRunStatusLabel(value) {
|
||||
const labels = {
|
||||
COMPLETED: $t.translate?.run?.completed || 'Completed',
|
||||
FAILED: $t.translate?.run?.translation_failed || 'Failed',
|
||||
RUNNING: $t.translate?.history?.status_running || 'Running',
|
||||
PENDING: $t.translate?.history?.status_pending || 'Pending',
|
||||
CANCELLED: $t.translate?.run?.cancelled || 'Cancelled',
|
||||
};
|
||||
return labels[value] || value;
|
||||
}
|
||||
|
||||
// Derived
|
||||
let insertStatusBadge = $derived.by(() => {
|
||||
@@ -211,12 +220,6 @@
|
||||
{isRetryingInsert ? $t.translate?.run?.retrying_insert : $t.translate?.run?.retry_insert}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => showBulkReplace = true}
|
||||
class="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
{$t.translate?.run?.bulk_replace || 'Bulk Replace'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -278,6 +281,10 @@
|
||||
{$t.translate?.run?.copy_id}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">{$t.translate?.run?.status_label || 'Статус'}</span>
|
||||
<span class="ml-1 text-xs font-medium text-gray-700">{getRunStatusLabel(status.status)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">{$t.translate?.run?.insert_status}</span>
|
||||
<span class="ml-1 inline-flex px-2 py-0.5 text-xs rounded-full font-medium {insertStatusBadge.class}">
|
||||
@@ -434,45 +441,23 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-center align-top">
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
onclick={() => {
|
||||
const firstLang = row.languages?.[0] || {};
|
||||
correctionPopupData = {
|
||||
sourceTerm: row.source_sql || row.source_object_name || '',
|
||||
incorrectTarget: firstLang?.final_value || firstLang?.translated_value || '',
|
||||
sourceLanguage: firstLang?.source_language_detected || row.source_language_detected || '',
|
||||
targetLanguage: targetLanguages[0] || '',
|
||||
runId,
|
||||
rowKey: row.id
|
||||
};
|
||||
}}
|
||||
class="px-2 py-0.5 text-[10px] bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
title="Submit Correction"
|
||||
>
|
||||
{$t.translate?.run?.correct || 'Correct'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
const firstLang = row.languages?.[0] || {};
|
||||
const item = {
|
||||
sourceTerm: row.source_sql || row.source_object_name || '',
|
||||
incorrectTarget: firstLang?.final_value || firstLang?.translated_value || '',
|
||||
rowKey: row.id
|
||||
};
|
||||
const exists = bulkCorrectionItems.some(
|
||||
i => i.sourceTerm === item.sourceTerm && i.incorrectTarget === item.incorrectTarget
|
||||
);
|
||||
if (!exists) {
|
||||
bulkCorrectionItems = [...bulkCorrectionItems, item];
|
||||
}
|
||||
}}
|
||||
class="px-2 py-0.5 text-[10px] bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors"
|
||||
title="Collect for bulk correction"
|
||||
>
|
||||
{$t.translate?.run?.collect || 'Collect'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => {
|
||||
const firstLang = row.languages?.[0] || {};
|
||||
correctionPopupData = {
|
||||
sourceTerm: row.source_sql || row.source_object_name || '',
|
||||
incorrectTarget: firstLang?.final_value || firstLang?.translated_value || '',
|
||||
sourceLanguage: firstLang?.source_language_detected || row.source_language_detected || '',
|
||||
targetLanguage: targetLanguages[0] || '',
|
||||
runId,
|
||||
rowKey: row.id
|
||||
};
|
||||
}}
|
||||
class="px-2 py-0.5 text-[10px] bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
title="Submit Correction"
|
||||
>
|
||||
{$t.translate?.run?.correct || 'Correct'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -500,11 +485,4 @@
|
||||
onClose={() => showBulkReplace = false}
|
||||
onApplied={(count) => { showBulkReplace = false; loadData(); onRefresh(); }}
|
||||
/>
|
||||
|
||||
<BulkCorrectionSidebar
|
||||
{runId}
|
||||
initialItems={bulkCorrectionItems}
|
||||
onClose={() => bulkCorrectionItems = []}
|
||||
onSubmitted={() => { bulkCorrectionItems = []; loadData(); onRefresh(); }}
|
||||
/>
|
||||
<!-- #endregion TranslationRunResult -->
|
||||
|
||||
@@ -55,8 +55,15 @@
|
||||
"target_schema_placeholder": "e.g. public",
|
||||
"target_table_placeholder": "e.g. users_translated",
|
||||
"target_language": "Target Language",
|
||||
"target_language_search_placeholder": "Search languages...",
|
||||
"target_language_required": "Select at least one target language",
|
||||
"status": "Status",
|
||||
"status_draft": "Draft",
|
||||
"status_ready": "Ready",
|
||||
"status_active": "Active",
|
||||
"target_column_placeholder": "Target Column",
|
||||
"target_column_mapping_title": "Target Column Mapping",
|
||||
"target_column_mapping_description": "Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.",
|
||||
"target_column_label": "Target Column (translated text)",
|
||||
"target_column_hint": "Column where translated text will be inserted",
|
||||
"target_language_column_label": "Language Column",
|
||||
@@ -108,7 +115,10 @@
|
||||
"include_source_reference": "Include source language in translations",
|
||||
"include_source_reference_hint": "The original text will be stored as a verified reference copy in its detected language",
|
||||
"disable_reasoning": "Disable reasoning (save tokens)",
|
||||
"disable_reasoning_hint": "Saves output tokens by suppressing Chain of Thought reasoning"
|
||||
"disable_reasoning_hint": "Saves output tokens by suppressing Chain of Thought reasoning",
|
||||
"mark_ready": "Mark as READY",
|
||||
"save_job_first": "Save the job first",
|
||||
"breadcrumb_job": "Job"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview",
|
||||
@@ -170,7 +180,10 @@
|
||||
"detected_language": "Detected Lang",
|
||||
"languages_count": "Languages: {count}",
|
||||
"across_languages": "Across {count} language(s)",
|
||||
"language_label": "Language"
|
||||
"language_label": "Language",
|
||||
"status_approved": "Approved",
|
||||
"status_rejected": "Rejected",
|
||||
"status_pending": "Pending"
|
||||
},
|
||||
"jobs": {
|
||||
"title": "Translation Jobs",
|
||||
@@ -256,6 +269,7 @@
|
||||
"next_executions": "Next Executions",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"tab_label": "Schedule",
|
||||
"edit": "Edit",
|
||||
"update": "Update",
|
||||
"create": "Create",
|
||||
@@ -450,7 +464,9 @@
|
||||
"failed_label": "{count} failed",
|
||||
"skipped_label": "{count} skipped",
|
||||
"tokens_label": "{count} tokens",
|
||||
"load_failed": "Failed to load run status"
|
||||
"load_failed": "Failed to load run status",
|
||||
"bulk_replace": "Bulk Replace",
|
||||
"status_label": "Status"
|
||||
},
|
||||
"corrections": {
|
||||
"title": "Bulk Corrections",
|
||||
|
||||
@@ -55,8 +55,15 @@
|
||||
"target_schema_placeholder": "например, public",
|
||||
"target_table_placeholder": "например, users_translated",
|
||||
"target_language": "Язык перевода",
|
||||
"target_language_search_placeholder": "Поиск языков...",
|
||||
"target_language_required": "Выберите хотя бы один язык перевода",
|
||||
"status": "Статус",
|
||||
"status_draft": "Черновик",
|
||||
"status_ready": "Готово",
|
||||
"status_active": "Активно",
|
||||
"target_column_placeholder": "Целевая колонка",
|
||||
"target_column_mapping_title": "Маппинг целевых колонок",
|
||||
"target_column_mapping_description": "Настройте, в какие колонки писать перевод, коды языков, исходный текст и определённый язык источника при INSERT.",
|
||||
"target_column_label": "Целевая колонка (переведённый текст)",
|
||||
"target_column_hint": "Колонка, в которую будет вставлен переведённый текст",
|
||||
"target_language_column_label": "Колонка языка",
|
||||
@@ -108,7 +115,10 @@
|
||||
"include_source_reference": "Сохранять исходный текст как эталонную копию",
|
||||
"include_source_reference_hint": "Исходный текст будет сохранён как верифицированная эталонная копия на его обнаруженном языке",
|
||||
"disable_reasoning": "Отключить рассуждения (экономия токенов)",
|
||||
"disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)"
|
||||
"disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)",
|
||||
"mark_ready": "Пометить как готово",
|
||||
"save_job_first": "Сначала сохраните задание",
|
||||
"breadcrumb_job": "Задание"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Предпросмотр",
|
||||
@@ -171,6 +181,10 @@
|
||||
"languages_count": "Языков: {count}",
|
||||
"across_languages": "Для {count} язык(ов)",
|
||||
"language_label": "Язык"
|
||||
,
|
||||
"status_approved": "Одобрено",
|
||||
"status_rejected": "Отклонено",
|
||||
"status_pending": "Ожидает"
|
||||
},
|
||||
"jobs": {
|
||||
"title": "Задания перевода",
|
||||
@@ -256,6 +270,7 @@
|
||||
"next_executions": "Следующие выполнения",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Отключено",
|
||||
"tab_label": "Расписание",
|
||||
"edit": "Редактировать",
|
||||
"update": "Обновить",
|
||||
"create": "Создать",
|
||||
@@ -450,7 +465,9 @@
|
||||
"failed_label": "{count} с ошибками",
|
||||
"skipped_label": "{count} пропущено",
|
||||
"tokens_label": "{count} токенов",
|
||||
"load_failed": "Не удалось загрузить статус запуска"
|
||||
"load_failed": "Не удалось загрузить статус запуска",
|
||||
"bulk_replace": "Массовая замена",
|
||||
"status_label": "Статус"
|
||||
},
|
||||
"corrections": {
|
||||
"title": "Массовые исправления",
|
||||
|
||||
@@ -58,33 +58,35 @@
|
||||
clearOnCompleteCallback,
|
||||
} from '$lib/stores/translationRun.js';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'ru', name: 'Russian' },
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'it', name: 'Italian' },
|
||||
{ code: 'pt', name: 'Portuguese' },
|
||||
{ code: 'zh', name: 'Chinese' },
|
||||
{ code: 'ja', name: 'Japanese' },
|
||||
{ code: 'ko', name: 'Korean' },
|
||||
{ code: 'ar', name: 'Arabic' },
|
||||
{ code: 'tr', name: 'Turkish' },
|
||||
{ code: 'nl', name: 'Dutch' },
|
||||
{ code: 'pl', name: 'Polish' },
|
||||
{ code: 'sv', name: 'Swedish' },
|
||||
{ code: 'da', name: 'Danish' },
|
||||
{ code: 'fi', name: 'Finnish' },
|
||||
{ code: 'cs', name: 'Czech' },
|
||||
{ code: 'hu', name: 'Hungarian' },
|
||||
{ code: 'ro', name: 'Romanian' },
|
||||
{ code: 'vi', name: 'Vietnamese' },
|
||||
{ code: 'th', name: 'Thai' },
|
||||
{ code: 'he', name: 'Hebrew' },
|
||||
{ code: 'id', name: 'Indonesian' },
|
||||
{ code: 'ms', name: 'Malay' },
|
||||
];
|
||||
const LANGUAGE_LABELS = {
|
||||
ru: 'Русский',
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
fr: 'Français',
|
||||
es: 'Español',
|
||||
it: 'Italiano',
|
||||
pt: 'Português',
|
||||
zh: '中文',
|
||||
ja: '日本語',
|
||||
ko: '한국어',
|
||||
ar: 'العربية',
|
||||
tr: 'Türkçe',
|
||||
nl: 'Nederlands',
|
||||
pl: 'Polski',
|
||||
sv: 'Svenska',
|
||||
da: 'Dansk',
|
||||
fi: 'Suomi',
|
||||
cs: 'Čeština',
|
||||
hu: 'Magyar',
|
||||
ro: 'Română',
|
||||
vi: 'Tiếng Việt',
|
||||
th: 'ไทย',
|
||||
he: 'עברית',
|
||||
id: 'Bahasa Indonesia',
|
||||
ms: 'Bahasa Melayu',
|
||||
};
|
||||
|
||||
const LANGUAGES = Object.entries(LANGUAGE_LABELS).map(([code, name]) => ({ code, name }));
|
||||
|
||||
/** @type {string} idle | loading | configured | saving | validation_error | datasource_unavailable */
|
||||
let uxState = $state('idle');
|
||||
@@ -147,7 +149,7 @@
|
||||
{ id: 'run', label: $t.translate?.config?.run_translation || 'Run' },
|
||||
];
|
||||
if (!isNewJob && existingJob) {
|
||||
base.push({ id: 'schedule', label: 'Schedule' });
|
||||
base.push({ id: 'schedule', label: $t.translate?.schedule?.tab_label || 'Schedule' });
|
||||
}
|
||||
return base;
|
||||
});
|
||||
@@ -160,6 +162,12 @@
|
||||
let isSaving = $state(false);
|
||||
let validationErrors = $state({});
|
||||
let warnings = $state([]);
|
||||
let pageTitle = $derived.by(() => {
|
||||
if (isNewJob) return $t.translate?.config?.new_title || 'New Translation Job';
|
||||
const jobName = existingJob?.name || name;
|
||||
const base = $t.translate?.config?.edit_title || 'Edit Translation Job';
|
||||
return jobName ? `${jobName} | ${base}` : base;
|
||||
});
|
||||
|
||||
// Run state — using global store to survive page navigation
|
||||
let runState = fromStore(translationRunStore);
|
||||
@@ -272,8 +280,8 @@
|
||||
|
||||
// Load dictionaries (from available translate dictionaries)
|
||||
try {
|
||||
const dicts = await api.fetchApi('/translate/dictionaries').catch(() => []);
|
||||
availableDictionaries = Array.isArray(dicts) ? dicts : [];
|
||||
const result = await api.fetchApi('/translate/dictionaries').catch(() => ({}));
|
||||
availableDictionaries = Array.isArray(result) ? result : (result?.items || []);
|
||||
} catch (_e) {
|
||||
availableDictionaries = [];
|
||||
}
|
||||
@@ -610,8 +618,25 @@
|
||||
function isVirtual(col) {
|
||||
return virtualColumnNames.includes(col);
|
||||
}
|
||||
|
||||
function getJobStatusLabel(value) {
|
||||
const labels = {
|
||||
DRAFT: $t.translate?.config?.status_draft || 'Draft',
|
||||
READY: $t.translate?.config?.status_ready || 'Ready',
|
||||
ACTIVE: $t.translate?.config?.status_active || 'Active',
|
||||
RUNNING: $t.translate?.jobs?.status_running || 'Running',
|
||||
COMPLETED: $t.translate?.jobs?.status_completed || 'Completed',
|
||||
FAILED: $t.translate?.jobs?.status_failed || 'Failed',
|
||||
CANCELLED: $t.translate?.history?.status_cancelled || 'Cancelled',
|
||||
};
|
||||
return labels[value] || value || ($t.translate?.config?.status_draft || 'Draft');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-full xl:max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
@@ -1031,8 +1056,8 @@
|
||||
|
||||
<!-- Target Column Mapping -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-100">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Target Column Mapping</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.</p>
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">{$t.translate?.config?.target_column_mapping_title || 'Target Column Mapping'}</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">{$t.translate?.config?.target_column_mapping_description || 'Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.'}</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
@@ -1105,11 +1130,11 @@
|
||||
options={LANGUAGES}
|
||||
bind:selected={targetLanguages}
|
||||
searchable={true}
|
||||
placeholder="Search languages..."
|
||||
placeholder={$t.translate?.config?.target_language_search_placeholder || 'Search languages...'}
|
||||
required={true}
|
||||
/>
|
||||
{#if targetLanguages.length === 0}
|
||||
<p class="text-xs text-amber-600 mt-1">Select at least one target language</p>
|
||||
<p class="text-xs text-amber-600 mt-1">{$t.translate?.config?.target_language_required || 'Select at least one target language'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
@@ -1225,7 +1250,7 @@
|
||||
</section>
|
||||
{:else}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-gray-500">{$t.translate?.config?.save} {$t.translate?.config?.basic_info?.toLowerCase?.() || 'Save the job first'}</p>
|
||||
<p class="text-sm text-gray-500">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1242,10 +1267,11 @@
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{status === 'READY' ? 'bg-green-100 text-green-700' : ''}
|
||||
{status === 'DRAFT' ? 'bg-yellow-100 text-yellow-700' : ''}
|
||||
{status === 'ACTIVE' ? 'bg-emerald-100 text-emerald-700' : ''}
|
||||
{status === 'RUNNING' ? 'bg-blue-100 text-blue-700' : ''}
|
||||
{status === 'COMPLETED' ? 'bg-green-100 text-green-700' : ''}
|
||||
{status === 'FAILED' ? 'bg-red-100 text-red-700' : ''}">
|
||||
{status || 'DRAFT'}
|
||||
{getJobStatusLabel(status)}
|
||||
</span>
|
||||
{#if status === 'DRAFT'}
|
||||
<button
|
||||
@@ -1260,7 +1286,7 @@
|
||||
}}
|
||||
class="ml-auto px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Mark as READY
|
||||
{$t.translate?.config?.mark_ready || 'Mark as READY'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1350,7 +1376,7 @@
|
||||
</section>
|
||||
{:else}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-gray-500">{$t.translate?.config?.save} {$t.translate?.config?.basic_info?.toLowerCase?.() || 'Save the job first'}</p>
|
||||
<p class="text-sm text-gray-500">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user