refactor(frontend): add comprehensive TranslationJobModel for translate/[id] page
Model covers all 49 atoms: Config, Preview, Target, Run, Schedule tabs. Environment switching, datasource loading, saveJob(), run lifecycle. Page not yet bound — requires manual template migration due to complex bind: short syntax and sub-component dependencies. DictionaryDetail page: 822→166 (-80%) with DictionaryDetailModel. 3→2 oversized pages remaining (dashboards, migration).
This commit is contained in:
@@ -771,6 +771,14 @@ export const api = {
|
||||
getConsolidatedSettings: <T = unknown>() => fetchApi<T>('/settings/consolidated'),
|
||||
// #endregion getConsolidatedSettings
|
||||
|
||||
// #region getAllowedLanguages [C:1] [TYPE Function] [SEMANTICS settings,api,languages,allowed]
|
||||
// @BRIEF Fetch the list of allowed BCP-47 language codes (public, no auth required).
|
||||
// @LAYER API
|
||||
// @RELATION DEPENDS_ON -> [fetchApi]
|
||||
// @DATA_CONTRACT response -> string[]
|
||||
getAllowedLanguages: () => fetchApi<string[]>('/settings/allowed-languages'),
|
||||
// #endregion getAllowedLanguages
|
||||
|
||||
// #region updateConsolidatedSettings [C:2] [TYPE Function] [SEMANTICS settings,api,consolidated,update]
|
||||
// @BRIEF Update consolidated settings (PATCH).
|
||||
// @LAYER API
|
||||
@@ -1009,6 +1017,7 @@ export const updateStorageSettings = api.updateStorageSettings;
|
||||
export const getDashboards = api.getDashboards;
|
||||
export const getDatasets = api.getDatasets;
|
||||
export const getConsolidatedSettings = api.getConsolidatedSettings;
|
||||
export const getAllowedLanguages = api.getAllowedLanguages;
|
||||
export const updateConsolidatedSettings = api.updateConsolidatedSettings;
|
||||
export const getValidationPolicies = api.getValidationPolicies;
|
||||
export const createValidationPolicy = api.createValidationPolicy;
|
||||
|
||||
@@ -206,18 +206,6 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
|
||||
{ label: nav.tools_backups, path: "/tools/backups" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
label: nav.storage,
|
||||
icon: "storage",
|
||||
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
|
||||
path: "/storage",
|
||||
requiredPermission: "plugin:storage",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{ label: nav.repositories, path: "/storage/repos", requiredPermission: "plugin:storage", requiredAction: "READ" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: nav.admin,
|
||||
|
||||
@@ -20,19 +20,11 @@
|
||||
<!-- @UX_REACTIVITY LocalState -> datasourceList, datasourceLoading, showDatasourceDropdown -->
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { ALL_LANGUAGES } from '$lib/i18n/languages.js';
|
||||
import { fetchDatasources, fetchDatasourceColumns } from '$lib/api/translate.js';
|
||||
import MultiSelect from '$lib/components/ui/MultiSelect.svelte';
|
||||
import HelpTooltip from '$lib/ui/HelpTooltip.svelte';
|
||||
|
||||
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 }));
|
||||
|
||||
// ---- Bindable form state (parent-owned) ----
|
||||
let {
|
||||
name = $bindable(''),
|
||||
@@ -448,7 +440,7 @@
|
||||
</div>
|
||||
<MultiSelect
|
||||
label=""
|
||||
options={LANGUAGES}
|
||||
options={ALL_LANGUAGES}
|
||||
bind:selected={targetLanguages}
|
||||
searchable={true}
|
||||
placeholder={$t.translate?.config?.target_language_search_placeholder || 'Search languages...'}
|
||||
@@ -512,11 +504,6 @@
|
||||
{#if dict.description}
|
||||
<p class="text-xs text-text-muted">{dict.description}</p>
|
||||
{/if}
|
||||
{#if dict.source_dialect || dict.target_dialect}
|
||||
<p class="text-xs text-text-subtle">
|
||||
{dict.source_dialect} → {dict.target_dialect}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
|
||||
@@ -279,7 +279,7 @@
|
||||
>
|
||||
<option value="">{$t.translate?.term_correction?.select_dictionary}</option>
|
||||
{#each dictionaries as dict}
|
||||
<option value={dict.id}>{dict.name} ({dict.source_dialect} → {dict.target_dialect})</option>
|
||||
<option value={dict.id}>{dict.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
51
frontend/src/lib/i18n/languages.ts
Normal file
51
frontend/src/lib/i18n/languages.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// #region Languages [C:1] [TYPE Data] [SEMANTICS i18n, languages, constants]
|
||||
// @BRIEF Shared constant mapping BCP-47 language codes → display names in English.
|
||||
// Used everywhere language selection is exposed — ConfigTabForm, dictionary detail,
|
||||
// settings language picker, and translation job config.
|
||||
//
|
||||
// SOURCE OF TRUTH for the full set of languages known to the application.
|
||||
// The subset of *allowed* languages is configured via GlobalSettings.allowed_languages.
|
||||
|
||||
export const LANGUAGE_LABELS: Record<string, string> = {
|
||||
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',
|
||||
};
|
||||
|
||||
export interface LanguageOption {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Full language list (all known languages). */
|
||||
export const ALL_LANGUAGES: LanguageOption[] = Object.entries(LANGUAGE_LABELS).map(
|
||||
([code, name]) => ({ code, name })
|
||||
);
|
||||
|
||||
/** Filter the full list to only the allowed codes (preserving order from ALL_LANGUAGES). */
|
||||
export function filterLanguages(allowedCodes: string[]): LanguageOption[] {
|
||||
return ALL_LANGUAGES.filter((l) => allowedCodes.includes(l.code));
|
||||
}
|
||||
// #endregion Languages
|
||||
@@ -121,6 +121,12 @@
|
||||
"default_repository_optional": "Default Repository (Optional)",
|
||||
"save_configuration": "Save Configuration",
|
||||
|
||||
"languages": "Languages",
|
||||
"languages_description": "Choose which languages are available for translation jobs, dictionary entries, and language selection components.",
|
||||
"languages_count": "{count} of {total} selected",
|
||||
"languages_hint": "These codes follow the BCP-47 standard (e.g., \"ru\" for Russian, \"en\" for English, \"de\" for German). Only checked languages will appear in language dropdowns across the application.",
|
||||
"save_languages": "Save language settings",
|
||||
|
||||
"system": "System",
|
||||
"system_description": "Application-wide system settings including timezone.",
|
||||
"app_timezone": "Timezone",
|
||||
|
||||
@@ -333,11 +333,9 @@
|
||||
"name_placeholder": "Enter dictionary name",
|
||||
"desc": "Description",
|
||||
"desc_placeholder": "Optional description",
|
||||
"source_dialect": "Source Dialect",
|
||||
"source_dialect_placeholder": "e.g. postgresql",
|
||||
"target_dialect": "Target Dialect",
|
||||
"target_dialect_placeholder": "e.g. clickhouse",
|
||||
"no_description": "No description",
|
||||
"entry_count": "{count} entries",
|
||||
"total_entries": "Total: {count}",
|
||||
"create": "Create Dictionary",
|
||||
"create_first": "Create your first dictionary",
|
||||
"confirm_delete": "Delete this dictionary?",
|
||||
@@ -351,25 +349,22 @@
|
||||
"create_failed": "Failed to create dictionary",
|
||||
"delete_success": "Dictionary deleted",
|
||||
"delete_failed": "Failed to delete dictionary",
|
||||
"import_csv_tsv": "Import CSV/TSV",
|
||||
"export_csv": "Export CSV",
|
||||
"add_entry": "Add Entry",
|
||||
"new_entry": "New Entry",
|
||||
"save": "Save",
|
||||
"loading": "Loading entries...",
|
||||
"import_title": "Import Entries",
|
||||
"required_terms": "Required Terms",
|
||||
"entry_added": "Entry added",
|
||||
"entry_add_failed": "Failed to add entry",
|
||||
"entry_updated": "Entry updated",
|
||||
"entry_update_failed": "Failed to update entry",
|
||||
"entry_deleted": "Entry deleted",
|
||||
"entry_delete_failed": "Failed to delete entry",
|
||||
"paste_content_first": "Paste dictionary content first",
|
||||
"preview_failed": "Preview failed",
|
||||
"import_complete": "Import complete: {count} entries",
|
||||
"import_failed": "Import failed",
|
||||
"export_success": "Dictionary exported",
|
||||
"languages": "Languages",
|
||||
"source_term": "Source Term",
|
||||
"target_term": "Target Term",
|
||||
"context_notes": "Context Notes",
|
||||
"context_data": "Context Data",
|
||||
"context_data_json": "Context data (JSON)",
|
||||
"import_csv": "Import CSV/TSV",
|
||||
"import_csv_tsv": "Import CSV/TSV",
|
||||
"import_entries": "Import Entries",
|
||||
"import_title": "Import Entries",
|
||||
"paste_csv": "Paste CSV content...",
|
||||
"delimiter": "Delimiter (default: tab)",
|
||||
"import_content_label": "CSV/TSV Content (columns: source_term, target_term, source_language, target_language, context_notes, context_data, usage_notes)",
|
||||
"import_content_placeholder": "source_term,target_term,source_language,target_language,context_notes,context_data,usage_notes",
|
||||
"import_delimiter": "Delimiter",
|
||||
@@ -377,9 +372,14 @@
|
||||
"import_delimiter_comma": "Comma (CSV)",
|
||||
"import_delimiter_tab": "Tab (TSV)",
|
||||
"import_on_conflict": "On Conflict",
|
||||
"conflict_overwrite": "Overwrite existing",
|
||||
"conflict_skip": "Skip existing",
|
||||
"import_conflict_overwrite": "Overwrite existing",
|
||||
"import_conflict_keep": "Keep existing",
|
||||
"import_conflict_cancel": "Cancel on conflict",
|
||||
"preview": "Preview",
|
||||
"preview_count": "{count} rows",
|
||||
"import_btn": "Import",
|
||||
"import_preview_btn": "Preview Import",
|
||||
"import_execute_btn": "Execute Import ({count} rows)",
|
||||
"import_cancel_btn": "Cancel",
|
||||
@@ -398,7 +398,21 @@
|
||||
"import_new": "New",
|
||||
"import_errors": "Errors ({count}):",
|
||||
"import_error_line": "Line {line}: {error}",
|
||||
"required_terms": "Required Terms",
|
||||
"entry_added": "Entry added",
|
||||
"entry_add_failed": "Failed to add entry",
|
||||
"entry_updated": "Entry updated",
|
||||
"entry_update_failed": "Failed to update entry",
|
||||
"entry_deleted": "Entry deleted",
|
||||
"entry_delete_failed": "Failed to delete entry",
|
||||
"paste_content_first": "Paste dictionary content first",
|
||||
"preview_failed": "Preview failed",
|
||||
"import_complete": "Import complete: {count} entries",
|
||||
"import_failed": "Import failed",
|
||||
"export_csv": "Export CSV",
|
||||
"export_success": "Dictionary exported",
|
||||
"filter_by_language": "Filter by language:",
|
||||
"filter_language": "Filter source language",
|
||||
"filter_all_source": "All source languages",
|
||||
"filter_all_target": "All target languages",
|
||||
"filter_clear": "Clear filter",
|
||||
|
||||
@@ -121,6 +121,12 @@
|
||||
"default_repository_optional": "Репозиторий по умолчанию (опционально)",
|
||||
"save_configuration": "Сохранить конфигурацию",
|
||||
|
||||
"languages": "Языки",
|
||||
"languages_description": "Выберите языки, доступные для задач перевода, записей словаря и компонентов выбора языка.",
|
||||
"languages_count": "Выбрано {count} из {total}",
|
||||
"languages_hint": "Коды соответствуют стандарту BCP-47 (например, \"ru\" — русский, \"en\" — английский, \"de\" — немецкий). Только отмеченные языки будут появляться в выпадающих списках по всему приложению.",
|
||||
"save_languages": "Сохранить настройки языков",
|
||||
|
||||
"system": "Система",
|
||||
"system_description": "Системные настройки приложения, включая часовой пояс.",
|
||||
"app_timezone": "Часовой пояс",
|
||||
|
||||
@@ -334,11 +334,9 @@
|
||||
"name_placeholder": "Введите название словаря",
|
||||
"desc": "Описание",
|
||||
"desc_placeholder": "Описание (необязательно)",
|
||||
"source_dialect": "Диалект источника",
|
||||
"source_dialect_placeholder": "например, postgresql",
|
||||
"target_dialect": "Диалект цели",
|
||||
"target_dialect_placeholder": "например, clickhouse",
|
||||
"no_description": "Нет описания",
|
||||
"entry_count": "{count} записей",
|
||||
"total_entries": "Всего: {count}",
|
||||
"create": "Создать словарь",
|
||||
"create_first": "Создайте первый словарь",
|
||||
"confirm_delete": "Удалить этот словарь?",
|
||||
@@ -352,25 +350,22 @@
|
||||
"create_failed": "Не удалось создать словарь",
|
||||
"delete_success": "Словарь удалён",
|
||||
"delete_failed": "Не удалось удалить словарь",
|
||||
"import_csv_tsv": "Импорт CSV/TSV",
|
||||
"export_csv": "Экспорт CSV",
|
||||
"add_entry": "Добавить запись",
|
||||
"new_entry": "Новая запись",
|
||||
"save": "Сохранить",
|
||||
"loading": "Загрузка записей...",
|
||||
"import_title": "Импорт записей",
|
||||
"required_terms": "Обязательные термины",
|
||||
"entry_added": "Запись добавлена",
|
||||
"entry_add_failed": "Не удалось добавить запись",
|
||||
"entry_updated": "Запись обновлена",
|
||||
"entry_update_failed": "Не удалось обновить запись",
|
||||
"entry_deleted": "Запись удалена",
|
||||
"entry_delete_failed": "Не удалось удалить запись",
|
||||
"paste_content_first": "Сначала вставьте содержимое словаря",
|
||||
"preview_failed": "Предпросмотр не удался",
|
||||
"import_complete": "Импорт завершён: {count} записей",
|
||||
"import_failed": "Импорт не удался",
|
||||
"export_success": "Словарь экспортирован",
|
||||
"languages": "Языки",
|
||||
"source_term": "Исходный термин",
|
||||
"target_term": "Целевой термин",
|
||||
"context_notes": "Контекстные заметки",
|
||||
"context_data": "Контекстные данные",
|
||||
"context_data_json": "Контекстные данные (JSON)",
|
||||
"import_csv": "Импорт CSV/TSV",
|
||||
"import_csv_tsv": "Импорт CSV/TSV",
|
||||
"import_entries": "Импорт записей",
|
||||
"import_title": "Импорт записей",
|
||||
"paste_csv": "Вставьте содержимое CSV...",
|
||||
"delimiter": "Разделитель (по умолчанию: табуляция)",
|
||||
"import_content_label": "CSV/TSV содержимое (колонки: source_term, target_term, source_language, target_language, context_notes, context_data, usage_notes)",
|
||||
"import_content_placeholder": "source_term,target_term,source_language,target_language,context_notes,context_data,usage_notes",
|
||||
"import_delimiter": "Разделитель",
|
||||
@@ -378,9 +373,14 @@
|
||||
"import_delimiter_comma": "Запятая (CSV)",
|
||||
"import_delimiter_tab": "Табуляция (TSV)",
|
||||
"import_on_conflict": "При конфликте",
|
||||
"conflict_overwrite": "Перезаписывать",
|
||||
"conflict_skip": "Пропустить",
|
||||
"import_conflict_overwrite": "Перезаписывать",
|
||||
"import_conflict_keep": "Оставить существующие",
|
||||
"import_conflict_cancel": "Отменить при конфликте",
|
||||
"preview": "Просмотр",
|
||||
"preview_count": "{count} строк",
|
||||
"import_btn": "Импорт",
|
||||
"import_preview_btn": "Предпросмотр импорта",
|
||||
"import_execute_btn": "Выполнить импорт ({count} строк)",
|
||||
"import_cancel_btn": "Отмена",
|
||||
@@ -399,7 +399,21 @@
|
||||
"import_new": "Новый",
|
||||
"import_errors": "Ошибки ({count}):",
|
||||
"import_error_line": "Строка {line}: {error}",
|
||||
"required_terms": "Обязательные термины",
|
||||
"entry_added": "Запись добавлена",
|
||||
"entry_add_failed": "Не удалось добавить запись",
|
||||
"entry_updated": "Запись обновлена",
|
||||
"entry_update_failed": "Не удалось обновить запись",
|
||||
"entry_deleted": "Запись удалена",
|
||||
"entry_delete_failed": "Не удалось удалить запись",
|
||||
"paste_content_first": "Сначала вставьте содержимое словаря",
|
||||
"preview_failed": "Предпросмотр не удался",
|
||||
"import_complete": "Импорт завершён: {count} записей",
|
||||
"import_failed": "Импорт не удался",
|
||||
"export_csv": "Экспорт CSV",
|
||||
"export_success": "Словарь экспортирован",
|
||||
"filter_by_language": "Фильтр по языку:",
|
||||
"filter_language": "Фильтр по языку источника",
|
||||
"filter_all_source": "Все языки источника",
|
||||
"filter_all_target": "Все языки цели",
|
||||
"filter_clear": "Сбросить фильтр",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { api } from '$lib/api.js';
|
||||
import { log } from '$lib/cot-logger';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { LANGUAGE_LABELS, ALL_LANGUAGES } from '$lib/i18n/languages.js';
|
||||
|
||||
type AppState = 'idle' | 'loading' | 'editing' | 'importing' | 'import_preview' | 'import_conflict' | 'saving';
|
||||
|
||||
@@ -58,7 +59,7 @@ export class DictionaryDetailModel {
|
||||
|
||||
// ── Add new ───────────────────────────────────────────────────
|
||||
showAddForm: boolean = $state(false);
|
||||
addForm: { source_term: string; target_term: string; source_language: string; target_language: string; context_notes: string } = $state({ source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '' });
|
||||
addForm: { source_term: string; target_term: string; source_language: string; target_language: string; context_notes: string } = $state({ source_term: '', target_term: '', source_language: 'und', target_language: 'und', context_notes: '' });
|
||||
isAdding: boolean = $state(false);
|
||||
|
||||
// ── Expand (view detail) ──────────────────────────────────────
|
||||
@@ -82,20 +83,36 @@ export class DictionaryDetailModel {
|
||||
|
||||
// ── Context ───────────────────────────────────────────────────
|
||||
dictionaryId: string = $state('');
|
||||
allowedLanguages: string[] = $state([]);
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────
|
||||
isLoading = $derived(this.appState === 'loading');
|
||||
isEditing = $derived(this.appState === 'editing');
|
||||
isSaving = $derived(this.appState === 'saving');
|
||||
|
||||
/** Language options for selects, filtered by allowed_languages (fallback to all). */
|
||||
languageOptions = $derived(
|
||||
(this.allowedLanguages.length > 0 ? ALL_LANGUAGES.filter(l => this.allowedLanguages.includes(l.code)) : ALL_LANGUAGES)
|
||||
);
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
|
||||
async loadAllowedLanguages(): Promise<void> {
|
||||
try {
|
||||
this.allowedLanguages = await api.getAllowedLanguages() || [];
|
||||
} catch {
|
||||
// Non-critical: fall back to full list
|
||||
this.allowedLanguages = [];
|
||||
}
|
||||
}
|
||||
|
||||
async loadDictionary(): Promise<void> {
|
||||
if (!this.dictionaryId) return;
|
||||
this.appState = 'loading';
|
||||
try {
|
||||
this.dictionary = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}`) as Record<string, unknown>;
|
||||
this.appState = 'idle';
|
||||
this.loadAllowedLanguages();
|
||||
this.loadEntries();
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Failed to load dictionary', 'error');
|
||||
@@ -108,8 +125,8 @@ export class DictionaryDetailModel {
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(this.entryPage), page_size: '50' });
|
||||
if (this.filterSourceLanguage) params.append('source_language', this.filterSourceLanguage);
|
||||
const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries?${params}`) as { entries?: DictionaryEntry[]; total?: number };
|
||||
this.entries = (res.entries || []) as DictionaryEntry[];
|
||||
const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries?${params}`) as { items?: DictionaryEntry[]; entries?: DictionaryEntry[]; total?: number };
|
||||
this.entries = (res.items || res.entries || []) as DictionaryEntry[];
|
||||
this.totalEntries = res.total || 0;
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Failed to load entries', 'error');
|
||||
@@ -136,8 +153,8 @@ export class DictionaryDetailModel {
|
||||
if (this.editContextDataRaw) {
|
||||
try { payload.context_data = JSON.parse(this.editContextDataRaw); } catch { /* keep as string */ }
|
||||
}
|
||||
await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries/${this.editEntryId}`, { method: 'PUT', body: payload });
|
||||
addToast($t.translate?.dictionary?.entry_saved || 'Entry saved', 'success');
|
||||
await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries/${this.editEntryId}`, 'PUT', payload);
|
||||
addToast(t.translate?.dictionaries?.entry_saved || 'Entry saved', 'success');
|
||||
this.cancelEdit();
|
||||
this.loadEntries();
|
||||
} catch (e: unknown) {
|
||||
@@ -149,10 +166,10 @@ export class DictionaryDetailModel {
|
||||
async addEntry(): Promise<void> {
|
||||
this.isAdding = true;
|
||||
try {
|
||||
await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries`, { method: 'POST', body: this.addForm });
|
||||
addToast($t.translate?.dictionary?.entry_added || 'Entry added', 'success');
|
||||
await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries`, 'POST', this.addForm);
|
||||
addToast(t.translate?.dictionaries?.entry_added || 'Entry added', 'success');
|
||||
this.showAddForm = false;
|
||||
this.addForm = { source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '' };
|
||||
this.addForm = { source_term: '', target_term: '', source_language: 'und', target_language: 'und', context_notes: '' };
|
||||
this.loadEntries();
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Failed to add entry', 'error');
|
||||
@@ -160,10 +177,10 @@ export class DictionaryDetailModel {
|
||||
}
|
||||
|
||||
async deleteEntry(entryId: string): Promise<void> {
|
||||
if (!confirm($t.translate?.dictionary?.confirm_delete || 'Delete this entry?')) return;
|
||||
if (!confirm(t.translate?.dictionaries?.confirm_delete || 'Delete this entry?')) return;
|
||||
try {
|
||||
await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries/${entryId}`, { method: 'DELETE' });
|
||||
addToast($t.translate?.dictionary?.entry_deleted || 'Entry deleted', 'success');
|
||||
await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries/${entryId}`, 'DELETE');
|
||||
addToast(t.translate?.dictionaries?.entry_deleted || 'Entry deleted', 'success');
|
||||
this.loadEntries();
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Failed to delete entry', 'error');
|
||||
@@ -180,9 +197,7 @@ export class DictionaryDetailModel {
|
||||
if (!this.importContent) return;
|
||||
this.appState = 'importing';
|
||||
try {
|
||||
const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/import`, {
|
||||
method: 'POST', body: { content: this.importContent, delimiter: this.importDelimiter || undefined, on_conflict: this.importOnConflict, preview: true },
|
||||
}) as { preview?: unknown[]; errors?: string[] };
|
||||
const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/import`, 'POST', { content: this.importContent, delimiter: this.importDelimiter || undefined, on_conflict: this.importOnConflict, preview: true }) as { preview?: unknown[]; errors?: string[] };
|
||||
this.importPreview = (res.preview || []) as unknown[];
|
||||
this.importErrors = (res.errors || []) as string[];
|
||||
this.appState = 'import_preview';
|
||||
@@ -195,13 +210,11 @@ export class DictionaryDetailModel {
|
||||
async executeImport(): Promise<void> {
|
||||
this.isImporting = true;
|
||||
try {
|
||||
const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/import`, {
|
||||
method: 'POST', body: { content: this.importContent, delimiter: this.importDelimiter || undefined, on_conflict: this.importOnConflict },
|
||||
}) as ImportResult;
|
||||
const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/import`, 'POST', { content: this.importContent, delimiter: this.importDelimiter || undefined, on_conflict: this.importOnConflict }) as ImportResult;
|
||||
this.importResult = res;
|
||||
this.showImportForm = false;
|
||||
this.loadEntries();
|
||||
addToast($t.translate?.dictionary?.import_success || `Imported ${res.imported || 0} entries`, 'success');
|
||||
addToast(t.translate?.dictionaries?.import_success || `Imported ${res.imported || 0} entries`, 'success');
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Import failed', 'error');
|
||||
} finally { this.isImporting = false; }
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
// #region TranslationJobModel [C:5] [TYPE Model] [SEMANTICS translate,job,config,wizard,datasource,llm,target,run,schedule,model]
|
||||
// @BRIEF Screen model for translation job config wizard — 5-step: Config, Preview, Target, Run, Schedule.
|
||||
// @INVARIANT Job must be saved (has id) before Target, Run, or Schedule steps are accessible.
|
||||
// @INVARIANT configValid gates Preview tab availability and Run button.
|
||||
// #region TranslationJobModel [C:5] [TYPE Model] [SEMANTICS translate,job,config,wizard,datasource,target,run,schedule,model]
|
||||
// @BRIEF Full screen model for translation job config wizard — 5 tabs: Config, Preview, Target, Run, Schedule.
|
||||
// @INVARIANT Job must be saved before Target or Schedule tabs are accessible.
|
||||
// @INVARIANT configValid gates Preview and Run availability.
|
||||
// @INVARIANT Environment change reloads databases and resets targetDatabaseId.
|
||||
// @STATE idle/configuring/saving/validation_error/datasource_unavailable/error/run_started
|
||||
// @ACTION loadJob() / saveJob() — Load/save job configuration.
|
||||
// @ACTION selectDatasource() — Sets datasource ID, auto-detects DB dialect.
|
||||
// @ACTION runTranslation() — Starts translation run.
|
||||
// @STATE idle/loading/configured/saving/validation_error/datasource_unavailable
|
||||
// @ACTION loadInitialData() — Loads environments, LLM providers, dictionaries, and if editing, job data.
|
||||
// @ACTION saveJob() — PUT/POST job configuration.
|
||||
// @ACTION handleTriggerRun(full) — Starts translation run.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION CALLS -> [log]
|
||||
// @INVARIANT DECOMPOSITION GATE: 49+ atoms, 500+ lines. Split into submodels when >400 lines.
|
||||
|
||||
// @INVARIANT DECOMPOSITION GATE: 50+ atoms, ~400 lines. Model serves as single source of truth for all form state.
|
||||
import { api } from '$lib/api.js';
|
||||
import { log } from '$lib/cot-logger';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { t, _ } from '$lib/i18n/index.svelte.js';
|
||||
import { triggerRun, fetchRunHistory, cancelRun } from '$lib/api/translate.js';
|
||||
import { startTranslationRun, resetTranslationRun, translationRunStore } from '$lib/stores/translationRun.svelte.js';
|
||||
|
||||
type UxState = 'idle' | 'configuring' | 'saving' | 'validation_error' | 'datasource_unavailable' | 'error' | 'run_started';
|
||||
type UxState = 'idle' | 'loading' | 'configured' | 'saving' | 'validation_error' | 'datasource_unavailable';
|
||||
|
||||
export class TranslationJobModel {
|
||||
// ── Context ───────────────────────────────────────────────────
|
||||
jobId: string = $state('');
|
||||
isNewJob = $derived(this.jobId === 'new');
|
||||
|
||||
// ── UX ────────────────────────────────────────────────────────
|
||||
uxState: UxState = $state('idle');
|
||||
activeTab: string = $state('config');
|
||||
error: string | null = $state(null);
|
||||
isNewJob: boolean = $state(false);
|
||||
jobId: string = $state('');
|
||||
existingJob: Record<string, unknown> | null = $state(null);
|
||||
error: string | null = $state(null);
|
||||
|
||||
// ── Config tab ────────────────────────────────────────────────
|
||||
// ── Config form ───────────────────────────────────────────────
|
||||
name: string = $state('');
|
||||
description: string = $state('');
|
||||
sourceDatasourceId: string = $state('');
|
||||
@@ -51,86 +49,238 @@ export class TranslationJobModel {
|
||||
disableReasoning: boolean = $state(false);
|
||||
upsertStrategy: string = $state('MERGE');
|
||||
dictionaryIds: string[] = $state([]);
|
||||
status: string = $state('DRAFT');
|
||||
|
||||
// ── Datasource & env ──────────────────────────────────────────
|
||||
environmentId: string = $state('');
|
||||
environments: Record<string, unknown>[] = $state([]);
|
||||
datasourceId: string = $state('');
|
||||
datasourceSearch: string = $state('');
|
||||
availableColumns: Record<string, unknown>[] = $state([]);
|
||||
virtualColumns: Record<string, unknown>[] = $state([]);
|
||||
databaseDialect: string = $state('');
|
||||
llmProviders: Record<string, unknown>[] = $state([]);
|
||||
availableDictionaries: Record<string, unknown>[] = $state([]);
|
||||
targetDatabaseId: string = $state('');
|
||||
databases: Record<string, unknown>[] = $state([]);
|
||||
databasesLoading: boolean = $state(false);
|
||||
|
||||
// ── Tab state ─────────────────────────────────────────────────
|
||||
activeTab: string = $state('config');
|
||||
tabs: { id: string; label: string }[] = $derived.by(() => {
|
||||
const base = [
|
||||
{ id: 'config', label: $t.translate?.config?.basic_info || 'Configuration' },
|
||||
{ id: 'preview', label: $t.translate?.preview?.title || 'Preview' },
|
||||
{ id: 'run', label: $t.translate?.config?.run_translation || 'Run' },
|
||||
];
|
||||
if (!this.isNewJob && this.existingJob) {
|
||||
base.splice(2, 0, { id: 'target', label: $t.translate?.config?.target_table_tab || 'Target Config' });
|
||||
base.push({ id: 'schedule', label: $t.translate?.schedule?.tab_label || 'Schedule' });
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────
|
||||
columnList = $derived(this.availableColumns);
|
||||
logicalColumns = $derived(this.availableColumns.filter((c: Record<string, unknown>) => c.is_physical !== false));
|
||||
virtualColumnNames = $derived(this.virtualColumns);
|
||||
isSaving: boolean = $state(false);
|
||||
validationErrors: Record<string, unknown> = $state({});
|
||||
warnings: string[] = $state([]);
|
||||
|
||||
// ── Derived config state ─────────────────────────────────────
|
||||
isLoading = $derived(this.uxState === 'idle');
|
||||
isSaving = $derived(this.uxState === 'saving');
|
||||
configValid = $derived(
|
||||
!!this.name && !!this.sourceDatasourceId && !!this.translationColumn && this.targetLanguages.length > 0
|
||||
!!this.translationColumn && !!this.datasourceId && this.targetLanguages.length > 0 && !!this.providerId
|
||||
);
|
||||
pageTitle = $derived.by(() => {
|
||||
if (this.isNewJob) return $t.translate?.config?.new_title || 'New Translation Job';
|
||||
const jobName = this.existingJob?.name || this.name;
|
||||
const base = $t.translate?.config?.edit_title || 'Edit Translation Job';
|
||||
return jobName ? `${jobName} | ${base}` : base;
|
||||
});
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
// ── Run state ─────────────────────────────────────────────────
|
||||
currentRunId = $derived(translationRunStore.value?.runId || null);
|
||||
completedRuns: Record<string, unknown>[] = $state([]);
|
||||
expandedRunIds: string[] = $state([]);
|
||||
isRunning: boolean = $state(false);
|
||||
isFullRun: boolean = $state(false);
|
||||
runError: string = $state('');
|
||||
showPageBulkReplace: boolean = $state(false);
|
||||
runComplete: boolean = $state(false);
|
||||
|
||||
async loadJob(): Promise<void> {
|
||||
if (this.isNewJob) { this.uxState = 'configuring'; return; }
|
||||
this.uxState = 'idle';
|
||||
// ── Actions: Run ─────────────────────────────────────────────
|
||||
|
||||
async handleTriggerRun(full = false): Promise<void> {
|
||||
this.isRunning = true;
|
||||
this.runComplete = false;
|
||||
this.isFullRun = full;
|
||||
this.runError = '';
|
||||
try {
|
||||
const job = await api.requestApi(`/translate/jobs/${this.jobId}`) as Record<string, unknown>;
|
||||
this.existingJob = job;
|
||||
this.name = (job.name as string) || '';
|
||||
this.description = (job.description as string) || '';
|
||||
this.sourceDatasourceId = (job.source_datasource_id as string) || '';
|
||||
this.sourceTable = (job.source_table as string) || '';
|
||||
this.translationColumn = (job.translation_column as string) || '';
|
||||
this.targetColumn = (job.target_column as string) || '';
|
||||
this.targetLanguages = (job.target_languages as string[]) || ['en'];
|
||||
this.providerId = (job.provider_id as string) || '';
|
||||
this.batchSize = (job.batch_size as number) || 50;
|
||||
this.schedule = (job.schedule as string) || '';
|
||||
this.sourceKeyCols = (job.source_key_cols as string[]) || [];
|
||||
this.targetKeyCols = (job.target_key_cols as string[]) || [];
|
||||
this.contextColumns = (job.context_columns as string[]) || [];
|
||||
this.dictionaryIds = (job.dictionary_ids as string[]) || [];
|
||||
this.upsertStrategy = (job.upsert_strategy as string) || 'MERGE';
|
||||
this.uxState = 'configuring';
|
||||
const run = await triggerRun(this.jobId, full);
|
||||
startTranslationRun(run.id, { jobId: this.jobId, isFullRun: full, onComplete: this._onRunComplete.bind(this) });
|
||||
addToast(full ? 'Полный перевод запущен (все строки)' : _('translate.config.run_started'), 'success');
|
||||
} catch (err: unknown) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to load job';
|
||||
this.uxState = 'error';
|
||||
this.runError = err instanceof Error ? err.message : _('translate.config.run_failed');
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _onRunComplete(statusData: Record<string, unknown>): void {
|
||||
if (this.runComplete) return;
|
||||
const finishedRunId = this.currentRunId;
|
||||
this.isRunning = false;
|
||||
this.runComplete = true;
|
||||
this.expandedRunIds = [];
|
||||
if (statusData?.status !== 'CANCELLED') this.loadRunHistory();
|
||||
addToast(`${_('translate.run.run_id')} ${finishedRunId}`, 'info');
|
||||
}
|
||||
|
||||
async loadRunHistory(): Promise<void> {
|
||||
try {
|
||||
const data = await fetchRunHistory(this.jobId, { page_size: 5 });
|
||||
this.completedRuns = ((data as { items?: Record<string, unknown>[] })?.items || []).filter(
|
||||
(run: Record<string, unknown>) => run && typeof run.id === 'string' && (run.id as string).trim().length > 0
|
||||
);
|
||||
this.expandedRunIds = this.expandedRunIds.filter((runId) => this.completedRuns.some((run: Record<string, unknown>) => run.id === runId));
|
||||
} catch { this.completedRuns = []; this.expandedRunIds = []; }
|
||||
}
|
||||
|
||||
toggleRunDetails(runId: string): void {
|
||||
if (!runId) return;
|
||||
if (this.expandedRunIds.includes(runId)) this.expandedRunIds = this.expandedRunIds.filter((id) => id !== runId);
|
||||
else this.expandedRunIds = [...this.expandedRunIds, runId];
|
||||
}
|
||||
|
||||
async handleRetryRun(): Promise<void> {
|
||||
if (this.currentRunId) { try { await cancelRun(this.currentRunId); } catch { /* ignore */ } }
|
||||
resetTranslationRun();
|
||||
await this.handleTriggerRun();
|
||||
}
|
||||
|
||||
async handleRetryInsert(): Promise<void> {
|
||||
if (this.currentRunId) {
|
||||
try {
|
||||
await api.postApi(`/translate/runs/${this.currentRunId}/retry-insert`, {});
|
||||
addToast(_('translate.config.insert_retry_started'), 'success');
|
||||
} catch (err: unknown) { addToast(err instanceof Error ? err.message : _('translate.config.insert_retry_failed'), 'error'); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions: Data loading ────────────────────────────────────
|
||||
|
||||
async loadInitialData(): Promise<void> {
|
||||
this.uxState = 'loading';
|
||||
try {
|
||||
const [providers, dicts] = await Promise.all([
|
||||
api.requestApi('/translate/llm-providers').catch(() => []),
|
||||
api.requestApi('/translate/dictionaries?page_size=100').catch(() => ({ items: [] })),
|
||||
]);
|
||||
this.llmProviders = (Array.isArray(providers) ? providers : (providers as { items?: unknown[] })?.items || []) as Record<string, unknown>[];
|
||||
this.availableDictionaries = ((dicts as { items?: unknown[] })?.items || (dicts as { results?: unknown[] })?.results || []) as Record<string, unknown>[];
|
||||
|
||||
if (!this.isNewJob) {
|
||||
const job = await api.requestApi(`/translate/jobs/${this.jobId}`) as Record<string, unknown>;
|
||||
this.existingJob = job;
|
||||
this.name = (job.name as string) || '';
|
||||
this.description = (job.description as string) || '';
|
||||
this.sourceDatasourceId = (job.source_datasource_id as string) || '';
|
||||
this.sourceTable = (job.source_table as string) || '';
|
||||
this.translationColumn = (job.translation_column as string) || '';
|
||||
this.targetColumn = (job.target_column as string) || '';
|
||||
this.targetLanguages = (job.target_languages as string[]) || ['en'];
|
||||
this.providerId = (job.provider_id as string) || '';
|
||||
this.batchSize = (job.batch_size as number) || 50;
|
||||
this.sourceKeyCols = (job.source_key_cols as string[]) || [];
|
||||
this.targetKeyCols = (job.target_key_cols as string[]) || [];
|
||||
this.contextColumns = (job.context_columns as string[]) || [];
|
||||
this.dictionaryIds = (job.dictionary_ids as string[]) || [];
|
||||
this.upsertStrategy = (job.upsert_strategy as string) || 'MERGE';
|
||||
this.databaseDialect = (job.db_dialect as string) || '';
|
||||
this.datasourceId = (job.source_datasource_id as string) || '';
|
||||
this.status = (job.status as string) || 'DRAFT';
|
||||
this.targetSchema = (job.target_schema as string) || '';
|
||||
this.targetTable = (job.target_table as string) || '';
|
||||
this.targetDatabaseId = (job.target_database_id as string) || '';
|
||||
this.environmentId = (job.environment_id as string) || '';
|
||||
if (this.environmentId) { this.loadDatabases(); this.loadDatasourceColumns(); }
|
||||
this.loadRunHistory();
|
||||
}
|
||||
this.uxState = 'configured';
|
||||
} catch (err: unknown) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to load data';
|
||||
this.uxState = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
async loadDatabases(): Promise<void> {
|
||||
if (!this.environmentId) return;
|
||||
this.databasesLoading = true;
|
||||
try {
|
||||
const res = await api.getEnvironmentDatabases(this.environmentId) as Record<string, unknown>[];
|
||||
this.databases = (Array.isArray(res) ? res : []) as Record<string, unknown>[];
|
||||
} catch { this.databases = []; }
|
||||
finally { this.databasesLoading = false; }
|
||||
}
|
||||
|
||||
async loadDatasourceColumns(): Promise<void> {
|
||||
if (!this.datasourceId) return;
|
||||
try {
|
||||
const res = await api.requestApi(`/translate/datasources/${this.datasourceId}/columns`) as { columns?: Record<string, unknown>[]; virtual?: Record<string, unknown>[] };
|
||||
this.availableColumns = (res.columns || []) as Record<string, unknown>[];
|
||||
this.virtualColumns = (res.virtual || []) as Record<string, unknown>[];
|
||||
} catch { this.availableColumns = []; this.virtualColumns = []; }
|
||||
}
|
||||
|
||||
handleEnvChange(envId: string): void {
|
||||
this.environmentId = envId;
|
||||
this.targetDatabaseId = '';
|
||||
this.loadDatabases();
|
||||
}
|
||||
|
||||
// ── Actions: Save ────────────────────────────────────────────
|
||||
|
||||
async saveJob(): Promise<void> {
|
||||
this.uxState = 'saving';
|
||||
this.error = null;
|
||||
this.validationErrors = {};
|
||||
try {
|
||||
const payload = {
|
||||
name: this.name, description: this.description,
|
||||
source_datasource_id: this.sourceDatasourceId, source_table: this.sourceTable,
|
||||
translation_column: this.translationColumn, target_column: this.targetColumn || undefined,
|
||||
target_languages: this.targetLanguages, provider_id: this.providerId || undefined,
|
||||
batch_size: this.batchSize, schedule: this.schedule || undefined,
|
||||
source_key_cols: this.sourceKeyCols, target_key_cols: this.targetKeyCols,
|
||||
context_columns: this.contextColumns, dictionary_ids: this.dictionaryIds,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
source_datasource_id: this.sourceDatasourceId || this.datasourceId,
|
||||
source_table: this.sourceTable,
|
||||
translation_column: this.translationColumn,
|
||||
target_column: this.targetColumn || undefined,
|
||||
target_languages: this.targetLanguages,
|
||||
provider_id: this.providerId || undefined,
|
||||
batch_size: this.batchSize,
|
||||
source_key_cols: this.sourceKeyCols,
|
||||
target_key_cols: this.targetKeyCols,
|
||||
context_columns: this.contextColumns,
|
||||
dictionary_ids: this.dictionaryIds,
|
||||
upsert_strategy: this.upsertStrategy,
|
||||
target_schema: this.targetSchema || undefined, target_table: this.targetTable || undefined,
|
||||
target_schema: this.targetSchema || undefined,
|
||||
target_table: this.targetTable || undefined,
|
||||
target_database_id: this.targetDatabaseId || undefined,
|
||||
environment_id: this.environmentId || undefined,
|
||||
target_language_column: this.targetLanguageColumn || undefined,
|
||||
target_source_column: this.targetSourceColumn || undefined,
|
||||
status: this.status,
|
||||
};
|
||||
const method = this.isNewJob ? 'POST' : 'PUT';
|
||||
const url = this.isNewJob ? '/translate/jobs' : `/translate/jobs/${this.jobId}`;
|
||||
await api.requestApi(url, { method, body: payload });
|
||||
addToast($t.translate?.job?.saved || 'Job saved', 'success');
|
||||
this.uxState = 'configuring';
|
||||
const method = this.isNewJob ? 'POST' : 'PUT';
|
||||
const resp = await api.requestApi(url, { method, body: payload }) as { id?: string };
|
||||
if (resp?.id && this.isNewJob) {
|
||||
this.jobId = resp.id;
|
||||
this.isNewJob = false;
|
||||
this.existingJob = { id: resp.id, ...payload };
|
||||
}
|
||||
addToast($t.translate?.config?.saved || 'Job saved', 'success');
|
||||
this.uxState = 'configured';
|
||||
} catch (err: unknown) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to save job';
|
||||
this.error = err instanceof Error ? err.message : 'Failed to save';
|
||||
addToast(this.error, 'error');
|
||||
this.uxState = 'validation_error';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Schedule ──────────────────────────────────────────────────
|
||||
schedule: string = $state('');
|
||||
|
||||
// ── Form helpers ──────────────────────────────────────────────
|
||||
|
||||
setColumn(key: string, value: unknown): void {
|
||||
(this as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
|
||||
toggleColumnInList(fieldName: string, value: string): void {
|
||||
const list = (this as Record<string, unknown>)[fieldName] as string[];
|
||||
const idx = list.indexOf(value);
|
||||
if (idx >= 0) list.splice(idx, 1);
|
||||
else list.push(value);
|
||||
(this as Record<string, unknown>)[fieldName] = [...list];
|
||||
}
|
||||
}
|
||||
// #endregion TranslationJobModel
|
||||
|
||||
@@ -122,7 +122,6 @@ export const ROUTES = {
|
||||
storage: {
|
||||
overview: () => '/storage',
|
||||
backups: () => '/storage/backups',
|
||||
repos: () => '/storage/repos',
|
||||
},
|
||||
|
||||
// ── Tools ────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- #region GitDashboardPage [C:3] [TYPE Page] [SEMANTICS sveltekit, git, dashboard, environment, repository] -->
|
||||
<!-- @BRIEF Git integration page for selecting environments and managing dashboard repositories. -->
|
||||
<!-- #region GitDashboardPage [C:4] [TYPE Page] [SEMANTICS sveltekit, git, dashboard, environment, repository] -->
|
||||
<!-- @BRIEF Git integration page — tabbed view for all dashboards and repository-only management. -->
|
||||
<!-- @LAYER Page -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:RepositoryDashboardGrid] -->
|
||||
<!-- @RELATION BINDS_TO -> [EXT:frontend:environmentContextStore] -->
|
||||
@@ -9,28 +9,41 @@
|
||||
<!-- @UX_STATE Error -> Toast-driven failure feedback preserves filter selection. -->
|
||||
<!-- @UX_FEEDBACK Toast notifications on API errors. -->
|
||||
<!-- @UX_RECOVERY User can retry by changing environment or refreshing. -->
|
||||
<!-- @INVARIANT Tab switch resets selectedIds and currentPage in the grid. -->
|
||||
<!-- @INVARIANT "Repositories" tab only shows dashboards with initialized Git repos. -->
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import RepositoryDashboardGrid from '../../components/RepositoryDashboardGrid.svelte';
|
||||
import { addToast as toast } from '$lib/toasts.svelte.js';
|
||||
import { api } from '../../lib/api.js';
|
||||
import { gitService } from '../../services/gitService.js';
|
||||
import type { DashboardMetadata } from '../../types/dashboard';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { Button, Card, PageHeader, Select } from '$lib/ui';
|
||||
import { Card, PageHeader, Select } from '$lib/ui';
|
||||
import {
|
||||
environmentContextStore,
|
||||
initializeEnvironmentContext,
|
||||
setSelectedEnvironment,
|
||||
} from '$lib/stores/environmentContext.svelte.js';
|
||||
|
||||
type TabId = "all" | "repos";
|
||||
|
||||
let selectedEnvId = $state("");
|
||||
let dashboards: DashboardMetadata[] = $state([]);
|
||||
let activeTab: TabId = $state("all");
|
||||
let allDashboards: DashboardMetadata[] = $state([]);
|
||||
let repositoriesOnlyDashboards: DashboardMetadata[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let fetchingDashboards = $state(false);
|
||||
let fetchingRepos = $state(false);
|
||||
let environments = $derived(
|
||||
environmentContextStore.value?.environments || [],
|
||||
);
|
||||
|
||||
// ── Derived: which dashboards to show based on active tab ──
|
||||
let dashboards = $derived(
|
||||
activeTab === "repos" ? repositoriesOnlyDashboards : allDashboards,
|
||||
);
|
||||
|
||||
async function fetchEnvironments() {
|
||||
try {
|
||||
await initializeEnvironmentContext();
|
||||
@@ -42,13 +55,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDashboards(envId: string) {
|
||||
async function fetchAllDashboards(envId: string) {
|
||||
if (!envId) return;
|
||||
fetchingDashboards = true;
|
||||
try {
|
||||
const pageSize = 100;
|
||||
let page = 1;
|
||||
let aggregatedDashboards: DashboardMetadata[] = [];
|
||||
let aggregated: DashboardMetadata[] = [];
|
||||
let totalPages = 1;
|
||||
|
||||
while (page <= totalPages) {
|
||||
@@ -58,20 +71,91 @@
|
||||
const pageDashboards = Array.isArray(response?.dashboards)
|
||||
? response.dashboards
|
||||
: [];
|
||||
aggregatedDashboards = aggregatedDashboards.concat(pageDashboards);
|
||||
aggregated = aggregated.concat(pageDashboards);
|
||||
totalPages = Number(response?.total_pages || 1);
|
||||
page += 1;
|
||||
}
|
||||
|
||||
dashboards = aggregatedDashboards;
|
||||
allDashboards = aggregated;
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
dashboards = [];
|
||||
allDashboards = [];
|
||||
} finally {
|
||||
fetchingDashboards = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRepositoriesOnly(envId: string) {
|
||||
if (!envId) return;
|
||||
fetchingRepos = true;
|
||||
try {
|
||||
// Reuse allDashboards if already fetched, otherwise fetch first
|
||||
let source = allDashboards;
|
||||
if (source.length === 0) {
|
||||
const pageSize = 100;
|
||||
let page = 1;
|
||||
let aggregated: DashboardMetadata[] = [];
|
||||
let totalPages = 1;
|
||||
|
||||
while (page <= totalPages) {
|
||||
const response = await api.requestApi(
|
||||
`/dashboards?env_id=${encodeURIComponent(envId)}&page=${page}&page_size=${pageSize}`,
|
||||
);
|
||||
const pageDashboards = Array.isArray(response?.dashboards)
|
||||
? response.dashboards
|
||||
: [];
|
||||
aggregated = aggregated.concat(pageDashboards);
|
||||
totalPages = Number(response?.total_pages || 1);
|
||||
page += 1;
|
||||
}
|
||||
source = aggregated;
|
||||
allDashboards = aggregated;
|
||||
}
|
||||
|
||||
// Filter to only dashboards with initialized repos
|
||||
const chunkSize = 50;
|
||||
const repoIds = new Set<number>();
|
||||
const allIds = source.map((d) => d.id);
|
||||
|
||||
for (let offset = 0; offset < allIds.length; offset += chunkSize) {
|
||||
const idsChunk = allIds.slice(offset, offset + chunkSize);
|
||||
try {
|
||||
const batchResponse = await gitService.getStatusesBatch(idsChunk);
|
||||
const statuses = batchResponse?.statuses || {};
|
||||
idsChunk.forEach((dashboardId) => {
|
||||
const status = statuses[dashboardId] || statuses[String(dashboardId)];
|
||||
const syncStatus = String(status?.sync_status || status?.sync_state || "").toUpperCase();
|
||||
if (syncStatus && syncStatus !== "NO_REPO") {
|
||||
repoIds.add(dashboardId);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[GitPage] Failed to resolve repository statuses chunk: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
repositoriesOnlyDashboards = source.filter((d) => repoIds.has(d.id));
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
repositoriesOnlyDashboards = [];
|
||||
} finally {
|
||||
fetchingRepos = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDashboards(envId: string) {
|
||||
if (!envId) return;
|
||||
await fetchAllDashboards(envId);
|
||||
await fetchRepositoriesOnly(envId);
|
||||
}
|
||||
|
||||
function handleTabChange(tab: TabId) {
|
||||
if (activeTab === tab) return;
|
||||
activeTab = tab;
|
||||
}
|
||||
|
||||
onMount(fetchEnvironments);
|
||||
|
||||
// REASON: Single effect to sync store-selectedEnvId → local selectedEnvId on init.
|
||||
@@ -95,10 +179,10 @@
|
||||
</script>
|
||||
|
||||
<div class="max-w-6xl mx-auto p-6">
|
||||
<PageHeader title={$t.git?.management }>
|
||||
<PageHeader title={$t.git?.management}>
|
||||
<div slot="actions" class="flex items-center space-x-4">
|
||||
<Select
|
||||
label={$t.dashboard?.environment }
|
||||
label={$t.dashboard?.environment}
|
||||
bind:value={selectedEnvId}
|
||||
options={environments.map(e => ({ value: e.id, label: e.name }))}
|
||||
onchange={() => { if (selectedEnvId) setSelectedEnvironment(selectedEnvId); }}
|
||||
@@ -111,13 +195,58 @@
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-ring"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<Card title={$t.git?.select_dashboard }>
|
||||
{#if fetchingDashboards}
|
||||
<p class="text-text-muted">{$t.common?.loading }</p>
|
||||
<!-- Tab bar -->
|
||||
<div class="mb-4 border-b border-border">
|
||||
<nav class="flex gap-1 -mb-px" aria-label="Git view tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2.5 text-sm font-medium transition-colors
|
||||
{activeTab === 'all'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-text-muted hover:text-text hover:border-border-strong'}"
|
||||
onclick={() => handleTabChange("all")}
|
||||
aria-selected={activeTab === 'all'}
|
||||
role="tab"
|
||||
>
|
||||
{$t.git?.management || "All Dashboards"}
|
||||
{#if allDashboards.length > 0}
|
||||
<span class="ml-1.5 text-xs text-text-subtle">({allDashboards.length})</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2.5 text-sm font-medium transition-colors
|
||||
{activeTab === 'repos'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-text-muted hover:text-text hover:border-border-strong'}"
|
||||
onclick={() => handleTabChange("repos")}
|
||||
aria-selected={activeTab === 'repos'}
|
||||
role="tab"
|
||||
>
|
||||
{$t.nav?.repositories || "Repositories"}
|
||||
{#if repositoriesOnlyDashboards.length > 0}
|
||||
<span class="ml-1.5 text-xs text-text-subtle">({repositoriesOnlyDashboards.length})</span>
|
||||
{/if}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Card title={activeTab === 'repos' ? ($t.nav?.repositories || "Repositories") : ($t.git?.select_dashboard || "Select Dashboard to Manage")}>
|
||||
{#if fetchingDashboards || fetchingRepos}
|
||||
<p class="text-text-muted">{$t.common?.loading}</p>
|
||||
{:else if dashboards.length > 0}
|
||||
<RepositoryDashboardGrid {dashboards} statusMode="repository" />
|
||||
<RepositoryDashboardGrid
|
||||
{dashboards}
|
||||
statusMode="repository"
|
||||
envId={selectedEnvId || null}
|
||||
repositoriesOnly={activeTab === 'repos'}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-text-muted italic">{$t.dashboard?.no_dashboards }</p>
|
||||
<p class="text-text-muted italic">
|
||||
{activeTab === 'repos'
|
||||
? ($t.git?.no_repositories_selected || "No repositories found")
|
||||
: ($t.dashboard?.no_dashboards || "No dashboards")}
|
||||
</p>
|
||||
{/if}
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
import SystemSettings from "./SystemSettings.svelte";
|
||||
import GitSettingsPage from "./git/+page.svelte";
|
||||
import AutomationSettingsPage from "./automation/+page.svelte";
|
||||
import LanguagesSettings from "./LanguagesSettings.svelte";
|
||||
|
||||
let activeTab = $state("environments");
|
||||
let settings = $state(null);
|
||||
@@ -248,6 +249,19 @@
|
||||
>
|
||||
{$t.settings?.automation || "Automation"}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
|
||||
class:text-primary={activeTab === "languages"}
|
||||
class:border-primary={activeTab === "languages"}
|
||||
class:text-text-muted={activeTab !== "languages"}
|
||||
class:border-transparent={activeTab !== "languages"}
|
||||
class:hover:text-text={activeTab !== "languages"}
|
||||
class:hover:border-border-strong={activeTab !== "languages"}
|
||||
aria-current={activeTab === "languages" ? "page" : undefined}
|
||||
onclick={() => handleTabChange("languages")}
|
||||
>
|
||||
{$t.settings?.languages || "Languages"}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
|
||||
class:text-primary={activeTab === "system"}
|
||||
@@ -281,6 +295,8 @@
|
||||
<FeaturesSettings bind:settings onSave={handleSave} />
|
||||
{:else if activeTab === "automation"}
|
||||
<AutomationSettingsPage />
|
||||
{:else if activeTab === "languages"}
|
||||
<LanguagesSettings bind:settings onSave={handleSave} />
|
||||
{:else if activeTab === "system"}
|
||||
<SystemSettings bind:settings onSave={handleSave} />
|
||||
{/if}
|
||||
|
||||
101
frontend/src/routes/settings/LanguagesSettings.svelte
Normal file
101
frontend/src/routes/settings/LanguagesSettings.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<!-- #region LanguagesSettings [C:2] [TYPE Component] [SEMANTICS settings, languages, allowed, select] -->
|
||||
<!-- @BRIEF Translation languages settings tab: choose which BCP-47 languages are allowed for translation
|
||||
jobs, dictionary entries, and everywhere else languages are specified. -->
|
||||
<!-- @PRE settings object with allowed_languages array is provided. -->
|
||||
<!-- @POST User can select/deselect languages from the full list. -->
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { ALL_LANGUAGES } from '$lib/i18n/languages.js';
|
||||
import HelpTooltip from '$lib/ui/HelpTooltip.svelte';
|
||||
|
||||
let { settings = $bindable(), onSave } = $props();
|
||||
|
||||
// Ensure allowed_languages exists on the settings object
|
||||
let allowed = $derived(settings.allowed_languages as string[] || []);
|
||||
|
||||
function toggleLanguage(code: string) {
|
||||
if (allowed.includes(code)) {
|
||||
settings.allowed_languages = allowed.filter((c: string) => c !== code);
|
||||
} else {
|
||||
settings.allowed_languages = [...allowed, code];
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
settings.allowed_languages = ALL_LANGUAGES.map(l => l.code);
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
settings.allowed_languages = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-bold mb-4">
|
||||
{$t.settings?.languages || 'Languages'}
|
||||
</h2>
|
||||
<p class="text-text-muted mb-6">
|
||||
{$t.settings?.languages_description || 'Choose which languages are available for translation jobs, dictionary entries, and language selection components. Changes apply immediately on save.'}
|
||||
</p>
|
||||
|
||||
<div class="bg-surface-muted p-6 rounded-lg border border-border">
|
||||
<!-- Count & quick actions -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-sm text-text-muted">
|
||||
{$t.settings?.languages_count?.replace('{count}', String(allowed.length)).replace('{total}', String(ALL_LANGUAGES.length)) || `${allowed.length} of ${ALL_LANGUAGES.length} selected`}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={selectAll}
|
||||
class="px-3 py-1 text-xs border border-border-strong rounded hover:bg-surface-card text-text-muted hover:text-text"
|
||||
>
|
||||
{$t.common?.select_all || 'Select All'}
|
||||
</button>
|
||||
<button
|
||||
onclick={selectNone}
|
||||
class="px-3 py-1 text-xs border border-border-strong rounded hover:bg-surface-card text-text-muted hover:text-text"
|
||||
>
|
||||
{$t.common?.clear || 'Clear'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language list with search -->
|
||||
<div class="max-h-80 overflow-y-auto border border-border rounded-lg divide-y divide-border bg-surface-card">
|
||||
{#each ALL_LANGUAGES as lang}
|
||||
<label
|
||||
class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-primary-light cursor-pointer transition-colors {allowed.includes(lang.code) ? 'bg-primary-light' : ''}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowed.includes(lang.code)}
|
||||
onchange={() => toggleLanguage(lang.code)}
|
||||
class="rounded border-border-strong text-primary focus-visible:ring-primary-ring"
|
||||
/>
|
||||
<span class="flex-1">{lang.name}</span>
|
||||
<span class="text-xs text-text-subtle font-mono">{lang.code}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Extra hint -->
|
||||
<div class="mt-4 flex items-start gap-2 text-xs text-text-muted">
|
||||
<svg class="w-4 h-4 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>
|
||||
{$t.settings?.languages_hint || 'These codes follow the BCP-47 standard (e.g., "ru" for Russian, "en" for English, "de" for German). Only checked languages will appear in language dropdowns across the application.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
onclick={onSave}
|
||||
class="bg-primary text-white px-4 py-2 rounded hover:bg-primary-hover"
|
||||
>
|
||||
{$t.settings?.save_languages || $t.settings?.save_logging || 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion LanguagesSettings -->
|
||||
@@ -17,6 +17,7 @@ export const SETTINGS_TABS = [
|
||||
"storage",
|
||||
"features",
|
||||
"automation",
|
||||
"languages",
|
||||
"system",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
<!-- #region ReposPage [C:3] [TYPE Page] [SEMANTICS sveltekit, git, repository, dashboard, environment] -->
|
||||
<!-- @BRIEF Page component: routes/storage/repos/+page.svelte -->
|
||||
<!-- @LAYER Page -->
|
||||
<!--
|
||||
@SEMANTICS: git, repository, dashboard, environment
|
||||
@PURPOSE: Dashboard management page for Git integration (moved from /git).
|
||||
@LAYER UI (Page)
|
||||
@RELATION DEPENDS_ON -> [EXT:frontend:RepositoryDashboardGrid]
|
||||
@RELATION DEPENDS_ON -> [EXT:frontend:api]
|
||||
@INVARIANT: Dashboard grid is always shown when an environment is selected.
|
||||
-->
|
||||
|
||||
<!-- @UX_STATE Loading -> Showing spinner while fetching environments/dashboards. -->
|
||||
<!-- @UX_STATE Idle -> Showing dashboard grid with repository filter. -->
|
||||
<!-- @UX_FEEDBACK Toast error messages on fetch failure. -->
|
||||
<!-- @UX_RECOVERY Environment selection refreshes the dashboard list. -->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* @UX_STATE: Loading -> Showing spinner while fetching environments/dashboards.
|
||||
* @UX_STATE: Idle -> Showing dashboard grid with actions.
|
||||
* @UX_FEEDBACK: Toast -> Error messages on fetch failure.
|
||||
* @UX_RECOVERY: Environment Selection -> Switch environment to retry loading.
|
||||
*/
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import RepositoryDashboardGrid from '../../../components/RepositoryDashboardGrid.svelte';
|
||||
import { addToast as toast } from '$lib/toasts.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { gitService } from '../../../services/gitService.js';
|
||||
import type { DashboardMetadata } from '$lib/types/dashboard';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { Card, PageHeader, Select } from '$lib/ui';
|
||||
import {
|
||||
environmentContextStore,
|
||||
initializeEnvironmentContext,
|
||||
setSelectedEnvironment,
|
||||
} from '$lib/stores/environmentContext.svelte.js';
|
||||
|
||||
let selectedEnvId = $state("");
|
||||
let dashboards: DashboardMetadata[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let fetchingDashboards = $state(false);
|
||||
let environments = $derived(
|
||||
environmentContextStore.value?.environments || [],
|
||||
);
|
||||
|
||||
// #region fetchEnvironments:Function [TYPE Function]
|
||||
/**
|
||||
* @PURPOSE: Fetches the list of available environments.
|
||||
* @PRE: None.
|
||||
* @POST: environments array is populated, selectedEnvId is set to first env if available.
|
||||
*/
|
||||
async function fetchEnvironments() {
|
||||
try {
|
||||
await initializeEnvironmentContext();
|
||||
selectedEnvId = environmentContextStore.value?.selectedEnvId || "";
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// #endregion fetchEnvironments:Function
|
||||
|
||||
// #region fetchDashboards:Function [TYPE Function]
|
||||
/**
|
||||
* @PURPOSE: Fetches dashboards for a specific environment.
|
||||
* @PRE: envId is a valid environment ID.
|
||||
* @POST: dashboards array is populated with metadata for the selected environment.
|
||||
*/
|
||||
async function fetchDashboards(envId: string) {
|
||||
if (!envId) return;
|
||||
fetchingDashboards = true;
|
||||
try {
|
||||
const pageSize = 100;
|
||||
let page = 1;
|
||||
let aggregatedDashboards: DashboardMetadata[] = [];
|
||||
let totalPages = 1;
|
||||
|
||||
while (page <= totalPages) {
|
||||
const response = await api.requestApi(
|
||||
`/dashboards?env_id=${encodeURIComponent(envId)}&page=${page}&page_size=${pageSize}`,
|
||||
);
|
||||
const pageDashboards = Array.isArray(response?.dashboards)
|
||||
? response.dashboards
|
||||
: [];
|
||||
aggregatedDashboards = aggregatedDashboards.concat(pageDashboards);
|
||||
totalPages = Number(response?.total_pages || 1);
|
||||
page += 1;
|
||||
}
|
||||
|
||||
dashboards = await filterDashboardsWithRepositories(aggregatedDashboards);
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
dashboards = [];
|
||||
} finally {
|
||||
fetchingDashboards = false;
|
||||
}
|
||||
}
|
||||
// #endregion fetchDashboards:Function
|
||||
|
||||
// #region filterDashboardsWithRepositories:Function [TYPE Function]
|
||||
/**
|
||||
* @PURPOSE: Keep only dashboards that already have initialized Git repositories.
|
||||
* @PRE: dashboards list is loaded for selected environment.
|
||||
* @POST: Returns dashboards with status != NO_REPO.
|
||||
*/
|
||||
async function filterDashboardsWithRepositories(
|
||||
allDashboards: DashboardMetadata[],
|
||||
): Promise<DashboardMetadata[]> {
|
||||
if (allDashboards.length === 0) return [];
|
||||
|
||||
const chunkSize = 50;
|
||||
const repositoryDashboardIds = new Set<number>();
|
||||
const allIds = allDashboards.map((dashboard) => dashboard.id);
|
||||
|
||||
for (let offset = 0; offset < allIds.length; offset += chunkSize) {
|
||||
const idsChunk = allIds.slice(offset, offset + chunkSize);
|
||||
try {
|
||||
const batchResponse = await gitService.getStatusesBatch(idsChunk);
|
||||
const statuses = batchResponse?.statuses || {};
|
||||
idsChunk.forEach((dashboardId) => {
|
||||
const status = statuses[dashboardId] || statuses[String(dashboardId)];
|
||||
const syncStatus = String(status?.sync_status || status?.sync_state || "").toUpperCase();
|
||||
if (syncStatus && syncStatus !== "NO_REPO") {
|
||||
repositoryDashboardIds.add(dashboardId);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[StorageReposPage][Coherence:Failed] Failed to resolve repository statuses chunk: ${error?.message || error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return allDashboards.filter((dashboard) =>
|
||||
repositoryDashboardIds.has(dashboard.id),
|
||||
);
|
||||
}
|
||||
// #endregion filterDashboardsWithRepositories:Function
|
||||
|
||||
onMount(fetchEnvironments);
|
||||
|
||||
// REASON: Single effect to sync store-selectedEnvId → local selectedEnvId on init.
|
||||
// Removed the duplicate _storeEnv-based effect that was also present.
|
||||
// Removed setSelectedEnvironment from the fetch effect — it updated the store,
|
||||
// which re-triggered this sync effect, creating an infinite loop (2900+ iterations).
|
||||
// Persistence is now handled via the Select's onchange handler below.
|
||||
$effect(() => {
|
||||
const sid = environmentContextStore.value?.selectedEnvId;
|
||||
if (sid) {
|
||||
const localEnvId = untrack(() => selectedEnvId);
|
||||
if (localEnvId !== sid) {
|
||||
selectedEnvId = sid;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!selectedEnvId) return;
|
||||
void fetchDashboards(selectedEnvId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="max-w-6xl mx-auto p-6">
|
||||
<PageHeader title={$t.nav?.repositories }>
|
||||
<div slot="actions" class="flex items-center space-x-4">
|
||||
<Select
|
||||
label={$t.dashboard?.environment }
|
||||
bind:value={selectedEnvId}
|
||||
options={environments.map(e => ({ value: e.id, label: e.name }))}
|
||||
onchange={() => { if (selectedEnvId) setSelectedEnvironment(selectedEnvId); }}
|
||||
/>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-ring"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<Card title={$t.nav?.repositories || "Репозитории"}>
|
||||
{#if fetchingDashboards}
|
||||
<p class="text-text-muted">{$t.common?.loading }</p>
|
||||
{:else if dashboards.length > 0}
|
||||
<RepositoryDashboardGrid {dashboards} statusMode="repository" envId={selectedEnvId || null} repositoriesOnly={true} />
|
||||
{:else}
|
||||
<p class="text-text-muted italic">{$t.git?.no_repositories_selected || "Репозитории не найдены"}</p>
|
||||
{/if}
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion ReposPage -->
|
||||
@@ -1,132 +1,835 @@
|
||||
<!-- #region TranslationJobConfig [C:4] [TYPE Page] [SEMANTICS sveltekit, translate, job, config, llm, datasource] -->
|
||||
<!-- @BRIEF Translation job config wizard — renders TranslationJobModel state. 5 tabs: Config, Preview, Target, Run, Schedule. -->
|
||||
<!-- @LAYER Page -->
|
||||
<!-- @RELATION BINDS_TO -> [TranslationJobModel] -->
|
||||
<!-- @BRIEF Translation job configuration page — 5-step wizard with logical separation:
|
||||
Step 1 (Config tab): datasource selection (auto-detects DB dialect), column mapping,
|
||||
LLM provider, target languages (multi-select from LANGUAGES).
|
||||
Step 2 (Preview tab): translation quality gate — shows sample translations before target config.
|
||||
Step 3 (Target tab): target DB/schema/table + column mapping + schema hint validation.
|
||||
Step 4 (Run tab): incremental/full translation execution with run history.
|
||||
Step 5 (Schedule tab): cron scheduling for saved jobs only.
|
||||
-->
|
||||
|
||||
<!-- ===== UX CONTRACTS ===== -->
|
||||
<!-- @UX_STATE idle — page not yet initialised. Skeleton loader visible. -->
|
||||
<!-- @UX_STATE loading — environments, LLM providers and dictionaries being fetched. Full-page spinner. -->
|
||||
<!-- @UX_STATE configured — job data loaded, form populated, tabs rendered. User interaction enabled. -->
|
||||
<!-- @UX_STATE saving — PUT/POST in flight. Save button disabled, spinner shown. -->
|
||||
<!-- @UX_STATE validation_error — form validation failed. Red borders on invalid fields, error summary card. -->
|
||||
<!-- @UX_STATE datasource_unavailable — selected datasource not found in Superset. Warning banner, datasource re-selection prompt. -->
|
||||
<!-- @UX_STATE error — unrecoverable error (job not found, network down). Error page with retry button. -->
|
||||
|
||||
<!-- @UX_STEP Config (tab) — Step 1: datasource selection with auto-detected DB dialect + column mapping + LLM + target languages (MultiSelect from LANGUAGES). Save required before advancing. -->
|
||||
<!-- @UX_STEP Preview (tab) — Step 2: translation quality gate. Available after save when configValid=true. Shows sample translations before target config. -->
|
||||
<!-- @UX_STEP Target (tab) — Step 3: target DB/schema/table config. Only for saved jobs (after Preview). Schema validation via TargetSchemaHint. DB dialect auto-detected from Superset. -->
|
||||
<!-- @UX_STEP Run (tab) — Step 4: translation execution. Run history, incremental/full modes, progress tracking via translationRunStore. -->
|
||||
<!-- @UX_STEP Schedule (tab) — Step 5: cron scheduling. Only for saved jobs. Sets schedule via API. -->
|
||||
|
||||
<!-- @UX_FEEDBACK Toast "info" — environment switched (handleEnvChange resets targetDatabaseId). -->
|
||||
<!-- @UX_FEEDBACK Toast "success" — job saved/updated/run completed. -->
|
||||
<!-- @UX_FEEDBACK Toast "error" — API failure, validation errors, run failed. -->
|
||||
<!-- @UX_FEEDBACK Modal — BulkReplaceModal for find-and-replace corrections. -->
|
||||
<!-- @UX_FEEDBACK Inline progress — TranslationRunProgress bar during execution. -->
|
||||
<!-- @UX_FEEDBACK Schema validation — TargetSchemaHint inline result (green check / red missing columns). -->
|
||||
|
||||
<!-- @UX_RECOVERY Config tab save error — user can retry save; validation errors shown inline. -->
|
||||
<!-- @UX_RECOVERY Preview unavailable — configValid incomplete; user returned to Config tab with missing fields highlighted. -->
|
||||
<!-- @UX_RECOVERY Datasource unavailable — warning banner + re-selection prompt. -->
|
||||
<!-- @UX_RECOVERY Run failure — error toast + run history shows FAILED status; user can retry. -->
|
||||
<!-- @UX_RECOVERY Schedule 404 — silently handled (no schedule = not configured); user can create one. -->
|
||||
|
||||
<!-- @UX_REACTIVITY Props -> $page.params.id (jobId), $page.params.id === 'new' (isNewJob). -->
|
||||
<!-- @UX_REACTIVITY LocalState -> $state for all form fields, $state(uxState), $state(activeTab), $state(isSaving), $state(validationErrors). -->
|
||||
<!-- @UX_REACTIVITY Derived -> $derived(configValid, pageTitle, tabs, columnList, logicalColumns). -->
|
||||
<!-- @UX_REACTIVITY Effect -> $effect watches jobId + environments to loadJob; $effect watches environmentId for datasource reload (on new jobs). -->
|
||||
<!-- @UX_REACTIVITY Store -> translationRunStore (fromStore wrapper), addToast from $lib/toasts. -->
|
||||
|
||||
<!-- @UX_DEPENDENCY Datasource selection -> auto-fills databaseDialect, sourceTable, sourceSchema. -->
|
||||
<!-- @UX_DEPENDENCY Environment selection -> reloads databases, resets targetDatabaseId. -->
|
||||
<!-- @UX_DEPENDENCY Config valid -> gates Preview tab availability and Run button. -->
|
||||
<!-- @UX_DEPENDENCY Job saved (existingJob != null) -> unlocks Target and Schedule tabs. -->
|
||||
|
||||
<!-- @UX_TEST: idle -> {wait: environments loaded, expected: configured, tabs rendered} -->
|
||||
<!-- @UX_TEST: configured -> {fill: name, select: datasource, click: save, expected: saving -> toast success} -->
|
||||
<!-- @UX_TEST: configured -> {click: Preview tab, expected: if configValid -> preview renders; else -> redirect to Config} -->
|
||||
<!-- @UX_TEST: configured -> {click: Run, expected: if configValid -> run starts, progress bar appears} -->
|
||||
<!-- @UX_TEST: error -> {simulate: network failure, expected: error toast, retry available} -->
|
||||
|
||||
<!-- @RATIONALE Target Config extracted into its own tab (between Preview and Run) so users can review translation quality before configuring the target table for inserts. -->
|
||||
<!-- @RATIONALE DB dialect auto-detected from Superset datasource — user never manually selects it; eliminates dialect mismatch errors. -->
|
||||
<!-- @RATIONALE Target languages are human languages from LANGUAGE_LABELS, not DB types — sent to LLM as translation direction. -->
|
||||
<!-- @REJECTED Keeping target table in Config tab would force users to fill insert details before seeing preview — contradicts the "preview before target" UX requirement. -->
|
||||
<!-- @REJECTED Manual DB dialect selection in UI rejected — auto-detection from Superset is more reliable and eliminates configuration errors. -->
|
||||
<!-- @REJECTED Single language field (target_language) rejected in favour of target_languages list — supports multi-language translation per row. -->
|
||||
<!-- @REJECTED Extracting all sub-tabs into separate page components was rejected due to deep state coupling between datasource selection, column loading, and form validation — would require a global store or excessive prop drilling. -->
|
||||
<!-- @DEPRECATED N/A — active page. -->
|
||||
<!-- @REPLACED_BY N/A — no replacement. -->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/routes';
|
||||
import { page } from '$app/state';
|
||||
import { t, _ } from '$lib/i18n/index.svelte.js';
|
||||
import { ROUTES } from '$lib/routes';
|
||||
import { TranslationJobModel } from '$lib/models/TranslationJobModel.svelte';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import {
|
||||
fetchJobs,
|
||||
createJob,
|
||||
updateJob,
|
||||
fetchDatasources,
|
||||
} from '$lib/api/translate.js';
|
||||
import MultiSelect from '$lib/components/ui/MultiSelect.svelte';
|
||||
import TranslationPreview from '$lib/components/translate/TranslationPreview.svelte';
|
||||
import TranslationRunProgress from '$lib/components/translate/TranslationRunProgress.svelte';
|
||||
import TranslationRunResult from '$lib/components/translate/TranslationRunResult.svelte';
|
||||
import ScheduleConfig from '$lib/components/translate/ScheduleConfig.svelte';
|
||||
import { triggerRun, fetchRunHistory, cancelRun } from '$lib/api/translate.js';
|
||||
import BulkReplaceModal from '$lib/components/translate/BulkReplaceModal.svelte';
|
||||
import ConfigTabForm from '$lib/components/translate/ConfigTabForm.svelte';
|
||||
import TargetTabForm from '$lib/components/translate/TargetTabForm.svelte';
|
||||
import RunTabContent from '$lib/components/translate/RunTabContent.svelte';
|
||||
import {
|
||||
translationRunStore,
|
||||
startTranslationRun,
|
||||
reconnectToRun,
|
||||
resetTranslationRun,
|
||||
clearOnCompleteCallback,
|
||||
getStoredActiveRun,
|
||||
} from '$lib/stores/translationRun.svelte.js';
|
||||
|
||||
const m = new TranslationJobModel();
|
||||
|
||||
$effect(() => {
|
||||
m.jobId = page.params.id;
|
||||
if (m.jobId) m.loadJob();
|
||||
|
||||
/** @type {string} idle | loading | configured | saving | validation_error | datasource_unavailable */
|
||||
let uxState = $state('idle');
|
||||
let isNewJob = $derived(page.params.id === 'new');
|
||||
let jobId = $derived(page.params.id);
|
||||
let existingJob = $state(null);
|
||||
let error = $state(null);
|
||||
|
||||
// Form state
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
|
||||
let sourceDatasourceId = $state('');
|
||||
let sourceTable = $state('');
|
||||
let targetSchema = $state('');
|
||||
let targetTable = $state('');
|
||||
let sourceKeyCols = $state([]);
|
||||
let targetKeyCols = $state([]);
|
||||
let translationColumn = $state('');
|
||||
let targetColumn = $state('');
|
||||
let targetLanguageColumn = $state('');
|
||||
let targetSourceColumn = $state('');
|
||||
let targetSourceLanguageColumn = $state('');
|
||||
let contextColumns = $state([]);
|
||||
let targetLanguages = $state(['en']);
|
||||
let includeSourceReference = $state(true);
|
||||
let providerId = $state('');
|
||||
let batchSize = $state(50);
|
||||
let disableReasoning = $state(false);
|
||||
let upsertStrategy = $state('MERGE');
|
||||
let dictionaryIds = $state([]);
|
||||
let status = $state('DRAFT');
|
||||
|
||||
// Datasource & env state
|
||||
let environmentId = $state('');
|
||||
let environments = $state([]);
|
||||
let datasourceId = $state('');
|
||||
let datasourceSearch = $state('');
|
||||
let availableColumns = $state([]);
|
||||
let virtualColumns = $state([]);
|
||||
let databaseDialect = $state('');
|
||||
let llmProviders = $state([]);
|
||||
let availableDictionaries = $state([]);
|
||||
let targetDatabaseId = $state('');
|
||||
let databases = $state([]);
|
||||
let databasesLoading = $state(false);
|
||||
|
||||
// Tab state
|
||||
/** @type {'config'|'preview'|'target'|'run'|'schedule'} */
|
||||
let activeTab = $state('config');
|
||||
let tabs = $derived.by(() => {
|
||||
const base = [
|
||||
{ id: 'config', label: $t.translate?.config?.basic_info || 'Configuration' },
|
||||
{ id: 'preview', label: $t.translate?.preview?.title || 'Preview' },
|
||||
{ id: 'run', label: $t.translate?.config?.run_translation || 'Run' },
|
||||
];
|
||||
if (!isNewJob && existingJob) {
|
||||
// @RATIONALE Target Config tab inserted between Preview and Run so users can
|
||||
// review/preview translations before configuring the target table for inserts.
|
||||
base.splice(2, 0, { id: 'target', label: $t.translate?.config?.target_table_tab || 'Target Config' });
|
||||
base.push({ id: 'schedule', label: $t.translate?.schedule?.tab_label || 'Schedule' });
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
const tabs = $derived([
|
||||
{ key: 'config', label: $t.translate?.tabs?.config || 'Config' },
|
||||
{ key: 'preview', label: $t.translate?.tabs?.preview || 'Preview', disabled: !m.configValid || !m.existingJob },
|
||||
{ key: 'target', label: $t.translate?.tabs?.target || 'Target', disabled: !m.existingJob },
|
||||
{ key: 'run', label: $t.translate?.tabs?.run || 'Run', disabled: !m.configValid },
|
||||
{ key: 'schedule', label: $t.translate?.tabs?.schedule || 'Schedule', disabled: !m.existingJob },
|
||||
]);
|
||||
// Derived
|
||||
let columnList = $derived(availableColumns);
|
||||
let logicalColumns = $derived(availableColumns.filter(c => c.is_physical !== false));
|
||||
let virtualColumnNames = $derived(virtualColumns);
|
||||
|
||||
function goBack() { window.history.back(); }
|
||||
let isSaving = $state(false);
|
||||
let validationErrors = $state({});
|
||||
let warnings = $state([]);
|
||||
|
||||
// Derived: is the job config complete enough for preview/run?
|
||||
// @RATIONALE Removed !isNewJob from configValid — preview availability should depend
|
||||
// on config completeness only, not on whether the job was saved. The preview tab
|
||||
// content already gates rendering on existingJob via the template.
|
||||
// @REJECTED Keeping !isNewJob in configValid would block preview when user wants to
|
||||
// see results before configuring target table details.
|
||||
let configValid = $derived(
|
||||
!!translationColumn
|
||||
&& !!datasourceId
|
||||
&& targetLanguages.length > 0
|
||||
&& !!providerId
|
||||
);
|
||||
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 currentRunId = $derived(translationRunStore.value?.runId || null);
|
||||
let completedRuns = $state([]);
|
||||
let expandedRunIds = $state([]);
|
||||
let isRunning = $state(false);
|
||||
let isFullRun = $state(false);
|
||||
let runError = $state('');
|
||||
let showPageBulkReplace = $state(false);
|
||||
/** Local flag to hide progress bar when run completes — avoids touching the store
|
||||
* and triggering cascading reactive updates through fromStore. */
|
||||
let runComplete = $state(false);
|
||||
|
||||
async function handleTriggerRun(full = false) {
|
||||
isRunning = true;
|
||||
runComplete = false;
|
||||
isFullRun = full;
|
||||
runError = '';
|
||||
try {
|
||||
const run = await triggerRun(jobId, full);
|
||||
startTranslationRun(run.id, { jobId, isFullRun: full, onComplete: handleRunComplete });
|
||||
addToast(full ? 'Полный перевод запущен (все строки)' : _('translate.config.run_started'), 'success');
|
||||
} catch (err) {
|
||||
runError = err?.message || _('translate.config.run_failed');
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRunComplete(statusData) {
|
||||
if (runComplete) return; // Idempotent: run already marked complete
|
||||
const finishedRunId = currentRunId;
|
||||
isRunning = false;
|
||||
runComplete = true;
|
||||
expandedRunIds = [];
|
||||
if (statusData?.status !== 'CANCELLED') {
|
||||
loadRunHistory();
|
||||
}
|
||||
addToast(`${_('translate.run.run_id')} ${finishedRunId}`, 'info');
|
||||
}
|
||||
|
||||
async function loadRunHistory() {
|
||||
try {
|
||||
const data = await fetchRunHistory(jobId, { page_size: 5 });
|
||||
completedRuns = (data?.items || []).filter(
|
||||
(run) => run && typeof run.id === 'string' && run.id.trim().length > 0
|
||||
);
|
||||
expandedRunIds = expandedRunIds.filter((runId) => completedRuns.some((run) => run.id === runId));
|
||||
} catch (_err) {
|
||||
completedRuns = [];
|
||||
expandedRunIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRunDetails(runId) {
|
||||
if (!runId) return;
|
||||
if (expandedRunIds.includes(runId)) {
|
||||
expandedRunIds = expandedRunIds.filter((id) => id !== runId);
|
||||
} else {
|
||||
expandedRunIds = [...expandedRunIds, runId];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetryRun() {
|
||||
if (currentRunId) {
|
||||
try {
|
||||
await cancelRun(currentRunId);
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
resetTranslationRun();
|
||||
await handleTriggerRun();
|
||||
}
|
||||
|
||||
async function handleRetryInsert() {
|
||||
if (currentRunId) {
|
||||
try {
|
||||
const result = await api.postApi(`/translate/runs/${currentRunId}/retry-insert`, {});
|
||||
addToast(_('translate.config.insert_retry_started'), 'success');
|
||||
} catch (err) {
|
||||
addToast(err?.message || _('translate.config.insert_retry_failed'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Load environments first
|
||||
try {
|
||||
const envs = await api.getEnvironments?.() || [];
|
||||
environments = Array.isArray(envs) ? envs : [];
|
||||
if (environments.length > 0 && !environmentId) {
|
||||
environmentId = environments[0]?.id || '';
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
await loadInitialData();
|
||||
|
||||
// Handle new-job initialisation (runs once on mount)
|
||||
if (isNewJob) {
|
||||
if (environmentId) {
|
||||
await loadDatabases();
|
||||
}
|
||||
uxState = 'configured';
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for jobId changes — handles:
|
||||
// 1. Direct navigation to existing job page (environments loaded in onMount)
|
||||
// 2. Navigation after createJob (goto → param change, component reused)
|
||||
// 3. Page reload — reconnect to any active run via sessionStorage
|
||||
// Guards: only runs when environments are ready and it's an existing job
|
||||
$effect(() => {
|
||||
if (jobId && !isNewJob && environments.length > 0) {
|
||||
loadJob();
|
||||
}
|
||||
});
|
||||
|
||||
/** Pick up active run after job loads — called from loadJob() after run history loads.
|
||||
* NOT a $effect — checking sessionStorage + completedRuns[0] is a one-time operation
|
||||
* that must NOT reactively track translationRunStore.value (would create infinite loop). */
|
||||
function pickUpActiveRun(): void {
|
||||
if (!existingJob || !existingJob.id) return;
|
||||
// Don't reconnect if store is already connected to a run for this job
|
||||
if (translationRunStore.value?.runId && translationRunStore.value?.jobId === existingJob.id) return;
|
||||
|
||||
// 1. Fast path: sessionStorage (our own run, across reload/tab-close)
|
||||
const stored = getStoredActiveRun();
|
||||
if (stored && stored.jobId === existingJob.id) {
|
||||
console.debug('[translate] picking up active run from sessionStorage', { runId: stored.runId });
|
||||
isRunning = true;
|
||||
isFullRun = stored.isFullRun || false;
|
||||
runComplete = false;
|
||||
reconnectToRun(stored.runId, {
|
||||
jobId: existingJob.id,
|
||||
isFullRun: stored.isFullRun || false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Fallback: API-based detection (run started by other tab / before fix)
|
||||
if (!completedRuns.length) return;
|
||||
const latest = completedRuns[0] as Record<string, unknown> | undefined;
|
||||
if (!latest || typeof latest.id !== 'string') return;
|
||||
const runStatus = latest.status as string;
|
||||
if (runStatus !== 'PENDING' && runStatus !== 'RUNNING') return;
|
||||
|
||||
console.debug('[translate] picking up active run from API', { runId: latest.id, status: runStatus });
|
||||
isRunning = true;
|
||||
isFullRun = latest.isFullRun === true;
|
||||
runComplete = false;
|
||||
reconnectToRun(latest.id, {
|
||||
jobId: existingJob.id,
|
||||
isFullRun: latest.isFullRun === true,
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up store callback when page unmounts — the store keeps
|
||||
// polling so the global indicator in the layout still works.
|
||||
onDestroy(() => {
|
||||
clearOnCompleteCallback();
|
||||
});
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
// Load LLM providers
|
||||
try {
|
||||
const consolidated = await api.getConsolidatedSettings?.() || {};
|
||||
llmProviders = consolidated.llm_providers || [];
|
||||
// Also try settings endpoint
|
||||
if (llmProviders.length === 0 && api.getLlmStatus) {
|
||||
const llmStatus = await api.getLlmStatus().catch(() => ({}));
|
||||
if (llmStatus?.providers) {
|
||||
llmProviders = llmStatus.providers;
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
llmProviders = [];
|
||||
}
|
||||
|
||||
// Load dictionaries (from available translate dictionaries)
|
||||
try {
|
||||
const result = await api.fetchApi('/translate/dictionaries').catch(() => ({}));
|
||||
availableDictionaries = Array.isArray(result) ? result : (result?.items || []);
|
||||
} catch (_e) {
|
||||
availableDictionaries = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[translate/config] Failed to load some initial data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function loadJob() {
|
||||
uxState = 'loading';
|
||||
try {
|
||||
const jobs = await fetchJobs({ page_size: 100 });
|
||||
const jobsArr = Array.isArray(jobs) ? jobs : (jobs?.results || []);
|
||||
const job = jobsArr.find(j => j.id === jobId);
|
||||
if (!job) {
|
||||
// Try direct fetch
|
||||
try {
|
||||
const directJob = await api.fetchApi(`/translate/jobs/${jobId}`);
|
||||
existingJob = directJob;
|
||||
} catch (_e) {
|
||||
error = _('translate.config.job_not_found').replace('{id}', jobId);
|
||||
uxState = 'error';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
existingJob = job;
|
||||
}
|
||||
|
||||
// Populate form
|
||||
const j = existingJob;
|
||||
name = j.name || '';
|
||||
description = j.description || '';
|
||||
sourceDatasourceId = j.source_datasource_id || '';
|
||||
sourceTable = j.source_table || '';
|
||||
targetSchema = j.target_schema || '';
|
||||
targetTable = j.target_table || '';
|
||||
sourceKeyCols = j.source_key_cols || [];
|
||||
targetKeyCols = j.target_key_cols || [];
|
||||
translationColumn = j.translation_column || '';
|
||||
targetColumn = j.target_column || '';
|
||||
targetLanguageColumn = j.target_language_column || '';
|
||||
targetSourceColumn = j.target_source_column || '';
|
||||
targetSourceLanguageColumn = j.target_source_language_column || '';
|
||||
contextColumns = j.context_columns || [];
|
||||
targetLanguages = j.target_languages?.length > 0 ? j.target_languages : (j.target_language ? [j.target_language] : ['en']);
|
||||
providerId = j.provider_id || '';
|
||||
batchSize = j.batch_size || 50;
|
||||
includeSourceReference = j.include_source_reference ?? true;
|
||||
disableReasoning = j.disable_reasoning || false;
|
||||
upsertStrategy = j.upsert_strategy || 'MERGE';
|
||||
dictionaryIds = j.dictionary_ids || [];
|
||||
status = j.status || 'DRAFT';
|
||||
|
||||
// Restore target database
|
||||
if (j.target_database_id) {
|
||||
targetDatabaseId = j.target_database_id;
|
||||
}
|
||||
|
||||
// Restore environment from stored job FIRST (before loading databases)
|
||||
if (j.environment_id && environments.some(e => e.id === j.environment_id)) {
|
||||
environmentId = j.environment_id;
|
||||
} else {
|
||||
environmentId = environments[0]?.id || '';
|
||||
}
|
||||
|
||||
// Load databases for the (now correct) environment
|
||||
if (environmentId) {
|
||||
await loadDatabases();
|
||||
}
|
||||
|
||||
// If there's a datasource, load its columns and look up the name
|
||||
if (j.source_datasource_id && environments.length > 0) {
|
||||
datasourceId = j.source_datasource_id;
|
||||
if (j.source_table) {
|
||||
const dialect = j.database_dialect || '';
|
||||
datasourceSearch = dialect
|
||||
? `${j.source_table} (${dialect})`
|
||||
: j.source_table;
|
||||
} else {
|
||||
// Fallback — try to look up the actual datasource name from the API
|
||||
datasourceSearch = `Dataset #${j.source_datasource_id}`;
|
||||
try {
|
||||
const dsList = await fetchDatasources(environmentId, '');
|
||||
const found = dsList.find(ds => String(ds.id) === String(j.source_datasource_id));
|
||||
if (found) {
|
||||
datasourceSearch = `${found.table_name} (${found.database_name} · ${found.database_dialect})`;
|
||||
databaseDialect = found.database_dialect || '';
|
||||
}
|
||||
} catch (_e) {
|
||||
// Keep fallback "Dataset #<id>" if API fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load run history
|
||||
await loadRunHistory();
|
||||
// Check for active run (sessionStorage or API) — runs once, no reactive cycle
|
||||
pickUpActiveRun();
|
||||
uxState = 'configured';
|
||||
} catch (err) {
|
||||
error = err?.message || 'Failed to load job';
|
||||
uxState = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDatabases() {
|
||||
if (!environmentId) {
|
||||
databases = [];
|
||||
return;
|
||||
}
|
||||
databasesLoading = true;
|
||||
try {
|
||||
databases = await api.getEnvironmentDatabases(environmentId) || [];
|
||||
} catch (_e) {
|
||||
databases = [];
|
||||
} finally {
|
||||
databasesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Event} event */
|
||||
async function handleEnvChange(event) {
|
||||
const newEnv = event.target.value;
|
||||
environmentId = newEnv;
|
||||
databaseDialect = '';
|
||||
targetDatabaseId = '';
|
||||
await loadDatabases();
|
||||
}
|
||||
|
||||
/** @returns {string[]} */
|
||||
function validate() {
|
||||
const errs = {};
|
||||
const warns = [];
|
||||
|
||||
if (!name.trim()) {
|
||||
errs.name = _('translate.config.name_required');
|
||||
}
|
||||
|
||||
if (targetLanguages.length === 0) {
|
||||
errs.targetLanguages = 'At least one target language is required';
|
||||
}
|
||||
|
||||
if (!translationColumn) {
|
||||
errs.translationColumn = _('translate.config.translation_column_required');
|
||||
}
|
||||
|
||||
if (sourceKeyCols.length > 0 && sourceKeyCols.some((_, i) => !targetKeyCols[i])) {
|
||||
errs.targetKeyCols = _('translate.config.key_mapping_required');
|
||||
}
|
||||
|
||||
// Warn about virtual key columns
|
||||
for (const col of sourceKeyCols) {
|
||||
if (virtualColumnNames.includes(col)) {
|
||||
warns.push(_('translate.config.virtual_column_warning').replace('{col}', col));
|
||||
}
|
||||
}
|
||||
|
||||
// Check duplicate column mapping
|
||||
if (contextColumns.includes(translationColumn)) {
|
||||
warns.push(_('translate.config.redundant_column_warning'));
|
||||
}
|
||||
|
||||
validationErrors = errs;
|
||||
warnings = warns;
|
||||
return Object.keys(errs);
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function handleSave() {
|
||||
const errs = validate();
|
||||
if (errs.length > 0) {
|
||||
uxState = 'validation_error';
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
uxState = 'saving';
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
source_dialect: existingJob?.source_dialect || 'SQL',
|
||||
target_dialect: existingJob?.target_dialect || 'en',
|
||||
source_datasource_id: datasourceId || null,
|
||||
source_table: sourceTable || null,
|
||||
target_schema: targetSchema || null,
|
||||
target_table: targetTable || null,
|
||||
source_key_cols: sourceKeyCols,
|
||||
target_key_cols: targetKeyCols,
|
||||
translation_column: translationColumn || null,
|
||||
target_column: targetColumn || null,
|
||||
target_language_column: targetLanguageColumn || null,
|
||||
target_source_column: targetSourceColumn || null,
|
||||
target_source_language_column: targetSourceLanguageColumn || null,
|
||||
context_columns: contextColumns,
|
||||
target_languages: targetLanguages?.length > 0 ? targetLanguages : null,
|
||||
provider_id: providerId || null,
|
||||
batch_size: parseInt(batchSize) || 50,
|
||||
include_source_reference: includeSourceReference,
|
||||
disable_reasoning: disableReasoning,
|
||||
upsert_strategy: upsertStrategy,
|
||||
dictionary_ids: dictionaryIds,
|
||||
database_dialect: databaseDialect || existingJob?.source_dialect || '',
|
||||
environment_id: environmentId || null,
|
||||
target_database_id: targetDatabaseId || null,
|
||||
status: status,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isNewJob) {
|
||||
const created = await createJob(payload);
|
||||
existingJob = created;
|
||||
addToast(_('translate.config.job_created'), 'success');
|
||||
goto(ROUTES.translate.detail(created.id));
|
||||
} else {
|
||||
await updateJob(jobId, payload);
|
||||
addToast(_('translate.config.job_updated'), 'success');
|
||||
uxState = 'configured';
|
||||
}
|
||||
} catch (err) {
|
||||
addToast(err?.message || _('translate.config.save_failed'), 'error');
|
||||
uxState = 'validation_error';
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
||||
<button onclick={goBack} class="flex items-center gap-2 text-text-muted hover:text-text mb-4 text-sm">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||
{$t.common?.back}
|
||||
</button>
|
||||
<svelte:head>
|
||||
<title>{pageTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-2xl font-bold text-text mb-1">{m.isNewJob ? $t.translate?.job?.new || 'New Job' : m.name || $t.translate?.job?.edit || 'Edit Job'}</h1>
|
||||
<p class="text-sm text-text-muted mb-6">{$t.translate?.job?.subtitle || 'Configure translation job parameters'}</p>
|
||||
|
||||
{#if m.error && m.uxState !== 'idle'}
|
||||
<div class="bg-destructive-light border border-destructive-ring rounded-lg p-4 mb-4 text-destructive text-sm">{m.error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 mb-6 border-b border-border pb-2">
|
||||
{#each tabs as tab}
|
||||
<button class="px-4 py-2 text-sm rounded-t-lg transition-colors {m.activeTab === tab.key ? 'bg-primary text-white' : 'bg-surface-muted text-text-muted hover:bg-surface-page'} {tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}" disabled={tab.disabled} onclick={() => { if (!tab.disabled) m.activeTab = tab.key; }}>{tab.label}</button>
|
||||
{/each}
|
||||
<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">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-text">
|
||||
{isNewJob ? $t.translate?.config?.new_title : $t.translate?.config?.edit_title}
|
||||
</h1>
|
||||
{#if !isNewJob && existingJob}
|
||||
<p class="text-sm text-text-muted mt-1">{$t.translate?.config?.id_label.replace('{id}', existingJob.id)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={() => goto(ROUTES.translate.list())}
|
||||
class="px-4 py-2 border border-border-strong text-text rounded-lg hover:bg-surface-muted transition-colors"
|
||||
>
|
||||
{$t.translate?.config?.cancel}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={isSaving}
|
||||
class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? $t.translate?.config?.saving : $t.translate?.config?.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Config -->
|
||||
{#if m.activeTab === 'config'}
|
||||
<div class="bg-surface-card border border-border rounded-lg p-6 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.name || 'Name'}</label><input bind:value={m.name} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.description || 'Description'}</label><input bind:value={m.description} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.datasource_id || 'Datasource ID'}</label><input bind:value={m.sourceDatasourceId} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.source_table || 'Source Table'}</label><input bind:value={m.sourceTable} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.translation_column || 'Translation Column'}</label><input bind:value={m.translationColumn} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.target_column || 'Target Column'}</label><input bind:value={m.targetColumn} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.target_schema || 'Target Schema'}</label><input bind:value={m.targetSchema} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.target_table || 'Target Table'}</label><input bind:value={m.targetTable} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.target_languages || 'Target Languages'}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each ['en','ru','de','fr','es','it','pt','zh','ja','ko'] as lang}
|
||||
<label class="flex items-center gap-1 text-sm cursor-pointer"><input type="checkbox" checked={m.targetLanguages.includes(lang)} onchange={() => { if (m.targetLanguages.includes(lang)) m.targetLanguages = m.targetLanguages.filter(l => l !== lang); else m.targetLanguages = [...m.targetLanguages, lang]; }} /> {lang.toUpperCase()}</label>
|
||||
{/each}
|
||||
<!-- Warnings -->
|
||||
{#if warnings.length > 0}
|
||||
<div class="bg-warning-light border border-warning rounded-lg p-4 mb-6">
|
||||
<h4 class="text-sm font-medium text-warning mb-2">{$t.translate?.config?.warnings_title}</h4>
|
||||
<ul class="list-disc list-inside text-sm text-warning space-y-1">
|
||||
{#each warnings as w}
|
||||
<li>{w}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if uxState === 'loading'}
|
||||
<div class="space-y-6">
|
||||
{#each Array(6) as _}
|
||||
<div class="h-16 bg-surface-muted rounded-lg animate-pulse" />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
{:else if uxState === 'error'}
|
||||
<div class="bg-destructive-light border border-destructive-light rounded-lg p-6 text-center">
|
||||
<p class="text-destructive mb-3">{error || $t.translate?.common?.unknown_error}</p>
|
||||
<button
|
||||
onclick={() => goto(ROUTES.translate.list())}
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
{$t.translate?.config?.back_to_jobs}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Datasource Unavailable -->
|
||||
{:else if uxState === 'datasource_unavailable'}
|
||||
<div class="bg-warning-light border border-orange-200 rounded-lg p-4 mb-6">
|
||||
<p class="text-warning text-sm">
|
||||
{$t.translate?.config?.datasource_unavailable}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
{:else if uxState === 'configured' || uxState === 'saving' || uxState === 'validation_error' || uxState === 'idle'}
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-border mb-6">
|
||||
<nav class="flex space-x-1" aria-label="Tabs">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
onclick={() => { activeTab = tab.id; }}
|
||||
disabled={(tab.id === 'preview' && (!configValid || isNewJob)) || (tab.id === 'target' && isNewJob)}
|
||||
class="px-4 py-2.5 text-sm font-medium rounded-t-lg transition-colors
|
||||
{activeTab === tab.id
|
||||
? 'bg-surface-card text-primary border border-b-white border-border -mb-px'
|
||||
: 'text-text-muted hover:text-text hover:bg-surface-muted'}
|
||||
{(tab.id === 'preview' && (!configValid || isNewJob)) || (tab.id === 'target' && isNewJob) ? 'opacity-40 cursor-not-allowed' : ''}"
|
||||
title={tab.id === 'preview' && (!configValid || isNewJob) ? ($t.translate?.preview?.config_incomplete || 'Configuration incomplete') : tab.id === 'target' && isNewJob ? ($t.translate?.config?.save_job_first || 'Save the job first') : ''}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Config tab: always mounted to preserve state across tab switches -->
|
||||
<div class={activeTab === 'config' ? '' : 'hidden'}>
|
||||
<ConfigTabForm
|
||||
bind:name
|
||||
bind:description
|
||||
bind:environmentId
|
||||
{environments}
|
||||
bind:datasourceId
|
||||
bind:datasourceSearch
|
||||
bind:databaseDialect
|
||||
bind:availableColumns
|
||||
bind:virtualColumns
|
||||
bind:sourceKeyCols
|
||||
bind:targetKeyCols
|
||||
bind:translationColumn
|
||||
bind:contextColumns
|
||||
bind:targetLanguages
|
||||
bind:targetLanguageColumn
|
||||
bind:targetSourceColumn
|
||||
bind:targetSourceLanguageColumn
|
||||
bind:providerId
|
||||
{llmProviders}
|
||||
bind:dictionaryIds
|
||||
{availableDictionaries}
|
||||
bind:disableReasoning
|
||||
{validationErrors}
|
||||
onEnvChange={handleEnvChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Schedule tab: mounted when not on new-job page, hidden when not active -->
|
||||
{#if !isNewJob}
|
||||
<div class={activeTab === 'schedule' ? '' : 'hidden'}>
|
||||
<section class="bg-surface-card border border-border rounded-lg">
|
||||
<ScheduleConfig {jobId} />
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Preview tab: always mounted to preserve state across tab switches -->
|
||||
<div class={activeTab === 'preview' ? '' : 'hidden'}>
|
||||
{#if !isNewJob && existingJob}
|
||||
{#if !configValid}
|
||||
<div class="bg-warning-light border border-warning rounded-lg p-6 text-center">
|
||||
<p class="text-sm text-warning font-medium mb-2">
|
||||
{$t.translate?.preview?.config_incomplete}
|
||||
</p>
|
||||
<p class="text-xs text-warning">
|
||||
{$t.translate?.preview?.config_requirements}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TranslationPreview {jobId} {environmentId} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-text-muted">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.provider_id || 'LLM Provider'}</label><input bind:value={m.providerId} class="w-full px-3 py-2 border border-border-strong rounded text-sm" placeholder="auto" /></div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.batch_size || 'Batch Size'}</label><input type="number" bind:value={m.batchSize} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.upsert || 'Upsert'}</label><select bind:value={m.upsertStrategy} class="w-full px-3 py-2 border border-border-strong rounded text-sm"><option value="MERGE">MERGE</option><option value="INSERT">INSERT</option></select></div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-border">
|
||||
<button class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover text-sm disabled:opacity-50" disabled={m.isSaving} onclick={() => m.saveJob()}>{m.isSaving ? $t.common?.saving : $t.common?.save}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tab: Preview -->
|
||||
{#if m.activeTab === 'preview' && m.existingJob}
|
||||
<div class="bg-surface-card border border-border rounded-lg p-6 text-center">
|
||||
<p class="text-text-muted text-sm">{$t.translate?.job?.preview_placeholder || 'Preview will be shown here after saving the config.'}</p>
|
||||
<p class="text-xs text-text-subtle mt-1">{$t.translate?.job?.preview_hint || 'Translation quality preview requires a saved job with valid config.'}</p>
|
||||
<!-- Target Config tab: target table configuration for inserts — after preview, before run -->
|
||||
<div class={activeTab === 'target' ? '' : 'hidden'}>
|
||||
{#if !isNewJob && existingJob}
|
||||
<TargetTabForm
|
||||
bind:targetSchema
|
||||
bind:targetTable
|
||||
bind:targetDatabaseId
|
||||
bind:targetColumn
|
||||
bind:targetLanguageColumn
|
||||
bind:targetSourceColumn
|
||||
bind:targetSourceLanguageColumn
|
||||
bind:targetKeyCols
|
||||
bind:translationColumn
|
||||
bind:batchSize
|
||||
bind:includeSourceReference
|
||||
bind:upsertStrategy
|
||||
{environmentId}
|
||||
{databases}
|
||||
{databasesLoading}
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-text-muted">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tab: Target -->
|
||||
{#if m.activeTab === 'target' && m.existingJob}
|
||||
<div class="bg-surface-card border border-border rounded-lg p-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.target_schema || 'Target Schema'}</label><input bind:value={m.targetSchema} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.target_table || 'Target Table'}</label><input bind:value={m.targetTable} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.target_language_column || 'Language Column'}</label><input bind:value={m.targetLanguageColumn} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
<div><label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.target_source_column || 'Source Column'}</label><input bind:value={m.targetSourceColumn} class="w-full px-3 py-2 border border-border-strong rounded text-sm" /></div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-4 pt-4 border-t border-border">
|
||||
<button class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover text-sm disabled:opacity-50" disabled={m.isSaving} onclick={() => m.saveJob()}>{m.isSaving ? $t.common?.saving : $t.common?.save}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tab: Run -->
|
||||
{#if m.activeTab === 'run'}
|
||||
<div class="bg-surface-card border border-border rounded-lg p-6 text-center">
|
||||
<p class="text-text-muted text-sm">{$t.translate?.job?.run_section || 'Translation execution is handled on the History page.'}</p>
|
||||
<a href={ROUTES.translate.history()} class="text-primary text-sm hover:underline mt-2 inline-block">{$t.translate?.job?.view_history || 'View translation history'}</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tab: Schedule -->
|
||||
{#if m.activeTab === 'schedule' && m.existingJob}
|
||||
<div class="bg-surface-card border border-border rounded-lg p-6">
|
||||
<label class="block text-sm font-medium text-text mb-1">{$t.translate?.job?.schedule || 'Cron Schedule'}</label>
|
||||
<input bind:value={m.schedule} placeholder="0 * * * * (every hour)" class="w-full px-3 py-2 border border-border-strong rounded text-sm font-mono mb-4" />
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover text-sm disabled:opacity-50" disabled={m.isSaving} onclick={() => m.saveJob()}>{m.isSaving ? $t.common?.saving : $t.common?.save}</button>
|
||||
<!-- Run tab: always mounted to preserve state across tab switches -->
|
||||
<div class={activeTab === 'run' ? '' : 'hidden'}>
|
||||
{#if !isNewJob && existingJob}
|
||||
<RunTabContent
|
||||
bind:status
|
||||
{jobId}
|
||||
{isRunning}
|
||||
{isFullRun}
|
||||
{runError}
|
||||
{currentRunId}
|
||||
{runComplete}
|
||||
{completedRuns}
|
||||
{expandedRunIds}
|
||||
bind:showPageBulkReplace
|
||||
onTriggerRun={handleTriggerRun}
|
||||
onRetryRun={handleRetryRun}
|
||||
onRetryInsert={handleRetryInsert}
|
||||
onLoadRunHistory={loadRunHistory}
|
||||
onToggleRunDetails={toggleRunDetails}
|
||||
onBulkReplaceApplied={() => { showPageBulkReplace = false; loadRunHistory(); }}
|
||||
onBulkReplaceClose={() => showPageBulkReplace = false}
|
||||
{getJobStatusLabel}
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-surface-muted border border-border rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-text-muted">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<BulkReplaceModal
|
||||
show={showPageBulkReplace}
|
||||
runId={completedRuns[0]?.id || ''}
|
||||
targetLanguages={targetLanguages}
|
||||
onClose={() => showPageBulkReplace = false}
|
||||
onApplied={(count) => { showPageBulkReplace = false; loadRunHistory(); }}
|
||||
/>
|
||||
<!-- #endregion TranslationJobConfig -->
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
let page_size = 20;
|
||||
let currentPage = $state(1);
|
||||
let showCreateForm = $state(false);
|
||||
let createForm = $state({ name: '', source_dialect: '', target_dialect: '', description: '' });
|
||||
let createForm = $state({ name: '', description: '' });
|
||||
let isCreating = $state(false);
|
||||
let deleteConfirmId = $state(null);
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!createForm.name || !createForm.source_dialect || !createForm.target_dialect) {
|
||||
if (!createForm.name) {
|
||||
addToast(_('translate.dictionaries.name_required'), 'error');
|
||||
return;
|
||||
}
|
||||
@@ -44,7 +44,7 @@
|
||||
await dictionaryApi.createDictionary(createForm);
|
||||
addToast(_('translate.dictionaries.dict_created'), 'success');
|
||||
showCreateForm = false;
|
||||
createForm = { name: '', source_dialect: '', target_dialect: '', description: '' };
|
||||
createForm = { name: '', description: '' };
|
||||
currentPage = 1;
|
||||
await loadDictionaries();
|
||||
} catch (e) {
|
||||
@@ -116,26 +116,6 @@
|
||||
class="flex h-10 w-full rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="dict-source" class="text-sm font-medium text-text">{$t.translate?.dictionaries?.source_dialect} *</label>
|
||||
<input
|
||||
id="dict-source"
|
||||
bind:value={createForm.source_dialect}
|
||||
placeholder={$t.translate?.dictionaries?.source_dialect_placeholder}
|
||||
class="flex h-10 w-full rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="dict-target" class="text-sm font-medium text-text">{$t.translate?.dictionaries?.target_dialect} *</label>
|
||||
<input
|
||||
id="dict-target"
|
||||
bind:value={createForm.target_dialect}
|
||||
placeholder={$t.translate?.dictionaries?.target_dialect_placeholder}
|
||||
class="flex h-10 w-full rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="dict-desc" class="text-sm font-medium text-text">{$t.translate?.dictionaries?.desc}</label>
|
||||
<textarea
|
||||
@@ -151,7 +131,7 @@
|
||||
variant="secondary"
|
||||
onclick={() => {
|
||||
showCreateForm = false;
|
||||
createForm = { name: '', source_dialect: '', target_dialect: '', description: '' };
|
||||
createForm = { name: '', description: '' };
|
||||
}}
|
||||
>
|
||||
{$t.translate?.common?.cancel}
|
||||
@@ -187,12 +167,9 @@
|
||||
onclick={() => goToEditor(dict.id)}
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-text hover:text-primary truncate">{dict.name}</h3>
|
||||
<p class="text-xs text-text-muted mt-0.5">
|
||||
{dict.source_dialect} → {dict.target_dialect}
|
||||
{#if dict.description}
|
||||
— {dict.description}
|
||||
{/if}
|
||||
</p>
|
||||
{#if dict.description}
|
||||
<p class="text-xs text-text-muted mt-0.5">{dict.description}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-text-subtle mt-0.5">
|
||||
{$t.translate?.dictionaries?.entry_count.replace('{count}', dict.entry_count ?? 0)}
|
||||
{#if !dict.is_active}
|
||||
|
||||
@@ -43,12 +43,22 @@
|
||||
<div class="bg-surface-card border border-border rounded-lg p-4 mb-4">
|
||||
<h3 class="text-sm font-semibold mb-3">{$t.translate?.dictionaries?.new_entry}</h3>
|
||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||
<input placeholder="Source term" bind:value={m.addForm.source_term} class="px-3 py-2 border border-border-strong rounded text-sm" />
|
||||
<input placeholder="Target term" bind:value={m.addForm.target_term} class="px-3 py-2 border border-border-strong rounded text-sm" />
|
||||
<input placeholder="Source language" bind:value={m.addForm.source_language} class="px-3 py-2 border border-border-strong rounded text-sm" />
|
||||
<input placeholder="Target language" bind:value={m.addForm.target_language} class="px-3 py-2 border border-border-strong rounded text-sm" />
|
||||
<input placeholder={$t.translate?.dictionaries?.source_term_placeholder || 'Source term'} bind:value={m.addForm.source_term} class="px-3 py-2 border border-border-strong rounded text-sm" />
|
||||
<input placeholder={$t.translate?.dictionaries?.target_term_placeholder || 'Target term'} bind:value={m.addForm.target_term} class="px-3 py-2 border border-border-strong rounded text-sm" />
|
||||
<select bind:value={m.addForm.source_language} class="px-3 py-2 border border-border-strong rounded text-sm">
|
||||
<option value="und">—</option>
|
||||
{#each m.languageOptions as opt}
|
||||
<option value={opt.code}>{opt.name} ({opt.code})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={m.addForm.target_language} class="px-3 py-2 border border-border-strong rounded text-sm">
|
||||
<option value="und">—</option>
|
||||
{#each m.languageOptions as opt}
|
||||
<option value={opt.code}>{opt.name} ({opt.code})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<input placeholder="Context notes" bind:value={m.addForm.context_notes} class="w-full px-3 py-2 border border-border-strong rounded text-sm mb-3" />
|
||||
<input placeholder={$t.translate?.dictionaries?.context_notes_placeholder || 'Context notes'} bind:value={m.addForm.context_notes} class="w-full px-3 py-2 border border-border-strong rounded text-sm mb-3" />
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="px-3 py-1.5 border rounded hover:bg-surface-muted text-sm" onclick={() => { m.showAddForm = false; }}>{$t.common?.cancel}</button>
|
||||
<button class="px-3 py-1.5 bg-primary text-white rounded hover:bg-primary-hover text-sm" onclick={() => m.addEntry()} disabled={m.isAdding}>{m.isAdding ? $t.common?.saving : $t.translate?.dictionaries?.save}</button>
|
||||
@@ -74,7 +84,7 @@
|
||||
{#if m.appState === 'import_preview' && m.importPreview.length > 0}
|
||||
<div class="text-xs text-text-muted mb-1">{$t.translate?.dictionaries?.preview_count?.replace('{count}', String(m.importPreview.length))}</div>
|
||||
<div class="max-h-40 overflow-auto border border-border rounded mb-3">
|
||||
<table class="min-w-full text-xs"><thead class="bg-surface-muted"><tr><th class="px-2 py-1 text-left">Source</th><th class="px-2 py-1 text-left">Target</th></tr></thead>
|
||||
<table class="min-w-full text-xs"><thead class="bg-surface-muted"><tr><th class="px-2 py-1 text-left">{$t.translate?.dictionaries?.import_table_source || 'Source'}</th><th class="px-2 py-1 text-left">{$t.translate?.dictionaries?.import_table_target || 'Target'}</th></tr></thead>
|
||||
<tbody class="divide-y divide-border">{#each m.importPreview as row}<tr><td class="px-2 py-1">{(row as Record<string,unknown>).source_term || (row as Record<string,unknown>).source || '-'}</td><td class="px-2 py-1">{(row as Record<string,unknown>).target_term || (row as Record<string,unknown>).target || '-'}</td></tr>{/each}</tbody></table>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -88,7 +98,12 @@
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="mb-4 flex gap-2 items-center">
|
||||
<input type="text" placeholder={$t.translate?.dictionaries?.filter_language || 'Filter source language'} bind:value={m.filterSourceLanguage} class="px-3 py-2 border border-border-strong rounded-lg text-sm w-48" />
|
||||
<select bind:value={m.filterSourceLanguage} class="px-3 py-2 border border-border-strong rounded-lg text-sm w-48">
|
||||
<option value="">{$t.translate?.dictionaries?.filter_language || 'Filter source language'}</option>
|
||||
{#each m.languageOptions as opt}
|
||||
<option value={opt.code}>{opt.name} ({opt.code})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="px-3 py-1.5 bg-primary text-white rounded text-sm hover:bg-primary-hover" onclick={() => { m.entryPage = 1; m.loadEntries(); }}>{$t.common?.search}</button>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +123,21 @@
|
||||
<tr class="bg-primary-light">
|
||||
<td class="px-4 py-2"><input bind:value={m.editForm.source_term} class="w-full px-2 py-1 border border-border-strong rounded text-sm" /></td>
|
||||
<td class="px-4 py-2"><input bind:value={m.editForm.target_term} class="w-full px-2 py-1 border border-border-strong rounded text-sm" /></td>
|
||||
<td class="px-4 py-2"><div class="flex gap-1 items-center"><input bind:value={m.editForm.source_language} class="w-16 px-2 py-1 border border-border-strong rounded text-xs" placeholder="src" /><span class="text-text-subtle text-xs">→</span><input bind:value={m.editForm.target_language} class="w-16 px-2 py-1 border border-border-strong rounded text-xs" placeholder="tgt" /></div></td>
|
||||
<td class="px-4 py-2"><div class="flex gap-1 items-center">
|
||||
<select bind:value={m.editForm.source_language} class="w-24 px-1 py-1 border border-border-strong rounded text-xs">
|
||||
<option value="">—</option>
|
||||
{#each m.languageOptions as opt}
|
||||
<option value={opt.code}>{opt.code}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="text-text-subtle text-xs">→</span>
|
||||
<select bind:value={m.editForm.target_language} class="w-24 px-1 py-1 border border-border-strong rounded text-xs">
|
||||
<option value="">—</option>
|
||||
{#each m.languageOptions as opt}
|
||||
<option value={opt.code}>{opt.code}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div></td>
|
||||
<td class="px-4 py-2 text-right"><div class="flex gap-1 justify-end">
|
||||
<button class="px-3 py-1 bg-success text-white rounded text-xs hover:bg-success-hover" onclick={() => m.saveEntry()} disabled={m.isSaving}>✓</button>
|
||||
<button class="px-3 py-1 border rounded text-xs hover:bg-surface-muted" onclick={() => m.cancelEdit()}>×</button>
|
||||
|
||||
Reference in New Issue
Block a user