diff --git a/frontend/src/lib/models/DictionaryDetailModel.svelte.ts b/frontend/src/lib/models/DictionaryDetailModel.svelte.ts new file mode 100644 index 00000000..0dde099e --- /dev/null +++ b/frontend/src/lib/models/DictionaryDetailModel.svelte.ts @@ -0,0 +1,210 @@ +// #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'; + +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 | 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: '', target_language: '', 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(''); + + // ── Derived ─────────────────────────────────────────────────── + isLoading = $derived(this.appState === 'loading'); + isEditing = $derived(this.appState === 'editing'); + isSaving = $derived(this.appState === 'saving'); + + // ── Actions ─────────────────────────────────────────────────── + + async loadDictionary(): Promise { + if (!this.dictionaryId) return; + this.appState = 'loading'; + try { + this.dictionary = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}`) as Record; + this.appState = 'idle'; + this.loadEntries(); + } catch (e: unknown) { + addToast(e instanceof Error ? e.message : 'Failed to load dictionary', 'error'); + this.appState = 'idle'; + } + } + + async loadEntries(): Promise { + 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 { entries?: DictionaryEntry[]; total?: number }; + this.entries = (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 { + 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}`, { method: 'PUT', body: payload }); + addToast($t.translate?.dictionary?.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 { + this.isAdding = true; + try { + await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries`, { method: 'POST', body: this.addForm }); + addToast($t.translate?.dictionary?.entry_added || 'Entry added', 'success'); + this.showAddForm = false; + this.addForm = { source_term: '', target_term: '', source_language: '', target_language: '', 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 { + if (!confirm($t.translate?.dictionary?.confirm_delete || 'Delete this entry?')) return; + try { + await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries/${entryId}`, { method: 'DELETE' }); + addToast($t.translate?.dictionary?.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 { + if (!this.importContent) return; + this.appState = 'importing'; + try { + const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/import`, { + method: 'POST', body: { 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 { + this.isImporting = true; + try { + const res = await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/import`, { + method: 'POST', body: { content: this.importContent, delimiter: this.importDelimiter || undefined, on_conflict: this.importOnConflict }, + }) as ImportResult; + this.importResult = res; + this.showImportForm = false; + this.loadEntries(); + addToast($t.translate?.dictionary?.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 diff --git a/frontend/src/routes/translate/dictionaries/[id]/+page.svelte b/frontend/src/routes/translate/dictionaries/[id]/+page.svelte index 7ee788d4..2bf16a20 100644 --- a/frontend/src/routes/translate/dictionaries/[id]/+page.svelte +++ b/frontend/src/routes/translate/dictionaries/[id]/+page.svelte @@ -1,821 +1,166 @@ + + + -
- {#snippet subtitleContent()} - {#if dictionary} -

- {dictionary.source_dialect} → {dictionary.target_dialect} - {#if dictionary.description} - — {dictionary.description} - {/if} - · {$t.translate?.dictionaries?.entry_count.replace('{count}', totalEntries)} -

- {/if} - {/snippet} +
+ - {#snippet dictActions()} - {#if state === 'editing'} + {#if m.isLoading} +
{#each Array(5) as _}
{/each}
+ {:else if m.dictionary} +
+
+

{m.dictionary.name as string}

+

{m.dictionary.description as string || $t.translate?.dictionaries?.no_description}

+
- - - + + +
+
+ + + {#if m.showAddForm} +
+

{$t.translate?.dictionaries?.new_entry}

+
+ + + + +
+ +
+ + +
{/if} - {/snippet} - - - {#if state === 'loading'} - -
- {#each [1, 2, 3] as _} -
- {/each} -
-
- - {:else if state === 'importing' || state === 'import_preview'} - -
- {#if state === 'importing'} -
- - -
-
-
- - -
-
- - -
-
-
- - -
- {/if} - - {#if state === 'import_preview'} - {#if importPreview.length > 0} -
- - - - - - - - - - - - - - {#each importPreview as row} - - - - - - - - - - {/each} - -
{$t.translate?.dictionaries?.import_table_line}{$t.translate?.dictionaries?.import_table_source}{$t.translate?.dictionaries?.import_table_target}{$t.translate?.dictionaries?.import_table_src_lang}{$t.translate?.dictionaries?.import_table_tgt_lang}{$t.translate?.dictionaries?.import_table_context}{$t.translate?.dictionaries?.import_table_conflict}
{row.line}{row.source_term}{row.target_term}{row.source_language || '—'}{row.target_language || '—'} - {#if row.has_context || row.context_data} - {$t.translate?.dictionaries?.import_yes} - {:else if row.usage_notes} - {$t.translate?.dictionaries?.import_notes} - {:else} - — - {/if} - - {#if row.is_conflict} - - {_('translate.dictionaries.import_existing').replace('{term}', row.existing_target_term)} - ({importOnConflict === 'overwrite' ? $t.translate?.dictionaries?.import_will_overwrite : $t.translate?.dictionaries?.import_will_skip}) - - {:else} - {$t.translate?.dictionaries?.import_new} - {/if} -
-
- {/if} - - {#if importErrors.length > 0} -
-

{$t.translate?.dictionaries?.import_errors.replace('{count}', importErrors.length)}

- {#each importErrors as err} -

{_('translate.dictionaries.import_error_line').replace('{line}', err.line).replace('{error}', err.error)}

- {/each} -
- {/if} - -
- - -
- {/if} -
-
- - {:else if state === 'editing'} - {#if showAddForm} - -
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
+ + {#if m.showImportForm} +
+

{$t.translate?.dictionaries?.import_entries}

+ +
+ +
- + {#if m.importErrors.length > 0} +
{#each m.importErrors as err}
{err}
{/each}
+ {/if} + {#if m.appState === 'import_preview' && m.importPreview.length > 0} +
{$t.translate?.dictionaries?.preview_count?.replace('{count}', String(m.importPreview.length))}
+
+ + {#each m.importPreview as row}{/each}
SourceTarget
{(row as Record).source_term || (row as Record).source || '-'}{(row as Record).target_term || (row as Record).target || '-'}
+
+ {/if} +
+ + + +
+
{/if} - {#if entries.length === 0} - -
-

{$t.translate?.dictionaries?.no_entries}

- -
-
- {:else} - - {#if availableSourceLanguages.length > 0 || availableTargetLanguages.length > 0} -
- {$t.translate?.dictionaries?.filter_by_language} - - - - {#if filterSourceLanguage || filterTargetLanguage} - - {_('translate.dictionaries.filter_count').replace('{filtered}', filteredEntries.length).replace('{total}', entries.length)} - {/if} -
- {/if} -
- - - - - - - - + +
+ + +
+ + +
+
{$t.translate?.dictionaries?.table_source_term}{$t.translate?.dictionaries?.table_target_term}{$t.translate?.dictionaries?.table_lang}{$t.translate?.dictionaries?.table_context}{$t.translate?.dictionaries?.table_actions}
+ + + + + + + + {#each m.entries as entry (entry.id)} + + {#if m.editEntryId === entry.id && m.appState !== 'loading'} + + + + + - - - {#each filteredEntries as entry} - {#if editEntryId === entry.id} - - - - - - - - {:else} - { - expandedEntryId = expandedEntryId === entry.id ? null : entry.id; - expandedEditing = false; - if (expandedEntryId !== entry.id) { - expandedContextData = entry.context_data ? JSON.stringify(entry.context_data, null, 2) : ''; - expandedUsageNotes = entry.usage_notes || ''; - } - }}> - - - - - - - {#if expandedEntryId === entry.id} - - - - {/if} - {/if} - {/each} - -
{$t.translate?.dictionaries?.source_term}{$t.translate?.dictionaries?.target_term}{$t.translate?.dictionaries?.languages}{$t.common?.actions}
+ + +
- - - - -
- - - -
-
- - -
- - -
-
{entry.source_term}{entry.target_term} - {#if entry.source_language || entry.target_language} - {entry.source_language || '?'} → {entry.target_language || '?'} - {:else} - - {/if} - -
- {entry.context_notes || ''} - {#if entry.has_context} - - context - - {/if} - {#if entry.usage_notes} - - notes - - {/if} -
-
- {#if deleteEntryId === entry.id} -
- {$t.translate?.dictionaries?.confirm_delete_entry} - - -
- {:else} -
- - -
- {/if} -
-
- {#if expandedEditing} - -
-
- Context data: -
- - -
-
- -
-
- - -
- {:else} - - {#if entry.has_context && entry.context_data} -
-
- {$t.translate?.dictionaries?.context_data_label} - -
-
- {#if typeof entry.context_data === 'object' && entry.context_data !== null} -
- {#each Object.entries(entry.context_data) as [key, val]} - {key}: - {typeof val === 'object' ? JSON.stringify(val) : String(val)} - {/each} -
- {:else} -
{JSON.stringify(entry.context_data, null, 2)}
- {/if} -
-
- {:else} -
-

{$t.translate?.dictionaries?.no_context_data}

- -
- {/if} - {#if entry.usage_notes} -
- {$t.translate?.dictionaries?.usage_notes_label} -

{entry.usage_notes}

-
- {:else if expandedEditing} - - {/if} - {/if} - {#if entry.context_source} -
- {$t.translate?.dictionaries?.source_label} - {entry.context_source} -
- {/if} -
-
-
-
+ {#if m.appState !== 'loading'} + + + + + {/if} + + {:else if m.expandedEntryId === entry.id} + m.toggleExpand(entry.id)}> + {entry.source_term} + {entry.target_term} + {entry.source_language || '-'} → {entry.target_language || '-'} + + + + {#if entry.context_notes}
{$t.translate?.dictionaries?.context_notes}: {entry.context_notes}
{/if} + {#if entry.context_data}
{$t.translate?.dictionaries?.context_data}: {JSON.stringify(entry.context_data)}
{/if} +
+ + +
+ + + {:else} + m.toggleExpand(entry.id)}> + {entry.source_term} + {entry.target_term} + {entry.source_language || '-'} → {entry.target_language || '-'} +
e.stopPropagation()}> + + +
+ + {/if} + {/each} + + +
- {#if totalEntries > entryPageSize} -
- - - {_('translate.dictionaries.page_info').replace('{current}', entryPage).replace('{total}', Math.ceil(totalEntries / entryPageSize))} - - -
- {/if} - {/if} + +
+ {$t.translate?.dictionaries?.total_entries?.replace('{count}', String(m.totalEntries))} +
+ + +
+
{/if}
-