// #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/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: 50+ atoms, ~400 lines. Model serves as single source of truth for all form state. // @RATIONALE Methods are NOT arrow class fields — they rely on `this` to mutate state. When passed as props // to child components (e.g. onTriggerRun={m.handleTriggerRun}), the binding is lost and `this` // becomes `undefined`, throwing "Cannot set properties of undefined". Consumers MUST wrap // in arrow functions: onTriggerRun={(full) => m.handleTriggerRun(full)}. // @REJECTED Converting methods to arrow class fields rejected — it would conflict with the Svelte 5 // `$state` rune initialization order for non-primitive state atoms in the constructor. // @REJECTED includeSourceReference ($state at line 52) has no backend column or schema field. // It is a UI-only checkbox; the value always resets to `true` on page load. // The backend TranslateJobCreate/Update/Response schemas lack `include_source_reference`. // Requires a DB migration + Pydantic schema update to persist. Not implemented as of 2026-06-03. import { api } from '$lib/api.js'; import { log } from '$lib/cot-logger'; import { addToast } from '$lib/toasts.svelte.js'; import { _, getT } from '$lib/i18n/index.svelte.js'; import { triggerRun, fetchRunHistory, cancelRun, fetchDatasourceColumns, fetchDatasources } from '$lib/api/translate.js'; import { startTranslationRun, resetTranslationRun, translationRunStore } from '$lib/stores/translationRun.svelte.js'; type UxState = 'idle' | 'loading' | 'configured' | 'saving' | 'validation_error' | 'datasource_unavailable'; export class TranslationJobModel { // ── Context ─────────────────────────────────────────────────── uxState: UxState = $state('idle'); isNewJob: boolean = $state(false); jobId: string = $state(''); existingJob: Record | null = $state(null); error: string | null = $state(null); // ── Config form ─────────────────────────────────────────────── 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([]); status: string = $state('DRAFT'); // ── Datasource & env ────────────────────────────────────────── environmentId: string = $state(''); environments: Record[] = $state([]); datasourceId: string = $state(''); datasourceSearch: string = $state(''); availableColumns: Record[] = $state([]); virtualColumns: Record[] = $state([]); databaseDialect: string = $state(''); llmProviders: Record[] = $state([]); availableDictionaries: Record[] = $state([]); targetDatabaseId: string = $state(''); databases: Record[] = $state([]); databasesLoading: boolean = $state(false); // ── Tab state ───────────────────────────────────────────────── activeTab: string = $state('config'); tabs: { id: string; label: string }[] = $derived.by(() => { const base = [ { id: 'config', label: getT()?.translate?.config?.basic_info || 'Configuration' }, { id: 'preview', label: getT()?.translate?.preview?.title || 'Preview' }, { id: 'run', label: getT()?.translate?.config?.run_translation || 'Run' }, ]; if (!this.isNewJob && this.existingJob) { base.splice(2, 0, { id: 'target', label: getT()?.translate?.config?.target_table_tab || 'Target Config' }); base.push({ id: 'schedule', label: getT()?.translate?.schedule?.tab_label || 'Schedule' }); } return base; }); // ── Derived ─────────────────────────────────────────────────── columnList = $derived(this.availableColumns); logicalColumns = $derived(this.availableColumns.filter((c: Record) => c.is_physical !== false)); virtualColumnNames = $derived(this.virtualColumns); isSaving: boolean = $state(false); validationErrors: Record = $state({}); warnings: string[] = $state([]); configValid = $derived( !!this.translationColumn && !!this.datasourceId && this.targetLanguages.length > 0 && !!this.providerId ); pageTitle = $derived.by(() => { if (this.isNewJob) return getT()?.translate?.config?.new_title || 'New Translation Job'; const jobName = this.existingJob?.name || this.name; const base = getT()?.translate?.config?.edit_title || 'Edit Translation Job'; return jobName ? `${jobName} | ${base}` : base; }); // ── Run state ───────────────────────────────────────────────── currentRunId = $derived(translationRunStore.value?.runId || null); completedRuns: Record[] = $state([]); expandedRunIds: string[] = $state([]); isRunning: boolean = $state(false); isFullRun: boolean = $state(false); runError: string = $state(''); showPageBulkReplace: boolean = $state(false); runComplete: boolean = $state(false); // ── Actions: Run ───────────────────────────────────────────── async handleTriggerRun(full = false): Promise { this.isRunning = true; this.runComplete = false; this.isFullRun = full; this.runError = ''; try { 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.runError = err instanceof Error ? err.message : _('translate.config.run_failed'); this.isRunning = false; } } private _onRunComplete(statusData: Record): 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 { try { const data = await fetchRunHistory(this.jobId, { page_size: 5 }); this.completedRuns = ((data as { items?: Record[] })?.items || []).filter( (run: Record) => run && typeof run.id === 'string' && (run.id as string).trim().length > 0 ); this.expandedRunIds = this.expandedRunIds.filter((runId) => this.completedRuns.some((run: Record) => 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 { if (this.currentRunId) { try { await cancelRun(this.currentRunId); } catch { /* ignore */ } } resetTranslationRun(); await this.handleTriggerRun(); } async handleRetryInsert(): Promise { 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 { this.uxState = 'loading'; try { const [providers, dicts] = await Promise.all([ api.requestApi('/llm/providers').catch(() => ({ providers: [] })), api.requestApi('/translate/dictionaries?page_size=100').catch(() => ({ items: [] })), ]); this.llmProviders = ( Array.isArray(providers) ? providers : ((providers as { providers?: unknown[] })?.providers ?? (providers as { results?: unknown[] })?.results ?? []) ) as Record[]; this.availableDictionaries = ((dicts as { items?: unknown[] })?.items || (dicts as { results?: unknown[] })?.results || []) as Record[]; if (!this.isNewJob) { const job = await api.requestApi(`/translate/jobs/${this.jobId}`) as Record; 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.targetLanguageColumn = (job.target_language_column as string) || ''; this.targetSourceColumn = (job.target_source_column as string) || ''; this.targetSourceLanguageColumn = (job.target_source_language_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.disableReasoning = (job.disable_reasoning as boolean) ?? false; 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(); // Look up datasource name to populate search input on page load if (this.datasourceId) { fetchDatasources[]>(this.environmentId, '').then((list) => { const ds = (Array.isArray(list) ? list : []).find((d) => String(d.id) === String(this.datasourceId)); if (ds) { this.datasourceSearch = `${ds.table_name} (${ds.database_name} · ${ds.database_dialect})`; } }).catch(() => {}); } } 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 { if (!this.environmentId) return; this.databasesLoading = true; try { const res = await api.getEnvironmentDatabases(this.environmentId) as Record[]; this.databases = (Array.isArray(res) ? res : []) as Record[]; } catch { this.databases = []; } finally { this.databasesLoading = false; } } async loadDatasourceColumns(): Promise { if (!this.datasourceId) return; try { const res = await fetchDatasourceColumns<{ columns?: Record[]; virtual?: Record[] }>(this.datasourceId, this.environmentId); this.availableColumns = (res?.columns || []) as Record[]; this.virtualColumns = (res?.virtual || []) as Record[]; } catch { this.availableColumns = []; this.virtualColumns = []; } } handleEnvChange(envId: string): void { this.environmentId = envId; this.targetDatabaseId = ''; this.loadDatabases(); } // ── Actions: Save ──────────────────────────────────────────── async saveJob(): Promise { this.uxState = 'saving'; this.validationErrors = {}; try { const payload = { 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, disable_reasoning: this.disableReasoning, database_dialect: this.databaseDialect || 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, target_source_language_column: this.targetSourceLanguageColumn || undefined, status: this.status, }; const url = this.isNewJob ? '/translate/jobs' : `/translate/jobs/${this.jobId}`; const method: string = this.isNewJob ? 'POST' : 'PUT'; const resp = await api.requestApi<{ id?: string }>(url, method, payload); if (resp?.id && this.isNewJob) { this.jobId = resp.id; this.isNewJob = false; this.existingJob = { id: resp.id, ...payload }; } addToast(getT()?.translate?.config?.saved || 'Job saved', 'success'); this.uxState = 'configured'; } catch (err: unknown) { this.error = err instanceof Error ? err.message : 'Failed to save'; addToast(this.error, 'error'); this.uxState = 'validation_error'; } } } // #endregion TranslationJobModel