Files
ss-tools/frontend/src/lib/models/DictionaryDetailModel.svelte.ts
busya fbb6a78d7d refactor(frontend): add comprehensive TranslationJobModel for translate/[id] page
Model covers all 49  atoms: Config, Preview, Target, Run, Schedule tabs.
Environment switching, datasource loading, saveJob(), run lifecycle.
Page not yet bound — requires manual template migration due to
complex bind: short syntax and sub-component dependencies.

DictionaryDetail page: 822→166 (-80%) with DictionaryDetailModel.
3→2 oversized pages remaining (dashboards, migration).
2026-06-02 19:54:15 +03:00

224 lines
10 KiB
TypeScript

// #region DictionaryDetailModel [C:4] [TYPE Model] [SEMANTICS translate,dictionary,entry,crud,import,model]
// @BRIEF Screen model for dictionary detail page — manages entry list, CRUD operations, and CSV import.
// @INVARIANT Expanding an entry clears previous expanded state.
// @INVARIANT Import preview is cleared when import form is closed.
// @STATE loading | editing | importing | import_preview | import_conflict | saving
// @ACTION loadDictionary() / loadEntries() — Fetch data.
// @ACTION saveEntry() / addEntry() / deleteEntry() — CRUD.
// @ACTION importDictionary() / previewImport() — Import flow.
// @RELATION DEPENDS_ON -> [api]
// @RELATION CALLS -> [log]
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';
import { LANGUAGE_LABELS, ALL_LANGUAGES } from '$lib/i18n/languages.js';
type AppState = 'idle' | 'loading' | 'editing' | 'importing' | 'import_preview' | 'import_conflict' | 'saving';
interface DictionaryEntry {
id: string;
source_term: string;
target_term: string;
source_language?: string;
target_language?: string;
context_notes?: string;
context_data?: unknown;
usage_notes?: string;
}
interface EditForm {
source_term: string;
target_term: string;
source_language: string;
target_language: string;
context_notes: string;
context_data: unknown;
usage_notes: string;
}
interface ImportResult {
imported?: number;
skipped?: number;
errors?: string[];
}
export class DictionaryDetailModel {
// ── Dictionary data ───────────────────────────────────────────
dictionary: Record<string, unknown> | null = $state(null);
entries: DictionaryEntry[] = $state([]);
totalEntries: number = $state(0);
entryPage: number = $state(1);
appState: AppState = $state('loading');
// ── Edit ──────────────────────────────────────────────────────
editEntryId: string | null = $state(null);
editForm: EditForm = $state({ source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '', context_data: null, usage_notes: '' });
editContextDataRaw: string = $state('');
// ── Add new ───────────────────────────────────────────────────
showAddForm: boolean = $state(false);
addForm: { source_term: string; target_term: string; source_language: string; target_language: string; context_notes: string } = $state({ source_term: '', target_term: '', source_language: 'und', target_language: 'und', context_notes: '' });
isAdding: boolean = $state(false);
// ── Expand (view detail) ──────────────────────────────────────
expandedEntryId: string | null = $state(null);
expandedEditing: boolean = $state(false);
expandedContextData: string = $state('');
expandedUsageNotes: string = $state('');
// ── Import ────────────────────────────────────────────────────
showImportForm: boolean = $state(false);
importContent: string = $state('');
importDelimiter: string = $state('');
importOnConflict: string = $state('overwrite');
importPreview: unknown[] = $state([]);
importErrors: string[] = $state([]);
importResult: ImportResult | null = $state(null);
isImporting: boolean = $state(false);
// ── Filter ────────────────────────────────────────────────────
filterSourceLanguage: string = $state('');
// ── Context ───────────────────────────────────────────────────
dictionaryId: string = $state('');
allowedLanguages: string[] = $state([]);
// ── Derived ───────────────────────────────────────────────────
isLoading = $derived(this.appState === 'loading');
isEditing = $derived(this.appState === 'editing');
isSaving = $derived(this.appState === 'saving');
/** Language options for selects, filtered by allowed_languages (fallback to all). */
languageOptions = $derived(
(this.allowedLanguages.length > 0 ? ALL_LANGUAGES.filter(l => this.allowedLanguages.includes(l.code)) : ALL_LANGUAGES)
);
// ── Actions ───────────────────────────────────────────────────
async loadAllowedLanguages(): Promise<void> {
try {
this.allowedLanguages = await api.getAllowedLanguages() || [];
} catch {
// Non-critical: fall back to full list
this.allowedLanguages = [];
}
}
async loadDictionary(): Promise<void> {
if (!this.dictionaryId) return;
this.appState = 'loading';
try {
this.dictionary = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}`) as Record<string, unknown>;
this.appState = 'idle';
this.loadAllowedLanguages();
this.loadEntries();
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Failed to load dictionary', 'error');
this.appState = 'idle';
}
}
async loadEntries(): Promise<void> {
if (!this.dictionaryId) return;
try {
const params = new URLSearchParams({ page: String(this.entryPage), page_size: '50' });
if (this.filterSourceLanguage) params.append('source_language', this.filterSourceLanguage);
const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries?${params}`) as { items?: DictionaryEntry[]; entries?: DictionaryEntry[]; total?: number };
this.entries = (res.items || res.entries || []) as DictionaryEntry[];
this.totalEntries = res.total || 0;
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Failed to load entries', 'error');
}
}
startEdit(entry: DictionaryEntry): void {
this.editEntryId = entry.id;
this.editForm = { source_term: entry.source_term, target_term: entry.target_term, source_language: entry.source_language || '', target_language: entry.target_language || '', context_notes: entry.context_notes || '', context_data: entry.context_data, usage_notes: entry.usage_notes || '' };
this.editContextDataRaw = entry.context_data ? JSON.stringify(entry.context_data, null, 2) : '';
this.appState = 'editing';
}
cancelEdit(): void {
this.editEntryId = null;
this.appState = 'idle';
}
async saveEntry(): Promise<void> {
if (!this.editEntryId) return;
this.appState = 'saving';
try {
const payload = { ...this.editForm };
if (this.editContextDataRaw) {
try { payload.context_data = JSON.parse(this.editContextDataRaw); } catch { /* keep as string */ }
}
await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries/${this.editEntryId}`, 'PUT', payload);
addToast(t.translate?.dictionaries?.entry_saved || 'Entry saved', 'success');
this.cancelEdit();
this.loadEntries();
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Failed to save entry', 'error');
this.appState = 'editing';
}
}
async addEntry(): Promise<void> {
this.isAdding = true;
try {
await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries`, 'POST', this.addForm);
addToast(t.translate?.dictionaries?.entry_added || 'Entry added', 'success');
this.showAddForm = false;
this.addForm = { source_term: '', target_term: '', source_language: 'und', target_language: 'und', context_notes: '' };
this.loadEntries();
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Failed to add entry', 'error');
} finally { this.isAdding = false; }
}
async deleteEntry(entryId: string): Promise<void> {
if (!confirm(t.translate?.dictionaries?.confirm_delete || 'Delete this entry?')) return;
try {
await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries/${entryId}`, 'DELETE');
addToast(t.translate?.dictionaries?.entry_deleted || 'Entry deleted', 'success');
this.loadEntries();
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Failed to delete entry', 'error');
}
}
toggleExpand(entryId: string): void {
if (this.expandedEntryId === entryId) { this.expandedEntryId = null; return; }
this.expandedEntryId = entryId;
this.expandedEditing = false;
}
async previewImport(): Promise<void> {
if (!this.importContent) return;
this.appState = 'importing';
try {
const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/import`, 'POST', { content: this.importContent, delimiter: this.importDelimiter || undefined, on_conflict: this.importOnConflict, preview: true }) as { preview?: unknown[]; errors?: string[] };
this.importPreview = (res.preview || []) as unknown[];
this.importErrors = (res.errors || []) as string[];
this.appState = 'import_preview';
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Preview failed', 'error');
this.appState = 'idle';
}
}
async executeImport(): Promise<void> {
this.isImporting = true;
try {
const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/import`, 'POST', { content: this.importContent, delimiter: this.importDelimiter || undefined, on_conflict: this.importOnConflict }) as ImportResult;
this.importResult = res;
this.showImportForm = false;
this.loadEntries();
addToast(t.translate?.dictionaries?.import_success || `Imported ${res.imported || 0} entries`, 'success');
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Import failed', 'error');
} finally { this.isImporting = false; }
}
}
// #endregion DictionaryDetailModel