Files
ss-tools/frontend/src/lib/models/TranslationJobModel.svelte.ts
busya ebbbd51230 fix(translate): restore datasource display name on job page load
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().
2026-06-03 11:52:38 +03:00

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