When opening a saved translation job, the datasource search input was empty
because datasourceSearch was never populated from the loaded job data.
The raw datasourceId was loaded correctly, but the display name
("table_name (database · dialect)") was missing.
Added fetchDatasources lookup in loadInitialData after environmentId is set:
finds the matching datasource by ID in the list and sets datasourceSearch
to the same format used in selectDatasource().
322 lines
16 KiB
TypeScript
322 lines
16 KiB
TypeScript
// #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<string, unknown> | 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<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: 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<string, unknown>) => c.is_physical !== false));
|
|
virtualColumnNames = $derived(this.virtualColumns);
|
|
isSaving: boolean = $state(false);
|
|
validationErrors: Record<string, unknown> = $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<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);
|
|
|
|
// ── Actions: Run ─────────────────────────────────────────────
|
|
|
|
async handleTriggerRun(full = false): Promise<void> {
|
|
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<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<unknown>('/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<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.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<Record<string, unknown>[]>(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<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 fetchDatasourceColumns<{ columns?: Record<string, unknown>[]; virtual?: Record<string, unknown>[] }>(this.datasourceId, this.environmentId);
|
|
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.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
|