refactor(frontend): extract TranslationJobModel for translate job config page

Translation job config: 835→131 lines (-84%).
Model: 5-step wizard (Config, Preview, Target, Run, Schedule),
form state management, saveJob() with method detection.

3→2 oversized pages remaining.
This commit is contained in:
2026-06-02 18:40:23 +03:00
parent 860791223c
commit 09ee1143d5
2 changed files with 241 additions and 808 deletions

View File

@@ -0,0 +1,136 @@
// #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.
// @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.
// @RELATION DEPENDS_ON -> [api]
// @RELATION CALLS -> [log]
// @INVARIANT DECOMPOSITION GATE: 49+ atoms, 500+ lines. Split into submodels when >400 lines.
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';
type UxState = 'idle' | 'configuring' | 'saving' | 'validation_error' | 'datasource_unavailable' | 'error' | 'run_started';
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);
existingJob: Record<string, unknown> | null = $state(null);
// ── Config tab ────────────────────────────────────────────────
name: string = $state('');
description: string = $state('');
sourceDatasourceId: string = $state('');
sourceTable: string = $state('');
targetSchema: string = $state('');
targetTable: string = $state('');
sourceKeyCols: string[] = $state([]);
targetKeyCols: string[] = $state([]);
translationColumn: string = $state('');
targetColumn: string = $state('');
targetLanguageColumn: string = $state('');
targetSourceColumn: string = $state('');
targetSourceLanguageColumn: string = $state('');
contextColumns: string[] = $state([]);
targetLanguages: string[] = $state(['en']);
includeSourceReference: boolean = $state(true);
providerId: string = $state('');
batchSize: number = $state(50);
disableReasoning: boolean = $state(false);
upsertStrategy: string = $state('MERGE');
dictionaryIds: 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
);
// ── Actions ───────────────────────────────────────────────────
async loadJob(): Promise<void> {
if (this.isNewJob) { this.uxState = 'configuring'; return; }
this.uxState = 'idle';
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';
} catch (err: unknown) {
this.error = err instanceof Error ? err.message : 'Failed to load job';
this.uxState = 'error';
}
}
async saveJob(): Promise<void> {
this.uxState = 'saving';
this.error = null;
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,
upsert_strategy: this.upsertStrategy,
target_schema: this.targetSchema || undefined, target_table: this.targetTable || undefined,
};
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';
} catch (err: unknown) {
this.error = err instanceof Error ? err.message : 'Failed to save job';
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

View File

@@ -1,835 +1,132 @@
<!-- #region TranslationJobConfig [C:4] [TYPE Page] [SEMANTICS sveltekit, translate, job, config, llm, datasource] -->
<!-- @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. -->
<!-- @BRIEF Translation job config wizard — renders TranslationJobModel state. 5 tabs: Config, Preview, Target, Run, Schedule. -->
<!-- @LAYER Page -->
<!-- @RELATION BINDS_TO -> [TranslationJobModel] -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { ROUTES } from '$lib/routes';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { t, _ } from '$lib/i18n/index.svelte.js';
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';
import { ROUTES } from '$lib/routes';
import { TranslationJobModel } from '$lib/models/TranslationJobModel.svelte';
const m = new TranslationJobModel();
/** @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;
});
// Derived
let columnList = $derived(availableColumns);
let logicalColumns = $derived(availableColumns.filter(c => c.is_physical !== false));
let virtualColumnNames = $derived(virtualColumns);
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();
}
m.jobId = page.params.id;
if (m.jobId) m.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;
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 },
]);
// 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');
}
function goBack() { window.history.back(); }
</script>
<svelte:head>
<title>{pageTitle}</title>
</svelte:head>
<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>
<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>
<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>
<!-- 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>
<!-- 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}
</div>
</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>
</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}
<!-- 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>
</div>
{/if}
<!-- 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>
<!-- 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>
{/if}
</div>
<!-- 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 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>
{/if}
</div>
<!-- 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 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>
</div>
{/if}
</div>
{/if}
</div>
<BulkReplaceModal
show={showPageBulkReplace}
runId={completedRuns[0]?.id || ''}
targetLanguages={targetLanguages}
onClose={() => showPageBulkReplace = false}
onApplied={(count) => { showPageBulkReplace = false; loadRunHistory(); }}
/>
<!-- #endregion TranslationJobConfig -->