diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8bfcbb4e4..a3d8e769f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2420,6 +2420,7 @@ dependencies = [ "natural", "once_cell", "rdev", + "regex", "reqwest", "rodio", "rubato", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ef5df830..2a3d41833 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 e39c792b6..0edd22b28 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -257,6 +257,8 @@ pub fn run() { shortcut::delete_post_process_prompt, shortcut::set_post_process_selected_prompt, shortcut::update_custom_words, + shortcut::update_replacements, + shortcut::update_replacements_enabled, 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..3a281e9c4 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -1,4 +1,5 @@ use crate::audio_toolkit::apply_custom_words; +use chrono::Local; use crate::managers::model::{EngineType, ModelManager}; use crate::settings::{get_settings, ModelUnloadTimeout}; use anyhow::Result; @@ -8,6 +9,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::{Duration, SystemTime}; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; use tauri::{AppHandle, Emitter}; use transcribe_rs::{ engines::{ @@ -19,6 +22,139 @@ use transcribe_rs::{ TranscriptionEngine, }; +enum MagicTransformation { + Lowercase, + Uppercase, + NoSpace, + Capitalize, + Run(String), +} + +fn parse_magic_transformations(s: &str) -> (Vec, String) { + let mut transformations = Vec::new(); + let mut clean_s = s.to_string(); + + // Handle dynamic content tags + if clean_s.contains("[date]") { + let now = Local::now(); + clean_s = clean_s.replace("[date]", &now.format("%Y-%m-%d").to_string()); + } + if clean_s.contains("[time]") { + let now = Local::now(); + clean_s = clean_s.replace("[time]", &now.format("%H:%M").to_string()); + } + + // Regex to find [run]"command" or [run]'command' + if let Ok(re_run) = regex::Regex::new(r#"\[run\]["'](.*?)["']"#) { + let mut tags_to_remove = Vec::new(); + for cap in re_run.captures_iter(&clean_s.clone()) { + transformations.push(MagicTransformation::Run(cap[1].to_string())); + tags_to_remove.push(cap[0].to_string()); + } + for tag in tags_to_remove { + clean_s = clean_s.replace(&tag, ""); + } + } + + // Regex to find tags like [lowercase] + if let Ok(re) = regex::Regex::new(r"\[([a-zA-Z]+)\]") { + let mut tags_to_remove = Vec::new(); + for cap in re.captures_iter(&clean_s.clone()) { + match &cap[1] { + "lowercase" | "lowcase" => { + transformations.push(MagicTransformation::Lowercase); + tags_to_remove.push(cap[0].to_string()); + }, + "uppercase" => { + transformations.push(MagicTransformation::Uppercase); + tags_to_remove.push(cap[0].to_string()); + }, + "nospace" => { + transformations.push(MagicTransformation::NoSpace); + tags_to_remove.push(cap[0].to_string()); + }, + "capitalize" => { + transformations.push(MagicTransformation::Capitalize); + tags_to_remove.push(cap[0].to_string()); + }, + _ => {} + } + } + for tag in tags_to_remove { + clean_s = clean_s.replace(&tag, ""); + } + } + (transformations, clean_s) +} + +fn apply_transformations(text: &mut String, transformations: &[MagicTransformation]) { + let mut should_clear = false; + for t in transformations { + match t { + MagicTransformation::Lowercase => *text = text.to_lowercase(), + MagicTransformation::Uppercase => *text = text.to_uppercase(), + MagicTransformation::NoSpace => *text = text.replace(" ", ""), + MagicTransformation::Capitalize => { + let mut result = String::new(); + let mut capitalize_next = true; + for c in text.chars() { + if c.is_whitespace() || c.is_ascii_punctuation() { + capitalize_next = true; + result.push(c); + } else if capitalize_next { + result.push(c.to_uppercase().next().unwrap_or(c)); + capitalize_next = false; + } else { + result.push(c); + } + } + *text = result; + } + MagicTransformation::Run(cmd_template) => { + let text_nospace = text.replace(" ", ""); + + let text_nopunctuation: String = text.chars() + .filter(|c| !c.is_ascii_punctuation() && !['«', '»', '—', '…', '’', '“', '”'].contains(c)) + .collect(); + + let text_nospace_nopunctuation: String = text.chars() + .filter(|c| *c != ' ' && !c.is_ascii_punctuation() && !['«', '»', '—', '…', '’', '“', '”'].contains(c)) + .collect(); + + let cmd_str = cmd_template + .replace("{text}", text) + .replace("{text_nospace}", &text_nospace) + .replace("{text_nopunctuation}", &text_nopunctuation) + .replace("{text_nospace_nopunctuation}", &text_nospace_nopunctuation); + + info!("Executing magic run command: {}", cmd_str); + + #[cfg(target_os = "windows")] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + std::process::Command::new("cmd") + .args(["/C", &cmd_str]) + // .creation_flags(CREATE_NO_WINDOW) + .spawn() + .ok(); + } + #[cfg(not(target_os = "windows"))] + { + std::process::Command::new("sh") + .arg("-c") + .arg(&cmd_str) + .spawn() + .ok(); + } + should_clear = true; + } + } + } + if should_clear { + *text = String::new(); + } +} + #[derive(Clone, Debug, Serialize)] pub struct ModelStateEvent { pub event_type: String, @@ -398,6 +534,249 @@ impl TranscriptionManager { result.text }; + // Apply replacements + let mut replaced_result = corrected_result.trim().to_string(); + let mut global_transformations = Vec::new(); + + if settings.replacements_enabled { + for replacement in &settings.replacements { + if !replacement.enabled { + continue; + } + + 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, + Err(_) => continue, // Skip invalid regex + }; + + // Check for magic tags + let (magic_transformations, clean_replace_with) = parse_magic_transformations(&replacement.replace); + let has_magic = !magic_transformations.is_empty(); + + // Handle \n in replacement string + let replace_with = clean_replace_with.replace("\\n", "\n"); + + if replacement.trim_punctuation_before + || replacement.trim_punctuation_after + || replacement.trim_spaces_before + || replacement.trim_spaces_after + || replacement.capitalization_rule != crate::settings::CapitalizationRule::None + || has_magic + { + 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; + } + + // If we have matches and magic transformations, add them to global list + if has_magic { + global_transformations.extend(magic_transformations); + } + + 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]; + let mut processed_prefix = prefix.to_string(); + + if replacement.trim_punctuation_before { + let mut chars: Vec = processed_prefix.chars().collect(); + let mut last_non_space = chars.len(); + // Skip trailing spaces (only horizontal) + while last_non_space > 0 { + let c = chars[last_non_space - 1]; + if c == ' ' || c == '\t' { + last_non_space -= 1; + } else { + break; + } + } + + // Check for punctuation + let mut punctuation_end = last_non_space; + while punctuation_end > 0 { + let c = chars[punctuation_end - 1]; + // Explicitly stop at newlines + if c == '\n' || c == '\r' { + break; + } + if c.is_ascii_punctuation() || ['«', '»', '—', '…', '’', '“', '”'].contains(&c) { + punctuation_end -= 1; + } else { + break; + } + } + + if punctuation_end < last_non_space { + // Found punctuation. Remove it. + // Keep spaces (from last_non_space to end) + let spaces: String = chars[last_non_space..].iter().collect(); + chars.truncate(punctuation_end); + chars.extend(spaces.chars()); + processed_prefix = chars.into_iter().collect(); + } + } + + if replacement.trim_spaces_before { + // Trim trailing horizontal whitespace + processed_prefix = processed_prefix.trim_end_matches(|c| c == ' ' || c == '\t').to_string(); + } + + new_text.push_str(&processed_prefix); + + // 2. Append REPLACEMENT + new_text.push_str(&replace_with); + + // 3. Handle text AFTER the match + let mut current_pos = end; + + // Handle trim_punctuation_after with lookahead + if replacement.trim_punctuation_after { + let remainder = &replaced_result[current_pos..]; + let mut spaces_len = 0; + let mut chars = remainder.chars(); + + // Check for spaces (only horizontal, stop at newlines) + while let Some(c) = chars.next() { + if c == ' ' || c == '\t' { + spaces_len += c.len_utf8(); + } else { + break; + } + } + + // Check for punctuation (but NOT newlines) + let mut punct_len = 0; + let mut punct_iter = remainder[spaces_len..].chars(); + while let Some(c) = punct_iter.next() { + // Skip if it's a newline/carriage return + if c == '\n' || c == '\r' { + break; + } + if c.is_ascii_punctuation() || ['«', '»', '—', '…', '\'', '"', '"'].contains(&c) { + punct_len += c.len_utf8(); + } else { + break; + } + } + + if punct_len > 0 { + // Found punctuation to trim + // If we are NOT trimming spaces, we must preserve the spaces we skipped + if !replacement.trim_spaces_after { + new_text.push_str(&remainder[..spaces_len]); + } + // If we ARE trimming spaces, we effectively drop them by advancing current_pos + + // Advance past spaces and punctuation + current_pos += spaces_len + punct_len; + } + } + + // Handle trim_spaces_after + if replacement.trim_spaces_after { + let remainder = &replaced_result[current_pos..]; + let mut spaces_len = 0; + for c in remainder.chars() { + if c == ' ' || c == '\t' { + spaces_len += c.len_utf8(); + } else { + break; + } + } + current_pos += spaces_len; + } + + // 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 + + // If we have magic tags, we need to check if a match occurs to trigger them + if has_magic { + if re.is_match(&replaced_result) { + global_transformations.extend(magic_transformations); + replaced_result = re.replace_all(&replaced_result, &replace_with).to_string(); + } + } else { + replaced_result = re.replace_all(&replaced_result, &replace_with).to_string(); + } + } + } + } // End of if settings.replacements_enabled + + // Apply global transformations + apply_transformations(&mut replaced_result, &global_transformations); + + let et = std::time::Instant::now(); let translation_note = if settings.translate_to_english { " (translated)" @@ -410,7 +789,7 @@ impl TranscriptionManager { translation_note ); - let final_result = corrected_result.trim().to_string(); + let final_result = replaced_result; 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 cc98d9891..785d38f23 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -151,6 +151,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 @@ -173,6 +181,12 @@ impl Default for ClipboardHandling { } } +impl Default for CapitalizationRule { + fn default() -> Self { + CapitalizationRule::None + } +} + impl ModelUnloadTimeout { pub fn to_minutes(self) -> Option { match self { @@ -262,6 +276,10 @@ pub struct AppSettings { #[serde(default)] pub custom_words: Vec, #[serde(default)] + pub replacements: Vec, + #[serde(default = "default_true")] + pub replacements_enabled: bool, + #[serde(default)] pub model_unload_timeout: ModelUnloadTimeout, #[serde(default = "default_word_correction_threshold")] pub word_correction_threshold: f64, @@ -295,6 +313,30 @@ pub struct AppSettings { pub app_language: String, } +#[derive(Serialize, Deserialize, Debug, Clone, Type)] +pub struct Replacement { + pub search: String, + pub replace: String, + #[serde(default)] + pub is_regex: bool, + #[serde(default)] + pub trim_punctuation_before: bool, + #[serde(default)] + pub trim_punctuation_after: bool, + #[serde(default)] + pub trim_spaces_before: bool, + #[serde(default)] + pub trim_spaces_after: bool, + #[serde(default)] + pub capitalization_rule: CapitalizationRule, + #[serde(default = "default_true")] + pub enabled: bool, +} + +fn default_true() -> bool { + true +} + fn default_model() -> String { "".to_string() } @@ -545,6 +587,8 @@ pub fn get_default_settings() -> AppSettings { debug_mode: false, log_level: default_log_level(), custom_words: Vec::new(), + replacements: Vec::new(), + replacements_enabled: true, 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 fa855baf8..3e914e073 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -929,3 +929,21 @@ pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result< Ok(()) } + +#[tauri::command] +#[specta::specta] +pub fn update_replacements_enabled(app: AppHandle, enabled: bool) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.replacements_enabled = enabled; + settings::write_settings(&app, settings); + 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 7e271a462..8dcddf07b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -213,6 +213,22 @@ 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 }; +} +}, +async updateReplacementsEnabled(enabled: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("update_replacements_enabled", { enabled }) }; +} 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. @@ -596,10 +612,8 @@ async updateRecordingRetentionPeriod(period: string) : Promise> { try { @@ -621,9 +635,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?: number; 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; append_trailing_space?: boolean; app_language?: string } +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[]; replacements_enabled?: boolean; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; 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; append_trailing_space?: boolean; app_language?: string } 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" @@ -637,6 +652,7 @@ export type OverlayPosition = "none" | "top" | "bottom" export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" 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; is_regex?: boolean; trim_punctuation_before?: boolean; trim_punctuation_after?: boolean; trim_spaces_before?: boolean; trim_spaces_after?: boolean; capitalization_rule?: CapitalizationRule; enabled?: boolean } 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 2d42cb6b7..4b52ab894 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; -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"; @@ -11,6 +11,7 @@ import { DebugSettings, AboutSettings, PostProcessingSettings, + Replacements, } from "./settings"; export type SidebarSection = keyof typeof SECTIONS_CONFIG; @@ -43,6 +44,12 @@ export const SECTIONS_CONFIG = { component: AdvancedSettings, enabled: () => true, }, + replacements: { + labelKey: "sidebar.replacements", + icon: Replace, + component: Replacements, + enabled: () => true, + }, postprocessing: { labelKey: "sidebar.postProcessing", icon: Sparkles, diff --git a/src/components/settings/Replacements.tsx b/src/components/settings/Replacements.tsx new file mode 100644 index 000000000..30801c347 --- /dev/null +++ b/src/components/settings/Replacements.tsx @@ -0,0 +1,959 @@ +import React, { useState, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { useSettings } from "../../hooks/useSettings"; +import { Input } from "../ui/Input"; +import { Button } from "../ui/Button"; +import { SettingsGroup } from "../ui/SettingsGroup"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; +import { Replacement, CapitalizationRule } from "@/bindings"; +import { Trash2, ArrowRight, CaseUpper, CaseLower, Scissors, Pencil, GripVertical, Download, Upload, Regex, Wand2, Space, X, Copy, Plus, Power, PowerOff, ChevronRight, ChevronDown, ChevronUp } from "lucide-react"; + +const MAGIC_TAGS: Record = { + '[lowercase]': 'Converts the entire text to lowercase', + '[uppercase]': 'Converts the entire text to uppercase', + '[capitalize]': 'Capitalizes the first letter of each word', + '[nospace]': 'Removes all spaces from the text', + '[date]': 'Inserts current date (YYYY-MM-DD)', + '[time]': 'Inserts current time (HH:MM)', + '[run]': 'Run a command. Usage: [run]"command {text}"', +}; + +const getMagicInfo = (text: string) => { + const tags = Object.keys(MAGIC_TAGS); + const foundTags = tags.filter(tag => text.includes(tag)); + + if (foundTags.length > 0) { + const description = foundTags.map(tag => `${tag}: ${MAGIC_TAGS[tag]}`).join('\n'); + return { isMagic: true, description }; + } + + return { isMagic: false, description: text }; +}; + +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 { t } = useTranslation(); + const { getSetting, updateSetting, isUpdating } = useSettings(); + const [search, setSearch] = useState(""); + const [replace, setReplace] = useState(""); + const [isRegex, setIsRegex] = useState(false); + const [trimPunctuationBefore, setTrimPunctuationBefore] = useState(false); + const [trimPunctuationAfter, setTrimPunctuationAfter] = useState(false); + const [trimSpacesBefore, setTrimSpacesBefore] = useState(false); + const [trimSpacesAfter, setTrimSpacesAfter] = useState(false); + const [capitalization, setCapitalization] = useState("none"); + const [editingIndex, setEditingIndex] = useState(null); + const [isAdding, setIsAdding] = useState(false); + const [filterText, setFilterText] = useState(""); + const [lastImportedRange, setLastImportedRange] = useState<{start: number, count: number} | null>(null); + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + + // Autocomplete state + const [showSuggestions, setShowSuggestions] = useState(false); + const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0); + const [suggestionFilter, setSuggestionFilter] = useState(""); + + // 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 searchInputRef = useRef(null); + + const replacements = getSetting("replacements") || []; + const replacementsEnabled = getSetting("replacements_enabled") ?? true; + + const renderText = (text: string) => { + if (!text) return {t('common.noOptionsFound') /* fallback for empty */}; + return text; + }; + + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && (editingIndex !== null || isAdding)) { + resetForm(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [editingIndex, isAdding]); + + const searchCounts = replacements.reduce((acc, item) => { + const key = item.search.trim().toLowerCase(); + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {} as Record); + + const handleReplaceChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setReplace(value); + + // Check for magic tag trigger + const lastOpenBracket = value.lastIndexOf('['); + const lastCloseBracket = value.lastIndexOf(']'); + + if (lastOpenBracket !== -1 && lastOpenBracket > lastCloseBracket) { + const filter = value.slice(lastOpenBracket + 1).toLowerCase(); + setSuggestionFilter(filter); + setShowSuggestions(true); + setActiveSuggestionIndex(0); + } else { + setShowSuggestions(false); + } + }; + + const handleReplaceKeyDown = (e: React.KeyboardEvent) => { + if (!showSuggestions) return; + + const suggestions = Object.keys(MAGIC_TAGS).filter(tag => + tag.toLowerCase().includes(`[${suggestionFilter}`) + ); + + if (suggestions.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveSuggestionIndex(prev => (prev + 1) % suggestions.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveSuggestionIndex(prev => (prev - 1 + suggestions.length) % suggestions.length); + } else if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + applySuggestion(suggestions[activeSuggestionIndex]); + } else if (e.key === 'Escape') { + setShowSuggestions(false); + } + }; + + const applySuggestion = (tag: string) => { + const lastOpenBracket = replace.lastIndexOf('['); + if (lastOpenBracket !== -1) { + const newValue = replace.slice(0, lastOpenBracket) + tag; + setReplace(newValue); + setShowSuggestions(false); + } + }; + + const handleAddOrUpdate = () => { + if (search && replace) { + const newReplacement: Replacement = { + search, + replace, + is_regex: isRegex, + trim_punctuation_before: trimPunctuationBefore, + trim_punctuation_after: trimPunctuationAfter, + trim_spaces_before: trimSpacesBefore, + trim_spaces_after: trimSpacesAfter, + 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(""); + setIsRegex(false); + setTrimPunctuationBefore(false); + setTrimPunctuationAfter(false); + setTrimSpacesBefore(false); + setTrimSpacesAfter(false); + setCapitalization("none"); + setEditingIndex(null); + setIsAdding(false); + }; + + const handleStartAdd = () => { + resetForm(); + setIsAdding(true); + setShowAdvancedOptions(false); + setTimeout(() => { + formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + searchInputRef.current?.focus(); + }, 0); + }; + + const handleEdit = (index: number) => { + const item = replacements[index]; + setSearch(item.search); + setReplace(item.replace); + setIsRegex(item.is_regex || false); + setTrimPunctuationBefore(item.trim_punctuation_before || false); + setTrimPunctuationAfter(item.trim_punctuation_after || false); + setTrimSpacesBefore(item.trim_spaces_before || false); + setTrimSpacesAfter(item.trim_spaces_after || false); + setCapitalization(item.capitalization_rule || "none"); + setEditingIndex(index); + setIsAdding(false); + // auto-expand if non-default advanced options are present + const hasAdvanced = !!(item.is_regex || item.trim_punctuation_before || item.trim_punctuation_after || item.trim_spaces_before || item.trim_spaces_after || (item.capitalization_rule && item.capitalization_rule !== 'none')); + setShowAdvancedOptions(hasAdvanced); + // Wait for render then scroll + setTimeout(() => { + formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 0); + }; + + const handleToggleItem = (index: number) => { + const newReplacements = [...replacements]; + newReplacements[index] = { + ...newReplacements[index], + enabled: !(newReplacements[index].enabled ?? true) + }; + updateSetting("replacements", newReplacements); + }; + + const handleDuplicate = (index: number) => { + const itemToDuplicate = replacements[index]; + const newReplacements = [...replacements]; + newReplacements.splice(index + 1, 0, { ...itemToDuplicate }); + updateSetting("replacements", newReplacements); + + 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 !== null && editingIndex > index) { + setEditingIndex(editingIndex + 1); + } + }; + + 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); + }; + + const renderForm = () => ( +
+
+ setSearch(e.target.value)} + placeholder={t('settings.replacements.wordPlaceholder')} + variant="compact" + /> + +
+ + {showSuggestions && ( +
+ {Object.keys(MAGIC_TAGS) + .filter(tag => tag.toLowerCase().includes(`[${suggestionFilter}`)) + .map((tag, index) => ( + + ))} +
+ )} +
+
+
+ +
+ + {showAdvancedOptions && ( +
+ {/* Regex at top as a separate option (inline control) */} +
+
+ + {t('settings.replacements.regex')} + +
+
+ setIsRegex(e.target.checked)} + className="rounded border-mid-gray bg-transparent text-logo-primary focus:ring-logo-primary" + aria-label={t('settings.replacements.regex')} + /> +
+
+ + {/* Trim Punctuation: inline controls right after label */} +
+
+ + {t('settings.replacements.trimPunctuation')} + +
+
+
+ + +
+
+
+ + {/* Trim Spaces: inline controls right after label */} +
+
+ + {t('settings.replacements.trimSpaces')} + +
+
+
+ + +
+
+
+ + {/* Next word: inline controls */} +
+
+ + {t('settings.replacements.nextWord')} + +
+
+
+ + + +
+
+
+
+ )} + +
+ + {(editingIndex !== null || isAdding) && ( + + )} +
+
+ ); + + return ( +
+ + updateSetting("replacements_enabled", checked)} + isUpdating={isUpdating("replacements_enabled")} + grouped + /> +
+ {!isAdding && editingIndex === null && ( + + )} + {isAdding && renderForm()} +
+
+ + {replacements.length > 0 && ( +
+
+ setFilterText(e.target.value)} + placeholder={t('settings.replacements.filterPlaceholder')} + variant="compact" + className="w-full pr-8" + /> + {filterText && ( + + )} +
+
+ {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; + + const magicInfo = getMagicInfo(item.replace); + const isEnabled = item.enabled ?? true; + + return ( + + {dropIndex === index && (draggingIndex === null || (dropIndex !== draggingIndex && dropIndex !== draggingIndex + 1)) && !filterText && ( +
+ )} +
+
!filterText && handleDragStart(e, index)} + > + +
+
+
+ + {renderText(item.search)} + + + + {renderText(item.replace)} + +
+ {!isEnabled && ( + + {t('settings.replacements.disabled')} + + )} + {isDuplicate && ( + + {t('settings.replacements.duplicate')} + + )} + {isNewImport && } +
+
+
+ {magicInfo.isMagic && ( + + {t('settings.replacements.magic')} + + )} + {item.is_regex && ( + + {t('settings.replacements.regex')} + + )} + {(item.trim_punctuation_before || item.trim_punctuation_after) && ( + + {t('settings.replacements.trimsPunctuationShort', { l: item.trim_punctuation_before ? 'L' : '', r: item.trim_punctuation_after ? 'R' : '' })} + + )} + {(item.trim_spaces_before || item.trim_spaces_after) && ( + + {t('settings.replacements.trimsSpacesShort', { l: item.trim_spaces_before ? 'L' : '', r: item.trim_spaces_after ? 'R' : '' })} + + )} + {item.capitalization_rule !== "none" && ( + + {item.capitalization_rule === "force_uppercase" ? ( + <> Upper + ) : ( + <> Lower + )} + + )} +
+
+
+ + + + +
+
+ {editingIndex === index && ( +
+
+
+ {renderForm()} +
+ )} + + )})} + {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/Input.tsx b/src/components/ui/Input.tsx index fef8458e4..ad0154e8e 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -1,15 +1,15 @@ -import React from "react"; +import React, { forwardRef } from "react"; interface InputProps extends React.InputHTMLAttributes { variant?: "default" | "compact"; } -export const Input: React.FC = ({ +export const Input = forwardRef(({ className = "", variant = "default", disabled, ...props -}) => { +}, ref) => { const baseClasses = "px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded text-left transition-all duration-150"; @@ -24,9 +24,12 @@ export const Input: React.FC = ({ return ( ); -}; +}); + +Input.displayName = "Input"; 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/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 8fefed6ee..b70c6b7bc 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -2,6 +2,7 @@ "sidebar": { "general": "Allgemein", "advanced": "Erweitert", + "replacements": "Ersetzungen", "postProcessing": "Nachbearbeitung", "history": "Verlauf", "debug": "Debug", @@ -254,6 +255,42 @@ "createFirst": "Klicke oben auf 'Neuen Prompt erstellen', um deinen ersten Nachbearbeitungs-Prompt zu erstellen." } }, + "replacements": { + "title": "Text-Ersetzungen", + "enableLabel": "Ersetzungen aktivieren", + "enableDescription": "Globale Ein-/Ausschaltung aller Text-Ersetzungen", + "addNew": "Neue Ersetzung hinzufügen", + "wordPlaceholder": "Zu ersetzendes Wort", + "replacementPlaceholder": "Ersetzung", + "trimPunctuation": "Satzzeichen entfernen", + "trimPunctuationTooltip": "Entfernt Satzzeichen um das Wort (z. B. \". wort ,\" → \"wort\")", + "before": "Vor", + "after": "Nach", + "trimSpaces": "Leerzeichen entfernen", + "trimSpacesTooltip": "Entfernt Leerzeichen um das Wort", + "regex": "Regex", + "regexTooltip": "Verwende Rust-Regex-Syntax (ähnlich Perl/Python). Unterstützt (?i) für Groß-/Kleinschreibung ignorieren, \\d für Ziffern usw.", + "nextWord": "Nächstes Wort:", + "nextWordTooltip": "Steuert die Groß-/Kleinschreibung des unmittelbar nach dem Ersetzung folgenden Wortes. 'None' behält die ursprüngliche Groß-/Kleinschreibung bei.", + "none": "Keine", + "updateReplacement": "Ersetzung aktualisieren", + "addReplacement": "Ersetzung hinzufügen", + "advancedOptions": "Erweiterte Optionen", + "filterPlaceholder": "Ersetzungen filtern...", + "magic": "Magisch", + "magicTooltip": "Magische Ersetzung", + "regularExpression": "Regulärer Ausdruck", + "trimsPunctuation": "Entfernt Satzzeichen", + "trimsPunctuationShort": "Satzzeichen entfernen ({{l}}{{r}})", + "trimsSpaces": "Entfernt Leerzeichen", + "trimsSpacesShort": "Leerzeichen ({{l}}{{r}})", + "disabled": "Deaktiviert", + "duplicate": "Duplikat", + "newlyImported": "Kürzlich importiert", + "disable": "Deaktivieren", + "enable": "Aktivieren", + "duplicateTitle": "Duplizieren" + }, "history": { "title": "Verlauf", "openFolder": "Aufnahmeordner öffnen", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index a97bd273a..cf42cba40 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -2,6 +2,7 @@ "sidebar": { "general": "General", "advanced": "Advanced", + "replacements": "Replacements", "postProcessing": "Post Process", "history": "History", "debug": "Debug", @@ -254,6 +255,43 @@ "createFirst": "Click 'Create New Prompt' above to create your first post-processing prompt." } }, + "replacements": { + "title": "Text Replacements", + "enableLabel": "Enable Replacements", + "enableDescription": "Globally enable or disable all text replacements", + "addNew": "Add New Replacement", + "wordPlaceholder": "Word to replace", + "replacementPlaceholder": "Replacement", + "trimPunctuation": "Trim Punctuation", + "trimPunctuationTooltip": "Removes punctuation around the word (e.g. \". word ,\" → \"word\")", + "before": "Before", + "after": "After", + "trimSpaces": "Trim Spaces", + "trimSpacesTooltip": "Removes spaces around the word", + "regex": "Regex", + "regexTooltip": "Use Rust regex syntax (similar to Perl/Python). Supports (?i) for case-insensitivity, \\d for digits, etc.", + "nextWord": "Next word capitalization", + "nextWordTooltip": "Force the first letter of the word immediately following the replacement. 'None' preserves the original casing.", + "none": "None", + "updateReplacement": "Update Replacement", + "addReplacement": "Add Replacement", + "advancedOptions": "Advanced options", + "filterPlaceholder": "Filter replacements...", + "magic": "Magic", + "magicTooltip": "Magic Replacement", + "regularExpression": "Regular Expression", + "trimsPunctuation": "Trims punctuation", + "trimsPunctuationShort": "Trim Punct ({{l}}{{r}})", + "trimsSpaces": "Trims spaces", + "trimsSpacesShort": "Trim Space ({{l}}{{r}})", + "disabled": "Disabled", + "duplicate": "Duplicate", + "newlyImported": "Newly imported", + "magicTooltip": "Magic Replacement", + "disable": "Disable", + "enable": "Enable", + "duplicateTitle": "Duplicate" + }, "history": { "title": "History", "openFolder": "Open Recordings Folder", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index eb8896e64..890e5cca1 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -2,6 +2,7 @@ "sidebar": { "general": "General", "advanced": "Avanzado", + "replacements": "Reemplazos", "postProcessing": "Post Proceso", "history": "Historial", "debug": "Depuración", @@ -254,6 +255,42 @@ "createFirst": "Haz clic en 'Crear Nuevo Prompt' arriba para crear tu primer prompt de post procesamiento." } }, + "replacements": { + "title": "Reemplazos de texto", + "enableLabel": "Habilitar reemplazos", + "enableDescription": "Habilitar o deshabilitar todos los reemplazos de texto globalmente", + "addNew": "Agregar nuevo reemplazo", + "wordPlaceholder": "Palabra a reemplazar", + "replacementPlaceholder": "Reemplazo", + "trimPunctuation": "Recortar puntuación", + "trimPunctuationTooltip": "Elimina puntuación alrededor de la palabra (p. ej. \". palabra ,\" → \"palabra\")", + "before": "Antes", + "after": "Después", + "trimSpaces": "Recortar espacios", + "trimSpacesTooltip": "Elimina espacios alrededor de la palabra", + "regex": "Regex", + "regexTooltip": "Usa la sintaxis de expresiones regulares de Rust (similar a Perl/Python). Admite (?i) para case-insensitive, \\d para dígitos, etc.", + "nextWord": "Siguiente palabra:", + "nextWordTooltip": "Controla la capitalización de la palabra inmediatamente después del reemplazo. 'None' preserva la capitalización original.", + "none": "Ninguno", + "updateReplacement": "Actualizar reemplazo", + "addReplacement": "Agregar reemplazo", + "advancedOptions": "Opciones avanzadas", + "filterPlaceholder": "Filtrar reemplazos...", + "magic": "Mágico", + "magicTooltip": "Reemplazo mágico", + "regularExpression": "Expresión regular", + "trimsPunctuation": "Recorta puntuación", + "trimsPunctuationShort": "Recorta puntuación ({{l}}{{r}})", + "trimsSpaces": "Recorta espacios", + "trimsSpacesShort": "Recorta espacios ({{l}}{{r}})", + "disabled": "Deshabilitado", + "duplicate": "Duplicado", + "newlyImported": "Importado recientemente", + "disable": "Desactivar", + "enable": "Activar", + "duplicateTitle": "Duplicar" + }, "history": { "title": "Historial", "openFolder": "Abrir Carpeta de Grabaciones", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index f36cd2558..da1dbe4cc 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -3,6 +3,7 @@ "sidebar": { "general": "Général", "advanced": "Avancé", + "replacements": "Remplacements", "postProcessing": "Post-traitement", "history": "Historique", "debug": "Débogage", @@ -255,6 +256,43 @@ "createFirst": "Cliquez sur 'Créer un nouveau prompt' ci-dessus pour créer votre premier prompt de post-traitement." } }, + "replacements": { + "title": "Remplacements de texte", + "enableLabel": "Activer les remplacements", + "enableDescription": "Activer ou désactiver globalement tous les remplacements de texte", + "addNew": "Ajouter un nouveau remplacement", + "wordPlaceholder": "Mot à remplacer", + "replacementPlaceholder": "Remplacement", + "trimPunctuation": "Supprimer la ponctuation", + "trimPunctuationTooltip": "Supprime la ponctuation autour du mot (ex. \". mot ,\" → \"mot\")", + "before": "Avant", + "after": "Après", + "trimSpaces": "Supprimer les espaces", + "trimSpacesTooltip": "Supprime les espaces autour du mot", + "regex": "Regex", + "regexTooltip": "Utilisez la syntaxe Rust regex (similaire à Perl/Python). Prend en charge (?i) pour insensibilité à la casse, \\d pour les chiffres, etc.", + "nextWord": "Mot suivant :", + "nextWordTooltip": "Contrôle la capitalisation du mot suivant immédiatement le remplacement. 'Aucun' préserve la casse d'origine.", + "none": "Aucun", + "updateReplacement": "Mettre à jour le remplacement", + "addReplacement": "Ajouter le remplacement", + "advancedOptions": "Options avancées", + "filterPlaceholder": "Filtrer les remplacements...", + "magic": "Magique", + "magicTooltip": "Remplacement magique", + "regularExpression": "Expression régulière", + "trimsPunctuation": "Supprime la ponctuation", + "trimsPunctuationShort": "Supprime ponct ({{l}}{{r}})", + "trimsSpaces": "Supprime les espaces", + "trimsSpacesShort": "Supprime espace ({{l}}{{r}})", + "disabled": "Désactivé", + "duplicate": "Dupliqué", + "newlyImported": "Importé récemment", + "magicTooltip": "Remplacement magique", + "disable": "Désactiver", + "enable": "Activer", + "duplicateTitle": "Dupliquer" + }, "history": { "title": "Historique", "openFolder": "Ouvrir le dossier des enregistrements", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index a22c44fc6..13362be2e 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -254,6 +254,42 @@ "createFirst": "Clicca 'Crea un nuovo prompt' qui sopra per creare il tuo primo prompt di post-elaborazione." } }, + "replacements": { + "title": "Sostituzioni di testo", + "enableLabel": "Abilita sostituzioni", + "enableDescription": "Abilita o disabilita globalmente tutte le sostituzioni di testo", + "addNew": "Aggiungi nuova sostituzione", + "wordPlaceholder": "Parola da sostituire", + "replacementPlaceholder": "Sostituzione", + "trimPunctuation": "Rimuovi punteggiatura", + "trimPunctuationTooltip": "Rimuove la punteggiatura attorno alla parola (es. \". parola ,\" → \"parola\")", + "before": "Prima", + "after": "Dopo", + "trimSpaces": "Rimuovi spazi", + "trimSpacesTooltip": "Rimuove gli spazi attorno alla parola", + "regex": "Regex", + "regexTooltip": "Usa la sintassi delle regex di Rust (simile a Perl/Python). Supporta (?i) per ignorare le maiuscole, \\d per le cifre, ecc.", + "nextWord": "Capitalizzazione parola successiva", + "nextWordTooltip": "Forza la prima lettera della parola immediatamente successiva alla sostituzione. 'Nessuno' mantiene la capitalizzazione originale.", + "none": "Nessuno", + "updateReplacement": "Aggiorna sostituzione", + "addReplacement": "Aggiungi sostituzione", + "advancedOptions": "Opzioni avanzate", + "filterPlaceholder": "Filtra sostituzioni...", + "magic": "Magico", + "magicTooltip": "Sostituzione magica", + "regularExpression": "Espressione regolare", + "trimsPunctuation": "Rimuove la punteggiatura", + "trimsPunctuationShort": "Rimuovi punteggiatura ({{l}}{{r}})", + "trimsSpaces": "Rimuove gli spazi", + "trimsSpacesShort": "Rimuovi spazi ({{l}}{{r}})", + "disabled": "Disabilitato", + "duplicate": "Duplicato", + "newlyImported": "Importato recentemente", + "disable": "Disabilita", + "enable": "Abilita", + "duplicateTitle": "Duplica" + }, "history": { "title": "Cronologia", "openFolder": "Apri la cartella delle registrazioni", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 2f2ea8963..1694ca228 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -2,6 +2,7 @@ "sidebar": { "general": "一般", "advanced": "詳細設定", + "replacements": "置換", "postProcessing": "後処理", "history": "履歴", "debug": "デバッグ", @@ -254,6 +255,42 @@ "createFirst": "上の「新しいプロンプトを作成」をクリックして、最初の後処理プロンプトを作成してください。" } }, + "replacements": { + "title": "テキスト置換", + "enableLabel": "置換を有効化", + "enableDescription": "すべてのテキスト置換をグローバルに有効/無効にします", + "addNew": "新しい置換を追加", + "wordPlaceholder": "置換する語", + "replacementPlaceholder": "置換後", + "trimPunctuation": "句読点を削除", + "trimPunctuationTooltip": "単語の前後の句読点を削除します(例:\". 単語 ,\" → \"単語\")", + "before": "前", + "after": "後", + "trimSpaces": "空白を削除", + "trimSpacesTooltip": "単語の前後の空白を削除します", + "regex": "正規表現", + "regexTooltip": "Rustの正規表現構文を使用します(Perl/Pythonに類似)。(?i)による大文字小文字無視、\\dによる数字などをサポートします。", + "nextWord": "次の単語:", + "nextWordTooltip": "置換直後の単語の大文字小文字を制御します。'None' は元の大文字小文字を保持します。", + "none": "なし", + "updateReplacement": "置換を更新", + "addReplacement": "置換を追加", + "advancedOptions": "詳細オプション", + "filterPlaceholder": "置換をフィルタ...", + "magic": "マジック", + "magicTooltip": "マジック置換", + "regularExpression": "正規表現", + "trimsPunctuation": "句読点を削除", + "trimsPunctuationShort": "句読点 ({{l}}{{r}}) を削除", + "trimsSpaces": "空白を削除", + "trimsSpacesShort": "空白 ({{l}}{{r}})", + "disabled": "無効", + "duplicate": "重複", + "newlyImported": "最近インポート", + "disable": "無効にする", + "enable": "有効にする", + "duplicateTitle": "複製" + }, "history": { "title": "履歴", "openFolder": "録音フォルダを開く", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 6943a48f4..e4fc762f1 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -2,6 +2,7 @@ "sidebar": { "general": "Ogólne", "advanced": "Zaawansowane", + "replacements": "Zamiany tekstu", "postProcessing": "Postproces", "history": "Historia", "debug": "Debugowanie", @@ -254,6 +255,42 @@ "createFirst": "Kliknij 'Utwórz nowy prompt' powyżej, aby utworzyć pierwszy prompt postprocessingu." } }, + "replacements": { + "title": "Zamiany tekstu", + "enableLabel": "Włącz zamiany", + "enableDescription": "Globalnie włącz lub wyłącz wszystkie zamiany tekstu", + "addNew": "Dodaj nowe zastąpienie", + "wordPlaceholder": "Słowo do zastąpienia", + "replacementPlaceholder": "Zastąpienie", + "trimPunctuation": "Usuń interpunkcję", + "trimPunctuationTooltip": "Usuwa interpunkcję wokół słowa (np. \". słowo ,\" → \"słowo\")", + "before": "Przed", + "after": "Po", + "trimSpaces": "Usuń spacje", + "trimSpacesTooltip": "Usuwa spacje wokół słowa", + "regex": "Regex", + "regexTooltip": "Użyj składni regex w Rust (podobna do Perl/Python). Obsługuje (?i) dla ignorowania wielkości liter, \\d dla cyfr, itp.", + "nextWord": "Następne słowo:", + "nextWordTooltip": "Kontroluje kapitalizację słowa bezpośrednio po zastąpieniu. 'Brak' zachowuje oryginalną kapitalizację.", + "none": "Brak", + "updateReplacement": "Zaktualizuj zastąpienie", + "addReplacement": "Dodaj zastąpienie", + "advancedOptions": "Opcje zaawansowane", + "filterPlaceholder": "Filtruj zamiany...", + "magic": "Magia", + "magicTooltip": "Magiczne zastąpienie", + "regularExpression": "Wyrażenie regularne", + "trimsPunctuation": "Usuwa interpunkcję", + "trimsPunctuationShort": "Usuń interpunkcję ({{l}}{{r}})", + "trimsSpaces": "Usuwa spacje", + "trimsSpacesShort": "Usuń spacje ({{l}}{{r}})", + "disabled": "Wyłączone", + "duplicate": "Duplikat", + "newlyImported": "Niedawno zaimportowane", + "disable": "Wyłącz", + "enable": "Włącz", + "duplicateTitle": "Duplikuj" + }, "history": { "title": "Historia", "openFolder": "Otwórz folder nagrań", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 7d548518d..cb5143c1d 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -3,6 +3,7 @@ "sidebar": { "general": "Chung", "advanced": "Nâng cao", + "replacements": "Thay thế", "postProcessing": "Xử lý sau", "history": "Lịch sử", "debug": "Gỡ lỗi", @@ -255,6 +256,42 @@ "createFirst": "Nhấn 'Tạo Prompt mới' ở trên để tạo prompt xử lý sau đầu tiên của bạn." } }, + "replacements": { + "title": "Thay thế văn bản", + "enableLabel": "Bật Thay thế", + "enableDescription": "Bật hoặc tắt tất cả các thay thế văn bản toàn cục", + "addNew": "Thêm thay thế mới", + "wordPlaceholder": "Từ cần thay", + "replacementPlaceholder": "Thay thế", + "trimPunctuation": "Loại bỏ dấu câu", + "trimPunctuationTooltip": "Loại bỏ dấu câu xung quanh từ (ví dụ: \". từ ,\" → \"từ\")", + "before": "Trước", + "after": "Sau", + "trimSpaces": "Loại bỏ khoảng trắng", + "trimSpacesTooltip": "Loại bỏ khoảng trắng xung quanh từ", + "regex": "Regex", + "regexTooltip": "Sử dụng cú pháp regex của Rust (tương tự Perl/Python). Hỗ trợ (?i) không phân biệt hoa thường, \\d cho chữ số, v.v.", + "nextWord": "Từ tiếp theo:", + "nextWordTooltip": "Kiểm soát chữ hoa chữ thường của từ ngay sau khi thay thế. 'None' giữ nguyên chữ hoa/chữ thường ban đầu.", + "none": "Không", + "updateReplacement": "Cập nhật thay thế", + "addReplacement": "Thêm thay thế", + "advancedOptions": "Tùy chọn nâng cao", + "filterPlaceholder": "Lọc thay thế...", + "magic": "Ma thuật", + "magicTooltip": "Thay thế ma thuật", + "regularExpression": "Biểu thức chính quy", + "trimsPunctuation": "Loại bỏ dấu câu", + "trimsPunctuationShort": "Loại bỏ ({{l}}{{r}})", + "trimsSpaces": "Loại bỏ khoảng trắng", + "trimsSpacesShort": "Khoảng trắng ({{l}}{{r}})", + "disabled": "Đã tắt", + "duplicate": "Bản sao", + "newlyImported": "Mới nhập", + "disable": "Tắt", + "enable": "Bật", + "duplicateTitle": "Sao chép" + }, "history": { "title": "Lịch sử", "openFolder": "Mở thư mục ghi âm", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 9463e82ad..3ae53b731 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -2,6 +2,7 @@ "sidebar": { "general": "通用", "advanced": "高级", + "replacements": "替换", "postProcessing": "后处理", "history": "历史记录", "debug": "调试", @@ -254,6 +255,42 @@ "createFirst": "点击上方的「创建新提示词」来创建您的第一个后处理提示词。" } }, + "replacements": { + "title": "文本替换", + "enableLabel": "启用替换", + "enableDescription": "全局启用或禁用所有文本替换", + "addNew": "添加新替换", + "wordPlaceholder": "要替换的词", + "replacementPlaceholder": "替换为", + "trimPunctuation": "修剪标点", + "trimPunctuationTooltip": "移除词前后的标点(例如:\". 词 ,\" → \"词\")", + "before": "前", + "after": "后", + "trimSpaces": "修剪空格", + "trimSpacesTooltip": "移除词前后的空格", + "regex": "正则表达式", + "regexTooltip": "使用 Rust 正则语法(类似 Perl/Python)。支持 (?i) 忽略大小写,\\d 表示数字等。", + "nextWord": "下一个词:", + "nextWordTooltip": "控制替换后紧接着的词的大小写。'None' 保持原有大小写。", + "none": "无", + "updateReplacement": "更新替换", + "addReplacement": "添加替换", + "advancedOptions": "高级选项", + "filterPlaceholder": "筛选替换...", + "magic": "魔法", + "magicTooltip": "魔法替换", + "regularExpression": "正则表达式", + "trimsPunctuation": "修剪标点", + "trimsPunctuationShort": "修剪标点 ({{l}}{{r}})", + "trimsSpaces": "修剪空格", + "trimsSpacesShort": "修剪空格 ({{l}}{{r}})", + "disabled": "已禁用", + "duplicate": "重复", + "newlyImported": "新导入", + "disable": "禁用", + "enable": "启用", + "duplicateTitle": "复制" + }, "history": { "title": "历史记录", "openFolder": "打开录音文件夹", diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index f35a6e956..5800c6f49 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,9 @@ 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[]), + replacements_enabled: (value) => + commands.updateReplacementsEnabled(value as boolean), word_correction_threshold: (value) => commands.changeWordCorrectionThresholdSetting(value as number), paste_method: (value) => commands.changePasteMethodSetting(value as string),