From f67e906af1b9eaf6108eb3f84f1317aa169c0b73 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Thu, 7 May 2026 21:30:32 +0800 Subject: [PATCH] Prevent silent Style preference save failures Style page writes previously updated local UI state before awaiting IPC, so rejected handlers left the page showing values that were never persisted. The change keeps the optimistic update, but centralizes rollback to the previous preferences and surfaces the backend error inline near the failing control. Constraint: Issue #313 asks for minimal frontend-only handling; backend handlers remain unchanged.\nRejected: Global toast framework | outside the issue scope and heavier than an inline page error.\nConfidence: high\nScope-risk: narrow\nTested: cd openless-all/app && npx tsx src/lib/stylePrefs.test.ts\nTested: cd openless-all/app && npm run build\nTested: git diff --check\nRelated: https://github.com/appergb/openless/issues/313 --- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + openless-all/app/src/lib/stylePrefs.test.ts | 103 ++++++++++++++++++++ openless-all/app/src/lib/stylePrefs.ts | 70 +++++++++++++ openless-all/app/src/pages/Style.tsx | 70 +++++++++++-- 8 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 openless-all/app/src/lib/stylePrefs.test.ts create mode 100644 openless-all/app/src/lib/stylePrefs.ts diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 30843cd9..fd2ad82d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -186,6 +186,7 @@ export const en: typeof zhCN = { masterToggle: 'Master switch', currentDefault: 'Current default', ariaSetDefault: 'Set as default', + saveFailed: 'Save failed: {{error}}', modes: { raw: { name: 'Raw', desc: 'Only adds punctuation and natural breaks — no rewriting or expansion.', sample: "Keeps spoken cadence; fillers like 'um' or 'you know' get dropped, but sentences stay intact." }, light: { name: 'Light polish', desc: 'Drops fillers, adds punctuation, and produces sendable natural prose.', sample: "Makes the transcript flow well without sounding scripted — your tone and habits remain." }, diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 27790919..4b37da84 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -188,6 +188,7 @@ export const ja: typeof zhCN = { masterToggle: '全体有効化', currentDefault: '現在のデフォルト', ariaSetDefault: 'デフォルトに設定', + saveFailed: '保存に失敗しました: {{error}}', modes: { raw: { name: '原文', desc: '句読点と必要な区切りのみ補い、書き換えや拡張はしません。', sample: '元の話し言葉を保持。「えー」「あの」などの口癖は除去しますが、文の組み替えはしません。' }, light: { name: '軽い整文', desc: '口癖の除去、句読点の補完、自然な送信可能テキストへの整理。', sample: '原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。' }, diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index a517a03f..052832ef 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -188,6 +188,7 @@ export const ko: typeof zhCN = { masterToggle: '전체 활성화', currentDefault: '현재 기본', ariaSetDefault: '기본으로 설정', + saveFailed: '저장 실패: {{error}}', modes: { raw: { name: '원문', desc: '구두점과 필요한 문장 구분만 보충하고 다시 쓰거나 확장하지 않습니다.', sample: '원래 구어체 유지. "음", "그게" 같은 입버릇은 제거하지만 문장을 재구성하지 않습니다.' }, light: { name: '가벼운 정리', desc: '입버릇 제거, 구두점 보충, 자연스럽게 보낼 수 있는 텍스트로 정리합니다.', sample: '원고를 읽는 듯한 느낌이 들지 않도록 어조와 표현 습관은 남기되, 문장이 매끄럽게 흐르도록 합니다.' }, diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index db99cebc..7dc1c2d5 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -184,6 +184,7 @@ export const zhCN = { masterToggle: '整体启用', currentDefault: '当前默认', ariaSetDefault: '设为默认', + saveFailed: '保存失败:{{error}}', modes: { raw: { name: '原文', desc: '只补标点和必要分句,不改写不扩写。', sample: '保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。' }, light: { name: '轻度润色', desc: '去口癖、补标点,整理为可发送的自然文字。', sample: '让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。' }, diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index e457f8cb..75e4af24 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -186,6 +186,7 @@ export const zhTW: typeof zhCN = { masterToggle: '整體啓用', currentDefault: '當前默認', ariaSetDefault: '設爲默認', + saveFailed: '保存失敗:{{error}}', modes: { raw: { name: '原文', desc: '只補標點和必要分句,不改寫不擴寫。', sample: '保留原始口語;嗯、那個等口癖會被去除,但不會重組語句。' }, light: { name: '輕度潤色', desc: '去口癖、補標點,整理爲可發送的自然文字。', sample: '讓轉寫聽起來不像念稿——保留語氣和表達習慣,但行文流暢。' }, diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts new file mode 100644 index 00000000..02eb50fd --- /dev/null +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -0,0 +1,103 @@ +import { + persistStylePreferenceChange, + rollbackStyleEnabledChange, + rollbackWholeStylePreferences, +} from './stylePrefs'; +import type { UserPreferences } from './types'; + +function assert(condition: boolean, message: string) { + if (!condition) throw new Error(message); +} + +const previousPrefs: UserPreferences = { + hotkey: { trigger: 'rightOption', mode: 'toggle' }, + dictationHotkey: { primary: 'RightOption', modifiers: [] }, + defaultMode: 'light', + enabledModes: ['raw', 'light', 'structured'], + launchAtLogin: false, + showCapsule: true, + muteDuringRecording: false, + microphoneDeviceName: '', + activeAsrProvider: 'volcengine', + activeLlmProvider: 'ark', + restoreClipboardAfterPaste: true, + allowNonTsfInsertionFallback: true, + workingLanguages: ['简体中文'], + translationTargetLanguage: '', + chineseScriptPreference: 'auto', + outputLanguagePreference: 'auto', + qaHotkey: null, + qaSaveHistory: false, + customComboHotkey: null, + translationHotkey: { primary: 'Shift', modifiers: [] }, + switchStyleHotkey: { primary: 'S', modifiers: ['alt'] }, + openAppHotkey: { primary: 'O', modifiers: ['alt'] }, + localAsrActiveModel: '', + localAsrMirror: 'huggingface', + localAsrKeepLoadedSecs: 300, + foundryLocalAsrModel: '', + foundryLocalAsrLanguageHint: '', + foundryLocalAsrKeepLoadedSecs: 300, +}; + +const nextPrefs: UserPreferences = { + ...previousPrefs, + enabledModes: [], +}; + +const states: UserPreferences[] = []; +const errors: string[] = []; +let firstCurrentPrefs: UserPreferences | null = previousPrefs; +const saved = await persistStylePreferenceChange( + nextPrefs, + async () => { + throw 'disk full'; + }, + update => { + firstCurrentPrefs = typeof update === 'function' ? update(firstCurrentPrefs) : update; + if (firstCurrentPrefs) states.push(firstCurrentPrefs); + }, + message => errors.push(message), + rollbackWholeStylePreferences(previousPrefs, nextPrefs), +); + +assert(saved === false, 'setSettings reject should report save failure'); +assert(states.length === 2, `expected optimistic state then rollback, got ${states.length} updates`); +assert(states[0] === nextPrefs, 'first state update should be the optimistic next prefs'); +assert( + states[1].enabledModes.join(',') === previousPrefs.enabledModes.join(','), + 'second state update should roll back enabled modes to previous prefs', +); +assert(errors[0] === 'disk full', `expected backend error message, got ${errors[0]}`); + +let currentPrefs: UserPreferences | null = previousPrefs; +const disableLightPrefs: UserPreferences = { + ...previousPrefs, + enabledModes: ['raw', 'structured'], +}; +const disableStructuredAfterLightPrefs: UserPreferences = { + ...previousPrefs, + enabledModes: ['raw'], +}; +const overlapSaved = await persistStylePreferenceChange( + disableLightPrefs, + async () => { + currentPrefs = disableStructuredAfterLightPrefs; + throw 'slow failure'; + }, + update => { + currentPrefs = typeof update === 'function' ? update(currentPrefs) : update; + }, + () => undefined, + rollbackStyleEnabledChange('light', previousPrefs, disableLightPrefs), +); + +assert(overlapSaved === false, 'overlapped style save should still report failure'); +assert( + currentPrefs?.enabledModes.includes('light') === true, + 'failed light toggle should roll back only the light mode', +); +assert( + currentPrefs?.enabledModes.includes('structured') === false, + 'failed light toggle should preserve newer structured edit', +); diff --git a/openless-all/app/src/lib/stylePrefs.ts b/openless-all/app/src/lib/stylePrefs.ts new file mode 100644 index 00000000..96cc8d70 --- /dev/null +++ b/openless-all/app/src/lib/stylePrefs.ts @@ -0,0 +1,70 @@ +import type { PolishMode, UserPreferences } from './types'; + +type StylePrefsState = UserPreferences | null; +type StylePrefsSetter = (prefs: StylePrefsState | ((current: StylePrefsState) => StylePrefsState)) => void; +type StylePrefsRollback = (current: StylePrefsState) => StylePrefsState; + +export function styleSaveErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + return String(error); +} + +export async function persistStylePreferenceChange( + nextPrefs: UserPreferences, + persist: () => Promise, + setPrefs: StylePrefsSetter, + onFailure: (message: string) => void, + rollback: StylePrefsRollback, +): Promise { + setPrefs(nextPrefs); + try { + await persist(); + return true; + } catch (error) { + setPrefs(current => rollback(current)); + onFailure(styleSaveErrorMessage(error)); + return false; + } +} + +export function rollbackDefaultModeChange( + previousPrefs: UserPreferences, + nextPrefs: UserPreferences, +): StylePrefsRollback { + return current => { + if (!current || current.defaultMode !== nextPrefs.defaultMode) return current; + return { ...current, defaultMode: previousPrefs.defaultMode }; + }; +} + +export function rollbackStyleEnabledChange( + mode: PolishMode, + previousPrefs: UserPreferences, + nextPrefs: UserPreferences, +): StylePrefsRollback { + const previousEnabled = previousPrefs.enabledModes.includes(mode); + const nextEnabled = nextPrefs.enabledModes.includes(mode); + return current => { + if (!current || current.enabledModes.includes(mode) !== nextEnabled) return current; + const enabledModes = previousEnabled + ? Array.from(new Set([...current.enabledModes, mode])) + : current.enabledModes.filter(m => m !== mode); + return { ...current, enabledModes }; + }; +} + +export function rollbackWholeStylePreferences( + previousPrefs: UserPreferences, + nextPrefs: UserPreferences, +): StylePrefsRollback { + return current => { + if (!current || !sameModes(current.enabledModes, nextPrefs.enabledModes)) return current; + return { ...current, enabledModes: previousPrefs.enabledModes }; + }; +} + +function sameModes(left: PolishMode[], right: PolishMode[]): boolean { + if (left.length !== right.length) return false; + return left.every((mode, index) => mode === right[index]); +} diff --git a/openless-all/app/src/pages/Style.tsx b/openless-all/app/src/pages/Style.tsx index f53497da..1a78948b 100644 --- a/openless-all/app/src/pages/Style.tsx +++ b/openless-all/app/src/pages/Style.tsx @@ -5,6 +5,12 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { getSettings, setDefaultPolishMode, setStyleEnabled, setSettings } from '../lib/ipc'; import type { PolishMode, UserPreferences } from '../lib/types'; +import { + persistStylePreferenceChange, + rollbackDefaultModeChange, + rollbackStyleEnabledChange, + rollbackWholeStylePreferences, +} from '../lib/stylePrefs'; import { PageHeader, Pill } from './_atoms'; interface StyleDef { @@ -15,6 +21,7 @@ interface StyleDef { } const STYLE_IDS: PolishMode[] = ['raw', 'light', 'structured', 'formal']; +type StyleSaveErrorTarget = PolishMode | 'master'; export function Style() { const { t } = useTranslation(); @@ -25,15 +32,27 @@ export function Style() { sample: t(`style.modes.${id}.sample`), })); const [prefs, setPrefs] = useState(null); + const [saveError, setSaveError] = useState<{ target: StyleSaveErrorTarget; message: string } | null>(null); useEffect(() => { getSettings().then(setPrefs); }, []); + const showSaveError = (target: StyleSaveErrorTarget, error: string) => { + setSaveError({ target, message: t('style.saveFailed', { error }) }); + }; + const onPickDefault = async (mode: PolishMode) => { if (!prefs) return; - setPrefs({ ...prefs, defaultMode: mode }); - await setDefaultPolishMode(mode); + const next = { ...prefs, defaultMode: mode }; + const saved = await persistStylePreferenceChange( + next, + () => setDefaultPolishMode(mode), + setPrefs, + error => showSaveError(mode, error), + rollbackDefaultModeChange(prefs, next), + ); + if (saved) setSaveError(null); }; const onToggleEnabled = async (mode: PolishMode) => { @@ -42,8 +61,15 @@ export function Style() { const nextEnabled = enabled ? [...prefs.enabledModes, mode] : prefs.enabledModes.filter(m => m !== mode); - setPrefs({ ...prefs, enabledModes: nextEnabled }); - await setStyleEnabled(mode, enabled); + const next = { ...prefs, enabledModes: nextEnabled }; + const saved = await persistStylePreferenceChange( + next, + () => setStyleEnabled(mode, enabled), + setPrefs, + error => showSaveError(mode, error), + rollbackStyleEnabledChange(mode, prefs, next), + ); + if (saved) setSaveError(null); }; if (!prefs) { @@ -63,12 +89,24 @@ export function Style() { if (masterEnabled) { // 全部关闭 → 留 raw 和当前 default 兜底 const next = { ...prefs, enabledModes: [] as PolishMode[] }; - setPrefs(next); - await setSettings(next); + const saved = await persistStylePreferenceChange( + next, + () => setSettings(next), + setPrefs, + error => showSaveError('master', error), + rollbackWholeStylePreferences(prefs, next), + ); + if (saved) setSaveError(null); } else { const next = { ...prefs, enabledModes: ['raw', 'light', 'structured', 'formal'] as PolishMode[] }; - setPrefs(next); - await setSettings(next); + const saved = await persistStylePreferenceChange( + next, + () => setSettings(next), + setPrefs, + error => showSaveError('master', error), + rollbackWholeStylePreferences(prefs, next), + ); + if (saved) setSaveError(null); } }; @@ -97,6 +135,14 @@ export function Style() { }} /> + {saveError?.target === 'master' && ( + + {saveError.message} + + )} } /> @@ -176,6 +222,14 @@ export function Style() { > {s.sample} + {saveError?.target === s.id && ( +
+ {saveError.message} +
+ )} ); })}