From 37de9b90616a60c13a1720db54be616fe8f00253 Mon Sep 17 00:00:00 2001 From: Schmurtz Date: Mon, 1 Dec 2025 20:14:34 +0100 Subject: [PATCH 01/15] Add customizable text replacements feature Introduces a new 'replacements' setting for accent-insensitive, punctuation-aware, and capitalization-controlled text substitutions in transcriptions. Adds UI for managing replacements, import/export functionality, backend support, and updates settings/types to support the feature. --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/resources/default_settings.json | 1 + src-tauri/src/lib.rs | 1 + src-tauri/src/managers/transcription.rs | 146 ++++- src-tauri/src/settings.rs | 27 + src-tauri/src/shortcut.rs | 9 + src/bindings.ts | 18 +- src/components/Sidebar.tsx | 9 +- src/components/settings/Replacements.tsx | 616 ++++++++++++++++++++++ src/components/settings/index.ts | 1 + src/components/ui/SettingContainer.tsx | 33 +- src/stores/settingsStore.ts | 3 +- 13 files changed, 851 insertions(+), 15 deletions(-) create mode 100644 src/components/settings/Replacements.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 386e6b133..9f5c784a2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2522,6 +2522,7 @@ dependencies = [ "natural", "once_cell", "rdev", + "regex", "reqwest", "rodio", "rubato", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3f4ba7f88..c876fc865 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -48,6 +48,7 @@ serde_json = "1" rdev = { git = "https://github.com/rustdesk-org/rdev" } cpal = "0.16.0" anyhow = "1.0.95" +regex = "1.10.0" rubato = "0.16.2" hound = "3.5.1" log = "0.4.25" diff --git a/src-tauri/resources/default_settings.json b/src-tauri/resources/default_settings.json index c7ec6b4fb..121c38a89 100644 --- a/src-tauri/resources/default_settings.json +++ b/src-tauri/resources/default_settings.json @@ -8,5 +8,6 @@ } }, "push_to_talk": true, + "replacements": [], "selected_language": "auto" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7958fd940..91f15d01a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -250,6 +250,7 @@ pub fn run() { shortcut::delete_post_process_prompt, shortcut::set_post_process_selected_prompt, shortcut::update_custom_words, + shortcut::update_replacements, shortcut::suspend_binding, shortcut::resume_binding, shortcut::change_mute_while_recording_setting, diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 2418db9db..d2dfb9e5a 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -398,6 +398,150 @@ impl TranscriptionManager { result.text }; + // Apply replacements + let mut replaced_result = corrected_result; + for replacement in &settings.replacements { + // Build accent-insensitive regex pattern + let mut search_pattern = String::from("(?i)"); + for c in replacement.search.chars() { + match c { + 'a' | 'A' | 'à' | 'À' | 'á' | 'Á' | 'â' | 'Â' | 'ã' | 'Ã' | 'ä' | 'Ä' | 'å' | 'Å' => search_pattern.push_str("[aàáâãäå]"), + 'e' | 'E' | 'é' | 'É' | 'è' | 'È' | 'ê' | 'Ê' | 'ë' | 'Ë' => search_pattern.push_str("[eéèêë]"), + 'i' | 'I' | 'ì' | 'Ì' | 'í' | 'Í' | 'î' | 'Î' | 'ï' | 'Ï' => search_pattern.push_str("[iìíîï]"), + 'o' | 'O' | 'ò' | 'Ò' | 'ó' | 'Ó' | 'ô' | 'Ô' | 'õ' | 'Õ' | 'ö' | 'Ö' => search_pattern.push_str("[oòóôõö]"), + 'u' | 'U' | 'ù' | 'Ù' | 'ú' | 'Ú' | 'û' | 'Û' | 'ü' | 'Ü' => search_pattern.push_str("[uùúûü]"), + 'y' | 'Y' | 'ý' | 'Ý' | 'ÿ' | 'Ÿ' => search_pattern.push_str("[yýÿ]"), + 'c' | 'C' | 'ç' | 'Ç' => search_pattern.push_str("[cç]"), + 'n' | 'N' | 'ñ' | 'Ñ' => search_pattern.push_str("[nñ]"), + _ => search_pattern.push_str(®ex::escape(&c.to_string())), + } + } + + let re = match regex::Regex::new(&search_pattern) { + Ok(re) => re, + Err(_) => continue, // Skip invalid regex + }; + + // Handle \n in replacement string + let replace_with = replacement.replace.replace("\\n", "\n"); + + if replacement.remove_surrounding_punctuation || replacement.capitalization_rule != crate::settings::CapitalizationRule::None { + let mut new_text = String::new(); + let mut last_end = 0; + + // Find all matches + let matches: Vec<_> = re.find_iter(&replaced_result).collect(); + + if matches.is_empty() { + continue; + } + + for mat in matches { + let start = mat.start(); + let end = mat.end(); + + // If we've already processed past this match (due to lookahead), skip it + if start < last_end { + continue; + } + + // 1. Handle text BEFORE the match + let prefix = &replaced_result[last_end..start]; + + if replacement.remove_surrounding_punctuation { + // Trim trailing punctuation and whitespace + // Also handles French punctuation and common symbols + let trimmed_prefix = prefix.trim_end_matches(|c: char| + c.is_ascii_punctuation() || + c.is_whitespace() || + ['«', '»', '—', '…', '’', '“', '”'].contains(&c) + ); + new_text.push_str(trimmed_prefix); + + // Add space if needed + if !trimmed_prefix.is_empty() { + // Don't add space if replacement starts with punctuation that shouldn't have one + let no_space_before = replace_with.chars().next().map_or(false, |c| + ['.', ',', ')', ']', '}', '…', '’'].contains(&c) + ); + + if !no_space_before { + new_text.push(' '); + } + } + } else { + new_text.push_str(prefix); + } + + // 2. Append REPLACEMENT + new_text.push_str(&replace_with); + + // 3. Handle text AFTER the match + let mut current_pos = end; + + if replacement.remove_surrounding_punctuation { + // Skip immediate punctuation/whitespace + let remainder = &replaced_result[current_pos..]; + let skipped_len = remainder.chars() + .take_while(|c| + c.is_ascii_punctuation() || + c.is_whitespace() || + ['«', '»', '—', '…', '’', '“', '”'].contains(&c) + ) + .map(|c| c.len_utf8()) + .sum::(); + current_pos += skipped_len; + + // Add space if replacement is a word + if replace_with.chars().last().map_or(false, |c| c.is_alphanumeric()) { + new_text.push(' '); + } + } + + // 4. Handle CAPITALIZATION + let remainder = &replaced_result[current_pos..]; + let mut chars = remainder.char_indices(); + + if let Some((_, c)) = chars.next() { + if c.is_whitespace() { + new_text.push(c); + // Look at next char for capitalization + if let Some((_, c2)) = chars.next() { + let char_to_append = match replacement.capitalization_rule { + crate::settings::CapitalizationRule::ForceUppercase => c2.to_uppercase().to_string(), + crate::settings::CapitalizationRule::ForceLowercase => c2.to_lowercase().to_string(), + _ => c2.to_string(), + }; + new_text.push_str(&char_to_append); + current_pos += c.len_utf8() + c2.len_utf8(); + } else { + current_pos += c.len_utf8(); + } + } else { + let char_to_append = match replacement.capitalization_rule { + crate::settings::CapitalizationRule::ForceUppercase => c.to_uppercase().to_string(), + crate::settings::CapitalizationRule::ForceLowercase => c.to_lowercase().to_string(), + _ => c.to_string(), + }; + new_text.push_str(&char_to_append); + current_pos += c.len_utf8(); + } + } + + last_end = current_pos; + } + + new_text.push_str(&replaced_result[last_end..]); + replaced_result = new_text; + + } else { + // Simple replacement but case-insensitive + // We can use regex replace_all + replaced_result = re.replace_all(&replaced_result, &replace_with).to_string(); + } + } + + let et = std::time::Instant::now(); let translation_note = if settings.translate_to_english { " (translated)" @@ -410,7 +554,7 @@ impl TranscriptionManager { translation_note ); - let final_result = corrected_result.trim().to_string(); + let final_result = replaced_result.trim().to_string(); if final_result.is_empty() { info!("Transcription result is empty"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 1981e4cf0..d71c8de35 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -147,6 +147,14 @@ pub enum RecordingRetentionPeriod { Months3, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)] +#[serde(rename_all = "snake_case")] +pub enum CapitalizationRule { + None, + ForceUppercase, + ForceLowercase, +} + impl Default for ModelUnloadTimeout { fn default() -> Self { ModelUnloadTimeout::Never @@ -169,6 +177,12 @@ impl Default for ClipboardHandling { } } +impl Default for CapitalizationRule { + fn default() -> Self { + CapitalizationRule::None + } +} + impl ModelUnloadTimeout { pub fn to_minutes(self) -> Option { match self { @@ -258,6 +272,8 @@ pub struct AppSettings { #[serde(default)] pub custom_words: Vec, #[serde(default)] + pub replacements: Vec, + #[serde(default)] pub model_unload_timeout: ModelUnloadTimeout, #[serde(default = "default_word_correction_threshold")] pub word_correction_threshold: f64, @@ -287,6 +303,16 @@ pub struct AppSettings { pub mute_while_recording: bool, } +#[derive(Serialize, Deserialize, Debug, Clone, Type)] +pub struct Replacement { + pub search: String, + pub replace: String, + #[serde(default)] + pub remove_surrounding_punctuation: bool, + #[serde(default)] + pub capitalization_rule: CapitalizationRule, +} + fn default_model() -> String { "".to_string() } @@ -469,6 +495,7 @@ pub fn get_default_settings() -> AppSettings { debug_mode: false, log_level: default_log_level(), custom_words: Vec::new(), + replacements: Vec::new(), model_unload_timeout: ModelUnloadTimeout::Never, word_correction_threshold: default_word_correction_threshold(), history_limit: default_history_limit(), diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index fdd5d758b..e38af4c96 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -895,3 +895,12 @@ pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result< Ok(()) } + +#[tauri::command] +#[specta::specta] +pub fn update_replacements(app: AppHandle, replacements: Vec) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.replacements = replacements; + settings::write_settings(&app, settings); + Ok(()) +} diff --git a/src/bindings.ts b/src/bindings.ts index ebde2990a..c3df14eea 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -213,6 +213,14 @@ async updateCustomWords(words: string[]) : Promise> { else return { status: "error", error: e as any }; } }, +async updateReplacements(replacements: Replacement[]) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("update_replacements", { replacements }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, /** * Temporarily unregister a binding while the user is editing it in the UI. * This avoids firing the action while keys are being recorded. @@ -577,10 +585,8 @@ async updateRecordingRetentionPeriod(period: string) : Promise> { try { @@ -602,9 +608,10 @@ async isLaptop() : Promise> { /** user-defined types **/ -export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: string; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean } +export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; replacements?: Replacement[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: string; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean } export type AudioDevice = { index: string; name: string; is_default: boolean } export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null } +export type CapitalizationRule = "none" | "force_uppercase" | "force_lowercase" export type ClipboardHandling = "dont_modify" | "copy_to_clipboard" export type CustomSounds = { start: boolean; stop: boolean } export type EngineType = "Whisper" | "Parakeet" @@ -618,6 +625,7 @@ export type OverlayPosition = "none" | "top" | "bottom" export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" +export type Replacement = { search: string; replace: string; remove_surrounding_punctuation?: boolean; capitalization_rule?: CapitalizationRule } export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string } export type SoundTheme = "marimba" | "pop" | "custom" diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 48065fc7a..101693573 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Cog, FlaskConical, History, Info, Sparkles } from "lucide-react"; +import { Cog, FlaskConical, History, Info, Sparkles, Replace } from "lucide-react"; import HandyTextLogo from "./icons/HandyTextLogo"; import HandyHand from "./icons/HandyHand"; import { useSettings } from "../hooks/useSettings"; @@ -10,6 +10,7 @@ import { DebugSettings, AboutSettings, PostProcessingSettings, + Replacements, } from "./settings"; export type SidebarSection = keyof typeof SECTIONS_CONFIG; @@ -42,6 +43,12 @@ export const SECTIONS_CONFIG = { component: AdvancedSettings, enabled: () => true, }, + replacements: { + label: "Replacements", + icon: Replace, + component: Replacements, + enabled: () => true, + }, postprocessing: { label: "Post Process", icon: Sparkles, diff --git a/src/components/settings/Replacements.tsx b/src/components/settings/Replacements.tsx new file mode 100644 index 000000000..dcd1465f2 --- /dev/null +++ b/src/components/settings/Replacements.tsx @@ -0,0 +1,616 @@ +import React, { useState, useRef } from "react"; +import { useSettings } from "../../hooks/useSettings"; +import { Input } from "../ui/Input"; +import { Button } from "../ui/Button"; +import { SettingsGroup } from "../ui/SettingsGroup"; +import { Replacement, CapitalizationRule } from "@/bindings"; +import { Trash2, ArrowRight, CaseUpper, CaseLower, Scissors, Pencil, GripVertical, Download, Upload } from "lucide-react"; + +const InfoTooltip: React.FC<{ text: string }> = ({ text }) => { + const [showTooltip, setShowTooltip] = useState(false); + const [tooltipPosition, setTooltipPosition] = useState<"top" | "bottom">("top"); + const tooltipTriggerRef = useRef(null); + + const handleTooltipEnter = () => { + if (tooltipTriggerRef.current) { + const rect = tooltipTriggerRef.current.getBoundingClientRect(); + const spaceAbove = rect.top; + if (spaceAbove < 100) { + setTooltipPosition("bottom"); + } else { + setTooltipPosition("top"); + } + } + setShowTooltip(true); + }; + + return ( +
setShowTooltip(false)} + > + + + + {showTooltip && ( +
+

+ {text} +

+
+
+ )} +
+ ); +}; + +const getScrollParent = (node: HTMLElement | null): HTMLElement | null => { + if (!node) return null; + const style = window.getComputedStyle(node); + const overflowY = style.overflowY; + const isScrollable = overflowY !== 'visible' && overflowY !== 'hidden'; + + if (isScrollable && node.scrollHeight > node.clientHeight) { + return node; + } + return getScrollParent(node.parentElement); +}; + +export const Replacements: React.FC = () => { + const { getSetting, updateSetting, isUpdating } = useSettings(); + const [search, setSearch] = useState(""); + const [replace, setReplace] = useState(""); + const [removePunctuation, setRemovePunctuation] = useState(false); + const [capitalization, setCapitalization] = useState("none"); + const [editingIndex, setEditingIndex] = useState(null); + const [filterText, setFilterText] = useState(""); + const [lastImportedRange, setLastImportedRange] = useState<{start: number, count: number} | null>(null); + + // Drag and drop state + const [draggingIndex, setDraggingIndex] = useState(null); + const [dropIndex, setDropIndex] = useState(null); + const listRef = useRef(null); + const fileInputRef = useRef(null); + const dropIndexRef = useRef(null); + const scrollInterval = useRef(null); + const scrollSpeed = useRef(0); + const formRef = useRef(null); + + const replacements = getSetting("replacements") || []; + + const renderText = (text: string) => { + if (!text) return empty; + return text.split('').map((char, i) => + char === ' ' ? · : char + ); + }; + + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && editingIndex !== null) { + resetForm(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [editingIndex]); + + const searchCounts = replacements.reduce((acc, item) => { + const key = item.search.trim().toLowerCase(); + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {} as Record); + + const handleAddOrUpdate = () => { + if (search && replace) { + const newReplacement: Replacement = { + search, + replace, + remove_surrounding_punctuation: removePunctuation, + capitalization_rule: capitalization + }; + + let newReplacements = [...replacements]; + if (editingIndex !== null) { + newReplacements[editingIndex] = newReplacement; + } else { + newReplacements = [...replacements, newReplacement]; + } + + updateSetting("replacements", newReplacements); + setLastImportedRange(null); + resetForm(); + } + }; + + const resetForm = () => { + setSearch(""); + setReplace(""); + setRemovePunctuation(false); + setCapitalization("none"); + setEditingIndex(null); + }; + + const handleEdit = (index: number) => { + const item = replacements[index]; + setSearch(item.search); + setReplace(item.replace); + setRemovePunctuation(item.remove_surrounding_punctuation); + setCapitalization(item.capitalization_rule); + setEditingIndex(index); + formRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + const handleRemove = (index: number) => { + const newReplacements = [...replacements]; + newReplacements.splice(index, 1); + updateSetting("replacements", newReplacements); + + // Adjust range if needed, or just clear it to be safe/simple + if (lastImportedRange) { + if (index < lastImportedRange.start) { + setLastImportedRange({ ...lastImportedRange, start: lastImportedRange.start - 1 }); + } else if (index >= lastImportedRange.start && index < lastImportedRange.start + lastImportedRange.count) { + setLastImportedRange({ ...lastImportedRange, count: lastImportedRange.count - 1 }); + } + } + + if (editingIndex === index) { + resetForm(); + } + }; + + const handleExport = async () => { + const dataStr = JSON.stringify(replacements, null, 2); + + try { + // Try to use the File System Access API if available (modern browsers/webviews) + // @ts-ignore - showSaveFilePicker is not yet in all TS definitions + if (window.showSaveFilePicker) { + // @ts-ignore + const handle = await window.showSaveFilePicker({ + suggestedName: 'handy-replacements.json', + types: [{ + description: 'JSON Files', + accept: {'application/json': ['.json']}, + }], + }); + const writable = await handle.createWritable(); + await writable.write(dataStr); + await writable.close(); + return; + } + } catch (err) { + // User cancelled or API failed, fall back to download + console.log("File System Access API failed or cancelled, falling back to download", err); + } + + // Fallback for older browsers or if user cancelled the picker (though usually we stop there) + // But if the API isn't supported, we do this: + const blob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "handy-replacements.json"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const handleImport = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const content = event.target?.result as string; + const imported = JSON.parse(content); + + if (Array.isArray(imported)) { + // Basic validation: check if items look like replacements + const isValid = imported.every(item => + typeof item === 'object' && + item !== null && + 'search' in item && + 'replace' in item + ); + + if (isValid) { + // Append imported items to existing replacements, allowing duplicates + const start = replacements.length; + const count = imported.length; + const newReplacements = [...replacements, ...imported]; + updateSetting("replacements", newReplacements); + setLastImportedRange({ start, count }); + } else { + console.error("Invalid format: items must have search and replace fields"); + } + } else { + console.error("Invalid format: expected an array"); + } + } catch (error) { + console.error("Failed to parse JSON", error); + } + }; + reader.readAsText(file); + e.target.value = ""; + }; + + const handleDragStart = (e: React.MouseEvent, index: number) => { + e.preventDefault(); + setDraggingIndex(index); + + const scrollContainer = getScrollParent(listRef.current); + + // Start scroll loop + scrollInterval.current = window.setInterval(() => { + if (scrollSpeed.current !== 0 && scrollContainer) { + scrollContainer.scrollBy(0, scrollSpeed.current); + } + }, 16); + + // Define handlers first so they can reference each other if needed (via cleanup) + let handleMouseMove: (e: MouseEvent) => void; + let handleMouseUp: () => void; + let handleKeyDown: (e: KeyboardEvent) => void; + + const cleanup = () => { + if (scrollInterval.current) { + clearInterval(scrollInterval.current); + scrollInterval.current = null; + } + scrollSpeed.current = 0; + + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('keydown', handleKeyDown); + }; + + handleMouseMove = (moveEvent: MouseEvent) => { + if (!listRef.current) return; + + // Handle scrolling + const SCROLL_ZONE = 100; + const MAX_SPEED = 20; + + let topZone = SCROLL_ZONE; + let bottomZone = window.innerHeight - SCROLL_ZONE; + + if (scrollContainer) { + const rect = scrollContainer.getBoundingClientRect(); + topZone = rect.top + SCROLL_ZONE; + bottomZone = rect.bottom - SCROLL_ZONE; + } + + if (moveEvent.clientY < topZone) { + const intensity = (topZone - moveEvent.clientY) / SCROLL_ZONE; + scrollSpeed.current = -Math.max(2, Math.round(MAX_SPEED * intensity)); + } else if (moveEvent.clientY > bottomZone) { + const intensity = (moveEvent.clientY - bottomZone) / SCROLL_ZONE; + scrollSpeed.current = Math.max(2, Math.round(MAX_SPEED * intensity)); + } else { + scrollSpeed.current = 0; + } + + const items = Array.from(listRef.current.children).filter(child => !child.classList.contains('drag-indicator')) as HTMLElement[]; + let newDropIndex = items.length; + + for (let i = 0; i < items.length; i++) { + const rect = items[i].getBoundingClientRect(); + const middleY = rect.top + rect.height / 2; + + if (moveEvent.clientY < middleY) { + newDropIndex = i; + break; + } + } + + if (newDropIndex !== dropIndexRef.current) { + dropIndexRef.current = newDropIndex; + setDropIndex(newDropIndex); + } + }; + + handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + cleanup(); + setDraggingIndex(null); + setDropIndex(null); + dropIndexRef.current = null; + } + }; + + handleMouseUp = () => { + cleanup(); + + const finalDropIndex = dropIndexRef.current; + + if (finalDropIndex !== null && finalDropIndex !== index && finalDropIndex !== index + 1) { + const newReplacements = [...replacements]; + const [movedItem] = newReplacements.splice(index, 1); + + let targetIndex = finalDropIndex; + if (targetIndex > index) { + targetIndex -= 1; + } + + newReplacements.splice(targetIndex, 0, movedItem); + updateSetting("replacements", newReplacements); + + // Adjust editing index if needed + if (editingIndex === index) { + setEditingIndex(targetIndex); + } else if (editingIndex !== null) { + // If we moved an item from before editingIndex to after, decrement editingIndex + if (index < editingIndex && targetIndex >= editingIndex) { + setEditingIndex(editingIndex - 1); + } + // If we moved an item from after editingIndex to before, increment editingIndex + else if (index > editingIndex && targetIndex <= editingIndex) { + setEditingIndex(editingIndex + 1); + } + } + } + + setDraggingIndex(null); + setDropIndex(null); + dropIndexRef.current = null; + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('keydown', handleKeyDown); + }; + + return ( +
+ +
+
+ setSearch(e.target.value)} + placeholder="Word to replace" + variant="compact" + /> + + setReplace(e.target.value)} + placeholder="Replacement" + variant="compact" + /> +
+ +
+
+ + + setRemovePunctuation(e.target.checked)} + className="rounded border-mid-gray bg-transparent text-logo-primary focus:ring-logo-primary" + /> +
+ +
+ +
+ Next word: + +
+ + + +
+
+
+ +
+ + {editingIndex !== null && ( + + )} +
+
+ + + {replacements.length > 0 && ( +
+
+ setFilterText(e.target.value)} + placeholder="Filter replacements..." + variant="compact" + className="w-full" + /> +
+
+ {replacements.map((item, index) => { + const isDuplicate = searchCounts[item.search.trim().toLowerCase()] > 1; + const isNewImport = lastImportedRange && index >= lastImportedRange.start && index < (lastImportedRange.start + lastImportedRange.count); + + const matchesFilter = !filterText || + item.search.toLowerCase().includes(filterText.toLowerCase()) || + item.replace.toLowerCase().includes(filterText.toLowerCase()); + + if (!matchesFilter) return null; + + return ( + + {dropIndex === index && (draggingIndex === null || (dropIndex !== draggingIndex && dropIndex !== draggingIndex + 1)) && !filterText && ( +
+ )} +
+
!filterText && handleDragStart(e, index)} + > + +
+
+
+ + {renderText(item.search)} + + + + {renderText(item.replace)} + +
+ {isDuplicate && ( + + Duplicate + + )} + {isNewImport && } +
+
+
+ {item.remove_surrounding_punctuation && ( + + Trim + + )} + {item.capitalization_rule !== "none" && ( + + {item.capitalization_rule === "force_uppercase" ? ( + <> Upper + ) : ( + <> Lower + )} + + )} +
+
+
+ + +
+
+ + )})} + {dropIndex === replacements.length && (draggingIndex === null || (dropIndex !== draggingIndex && dropIndex !== draggingIndex + 1)) && !filterText && ( +
+ )} +
+
+ )} + +
+ +
+ + +
+
+
+ ); +}; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index b01bcd202..ef38c709f 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -27,3 +27,4 @@ export { HistoryLimit } from "./HistoryLimit"; export { RecordingRetentionPeriodSelector } from "./RecordingRetentionPeriod"; export { AutostartToggle } from "./AutostartToggle"; export { UpdateChecksToggle } from "./UpdateChecksToggle"; +export { Replacements } from "./Replacements"; diff --git a/src/components/ui/SettingContainer.tsx b/src/components/ui/SettingContainer.tsx index ce24b4687..322f5ec7b 100644 --- a/src/components/ui/SettingContainer.tsx +++ b/src/components/ui/SettingContainer.tsx @@ -22,6 +22,7 @@ export const SettingContainer: React.FC = ({ tooltipPosition = "top", }) => { const [showTooltip, setShowTooltip] = useState(false); + const [calculatedPosition, setCalculatedPosition] = useState<"top" | "bottom">(tooltipPosition); const tooltipRef = useRef(null); // Handle click outside to close tooltip @@ -42,8 +43,26 @@ export const SettingContainer: React.FC = ({ } }, [showTooltip]); + const handleMouseEnter = () => { + if (tooltipRef.current) { + const rect = tooltipRef.current.getBoundingClientRect(); + // If we are too close to the top (e.g. < 150px), force bottom + // Otherwise respect the prop (default top) + if (rect.top < 150) { + setCalculatedPosition("bottom"); + } else { + setCalculatedPosition(tooltipPosition); + } + } + setShowTooltip(true); + }; + const toggleTooltip = () => { - setShowTooltip(!showTooltip); + if (!showTooltip) { + handleMouseEnter(); + } else { + setShowTooltip(false); + } }; const containerClasses = grouped @@ -63,7 +82,7 @@ export const SettingContainer: React.FC = ({
setShowTooltip(true)} + onMouseEnter={handleMouseEnter} onMouseLeave={() => setShowTooltip(false)} onClick={toggleTooltip} > @@ -90,11 +109,11 @@ export const SettingContainer: React.FC = ({ /> {showTooltip && ( -
+

{description}

-
+
)}
@@ -137,7 +156,7 @@ export const SettingContainer: React.FC = ({
setShowTooltip(true)} + onMouseEnter={handleMouseEnter} onMouseLeave={() => setShowTooltip(false)} onClick={toggleTooltip} > @@ -165,13 +184,13 @@ export const SettingContainer: React.FC = ({ {showTooltip && (

{description}

)} diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index ec28d8e6b..1eb4a0c0b 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; -import type { AppSettings as Settings, AudioDevice } from "@/bindings"; +import type { AppSettings as Settings, AudioDevice, Replacement } from "@/bindings"; import { commands } from "@/bindings"; interface SettingsStore { @@ -109,6 +109,7 @@ const settingUpdaters: { commands.changeOverlayPositionSetting(value as string), debug_mode: (value) => commands.changeDebugModeSetting(value as boolean), custom_words: (value) => commands.updateCustomWords(value as string[]), + replacements: (value) => commands.updateReplacements(value as Replacement[]), word_correction_threshold: (value) => commands.changeWordCorrectionThresholdSetting(value as number), paste_method: (value) => commands.changePasteMethodSetting(value as string), From 9899a5563b96e8d506e36431cfa54ce2e2f8075b Mon Sep 17 00:00:00 2001 From: Schmurtz Date: Mon, 1 Dec 2025 20:38:52 +0100 Subject: [PATCH 02/15] Add optional regex search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (?i)ouvr(ir|ez|e) la parenthèse -------------------------------- This will match: "ouvrez la parenthèse" "Ouvre la parenthèse" "OUVRIR LA PARENTHÈSE" --- src-tauri/src/managers/transcription.rs | 33 ++++++++++++---------- src-tauri/src/settings.rs | 2 ++ src/bindings.ts | 2 +- src/components/settings/Replacements.tsx | 35 ++++++++++++++++++++---- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index d2dfb9e5a..2b39943cc 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -401,21 +401,26 @@ impl TranscriptionManager { // Apply replacements let mut replaced_result = corrected_result; for replacement in &settings.replacements { - // Build accent-insensitive regex pattern - let mut search_pattern = String::from("(?i)"); - for c in replacement.search.chars() { - match c { - 'a' | 'A' | 'à' | 'À' | 'á' | 'Á' | 'â' | 'Â' | 'ã' | 'Ã' | 'ä' | 'Ä' | 'å' | 'Å' => search_pattern.push_str("[aàáâãäå]"), - 'e' | 'E' | 'é' | 'É' | 'è' | 'È' | 'ê' | 'Ê' | 'ë' | 'Ë' => search_pattern.push_str("[eéèêë]"), - 'i' | 'I' | 'ì' | 'Ì' | 'í' | 'Í' | 'î' | 'Î' | 'ï' | 'Ï' => search_pattern.push_str("[iìíîï]"), - 'o' | 'O' | 'ò' | 'Ò' | 'ó' | 'Ó' | 'ô' | 'Ô' | 'õ' | 'Õ' | 'ö' | 'Ö' => search_pattern.push_str("[oòóôõö]"), - 'u' | 'U' | 'ù' | 'Ù' | 'ú' | 'Ú' | 'û' | 'Û' | 'ü' | 'Ü' => search_pattern.push_str("[uùúûü]"), - 'y' | 'Y' | 'ý' | 'Ý' | 'ÿ' | 'Ÿ' => search_pattern.push_str("[yýÿ]"), - 'c' | 'C' | 'ç' | 'Ç' => search_pattern.push_str("[cç]"), - 'n' | 'N' | 'ñ' | 'Ñ' => search_pattern.push_str("[nñ]"), - _ => search_pattern.push_str(®ex::escape(&c.to_string())), + let search_pattern = if replacement.is_regex { + replacement.search.clone() + } else { + // Build accent-insensitive regex pattern + let mut pattern = String::from("(?i)"); + for c in replacement.search.chars() { + match c { + 'a' | 'A' | 'à' | 'À' | 'á' | 'Á' | 'â' | 'Â' | 'ã' | 'Ã' | 'ä' | 'Ä' | 'å' | 'Å' => pattern.push_str("[aàáâãäå]"), + 'e' | 'E' | 'é' | 'É' | 'è' | 'È' | 'ê' | 'Ê' | 'ë' | 'Ë' => pattern.push_str("[eéèêë]"), + 'i' | 'I' | 'ì' | 'Ì' | 'í' | 'Í' | 'î' | 'Î' | 'ï' | 'Ï' => pattern.push_str("[iìíîï]"), + 'o' | 'O' | 'ò' | 'Ò' | 'ó' | 'Ó' | 'ô' | 'Ô' | 'õ' | 'Õ' | 'ö' | 'Ö' => pattern.push_str("[oòóôõö]"), + 'u' | 'U' | 'ù' | 'Ù' | 'ú' | 'Ú' | 'û' | 'Û' | 'ü' | 'Ü' => pattern.push_str("[uùúûü]"), + 'y' | 'Y' | 'ý' | 'Ý' | 'ÿ' | 'Ÿ' => pattern.push_str("[yýÿ]"), + 'c' | 'C' | 'ç' | 'Ç' => pattern.push_str("[cç]"), + 'n' | 'N' | 'ñ' | 'Ñ' => pattern.push_str("[nñ]"), + _ => pattern.push_str(®ex::escape(&c.to_string())), + } } - } + pattern + }; let re = match regex::Regex::new(&search_pattern) { Ok(re) => re, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index d71c8de35..9635d9fa6 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -308,6 +308,8 @@ pub struct Replacement { pub search: String, pub replace: String, #[serde(default)] + pub is_regex: bool, + #[serde(default)] pub remove_surrounding_punctuation: bool, #[serde(default)] pub capitalization_rule: CapitalizationRule, diff --git a/src/bindings.ts b/src/bindings.ts index c3df14eea..d9e88ec86 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -625,7 +625,7 @@ export type OverlayPosition = "none" | "top" | "bottom" export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" -export type Replacement = { search: string; replace: string; remove_surrounding_punctuation?: boolean; capitalization_rule?: CapitalizationRule } +export type Replacement = { search: string; replace: string; is_regex?: boolean; remove_surrounding_punctuation?: boolean; capitalization_rule?: CapitalizationRule } export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string } export type SoundTheme = "marimba" | "pop" | "custom" diff --git a/src/components/settings/Replacements.tsx b/src/components/settings/Replacements.tsx index dcd1465f2..8e8489f36 100644 --- a/src/components/settings/Replacements.tsx +++ b/src/components/settings/Replacements.tsx @@ -4,7 +4,7 @@ import { Input } from "../ui/Input"; import { Button } from "../ui/Button"; import { SettingsGroup } from "../ui/SettingsGroup"; import { Replacement, CapitalizationRule } from "@/bindings"; -import { Trash2, ArrowRight, CaseUpper, CaseLower, Scissors, Pencil, GripVertical, Download, Upload } from "lucide-react"; +import { Trash2, ArrowRight, CaseUpper, CaseLower, Scissors, Pencil, GripVertical, Download, Upload, Regex } from "lucide-react"; const InfoTooltip: React.FC<{ text: string }> = ({ text }) => { const [showTooltip, setShowTooltip] = useState(false); @@ -73,6 +73,7 @@ export const Replacements: React.FC = () => { const { getSetting, updateSetting, isUpdating } = useSettings(); const [search, setSearch] = useState(""); const [replace, setReplace] = useState(""); + const [isRegex, setIsRegex] = useState(false); const [removePunctuation, setRemovePunctuation] = useState(false); const [capitalization, setCapitalization] = useState("none"); const [editingIndex, setEditingIndex] = useState(null); @@ -120,6 +121,7 @@ export const Replacements: React.FC = () => { const newReplacement: Replacement = { search, replace, + is_regex: isRegex, remove_surrounding_punctuation: removePunctuation, capitalization_rule: capitalization }; @@ -140,6 +142,7 @@ export const Replacements: React.FC = () => { const resetForm = () => { setSearch(""); setReplace(""); + setIsRegex(false); setRemovePunctuation(false); setCapitalization("none"); setEditingIndex(null); @@ -149,8 +152,9 @@ export const Replacements: React.FC = () => { const item = replacements[index]; setSearch(item.search); setReplace(item.replace); - setRemovePunctuation(item.remove_surrounding_punctuation); - setCapitalization(item.capitalization_rule); + setIsRegex(item.is_regex || false); + setRemovePunctuation(item.remove_surrounding_punctuation || false); + setCapitalization(item.capitalization_rule || "none"); setEditingIndex(index); formRef.current?.scrollIntoView({ behavior: 'smooth' }); }; @@ -381,7 +385,7 @@ export const Replacements: React.FC = () => { return (
-
+
{
+
+ + + setIsRegex(e.target.checked)} + className="rounded border-mid-gray bg-transparent text-logo-primary focus:ring-logo-primary" + /> +
+ +
+