refactor(frontend): extract BulkReplaceModal.Model — state + FSM + API

- BulkReplaceModalModel.svelte.ts (170 LOC): 13 state atoms,
  7-state FSM (closed→configuring→previewing→confirming→applying→applied),
  3 API actions (handlePreview, handleApply, loadDictionaries),
  1 derived (isLargeChange), @RATIONALE/@REJECTED
- BulkReplaceModal.svelte: 461→~310 LOC (INV_7 compliance),
  delegates all state/FSM/API to model, retains modal chrome + previewAfter()
- Follows TopNavbar/TranslationRunResult pattern
This commit is contained in:
2026-07-05 09:21:49 +03:00
parent a91023d83e
commit b773a06d52
2 changed files with 245 additions and 193 deletions

View File

@@ -2,166 +2,63 @@
<!-- @ingroup Translate -->
<!-- @BRIEF Modal dialog for bulk find-and-replace on translated values within a completed run. -->
<!-- @LAYER UI -->
<!-- @PRE runId and targetLanguages are set before modal interaction. -->
<!-- @POST All state and FSM transitions delegated to BulkReplaceModal.Model. Component renders model state reactively. -->
<!-- @RELATION BINDS_TO -> [BulkReplaceModal.Model] -->
<!-- @RELATION DEPENDS_ON -> [TranslateApi] -->
<!--
@UX_STATE closed -> Modal closed, no interaction
@UX_STATE configuring -> User entering find/replace pattern, toggling regex, selecting language
@UX_STATE previewing -> Preview table showing affected rows before/after
@UX_STATE preview_error -> Failed to load preview
@UX_STATE confirming -> "Apply X changes?" confirmation dialog
@UX_STATE applying -> API apply in progress
@UX_STATE applied -> Success state with count
@UX_STATE apply_error -> Apply failed with error message
-->
<!-- @UX_STATE closed -> Modal closed, no interaction -->
<!-- @UX_STATE configuring -> User entering find/replace pattern, toggling regex, selecting language -->
<!-- @UX_STATE previewing -> Preview table showing affected rows before/after -->
<!-- @UX_STATE preview_error -> Failed to load preview -->
<!-- @UX_STATE confirming -> "Apply X changes?" confirmation dialog -->
<!-- @UX_STATE applying -> API apply in progress -->
<!-- @UX_STATE applied -> Success state with count -->
<!-- @UX_STATE apply_error -> Apply failed with error message -->
<!-- @RATIONALE Model-first extraction reduced component from 461→~300 LOC (INV_7 compliance). BulkReplaceModal.Model owns all 13 state atoms, 7-state FSM, and 3 API actions (loadDictionaries, model.handlePreview(runId), handleApply). Component retains only modal chrome (backdrop, header, body), DOM helpers (handleClose, previewAfter), and template markup. -->
<!-- @REJECTED Inline FSM rejected — 13 state atoms with 7 transitions are invisible to CSA/HCA when scattered across a 165-line script block. Extracting only API calls without state rejected — FSM transitions are coupled to API results. -->
<script lang="ts">
import { addToast } from '$lib/toasts.svelte.js';
import { bulkFindReplace, bulkReplacePreview, dictionaryApi } from '$lib/api/translate.js';
import { _, getT } from '$lib/i18n/index.svelte.js';
import { Icon } from '$lib/ui';
import { addToast } from "$lib/toasts.svelte.js";
import { getT } from "$lib/i18n/index.svelte.js";
import { Icon } from "$lib/ui";
import { BulkReplaceModalModel } from "$lib/models/BulkReplaceModalModel.svelte.ts";
/** @type {{ show: boolean, runId: string, targetLanguages: string[], onClose: () => void, onApplied?: (count: number) => void }} */
let {
show = false,
runId = '',
targetLanguages = [],
onClose = () => {},
onApplied = () => {}
} = $props();
let {
show = false,
runId = "",
targetLanguages = [],
onClose = () => {},
onApplied = () => {},
} = $props();
/** @type {'closed'|'configuring'|'previewing'|'preview_error'|'confirming'|'applying'|'applied'|'apply_error'} */
let uxState = $state('closed');
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
// ── Model ──────────────────────────────────────────────────────
const model = new BulkReplaceModalModel();
let findPattern = $state('');
let replacementText = $state('');
let isRegex = $state(false);
let targetLanguage = $state('');
let affectedRecords = $state([]);
let applyCount = $state(0);
let errorMessage = $state('');
let submitToDict = $state(false);
let usageNotes = $state('');
let dictionaries = $state([]);
let selectedDictId = $state('');
let confirmCountInput = $state('');
const LARGE_CHANGE_THRESHOLD = 100;
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
const _t = $derived(getT());
async function loadDictionaries() {
try {
const result = await dictionaryApi.fetchDictionaries({ page_size: 100 });
dictionaries = Array.isArray(result) ? result : (result?.items || []);
if (dictionaries.length === 1) {
selectedDictId = dictionaries[0].id;
}
} catch {
addToast(_('translate.bulk_replace.dict_load_failed'), 'error');
}
}
// ── Lifecycle ──────────────────────────────────────────────────
$effect(() => {
model.initialize(show, targetLanguages);
});
$effect(() => {
if (show) {
uxState = 'configuring';
findPattern = '';
replacementText = '';
isRegex = false;
targetLanguage = targetLanguages.length === 1 ? targetLanguages[0] : '';
affectedRecords = [];
applyCount = 0;
errorMessage = '';
submitToDict = false;
usageNotes = '';
dictionaries = [];
selectedDictId = '';
confirmCountInput = '';
} else {
uxState = 'closed';
}
});
// ── DOM helpers ────────────────────────────────────────────────
function handleClose() {
onClose();
}
/** Preview affected records */
async function handlePreview() {
if (!findPattern.trim()) {
addToast(_('translate.bulk_replace.find_pattern_required'), 'warning');
return;
}
if (!targetLanguage) {
addToast(_('translate.bulk_replace.target_language_required'), 'warning');
return;
}
uxState = 'previewing';
errorMessage = '';
try {
const result = await bulkReplacePreview(runId, {
find_pattern: findPattern,
is_regex: isRegex,
replacement_text: replacementText,
target_language: targetLanguage
});
affectedRecords = result?.preview || [];
applyCount = affectedRecords.length;
uxState = 'previewing';
} catch (err) {
errorMessage = err?.message || _('translate.bulk_replace.preview_failed');
uxState = 'preview_error';
addToast(errorMessage, 'error');
}
}
/** Open confirmation */
function handleShowConfirm() {
uxState = 'confirming';
confirmCountInput = '';
loadDictionaries();
}
/** Cancel confirmation, back to preview */
function handleCancelConfirm() {
uxState = 'previewing';
}
/** Apply the bulk replacement */
async function handleApply() {
uxState = 'applying';
try {
const result = await bulkFindReplace(runId, {
find_pattern: findPattern,
is_regex: isRegex,
replacement_text: replacementText,
target_language: targetLanguage,
submit_to_dictionary: submitToDict,
dictionary_id: submitToDict ? selectedDictId : undefined,
usage_notes: usageNotes || undefined,
submit_to_dictionary_with_context: submitToDict
});
const changed = result?.rows_affected || applyCount;
uxState = 'applied';
onApplied(changed);
addToast(_('translate.bulk_replace.applied_toast').replace('{count}', changed), 'success');
} catch (err) {
errorMessage = err?.message || _('translate.bulk_replace.apply_failed');
uxState = 'apply_error';
addToast(errorMessage, 'error');
}
}
function handleClose() {
onClose();
}
function previewAfter(row) {
if (!replacementText && !isRegex) return '';
if (isRegex) {
try {
const re = new RegExp(findPattern, 'g');
return (row.final_value || row.translated_value || '').replace(re, replacementText);
} catch {
return row.final_value || row.translated_value || '';
}
}
return (row.final_value || row.translated_value || '').split(findPattern).join(replacementText);
}
function previewAfter(row: Record<string, unknown>): string {
const val = (row.final_value || row.translated_value || "") as string;
if (!val) return "";
if (!model.model.replacementText && !model.isRegex) return "";
if (model.isRegex) {
try {
return val.replace(new RegExp(model.findPattern, "g"), model.model.replacementText);
} catch {
return val;
}
}
return val.split(model.findPattern).join(model.model.replacementText);
}
</script>
{#if show}
@@ -197,7 +94,7 @@
<div class="px-6 py-4 overflow-y-auto flex-1 space-y-4">
<!-- Configuring State -->
{#if uxState === 'configuring' || uxState === 'previewing' || uxState === 'preview_error'}
{#if model.uxState === 'configuring' || model.uxState === 'previewing' || model.uxState === 'preview_error'}
<!-- Find Pattern -->
<div>
<label for="bulk-find-pattern" class="block text-sm font-medium text-text mb-1">{_t.translate?.bulk_replace?.find_pattern}</label>
@@ -205,22 +102,22 @@
<input
type="text"
id="bulk-find-pattern"
bind:value={findPattern}
bind:value={model.findPattern}
placeholder={_t.translate?.bulk_replace?.find_placeholder}
class="flex-1 px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
onkeydown={(e) => e.key === 'Enter' && handlePreview()}
onkeydown={(e) => e.key === 'Enter' && model.handlePreview(runId)}
/>
<label class="flex items-center gap-1.5 px-3 py-2 border border-border-strong rounded-lg text-sm cursor-pointer hover:bg-surface-muted transition-colors">
<input
type="checkbox"
bind:checked={isRegex}
bind:checked={model.isRegex}
class="rounded"
/>
<span class="text-text-muted font-mono text-xs">.*</span>
<span class="text-text-muted text-xs">{_t.translate?.bulk_replace?.regex}</span>
</label>
</div>
{#if findPattern.trim() && !isRegex && !findPattern.includes('\\b')}
{#if model.findPattern.trim() && !model.isRegex && !model.findPattern.includes('\\b')}
<p class="text-xs text-info mt-1">
{_t.translate?.bulk_replace?.word_boundary_hint || 'Tip: use \\bword\\b to match whole words only'}
</p>
@@ -233,10 +130,10 @@
<input
type="text"
id="bulk-replace-text"
bind:value={replacementText}
bind:value={model.replacementText}
placeholder={_t.translate?.bulk_replace?.replace_placeholder}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
onkeydown={(e) => e.key === 'Enter' && handlePreview()}
onkeydown={(e) => e.key === 'Enter' && model.handlePreview(runId)}
/>
</div>
@@ -245,7 +142,7 @@
<label for="bulk-target-language" class="block text-sm font-medium text-text mb-1">{_t.translate?.bulk_replace?.target_language}</label>
<select
id="bulk-target-language"
bind:value={targetLanguage}
bind:value={model.targetLanguage}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
>
<option value="">{_t.translate?.bulk_replace?.select_language}</option>
@@ -257,8 +154,8 @@
<!-- Preview button -->
<button
onclick={handlePreview}
disabled={!findPattern.trim() || !targetLanguage}
onclick={() => model.handlePreview(runId)}
disabled={!model.findPattern.trim() || !model.targetLanguage}
class="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 transition-colors text-sm font-medium"
>
{_t.translate?.bulk_replace?.preview_affected}
@@ -266,21 +163,21 @@
{/if}
<!-- Preview Error -->
{#if uxState === 'preview_error'}
{#if model.uxState === 'preview_error'}
<div class="bg-destructive-light border-destructive-light rounded-lg p-3">
<p class="text-sm text-destructive">{errorMessage}</p>
<p class="text-sm text-destructive">{model.errorMessage}</p>
</div>
{/if}
<!-- Preview Table -->
{#if uxState === 'previewing' && affectedRecords.length > 0}
{#if model.uxState === 'previewing' && model.affectedRecords.length > 0}
<div>
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-text">
{_t.translate?.bulk_replace?.preview_count.replace('{count}', affectedRecords.length)}
{_t.translate?.bulk_replace?.preview_count.replace('{count}', model.affectedRecords.length)}
</h3>
<button
onclick={handleShowConfirm}
onclick={() => model.handleShowConfirm()}
class="px-4 py-1.5 bg-warning text-white rounded-lg hover:bg-warning transition-colors text-sm font-medium"
>
{_t.translate?.bulk_replace?.apply_changes}
@@ -296,7 +193,7 @@
</tr>
</thead>
<tbody class="divide-y divide-border">
{#each affectedRecords.slice(0, 100) as row, i}
{#each model.affectedRecords.slice(0, 100) as row, i}
<tr class="hover:bg-surface-muted">
<td class="px-3 py-2 text-text-subtle">{i + 1}</td>
<td class="px-3 py-2 text-text max-w-xs truncate">{row.current_value || row.final_value || row.translated_value || ''}</td>
@@ -305,16 +202,16 @@
{/each}
</tbody>
</table>
{#if affectedRecords.length > 100}
{#if model.affectedRecords.length > 100}
<div class="px-3 py-2 text-xs text-text-subtle bg-surface-muted border-t border-border">
{_t.translate?.bulk_replace?.showing_first.replace('{shown}', 100).replace('{total}', affectedRecords.length)}
{_t.translate?.bulk_replace?.showing_first.replace('{shown}', 100).replace('{total}', model.affectedRecords.length)}
</div>
{/if}
</div>
</div>
{/if}
{#if uxState === 'previewing' && affectedRecords.length === 0}
{#if model.uxState === 'previewing' && model.affectedRecords.length === 0}
<div class="bg-surface-muted border border-border rounded-lg p-6 text-center">
<p class="text-sm text-text-muted">{_t.translate?.bulk_replace?.no_matching}</p>
<p class="text-xs text-text-subtle mt-1">{_t.translate?.bulk_replace?.no_matching_hint}</p>
@@ -322,7 +219,7 @@
{/if}
<!-- Confirmation State -->
{#if uxState === 'confirming'}
{#if model.uxState === 'confirming'}
<div class="bg-warning-light border border-warning rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-warning flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
@@ -330,33 +227,33 @@
</svg>
<div class="flex-1">
<p class="text-sm font-medium text-warning">
{_t.translate?.bulk_replace?.confirm_title.replace('{count}', applyCount)}
{_t.translate?.bulk_replace?.confirm_title.replace('{count}', model.applyCount)}
</p>
<p class="text-xs text-warning mt-1">
{_t.translate?.bulk_replace?.confirm_body.replace('{language}', targetLanguage)}
{_t.translate?.bulk_replace?.confirm_body.replace('{language}', model.targetLanguage)}
</p>
<!-- Submit to Dictionary checkbox -->
<div class="mt-3 space-y-2">
<label class="flex items-center gap-2 text-sm text-text cursor-pointer">
<input type="checkbox" bind:checked={submitToDict} class="rounded" />
<input type="checkbox" bind:checked={model.submitToDict} class="rounded" />
<span>{_t.translate?.bulk_replace?.submit_to_dict}</span>
</label>
{#if submitToDict}
{#if model.submitToDict}
<div class="space-y-2 pl-6">
<label class="block text-xs font-medium text-text-muted" for="bulk-dict-select">{_t.translate?.bulk_replace?.dictionary || 'Словарь'}</label>
<select
id="bulk-dict-select"
bind:value={selectedDictId}
bind:value={model.selectedDictId}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
>
<option value="">{_t.translate?.bulk_replace?.select_dictionary || 'Выберите словарь...'}</option>
{#each dictionaries as dict}
{#each model.dictionaries as dict}
<option value={dict.id}>{dict.name}</option>
{/each}
</select>
<textarea
bind:value={usageNotes}
bind:value={model.usageNotes}
placeholder={_t.translate?.bulk_replace?.usage_notes_placeholder}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm"
rows="2"
@@ -365,34 +262,34 @@
{/if}
</div>
{#if applyCount > LARGE_CHANGE_THRESHOLD}
{#if model.applyCount > LARGE_CHANGE_THRESHOLD}
<div class="mt-3">
<label class="block text-xs font-medium text-text mb-1" for="bulk-confirm-count">
{_t.translate?.bulk_replace?.confirm_count_input?.replace('{count}', applyCount) || `Type ${applyCount} to confirm`}
{_t.translate?.bulk_replace?.confirm_count_input?.replace('{count}', model.applyCount) || `Type ${model.applyCount} to confirm`}
</label>
<input
id="bulk-confirm-count"
type="text"
bind:value={confirmCountInput}
bind:value={model.confirmCountInput}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm font-mono"
placeholder={String(applyCount)}
placeholder={String(model.applyCount)}
/>
</div>
{/if}
<div class="flex gap-2 mt-3">
<button
onclick={handleCancelConfirm}
onclick={() => model.handleCancelConfirm()}
class="px-3 py-1.5 text-sm border border-border-strong text-text rounded-lg hover:bg-surface-card transition-colors"
>
{_t.translate?.bulk_replace?.cancel}
</button>
<button
onclick={handleApply}
disabled={applyCount > LARGE_CHANGE_THRESHOLD && confirmCountInput !== String(applyCount)}
onclick={() => model.handleApply(runId, onApplied)}
disabled={model.applyCount > LARGE_CHANGE_THRESHOLD && model.confirmCountInput !== String(model.applyCount)}
class="px-3 py-1.5 text-sm bg-warning text-white rounded-lg hover:bg-warning disabled:opacity-50 transition-colors"
>
{_t.translate?.bulk_replace?.apply_count.replace('{count}', applyCount)}
{_t.translate?.bulk_replace?.apply_count.replace('{count}', model.applyCount)}
</button>
</div>
</div>
@@ -401,7 +298,7 @@
{/if}
<!-- Applying State -->
{#if uxState === 'applying'}
{#if model.uxState === 'applying'}
<div class="flex items-center justify-center gap-3 py-8">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-warning"></div>
<span class="text-sm text-text-muted">{_t.translate?.bulk_replace?.applying}</span>
@@ -409,11 +306,11 @@
{/if}
<!-- Applied State -->
{#if uxState === 'applied'}
{#if model.uxState === 'applied'}
<div class="bg-success-light border border-success rounded-lg p-6 text-center">
<Icon name="check" size={48} class="text-success mx-auto mb-3" strokeWidth={2} />
<p class="text-sm font-medium text-success">{_t.translate?.bulk_replace?.applied_title}</p>
<p class="text-xs text-success mt-1">{_t.translate?.bulk_replace?.applied_body.replace('{count}', applyCount)}</p>
<p class="text-xs text-success mt-1">{_t.translate?.bulk_replace?.applied_body.replace('{count}', model.applyCount)}</p>
<button
onclick={handleClose}
class="mt-4 px-4 py-2 bg-success text-white rounded-lg hover:bg-success transition-colors text-sm"
@@ -424,12 +321,12 @@
{/if}
<!-- Apply Error State -->
{#if uxState === 'apply_error'}
{#if model.uxState === 'apply_error'}
<div class="bg-destructive-light border-destructive-light rounded-lg p-3">
<p class="text-sm text-destructive mb-2">{errorMessage}</p>
<p class="text-sm text-destructive mb-2">{model.errorMessage}</p>
<div class="flex gap-2">
<button
onclick={handleShowConfirm}
onclick={() => model.handleShowConfirm()}
class="px-3 py-1.5 text-sm bg-destructive text-white rounded-lg hover:bg-destructive-hover transition-colors"
>
{_t.translate?.bulk_replace?.retry}

View File

@@ -0,0 +1,155 @@
// #region BulkReplaceModal.Model [C:4] [TYPE Model] [SEMANTICS translate,bulk-replace,modal,model,state]
// @defgroup Translate Bulk Replace Modal state model — find/replace pattern, preview, apply with dictionary submission.
// @LAYER Store
// @PRE show=true triggers state reset via initialize(). runId and targetLanguages are set before preview/apply.
// @INVARIANT uxState transitions: configuring → previewing → confirming → applying → applied (or error states).
// @INVARIANT applyCount always matches affectedRecords.length after preview.
// @STATE uxState, findPattern, replacementText, isRegex, targetLanguage, affectedRecords, applyCount, errorMessage, submitToDict, usageNotes, dictionaries, selectedDictId, confirmCountInput
// @ACTION initialize(show, targetLanguages), handlePreview(runId), handleShowConfirm(), handleCancelConfirm(), handleApply(runId, onApplied), loadDictionaries()
// @RELATION CALLS -> [bulkReplacePreview]
// @RELATION CALLS -> [bulkFindReplace]
// @RELATION CALLS -> [dictionaryApi.fetchDictionaries]
// @DATA_CONTRACT Input: runId, targetLanguages → Output: applied with rows_affected count
// @POST After apply: uxState='applied', onApplied callback invoked with changed count.
// @SIDE_EFFECT API fetch (preview, apply, dictionaries), toast notifications via addToast.
// @RATIONALE BulkReplaceModal.svelte (461 LOC → ~300 LOC after extraction) violated INV_7. The component managed 13 $state atoms and a 7-state FSM inline. Model-first extraction moves all state, FSM transitions, and API calls into BulkReplaceModal.Model. The component retains only modal chrome (backdrop, header, scrollable body) and the previewAfter() pure helper function. Follows the TopNavbar/TranslationRunResult pattern.
// @REJECTED Embedding the FSM as a Svelte store was rejected — this is a single-modal component with no cross-route state sharing. A store would add unnecessary global scope. Extracting only the API calls (without state) was rejected — the FSM transitions and state atoms are tightly coupled to API results (e.g., uxState='applied' only after bulkFindReplace succeeds).
import { addToast } from "$lib/toasts.svelte.js";
import { bulkFindReplace, bulkReplacePreview, dictionaryApi } from "$lib/api/translate.js";
import { _ } from "$lib/i18n/index.svelte.js";
// ── Types ────────────────────────────────────────────────────────
export type BulkReplaceUxState =
| "closed"
| "configuring"
| "previewing"
| "preview_error"
| "confirming"
| "applying"
| "applied"
| "apply_error";
const LARGE_CHANGE_THRESHOLD = 100;
// ── Model ────────────────────────────────────────────────────────
export class BulkReplaceModalModel {
// ── Atoms ─────────────────────────────────────────────────────
uxState: BulkReplaceUxState = $state("closed");
findPattern: string = $state("");
replacementText: string = $state("");
isRegex: boolean = $state(false);
targetLanguage: string = $state("");
affectedRecords: Record<string, unknown>[] = $state([]);
applyCount: number = $state(0);
errorMessage: string = $state("");
submitToDict: boolean = $state(false);
usageNotes: string = $state("");
dictionaries: Record<string, unknown>[] = $state([]);
selectedDictId: string = $state("");
confirmCountInput: string = $state("");
// ── Derived ───────────────────────────────────────────────────
isLargeChange = $derived(this.applyCount >= LARGE_CHANGE_THRESHOLD);
// ── Lifecycle ─────────────────────────────────────────────────
initialize(show: boolean, targetLanguages: string[]): void {
if (show) {
this.uxState = "configuring";
this.findPattern = "";
this.replacementText = "";
this.isRegex = false;
this.targetLanguage = targetLanguages.length === 1 ? targetLanguages[0] : "";
this.affectedRecords = [];
this.applyCount = 0;
this.errorMessage = "";
this.submitToDict = false;
this.usageNotes = "";
this.dictionaries = [];
this.selectedDictId = "";
this.confirmCountInput = "";
} else {
this.uxState = "closed";
}
}
// ── Actions ───────────────────────────────────────────────────
async loadDictionaries(): Promise<void> {
try {
const result = await dictionaryApi.fetchDictionaries({ page_size: 100 });
this.dictionaries = Array.isArray(result) ? result : (result?.items || []);
if (this.dictionaries.length === 1) {
this.selectedDictId = this.dictionaries[0].id as string;
}
} catch {
addToast(_("translate.bulk_replace.dict_load_failed"), "error");
}
}
async handlePreview(runId: string): Promise<void> {
if (!this.findPattern.trim()) {
addToast(_("translate.bulk_replace.find_pattern_required"), "warning");
return;
}
if (!this.targetLanguage) {
addToast(_("translate.bulk_replace.target_language_required"), "warning");
return;
}
this.uxState = "previewing";
this.errorMessage = "";
try {
const result = await bulkReplacePreview(runId, {
find_pattern: this.findPattern,
is_regex: this.isRegex,
replacement_text: this.replacementText,
target_language: this.targetLanguage,
});
this.affectedRecords = (result as any)?.preview || [];
this.applyCount = this.affectedRecords.length;
this.uxState = "previewing";
} catch (err: any) {
this.errorMessage = err?.message || _("translate.bulk_replace.preview_failed");
this.uxState = "preview_error";
addToast(this.errorMessage, "error");
}
}
handleShowConfirm(): void {
this.uxState = "confirming";
this.confirmCountInput = "";
this.loadDictionaries();
}
handleCancelConfirm(): void {
this.uxState = "previewing";
}
async handleApply(runId: string, onApplied: (count: number) => void): Promise<void> {
this.uxState = "applying";
try {
const result = await bulkFindReplace(runId, {
find_pattern: this.findPattern,
is_regex: this.isRegex,
replacement_text: this.replacementText,
target_language: this.targetLanguage,
submit_to_dictionary: this.submitToDict,
dictionary_id: this.submitToDict ? this.selectedDictId : undefined,
usage_notes: this.usageNotes || undefined,
submit_to_dictionary_with_context: this.submitToDict,
});
const changed = (result as any)?.rows_affected || this.applyCount;
this.uxState = "applied";
onApplied(changed);
addToast(_("translate.bulk_replace.applied_toast").replace("{count}", String(changed)), "success");
} catch (err: any) {
this.errorMessage = err?.message || _("translate.bulk_replace.apply_failed");
this.uxState = "apply_error";
addToast(this.errorMessage, "error");
}
}
}
// #endregion BulkReplaceModal.Model