- 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
156 lines
7.1 KiB
TypeScript
156 lines
7.1 KiB
TypeScript
// #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
|