From 8cfbddf3a760de26f3081fbd8e6559a49cb7e3ba Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sat, 18 Jul 2026 02:19:15 +0300 Subject: [PATCH 1/7] fix: block selected-text transforms in secure fields --- src-tauri/src/actions.rs | 17 ++ src-tauri/src/commands/transform.rs | 84 ++++++++- src-tauri/src/post_paste_learning.rs | 222 ++++++++++++++++++------ src-tauri/src/selection.rs | 60 ++++++- src/App.tsx | 21 +++ src/i18n/locales/ar/translation.json | 3 + src/i18n/locales/bg/translation.json | 3 + src/i18n/locales/cs/translation.json | 3 + src/i18n/locales/de/translation.json | 3 + src/i18n/locales/en/translation.json | 3 + src/i18n/locales/es/translation.json | 3 + src/i18n/locales/fr/translation.json | 3 + src/i18n/locales/he/translation.json | 3 + src/i18n/locales/it/translation.json | 3 + src/i18n/locales/ja/translation.json | 3 + src/i18n/locales/ko/translation.json | 3 + src/i18n/locales/pl/translation.json | 3 + src/i18n/locales/pt/translation.json | 3 + src/i18n/locales/ru/translation.json | 3 + src/i18n/locales/sv/translation.json | 3 + src/i18n/locales/tr/translation.json | 3 + src/i18n/locales/uk/translation.json | 3 + src/i18n/locales/vi/translation.json | 3 + src/i18n/locales/zh-TW/translation.json | 3 + src/i18n/locales/zh/translation.json | 3 + 25 files changed, 400 insertions(+), 64 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 0083ebf6..0c771625 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -49,6 +49,11 @@ struct LanguageGuardEvent { preview: String, } +#[derive(Clone, serde::Serialize)] +struct TransformSelectionCaptureBlockedEvent { + reason_code: String, +} + /// Drop guard that notifies the [`TranscriptionCoordinator`] when the /// transcription pipeline finishes — whether it completes normally or panics. struct FinishGuard(AppHandle, u64); @@ -2394,6 +2399,18 @@ impl ShortcutAction for TransformShortcutAction { ); } Err(err) => { + if matches!( + err.as_str(), + crate::selection::SECURE_FIELD_REASON_CODE + | crate::selection::SECURE_CHECK_ERROR_REASON_CODE + ) { + let _ = app.emit( + "transform-selection-capture-blocked", + TransformSelectionCaptureBlockedEvent { + reason_code: err.clone(), + }, + ); + } warn!("Transform shortcut '{}' failed: {}", binding_id, err); } } diff --git a/src-tauri/src/commands/transform.rs b/src-tauri/src/commands/transform.rs index 03ededec..11cb0e47 100644 --- a/src-tauri/src/commands/transform.rs +++ b/src-tauri/src/commands/transform.rs @@ -1,3 +1,4 @@ +use std::future::Future; use std::sync::Arc; use serde::{Deserialize, Serialize}; @@ -6,7 +7,9 @@ use tauri::{AppHandle, Emitter, Manager, State}; use crate::adaptive::types::{InsertionMethod, InsertionReceipt}; use crate::managers::history::HistoryManager; -use crate::selection::{SelectionReplaceError, SelectionReplacementOutcome}; +use crate::selection::{ + SelectionCaptureError, SelectionReplaceError, SelectionReplacementOutcome, SelectionSnapshot, +}; use crate::transform_mode::{self, TransformAction}; #[derive(Clone, Debug, Serialize, Deserialize, Type)] @@ -51,8 +54,34 @@ pub async fn run_transform_selected_text( return Err("Text transforms are disabled while Private Session is on".to_string()); } - let captured = capture_selection_on_main_thread(&app).await?; + let capture_result = capture_selection_on_main_thread(&app).await; + run_transform_selected_text_with_executor(capture_result, move |captured| { + execute_captured_transform(app, history_manager, action, target_language, captured) + }) + .await +} + +async fn run_transform_selected_text_with_executor( + capture_result: Result, + executor: E, +) -> Result +where + E: FnOnce(SelectionSnapshot) -> F, + F: Future>, +{ + let captured = capture_result.map_err(transform_capture_error_for_command)?; crate::selection::validate_selected_text_anchor(&captured).map_err(|err| format!("{err:?}"))?; + + executor(captured).await +} + +async fn execute_captured_transform( + app: AppHandle, + history_manager: Arc, + action: TransformAction, + target_language: Option, + captured: SelectionSnapshot, +) -> Result { let task = transform_mode::build_transform_task( action.clone(), &captured.selected_text, @@ -122,6 +151,14 @@ pub async fn run_transform_selected_text( }) } +fn transform_capture_error_for_command(error: SelectionCaptureError) -> String { + if let SelectionCaptureError::Unavailable(detail) = &error { + log::warn!("Selected-text capture unavailable: {detail}"); + } + + error.reason_code().to_string() +} + fn ensure_transform_not_cancelled( operation_token: Option<&crate::operation_cancellation::OperationToken>, stage: &str, @@ -149,18 +186,17 @@ fn emit_transform_recovery_copied(app: &AppHandle) { async fn capture_selection_on_main_thread( app: &AppHandle, -) -> Result { +) -> Result { let (sender, receiver) = std::sync::mpsc::channel(); app.run_on_main_thread(move || { let _ = sender.send(crate::selection::capture_current_selection_snapshot()); }) - .map_err(|err| err.to_string())?; + .map_err(|err| SelectionCaptureError::Unavailable(err.to_string()))?; receiver .recv() - .map_err(|err| err.to_string())? - .map_err(|err| format!("{err:?}")) + .map_err(|err| SelectionCaptureError::Unavailable(err.to_string()))? } async fn replace_selection_with_transaction_on_main_thread( @@ -251,6 +287,7 @@ fn recovery_status(status: &TransformCommandStatus) -> &'static str { #[cfg(test)] mod tests { use super::*; + use std::cell::Cell; #[test] fn shortcut_target_language_uses_configured_translation_target() { @@ -282,4 +319,39 @@ mod tests { assert!(err.contains("history save")); } + + #[test] + fn secure_capture_errors_stop_executor_before_provider_history_or_mutation() { + for capture_error in [ + SelectionCaptureError::SecureField, + SelectionCaptureError::SecureCheckError, + ] { + let provider_calls = Cell::new(0); + let history_writes = Cell::new(0); + let clipboard_mutations = Cell::new(0); + let selection_mutations = Cell::new(0); + + let result = tauri::async_runtime::block_on(run_transform_selected_text_with_executor( + Err(capture_error.clone()), + |_| { + provider_calls.set(provider_calls.get() + 1); + history_writes.set(history_writes.get() + 1); + clipboard_mutations.set(clipboard_mutations.get() + 1); + selection_mutations.set(selection_mutations.get() + 1); + std::future::ready(Err::( + "injected executor should not run".to_string(), + )) + }, + )); + + assert_eq!( + result.expect_err("secure capture errors must stop the command"), + capture_error.reason_code() + ); + assert_eq!(provider_calls.get(), 0); + assert_eq!(history_writes.get(), 0); + assert_eq!(clipboard_mutations.get(), 0); + assert_eq!(selection_mutations.get(), 0); + } + } } diff --git a/src-tauri/src/post_paste_learning.rs b/src-tauri/src/post_paste_learning.rs index 49cc9b71..3c482fd6 100644 --- a/src-tauri/src/post_paste_learning.rs +++ b/src-tauri/src/post_paste_learning.rs @@ -5,6 +5,8 @@ use std::process::Command; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter}; +use crate::selection::SelectionCaptureError; + const POST_PASTE_SETTLE_DELAY: Duration = Duration::from_millis(120); const POST_PASTE_POLL_INTERVAL: Duration = Duration::from_millis(150); const POST_PASTE_STABLE_EDIT_DELAY: Duration = Duration::from_millis(300); @@ -139,7 +141,8 @@ pub fn capture_focused_text_snapshot() -> Option { } #[allow(dead_code)] -pub fn capture_focused_text_selection_snapshot() -> Result { +pub fn capture_focused_text_selection_snapshot( +) -> Result { capture_platform_focused_text_selection_snapshot() } @@ -439,9 +442,11 @@ fn capture_platform_focused_text_snapshot() -> Result Result -{ - Err("selected-text capture is not implemented on this platform yet".to_string()) +fn capture_platform_focused_text_selection_snapshot( +) -> Result { + Err(SelectionCaptureError::Unavailable( + "selected-text capture is not implemented on this platform yet".to_string(), + )) } #[cfg(target_os = "macos")] @@ -450,8 +455,8 @@ fn capture_platform_focused_text_snapshot() -> Result Result -{ +fn capture_platform_focused_text_selection_snapshot( +) -> Result { macos_focused_text::capture_with_selection() } @@ -461,8 +466,8 @@ fn capture_platform_focused_text_snapshot() -> Result Result -{ +fn capture_platform_focused_text_selection_snapshot( +) -> Result { linux_focused_text::capture_with_selection() } @@ -472,8 +477,8 @@ fn capture_platform_focused_text_snapshot() -> Result Result -{ +fn capture_platform_focused_text_selection_snapshot( +) -> Result { windows_focused_text::capture_with_selection() } @@ -526,21 +531,33 @@ fn parse_selection_snapshot_output(output: &[u8]) -> Result Option<&'static str> { +fn classify_secure_sentinel(stdout: &str, stderr: &str) -> Option { if stdout.trim() == "__VERBATIM_SECURE__" || stderr.contains("__VERBATIM_SECURE__") { - return Some("skip: secure_field"); + return Some(SelectionCaptureError::SecureField); } if stderr.contains("__VERBATIM_SECURE_CHECK_ERROR__") { - return Some("skip: secure_check_error"); + return Some(SelectionCaptureError::SecureCheckError); + } + if stdout.trim() == "__VERBATIM_SECURE_CHECK_ERROR__" { + return Some(SelectionCaptureError::SecureCheckError); } None } +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn focused_text_secure_skip_reason(error: SelectionCaptureError) -> String { + match error { + SelectionCaptureError::SecureField => "skip: secure_field".to_string(), + SelectionCaptureError::SecureCheckError => "skip: secure_check_error".to_string(), + other => other.to_string(), + } +} + #[cfg(target_os = "macos")] mod macos_focused_text { use super::{ parse_selection_snapshot_output, parse_snapshot_output, Command, - FocusedTextSelectionSnapshot, FocusedTextSnapshot, + FocusedTextSelectionSnapshot, FocusedTextSnapshot, SelectionCaptureError, }; pub fn capture() -> Result { @@ -557,27 +574,35 @@ mod macos_focused_text { } let stdout = String::from_utf8_lossy(&output.stdout); - if let Some(skip) = super::classify_secure_sentinel(&stdout, "") { - return Err(skip.to_string()); + if let Some(error) = super::classify_secure_sentinel(&stdout, "") { + return Err(super::focused_text_secure_skip_reason(error)); } parse_snapshot_output(&output.stdout) } - pub fn capture_with_selection() -> Result { + pub fn capture_with_selection() -> Result { let output = Command::new("osascript") .args(["-e", MACOS_FOCUSED_TEXT_SELECTION_SCRIPT]) .output() - .map_err(|error| format!("failed to run osascript: {}", error))?; + .map_err(|error| { + SelectionCaptureError::Unavailable(format!("failed to run osascript: {}", error)) + })?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if let Some(error) = super::classify_secure_sentinel(&stdout, &stderr) { + return Err(error); + } if !output.status.success() { - return Err(format!( + return Err(SelectionCaptureError::Unavailable(format!( "macOS Accessibility selection snapshot failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); + stderr.trim() + ))); } - parse_selection_snapshot_output(&output.stdout) + parse_selection_snapshot_output(&output.stdout).map_err(SelectionCaptureError::Unavailable) } const MACOS_FOCUSED_TEXT_SCRIPT: &str = r#" @@ -628,6 +653,16 @@ end attrText tell application "System Events" set frontApp to first application process whose frontmost is true set focusedElement to value of attribute "AXFocusedUIElement" of frontApp + + set subroleValue to "" + try + set subroleRaw to value of attribute "AXSubrole" of focusedElement + if subroleRaw is not missing value then set subroleValue to subroleRaw as text + on error + return "__VERBATIM_SECURE_CHECK_ERROR__" + end try + if subroleValue is "AXSecureTextField" then return "__VERBATIM_SECURE__" + set textValue to my attrText(focusedElement, "AXValue") set selectedText to my attrText(focusedElement, "AXSelectedText") @@ -644,7 +679,7 @@ end tell mod linux_focused_text { use super::{ parse_selection_snapshot_output, parse_snapshot_output, Command, - FocusedTextSelectionSnapshot, FocusedTextSnapshot, + FocusedTextSelectionSnapshot, FocusedTextSnapshot, SelectionCaptureError, }; pub fn capture() -> Result { @@ -666,8 +701,8 @@ mod linux_focused_text { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - if let Some(skip) = super::classify_secure_sentinel("", &stderr) { - return Err(skip.to_string()); + if let Some(error) = super::classify_secure_sentinel("", &stderr) { + return Err(super::focused_text_secure_skip_reason(error)); } return Err(format!( "{} AT-SPI snapshot failed: {}", @@ -679,32 +714,46 @@ mod linux_focused_text { parse_snapshot_output(&output.stdout) } - pub fn capture_with_selection() -> Result { - run_selection_python("python3").or_else(|python3_error| { - run_selection_python("python").map_err(|python_error| { - format!( - "Linux AT-SPI selection snapshot failed with python3 ({}) and python ({})", - python3_error, python_error - ) - }) - }) + pub fn capture_with_selection() -> Result { + match run_selection_python("python3") { + Err(SelectionCaptureError::Unavailable(python3_error)) => { + match run_selection_python("python") { + Err(SelectionCaptureError::Unavailable(python_error)) => { + Err(SelectionCaptureError::Unavailable(format!( + "Linux AT-SPI selection snapshot failed with python3 ({}) and python ({})", + python3_error, python_error + ))) + } + result => result, + } + } + result => result, + } } - fn run_selection_python(binary: &str) -> Result { + fn run_selection_python( + binary: &str, + ) -> Result { let output = Command::new(binary) .args(["-c", LINUX_FOCUSED_TEXT_SELECTION_SCRIPT]) .output() - .map_err(|error| format!("failed to run {binary}: {error}"))?; + .map_err(|error| { + SelectionCaptureError::Unavailable(format!("failed to run {binary}: {error}")) + })?; if !output.status.success() { - return Err(format!( + let stderr = String::from_utf8_lossy(&output.stderr); + if let Some(error) = super::classify_secure_sentinel("", &stderr) { + return Err(error); + } + return Err(SelectionCaptureError::Unavailable(format!( "{} AT-SPI selection snapshot failed: {}", binary, - String::from_utf8_lossy(&output.stderr).trim() - )); + stderr.trim() + ))); } - parse_selection_snapshot_output(&output.stdout) + parse_selection_snapshot_output(&output.stdout).map_err(SelectionCaptureError::Unavailable) } const LINUX_FOCUSED_TEXT_SCRIPT: &str = r#" @@ -858,6 +907,13 @@ focused, path = find_focused(window) if focused is None: raise SystemExit("no focused AT-SPI element") +try: + role = focused.getRoleName() if hasattr(focused, "getRoleName") else "" +except Exception as exc: + raise SystemExit(f"__VERBATIM_SECURE_CHECK_ERROR__: {exc}") +if isinstance(role, str) and role.strip().lower() in ("password text", "password"): + raise SystemExit("__VERBATIM_SECURE__") + text_iface, text_value = accessible_text(focused) if text_iface is None: raise SystemExit("focused AT-SPI element has no readable text") @@ -865,7 +921,7 @@ if text_iface is None: parts = [ getattr(app, "name", "") or "", getattr(window, "name", "") or "", - focused.getRoleName() if hasattr(focused, "getRoleName") else "", + role, path, ] print("|".join(parts)) @@ -878,7 +934,10 @@ print(text_value, end="") #[cfg(target_os = "windows")] mod windows_focused_text { - use super::{FocusedTextSelection, FocusedTextSelectionSnapshot, FocusedTextSnapshot}; + use super::{ + FocusedTextSelection, FocusedTextSelectionSnapshot, FocusedTextSnapshot, + SelectionCaptureError, + }; use std::ffi::c_void; use windows::Win32::Foundation::{RPC_E_CHANGED_MODE, S_FALSE, S_OK}; use windows::Win32::System::Com::{ @@ -928,8 +987,8 @@ mod windows_focused_text { } } - pub fn capture_with_selection() -> Result { - let _com = ComApartment::initialize()?; + pub fn capture_with_selection() -> Result { + let _com = ComApartment::initialize().map_err(SelectionCaptureError::Unavailable)?; unsafe { let automation: IUIAutomation = CoCreateInstance( @@ -937,16 +996,41 @@ mod windows_focused_text { None::<&windows::core::IUnknown>, CLSCTX_INPROC_SERVER, ) - .map_err(|error| format!("failed to create UI Automation client: {}", error))?; - let element = automation - .GetFocusedElement() - .map_err(|error| format!("failed to get focused element: {}", error))?; - let text = read_element_text(&element) - .ok_or_else(|| "focused element has no readable text pattern".to_string())?; + .map_err(|error| { + SelectionCaptureError::Unavailable(format!( + "failed to create UI Automation client: {}", + error + )) + })?; + let element = automation.GetFocusedElement().map_err(|error| { + SelectionCaptureError::Unavailable(format!( + "failed to get focused element: {}", + error + )) + })?; + + // Determine whether the field is protected before reading either its full value or + // selected ranges. Failure to determine the property is itself treated as secure. + match element.CurrentIsPassword() { + Ok(value) if value.as_bool() => { + return Err(SelectionCaptureError::SecureField); + } + Ok(_) => {} + Err(_) => { + return Err(SelectionCaptureError::SecureCheckError); + } + } + + let text = read_element_text(&element).ok_or_else(|| { + SelectionCaptureError::Unavailable( + "focused element has no readable text pattern".to_string(), + ) + })?; let selection = read_element_selection(&element); Ok(FocusedTextSelectionSnapshot { - target_id: strict_target_id(&element)?, + target_id: strict_target_id(&element) + .map_err(SelectionCaptureError::Unavailable)?, text, selection, }) @@ -1143,7 +1227,8 @@ mod tests { use super::{ auto_learn_outcome_log_message, extract_corrected_inserted_text, focused_text_snapshot_from_parts, focused_text_within_cap, parse_selection_snapshot_output, - FocusedTextSelection, MAX_FOCUSED_TEXT_CHARS, POST_PASTE_LEARNING_WINDOW, + FocusedTextSelection, SelectionCaptureError, MAX_FOCUSED_TEXT_CHARS, + POST_PASTE_LEARNING_WINDOW, }; use std::time::Duration; @@ -1312,26 +1397,51 @@ mod tests { fn classifies_secure_sentinels() { assert_eq!( super::classify_secure_sentinel("__VERBATIM_SECURE__", ""), - Some("skip: secure_field") + Some(SelectionCaptureError::SecureField) ); assert_eq!( super::classify_secure_sentinel(" __VERBATIM_SECURE__ \n", ""), - Some("skip: secure_field") + Some(SelectionCaptureError::SecureField) ); assert_eq!( super::classify_secure_sentinel("", "__VERBATIM_SECURE__"), - Some("skip: secure_field") + Some(SelectionCaptureError::SecureField) ); assert_eq!( super::classify_secure_sentinel( "", "Traceback...__VERBATIM_SECURE_CHECK_ERROR__: boom" ), - Some("skip: secure_check_error") + Some(SelectionCaptureError::SecureCheckError) + ); + assert_eq!( + super::classify_secure_sentinel("__VERBATIM_SECURE_CHECK_ERROR__", ""), + Some(SelectionCaptureError::SecureCheckError) ); assert_eq!( super::classify_secure_sentinel("app|window\ntext", ""), None ); } + + #[test] + fn secure_selection_platform_output_is_rejected_before_snapshot_parsing() { + for (stdout, stderr, expected) in [ + ( + "__VERBATIM_SECURE__", + "", + SelectionCaptureError::SecureField, + ), + ( + "", + "__VERBATIM_SECURE_CHECK_ERROR__: denied", + SelectionCaptureError::SecureCheckError, + ), + ] { + assert_eq!( + super::classify_secure_sentinel(stdout, stderr), + Some(expected) + ); + } + } } diff --git a/src-tauri/src/selection.rs b/src-tauri/src/selection.rs index d9feb17f..54905e48 100644 --- a/src-tauri/src/selection.rs +++ b/src-tauri/src/selection.rs @@ -60,9 +60,33 @@ pub enum SelectionReplaceError { #[derive(Clone, Debug, PartialEq, Eq)] pub enum SelectionCaptureError { NoSelection, + SecureField, + SecureCheckError, Unavailable(String), } +pub const SECURE_FIELD_REASON_CODE: &str = "secure_field"; +pub const SECURE_CHECK_ERROR_REASON_CODE: &str = "secure_check_error"; + +impl SelectionCaptureError { + pub const fn reason_code(&self) -> &'static str { + match self { + Self::NoSelection => "no_selection", + Self::SecureField => SECURE_FIELD_REASON_CODE, + Self::SecureCheckError => SECURE_CHECK_ERROR_REASON_CODE, + Self::Unavailable(_) => "selection_unavailable", + } + } +} + +impl fmt::Display for SelectionCaptureError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.reason_code()) + } +} + +impl std::error::Error for SelectionCaptureError {} + impl SelectionSnapshot { #[cfg(test)] pub fn from_focused_snapshot( @@ -164,9 +188,9 @@ fn capture_current_selection_snapshot_with( capture: C, ) -> Result where - C: FnOnce() -> Result, + C: FnOnce() -> Result, { - let snapshot = capture().map_err(SelectionCaptureError::Unavailable)?; + let snapshot = capture()?; selection_snapshot_from_focused_text_selection(snapshot) } @@ -492,6 +516,38 @@ mod tests { ); } + #[test] + fn secure_capture_errors_have_stable_reason_codes() { + assert_eq!( + SelectionCaptureError::SecureField.reason_code(), + SECURE_FIELD_REASON_CODE + ); + assert_eq!( + SelectionCaptureError::SecureCheckError.reason_code(), + SECURE_CHECK_ERROR_REASON_CODE + ); + assert_eq!( + SelectionCaptureError::SecureField.to_string(), + "secure_field" + ); + assert_eq!( + SelectionCaptureError::SecureCheckError.to_string(), + "secure_check_error" + ); + } + + #[test] + fn secure_capture_policy_propagates_before_snapshot_conversion() { + for capture_error in [ + SelectionCaptureError::SecureField, + SelectionCaptureError::SecureCheckError, + ] { + let result = capture_current_selection_snapshot_with(|| Err(capture_error.clone())); + + assert_eq!(result, Err(capture_error)); + } + } + #[test] fn capture_current_selection_snapshot_routes_through_selection_module() { let result = capture_current_selection_snapshot_with(|| { diff --git a/src/App.tsx b/src/App.tsx index 9246d38a..aa5ea810 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,6 +48,10 @@ type LanguageGuardBlockedEvent = { preview: string; }; +type TransformSelectionCaptureBlockedEvent = { + reason_code: "secure_field" | "secure_check_error"; +}; + type DictationBlockedEvent = { app_name: string; }; @@ -301,6 +305,23 @@ function DesktopApp() { }; }, [t]); + useEffect(() => { + const unlisten = listen( + "transform-selection-capture-blocked", + (event) => { + const description = + event.payload.reason_code === "secure_check_error" + ? t("errors.transformSecureCheckErrorDescription") + : t("errors.transformSecureFieldDescription"); + + toast.warning(t("errors.transformSecureFieldTitle"), { description }); + }, + ); + return () => { + unlisten.then((fn) => fn()); + }; + }, [t]); + // If a locked language clearly conflicts with the transcribed script, keep // the text recoverable while making the recovery action explicit. useEffect(() => { diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 8a1a67d5..6e61abd7 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "الصق على أي حال", "transformRecoveryCopiedTitle": "تم نسخ نتيجة التحويل", "transformRecoveryCopiedDescription": "تم تحويل النص المحدد، لكن تعذر استبداله في التطبيق النشط. تم نسخ النتيجة إلى الحافظة.", + "transformSecureFieldTitle": "تم حظر تحويل النص", + "transformSecureFieldDescription": "لا يمكن لـ Verbatim تحويل النص المحدد في حقول كلمات المرور أو الحقول الآمنة.", + "transformSecureCheckErrorDescription": "تعذر على Verbatim التحقق مما إذا كان هذا الحقل آمنًا، لذلك لم تتم قراءة أي نص أو إرساله.", "coordinatorFailedMessage": "توقف منسق الإملاء بعد إخفاقات متكررة. أعد تشغيل Verbatim للاسترداد.", "coordinatorFailedStep": "منسق الإملاء", "coordinatorRestartedDescription": "استعاد Verbatim عامل الإملاء. إذا تكرر ذلك، افتح السجلات من إعدادات التصحيح.", diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 71d39386..78318bbd 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index c28d9b6d..3baf7804 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 7c2055ad..70b12cbc 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 745f6f03..16a4f10c 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -1012,6 +1012,9 @@ "pasteFailedCopyAction": "Copy again", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "languageGuardTitle": "Wrong Language Detected", "languageGuardDescription": "Verbatim blocked text that did not match your locked language ({{language}}). It was copied to your clipboard.", "languageGuardPreview": "Preview: {{preview}}", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 70155a41..26004c6f 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index e1a1553d..27f7cca8 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index 293b8db8..bead7a27 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "הדבק בכל זאת", "transformRecoveryCopiedTitle": "תוצאת ההמרה הועתקה", "transformRecoveryCopiedDescription": "הטקסט שנבחר הומר, אך לא ניתן היה להחליף אותו ביישום הפעיל. התוצאה הועתקה ללוח.", + "transformSecureFieldTitle": "שינוי הטקסט נחסם", + "transformSecureFieldDescription": "Verbatim אינו יכול לשנות טקסט שנבחר בשדות סיסמה או בשדות מאובטחים.", + "transformSecureCheckErrorDescription": "Verbatim לא הצליח לוודא אם שדה זה מאובטח, ולכן לא נקרא ולא נשלח טקסט.", "coordinatorFailedMessage": "מתאם ההכתבה נעצר לאחר כשלים חוזרים. הפעל מחדש את Verbatim כדי לשחזר.", "coordinatorFailedStep": "מתאם הכתבה", "coordinatorRestartedDescription": "Verbatim שחזר את עובד ההכתבה. אם זה ממשיך לקרות, פתח יומנים מהגדרות ניפוי השגיאות.", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 42335f61..879a1be2 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index f4ec7574..d5fd5f89 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index a5e58a90..f52c02e1 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 251b4090..a552d424 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 5635e8f3..9a286787 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 31ef3dab..dc075ac8 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index 9893a2c8..e0dd3255 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index a5abe5ae..9b354bed 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index af1e8558..d420bfef 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index cae061cc..a96ad39d 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index f6857dc9..fac3e784 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 36dc3f21..439d03fe 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -1016,6 +1016,9 @@ "languageGuardPasteAnyway": "Paste anyway", "transformRecoveryCopiedTitle": "Transform Result Copied", "transformRecoveryCopiedDescription": "The selected text was transformed, but Verbatim could not replace it in the active app. The result was copied to your clipboard.", + "transformSecureFieldTitle": "Text Transformation Blocked", + "transformSecureFieldDescription": "Verbatim cannot transform selected text from password or secure fields.", + "transformSecureCheckErrorDescription": "Verbatim could not verify whether this field is secure, so no text was read or sent.", "coordinatorFailedMessage": "The dictation coordinator stopped after repeated failures. Restart Verbatim to recover.", "coordinatorFailedStep": "Dictation coordinator", "coordinatorRestartedDescription": "Verbatim recovered the dictation worker. If this keeps happening, open logs from Debug settings.", From b567c5603e8905db99830f1dba24790e8232a2dc Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sat, 18 Jul 2026 02:26:03 +0300 Subject: [PATCH 2/7] fix: block adaptive reprocess in private sessions --- src-tauri/src/commands/adaptive.rs | 150 ++++++++++++++++--- src/components/settings/AdaptiveProfiles.tsx | 13 +- src/i18n/locales/ar/translation.json | 3 +- src/i18n/locales/bg/translation.json | 3 +- src/i18n/locales/cs/translation.json | 3 +- src/i18n/locales/de/translation.json | 3 +- src/i18n/locales/en/translation.json | 3 +- src/i18n/locales/es/translation.json | 3 +- src/i18n/locales/fr/translation.json | 3 +- src/i18n/locales/he/translation.json | 3 +- src/i18n/locales/it/translation.json | 3 +- src/i18n/locales/ja/translation.json | 3 +- src/i18n/locales/ko/translation.json | 3 +- src/i18n/locales/pl/translation.json | 3 +- src/i18n/locales/pt/translation.json | 3 +- src/i18n/locales/ru/translation.json | 3 +- src/i18n/locales/sv/translation.json | 3 +- src/i18n/locales/tr/translation.json | 3 +- src/i18n/locales/uk/translation.json | 3 +- src/i18n/locales/vi/translation.json | 3 +- src/i18n/locales/zh-TW/translation.json | 3 +- src/i18n/locales/zh/translation.json | 3 +- 22 files changed, 177 insertions(+), 46 deletions(-) diff --git a/src-tauri/src/commands/adaptive.rs b/src-tauri/src/commands/adaptive.rs index ed3b1e9c..3f5189aa 100644 --- a/src-tauri/src/commands/adaptive.rs +++ b/src-tauri/src/commands/adaptive.rs @@ -4,6 +4,8 @@ use crate::settings::{get_settings, try_write_settings_domain, SettingsWriteDoma use std::sync::Arc; use tauri::{AppHandle, State}; +const PRIVATE_SESSION_REPROCESS_ERROR: &str = "private_session_active"; + struct ReprocessedAdaptiveEntry { file_name: String, raw_text: String, @@ -48,6 +50,32 @@ fn build_reprocessed_adaptive_entry( }) } +async fn reprocess_last_adaptive_entry_with( + private_session_enabled: bool, + profile_id: Option, + load_latest: Load, + save: Save, +) -> Result<(), String> +where + Load: FnOnce() -> LoadFuture, + LoadFuture: std::future::Future< + Output = Result<(Option, Vec, String), String>, + >, + Save: FnOnce(ReprocessedAdaptiveEntry) -> Result<(), String>, +{ + if private_session_enabled { + return Err(PRIVATE_SESSION_REPROCESS_ERROR.to_string()); + } + + let (entry, profiles, default_profile_id) = load_latest().await?; + let entry = + entry.ok_or_else(|| "No adaptive history entry available to reprocess".to_string())?; + let reprocessed = + build_reprocessed_adaptive_entry(&entry, &profiles, &default_profile_id, profile_id)?; + + save(reprocessed) +} + #[tauri::command] #[specta::specta] pub fn get_adaptive_profiles( @@ -78,37 +106,44 @@ pub async fn reprocess_last_adaptive_entry( history_manager: State<'_, Arc>, profile_id: Option, ) -> Result<(), String> { - let settings = get_settings(&app); - let entry = history_manager - .get_latest_adaptive_entry() - .await - .map_err(|err| err.to_string())? - .ok_or_else(|| "No adaptive history entry available to reprocess".to_string())?; - - let reprocessed = build_reprocessed_adaptive_entry( - &entry, - &settings.adaptive_profiles, - &settings.adaptive_default_profile_id, + reprocess_last_adaptive_entry_with( + crate::private_session::is_enabled(&app), profile_id, - )?; - - history_manager - .save_entry_with_metadata( - reprocessed.file_name, - reprocessed.raw_text, - true, - reprocessed.post_processed_text, - None, - reprocessed.metadata, - ) - .map(|_| ()) - .map_err(|err| err.to_string()) + || async { + let settings = get_settings(&app); + let entry = history_manager + .get_latest_adaptive_entry() + .await + .map_err(|err| err.to_string())?; + Ok(( + entry, + settings.adaptive_profiles, + settings.adaptive_default_profile_id, + )) + }, + |reprocessed| { + history_manager + .save_entry_with_metadata( + reprocessed.file_name, + reprocessed.raw_text, + true, + reprocessed.post_processed_text, + None, + reprocessed.metadata, + ) + .map(|_| ()) + .map_err(|err| err.to_string()) + }, + ) + .await } #[cfg(test)] mod tests { use super::*; use crate::adaptive::profile::default_profiles; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; fn adaptive_entry() -> HistoryEntry { HistoryEntry { @@ -192,4 +227,71 @@ mod tests { assert_eq!(reprocessed.metadata.profile_id.as_deref(), Some("email")); } + + #[tokio::test] + async fn private_session_blocks_reprocess_before_history_query_or_save() { + let query_count = Arc::new(AtomicUsize::new(0)); + let save_count = Arc::new(AtomicUsize::new(0)); + let query_count_for_load = Arc::clone(&query_count); + let save_count_for_save = Arc::clone(&save_count); + + let result = reprocess_last_adaptive_entry_with( + true, + None, + move || { + query_count_for_load.fetch_add(1, Ordering::SeqCst); + std::future::ready(Ok(( + Some(adaptive_entry()), + default_profiles(), + "default_clean".to_string(), + ))) + }, + move |_| { + save_count_for_save.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await; + + assert_eq!(result, Err(PRIVATE_SESSION_REPROCESS_ERROR.to_string())); + assert_eq!(query_count.load(Ordering::SeqCst), 0); + assert_eq!(save_count.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn public_session_reprocesses_latest_entry() { + let query_count = Arc::new(AtomicUsize::new(0)); + let save_count = Arc::new(AtomicUsize::new(0)); + let query_count_for_load = Arc::clone(&query_count); + let save_count_for_save = Arc::clone(&save_count); + + let result = reprocess_last_adaptive_entry_with( + false, + None, + move || { + query_count_for_load.fetch_add(1, Ordering::SeqCst); + std::future::ready(Ok(( + Some(adaptive_entry()), + default_profiles(), + "default_clean".to_string(), + ))) + }, + move |reprocessed| { + save_count_for_save.fetch_add(1, Ordering::SeqCst); + assert_eq!(reprocessed.file_name, "verbatim-42.wav"); + assert_eq!( + reprocessed.post_processed_text.as_deref(), + Some("please send the file today") + ); + assert_eq!(reprocessed.metadata.profile_id.as_deref(), Some("email")); + assert_eq!(reprocessed.metadata.parent_entry_id, Some(42)); + Ok(()) + }, + ) + .await; + + assert_eq!(result, Ok(())); + assert_eq!(query_count.load(Ordering::SeqCst), 1); + assert_eq!(save_count.load(Ordering::SeqCst), 1); + } } diff --git a/src/components/settings/AdaptiveProfiles.tsx b/src/components/settings/AdaptiveProfiles.tsx index 0f53edd5..d4ae12eb 100644 --- a/src/components/settings/AdaptiveProfiles.tsx +++ b/src/components/settings/AdaptiveProfiles.tsx @@ -15,6 +15,8 @@ interface AdaptiveProfilesProps { grouped?: boolean; } +const PRIVATE_SESSION_REPROCESS_ERROR = "private_session_active"; + export const AdaptiveProfiles: React.FC = ({ descriptionMode = "tooltip", grouped = false, @@ -25,6 +27,13 @@ export const AdaptiveProfiles: React.FC = ({ const [profiles, setProfiles] = useState([]); const [isCommandRunning, setCommandRunning] = useState(false); + const reprocessErrorMessage = (error: unknown) => { + const rawError = error instanceof Error ? error.message : String(error); + return rawError === PRIVATE_SESSION_REPROCESS_ERROR + ? t("settings.advanced.adaptiveProfiles.actions.privateSessionBlocked") + : rawError; + }; + useEffect(() => { let cancelled = false; @@ -70,7 +79,7 @@ export const AdaptiveProfiles: React.FC = ({ try { const result = await commands.reprocessLastAdaptiveEntry(null); if (result.status === "error") { - toast.error(String(result.error)); + toast.error(reprocessErrorMessage(result.error)); } else { await refreshSettings(); toast.success( @@ -78,7 +87,7 @@ export const AdaptiveProfiles: React.FC = ({ ); } } catch (error) { - toast.error(String(error)); + toast.error(reprocessErrorMessage(error)); } finally { setCommandRunning(false); } diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 6e61abd7..e60df69d 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -467,7 +467,8 @@ "reprocess": "إعادة معالجة الأخير", "reset": "إعادة ضبط الذاكرة", "reprocessDone": "تمت إعادة معالجة أحدث إدخال تكيفي.", - "resetDone": "تمت إعادة ضبط ذاكرة التصحيحات التكيفية." + "resetDone": "تمت إعادة ضبط ذاكرة التصحيحات التكيفية.", + "privateSessionBlocked": "إعادة المعالجة غير متاحة أثناء الجلسة الخاصة." } }, "dockedPill": { diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 78318bbd..3fc84270 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 3baf7804..84a7f7ed 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 70b12cbc..5b67ddfc 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 16a4f10c..51930dab 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess last", "reset": "Reset memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 26004c6f..0fcb3668 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 27f7cca8..28f84102 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index bead7a27..3d54f9e1 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -489,7 +489,8 @@ "reprocess": "עבד מחדש את האחרון", "reset": "אפס זיכרון", "reprocessDone": "הרשומה המסתגלת האחרונה עובדה מחדש.", - "resetDone": "זיכרון התיקונים המסתגלים אופס." + "resetDone": "זיכרון התיקונים המסתגלים אופס.", + "privateSessionBlocked": "עיבוד מחדש אינו זמין במהלך הפעלה פרטית." } }, "dockedPill": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 879a1be2..ab50d070 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index d5fd5f89..b8a1e2be 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index f52c02e1..fcce1ea9 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index a552d424..86b9482f 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 9a286787..709e4266 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index dc075ac8..480f822c 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index e0dd3255..2e2de33d 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 9b354bed..439e8ace 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index d420bfef..79426811 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index a96ad39d..f64bd06e 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index fac3e784..f246db17 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 439d03fe..9e1602b2 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -489,7 +489,8 @@ "reprocess": "Reprocess Last", "reset": "Reset Memory", "reprocessDone": "Latest adaptive entry reprocessed.", - "resetDone": "Adaptive correction memory reset." + "resetDone": "Adaptive correction memory reset.", + "privateSessionBlocked": "Reprocessing is unavailable during a private session." } }, "dockedPill": { From 5e74fd39cf469945e826fc348229a394cab05475 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sat, 18 Jul 2026 02:29:02 +0300 Subject: [PATCH 3/7] fix: make punctuation extraction UTF-8 safe --- src-tauri/src/audio_toolkit/text.rs | 53 ++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/audio_toolkit/text.rs b/src-tauri/src/audio_toolkit/text.rs index 4bc62c62..6130f33c 100644 --- a/src-tauri/src/audio_toolkit/text.rs +++ b/src-tauri/src/audio_toolkit/text.rs @@ -343,26 +343,19 @@ fn preserve_case_pattern(original: &str, replacement: &str) -> String { /// Extracts punctuation prefix and suffix from a word fn extract_punctuation(word: &str) -> (&str, &str) { - let prefix_end = word.chars().take_while(|c| !c.is_alphanumeric()).count(); + let prefix_end = word + .char_indices() + .find(|(_, character)| character.is_alphanumeric()) + .map(|(offset, _)| offset) + .unwrap_or(word.len()); let suffix_start = word .char_indices() .rev() - .take_while(|(_, c)| !c.is_alphanumeric()) - .count(); - - let prefix = if prefix_end > 0 { - &word[..prefix_end] - } else { - "" - }; - - let suffix = if suffix_start > 0 { - &word[word.len() - suffix_start..] - } else { - "" - }; + .find(|(_, character)| character.is_alphanumeric()) + .map(|(offset, character)| offset + character.len_utf8()) + .unwrap_or(0); - (prefix, suffix) + (&word[..prefix_end], &word[suffix_start..]) } /// Returns filler words appropriate for the given language code. @@ -544,6 +537,34 @@ mod tests { assert_eq!(extract_punctuation("...hello..."), ("...", "...")); } + #[test] + fn extract_punctuation_handles_arabic_punctuation() { + assert_eq!(extract_punctuation("،مرحبا؟"), ("،", "؟")); + } + + #[test] + fn extract_punctuation_handles_combining_marks() { + assert_eq!( + extract_punctuation("\u{301}e\u{301}"), + ("\u{301}", "\u{301}") + ); + } + + #[test] + fn extract_punctuation_handles_emoji() { + assert_eq!(extract_punctuation("🔥hello🙂"), ("🔥", "🙂")); + } + + #[test] + fn extract_punctuation_handles_mixed_multibyte_edges() { + assert_eq!(extract_punctuation("(🔥مرحبا؟!)"), ("(🔥", "؟!)")); + } + + #[test] + fn extract_punctuation_preserves_ascii_regression() { + assert_eq!(extract_punctuation("[...hello?!]"), ("[...", "?!]")); + } + #[test] fn test_empty_custom_words() { let text = "hello world"; From 385dc207c59fefcd1243589a142fd4dcb3e50084 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sat, 18 Jul 2026 02:31:08 +0300 Subject: [PATCH 4/7] fix: preserve Android formatter state during startup --- src/android/AndroidApp.tsx | 33 ++++++---- src/android/textFormatterSnapshot.test.ts | 73 +++++++++++++++++++++++ src/android/textFormatterSnapshot.ts | 30 ++++++++++ src/stores/dictionaryStore.ts | 4 +- src/stores/snippetsStore.ts | 4 +- tests/app.spec.ts | 1 + 6 files changed, 130 insertions(+), 15 deletions(-) create mode 100644 src/android/textFormatterSnapshot.test.ts create mode 100644 src/android/textFormatterSnapshot.ts diff --git a/src/android/AndroidApp.tsx b/src/android/AndroidApp.tsx index 82128ae2..8820078e 100644 --- a/src/android/AndroidApp.tsx +++ b/src/android/AndroidApp.tsx @@ -103,6 +103,7 @@ import { clearModelProgress, clearProgressEntry, } from "./modelProgress"; +import { buildAndroidTextFormatterSnapshot } from "./textFormatterSnapshot"; import "./AndroidApp.css"; type AndroidTab = "home" | "history" | "models" | "settings"; @@ -437,10 +438,14 @@ const WaveformPreview = () => ( const useAndroidTextFormatterSync = () => { const dictionaryEntries = useDictionaryStore((store) => store.entries); + const dictionaryEntriesLoaded = useDictionaryStore( + (store) => store.entriesLoaded, + ); const loadDictionaryEntries = useDictionaryStore( (store) => store.loadEntries, ); const snippetEntries = useSnippetsStore((store) => store.entries); + const snippetEntriesLoaded = useSnippetsStore((store) => store.entriesLoaded); const loadSnippetEntries = useSnippetsStore((store) => store.loadEntries); useEffect(() => { @@ -450,20 +455,22 @@ const useAndroidTextFormatterSync = () => { }, [loadDictionaryEntries, loadSnippetEntries]); useEffect(() => { - void syncTextFormatter( - JSON.stringify({ - dictionary_entries: dictionaryEntries.map((entry) => ({ - phrase: entry.phrase, - replacement_of: entry.replacement_of ?? null, - priority: entry.priority ?? "normal", - })), - snippets: snippetEntries.map((entry) => ({ - trigger: entry.trigger, - content: entry.content, - })), - }), + const snapshot = buildAndroidTextFormatterSnapshot( + dictionaryEntries, + snippetEntries, + { dictionaryEntriesLoaded, snippetEntriesLoaded }, ); - }, [dictionaryEntries, snippetEntries]); + if (snapshot === null) { + return; + } + + void syncTextFormatter(snapshot); + }, [ + dictionaryEntries, + dictionaryEntriesLoaded, + snippetEntries, + snippetEntriesLoaded, + ]); }; export default function AndroidApp() { diff --git a/src/android/textFormatterSnapshot.test.ts b/src/android/textFormatterSnapshot.test.ts new file mode 100644 index 00000000..d1a0ca56 --- /dev/null +++ b/src/android/textFormatterSnapshot.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import type { DictionaryEntry, SnippetEntry } from "../bindings"; +import { buildAndroidTextFormatterSnapshot } from "./textFormatterSnapshot"; + +const dictionaryEntry = ( + overrides: Partial = {}, +): DictionaryEntry => ({ + id: "dict-1", + phrase: "Verbatim", + replacement_of: "ver bait um", + priority: "normal", + active: true, + ...overrides, +}); + +const snippetEntry = (overrides: Partial = {}): SnippetEntry => ({ + id: "snippet-1", + trigger: "/sig", + content: "Sent from Verbatim", + ...overrides, +}); + +describe("Android text formatter snapshot", () => { + test("serializes only explicitly active dictionary entries", () => { + const snapshot = buildAndroidTextFormatterSnapshot( + [ + dictionaryEntry(), + dictionaryEntry({ + id: "inactive", + phrase: "quarantined", + active: false, + }), + dictionaryEntry({ + id: "legacy", + phrase: "missing active", + active: undefined, + }), + ], + [snippetEntry()], + { dictionaryEntriesLoaded: true, snippetEntriesLoaded: true }, + ); + + expect(snapshot).not.toBeNull(); + expect(JSON.parse(snapshot!)).toEqual({ + dictionary_entries: [ + { + phrase: "Verbatim", + replacement_of: "ver bait um", + priority: "normal", + }, + ], + snippets: [{ trigger: "/sig", content: "Sent from Verbatim" }], + }); + }); + + test("does not build a snapshot before both stores have loaded", () => { + expect( + buildAndroidTextFormatterSnapshot([dictionaryEntry()], [snippetEntry()], { + dictionaryEntriesLoaded: true, + snippetEntriesLoaded: false, + }), + ).toBeNull(); + }); + + test("does not build a snapshot when a load failed and stale data exists", () => { + expect( + buildAndroidTextFormatterSnapshot([dictionaryEntry()], [snippetEntry()], { + dictionaryEntriesLoaded: false, + snippetEntriesLoaded: true, + }), + ).toBeNull(); + }); +}); diff --git a/src/android/textFormatterSnapshot.ts b/src/android/textFormatterSnapshot.ts new file mode 100644 index 00000000..a840e3ff --- /dev/null +++ b/src/android/textFormatterSnapshot.ts @@ -0,0 +1,30 @@ +import type { DictionaryEntry, SnippetEntry } from "../bindings"; + +type AndroidTextFormatterLoadState = { + dictionaryEntriesLoaded: boolean; + snippetEntriesLoaded: boolean; +}; + +export function buildAndroidTextFormatterSnapshot( + dictionaryEntries: DictionaryEntry[], + snippetEntries: SnippetEntry[], + loadState: AndroidTextFormatterLoadState, +): string | null { + if (!loadState.dictionaryEntriesLoaded || !loadState.snippetEntriesLoaded) { + return null; + } + + return JSON.stringify({ + dictionary_entries: dictionaryEntries + .filter((entry) => entry.active === true) + .map((entry) => ({ + phrase: entry.phrase, + replacement_of: entry.replacement_of ?? null, + priority: entry.priority ?? "normal", + })), + snippets: snippetEntries.map((entry) => ({ + trigger: entry.trigger, + content: entry.content, + })), + }); +} diff --git a/src/stores/dictionaryStore.ts b/src/stores/dictionaryStore.ts index 2c29d3e8..a0c97f4c 100644 --- a/src/stores/dictionaryStore.ts +++ b/src/stores/dictionaryStore.ts @@ -14,6 +14,7 @@ type DictionaryState = { candidates: LearnCandidate[]; diagnostics: DictionaryDiagnostics | null; isLoading: boolean; + entriesLoaded: boolean; updatingIds: Set; loadEntries: () => Promise; addEntry: (input: DictionaryEntryInput) => Promise; @@ -77,13 +78,14 @@ export const useDictionaryStore = create()((set, get) => ({ candidates: [], diagnostics: null, isLoading: false, + entriesLoaded: false, updatingIds: new Set(), loadEntries: async () => { set({ isLoading: true }); try { const entries = unwrapResult(await commands.listDictionaryEntries()); - set({ entries: sortEntries(entries) }); + set({ entries: sortEntries(entries), entriesLoaded: true }); } finally { set({ isLoading: false }); } diff --git a/src/stores/snippetsStore.ts b/src/stores/snippetsStore.ts index b8033420..37ddb49c 100644 --- a/src/stores/snippetsStore.ts +++ b/src/stores/snippetsStore.ts @@ -9,6 +9,7 @@ import { type SnippetsState = { entries: SnippetEntry[]; isLoading: boolean; + entriesLoaded: boolean; updatingIds: Set; loadEntries: () => Promise; addEntry: (input: SnippetEntryInput) => Promise; @@ -42,13 +43,14 @@ const unwrapResult = ( export const useSnippetsStore = create()((set) => ({ entries: [], isLoading: false, + entriesLoaded: false, updatingIds: new Set(), loadEntries: async () => { set({ isLoading: true }); try { const entries = unwrapResult(await commands.listSnippetEntries()); - set({ entries: sortEntries(entries) }); + set({ entries: sortEntries(entries), entriesLoaded: true }); } finally { set({ isLoading: false }); } diff --git a/tests/app.spec.ts b/tests/app.spec.ts index 116c5372..1c8ff359 100644 --- a/tests/app.spec.ts +++ b/tests/app.spec.ts @@ -1999,6 +1999,7 @@ test.describe("Verbatim App", () => { replacement_of: "club", source: "manual", priority: "starred", + active: true, created_at_ms: 1, updated_at_ms: 2, }, From 49b248514c8606a5e70304642ffe7e598a45ee50 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sat, 18 Jul 2026 02:37:53 +0300 Subject: [PATCH 5/7] fix: reject ambiguous dictionary entry IDs --- src-tauri/src/commands/dictionary.rs | 13 ++- src-tauri/src/dictionary.rs | 125 +++++++++++++++++++++--- src/i18n/locales/ar/translation.json | 3 +- src/i18n/locales/bg/translation.json | 3 +- src/i18n/locales/cs/translation.json | 3 +- src/i18n/locales/de/translation.json | 3 +- src/i18n/locales/en/translation.json | 3 +- src/i18n/locales/es/translation.json | 3 +- src/i18n/locales/fr/translation.json | 3 +- src/i18n/locales/he/translation.json | 3 +- src/i18n/locales/it/translation.json | 3 +- src/i18n/locales/ja/translation.json | 3 +- src/i18n/locales/ko/translation.json | 3 +- src/i18n/locales/pl/translation.json | 3 +- src/i18n/locales/pt/translation.json | 3 +- src/i18n/locales/ru/translation.json | 3 +- src/i18n/locales/sv/translation.json | 3 +- src/i18n/locales/tr/translation.json | 3 +- src/i18n/locales/uk/translation.json | 3 +- src/i18n/locales/vi/translation.json | 3 +- src/i18n/locales/zh-TW/translation.json | 3 +- src/i18n/locales/zh/translation.json | 3 +- src/stores/dictionaryStore.ts | 7 +- 23 files changed, 165 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/commands/dictionary.rs b/src-tauri/src/commands/dictionary.rs index d34971d1..94b84ab1 100644 --- a/src-tauri/src/commands/dictionary.rs +++ b/src-tauri/src/commands/dictionary.rs @@ -69,8 +69,8 @@ pub fn update_dictionary_entry( #[specta::specta] pub fn delete_dictionary_entry(app: AppHandle, id: String) -> Result<(), String> { crate::settings::mutate_settings_locked(&app, |settings| { - crate::dictionary::delete_entries(settings, &[id]); - }); + crate::dictionary::delete_entries(settings, &[id]) + })?; Ok(()) } @@ -80,10 +80,9 @@ pub fn undo_dictionary_entries( app: AppHandle, ids: Vec, ) -> Result, String> { - let deleted = crate::settings::mutate_settings_locked(&app, |settings| { + crate::settings::mutate_settings_locked(&app, |settings| { crate::dictionary::delete_entries(settings, &ids) - }); - Ok(deleted) + }) } #[tauri::command] @@ -177,8 +176,8 @@ pub fn reject_learn_candidate(app: AppHandle, phrase: String) -> Result<(), Stri pub fn set_dictionary_entry_active(app: AppHandle, id: String, active: bool) -> Result<(), String> { let now_ms = crate::dictionary::current_unix_ms(); crate::settings::mutate_settings_locked(&app, |settings| { - crate::dictionary::set_entry_active(settings, now_ms, &id, active); - }); + crate::dictionary::set_entry_active(settings, now_ms, &id, active) + })?; Ok(()) } diff --git a/src-tauri/src/dictionary.rs b/src-tauri/src/dictionary.rs index 2215accd..5c4faa1c 100644 --- a/src-tauri/src/dictionary.rs +++ b/src-tauri/src/dictionary.rs @@ -2,6 +2,8 @@ use crate::dictionary_learning::canonicalize; use crate::settings::{ AppSettings, DictionaryEntry, DictionaryEntryPriority, DictionaryEntrySource, LearnCandidate, }; + +pub const AMBIGUOUS_ENTRY_ID: &str = "ambiguous_entry_id"; use std::time::{SystemTime, UNIX_EPOCH}; pub const MAX_DICTIONARY_PHRASE_CHARS: usize = 120; @@ -238,11 +240,17 @@ pub fn update_entry( replacement_of: Option>, priority: Option, ) -> Result { - let index = settings + let mut matching_indices = settings .dictionary_entries .iter() - .position(|entry| entry.id == id) + .enumerate() + .filter_map(|(index, entry)| (entry.id == id).then_some(index)); + let index = matching_indices + .next() .ok_or("Dictionary entry not found")?; + if matching_indices.next().is_some() { + return Err(AMBIGUOUS_ENTRY_ID.to_string()); + } if let Some(next_phrase) = phrase.as_deref().and_then(sanitize_dictionary_phrase) { if has_phrase(&settings.dictionary_entries, &next_phrase, Some(id)) { @@ -282,7 +290,23 @@ pub fn update_entry( Ok(updated) } -pub fn delete_entries(settings: &mut AppSettings, ids: &[String]) -> Vec { +pub fn delete_entries( + settings: &mut AppSettings, + ids: &[String], +) -> Result, String> { + for id in ids { + if settings + .dictionary_entries + .iter() + .filter(|entry| entry.id == *id) + .take(2) + .count() + > 1 + { + return Err(AMBIGUOUS_ENTRY_ID.to_string()); + } + } + let mut deleted = Vec::new(); settings.dictionary_entries.retain(|entry| { if ids.iter().any(|id| id == &entry.id) { @@ -301,7 +325,7 @@ pub fn delete_entries(settings: &mut AppSettings, ids: &[String]) -> Vec bool { - let Some(entry) = settings.dictionary_entries.iter_mut().find(|e| e.id == id) else { - return false; +pub fn set_entry_active( + settings: &mut AppSettings, + now_ms: u64, + id: &str, + active: bool, +) -> Result { + let mut matching_indices = settings + .dictionary_entries + .iter() + .enumerate() + .filter_map(|(index, entry)| (entry.id == id).then_some(index)); + let Some(index) = matching_indices.next() else { + return Ok(false); }; + if matching_indices.next().is_some() { + return Err(AMBIGUOUS_ENTRY_ID.to_string()); + } + + let entry = &mut settings.dictionary_entries[index]; entry.active = active; entry.needs_review = false; entry.updated_at_ms = now_ms; sync_legacy_custom_words(settings); - true + Ok(true) } fn stamp_since(diag: &mut crate::settings::DictionaryDiagnostics, now_ms: u64) { @@ -975,13 +1014,75 @@ mod tests { ]; sync_legacy_custom_words(&mut settings); - let deleted = delete_entries(&mut settings, &["dict_1_robyn".to_string()]); + let deleted = delete_entries(&mut settings, &["dict_1_robyn".to_string()]) + .expect("unique entry deleted"); assert_eq!(deleted.len(), 1); assert_eq!(deleted[0].phrase, "Robyn"); assert_eq!(settings.custom_words, vec!["Abdullah al Kulaib"]); } + #[test] + fn update_entry_rejects_ambiguous_id_without_mutating_entries() { + let mut settings = get_default_settings(); + settings.dictionary_entries = vec![ + entry("dict_collision", "Robyn"), + entry("dict_collision", "Robinette"), + ]; + sync_legacy_custom_words(&mut settings); + let original_entries = settings.dictionary_entries.clone(); + let original_words = settings.custom_words.clone(); + + let error = update_entry( + &mut settings, + 100, + "dict_collision", + Some("Changed".to_string()), + None, + None, + ) + .expect_err("duplicate persisted IDs must be rejected"); + + assert_eq!(error, "ambiguous_entry_id"); + assert_eq!(settings.dictionary_entries, original_entries); + assert_eq!(settings.custom_words, original_words); + } + + #[test] + fn delete_entries_rejects_ambiguous_id_without_mutating_entries() { + let mut settings = get_default_settings(); + settings.dictionary_entries = vec![ + entry("dict_collision", "Robyn"), + entry("dict_collision", "Robinette"), + ]; + sync_legacy_custom_words(&mut settings); + let original_entries = settings.dictionary_entries.clone(); + let original_words = settings.custom_words.clone(); + + let error = delete_entries(&mut settings, &["dict_collision".to_string()]) + .expect_err("duplicate persisted IDs must be rejected"); + + assert_eq!(error, "ambiguous_entry_id"); + assert_eq!(settings.dictionary_entries, original_entries); + assert_eq!(settings.custom_words, original_words); + } + + #[test] + fn set_entry_active_rejects_ambiguous_id_without_mutating_entries() { + let mut settings = get_default_settings(); + settings.dictionary_entries = vec![ + entry("dict_collision", "Robyn"), + entry("dict_collision", "Robinette"), + ]; + let original_entries = settings.dictionary_entries.clone(); + + let error = set_entry_active(&mut settings, 100, "dict_collision", false) + .expect_err("duplicate persisted IDs must be rejected"); + + assert_eq!(error, "ambiguous_entry_id"); + assert_eq!(settings.dictionary_entries, original_entries); + } + #[test] fn upsert_auto_learn_entry_marks_source_and_replacement() { let mut settings = get_default_settings(); @@ -1024,7 +1125,7 @@ mod tests { .expect("auto learn") .expect("new entry"); - let deleted = delete_entries(&mut settings, &[learned.id]); + let deleted = delete_entries(&mut settings, &[learned.id]).expect("unique entry deleted"); assert_eq!(deleted.len(), 1); assert!(settings.dictionary_entries.is_empty()); assert_eq!(settings.dictionary_auto_learn_suppressed, vec!["gibbeteen"]); @@ -1492,11 +1593,11 @@ mod tests { needs_review: true, ..entry_full("dict_1", "their", Some("there")) }); - assert!(set_entry_active(&mut s, 99, "dict_1", true)); + assert!(set_entry_active(&mut s, 99, "dict_1", true).expect("unique entry")); assert!(s.dictionary_entries[0].active); assert!(!s.dictionary_entries[0].needs_review); assert_eq!(s.dictionary_entries[0].updated_at_ms, 99); - assert!(!set_entry_active(&mut s, 99, "missing", true)); + assert!(!set_entry_active(&mut s, 99, "missing", true).expect("missing is unambiguous")); } #[test] diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index e60df69d..ffcb7fa8 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -869,7 +869,8 @@ "delete": "فشل حذف إدخال القاموس", "duplicate": "{{phrase}} موجود بالفعل في قاموسك", "approve": "تعذرت الموافقة على الإدخال", - "reject": "تعذر رفض الإدخال" + "reject": "تعذر رفض الإدخال", + "ambiguousEntryId": "يتطابق هذا الإدخال مع أكثر من عنصر محفوظ في القاموس. لم يتم إجراء أي تغييرات." }, "pending": { "title": "قيد المراجعة", diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 3fc84270..f668935a 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 84a7f7ed..e5943e9b 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 5b67ddfc..a8a34f67 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 51930dab..acb4652d 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -899,7 +899,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." } }, "snippets": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 0fcb3668..220bbde3 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 28f84102..42c51a1b 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index 3d54f9e1..25a5c387 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -869,7 +869,8 @@ "delete": "מחיקת רשומת מילון נכשלה", "duplicate": "{{phrase}} כבר קיים במילון שלך", "approve": "לא ניתן היה לאשר את הרשומה", - "reject": "לא ניתן היה לדחות את הרשומה" + "reject": "לא ניתן היה לדחות את הרשומה", + "ambiguousEntryId": "רשומה זו תואמת ליותר מפריט שמור אחד במילון. לא בוצעו שינויים." }, "pending": { "title": "ממתין לבדיקה", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index ab50d070..c4e7de11 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index b8a1e2be..e7fb4859 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index fcce1ea9..f3249e27 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 86b9482f..ebbabf39 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 709e4266..4553af8f 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 480f822c..5fcb7ed8 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index 2e2de33d..1bb8ce4a 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 439e8ace..85969f02 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 79426811..b053ce52 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index f64bd06e..25a9f5fa 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index f246db17..e5d6fd87 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 9e1602b2..c70c2a0e 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -869,7 +869,8 @@ "delete": "Failed to delete dictionary entry", "duplicate": "{{phrase}} is already in your dictionary", "approve": "Couldn't approve entry", - "reject": "Couldn't reject entry" + "reject": "Couldn't reject entry", + "ambiguousEntryId": "This entry matches more than one saved dictionary item. No changes were made." }, "pending": { "title": "Pending review", diff --git a/src/stores/dictionaryStore.ts b/src/stores/dictionaryStore.ts index a0c97f4c..6fd76431 100644 --- a/src/stores/dictionaryStore.ts +++ b/src/stores/dictionaryStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import i18n from "@/i18n"; import { commands, type DictionaryDiagnostics, @@ -66,7 +67,11 @@ const unwrapResult = ( result: { status: "ok"; data: T } | { status: "error"; error: string }, ) => { if (result.status === "error") { - throw new Error(result.error); + const message = + result.error === "ambiguous_entry_id" + ? i18n.t("settings.dictionary.errors.ambiguousEntryId") + : result.error; + throw new Error(message); } return result.data; From f8ad2dc08f8280e40313f92c786648d11fd11f92 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sat, 18 Jul 2026 02:38:40 +0300 Subject: [PATCH 6/7] fix: make dictionary mutations durable --- src-tauri/src/commands/dictionary.rs | 186 ++++++++++++++++++++++----- src-tauri/src/post_paste_learning.rs | 6 +- src-tauri/src/settings.rs | 115 ++++++++++++++++- 3 files changed, 268 insertions(+), 39 deletions(-) diff --git a/src-tauri/src/commands/dictionary.rs b/src-tauri/src/commands/dictionary.rs index 94b84ab1..9a1b189b 100644 --- a/src-tauri/src/commands/dictionary.rs +++ b/src-tauri/src/commands/dictionary.rs @@ -35,10 +35,7 @@ pub fn add_dictionary_entry( input: DictionaryEntryInput, ) -> Result { let now_ms = crate::dictionary::current_unix_ms(); - // The closure returns the fallible upsert Result as-is; mutate_settings_locked persists - // the settings regardless of Ok/Err. A failed upsert leaves settings unchanged, so the - // redundant write-back on the Err path is harmless. - crate::settings::mutate_settings_locked(&app, |settings| { + crate::settings::try_mutate_settings_locked_and_save(&app, |settings| { crate::dictionary::upsert_manual_entry(settings, now_ms, input.phrase, input.replacement_of) }) } @@ -51,9 +48,7 @@ pub fn update_dictionary_entry( update: DictionaryEntryUpdate, ) -> Result { let now_ms = crate::dictionary::current_unix_ms(); - // See add_dictionary_entry: the Result is returned as-is; a failed update leaves - // settings unchanged, so the redundant write-back on the Err path is harmless. - crate::settings::mutate_settings_locked(&app, |settings| { + crate::settings::try_mutate_settings_locked_and_save(&app, |settings| { crate::dictionary::update_entry( settings, now_ms, @@ -68,10 +63,9 @@ pub fn update_dictionary_entry( #[tauri::command] #[specta::specta] pub fn delete_dictionary_entry(app: AppHandle, id: String) -> Result<(), String> { - crate::settings::mutate_settings_locked(&app, |settings| { - crate::dictionary::delete_entries(settings, &[id]) - })?; - Ok(()) + crate::settings::try_mutate_settings_locked_and_save(&app, |settings| { + crate::dictionary::delete_entries(settings, &[id]).map(|_| ()) + }) } #[tauri::command] @@ -80,7 +74,7 @@ pub fn undo_dictionary_entries( app: AppHandle, ids: Vec, ) -> Result, String> { - crate::settings::mutate_settings_locked(&app, |settings| { + crate::settings::try_mutate_settings_locked_and_save(&app, |settings| { crate::dictionary::delete_entries(settings, &ids) }) } @@ -91,6 +85,14 @@ pub fn learn_custom_words_from_correction( app: AppHandle, dictated_text: String, corrected_text: String, +) -> Result, String> { + learn_custom_words_from_correction_with_app(&app, dictated_text, corrected_text) +} + +fn learn_custom_words_from_correction_with_app( + app: &AppHandle, + dictated_text: String, + corrected_text: String, ) -> Result, String> { let now_ms = crate::dictionary::current_unix_ms(); // Mint a session id local to this command invocation. Running every inferred candidate @@ -99,7 +101,7 @@ pub fn learn_custom_words_from_correction( // single correction can no longer mint a permanent entry outright. let session = format!("command_{now_ms}"); - let (promoted, learned_count) = crate::settings::mutate_settings_locked(&app, |settings| { + let persisted = crate::settings::try_mutate_settings_locked_and_save(app, |settings| { let candidates = crate::dictionary_learning::infer_auto_learn_candidates( &dictated_text, &corrected_text, @@ -128,15 +130,22 @@ pub fn learn_custom_words_from_correction( _ => {} } } - (promoted, learned_count) + Ok((promoted, learned_count)) }); - // Emit AFTER the lock is released, mirroring the post-paste learn path, so the - // review-queue UI refreshes when a first-time correction stages a candidate. - if learned_count > 0 { + finish_learn_command_after_persist(persisted, |learned_count| { let _ = app.emit("dictionary-candidates-learned", learned_count); - } + }) +} +fn finish_learn_command_after_persist( + persisted: Result<(Vec, usize), String>, + emit_candidates_learned: impl FnOnce(usize), +) -> Result, String> { + let (promoted, learned_count) = persisted?; + if learned_count > 0 { + emit_candidates_learned(learned_count); + } Ok(promoted) } @@ -156,29 +165,32 @@ pub fn approve_learn_candidate( replacement_of: Option, ) -> Result, String> { let now_ms = crate::dictionary::current_unix_ms(); - let entry = crate::settings::mutate_settings_locked(&app, |settings| { - crate::dictionary::approve_candidate(settings, now_ms, &phrase, replacement_of.as_deref()) - }); - Ok(entry) + crate::settings::try_mutate_settings_locked_and_save(&app, |settings| { + Ok(crate::dictionary::approve_candidate( + settings, + now_ms, + &phrase, + replacement_of.as_deref(), + )) + }) } #[tauri::command] #[specta::specta] pub fn reject_learn_candidate(app: AppHandle, phrase: String) -> Result<(), String> { - crate::settings::mutate_settings_locked(&app, |settings| { + crate::settings::try_mutate_settings_locked_and_save(&app, |settings| { crate::dictionary::reject_candidate(settings, &phrase); - }); - Ok(()) + Ok(()) + }) } #[tauri::command] #[specta::specta] pub fn set_dictionary_entry_active(app: AppHandle, id: String, active: bool) -> Result<(), String> { let now_ms = crate::dictionary::current_unix_ms(); - crate::settings::mutate_settings_locked(&app, |settings| { - crate::dictionary::set_entry_active(settings, now_ms, &id, active) - })?; - Ok(()) + crate::settings::try_mutate_settings_locked_and_save(&app, |settings| { + crate::dictionary::set_entry_active(settings, now_ms, &id, active).map(|_| ()) + }) } #[tauri::command] @@ -193,8 +205,118 @@ pub fn get_dictionary_diagnostics( #[specta::specta] pub fn reset_dictionary_diagnostics(app: AppHandle) -> Result<(), String> { let now_ms = crate::dictionary::current_unix_ms(); - crate::settings::mutate_settings_locked(&app, |settings| { + crate::settings::try_mutate_settings_locked_and_save(&app, |settings| { crate::dictionary::reset_dictionary_diagnostics(settings, now_ms); - }); - Ok(()) + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::{finish_learn_command_after_persist, learn_custom_words_from_correction_with_app}; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tauri::Listener; + use tauri_plugin_store::StoreExt; + + struct TestPathCleanup(PathBuf); + + impl Drop for TestPathCleanup { + fn drop(&mut self) { + if self.0.is_dir() { + let _ = std::fs::remove_dir_all(&self.0); + } else { + let _ = std::fs::remove_file(&self.0); + } + } + } + + #[test] + fn failed_durable_learn_returns_error_without_emitting_event() { + let emitted = AtomicUsize::new(0); + + let result = finish_learn_command_after_persist( + Err("atomically persist settings: forced failure".to_string()), + |_| { + emitted.fetch_add(1, Ordering::SeqCst); + }, + ); + + assert_eq!( + result.expect_err("persistence failure must reach the command caller"), + "atomically persist settings: forced failure" + ); + assert_eq!(emitted.load(Ordering::SeqCst), 0); + } + + #[test] + fn learn_command_save_failure_rolls_back_and_emits_no_event() { + let mut context = tauri::test::mock_context(tauri::test::noop_assets()); + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after Unix epoch") + .as_nanos(); + context.config_mut().identifier = format!( + "com.galaxyruler.verbatim.dictionary-command-test.{}.{}", + std::process::id(), + unique + ); + let app = tauri::test::mock_builder() + .plugin(tauri_plugin_store::Builder::new().build()) + .build(context) + .expect("build isolated command test app"); + let app_data_dir = crate::portable::app_data_dir(app.handle()) + .expect("resolve isolated app data directory"); + let _cleanup = TestPathCleanup(app_data_dir.clone()); + let store = app + .store_builder(PathBuf::from(crate::settings::SETTINGS_STORE_PATH)) + .disable_auto_save() + .build() + .expect("build cached settings store"); + store.set( + "settings", + serde_json::to_value(crate::settings::get_default_settings()) + .expect("serialize original settings"), + ); + assert!( + !crate::dictionary_learning::infer_auto_learn_candidates( + "meet robin.", + "meet Robyn.", + &[], + ) + .is_empty(), + "fixture must reach the learned-candidate event path" + ); + + let emitted = Arc::new(AtomicUsize::new(0)); + let emitted_for_listener = Arc::clone(&emitted); + let _listener = app.listen("dictionary-candidates-learned", move |_| { + emitted_for_listener.fetch_add(1, Ordering::SeqCst); + }); + + if app_data_dir.exists() { + std::fs::remove_dir_all(&app_data_dir).expect("remove test app data directory"); + } + std::fs::write(&app_data_dir, "block settings directory") + .expect("replace app data directory with a file"); + + let error = learn_custom_words_from_correction_with_app( + app.handle(), + "meet robin.".to_string(), + "meet Robyn.".to_string(), + ) + .expect_err("forced settings save failure must reach the command caller"); + + assert!(error.contains("atomically persist settings")); + let cached: crate::settings::AppSettings = serde_json::from_value( + store + .get("settings") + .expect("original settings remain cached"), + ) + .expect("cached settings deserialize"); + assert!(cached.dictionary_entries.is_empty()); + assert!(cached.dictionary_learn_candidates.is_empty()); + assert_eq!(emitted.load(Ordering::SeqCst), 0); + } } diff --git a/src-tauri/src/post_paste_learning.rs b/src-tauri/src/post_paste_learning.rs index 3c482fd6..0619997b 100644 --- a/src-tauri/src/post_paste_learning.rs +++ b/src-tauri/src/post_paste_learning.rs @@ -265,7 +265,7 @@ fn learn_from_text_snapshots( let now_ms = crate::dictionary::current_unix_ms(); let (promoted_entries, learned_count, routed_count) = - crate::settings::mutate_settings_locked(app, |settings| { + crate::settings::try_mutate_settings_locked_and_save(app, |settings| { let candidates = crate::dictionary_learning::infer_auto_learn_candidates( &inserted_text, &corrected_text, @@ -307,8 +307,8 @@ fn learn_from_text_snapshots( reinforced as u32, routed as u32, ); - (promoted, learned, routed) - }); + Ok((promoted, learned, routed)) + })?; // Emit AFTER the lock is released. if !promoted_entries.is_empty() { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 082a1bd2..1d0fe986 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -2006,8 +2006,8 @@ fn persist_loaded_settings_value( persist_settings_value(store, settings_value, immediate_save) } -/// The ONLY public way to mutate persisted settings. Holds the write lock across the -/// whole read-modify-write so concurrent mutations cannot lost-update each other. +/// The legacy public way to mutate debounced persisted settings. Holds the write lock across +/// the whole read-modify-write so concurrent mutations cannot lost-update each other. /// Do NOT `.await` or emit Tauri events inside `f`; emit after this returns. pub fn mutate_settings_locked(app: &AppHandle, f: impl FnOnce(&mut AppSettings) -> T) -> T { let _guard = SETTINGS_WRITE_LOCK @@ -2027,9 +2027,46 @@ pub fn mutate_settings_locked(app: &AppHandle, f: impl FnOnce(&mut AppSetting result } +fn try_mutate_settings_locked_and_save_to_store( + store: &tauri_plugin_store::Store, + mut settings: AppSettings, + mutate: F, +) -> Result +where + R: tauri::Runtime, + F: FnOnce(&mut AppSettings) -> Result, +{ + let selected_microphone_before = settings.selected_microphone.clone(); + let result = apply_settings_mutation(&mut settings, mutate)?; + reconcile_selected_microphone_identity(selected_microphone_before.as_deref(), &mut settings); + write_settings_to_store_with_immediate_save(store, settings, true)?; + Ok(result) +} + +/// Fallible locked mutation for command paths that must not report success until the +/// updated settings value is durable. The Store helper restores its previous cached value +/// when the forced save fails, so callers observe the same settings after an error. +/// Do NOT `.await` or emit Tauri events inside `mutate`; emit only after this returns `Ok`. +pub(crate) fn try_mutate_settings_locked_and_save( + app: &AppHandle, + mutate: F, +) -> Result +where + R: tauri::Runtime, + F: FnOnce(&mut AppSettings) -> Result, +{ + let _guard = SETTINGS_WRITE_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let store = open_settings_store(app)?; + let settings = + settings_for_fallible_domain_write(load_settings_from_store(app, store.as_ref()))?; + try_mutate_settings_locked_and_save_to_store(store.as_ref(), settings, mutate) +} + // NOTE: `write_settings` and `get_settings` are the lock-free primitives. All MUTATION -// paths must go through `mutate_settings_locked` or the domain writers -// (`write_settings_domain` / `try_write_settings_domain`), which take the same lock. +// paths must go through `mutate_settings_locked`, `try_mutate_settings_locked_and_save`, or the +// domain writers (`write_settings_domain` / `try_write_settings_domain`), which take the same lock. // The deny-list test `dictionary_mutation_paths_do_not_call_write_settings_directly` // guards this for every migrated file. pub fn write_settings(app: &AppHandle, settings: AppSettings) { @@ -2607,6 +2644,56 @@ mod tests { ); } + #[test] + fn forced_save_mutation_rolls_back_cached_settings_on_failure() { + let temp_dir = tempfile::tempdir().expect("create settings tempdir"); + let blocked_parent = temp_dir.path().join("settings-parent-file"); + std::fs::write(&blocked_parent, "not a directory").expect("create blocked parent file"); + let store_path = blocked_parent.join("settings.json"); + let app = tauri::test::mock_builder() + .plugin(tauri_plugin_store::Builder::new().build()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("build Tauri test app"); + let store = app + .store_builder(&store_path) + .disable_auto_save() + .build() + .expect("build store before attempted persistence"); + let before = get_default_settings(); + store.set( + "settings", + serde_json::to_value(&before).expect("serialize original settings"), + ); + + let error = + try_mutate_settings_locked_and_save_to_store(store.as_ref(), before, |settings| { + settings.dictionary_entries.push(DictionaryEntry { + id: "dict_1_robyn".to_string(), + phrase: "Robyn".to_string(), + replacement_of: None, + source: DictionaryEntrySource::Manual, + priority: DictionaryEntryPriority::Normal, + created_at_ms: 1, + updated_at_ms: 1, + active: true, + user_confirmed: false, + needs_review: false, + }); + Ok(()) + }) + .expect_err("blocked store path must fail the forced save"); + + assert!(error.contains("atomically persist settings")); + let cached: AppSettings = serde_json::from_value( + store + .get("settings") + .expect("original settings remain cached"), + ) + .expect("cached settings deserialize"); + assert!(cached.dictionary_entries.is_empty()); + assert!(cached.custom_words.is_empty()); + } + #[test] fn adaptive_correction_memory_domain_write_saves_immediately_without_auto_save() { let temp_dir = tempfile::tempdir().expect("create settings tempdir"); @@ -3137,6 +3224,26 @@ mod tests { } } + #[test] + fn dictionary_durable_mutation_paths_use_forced_save_writer() { + let commands = std::fs::read_to_string("src/commands/dictionary.rs") + .expect("read dictionary commands"); + assert!(commands.contains("try_mutate_settings_locked_and_save(")); + assert!(!commands.contains("mutate_settings_locked(")); + + let watcher = + std::fs::read_to_string("src/post_paste_learning.rs").expect("read post-paste watcher"); + let learning_start = watcher + .find("fn learn_from_text_snapshots(") + .expect("find post-paste learning function"); + let learning_end = watcher[learning_start..] + .find("\nfn auto_learn_outcome_log_message") + .expect("find post-paste learning function end"); + let learning = &watcher[learning_start..learning_start + learning_end]; + assert!(learning.contains("try_mutate_settings_locked_and_save(")); + assert!(!learning.contains("mutate_settings_locked(")); + } + #[test] fn apply_settings_mutation_runs_closure_and_returns_value() { let mut settings = crate::settings::get_default_settings(); From c53f45510e02ed66745df15fe9ee4a2cdb5605fd Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sat, 18 Jul 2026 02:39:42 +0300 Subject: [PATCH 7/7] fix: key filler cleanup to dictation language --- src-tauri/src/actions.rs | 13 +- src-tauri/src/adaptive/processor.rs | 38 +++++- src-tauri/src/audio_toolkit/text.rs | 78 ++++++----- src-tauri/src/commands/adaptive.rs | 14 +- src-tauri/src/managers/transcription.rs | 128 ++++++++++++++++++- src-tauri/src/managers/transcription_mock.rs | 16 ++- 6 files changed, 234 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 0c771625..f63613f6 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -1024,6 +1024,7 @@ fn force_ltr_input_direction_before_paste( pub(crate) async fn process_adaptive_transcription_output( settings: &AppSettings, transcription: &str, + effective_language: Option<&str>, context: crate::adaptive::types::CapturedContext, shortcut: crate::adaptive::types::ShortcutIntent, ) -> crate::adaptive::types::AdaptiveProcessResult { @@ -1053,7 +1054,11 @@ pub(crate) async fn process_adaptive_transcription_output( let final_text = if settings.formatting_level == crate::settings::FormattingLevel::None { transcription.to_string() } else { - crate::adaptive::processor::deterministic_process(transcription, profile) + crate::adaptive::processor::deterministic_process( + transcription, + profile, + effective_language, + ) }; let final_text = crate::adaptive::smart_formatting::format_transcript( &final_text, @@ -1987,7 +1992,7 @@ impl ShortcutAction for TranscribeAction { // Transcribe concurrently with WAV save let transcription_time = Instant::now(); - let transcription_result = tm.transcribe_with_cancellation( + let transcription_result = tm.transcribe_with_cancellation_context( samples, operation_token .as_ref() @@ -2030,7 +2035,8 @@ impl ShortcutAction for TranscribeAction { } match transcription_result { - Ok(transcription) => { + Ok(transcription_output) => { + let transcription = transcription_output.text; debug!( "{}", transcription_completed_log_message( @@ -2071,6 +2077,7 @@ impl ShortcutAction for TranscribeAction { let processed = process_adaptive_transcription_output( &settings, &transcription, + transcription_output.effective_language.as_deref(), context.clone(), crate::adaptive::types::ShortcutIntent::Default, ) diff --git a/src-tauri/src/adaptive/processor.rs b/src-tauri/src/adaptive/processor.rs index 8fdc3057..affa9c04 100644 --- a/src-tauri/src/adaptive/processor.rs +++ b/src-tauri/src/adaptive/processor.rs @@ -2,14 +2,21 @@ use crate::adaptive::language::analyze_language; use crate::adaptive::profile::{AdaptiveProfile, RewriteMode}; use crate::adaptive::types::LanguageClass; -pub fn deterministic_process(raw: &str, profile: &AdaptiveProfile) -> String { +pub fn deterministic_process( + raw: &str, + profile: &AdaptiveProfile, + effective_language: Option<&str>, +) -> String { if profile.rewrite.mode == RewriteMode::Disabled { return raw.to_string(); } let mut tokens = Vec::new(); for token in raw.split_whitespace() { - if profile.cleanup.remove_fillers && is_simple_filler(token) { + if profile.cleanup.remove_fillers + && effective_language.is_some_and(is_english_language) + && is_simple_filler(token) + { continue; } tokens.push(token); @@ -25,6 +32,13 @@ pub fn deterministic_process(raw: &str, profile: &AdaptiveProfile) -> String { output } +fn is_english_language(language: &str) -> bool { + language + .split(['-', '_']) + .next() + .is_some_and(|base| base.eq_ignore_ascii_case("en")) +} + pub fn validate_output(raw: &str, output: &str, profile: &AdaptiveProfile) -> Result<(), String> { if output.trim().is_empty() && !raw.trim().is_empty() { return Err("processed output is empty".to_string()); @@ -213,21 +227,35 @@ mod tests { #[test] fn raw_profile_returns_input_unchanged() { - let result = deterministic_process("um hello hello", &profile("raw")); + let result = deterministic_process("um hello hello", &profile("raw"), None); assert_eq!(result, "um hello hello"); } #[test] fn clean_profile_removes_simple_english_fillers() { - let result = deterministic_process("um hello, uh we should go", &profile("default_clean")); + let result = deterministic_process( + "um hello, uh we should go", + &profile("default_clean"), + Some("en"), + ); assert_eq!(result, "hello, we should go"); } + #[test] + fn clean_profile_preserves_english_fillers_without_locked_english() { + let auto = deterministic_process("um hello", &profile("default_clean"), None); + let portuguese = deterministic_process("um hello", &profile("default_clean"), Some("pt")); + + assert_eq!(auto, "um hello"); + assert_eq!(portuguese, "um hello"); + } + #[test] fn technical_profile_preserves_identifiers() { let result = deterministic_process( "uh run cargo_test in src-tauri/src/actions.rs", &profile("technical"), + Some("en"), ); assert!(result.contains("cargo_test")); assert!(result.contains("src-tauri/src/actions.rs")); @@ -238,6 +266,7 @@ mod tests { let result = deterministic_process( "Dear James, I have received your Excel file. Sincerely, Abdullah Al-Khalid.", &profile("email"), + Some("en"), ); assert_eq!( @@ -251,6 +280,7 @@ mod tests { let result = deterministic_process( "Hello Dana, The report is attached. Best regards, Abdullah.", &profile("email"), + Some("en"), ); assert_eq!( diff --git a/src-tauri/src/audio_toolkit/text.rs b/src-tauri/src/audio_toolkit/text.rs index 6130f33c..c77be4af 100644 --- a/src-tauri/src/audio_toolkit/text.rs +++ b/src-tauri/src/audio_toolkit/text.rs @@ -437,35 +437,40 @@ fn collapse_stutters(text: &str) -> String { /// Filters transcription output by removing filler words and stutter artifacts. /// /// This function cleans up raw transcription text by: -/// 1. Removing filler words based on the app language (or custom list) +/// 1. Removing filler words based on a validated dictation language (or custom list) /// 2. Collapsing repeated word stutters (e.g., "wh wh wh" -> "wh") /// 3. Cleaning up excess whitespace /// /// # Arguments /// * `text` - The raw transcription text to filter -/// * `lang` - The app language code (e.g., "en", "pt-BR") used to select filler words +/// * `lang` - A validated locked dictation language (e.g., "en", "pt-BR"). `None` +/// skips language-default filler removal. /// * `custom_filler_words` - Optional user-provided filler word list. `Some(vec)` overrides -/// language defaults; `Some(empty vec)` disables filtering; `None` uses language defaults. +/// language defaults; `Some(empty vec)` disables filler removal; `None` uses language +/// defaults only when `lang` is `Some`. /// /// # Returns /// The filtered text with filler words and stutters removed pub fn filter_transcription_output( text: &str, - lang: &str, + lang: Option<&str>, custom_filler_words: &Option>, ) -> String { let mut filtered = text.to_string(); // Build filler patterns from custom list or language defaults - let patterns: Vec = match custom_filler_words { - Some(words) => words + let patterns: Vec = if let Some(words) = custom_filler_words { + words .iter() .filter_map(|word| Regex::new(&format!(r"(?i)\b{}\b[,.]?", regex::escape(word))).ok()) - .collect(), - None => get_filler_words_for_language(lang) + .collect() + } else if let Some(lang) = lang { + get_filler_words_for_language(lang) .iter() .map(|word| Regex::new(&format!(r"(?i)\b{}\b[,.]?", regex::escape(word))).unwrap()) - .collect(), + .collect() + } else { + Vec::new() }; // Remove filler words @@ -710,7 +715,7 @@ mod tests { #[test] fn test_filter_filler_words() { let text = "So uhm I was thinking uh about this"; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "So I was thinking about this"); } @@ -732,84 +737,84 @@ mod tests { #[test] fn test_filter_filler_words_case_insensitive() { let text = "UHM this is UH a test"; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "this is a test"); } #[test] fn test_filter_filler_words_with_punctuation() { let text = "Well, uhm, I think, uh. that's right"; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "Well, I think, that's right"); } #[test] fn test_filter_cleans_whitespace() { let text = "Hello world test"; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "Hello world test"); } #[test] fn test_filter_trims() { let text = " Hello world "; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "Hello world"); } #[test] fn test_filter_combined() { let text = " Uhm, so I was, uh, thinking about this "; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "so I was, thinking about this"); } #[test] fn test_filter_preserves_valid_text() { let text = "This is a completely normal sentence."; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "This is a completely normal sentence."); } #[test] fn test_filter_stutter_collapse() { let text = "w wh wh wh wh wh wh wh wh wh why"; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "w wh why"); } #[test] fn test_filter_stutter_short_words() { let text = "I I I I think so so so so"; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "I think so"); } #[test] fn test_filter_stutter_longer_words() { let text = "Check data doc doc doc doc documentation."; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "Check data doc documentation."); } #[test] fn test_filter_stutter_mixed_case() { let text = "No NO no NO no"; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "No"); } #[test] fn test_filter_stutter_preserves_two_repetitions() { let text = "no no is fine"; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "no no is fine"); } #[test] fn test_filter_english_removes_um() { let text = "um I think um this is good"; - let result = filter_transcription_output(text, "en", &None); + let result = filter_transcription_output(text, Some("en"), &None); assert_eq!(result, "I think this is good"); } @@ -817,7 +822,7 @@ mod tests { fn test_filter_portuguese_preserves_um() { // "um" means "a/an" in Portuguese let text = "um gato bonito"; - let result = filter_transcription_output(text, "pt", &None); + let result = filter_transcription_output(text, Some("pt"), &None); assert_eq!(result, "um gato bonito"); } @@ -825,7 +830,7 @@ mod tests { fn test_filter_spanish_preserves_ha() { // "ha" means "has" in Spanish let text = "ha sido un buen día"; - let result = filter_transcription_output(text, "es", &None); + let result = filter_transcription_output(text, Some("es"), &None); assert_eq!(result, "ha sido un buen día"); } @@ -833,7 +838,7 @@ mod tests { fn test_filter_language_code_with_region() { // "pt-BR" should normalize to "pt" let text = "um gato bonito"; - let result = filter_transcription_output(text, "pt-BR", &None); + let result = filter_transcription_output(text, Some("pt-BR"), &None); assert_eq!(result, "um gato bonito"); } @@ -841,7 +846,7 @@ mod tests { fn test_filter_custom_filler_words_override() { let custom = Some(vec!["okay".to_string(), "right".to_string()]); let text = "okay so I think right this works"; - let result = filter_transcription_output(text, "en", &custom); + let result = filter_transcription_output(text, Some("en"), &custom); assert_eq!(result, "so I think this works"); } @@ -849,7 +854,7 @@ mod tests { fn test_filter_custom_filler_words_empty_disables() { let custom = Some(vec![]); let text = "So uhm I was thinking uh about this"; - let result = filter_transcription_output(text, "en", &custom); + let result = filter_transcription_output(text, Some("en"), &custom); // No filler words removed since custom list is empty assert_eq!(result, "So uhm I was thinking uh about this"); } @@ -857,7 +862,7 @@ mod tests { #[test] fn test_filter_unknown_language_uses_fallback() { let text = "uh I think uhm this works"; - let result = filter_transcription_output(text, "xx", &None); + let result = filter_transcription_output(text, Some("xx"), &None); assert_eq!(result, "I think this works"); } @@ -865,10 +870,25 @@ mod tests { fn test_filter_fallback_does_not_remove_um() { // Fallback (unknown language) should not remove "um" since it's a real word in some languages let text = "um I think this works"; - let result = filter_transcription_output(text, "xx", &None); + let result = filter_transcription_output(text, Some("xx"), &None); assert_eq!(result, "um I think this works"); } + #[test] + fn no_validated_language_skips_default_fillers() { + let result = filter_transcription_output("um this stays", None, &None); + + assert_eq!(result, "um this stays"); + } + + #[test] + fn custom_fillers_apply_without_a_validated_language() { + let custom = Some(vec!["deliberate".to_string()]); + let result = filter_transcription_output("keep deliberate words", None, &custom); + + assert_eq!(result, "keep words"); + } + #[test] fn test_apply_custom_words_ngram_two_words() { let text = "il cui nome è Charge B,"; diff --git a/src-tauri/src/commands/adaptive.rs b/src-tauri/src/commands/adaptive.rs index 3f5189aa..31d15ce6 100644 --- a/src-tauri/src/commands/adaptive.rs +++ b/src-tauri/src/commands/adaptive.rs @@ -23,8 +23,10 @@ fn build_reprocessed_adaptive_entry( .or(entry.adaptive_profile_id.clone()) .unwrap_or(default_profile_id.to_string()); let profile = find_profile_or_default(profiles, &selected_profile_id); + // Reprocessing has no current model-validation result to prove a single locked language, + // so it must conservatively skip language-default filler removal. let final_text = - crate::adaptive::processor::deterministic_process(&entry.transcription_text, profile); + crate::adaptive::processor::deterministic_process(&entry.transcription_text, profile, None); crate::adaptive::processor::validate_output(&entry.transcription_text, &final_text, profile)?; Ok(ReprocessedAdaptiveEntry { @@ -198,10 +200,7 @@ mod tests { assert_eq!(reprocessed.file_name, "verbatim-42.wav"); assert_eq!(reprocessed.raw_text, entry.transcription_text); - assert_eq!( - reprocessed.post_processed_text.as_deref(), - Some("please send the file today") - ); + assert!(reprocessed.post_processed_text.is_none()); assert_eq!(reprocessed.metadata.parent_entry_id, Some(42)); assert_eq!( reprocessed.metadata.profile_id.as_deref(), @@ -279,10 +278,7 @@ mod tests { move |reprocessed| { save_count_for_save.fetch_add(1, Ordering::SeqCst); assert_eq!(reprocessed.file_name, "verbatim-42.wav"); - assert_eq!( - reprocessed.post_processed_text.as_deref(), - Some("please send the file today") - ); + assert!(reprocessed.post_processed_text.is_none()); assert_eq!(reprocessed.metadata.profile_id.as_deref(), Some("email")); assert_eq!(reprocessed.metadata.parent_entry_id, Some(42)); Ok(()) diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 98749930..30087082 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -34,6 +34,11 @@ pub struct ModelStateEvent { pub fallback: Option, } +pub(crate) struct TranscriptionOutput { + pub text: String, + pub effective_language: Option, +} + fn model_load_diagnostic_code(error: &anyhow::Error) -> &'static str { let message = error.to_string().to_ascii_lowercase(); if [ @@ -436,6 +441,10 @@ fn validate_selected_language(selected_language: &str, supported_languages: &[St } } +fn effective_validated_dictation_language(validated_language: &str) -> Option<&str> { + (validated_language != "auto").then_some(validated_language) +} + fn transcription_result_log_message(final_result: &str) -> String { format!( "Transcription result ready ({} chars)", @@ -1186,6 +1195,15 @@ impl TranscriptionManager { audio: Vec, cancellation: CancellationToken, ) -> Result { + self.transcribe_with_cancellation_context(audio, cancellation) + .map(|output| output.text) + } + + pub(crate) fn transcribe_with_cancellation_context( + &self, + audio: Vec, + cancellation: CancellationToken, + ) -> Result { #[cfg(debug_assertions)] if std::env::var("VERBATIM_FORCE_TRANSCRIPTION_FAILURE").is_ok() { return Err(anyhow::anyhow!( @@ -1205,7 +1223,10 @@ impl TranscriptionManager { if audio.is_empty() { debug!("Empty audio vector"); self.maybe_unload_immediately("empty audio"); - return Ok(String::new()); + return Ok(TranscriptionOutput { + text: String::new(), + effective_language: None, + }); } // Check if model is loaded, if not try to load it @@ -1243,6 +1264,8 @@ impl TranscriptionManager { .map(|info| info.supported_languages.clone()) .unwrap_or_default(), ); + let effective_language = + effective_validated_dictation_language(&validated_language).map(str::to_string); if validated_language == "auto" && settings.selected_language != "auto" { warn!( @@ -1349,7 +1372,12 @@ impl TranscriptionManager { .map(|info| matches!(info.engine_type, EngineType::Whisper)) .unwrap_or(false); - let final_result = apply_local_text_transforms(result.text, &settings, is_whisper); + let final_result = apply_local_text_transforms( + result.text, + &settings, + is_whisper, + effective_language.as_deref(), + ); let et = std::time::Instant::now(); let translation_note = if effective_translate_to_english { @@ -1371,7 +1399,10 @@ impl TranscriptionManager { self.maybe_unload_immediately("transcription"); - Ok(final_result) + Ok(TranscriptionOutput { + text: final_result, + effective_language, + }) } } @@ -1379,6 +1410,7 @@ fn apply_local_text_transforms( raw_text: String, settings: &AppSettings, is_whisper: bool, + effective_language: Option<&str>, ) -> String { let corrected_result = if !settings.dictionary_entries.is_empty() { if is_whisper { @@ -1395,7 +1427,7 @@ fn apply_local_text_transforms( let filtered_result = filter_transcription_output( &corrected_result, - &settings.app_language, + effective_language, &settings.custom_filler_words, ); @@ -1887,8 +1919,12 @@ mod tests { updated_at_ms: 1, }]; - let result = - apply_local_text_transforms("please use email signature".to_string(), &settings, false); + let result = apply_local_text_transforms( + "please use email signature".to_string(), + &settings, + false, + None, + ); assert_eq!(result, "please use Regards,\nAbdullah"); } @@ -1910,11 +1946,89 @@ mod tests { needs_review: false, }]; - let result = apply_local_text_transforms("posgres is running".to_string(), &legacy, false); + let result = + apply_local_text_transforms("posgres is running".to_string(), &legacy, false, None); assert_eq!(result, "Postgres is running"); } + #[test] + fn local_text_transforms_use_locked_english_not_ui_language_for_fillers() { + let mut settings = crate::settings::get_default_settings(); + settings.app_language = "ar".to_string(); + settings.selected_language = "en".to_string(); + assert_eq!( + settings.dictation_language_mode, + crate::settings::DictationLanguageMode::Auto, + "legacy desktop language selection does not update the newer mode field" + ); + let validated = validate_selected_language("en", &["en".to_string()]); + let effective = effective_validated_dictation_language(&validated); + + let result = + apply_local_text_transforms("um hello".to_string(), &settings, false, effective); + + assert_eq!(result, "hello"); + } + + #[test] + fn local_text_transforms_preserve_english_looking_fillers_for_locked_portuguese() { + let mut settings = crate::settings::get_default_settings(); + settings.selected_language = "pt".to_string(); + let validated = validate_selected_language("pt", &["pt".to_string()]); + let effective = effective_validated_dictation_language(&validated); + + let result = + apply_local_text_transforms("um gato".to_string(), &settings, false, effective); + + assert_eq!(result, "um gato"); + } + + #[test] + fn local_text_transforms_auto_mode_skips_defaults_but_applies_custom_fillers() { + let mut settings = crate::settings::get_default_settings(); + settings.dictation_language_mode = crate::settings::DictationLanguageMode::Auto; + settings.selected_language = "auto".to_string(); + settings.custom_filler_words = Some(vec!["deliberate".to_string()]); + let validated = validate_selected_language("auto", &["en".to_string()]); + let effective = effective_validated_dictation_language(&validated); + + let result = apply_local_text_transforms( + "um deliberate stays".to_string(), + &settings, + false, + effective, + ); + + assert_eq!(result, "um stays"); + } + + #[test] + fn local_text_transforms_preserve_dictionary_filler_output_in_auto_mode() { + let mut settings = crate::settings::get_default_settings(); + settings.dictation_language_mode = crate::settings::DictationLanguageMode::Auto; + settings.selected_language = "auto".to_string(); + settings.dictionary_entries = vec![crate::settings::DictionaryEntry { + id: "dictionary_1_um".to_string(), + phrase: "um".to_string(), + replacement_of: Some("placeholder".to_string()), + source: crate::settings::DictionaryEntrySource::Manual, + priority: crate::settings::DictionaryEntryPriority::Normal, + created_at_ms: 1, + updated_at_ms: 1, + active: true, + user_confirmed: false, + needs_review: false, + }]; + let validated = validate_selected_language("auto", &["en".to_string()]); + let effective = effective_validated_dictation_language(&validated); + + let result = + apply_local_text_transforms("placeholder".to_string(), &settings, false, effective); + + assert_eq!(result, "um"); + } + #[test] fn transcription_result_log_message_does_not_include_transcript_text() { let transcript = "Private dictated sentence with a customer name"; diff --git a/src-tauri/src/managers/transcription_mock.rs b/src-tauri/src/managers/transcription_mock.rs index 880a8dca..5690d043 100644 --- a/src-tauri/src/managers/transcription_mock.rs +++ b/src-tauri/src/managers/transcription_mock.rs @@ -29,6 +29,11 @@ pub struct ModelStateEvent { pub fallback: Option, } +pub(crate) struct TranscriptionOutput { + pub text: String, + pub effective_language: Option, +} + #[derive(Clone, Debug, Serialize)] pub(crate) struct ModelLoadFallbackDrillCase { pub case: String, @@ -129,9 +134,18 @@ impl TranscriptionManager { pub fn transcribe_with_cancellation( &self, - _audio: Vec, + audio: Vec, cancellation: CancellationToken, ) -> Result { + self.transcribe_with_cancellation_context(audio, cancellation) + .map(|output| output.text) + } + + pub(crate) fn transcribe_with_cancellation_context( + &self, + _audio: Vec, + cancellation: CancellationToken, + ) -> Result { ensure_transcription_not_cancelled(&cancellation)?; Err(anyhow::anyhow!(ENGINE_DISABLED_ERROR))