Files
ss-tools/frontend/src/lib/models/TranslationJobModel.svelte.ts
busya 27435e4d92 fix(frontend): replace $t with _t pattern in translate components
Svelte 5 compiler fails to detect Proxy-based i18n store `t` as a store
in deeply nested/complex templates and in `.svelte.ts` model files,
generating `ReferenceError: $t is not defined` at runtime.

Fix: replace `$t.` references with:
- Template: `_t.` where `const _t = $derived(getT())`
- Script:   `getT()?.` direct function call

Applied to:
- translate/[id]/+page.svelte (the failing page)
- TranslationJobModel.svelte.ts ($derived.by() in `tabs`)
- 12 translate components (ConfigTabForm, ScheduleConfig,
  TranslationPreview, TargetTabForm, RunTabContent, BulkReplaceModal,
  TranslationRunProgress, TranslationRunResult, TermCorrectionPopup,
  BulkCorrectionSidebar, TranslationMetricsDashboard, CorrectionCell,
  TranslationRunGlobalIndicator)

Verified all 5 tabs render correctly in browser:
Config, Preview, Target, Run, Schedule.

Build clean, 698/698 tests pass, 0 color violations.
2026-06-02 20:38:38 +03:00

287 lines
14 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.
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 } 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('/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.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,
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 url = this.isNewJob ? '/translate/jobs' : `/translate/jobs/${this.jobId}`;
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(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