diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 26db05416f..a304bef411 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -7,7 +7,9 @@ use crate::managers::history::HistoryManager; use crate::managers::model::ModelManager; use crate::managers::transcription::StreamWorkKind; use crate::managers::transcription::TranscriptionManager; -use crate::settings::{get_settings, AppSettings, OverlayStyle, APPLE_INTELLIGENCE_PROVIDER_ID}; +use crate::settings::{ + get_settings, AppSettings, OverlayStyle, PostProcessProfile, APPLE_INTELLIGENCE_PROVIDER_ID, +}; use crate::shortcut; use crate::tray::{set_tray_state, TrayIconState}; use crate::utils::{ @@ -119,13 +121,16 @@ fn should_use_streaming_overlay(style: OverlayStyle, is_streaming: bool) -> bool style == OverlayStyle::Live && is_streaming } -async fn post_process_transcription(settings: &AppSettings, transcription: &str) -> Option { +async fn post_process_transcription( + profile: &PostProcessProfile, + transcription: &str, +) -> Option { if is_blank_transcription(transcription) { debug!("Post-processing skipped because the transcription is empty"); return None; } - let provider = match settings.active_post_process_provider().cloned() { + let provider = match profile.active_provider().cloned() { Some(provider) => provider, None => { debug!("Post-processing enabled but no provider is selected"); @@ -133,8 +138,8 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) } }; - let model = settings - .post_process_models + let model = profile + .models .get(&provider.id) .cloned() .unwrap_or_default(); @@ -147,7 +152,7 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) return None; } - let selected_prompt_id = match &settings.post_process_selected_prompt_id { + let selected_prompt_id = match &profile.selected_prompt_id { Some(id) => id.clone(), None => { debug!("Post-processing skipped because no prompt is selected"); @@ -155,8 +160,8 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) } }; - let prompt = match settings - .post_process_prompts + let prompt = match profile + .prompts .iter() .find(|prompt| prompt.id == selected_prompt_id) { @@ -176,12 +181,12 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) } debug!( - "Starting LLM post-processing with provider '{}' (model: {})", - provider.id, model + "Starting LLM post-processing with profile '{}' ({}), provider '{}' (model: {})", + profile.name, profile.id, provider.id, model ); - let api_key = settings - .post_process_api_keys + let api_key = profile + .api_keys .get(&provider.id) .cloned() .unwrap_or_default(); @@ -420,10 +425,12 @@ fn resolve_effective_language(app: &AppHandle, settings: &AppSettings) -> String } } +/// `post_process_profile` is the profile to post-process with, or `None` to +/// skip post-processing. pub(crate) async fn process_transcription_output( app: &AppHandle, transcription: &str, - post_process: bool, + post_process_profile: Option<&PostProcessProfile>, ) -> ProcessedTranscription { let settings = get_settings(app); let mut final_text = transcription.to_string(); @@ -440,14 +447,14 @@ pub(crate) async fn process_transcription_output( final_text = converted_text; } - if post_process { - if let Some(processed_text) = post_process_transcription(&settings, &final_text).await { + if let Some(profile) = post_process_profile { + if let Some(processed_text) = post_process_transcription(profile, &final_text).await { post_processed_text = Some(processed_text.clone()); final_text = processed_text; - if let Some(prompt_id) = &settings.post_process_selected_prompt_id { - if let Some(prompt) = settings - .post_process_prompts + if let Some(prompt_id) = &profile.selected_prompt_id { + if let Some(prompt) = profile + .prompts .iter() .find(|prompt| &prompt.id == prompt_id) { @@ -678,6 +685,22 @@ impl ShortcutAction for TranscribeAction { "Starting async transcription task for binding: {}", binding_id ); + // Resolved once for the whole run and recorded with the history + // entry so a retry reuses this profile. A profile deleted while + // recording means no post-processing (not a silent Default run). + let post_process_profile = if post_process { + let profile = get_settings(&ah) + .post_process_profile_for_binding(&binding_id) + .cloned(); + if profile.is_none() { + warn!("No post-processing profile for binding '{binding_id}'"); + } + profile + } else { + None + }; + let post_process = post_process_profile.is_some(); + let post_process_profile_id = post_process_profile.as_ref().map(|p| p.id.clone()); let stop_recording_time = Instant::now(); if let Some(samples) = rm.stop_recording(&binding_id, cancel_generation) { @@ -768,7 +791,11 @@ impl ShortcutAction for TranscribeAction { utils::redact_text(&transcription) ); - if post_process { + if let Some(profile) = &post_process_profile { + crate::overlay::set_overlay_post_process_profile( + &ah, + Some(profile.name.as_str()), + ); if use_streaming_overlay { tm.emit_stream_working(StreamWorkKind::Polishing); } else { @@ -776,7 +803,11 @@ impl ShortcutAction for TranscribeAction { } } let Some(processed) = complete_unless_cancelled( - process_transcription_output(&ah, &transcription, post_process), + process_transcription_output( + &ah, + &transcription, + post_process_profile.as_ref(), + ), || rm.was_cancelled_since(cancel_generation), ) .await @@ -802,6 +833,7 @@ impl ShortcutAction for TranscribeAction { post_process, processed.post_processed_text.clone(), processed.post_process_prompt.clone(), + post_process_profile_id.clone(), ) { error!("Failed to save history entry: {}", err); } @@ -865,6 +897,7 @@ impl ShortcutAction for TranscribeAction { post_process, None, None, + post_process_profile_id.clone(), ) { error!("Failed to save failed history entry: {}", save_err); } @@ -926,19 +959,19 @@ impl ShortcutAction for TestAction { } } -// Static Action Map +/// The action for a transcribe binding. Transcribe bindings are routed to the +/// coordinator rather than `ACTION_MAP`, since post-processing profile +/// bindings are created at runtime; every transcribe binding other than plain +/// "transcribe" post-processes with its own profile. +pub fn transcribe_action(binding_id: &str) -> Arc { + Arc::new(TranscribeAction { + post_process: binding_id != "transcribe", + }) +} + +// Static Action Map for the non-transcribe bindings (see `transcribe_action`). pub static ACTION_MAP: Lazy>> = Lazy::new(|| { let mut map = HashMap::new(); - map.insert( - "transcribe".to_string(), - Arc::new(TranscribeAction { - post_process: false, - }) as Arc, - ); - map.insert( - "transcribe_with_post_process".to_string(), - Arc::new(TranscribeAction { post_process: true }) as Arc, - ); map.insert( "cancel".to_string(), Arc::new(CancelAction) as Arc, diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index 3792f6f1d6..0eee3b4d4c 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -93,8 +93,19 @@ pub async fn retry_history_entry_transcription( return Err("Recording contains no speech".to_string()); } - let processed = - process_transcription_output(&app, &transcription, entry.post_process_requested).await; + // Reuse the profile that originally processed the entry. + let settings = crate::settings::get_settings(&app); + let post_process_profile = settings.retry_post_process_profile( + entry.post_process_requested, + entry.post_process_profile_id.as_deref(), + ); + if entry.post_process_requested && post_process_profile.is_none() { + log::warn!( + "Post-processing profile of history entry {} was deleted; retrying without post-processing", + id + ); + } + let processed = process_transcription_output(&app, &transcription, post_process_profile).await; history_manager .update_transcription( id, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index facc9786a0..fccf6c7441 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -686,6 +686,9 @@ pub fn run(cli_args: CliArgs) { shortcut::update_post_process_prompt, shortcut::delete_post_process_prompt, shortcut::set_post_process_selected_prompt, + shortcut::add_post_process_profile, + shortcut::delete_post_process_profile, + shortcut::rename_post_process_profile, shortcut::update_custom_words, shortcut::suspend_all_bindings, shortcut::resume_all_bindings, diff --git a/src-tauri/src/managers/history.rs b/src-tauri/src/managers/history.rs index c73b46c92f..6f63377a1b 100644 --- a/src-tauri/src/managers/history.rs +++ b/src-tauri/src/managers/history.rs @@ -31,6 +31,14 @@ static MIGRATIONS: &[M] = &[ M::up("ALTER TABLE transcription_history ADD COLUMN post_processed_text TEXT;"), M::up("ALTER TABLE transcription_history ADD COLUMN post_process_prompt TEXT;"), M::up("ALTER TABLE transcription_history ADD COLUMN post_process_requested BOOLEAN NOT NULL DEFAULT 0;"), + // Everything post-processed before profiles existed used what is now the + // Default profile. NULL afterwards means "not post-processed", or that the + // profile was deleted (see `clear_post_process_profile`). + M::up( + "ALTER TABLE transcription_history ADD COLUMN post_process_profile_id TEXT; + UPDATE transcription_history SET post_process_profile_id = 'default' + WHERE post_process_requested = 1;", + ), ]; #[derive(Clone, Debug, Serialize, Deserialize, Type)] @@ -63,6 +71,11 @@ pub struct HistoryEntry { pub post_processed_text: Option, pub post_process_prompt: Option, pub post_process_requested: bool, + /// Post-processing profile that processed this entry; `None` for entries + /// from before profiles, which used what is now the Default profile. + /// Post-processing profile that processed this entry. `None` if it was + /// not post-processed or its profile has since been deleted. + pub post_process_profile_id: Option, } pub struct HistoryManager { @@ -207,6 +220,7 @@ impl HistoryManager { post_processed_text: row.get("post_processed_text")?, post_process_prompt: row.get("post_process_prompt")?, post_process_requested: row.get("post_process_requested")?, + post_process_profile_id: row.get("post_process_profile_id")?, }) } @@ -223,6 +237,7 @@ impl HistoryManager { post_process_requested: bool, post_processed_text: Option, post_process_prompt: Option, + post_process_profile_id: Option, ) -> Result { let timestamp = Utc::now().timestamp(); let title = self.format_timestamp_title(timestamp); @@ -237,8 +252,9 @@ impl HistoryManager { transcription_text, post_processed_text, post_process_prompt, - post_process_requested - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + post_process_requested, + post_process_profile_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ &file_name, timestamp, @@ -248,6 +264,7 @@ impl HistoryManager { &post_processed_text, &post_process_prompt, post_process_requested, + &post_process_profile_id, ], )?; @@ -261,6 +278,7 @@ impl HistoryManager { post_processed_text, post_process_prompt, post_process_requested, + post_process_profile_id, }; debug!("Saved history entry with id {}", entry.id); @@ -308,7 +326,7 @@ impl HistoryManager { let entry = conn .query_row( - "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested + "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested, post_process_profile_id FROM transcription_history WHERE id = ?1", params![id], Self::map_history_entry, @@ -459,7 +477,7 @@ impl HistoryManager { (Some(cursor_id), Some(lim)) => { let fetch_count = (lim + 1) as i64; let mut stmt = conn.prepare( - "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested + "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested, post_process_profile_id FROM transcription_history WHERE id < ?1 ORDER BY id DESC @@ -473,7 +491,7 @@ impl HistoryManager { (None, Some(lim)) => { let fetch_count = (lim + 1) as i64; let mut stmt = conn.prepare( - "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested + "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested, post_process_profile_id FROM transcription_history ORDER BY id DESC LIMIT ?1", @@ -485,7 +503,7 @@ impl HistoryManager { } (_, None) => { let mut stmt = conn.prepare( - "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested + "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested, post_process_profile_id FROM transcription_history ORDER BY id DESC", )?; @@ -504,6 +522,22 @@ impl HistoryManager { Ok(PaginatedHistory { entries, has_more }) } + /// Detaches entries from a deleted post-processing profile — the + /// equivalent of `ON DELETE SET NULL`, which SQLite cannot express because + /// profiles live in the settings store rather than in this database. + pub fn clear_post_process_profile(&self, profile_id: &str) -> Result { + let conn = self.get_connection()?; + Self::clear_post_process_profile_with_conn(&conn, profile_id) + } + + fn clear_post_process_profile_with_conn(conn: &Connection, profile_id: &str) -> Result { + Ok(conn.execute( + "UPDATE transcription_history SET post_process_profile_id = NULL + WHERE post_process_profile_id = ?1", + params![profile_id], + )?) + } + #[cfg(test)] fn get_latest_entry_with_conn(conn: &Connection) -> Result> { let mut stmt = conn.prepare( @@ -516,7 +550,8 @@ impl HistoryManager { transcription_text, post_processed_text, post_process_prompt, - post_process_requested + post_process_requested, + post_process_profile_id FROM transcription_history ORDER BY timestamp DESC LIMIT 1", @@ -543,7 +578,8 @@ impl HistoryManager { transcription_text, post_processed_text, post_process_prompt, - post_process_requested + post_process_requested, + post_process_profile_id FROM transcription_history WHERE transcription_text != '' ORDER BY timestamp DESC @@ -597,7 +633,8 @@ impl HistoryManager { transcription_text, post_processed_text, post_process_prompt, - post_process_requested + post_process_requested, + post_process_profile_id FROM transcription_history WHERE id = ?1", )?; @@ -666,7 +703,8 @@ mod tests { transcription_text TEXT NOT NULL, post_processed_text TEXT, post_process_prompt TEXT, - post_process_requested BOOLEAN NOT NULL DEFAULT 0 + post_process_requested BOOLEAN NOT NULL DEFAULT 0, + post_process_profile_id TEXT );", ) .expect("create transcription_history table"); @@ -734,4 +772,67 @@ mod tests { assert_eq!(entry.timestamp, 100); assert_eq!(entry.transcription_text, "completed"); } + + #[test] + fn profile_migration_assigns_default_to_post_processed_entries() { + let mut conn = Connection::open_in_memory().expect("open in-memory db"); + // A database as the release before profiles left it. + Migrations::new(MIGRATIONS[..4].to_vec()) + .to_latest(&mut conn) + .expect("apply pre-profile migrations"); + for (file, requested) in [("plain.wav", false), ("processed.wav", true)] { + conn.execute( + "INSERT INTO transcription_history + (file_name, timestamp, saved, title, transcription_text, post_process_requested) + VALUES (?1, 0, 0, 'Recording', 'text', ?2)", + params![file, requested], + ) + .expect("insert entry"); + } + + Migrations::new(MIGRATIONS.to_vec()) + .to_latest(&mut conn) + .expect("apply profile migration"); + + let profile_of = |file: &str| -> Option { + conn.query_row( + "SELECT post_process_profile_id FROM transcription_history WHERE file_name = ?1", + [file], + |row| row.get(0), + ) + .expect("entry exists") + }; + assert_eq!(profile_of("plain.wav"), None); + assert_eq!(profile_of("processed.wav").as_deref(), Some("default")); + } + + #[test] + fn deleting_a_profile_detaches_only_its_entries() { + let conn = setup_conn(); + for (file, profile) in [("a.wav", "profile_a"), ("b.wav", "profile_b")] { + conn.execute( + "INSERT INTO transcription_history + (file_name, timestamp, saved, title, transcription_text, + post_process_requested, post_process_profile_id) + VALUES (?1, 0, 0, 'Recording', 'text', 1, ?2)", + params![file, profile], + ) + .expect("insert entry"); + } + + let cleared = HistoryManager::clear_post_process_profile_with_conn(&conn, "profile_a") + .expect("clear profile"); + assert_eq!(cleared, 1); + + let profile_of = |file: &str| -> Option { + conn.query_row( + "SELECT post_process_profile_id FROM transcription_history WHERE file_name = ?1", + [file], + |row| row.get(0), + ) + .expect("entry exists") + }; + assert_eq!(profile_of("a.wav"), None); + assert_eq!(profile_of("b.wav").as_deref(), Some("profile_b")); + } } diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 292acce363..62d053bf34 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -630,6 +630,16 @@ pub fn show_processing_overlay(app_handle: &AppHandle) { show_overlay_state(app_handle, "processing"); } +/// Tells the overlay which post-processing profile is running so its +/// processing label can name it; `None` falls back to the plain label. +pub fn set_overlay_post_process_profile(app_handle: &AppHandle, profile_name: Option<&str>) { + let _ = app_handle.emit_to( + "recording_overlay", + "overlay-post-process-profile", + profile_name, + ); +} + /// Updates the overlay window position based on current settings pub fn update_overlay_position(app_handle: &AppHandle) { // Positioning queries monitors/cursor (GDK/Xlib on Linux) and moves the diff --git a/src-tauri/src/secure_input.rs b/src-tauri/src/secure_input.rs index 2201dba43c..b4cc4ed1a3 100644 --- a/src-tauri/src/secure_input.rs +++ b/src-tauri/src/secure_input.rs @@ -477,10 +477,13 @@ mod imp { let mut wanted: Vec<(String, ShortcutBinding, bool)> = Vec::new(); if eligible { for (id, binding) in &settings.bindings { - if id == "cancel" && !state.cancel_requested.load(Ordering::SeqCst) { - continue; - } - if id == "transcribe_with_post_process" && !settings.post_process_enabled { + if id == "cancel" { + if !state.cancel_requested.load(Ordering::SeqCst) + || binding.current_binding.trim().is_empty() + { + continue; + } + } else if !settings.should_register_binding(binding) { continue; } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 182ae59355..30c3bf69eb 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -107,6 +107,72 @@ pub struct PostProcessProvider { pub supports_structured_output: bool, } +/// Id of the built-in post-processing profile, which always exists. +pub const DEFAULT_POST_PROCESS_PROFILE_ID: &str = "default"; +/// Binding of the built-in profile: the pre-profiles post-processing shortcut. +pub const DEFAULT_POST_PROCESS_BINDING_ID: &str = "transcribe_with_post_process"; +/// Binding ids of user-created profiles start with this prefix, which is how +/// bindings left behind by deleted profiles are recognised. +const PROFILE_BINDING_PREFIX: &str = "post_process_profile_"; + +/// One post-processing configuration with its own global shortcut. Each +/// profile is a full, independent copy of the post-processing settings. +#[derive(Serialize, Deserialize, Debug, Clone, Type)] +pub struct PostProcessProfile { + pub id: String, + /// Tab label. Empty for the built-in profile until the user renames it, + /// so the UI can show "Default" in the current language. + #[serde(default)] + pub name: String, + /// Key into `AppSettings::bindings`. + pub binding_id: String, + #[serde(default = "default_post_process_provider_id")] + pub provider_id: String, + #[serde(default = "default_post_process_providers")] + pub providers: Vec, + #[serde(default = "default_post_process_api_keys")] + pub api_keys: SecretMap, + #[serde(default = "default_post_process_models")] + pub models: HashMap, + #[serde(default = "default_post_process_prompts")] + pub prompts: Vec, + #[serde(default)] + pub selected_prompt_id: Option, +} + +impl PostProcessProfile { + /// A profile with the same post-processing defaults as a fresh install. + fn with_defaults(id: String, name: String, binding_id: String) -> Self { + PostProcessProfile { + id, + name, + binding_id, + provider_id: default_post_process_provider_id(), + providers: default_post_process_providers(), + api_keys: default_post_process_api_keys(), + models: default_post_process_models(), + prompts: default_post_process_prompts(), + selected_prompt_id: None, + } + } + + pub fn active_provider(&self) -> Option<&PostProcessProvider> { + self.provider(&self.provider_id) + } + + pub fn provider(&self, provider_id: &str) -> Option<&PostProcessProvider> { + self.providers + .iter() + .find(|provider| provider.id == provider_id) + } + + pub fn provider_mut(&mut self, provider_id: &str) -> Option<&mut PostProcessProvider> { + self.providers + .iter_mut() + .find(|provider| provider.id == provider_id) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)] #[serde(rename_all = "lowercase")] pub enum OverlayPosition { @@ -445,18 +511,13 @@ pub struct AppSettings { pub auto_submit_key: AutoSubmitKey, #[serde(default = "default_post_process_enabled")] pub post_process_enabled: bool, - #[serde(default = "default_post_process_provider_id")] - pub post_process_provider_id: String, - #[serde(default = "default_post_process_providers")] - pub post_process_providers: Vec, - #[serde(default = "default_post_process_api_keys")] - pub post_process_api_keys: SecretMap, - #[serde(default = "default_post_process_models")] - pub post_process_models: HashMap, - #[serde(default = "default_post_process_prompts")] - pub post_process_prompts: Vec, - #[serde(default)] - pub post_process_selected_prompt_id: Option, + /// Replaces the pre-profiles flat `post_process_*` fields, which are + /// migrated into the `Default` profile in `apply_settings_migrations`. + #[serde( + default = "default_post_process_profiles", + deserialize_with = "deserialize_post_process_profiles" + )] + pub post_process_profiles: Vec, #[serde(default)] pub mute_while_recording: bool, #[serde(default)] @@ -520,7 +581,7 @@ fn default_model() -> String { "".to_string() } -const CURRENT_SETTINGS_SCHEMA_VERSION: u32 = 2; +const CURRENT_SETTINGS_SCHEMA_VERSION: u32 = 3; fn default_settings_schema_version() -> u32 { CURRENT_SETTINGS_SCHEMA_VERSION @@ -772,6 +833,59 @@ fn default_post_process_prompts() -> Vec { }] } +/// Longest profile (tab) name accepted, in characters. +pub const MAX_POST_PROCESS_PROFILE_NAME_LEN: usize = 40; +/// English base name of new profiles, used when the UI supplies none. +const FALLBACK_PROFILE_BASE_NAME: &str = "Default"; + +fn default_post_process_profile() -> PostProcessProfile { + PostProcessProfile::with_defaults( + DEFAULT_POST_PROCESS_PROFILE_ID.to_string(), + String::new(), + DEFAULT_POST_PROCESS_BINDING_ID.to_string(), + ) +} + +/// Reads the profile list one profile at a time, so a single malformed +/// profile is dropped instead of failing the whole list (which would lose +/// every profile and its API keys). +fn deserialize_post_process_profiles<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let values = Vec::::deserialize(deserializer)?; + Ok(values + .into_iter() + .filter_map(|value| match serde_json::from_value(value) { + Ok(profile) => Some(profile), + Err(e) => { + warn!("Dropping invalid post-processing profile: {e}"); + None + } + }) + .collect()) +} + +fn default_post_process_profiles() -> Vec { + vec![default_post_process_profile()] +} + +/// Shortcut entry for a user-created profile. It starts unassigned, so a new +/// profile never collides with an existing shortcut. +fn profile_binding(binding_id: &str, profile_name: &str) -> ShortcutBinding { + ShortcutBinding { + id: binding_id.to_string(), + name: format!("Transcribe with Post-Processing ({profile_name})"), + description: format!( + "Converts your speech into text and applies the '{profile_name}' post-processing profile." + ), + default_binding: String::new(), + current_binding: String::new(), + } +} + fn default_transcribe_gpu_device() -> Option { None // automatic device selection } @@ -798,14 +912,20 @@ fn default_typing_tool() -> TypingTool { } fn ensure_post_process_defaults(settings: &mut AppSettings) -> bool { + let mut changed = false; + for profile in &mut settings.post_process_profiles { + if ensure_profile_provider_defaults(profile) { + changed = true; + } + } + changed +} + +fn ensure_profile_provider_defaults(profile: &mut PostProcessProfile) -> bool { let mut changed = false; for provider in default_post_process_providers() { // Use match to do a single lookup - either sync existing or add new - match settings - .post_process_providers - .iter_mut() - .find(|p| p.id == provider.id) - { + match profile.providers.iter_mut().find(|p| p.id == provider.id) { Some(existing) => { // Sync supports_structured_output field for existing providers (migration) if existing.supports_structured_output != provider.supports_structured_output { @@ -821,20 +941,18 @@ fn ensure_post_process_defaults(settings: &mut AppSettings) -> bool { } None => { // Provider doesn't exist, add it - settings.post_process_providers.push(provider.clone()); + profile.providers.push(provider.clone()); changed = true; } } - if !settings.post_process_api_keys.contains_key(&provider.id) { - settings - .post_process_api_keys - .insert(provider.id.clone(), String::new()); + if !profile.api_keys.contains_key(&provider.id) { + profile.api_keys.insert(provider.id.clone(), String::new()); changed = true; } let default_model = default_model_for_provider(&provider.id); - match settings.post_process_models.get_mut(&provider.id) { + match profile.models.get_mut(&provider.id) { Some(existing) => { if existing.is_empty() && !default_model.is_empty() { *existing = default_model.clone(); @@ -842,9 +960,7 @@ fn ensure_post_process_defaults(settings: &mut AppSettings) -> bool { } } None => { - settings - .post_process_models - .insert(provider.id.clone(), default_model); + profile.models.insert(provider.id.clone(), default_model); changed = true; } } @@ -853,6 +969,198 @@ fn ensure_post_process_defaults(settings: &mut AppSettings) -> bool { changed } +/// Enforces the profile invariants: exactly one `default` profile (bound to +/// the original post-processing shortcut), unique profile ids and binding +/// ids, a binding entry for every profile, and no bindings left behind by +/// deleted profiles. +fn ensure_post_process_profiles(settings: &mut AppSettings) -> bool { + let mut changed = false; + + // Settle the default profile's binding and drop any other profile that + // claims it *before* deduplicating, so a stray profile listed ahead of + // Default can never evict the user's real Default configuration. + for profile in &mut settings.post_process_profiles { + if profile.id == DEFAULT_POST_PROCESS_PROFILE_ID + && profile.binding_id != DEFAULT_POST_PROCESS_BINDING_ID + { + profile.binding_id = DEFAULT_POST_PROCESS_BINDING_ID.to_string(); + changed = true; + } + } + let mut seen_ids = std::collections::HashSet::new(); + let mut seen_bindings = std::collections::HashSet::new(); + let before = settings.post_process_profiles.len(); + settings.post_process_profiles.retain(|profile| { + (profile.id == DEFAULT_POST_PROCESS_PROFILE_ID + || profile.binding_id != DEFAULT_POST_PROCESS_BINDING_ID) + && seen_ids.insert(profile.id.clone()) + && seen_bindings.insert(profile.binding_id.clone()) + }); + changed |= settings.post_process_profiles.len() != before; + + match settings + .post_process_profiles + .iter() + .position(|p| p.id == DEFAULT_POST_PROCESS_PROFILE_ID) + { + Some(0) => {} + Some(index) => { + let profile = settings.post_process_profiles.remove(index); + settings.post_process_profiles.insert(0, profile); + changed = true; + } + None => { + settings + .post_process_profiles + .insert(0, default_post_process_profile()); + changed = true; + } + } + + for profile in &settings.post_process_profiles { + if profile.binding_id == DEFAULT_POST_PROCESS_BINDING_ID { + continue; // Built-in binding, merged from the defaults on load. + } + if !settings.bindings.contains_key(&profile.binding_id) { + settings.bindings.insert( + profile.binding_id.clone(), + profile_binding(&profile.binding_id, &profile.name), + ); + changed = true; + } + } + + let profiles = &settings.post_process_profiles; + let before = settings.bindings.len(); + settings.bindings.retain(|id, _| { + !id.starts_with(PROFILE_BINDING_PREFIX) || profiles.iter().any(|p| &p.binding_id == id) + }); + changed |= settings.bindings.len() != before; + + changed +} + +/// Adds a new profile named `Default N` (lowest free N) with fresh-install +/// post-processing defaults, plus its (unassigned) shortcut binding. +pub fn add_post_process_profile(settings: &mut AppSettings, base_name: &str) -> PostProcessProfile { + let base_name = match base_name.trim() { + "" => FALLBACK_PROFILE_BASE_NAME, + trimmed => trimmed, + }; + let number = (1..) + .find(|n| { + let name = format!("{base_name} {n}"); + !settings + .post_process_profiles + .iter() + .any(|p| p.name == name) + }) + .expect("an unused profile number always exists"); + let name = format!("{base_name} {number}"); + + let mut stamp = chrono::Utc::now().timestamp_millis(); + while settings + .post_process_profiles + .iter() + .any(|p| p.id == format!("profile_{stamp}")) + { + stamp += 1; + } + let id = format!("profile_{stamp}"); + // Must carry the prefix, or `ensure_post_process_profiles` cannot tell + // this binding apart from built-in ones once the profile is deleted. + let binding_id = format!("{PROFILE_BINDING_PREFIX}{stamp}"); + + let profile = PostProcessProfile::with_defaults(id, name, binding_id); + settings.bindings.insert( + profile.binding_id.clone(), + profile_binding(&profile.binding_id, &profile.name), + ); + settings.post_process_profiles.push(profile.clone()); + profile +} + +/// Renames a profile. Clearing the built-in profile's name restores its +/// localized default label; other profiles need a non-empty name. +pub fn rename_post_process_profile( + settings: &mut AppSettings, + profile_id: &str, + name: &str, +) -> Result { + let name = name.trim(); + if name.chars().count() > MAX_POST_PROCESS_PROFILE_NAME_LEN { + return Err(format!( + "Profile names can be at most {MAX_POST_PROCESS_PROFILE_NAME_LEN} characters" + )); + } + if name.is_empty() && profile_id != DEFAULT_POST_PROCESS_PROFILE_ID { + return Err("Profile name cannot be empty".to_string()); + } + let profile = settings + .post_process_profile_mut(profile_id) + .ok_or_else(|| format!("Post-processing profile '{}' not found", profile_id))?; + profile.name = name.to_string(); + let profile = profile.clone(); + + if profile_id != DEFAULT_POST_PROCESS_PROFILE_ID { + if let Some(binding) = settings.bindings.get_mut(&profile.binding_id) { + let renamed = profile_binding(&profile.binding_id, &profile.name); + binding.name = renamed.name; + binding.description = renamed.description; + } + } + Ok(profile) +} + +/// Removes a user-created profile and its binding, returning the binding so +/// the caller can unregister it. The `default` profile cannot be removed. +pub fn remove_post_process_profile( + settings: &mut AppSettings, + profile_id: &str, +) -> Result, String> { + if profile_id == DEFAULT_POST_PROCESS_PROFILE_ID { + return Err("The default post-processing profile cannot be deleted".to_string()); + } + let index = settings + .post_process_profiles + .iter() + .position(|p| p.id == profile_id) + .ok_or_else(|| format!("Post-processing profile '{}' not found", profile_id))?; + let profile = settings.post_process_profiles.remove(index); + Ok(settings.bindings.remove(&profile.binding_id)) +} + +/// Builds the `Default` profile from the pre-profiles flat settings keys. +/// Missing or invalid values fall back to their fresh-install defaults. +fn legacy_post_process_profile(settings_value: &serde_json::Value) -> PostProcessProfile { + fn field(value: &serde_json::Value, key: &str) -> Option { + value + .get(key) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + } + + let mut profile = default_post_process_profile(); + if let Some(v) = field(settings_value, "post_process_provider_id") { + profile.provider_id = v; + } + if let Some(v) = field(settings_value, "post_process_providers") { + profile.providers = v; + } + if let Some(v) = field(settings_value, "post_process_api_keys") { + profile.api_keys = v; + } + if let Some(v) = field(settings_value, "post_process_models") { + profile.models = v; + } + if let Some(v) = field(settings_value, "post_process_prompts") { + profile.prompts = v; + } + if let Some(v) = field(settings_value, "post_process_selected_prompt_id") { + profile.selected_prompt_id = v; + } + profile +} + pub const SETTINGS_STORE_PATH: &str = "settings_store.json"; pub fn get_default_settings() -> AppSettings { @@ -942,12 +1250,7 @@ pub fn get_default_settings() -> AppSettings { auto_submit: default_auto_submit(), auto_submit_key: AutoSubmitKey::default(), post_process_enabled: default_post_process_enabled(), - post_process_provider_id: default_post_process_provider_id(), - post_process_providers: default_post_process_providers(), - post_process_api_keys: default_post_process_api_keys(), - post_process_models: default_post_process_models(), - post_process_prompts: default_post_process_prompts(), - post_process_selected_prompt_id: None, + post_process_profiles: default_post_process_profiles(), mute_while_recording: false, append_trailing_space: false, app_language: default_app_language(), @@ -980,25 +1283,78 @@ impl Default for AppSettings { } impl AppSettings { - pub fn active_post_process_provider(&self) -> Option<&PostProcessProvider> { - self.post_process_providers + pub fn post_process_profile(&self, profile_id: &str) -> Option<&PostProcessProfile> { + self.post_process_profiles .iter() - .find(|provider| provider.id == self.post_process_provider_id) + .find(|profile| profile.id == profile_id) } - pub fn post_process_provider(&self, provider_id: &str) -> Option<&PostProcessProvider> { - self.post_process_providers + pub fn post_process_profile_mut( + &mut self, + profile_id: &str, + ) -> Option<&mut PostProcessProfile> { + self.post_process_profiles + .iter_mut() + .find(|profile| profile.id == profile_id) + } + + /// The profile a post-processing shortcut belongs to. + pub fn post_process_profile_for_binding( + &self, + binding_id: &str, + ) -> Option<&PostProcessProfile> { + self.post_process_profiles .iter() - .find(|provider| provider.id == provider_id) + .find(|profile| profile.binding_id == binding_id) } - pub fn post_process_provider_mut( - &mut self, - provider_id: &str, - ) -> Option<&mut PostProcessProvider> { - self.post_process_providers - .iter_mut() - .find(|provider| provider.id == provider_id) + /// The profile that should post-process a retried history entry: the one + /// recorded with it. History migration assigns Default to entries from + /// before profiles, and deleting a profile clears its id, so a missing id + /// means the profile is gone — a retry then skips post-processing rather + /// than silently sending the transcript to a different provider. + pub fn retry_post_process_profile( + &self, + post_process_requested: bool, + profile_id: Option<&str>, + ) -> Option<&PostProcessProfile> { + if !post_process_requested { + return None; + } + profile_id.and_then(|id| self.post_process_profile(id)) + } + + pub fn is_post_process_binding(&self, binding_id: &str) -> bool { + self.post_process_profile_for_binding(binding_id).is_some() + } + + /// Whether a binding should currently be registered as a global shortcut: + /// cancel is registered only while recording, post-processing bindings + /// only while post-processing is enabled, and unassigned ones never. + pub fn should_register_binding(&self, binding: &ShortcutBinding) -> bool { + binding.id != "cancel" + && !binding.current_binding.trim().is_empty() + && (self.post_process_enabled || !self.is_post_process_binding(&binding.id)) + } + + /// Every built-in binding (as customised by the user) plus each profile's + /// binding, filtered to those that should currently be registered. + pub fn registrable_bindings(&self) -> Vec { + let mut bindings: Vec = get_default_settings() + .bindings + .into_iter() + .map(|(id, default)| self.bindings.get(&id).cloned().unwrap_or(default)) + .collect(); + for profile in &self.post_process_profiles { + if bindings.iter().any(|b| b.id == profile.binding_id) { + continue; + } + if let Some(binding) = self.bindings.get(&profile.binding_id) { + bindings.push(binding.clone()); + } + } + bindings.retain(|binding| self.should_register_binding(binding)); + bindings } } @@ -1052,7 +1408,8 @@ pub fn get_settings(app: &AppHandle) -> AppSettings { default_settings }; - if ensure_post_process_defaults(&mut settings) { + let profiles_changed = ensure_post_process_profiles(&mut settings); + if ensure_post_process_defaults(&mut settings) || profiles_changed { store.set("settings", serde_json::to_value(&settings).unwrap()); } @@ -1155,9 +1512,19 @@ fn apply_settings_migrations( // transcribe.cpp 0.2 replaced integer registry indices with opaque // process-local handles. Clear every old index once. settings.transcribe_gpu_device = default_transcribe_gpu_device(); - settings.settings_schema_version = CURRENT_SETTINGS_SCHEMA_VERSION; updated = true; } + if stored_schema_version < 3 { + // Post-processing profiles replaced the flat `post_process_*` keys. + // Carry the user's existing configuration over as the Default profile. + if settings_value.get("post_process_profiles").is_none() { + settings.post_process_profiles = vec![legacy_post_process_profile(settings_value)]; + } + updated = true; + } + if stored_schema_version < u64::from(CURRENT_SETTINGS_SCHEMA_VERSION) { + settings.settings_schema_version = CURRENT_SETTINGS_SCHEMA_VERSION; + } // The generic GPU choice was removed in favor of Auto or an exact device. // Normalize settings created by builds that exposed that short-lived option. @@ -1413,6 +1780,385 @@ mod tests { // matching legacy mode rather than the new hold-or-toggle default. assert_eq!(settings.shortcut_activation, ShortcutActivation::Toggle); assert_eq!(settings.transcribe_gpu_device, None); + + // The flat post-processing keys become the Default profile. + assert_eq!(settings.post_process_profiles.len(), 1); + let profile = &settings.post_process_profiles[0]; + assert_eq!(profile.id, DEFAULT_POST_PROCESS_PROFILE_ID); + assert_eq!(profile.binding_id, DEFAULT_POST_PROCESS_BINDING_ID); + assert_eq!(profile.models["openai"], "gpt-4o-mini"); + assert_eq!(profile.prompts[0].prompt, "Clean up the transcript."); + } + + /// A pre-profiles store with a customised post-processing setup (the + /// Custom provider pointed at a local server, keys, models, prompts and + /// a selected prompt) must land in the Default profile unchanged. + #[test] + fn profiles_migration_moves_flat_settings_into_default_profile() { + let stored = serde_json::json!({ + "settings_schema_version": 2, + "post_process_enabled": true, + "post_process_provider_id": "custom", + "post_process_providers": [ + { + "id": "custom", + "label": "Custom", + "base_url": "http://localhost:11434/v1", + "allow_base_url_edit": true, + "models_endpoint": "/models", + "supports_structured_output": false + } + ], + "post_process_api_keys": { "custom": "local-key", "openai": "sk-test" }, + "post_process_models": { "custom": "qwen3.6-rewrite", "openai": "gpt-5.6-luna" }, + "post_process_prompts": [ + { "id": "local", "name": "Local", "prompt": "Rewrite: ${output}" }, + { "id": "cloud", "name": "Cloud", "prompt": "Polish: ${output}" } + ], + "post_process_selected_prompt_id": "local" + }); + let mut settings: AppSettings = serde_json::from_value(stored.clone()).unwrap(); + + assert!(apply_settings_migrations(&mut settings, &stored)); + assert_eq!( + settings.settings_schema_version, + CURRENT_SETTINGS_SCHEMA_VERSION + ); + assert!(settings.post_process_enabled); + assert_eq!(settings.post_process_profiles.len(), 1); + + let profile = &settings.post_process_profiles[0]; + assert_eq!(profile.id, "default"); + // Empty: the UI shows "Default" in the current language. + assert_eq!(profile.name, ""); + assert_eq!(profile.binding_id, "transcribe_with_post_process"); + assert_eq!(profile.provider_id, "custom"); + assert_eq!(profile.providers.len(), 1); + assert_eq!( + profile.active_provider().unwrap().base_url, + "http://localhost:11434/v1" + ); + assert_eq!(profile.api_keys["custom"], "local-key"); + assert_eq!(profile.api_keys["openai"], "sk-test"); + assert_eq!(profile.models["custom"], "qwen3.6-rewrite"); + assert_eq!(profile.models["openai"], "gpt-5.6-luna"); + assert_eq!( + profile + .prompts + .iter() + .map(|p| p.id.as_str()) + .collect::>(), + ["local", "cloud"] + ); + assert_eq!(profile.selected_prompt_id.as_deref(), Some("local")); + + // Backfill afterwards adds the missing providers without touching + // the migrated values. + let mut profile = profile.clone(); + assert!(ensure_profile_provider_defaults(&mut profile)); + assert_eq!(profile.providers[0].id, "custom"); + assert_eq!(profile.models["custom"], "qwen3.6-rewrite"); + assert!(profile.provider("anthropic").is_some()); + + // Migration is one-time: a store already at the current schema is + // left alone. + let current = serde_json::to_value(&settings).unwrap(); + let mut again: AppSettings = serde_json::from_value(current.clone()).unwrap(); + assert!(!apply_settings_migrations(&mut again, ¤t)); + assert_eq!( + again.post_process_profiles[0].models["custom"], + "qwen3.6-rewrite" + ); + } + + #[test] + fn profiles_migration_without_legacy_keys_creates_default_profile() { + let stored = serde_json::json!({ "settings_schema_version": 2 }); + let mut settings: AppSettings = serde_json::from_value(stored.clone()).unwrap(); + + apply_settings_migrations(&mut settings, &stored); + assert_eq!(settings.post_process_profiles.len(), 1); + let profile = &settings.post_process_profiles[0]; + assert_eq!(profile.id, DEFAULT_POST_PROCESS_PROFILE_ID); + assert_eq!(profile.provider_id, default_post_process_provider_id()); + assert_eq!(profile.prompts[0].id, "default_improve_transcriptions"); + } + + #[test] + fn fresh_settings_have_only_the_default_profile() { + let mut settings = get_default_settings(); + assert_eq!(settings.post_process_profiles.len(), 1); + assert_eq!(settings.post_process_profiles[0].name, ""); + assert!(!ensure_post_process_profiles(&mut settings)); + } + + #[test] + fn added_profiles_use_lowest_free_number_and_fresh_defaults() { + let mut settings = get_default_settings(); + settings.post_process_profiles[0].provider_id = "custom".to_string(); + + let first = add_post_process_profile(&mut settings, "Default"); + let second = add_post_process_profile(&mut settings, "Default"); + assert_eq!(first.name, "Default 1"); + assert_eq!(second.name, "Default 2"); + assert_ne!(first.id, second.id); + // Not a copy of another tab: fresh-install post-processing defaults. + assert_eq!(first.provider_id, default_post_process_provider_id()); + assert_eq!(first.prompts.len(), default_post_process_prompts().len()); + + // Each profile gets its own binding, unassigned by default. + let binding = &settings.bindings[&first.binding_id]; + assert_eq!(binding.id, first.binding_id); + assert!(binding.current_binding.is_empty()); + assert!(!settings.should_register_binding(binding)); + + remove_post_process_profile(&mut settings, &first.id).unwrap(); + assert!(!settings.bindings.contains_key(&first.binding_id)); + let third = add_post_process_profile(&mut settings, "Default"); + assert_eq!(third.name, "Default 1"); + assert_eq!(settings.post_process_profiles.len(), 3); + assert!(!ensure_post_process_profiles(&mut settings)); + } + + #[test] + fn added_profiles_use_the_localized_base_name() { + let mut settings = get_default_settings(); + assert_eq!( + add_post_process_profile(&mut settings, "Standard").name, + "Standard 1" + ); + assert_eq!( + add_post_process_profile(&mut settings, " Standard ").name, + "Standard 2" + ); + // Numbering is per base name: another language starts again at 1. + assert_eq!( + add_post_process_profile(&mut settings, "Par défaut").name, + "Par défaut 1" + ); + assert_eq!( + add_post_process_profile(&mut settings, "").name, + "Default 1" + ); + } + + #[test] + fn profiles_can_be_renamed() { + let mut settings = get_default_settings(); + let extra = add_post_process_profile(&mut settings, "Default"); + + let renamed = rename_post_process_profile(&mut settings, &extra.id, " Cloud ").unwrap(); + assert_eq!(renamed.name, "Cloud"); + assert_eq!( + settings.post_process_profile(&extra.id).unwrap().name, + "Cloud" + ); + assert!(settings.bindings[&extra.binding_id].name.contains("Cloud")); + + assert!(rename_post_process_profile(&mut settings, &extra.id, " ").is_err()); + assert!(rename_post_process_profile(&mut settings, &extra.id, &"x".repeat(41)).is_err()); + assert!(rename_post_process_profile(&mut settings, "missing", "Name").is_err()); + + // The built-in profile can be renamed, and cleared back to its + // localized default label; its built-in binding is left alone. + let before = settings.bindings[DEFAULT_POST_PROCESS_BINDING_ID] + .name + .clone(); + rename_post_process_profile(&mut settings, DEFAULT_POST_PROCESS_PROFILE_ID, "Local") + .unwrap(); + assert_eq!(settings.post_process_profiles[0].name, "Local"); + rename_post_process_profile(&mut settings, DEFAULT_POST_PROCESS_PROFILE_ID, "").unwrap(); + assert_eq!(settings.post_process_profiles[0].name, ""); + assert_eq!( + settings.bindings[DEFAULT_POST_PROCESS_BINDING_ID].name, + before + ); + } + + #[test] + fn one_malformed_profile_does_not_drop_the_others() { + let mut stored = default_settings_json(); + let mut extra = get_default_settings(); + let profile = add_post_process_profile(&mut extra, "Default"); + let mut good = serde_json::to_value(&profile).unwrap(); + good["api_keys"]["openai"] = serde_json::json!("sk-keep-me"); + stored["post_process_profiles"] = serde_json::json!([ + serde_json::to_value(&extra.post_process_profiles[0]).unwrap(), + { "id": 42, "name": ["broken"] }, + good + ]); + + let settings: AppSettings = serde_json::from_value(stored) + .expect("a malformed profile must not fail the whole settings load"); + assert_eq!(settings.post_process_profiles.len(), 2); + assert_eq!( + settings.post_process_profiles[1].api_keys["openai"], + "sk-keep-me" + ); + } + + #[test] + fn history_retry_reuses_the_recorded_profile() { + let mut settings = get_default_settings(); + let extra = add_post_process_profile(&mut settings, "Default"); + + let retry_id = |s: &AppSettings, requested: bool, id: Option<&str>| { + s.retry_post_process_profile(requested, id) + .map(|profile| profile.id.clone()) + }; + assert_eq!(retry_id(&settings, false, Some(&extra.id)), None); + assert_eq!( + retry_id(&settings, true, Some(&extra.id)), + Some(extra.id.clone()) + ); + // No recorded profile (it was deleted): no fallback to Default. + assert_eq!(retry_id(&settings, true, None), None); + // A deleted profile never falls back to another provider. + remove_post_process_profile(&mut settings, &extra.id).unwrap(); + assert_eq!(retry_id(&settings, true, Some(&extra.id)), None); + } + + #[test] + fn default_profile_cannot_be_removed() { + let mut settings = get_default_settings(); + assert!( + remove_post_process_profile(&mut settings, DEFAULT_POST_PROCESS_PROFILE_ID).is_err() + ); + assert!(remove_post_process_profile(&mut settings, "missing").is_err()); + assert_eq!(settings.post_process_profiles.len(), 1); + assert!(settings + .bindings + .contains_key(DEFAULT_POST_PROCESS_BINDING_ID)); + } + + #[test] + fn profile_invariants_are_restored_on_load() { + let mut settings = get_default_settings(); + let extra = add_post_process_profile(&mut settings, "Default"); + + // Lose the default profile, the extra profile's binding, and leave an + // orphan binding from a profile that no longer exists. + settings + .post_process_profiles + .retain(|p| p.id != DEFAULT_POST_PROCESS_PROFILE_ID); + settings.bindings.remove(&extra.binding_id); + settings.bindings.insert( + "post_process_profile_gone".to_string(), + profile_binding("post_process_profile_gone", "Gone"), + ); + + assert!(ensure_post_process_profiles(&mut settings)); + assert_eq!( + settings.post_process_profiles[0].id, + DEFAULT_POST_PROCESS_PROFILE_ID + ); + assert_eq!( + settings + .post_process_profiles + .iter() + .filter(|p| p.id == DEFAULT_POST_PROCESS_PROFILE_ID) + .count(), + 1 + ); + assert!(settings.bindings.contains_key(&extra.binding_id)); + assert!(!settings.bindings.contains_key("post_process_profile_gone")); + // Built-in bindings are never treated as orphans. + assert!(settings.bindings.contains_key("transcribe")); + assert!(settings.bindings.contains_key("cancel")); + assert!(!ensure_post_process_profiles(&mut settings)); + } + + #[test] + fn a_profile_claiming_the_default_binding_never_evicts_default() { + let mut settings = get_default_settings(); + settings.post_process_profiles[0] + .api_keys + .insert("openai".to_string(), "sk-keep-me".to_string()); + let mut hijack = settings.post_process_profiles[0].clone(); + hijack.id = "hijack".to_string(); + settings.post_process_profiles.insert(0, hijack); + + assert!(ensure_post_process_profiles(&mut settings)); + assert_eq!(settings.post_process_profiles.len(), 1); + let default = &settings.post_process_profiles[0]; + assert_eq!(default.id, DEFAULT_POST_PROCESS_PROFILE_ID); + assert_eq!(default.api_keys["openai"], "sk-keep-me"); + } + + #[test] + fn duplicate_profiles_are_dropped() { + let mut settings = get_default_settings(); + let extra = add_post_process_profile(&mut settings, "Default"); + settings.post_process_profiles.push(extra.clone()); + let mut hijack = extra.clone(); + hijack.id = "hijack".to_string(); + hijack.binding_id = DEFAULT_POST_PROCESS_BINDING_ID.to_string(); + settings.post_process_profiles.push(hijack); + + assert!(ensure_post_process_profiles(&mut settings)); + assert_eq!(settings.post_process_profiles.len(), 2); + assert_eq!( + settings + .post_process_profile_for_binding(DEFAULT_POST_PROCESS_BINDING_ID) + .unwrap() + .id, + DEFAULT_POST_PROCESS_PROFILE_ID + ); + } + + #[test] + fn profiles_resolve_by_binding_id() { + let mut settings = get_default_settings(); + let mut extra = add_post_process_profile(&mut settings, "Default"); + extra.provider_id = "custom".to_string(); + *settings.post_process_profile_mut(&extra.id).unwrap() = extra.clone(); + + let resolved = settings + .post_process_profile_for_binding(&extra.binding_id) + .unwrap(); + assert_eq!(resolved.id, extra.id); + assert_eq!(resolved.provider_id, "custom"); + assert_eq!( + settings + .post_process_profile_for_binding(DEFAULT_POST_PROCESS_BINDING_ID) + .unwrap() + .id, + DEFAULT_POST_PROCESS_PROFILE_ID + ); + assert!(settings + .post_process_profile_for_binding("transcribe") + .is_none()); + assert!(settings.is_post_process_binding(&extra.binding_id)); + assert!(!settings.is_post_process_binding("cancel")); + } + + #[test] + fn registrable_bindings_follow_post_process_toggle() { + let mut settings = get_default_settings(); + let extra = add_post_process_profile(&mut settings, "Default"); + settings + .bindings + .get_mut(&extra.binding_id) + .unwrap() + .current_binding = "f6".to_string(); + + let ids = |s: &AppSettings| { + let mut ids: Vec = s.registrable_bindings().into_iter().map(|b| b.id).collect(); + ids.sort(); + ids + }; + + settings.post_process_enabled = false; + assert_eq!(ids(&settings), ["transcribe"]); + + settings.post_process_enabled = true; + let mut expected = vec![ + "transcribe".to_string(), + DEFAULT_POST_PROCESS_BINDING_ID.to_string(), + extra.binding_id.clone(), + ]; + expected.sort(); + assert_eq!(ids(&settings), expected); } #[test] @@ -1706,16 +2452,13 @@ mod tests { #[test] fn debug_output_redacts_api_keys() { let mut settings = get_default_settings(); - settings - .post_process_api_keys - .insert("openai".to_string(), "sk-proj-secret-key-12345".to_string()); - settings.post_process_api_keys.insert( + let api_keys = &mut settings.post_process_profiles[0].api_keys; + api_keys.insert("openai".to_string(), "sk-proj-secret-key-12345".to_string()); + api_keys.insert( "anthropic".to_string(), "sk-ant-secret-key-67890".to_string(), ); - settings - .post_process_api_keys - .insert("empty_provider".to_string(), "".to_string()); + api_keys.insert("empty_provider".to_string(), "".to_string()); let debug_output = format!("{:?}", settings); diff --git a/src-tauri/src/shortcut/handler.rs b/src-tauri/src/shortcut/handler.rs index 3365b3fbe5..d57fe51f3d 100644 --- a/src-tauri/src/shortcut/handler.rs +++ b/src-tauri/src/shortcut/handler.rs @@ -35,7 +35,7 @@ pub fn handle_shortcut_event( let settings = get_settings(app); // Transcribe bindings are handled by the coordinator. - if is_transcribe_binding(binding_id) { + if is_transcribe_binding(binding_id, &settings) { if let Some(coordinator) = app.try_state::() { coordinator.send_input( binding_id, diff --git a/src-tauri/src/shortcut/handy_keys.rs b/src-tauri/src/shortcut/handy_keys.rs index bd8e562e50..4c4b596a30 100644 --- a/src-tauri/src/shortcut/handy_keys.rs +++ b/src-tauri/src/shortcut/handy_keys.rs @@ -425,29 +425,15 @@ pub fn validate_shortcut(raw: &str) -> Result<(), String> { pub fn init_shortcuts(app: &AppHandle) -> Result<(), String> { let state = HandyKeysState::new(app.clone())?; - let default_bindings = settings::get_default_settings().bindings; let user_settings = settings::load_or_create_app_settings(app); - // Register all bindings except cancel (which is dynamic) - for (id, default_binding) in default_bindings { - if id == "cancel" { - continue; - } - // Skip post-processing shortcut when the feature is disabled - if id == "transcribe_with_post_process" && !user_settings.post_process_enabled { - continue; - } - - let binding = user_settings - .bindings - .get(&id) - .cloned() - .unwrap_or(default_binding); - + // Register all default and profile bindings except cancel (which is + // dynamic); post-processing ones only when the feature is enabled. + for binding in user_settings.registrable_bindings() { if let Err(e) = state.register(&binding) { error!( "Failed to register handy-keys shortcut {} during init: {}", - id, e + binding.id, e ); } } diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index 6498cc0c95..c0092f99cc 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -22,8 +22,9 @@ use tauri::{AppHandle, Emitter, Manager}; use crate::settings::APPLE_INTELLIGENCE_DEFAULT_MODEL_ID; use crate::settings::{ self, get_settings, AutoSubmitKey, ClipboardHandling, KeyboardImplementation, LLMPrompt, - OverlayPosition, OverlayStyle, PasteMethod, ShortcutActivation, ShortcutBinding, SoundTheme, - Theme, TypingTool, VadBackend, APPLE_INTELLIGENCE_PROVIDER_ID, + OverlayPosition, OverlayStyle, PasteMethod, PostProcessProfile, ShortcutActivation, + ShortcutBinding, SoundTheme, Theme, TypingTool, VadBackend, APPLE_INTELLIGENCE_PROVIDER_ID, + DEFAULT_POST_PROCESS_BINDING_ID, }; use crate::tray; @@ -115,13 +116,34 @@ pub fn change_binding( id: String, binding: String, ) -> Result { - // Reject empty bindings — every shortcut should have a value + let mut settings = settings::get_settings(&app); + + // Reject empty bindings — every shortcut should have a value, except a + // user-created post-processing profile's, which may be left unassigned + // (resetting it restores that unassigned default). if binding.trim().is_empty() { - return Err("Binding cannot be empty".to_string()); + if id == DEFAULT_POST_PROCESS_BINDING_ID || !settings.is_post_process_binding(&id) { + return Err("Binding cannot be empty".to_string()); + } + let Some(mut cleared) = settings.bindings.get(&id).cloned() else { + return Err(format!("Binding with id '{}' not found", id)); + }; + if settings.should_register_binding(&cleared) { + if let Err(e) = unregister_shortcut(&app, cleared.clone()) { + warn!("change_binding: failed to unregister '{}': {}", id, e); + } + } + cleared.current_binding = String::new(); + settings.bindings.insert(id, cleared.clone()); + settings::write_settings(&app, settings); + crate::secure_input::reconcile_fallback(&app); + return Ok(BindingResponse { + success: true, + binding: Some(cleared), + error: None, + }); } - let mut settings = settings::get_settings(&app); - // Get the binding to modify, or create it from defaults if it doesn't exist let binding_to_modify = match settings.bindings.get(&id) { Some(binding) => binding.clone(), @@ -165,17 +187,23 @@ pub fn change_binding( } } - // Unregister the existing binding - if let Err(e) = unregister_shortcut(&app, binding_to_modify.clone()) { - let error_msg = format!("Failed to unregister shortcut: {}", e); - error!("change_binding error: {}", error_msg); + // Unregister the existing binding (an unassigned profile shortcut has + // nothing registered) + let was_assigned = !binding_to_modify.current_binding.trim().is_empty(); + if was_assigned { + if let Err(e) = unregister_shortcut(&app, binding_to_modify.clone()) { + let error_msg = format!("Failed to unregister shortcut: {}", e); + error!("change_binding error: {}", error_msg); + } } // Validate the new shortcut for the current keyboard implementation if let Err(e) = validate_shortcut_for_implementation(&binding, settings.keyboard_implementation) { warn!("change_binding validation error: {}", e); - restore_registration(&app, &binding_to_modify); + if was_assigned { + restore_registration(&app, &binding_to_modify); + } return Err(e); } @@ -187,7 +215,9 @@ pub fn change_binding( if let Err(e) = register_shortcut(&app, updated_binding.clone()) { let error_msg = format!("Failed to register shortcut: {}", e); error!("change_binding error: {}", error_msg); - restore_registration(&app, &binding_to_modify); + if was_assigned { + restore_registration(&app, &binding_to_modify); + } return Ok(BindingResponse { success: false, binding: None, @@ -234,7 +264,8 @@ pub fn reset_binding(app: AppHandle, id: String) -> Result Res settings.post_process_enabled = enabled; settings::write_settings(&app, settings.clone()); - // Register or unregister the post-processing shortcut - if let Some(binding) = settings - .bindings - .get("transcribe_with_post_process") - .cloned() - { + // Register or unregister every profile's post-processing shortcut + for profile in &settings.post_process_profiles { + let Some(binding) = settings.bindings.get(&profile.binding_id).cloned() else { + continue; + }; + if binding.current_binding.trim().is_empty() { + continue; + } if enabled { let _ = register_shortcut(&app, binding); } else { @@ -1031,21 +1057,33 @@ pub fn change_experimental_enabled_setting(app: AppHandle, enabled: bool) -> Res Ok(()) } +/// Look up a post-processing profile for mutation. +fn profile_mut<'a>( + settings: &'a mut settings::AppSettings, + profile_id: &str, +) -> Result<&'a mut PostProcessProfile, String> { + settings + .post_process_profile_mut(profile_id) + .ok_or_else(|| format!("Post-processing profile '{}' not found", profile_id)) +} + #[tauri::command] #[specta::specta] pub fn change_post_process_base_url_setting( app: AppHandle, + profile_id: String, provider_id: String, base_url: String, ) -> Result<(), String> { let mut settings = settings::get_settings(&app); - let label = settings - .post_process_provider(&provider_id) + let profile = profile_mut(&mut settings, &profile_id)?; + let label = profile + .provider(&provider_id) .map(|provider| provider.label.clone()) .ok_or_else(|| format!("Provider '{}' not found", provider_id))?; - let provider = settings - .post_process_provider_mut(&provider_id) + let provider = profile + .provider_mut(&provider_id) .expect("Provider looked up above must exist"); if provider.id != "custom" { @@ -1061,12 +1099,9 @@ pub fn change_post_process_base_url_setting( } /// Generic helper to validate provider exists -fn validate_provider_exists( - settings: &settings::AppSettings, - provider_id: &str, -) -> Result<(), String> { - if !settings - .post_process_providers +fn validate_provider_exists(profile: &PostProcessProfile, provider_id: &str) -> Result<(), String> { + if !profile + .providers .iter() .any(|provider| provider.id == provider_id) { @@ -1079,12 +1114,14 @@ fn validate_provider_exists( #[specta::specta] pub fn change_post_process_api_key_setting( app: AppHandle, + profile_id: String, provider_id: String, api_key: String, ) -> Result<(), String> { let mut settings = settings::get_settings(&app); - validate_provider_exists(&settings, &provider_id)?; - settings.post_process_api_keys.insert(provider_id, api_key); + let profile = profile_mut(&mut settings, &profile_id)?; + validate_provider_exists(profile, &provider_id)?; + profile.api_keys.insert(provider_id, api_key); settings::write_settings(&app, settings); Ok(()) } @@ -1093,22 +1130,29 @@ pub fn change_post_process_api_key_setting( #[specta::specta] pub fn change_post_process_model_setting( app: AppHandle, + profile_id: String, provider_id: String, model: String, ) -> Result<(), String> { let mut settings = settings::get_settings(&app); - validate_provider_exists(&settings, &provider_id)?; - settings.post_process_models.insert(provider_id, model); + let profile = profile_mut(&mut settings, &profile_id)?; + validate_provider_exists(profile, &provider_id)?; + profile.models.insert(provider_id, model); settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] -pub fn set_post_process_provider(app: AppHandle, provider_id: String) -> Result<(), String> { +pub fn set_post_process_provider( + app: AppHandle, + profile_id: String, + provider_id: String, +) -> Result<(), String> { let mut settings = settings::get_settings(&app); - validate_provider_exists(&settings, &provider_id)?; - settings.post_process_provider_id = provider_id; + let profile = profile_mut(&mut settings, &profile_id)?; + validate_provider_exists(profile, &provider_id)?; + profile.provider_id = provider_id; settings::write_settings(&app, settings); Ok(()) } @@ -1117,10 +1161,12 @@ pub fn set_post_process_provider(app: AppHandle, provider_id: String) -> Result< #[specta::specta] pub fn add_post_process_prompt( app: AppHandle, + profile_id: String, name: String, prompt: String, ) -> Result { let mut settings = settings::get_settings(&app); + let profile = profile_mut(&mut settings, &profile_id)?; // Generate unique ID using timestamp and random component let id = format!("prompt_{}", chrono::Utc::now().timestamp_millis()); @@ -1131,7 +1177,7 @@ pub fn add_post_process_prompt( prompt, }; - settings.post_process_prompts.push(new_prompt.clone()); + profile.prompts.push(new_prompt.clone()); settings::write_settings(&app, settings); Ok(new_prompt) @@ -1141,17 +1187,15 @@ pub fn add_post_process_prompt( #[specta::specta] pub fn update_post_process_prompt( app: AppHandle, + profile_id: String, id: String, name: String, prompt: String, ) -> Result<(), String> { let mut settings = settings::get_settings(&app); + let profile = profile_mut(&mut settings, &profile_id)?; - if let Some(existing_prompt) = settings - .post_process_prompts - .iter_mut() - .find(|p| p.id == id) - { + if let Some(existing_prompt) = profile.prompts.iter_mut().find(|p| p.id == id) { existing_prompt.name = name; existing_prompt.prompt = prompt; settings::write_settings(&app, settings); @@ -1163,26 +1207,30 @@ pub fn update_post_process_prompt( #[tauri::command] #[specta::specta] -pub fn delete_post_process_prompt(app: AppHandle, id: String) -> Result<(), String> { +pub fn delete_post_process_prompt( + app: AppHandle, + profile_id: String, + id: String, +) -> Result<(), String> { let mut settings = settings::get_settings(&app); + let profile = profile_mut(&mut settings, &profile_id)?; // Don't allow deleting the last prompt - if settings.post_process_prompts.len() <= 1 { + if profile.prompts.len() <= 1 { return Err("Cannot delete the last prompt".to_string()); } // Find and remove the prompt - let original_len = settings.post_process_prompts.len(); - settings.post_process_prompts.retain(|p| p.id != id); + let original_len = profile.prompts.len(); + profile.prompts.retain(|p| p.id != id); - if settings.post_process_prompts.len() == original_len { + if profile.prompts.len() == original_len { return Err(format!("Prompt with id '{}' not found", id)); } // If the deleted prompt was selected, select the first one or None - if settings.post_process_selected_prompt_id.as_ref() == Some(&id) { - settings.post_process_selected_prompt_id = - settings.post_process_prompts.first().map(|p| p.id.clone()); + if profile.selected_prompt_id.as_ref() == Some(&id) { + profile.selected_prompt_id = profile.prompts.first().map(|p| p.id.clone()); } settings::write_settings(&app, settings); @@ -1193,15 +1241,17 @@ pub fn delete_post_process_prompt(app: AppHandle, id: String) -> Result<(), Stri #[specta::specta] pub async fn fetch_post_process_models( app: AppHandle, + profile_id: String, provider_id: String, ) -> Result, String> { let settings = settings::get_settings(&app); + let profile = settings + .post_process_profile(&profile_id) + .ok_or_else(|| format!("Post-processing profile '{}' not found", profile_id))?; // Find the provider - let provider = settings - .post_process_providers - .iter() - .find(|p| p.id == provider_id) + let provider = profile + .provider(&provider_id) .ok_or_else(|| format!("Provider '{}' not found", provider_id))?; if provider.id == APPLE_INTELLIGENCE_PROVIDER_ID { @@ -1217,8 +1267,8 @@ pub async fn fetch_post_process_models( } // Get API key - let api_key = settings - .post_process_api_keys + let api_key = profile + .api_keys .get(&provider_id) .cloned() .unwrap_or_default(); @@ -1236,19 +1286,92 @@ pub async fn fetch_post_process_models( #[tauri::command] #[specta::specta] -pub fn set_post_process_selected_prompt(app: AppHandle, id: String) -> Result<(), String> { +pub fn set_post_process_selected_prompt( + app: AppHandle, + profile_id: String, + id: String, +) -> Result<(), String> { let mut settings = settings::get_settings(&app); + let profile = profile_mut(&mut settings, &profile_id)?; // Verify the prompt exists - if !settings.post_process_prompts.iter().any(|p| p.id == id) { + if !profile.prompts.iter().any(|p| p.id == id) { return Err(format!("Prompt with id '{}' not found", id)); } - settings.post_process_selected_prompt_id = Some(id); + profile.selected_prompt_id = Some(id); settings::write_settings(&app, settings); Ok(()) } +/// Create a new post-processing profile (tab) named ` N` (the UI +/// passes the localized word for "Default"), with fresh-install defaults and +/// an unassigned shortcut. +#[tauri::command] +#[specta::specta] +pub fn add_post_process_profile( + app: AppHandle, + base_name: String, +) -> Result { + let mut settings = settings::get_settings(&app); + let profile = settings::add_post_process_profile(&mut settings, &base_name); + settings::write_settings(&app, settings); + Ok(profile) +} + +/// Rename a post-processing profile (tab). An empty name for the built-in +/// profile restores its localized "Default" label. +#[tauri::command] +#[specta::specta] +pub fn rename_post_process_profile( + app: AppHandle, + profile_id: String, + name: String, +) -> Result { + let mut settings = settings::get_settings(&app); + let profile = settings::rename_post_process_profile(&mut settings, &profile_id, &name)?; + settings::write_settings(&app, settings); + Ok(profile) +} + +/// Delete a post-processing profile and unregister its shortcut. The +/// built-in `default` profile cannot be deleted. +#[tauri::command] +#[specta::specta] +pub fn delete_post_process_profile(app: AppHandle, profile_id: String) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + let binding = settings::remove_post_process_profile(&mut settings, &profile_id)?; + // Only a registered shortcut may be unregistered: the Tauri backend + // unregisters by key, so touching an unregistered one could drop + // another binding that uses the same key. + let was_registered = binding + .as_ref() + .is_some_and(|b| settings.post_process_enabled && !b.current_binding.trim().is_empty()); + settings::write_settings(&app, settings); + + if let Some(history) = + app.try_state::>() + { + if let Err(e) = history.clear_post_process_profile(&profile_id) { + error!( + "Failed to detach history entries from deleted profile '{}': {}", + profile_id, e + ); + } + } + + if let Some(binding) = binding.filter(|_| was_registered) { + if let Err(e) = unregister_shortcut(&app, binding) { + warn!( + "Failed to unregister shortcut of deleted profile '{}': {}", + profile_id, e + ); + } + } + crate::secure_input::reconcile_fallback(&app); + Ok(()) +} + #[tauri::command] #[specta::specta] pub fn change_mute_while_recording_setting(app: AppHandle, enabled: bool) -> Result<(), String> { diff --git a/src-tauri/src/shortcut/tauri_impl.rs b/src-tauri/src/shortcut/tauri_impl.rs index 6e19c03c1e..e8df6b63fc 100644 --- a/src-tauri/src/shortcut/tauri_impl.rs +++ b/src-tauri/src/shortcut/tauri_impl.rs @@ -15,24 +15,12 @@ use super::handler::handle_shortcut_event; /// Initialize shortcuts using Tauri's global-shortcut plugin pub fn init_shortcuts(app: &AppHandle) { - let default_bindings = settings::get_default_settings().bindings; let user_settings = settings::load_or_create_app_settings(app); - // Register all default shortcuts, applying user customizations - for (id, default_binding) in default_bindings { - if id == "cancel" { - continue; // Skip cancel shortcut, it will be registered dynamically - } - // Skip post-processing shortcut when the feature is disabled - if id == "transcribe_with_post_process" && !user_settings.post_process_enabled { - continue; - } - let binding = user_settings - .bindings - .get(&id) - .cloned() - .unwrap_or(default_binding); - + // Register all default and profile shortcuts, applying user customizations. + // Cancel is registered dynamically; post-processing ones only when enabled. + for binding in user_settings.registrable_bindings() { + let id = binding.id.clone(); if let Err(e) = register_shortcut(app, binding) { error!("Failed to register shortcut {} during init: {}", id, e); } diff --git a/src-tauri/src/transcription_coordinator.rs b/src-tauri/src/transcription_coordinator.rs index a35bff37ea..fad21e65e7 100644 --- a/src-tauri/src/transcription_coordinator.rs +++ b/src-tauri/src/transcription_coordinator.rs @@ -1,4 +1,4 @@ -use crate::actions::ACTION_MAP; +use crate::actions::transcribe_action; use crate::managers::audio::AudioRecordingManager; use crate::settings::ShortcutActivation; use log::{debug, error, warn}; @@ -534,8 +534,10 @@ pub struct TranscriptionCoordinator { tx: Sender, } -pub fn is_transcribe_binding(id: &str) -> bool { - id == "transcribe" || id == "transcribe_with_post_process" +/// Whether a binding starts a recording: plain "transcribe" or the shortcut +/// of any post-processing profile. +pub fn is_transcribe_binding(id: &str, settings: &crate::settings::AppSettings) -> bool { + id == "transcribe" || settings.is_post_process_binding(id) } impl TranscriptionCoordinator { @@ -687,10 +689,7 @@ fn run_effect(app: &AppHandle, state: &mut CoordinatorState, effect: Effect) { /// Execute a start effect; returns whether recording actually began, so the /// state machine can roll back its optimistic transition on failure. fn start(app: &AppHandle, binding_id: &str, hotkey_string: &str) -> bool { - let Some(action) = ACTION_MAP.get(binding_id) else { - warn!("No action in ACTION_MAP for '{binding_id}'"); - return false; - }; + let action = transcribe_action(binding_id); action.start(app, binding_id, hotkey_string); let recording = app .try_state::>() @@ -702,10 +701,7 @@ fn start(app: &AppHandle, binding_id: &str, hotkey_string: &str) -> bool { } fn stop(app: &AppHandle, binding_id: &str, hotkey_string: &str) { - let Some(action) = ACTION_MAP.get(binding_id) else { - warn!("No action in ACTION_MAP for '{binding_id}'"); - return; - }; + let action = transcribe_action(binding_id); action.stop(app, binding_id, hotkey_string); } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 8db5eb71bb..807c42c10e 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -682,6 +682,7 @@ mod tests { post_processed_text: post_processed.map(|text| text.to_string()), post_process_prompt: None, post_process_requested: false, + post_process_profile_id: None, } } diff --git a/src/bindings.ts b/src/bindings.ts index c6d3953515..2b33600892 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -232,73 +232,110 @@ async changeExperimentalEnabledSetting(enabled: boolean) : Promise> { +async changePostProcessBaseUrlSetting(profileId: string, providerId: string, baseUrl: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("change_post_process_base_url_setting", { providerId, baseUrl }) }; + return { status: "ok", data: await TAURI_INVOKE("change_post_process_base_url_setting", { profileId, providerId, baseUrl }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, -async changePostProcessApiKeySetting(providerId: string, apiKey: string) : Promise> { +async changePostProcessApiKeySetting(profileId: string, providerId: string, apiKey: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("change_post_process_api_key_setting", { providerId, apiKey }) }; + return { status: "ok", data: await TAURI_INVOKE("change_post_process_api_key_setting", { profileId, providerId, apiKey }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, -async changePostProcessModelSetting(providerId: string, model: string) : Promise> { +async changePostProcessModelSetting(profileId: string, providerId: string, model: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("change_post_process_model_setting", { providerId, model }) }; + return { status: "ok", data: await TAURI_INVOKE("change_post_process_model_setting", { profileId, providerId, model }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, -async setPostProcessProvider(providerId: string) : Promise> { +async setPostProcessProvider(profileId: string, providerId: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("set_post_process_provider", { providerId }) }; + return { status: "ok", data: await TAURI_INVOKE("set_post_process_provider", { profileId, providerId }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, -async fetchPostProcessModels(providerId: string) : Promise> { +async fetchPostProcessModels(profileId: string, providerId: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("fetch_post_process_models", { providerId }) }; + return { status: "ok", data: await TAURI_INVOKE("fetch_post_process_models", { profileId, providerId }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, -async addPostProcessPrompt(name: string, prompt: string) : Promise> { +async addPostProcessPrompt(profileId: string, name: string, prompt: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("add_post_process_prompt", { name, prompt }) }; + return { status: "ok", data: await TAURI_INVOKE("add_post_process_prompt", { profileId, name, prompt }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, -async updatePostProcessPrompt(id: string, name: string, prompt: string) : Promise> { +async updatePostProcessPrompt(profileId: string, id: string, name: string, prompt: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("update_post_process_prompt", { id, name, prompt }) }; + return { status: "ok", data: await TAURI_INVOKE("update_post_process_prompt", { profileId, id, name, prompt }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, -async deletePostProcessPrompt(id: string) : Promise> { +async deletePostProcessPrompt(profileId: string, id: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("delete_post_process_prompt", { id }) }; + return { status: "ok", data: await TAURI_INVOKE("delete_post_process_prompt", { profileId, id }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, -async setPostProcessSelectedPrompt(id: string) : Promise> { +async setPostProcessSelectedPrompt(profileId: string, id: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("set_post_process_selected_prompt", { id }) }; + return { status: "ok", data: await TAURI_INVOKE("set_post_process_selected_prompt", { profileId, id }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Create a new post-processing profile (tab) named ` N` (the UI + * passes the localized word for "Default"), with fresh-install defaults and + * an unassigned shortcut. + */ +async addPostProcessProfile(baseName: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("add_post_process_profile", { baseName }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Delete a post-processing profile and unregister its shortcut. The + * built-in `default` profile cannot be deleted. + */ +async deletePostProcessProfile(profileId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("delete_post_process_profile", { profileId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Rename a post-processing profile (tab). An empty name for the built-in + * profile restores its localized "Default" label. + */ +async renamePostProcessProfile(profileId: string, name: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("rename_post_process_profile", { profileId, name }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -982,7 +1019,12 @@ whats_new_last_seen_version?: string; selected_model?: string; onboarding_comple * Which input channel to use on the selected microphone device. * None means "average all channels" (original behavior). */ -selected_channel?: number | 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; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; 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; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; +selected_channel?: number | 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; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; +/** + * Replaces the pre-profiles flat `post_process_*` fields, which are + * migrated into the `Default` profile in `apply_settings_migrations`. + */ +post_process_profiles?: PostProcessProfile[]; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; /** * Debug-gated ("beta") receipt-sequenced paste: restore the clipboard only * after the target app actually reads the transcript, instead of after a @@ -1019,7 +1061,14 @@ export type EngineType = */ "TranscribeCpp" | "Parakeet" | "Moonshine" | "MoonshineStreaming" | "SenseVoice" | "GigaAM" | "Canary" | "Cohere" export type GpuDeviceOption = { id: string; name: string; total_vram_mb: number } -export type HistoryEntry = { id: number; file_name: string; timestamp: number; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null; post_process_requested: boolean } +export type HistoryEntry = { id: number; file_name: string; timestamp: number; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null; post_process_requested: boolean; +/** + * Post-processing profile that processed this entry; `None` for entries + * from before profiles, which used what is now the Default profile. + * Post-processing profile that processed this entry. `None` if it was + * not post-processed or its profile has since been deleted. + */ +post_process_profile_id: string | null } export type HistoryUpdatePayload = { action: "added"; entry: HistoryEntry } | { action: "updated"; entry: HistoryEntry } | { action: "deleted"; id: number } | { action: "toggled"; id: number } /** * Result of changing keyboard implementation @@ -1076,6 +1125,20 @@ export type OverlayStyle = "none" | "minimal" | "live" export type PaginatedHistory = { entries: HistoryEntry[]; has_more: boolean } export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" | "external_script" export type PermissionAccess = "allowed" | "denied" | "unknown" +/** + * One post-processing configuration with its own global shortcut. Each + * profile is a full, independent copy of the post-processing settings. + */ +export type PostProcessProfile = { id: string; +/** + * Tab label. Empty for the built-in profile until the user renames it, + * so the UI can show "Default" in the current language. + */ +name?: string; +/** + * Key into `AppSettings::bindings`. + */ +binding_id: string; provider_id?: string; providers?: PostProcessProvider[]; api_keys?: SecretMap; models?: Partial<{ [key in string]: string }>; prompts?: LLMPrompt[]; selected_prompt_id?: string | null } export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" export type SecretMap = Partial<{ [key in string]: string }> diff --git a/src/components/settings/GlobalShortcutInput.tsx b/src/components/settings/GlobalShortcutInput.tsx index 71997fc219..a375234d68 100644 --- a/src/components/settings/GlobalShortcutInput.tsx +++ b/src/components/settings/GlobalShortcutInput.tsx @@ -17,6 +17,10 @@ interface GlobalShortcutInputProps { grouped?: boolean; shortcutId: string; disabled?: boolean; + /** Overrides the binding's translated name, e.g. for runtime bindings. */ + title?: string; + /** Overrides the binding's translated description. */ + description?: string; } export const GlobalShortcutInput: React.FC = ({ @@ -24,6 +28,8 @@ export const GlobalShortcutInput: React.FC = ({ grouped = false, shortcutId, disabled = false, + title, + description, }) => { const { t } = useTranslation(); const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } = @@ -257,14 +263,15 @@ export const GlobalShortcutInput: React.FC = ({ } // Get translated name and description for the binding - const translatedName = t( - `settings.general.shortcut.bindings.${shortcutId}.name`, - binding.name, - ); - const translatedDescription = t( - `settings.general.shortcut.bindings.${shortcutId}.description`, - binding.description, - ); + const translatedName = + title ?? + t(`settings.general.shortcut.bindings.${shortcutId}.name`, binding.name); + const translatedDescription = + description ?? + t( + `settings.general.shortcut.bindings.${shortcutId}.description`, + binding.description, + ); return ( = ({ className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded-md cursor-pointer hover:border-logo-primary" onClick={() => startRecording(shortcutId)} > - {formatKeyCombination(binding.current_binding, osType)} + {binding.current_binding + ? formatKeyCombination(binding.current_binding, osType) + : t("settings.general.shortcut.unassigned")} )} = ({ grouped = false, shortcutId, disabled = false, + title, + description, }) => { const { t } = useTranslation(); const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } = @@ -305,14 +311,15 @@ export const HandyKeysShortcutInput: React.FC = ({ } // Get translated name and description for the binding - const translatedName = t( - `settings.general.shortcut.bindings.${shortcutId}.name`, - binding.name, - ); - const translatedDescription = t( - `settings.general.shortcut.bindings.${shortcutId}.description`, - binding.description, - ); + const translatedName = + title ?? + t(`settings.general.shortcut.bindings.${shortcutId}.name`, binding.name); + const translatedDescription = + description ?? + t( + `settings.general.shortcut.bindings.${shortcutId}.description`, + binding.description, + ); return ( = ({ className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded-md cursor-pointer hover:border-logo-primary" onClick={startRecording} > - {formatKeyCombination(binding.current_binding, osType)} + {binding.current_binding + ? formatKeyCombination(binding.current_binding, osType) + : t("settings.general.shortcut.unassigned")} )} { +export const usePostProcessProviderState = ( + profileId: string, +): PostProcessProviderState => { const { settings, isUpdating, @@ -42,12 +45,16 @@ export const usePostProcessProviderState = (): PostProcessProviderState => { postProcessModelOptions, } = useSettings(); + const profile = settings?.post_process_profiles?.find( + (p) => p.id === profileId, + ); + // Settings are guaranteed to have providers after migration - const providers = settings?.post_process_providers || []; + const providers = profile?.providers || []; const selectedProviderId = useMemo(() => { - return settings?.post_process_provider_id || providers[0]?.id || "openai"; - }, [providers, settings?.post_process_provider_id]); + return profile?.provider_id || providers[0]?.id || "openai"; + }, [providers, profile?.provider_id]); const selectedProvider = useMemo(() => { return ( @@ -62,8 +69,8 @@ export const usePostProcessProviderState = (): PostProcessProviderState => { // Use settings directly as single source of truth const baseUrl = selectedProvider?.base_url ?? ""; - const apiKey = settings?.post_process_api_keys?.[selectedProviderId] ?? ""; - const model = settings?.post_process_models?.[selectedProviderId] ?? ""; + const apiKey = profile?.api_keys?.[selectedProviderId] ?? ""; + const model = profile?.models?.[selectedProviderId] ?? ""; const providerOptions = useMemo(() => { return providers.map((provider) => ({ @@ -89,7 +96,7 @@ export const usePostProcessProviderState = (): PostProcessProviderState => { } } - await setPostProcessProvider(providerId); + await setPostProcessProvider(profileId, providerId); // Auto-fetch available models for the new provider so the model dropdown // reflects what's actually valid. Without this, a stale model value from @@ -98,21 +105,22 @@ export const usePostProcessProviderState = (): PostProcessProviderState => { // to avoid unnecessary backend errors. if (providerId !== APPLE_PROVIDER_ID) { const provider = providers.find((p) => p.id === providerId); - const apiKey = settings?.post_process_api_keys?.[providerId] ?? ""; + const apiKey = profile?.api_keys?.[providerId] ?? ""; const hasBaseUrl = (provider?.base_url ?? "").trim() !== ""; const hasApiKey = apiKey.trim() !== ""; if (provider?.id === "custom" ? hasBaseUrl : hasApiKey) { - void fetchPostProcessModels(providerId); + void fetchPostProcessModels(profileId, providerId); } } }, [ + profileId, selectedProviderId, setPostProcessProvider, fetchPostProcessModels, providers, - settings, + profile, ], ); @@ -123,52 +131,53 @@ export const usePostProcessProviderState = (): PostProcessProviderState => { } const trimmed = value.trim(); if (trimmed && trimmed !== baseUrl) { - void updatePostProcessBaseUrl(selectedProvider.id, trimmed); + void updatePostProcessBaseUrl(profileId, selectedProvider.id, trimmed); } }, - [selectedProvider, baseUrl, updatePostProcessBaseUrl], + [profileId, selectedProvider, baseUrl, updatePostProcessBaseUrl], ); const handleApiKeyChange = useCallback( (value: string) => { const trimmed = value.trim(); if (trimmed !== apiKey) { - void updatePostProcessApiKey(selectedProviderId, trimmed); + void updatePostProcessApiKey(profileId, selectedProviderId, trimmed); } }, - [apiKey, selectedProviderId, updatePostProcessApiKey], + [profileId, apiKey, selectedProviderId, updatePostProcessApiKey], ); const handleModelChange = useCallback( (value: string) => { const trimmed = value.trim(); if (trimmed !== model) { - void updatePostProcessModel(selectedProviderId, trimmed); + void updatePostProcessModel(profileId, selectedProviderId, trimmed); } }, - [model, selectedProviderId, updatePostProcessModel], + [profileId, model, selectedProviderId, updatePostProcessModel], ); const handleModelSelect = useCallback( (value: string) => { - void updatePostProcessModel(selectedProviderId, value.trim()); + void updatePostProcessModel(profileId, selectedProviderId, value.trim()); }, - [selectedProviderId, updatePostProcessModel], + [profileId, selectedProviderId, updatePostProcessModel], ); const handleModelCreate = useCallback( (value: string) => { - void updatePostProcessModel(selectedProviderId, value); + void updatePostProcessModel(profileId, selectedProviderId, value); }, - [selectedProviderId, updatePostProcessModel], + [profileId, selectedProviderId, updatePostProcessModel], ); const handleRefreshModels = useCallback(() => { if (isAppleProvider) return; - void fetchPostProcessModels(selectedProviderId); - }, [fetchPostProcessModels, isAppleProvider, selectedProviderId]); + void fetchPostProcessModels(profileId, selectedProviderId); + }, [fetchPostProcessModels, isAppleProvider, profileId, selectedProviderId]); - const availableModelsRaw = postProcessModelOptions[selectedProviderId] || []; + const providerKey = postProcessKey(profileId, selectedProviderId); + const availableModelsRaw = postProcessModelOptions[providerKey] || []; const modelOptions = useMemo(() => { const seen = new Set(); @@ -192,17 +201,11 @@ export const usePostProcessProviderState = (): PostProcessProviderState => { return options; }, [availableModelsRaw, model]); - const isBaseUrlUpdating = isUpdating( - `post_process_base_url:${selectedProviderId}`, - ); - const isApiKeyUpdating = isUpdating( - `post_process_api_key:${selectedProviderId}`, - ); - const isModelUpdating = isUpdating( - `post_process_model:${selectedProviderId}`, - ); + const isBaseUrlUpdating = isUpdating(`post_process_base_url:${providerKey}`); + const isApiKeyUpdating = isUpdating(`post_process_api_key:${providerKey}`); + const isModelUpdating = isUpdating(`post_process_model:${providerKey}`); const isFetchingModels = isUpdating( - `post_process_models_fetch:${selectedProviderId}`, + `post_process_models_fetch:${providerKey}`, ); const isCustomProvider = selectedProvider?.id === "custom"; diff --git a/src/components/settings/ShortcutInput.tsx b/src/components/settings/ShortcutInput.tsx index 817900d321..80b6ff5c1d 100644 --- a/src/components/settings/ShortcutInput.tsx +++ b/src/components/settings/ShortcutInput.tsx @@ -8,6 +8,8 @@ interface ShortcutInputProps { grouped?: boolean; shortcutId: string; disabled?: boolean; + title?: string; + description?: string; } /** diff --git a/src/components/settings/history/HistorySettings.tsx b/src/components/settings/history/HistorySettings.tsx index e900701dd7..3a594edc27 100644 --- a/src/components/settings/history/HistorySettings.tsx +++ b/src/components/settings/history/HistorySettings.tsx @@ -11,6 +11,8 @@ import { type HistoryUpdatePayload, } from "@/bindings"; import { useOsType } from "@/hooks/useOsType"; +import { useSettingsStore } from "@/stores/settingsStore"; +import { getProfileDisplayName } from "@/lib/utils/postProcessProfile"; import { formatDateTime } from "@/utils/dateFormat"; import { AudioPlayer, AudioPlayerGroup } from "../../ui/AudioPlayer"; import { Button } from "../../ui/Button"; @@ -305,9 +307,28 @@ const HistoryEntryComponent: React.FC = ({ retryTranscription, }) => { const { t, i18n } = useTranslation(); + // Selector subscription: rows re-render only when the profiles change, not + // on every settings store update. + const profiles = useSettingsStore( + (state) => state.settings?.post_process_profiles, + ); const [showCopied, setShowCopied] = useState(false); const [retrying, setRetrying] = useState(false); + // Which post-processing profile processed this entry. + let profileLabel: string | null = null; + if (entry.post_process_requested) { + // No profile id: the profile that processed it was deleted. + const profile = profiles?.find( + (p) => p.id === entry.post_process_profile_id, + ); + profileLabel = profile + ? t("settings.history.postProcessProfile", { + name: getProfileDisplayName(profile, t), + }) + : t("settings.history.deletedProfile"); + } + const hasTranscription = entry.transcription_text.trim().length > 0; const handleLoadAudio = useCallback( @@ -356,7 +377,14 @@ const HistoryEntryComponent: React.FC = ({ return (
-

{formattedDate}

+

+ {formattedDate} + {profileLabel && ( + + {profileLabel} + + )} +

{ +import { + DEFAULT_PROFILE_ID, + MAX_PROFILE_NAME_LENGTH, + getProfileDisplayName, +} from "../../../lib/utils/postProcessProfile"; + +interface ProfileProps { + profileId?: string; +} + +const PostProcessingSettingsApiComponent: React.FC = ({ + profileId = DEFAULT_PROFILE_ID, +}) => { const { t } = useTranslation(); - const state = usePostProcessProviderState(); + const state = usePostProcessProviderState(profileId); return ( <> @@ -143,16 +156,21 @@ const PostProcessingSettingsApiComponent: React.FC = () => { ); }; -const PostProcessingSettingsPromptsComponent: React.FC = () => { +const PostProcessingSettingsPromptsComponent: React.FC = ({ + profileId = DEFAULT_PROFILE_ID, +}) => { const { t } = useTranslation(); - const { getSetting, updateSetting, isUpdating, refreshSettings } = - useSettings(); + const { getSetting, refreshSettings } = useSettings(); const [isCreating, setIsCreating] = useState(false); + const [isSelecting, setIsSelecting] = useState(false); const [draftName, setDraftName] = useState(""); const [draftText, setDraftText] = useState(""); - const prompts = getSetting("post_process_prompts") || []; - const selectedPromptId = getSetting("post_process_selected_prompt_id") || ""; + const profile = getSetting("post_process_profiles")?.find( + (p) => p.id === profileId, + ); + const prompts = profile?.prompts || []; + const selectedPromptId = profile?.selected_prompt_id || ""; const selectedPrompt = prompts.find((prompt) => prompt.id === selectedPromptId) || null; @@ -173,9 +191,27 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => { selectedPrompt?.prompt, ]); + const selectPrompt = async (promptId: string) => { + setIsSelecting(true); + try { + const result = await commands.setPostProcessSelectedPrompt( + profileId, + promptId, + ); + if (result.status === "error") { + console.error("Failed to select prompt:", result.error); + } + await refreshSettings(); + } catch (error) { + console.error("Failed to select prompt:", error); + } finally { + setIsSelecting(false); + } + }; + const handlePromptSelect = (promptId: string | null) => { if (!promptId) return; - updateSetting("post_process_selected_prompt_id", promptId); + void selectPrompt(promptId); setIsCreating(false); }; @@ -184,12 +220,12 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => { try { const result = await commands.addPostProcessPrompt( + profileId, draftName.trim(), draftText.trim(), ); if (result.status === "ok") { - await refreshSettings(); - updateSetting("post_process_selected_prompt_id", result.data.id); + await selectPrompt(result.data.id); setIsCreating(false); } } catch (error) { @@ -202,6 +238,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => { try { await commands.updatePostProcessPrompt( + profileId, selectedPromptId, draftName.trim(), draftText.trim(), @@ -216,7 +253,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => { if (!promptId) return; try { - await commands.deletePostProcessPrompt(promptId); + await commands.deletePostProcessPrompt(profileId, promptId); await refreshSettings(); setIsCreating(false); } catch (error) { @@ -271,9 +308,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => { ? t("settings.postProcessing.prompts.noPrompts") : t("settings.postProcessing.prompts.selectPrompt") } - disabled={ - isUpdating("post_process_selected_prompt_id") || isCreating - } + disabled={isSelecting || isCreating} className="flex-1 min-w-0" /> + )} + {profile.id !== DEFAULT_PROFILE_ID && editingId !== profile.id && ( + + )} +
+ ); + })} + +
+ ); +}; + export const PostProcessingSettings: React.FC = () => { const { t } = useTranslation(); + const { getSetting, refreshSettings } = useSettings(); + const [activeId, setActiveId] = useState(DEFAULT_PROFILE_ID); + + const profiles = getSetting("post_process_profiles") || []; + const activeProfile = + profiles.find((profile) => profile.id === activeId) || profiles[0]; + + const handleAddProfile = async () => { + // New tabs are named " N". + const result = await commands.addPostProcessProfile( + t("settings.postProcessing.profiles.defaultName"), + ); + if (result.status === "ok") { + await refreshSettings(); + setActiveId(result.data.id); + } else { + console.error("Failed to add profile:", result.error); + toast.error(t("settings.postProcessing.profiles.errors.add")); + } + }; + + const handleRenameProfile = async ( + profile: PostProcessProfile, + name: string, + ) => { + const result = await commands.renamePostProcessProfile(profile.id, name); + if (result.status === "ok") { + await refreshSettings(); + } else { + console.error("Failed to rename profile:", result.error); + toast.error(t("settings.postProcessing.profiles.errors.rename")); + } + }; + + const handleDeleteProfile = async (profile: PostProcessProfile) => { + const confirmed = await ask( + t("settings.postProcessing.profiles.deleteConfirm", { + name: getProfileDisplayName(profile, t), + }), + { + title: t("settings.postProcessing.profiles.deleteTitle"), + kind: "warning", + }, + ); + if (!confirmed) return; + + const result = await commands.deletePostProcessProfile(profile.id); + if (result.status === "ok") { + if (activeId === profile.id) { + setActiveId(DEFAULT_PROFILE_ID); + } + await refreshSettings(); + } else { + console.error("Failed to delete profile:", result.error); + toast.error(t("settings.postProcessing.profiles.errors.delete")); + } + }; return (
- - - + + + {activeProfile && ( + <> + + + - - - + + + - - - + + + + + )}
); }; diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 33c17d3d35..1ca4f4e251 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -31,17 +31,29 @@ interface UseSettingsReturn { getSetting: (key: K) => Settings[K] | undefined; // Post-processing helpers - setPostProcessProvider: (providerId: string) => Promise; + setPostProcessProvider: ( + profileId: string, + providerId: string, + ) => Promise; updatePostProcessBaseUrl: ( + profileId: string, providerId: string, baseUrl: string, ) => Promise; updatePostProcessApiKey: ( + profileId: string, providerId: string, apiKey: string, ) => Promise; - updatePostProcessModel: (providerId: string, model: string) => Promise; - fetchPostProcessModels: (providerId: string) => Promise; + updatePostProcessModel: ( + profileId: string, + providerId: string, + model: string, + ) => Promise; + fetchPostProcessModels: ( + profileId: string, + providerId: string, + ) => Promise; } export const useSettings = (): UseSettingsReturn => { diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 026224d508..cc076232c1 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -163,6 +163,7 @@ "loading": "...جاري تحميل الاختصارات", "none": "لا توجد اختصارات مجهزة", "notFound": "الاختصار غير موجود", + "unassigned": "غير معيّن", "pressKeys": "...اضغط على المفاتيح", "bindings": { "transcribe": { @@ -378,6 +379,22 @@ } }, "postProcessing": { + "profiles": { + "title": "ملفات المعالجة اللاحقة", + "add": "إضافة ملف تعريف", + "delete": "حذف ملف التعريف {{name}}", + "deleteTitle": "حذف ملف التعريف", + "deleteConfirm": "هل تريد حذف ملف التعريف \"{{name}}\"؟ ستُزال إعداداته ويتوقف اختصاره عن العمل.", + "defaultName": "افتراضي", + "renameHint": "انقر نقرًا مزدوجًا لإعادة التسمية", + "nameLabel": "اسم ملف التعريف", + "shortcutTitle": "مفتاح المعالجة اللاحقة ({{name}})", + "errors": { + "add": "تعذّرت إضافة ملف التعريف", + "rename": "تعذّرت إعادة تسمية ملف التعريف", + "delete": "تعذّر حذف ملف التعريف" + } + }, "hotkey": { "title": "مفتاح الاختصار" }, @@ -445,7 +462,9 @@ "retranscribe": "إعادة النسخ", "retranscribeError": "فشلت إعادة النسخ. يرجى المحاولة مرة أخرى.", "transcribing": "جارٍ النسخ...", - "transcriptionFailed": "فشل النسخ. يمكنك إعادة النسخ باستخدام أيقونة إعادة المحاولة." + "transcriptionFailed": "فشل النسخ. يمكنك إعادة النسخ باستخدام أيقونة إعادة المحاولة.", + "postProcessProfile": "ملف التعريف: {{name}}", + "deletedProfile": "ملف التعريف محذوف" }, "debug": { "title": "تصحيح الأخطاء", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "...جاري التفريغ", - "processing": "...جاري المعالجة" + "processing": "...جاري المعالجة", + "processingProfile": "...جاري المعالجة ({{name}})" }, "whatsNew": { "title": "الجديد في Handy v{{version}}" diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index f517071636..b50110b440 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -166,6 +166,7 @@ "loading": "Зареждане на бързите клавиши...", "none": "Няма настроени бързи клавиши", "notFound": "Бързият клавиш не е намерен", + "unassigned": "Не е зададен", "pressKeys": "Натиснете клавишите...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Профили за последваща обработка", + "add": "Добавяне на профил", + "delete": "Изтриване на профил {{name}}", + "deleteTitle": "Изтриване на профил", + "deleteConfirm": "Да се изтрие ли профилът „{{name}}“? Настройките му ще бъдат премахнати и бързият му клавиш ще спре да работи.", + "defaultName": "По подразбиране", + "renameHint": "Щракнете два пъти за преименуване", + "nameLabel": "Име на профила", + "shortcutTitle": "Бърз клавиш за постобработка ({{name}})", + "errors": { + "add": "Профилът не можа да бъде добавен", + "rename": "Профилът не можа да бъде преименуван", + "delete": "Профилът не можа да бъде изтрит" + } + }, "hotkey": { "title": "Бърз клавиш" }, @@ -468,7 +485,9 @@ "retranscribe": "Повторна транскрипция", "retranscribeError": "Повторната транскрипция не бе успешна. Опитайте отново.", "transcribing": "Транскрибиране...", - "transcriptionFailed": "Транскрипцията не бе успешна. Можете да опитате отново с иконата за повторен опит." + "transcriptionFailed": "Транскрипцията не бе успешна. Можете да опитате отново с иконата за повторен опит.", + "postProcessProfile": "Профил: {{name}}", + "deletedProfile": "Профилът е изтрит" }, "debug": { "title": "Отстраняване", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Транскрибиране...", - "processing": "Обработка..." + "processing": "Обработка...", + "processingProfile": "Обработка ({{name}})..." }, "whatsNew": { "title": "Ново в Handy v{{version}}" diff --git a/src/i18n/locales/ca/translation.json b/src/i18n/locales/ca/translation.json index 5d6ca03421..73a2576b8d 100644 --- a/src/i18n/locales/ca/translation.json +++ b/src/i18n/locales/ca/translation.json @@ -166,6 +166,7 @@ "loading": "S'estan carregant les dreceres...", "none": "No hi ha cap drecera configurada", "notFound": "No s'ha trobat la drecera", + "unassigned": "Sense assignar", "pressKeys": "Premeu les tecles...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Perfils de postprocessament", + "add": "Afegeix un perfil", + "delete": "Suprimeix el perfil {{name}}", + "deleteTitle": "Suprimeix el perfil", + "deleteConfirm": "Voleu suprimir el perfil «{{name}}»? Se n'eliminaran els paràmetres i la drecera deixarà de funcionar.", + "defaultName": "Per defecte", + "renameHint": "Feu doble clic per canviar el nom", + "nameLabel": "Nom del perfil", + "shortcutTitle": "Drecera de postprocessament ({{name}})", + "errors": { + "add": "No s'ha pogut afegir el perfil", + "rename": "No s'ha pogut canviar el nom del perfil", + "delete": "No s'ha pogut suprimir el perfil" + } + }, "hotkey": { "title": "Drecera de teclat" }, @@ -468,7 +485,9 @@ "retranscribe": "Torneu a transcriure", "retranscribeError": "No s'ha pogut tornar a transcriure. Torneu-ho a provar.", "transcribing": "S'està transcrivint...", - "transcriptionFailed": "La transcripció ha fallat. Podeu tornar a transcriure amb la icona de reintent." + "transcriptionFailed": "La transcripció ha fallat. Podeu tornar a transcriure amb la icona de reintent.", + "postProcessProfile": "Perfil: {{name}}", + "deletedProfile": "Perfil suprimit" }, "debug": { "title": "Depuració", @@ -702,7 +721,8 @@ }, "overlay": { "transcribing": "S'està transcrivint...", - "processing": "S'està processant..." + "processing": "S'està processant...", + "processingProfile": "S'està processant ({{name}})..." }, "secureInput": { "blockedWithCulprit_one": "{{name}} pot estar blocant 1 drecera", diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 27da0ae353..65616c71f3 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -186,6 +186,7 @@ "loading": "Načítám zkratky...", "none": "Žádné zkratky nejsou nastavené", "notFound": "Zkratka nenalezena", + "unassigned": "Nenastaveno", "pressKeys": "Stiskněte klávesy...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Profily následného zpracování", + "add": "Přidat profil", + "delete": "Smazat profil {{name}}", + "deleteTitle": "Smazat profil", + "deleteConfirm": "Smazat profil „{{name}}“? Jeho nastavení budou odstraněna a jeho zkratka přestane fungovat.", + "defaultName": "Výchozí", + "renameHint": "Dvojitým kliknutím přejmenujete", + "nameLabel": "Název profilu", + "shortcutTitle": "Klávesa pro následné zpracování ({{name}})", + "errors": { + "add": "Profil se nepodařilo přidat", + "rename": "Profil se nepodařilo přejmenovat", + "delete": "Profil se nepodařilo smazat" + } + }, "hotkey": { "title": "Klávesová zkratka" }, @@ -468,7 +485,9 @@ "retranscribe": "Přepsat znovu", "retranscribeError": "Opětovný přepis se nezdařil. Zkuste to prosím znovu.", "transcribing": "Přepisuji...", - "transcriptionFailed": "Přepis selhal. Můžete zkusit přepis znovu pomocí ikony opakování." + "transcriptionFailed": "Přepis selhal. Můžete zkusit přepis znovu pomocí ikony opakování.", + "postProcessProfile": "Profil: {{name}}", + "deletedProfile": "Profil smazán" }, "debug": { "title": "Ladění", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Přepisuji...", - "processing": "Zpracovávám..." + "processing": "Zpracovávám...", + "processingProfile": "Zpracovávám ({{name}})..." }, "whatsNew": { "title": "Novinky v Handy v{{version}}" diff --git a/src/i18n/locales/da/translation.json b/src/i18n/locales/da/translation.json index 162aa21d3c..4b3fc0d803 100644 --- a/src/i18n/locales/da/translation.json +++ b/src/i18n/locales/da/translation.json @@ -166,6 +166,7 @@ "loading": "Indlæser genveje...", "none": "Ingen genveje konfigureret", "notFound": "Genvej ikke fundet", + "unassigned": "Ikke angivet", "pressKeys": "Tryk på ønskede taster...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Efterbehandlingsprofiler", + "add": "Tilføj profil", + "delete": "Slet profilen {{name}}", + "deleteTitle": "Slet profil", + "deleteConfirm": "Vil du slette profilen \"{{name}}\"? Dens indstillinger fjernes, og dens genvej holder op med at virke.", + "defaultName": "Standard", + "renameHint": "Dobbeltklik for at omdøbe", + "nameLabel": "Profilnavn", + "shortcutTitle": "Genvej til efterbehandling ({{name}})", + "errors": { + "add": "Profilen kunne ikke tilføjes", + "rename": "Profilen kunne ikke omdøbes", + "delete": "Profilen kunne ikke slettes" + } + }, "hotkey": { "title": "Genvejstast" }, @@ -468,7 +485,9 @@ "retranscribe": "Transskribér igen", "retranscribeError": "Kunne ikke transskribere igen. Prøv igen.", "transcribing": "Transskriberer...", - "transcriptionFailed": "Transskription mislykkedes. Du kan transskribere igen ved hjælp af genforsøgsikonet." + "transcriptionFailed": "Transskription mislykkedes. Du kan transskribere igen ved hjælp af genforsøgsikonet.", + "postProcessProfile": "Profil: {{name}}", + "deletedProfile": "Profil slettet" }, "debug": { "title": "Fejlfinding", @@ -702,7 +721,8 @@ }, "overlay": { "transcribing": "Transskriberer...", - "processing": "Behandler..." + "processing": "Behandler...", + "processingProfile": "Behandler ({{name}})..." }, "secureInput": { "blockedWithCulprit_one": "{{name}} blokerer muligvis 1 genvej", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 1e4dc5ca65..10fb3e5543 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -186,6 +186,7 @@ "loading": "Tastenkürzel werden geladen...", "none": "Keine Tastenkürzel konfiguriert", "notFound": "Tastenkürzel nicht gefunden", + "unassigned": "Nicht festgelegt", "pressKeys": "Tasten drücken...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Nachbearbeitungsprofile", + "add": "Profil hinzufügen", + "delete": "Profil {{name}} löschen", + "deleteTitle": "Profil löschen", + "deleteConfirm": "Profil „{{name}}“ löschen? Seine Einstellungen werden entfernt und sein Tastenkürzel funktioniert nicht mehr.", + "defaultName": "Standard", + "renameHint": "Doppelklicken zum Umbenennen", + "nameLabel": "Profilname", + "shortcutTitle": "Nachbearbeitungs-Tastenkürzel ({{name}})", + "errors": { + "add": "Profil konnte nicht hinzugefügt werden", + "rename": "Profil konnte nicht umbenannt werden", + "delete": "Profil konnte nicht gelöscht werden" + } + }, "hotkey": { "title": "Tastenkürzel" }, @@ -468,7 +485,9 @@ "retranscribe": "Erneut transkribieren", "retranscribeError": "Erneute Transkription fehlgeschlagen. Bitte versuche es erneut.", "transcribing": "Transkribiere...", - "transcriptionFailed": "Transkription fehlgeschlagen. Du kannst über das Wiederholen-Symbol erneut transkribieren." + "transcriptionFailed": "Transkription fehlgeschlagen. Du kannst über das Wiederholen-Symbol erneut transkribieren.", + "postProcessProfile": "Profil: {{name}}", + "deletedProfile": "Profil gelöscht" }, "debug": { "title": "Debug", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Transkribiere...", - "processing": "Verarbeite..." + "processing": "Verarbeite...", + "processingProfile": "Verarbeite ({{name}})..." }, "whatsNew": { "title": "Neu in Handy v{{version}}" diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 4ddfdedc9d..9e421172c2 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -166,6 +166,7 @@ "loading": "Loading shortcuts...", "none": "No shortcuts configured", "notFound": "Shortcut not found", + "unassigned": "Not set", "pressKeys": "Press keys...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Post-processing profiles", + "add": "Add profile", + "delete": "Delete profile {{name}}", + "deleteTitle": "Delete profile", + "deleteConfirm": "Delete the \"{{name}}\" profile? Its settings are removed and its shortcut stops working.", + "defaultName": "Default", + "renameHint": "Double-click to rename", + "nameLabel": "Profile name", + "shortcutTitle": "Post-Processing Hotkey ({{name}})", + "errors": { + "add": "Couldn't add the profile", + "rename": "Couldn't rename the profile", + "delete": "Couldn't delete the profile" + } + }, "hotkey": { "title": "Hotkey" }, @@ -468,7 +485,9 @@ "retranscribe": "Re-transcribe", "retranscribeError": "Failed to re-transcribe. Please try again.", "transcribing": "Transcribing...", - "transcriptionFailed": "Transcription failed. You can re-transcribe using the retry icon." + "transcriptionFailed": "Transcription failed. You can re-transcribe using the retry icon.", + "postProcessProfile": "Profile: {{name}}", + "deletedProfile": "Profile deleted" }, "debug": { "title": "Debug", @@ -702,7 +721,8 @@ }, "overlay": { "transcribing": "Transcribing...", - "processing": "Processing..." + "processing": "Processing...", + "processingProfile": "Processing ({{name}})..." }, "secureInput": { "blockedWithCulprit_one": "{{name}} may be blocking 1 shortcut", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 2e10288d52..0129ca2454 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -186,6 +186,7 @@ "loading": "Cargando atajos...", "none": "No hay atajos configurados", "notFound": "Atajo no encontrado", + "unassigned": "Sin asignar", "pressKeys": "Presiona teclas...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Perfiles de posprocesamiento", + "add": "Añadir perfil", + "delete": "Eliminar el perfil {{name}}", + "deleteTitle": "Eliminar perfil", + "deleteConfirm": "¿Eliminar el perfil «{{name}}»? Se borrará su configuración y su atajo dejará de funcionar.", + "defaultName": "Predeterminado", + "renameHint": "Haz doble clic para cambiar el nombre", + "nameLabel": "Nombre del perfil", + "shortcutTitle": "Tecla de post procesamiento ({{name}})", + "errors": { + "add": "No se pudo añadir el perfil", + "rename": "No se pudo cambiar el nombre del perfil", + "delete": "No se pudo eliminar el perfil" + } + }, "hotkey": { "title": "Tecla de acceso rápido" }, @@ -468,7 +485,9 @@ "retranscribe": "Re-transcribir", "retranscribeError": "No se pudo re-transcribir. Por favor, inténtalo de nuevo.", "transcribing": "Transcribiendo...", - "transcriptionFailed": "La transcripción falló. Puedes re-transcribir usando el icono de reintentar." + "transcriptionFailed": "La transcripción falló. Puedes re-transcribir usando el icono de reintentar.", + "postProcessProfile": "Perfil: {{name}}", + "deletedProfile": "Perfil eliminado" }, "debug": { "title": "Depuración", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Transcribiendo...", - "processing": "Procesando..." + "processing": "Procesando...", + "processingProfile": "Procesando ({{name}})..." }, "whatsNew": { "title": "Nuevo en Handy v{{version}}" diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index fa71a955f9..32535d0c93 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -186,6 +186,7 @@ "loading": "Chargement des raccourcis...", "none": "Aucun raccourci configuré", "notFound": "Raccourci non trouvé", + "unassigned": "Non défini", "pressKeys": "Appuyez sur les touches...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Profils de post-traitement", + "add": "Ajouter un profil", + "delete": "Supprimer le profil {{name}}", + "deleteTitle": "Supprimer le profil", + "deleteConfirm": "Supprimer le profil « {{name}} » ? Ses paramètres seront supprimés et son raccourci cessera de fonctionner.", + "defaultName": "Par défaut", + "renameHint": "Double-cliquez pour renommer", + "nameLabel": "Nom du profil", + "shortcutTitle": "Raccourci de post-traitement ({{name}})", + "errors": { + "add": "Impossible d'ajouter le profil", + "rename": "Impossible de renommer le profil", + "delete": "Impossible de supprimer le profil" + } + }, "hotkey": { "title": "Raccourci clavier" }, @@ -468,7 +485,9 @@ "retranscribe": "Re-transcrire", "retranscribeError": "Impossible de re-transcrire. Veuillez réessayer.", "transcribing": "Transcription en cours...", - "transcriptionFailed": "La transcription a échoué. Vous pouvez re-transcrire en utilisant l'icône de réessai." + "transcriptionFailed": "La transcription a échoué. Vous pouvez re-transcrire en utilisant l'icône de réessai.", + "postProcessProfile": "Profil : {{name}}", + "deletedProfile": "Profil supprimé" }, "debug": { "title": "Débogage", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Transcription...", - "processing": "Traitement..." + "processing": "Traitement...", + "processingProfile": "Traitement ({{name}})..." }, "whatsNew": { "title": "Nouveautés de Handy v{{version}}" diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index 54b33c0c67..9028f95f1e 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -166,6 +166,7 @@ "loading": "טוען קיצורי מקשים...", "none": "לא הוגדרו קיצורי מקשים", "notFound": "קיצור הדרך לא נמצא", + "unassigned": "לא הוגדר", "pressKeys": "לחץ על המקשים...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "פרופילי עיבוד המשך", + "add": "הוסף פרופיל", + "delete": "מחק את הפרופיל {{name}}", + "deleteTitle": "מחיקת פרופיל", + "deleteConfirm": "למחוק את הפרופיל \"{{name}}\"? ההגדרות שלו יימחקו וקיצור המקשים שלו יפסיק לפעול.", + "defaultName": "ברירת מחדל", + "renameHint": "לחץ פעמיים כדי לשנות שם", + "nameLabel": "שם הפרופיל", + "shortcutTitle": "קיצור לעיבוד משלים ({{name}})", + "errors": { + "add": "לא ניתן להוסיף את הפרופיל", + "rename": "לא ניתן לשנות את שם הפרופיל", + "delete": "לא ניתן למחוק את הפרופיל" + } + }, "hotkey": { "title": "קיצור מקשים" }, @@ -468,7 +485,9 @@ "retranscribe": "תמלל מחדש", "retranscribeError": "תמלול מחדש נכשל. נסה שוב.", "transcribing": "מתמלל...", - "transcriptionFailed": "התמלול נכשל. אפשר לתמלל מחדש באמצעות אייקון הניסיון החוזר." + "transcriptionFailed": "התמלול נכשל. אפשר לתמלל מחדש באמצעות אייקון הניסיון החוזר.", + "postProcessProfile": "פרופיל: {{name}}", + "deletedProfile": "הפרופיל נמחק" }, "debug": { "title": "ניפוי שגיאות", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "מתמלל...", - "processing": "מעבד..." + "processing": "מעבד...", + "processingProfile": "מעבד ({{name}})..." }, "whatsNew": { "title": "חדש ב-Handy v{{version}}" diff --git a/src/i18n/locales/hi/translation.json b/src/i18n/locales/hi/translation.json index 33fed79ee8..e5233d23fc 100644 --- a/src/i18n/locales/hi/translation.json +++ b/src/i18n/locales/hi/translation.json @@ -166,6 +166,7 @@ "loading": "शॉर्टकट लोड हो रहे हैं...", "none": "कोई शॉर्टकट सेट नहीं है", "notFound": "शॉर्टकट नहीं मिला", + "unassigned": "सेट नहीं है", "pressKeys": "बटन दबाएं...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "पोस्ट-प्रोसेसिंग प्रोफ़ाइल", + "add": "प्रोफ़ाइल जोड़ें", + "delete": "प्रोफ़ाइल {{name}} हटाएं", + "deleteTitle": "प्रोफ़ाइल हटाएं", + "deleteConfirm": "\"{{name}}\" प्रोफ़ाइल हटाएं? इसकी सेटिंग्स हटा दी जाएंगी और इसका शॉर्टकट काम करना बंद कर देगा।", + "defaultName": "डिफ़ॉल्ट", + "renameHint": "नाम बदलने के लिए डबल-क्लिक करें", + "nameLabel": "प्रोफ़ाइल का नाम", + "shortcutTitle": "पोस्ट-प्रोसेसिंग हॉटकी ({{name}})", + "errors": { + "add": "प्रोफ़ाइल नहीं जोड़ी जा सकी", + "rename": "प्रोफ़ाइल का नाम नहीं बदला जा सका", + "delete": "प्रोफ़ाइल नहीं हटाई जा सकी" + } + }, "hotkey": { "title": "हॉटकी" }, @@ -468,7 +485,9 @@ "retranscribe": "फिर से ट्रांसक्राइब करें", "retranscribeError": "फिर से ट्रांसक्राइब नहीं किया जा सका. कृपया फिर से कोशिश करें.", "transcribing": "ट्रांसक्राइब हो रहा है...", - "transcriptionFailed": "ट्रांसक्रिप्शन नहीं हो सका. आप रीट्राई आइकन से फिर से ट्रांसक्राइब कर सकते हैं." + "transcriptionFailed": "ट्रांसक्रिप्शन नहीं हो सका. आप रीट्राई आइकन से फिर से ट्रांसक्राइब कर सकते हैं.", + "postProcessProfile": "प्रोफ़ाइल: {{name}}", + "deletedProfile": "प्रोफ़ाइल हटाई गई" }, "debug": { "title": "डिबग", @@ -702,7 +721,8 @@ }, "overlay": { "transcribing": "ट्रांसक्राइब हो रहा है...", - "processing": "प्रोसेस हो रहा है..." + "processing": "प्रोसेस हो रहा है...", + "processingProfile": "प्रोसेस हो रहा है ({{name}})..." }, "secureInput": { "blockedWithCulprit_one": "{{name}} शायद 1 शॉर्टकट को ब्लॉक कर रहा है", diff --git a/src/i18n/locales/id/translation.json b/src/i18n/locales/id/translation.json index 5b3edeee26..b8235ff2bc 100644 --- a/src/i18n/locales/id/translation.json +++ b/src/i18n/locales/id/translation.json @@ -166,6 +166,7 @@ "loading": "Memuat pintasan...", "none": "Belum ada pintasan yang diatur", "notFound": "Pintasan tidak ditemukan", + "unassigned": "Belum diatur", "pressKeys": "Tekan tombol...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Profil pascapemrosesan", + "add": "Tambah profil", + "delete": "Hapus profil {{name}}", + "deleteTitle": "Hapus Profil", + "deleteConfirm": "Hapus profil \"{{name}}\"? Pengaturannya akan dihapus dan pintasannya tidak akan berfungsi lagi.", + "defaultName": "Bawaan", + "renameHint": "Klik dua kali untuk mengganti nama", + "nameLabel": "Nama profil", + "shortcutTitle": "Pintasan Pascaproses ({{name}})", + "errors": { + "add": "Tidak dapat menambahkan profil", + "rename": "Tidak dapat mengganti nama profil", + "delete": "Tidak dapat menghapus profil" + } + }, "hotkey": { "title": "Pintasan" }, @@ -468,7 +485,9 @@ "retranscribe": "Transkripsikan ulang", "retranscribeError": "Gagal mentranskripsikan ulang. Silakan coba lagi.", "transcribing": "Mentranskripsikan...", - "transcriptionFailed": "Transkripsi gagal. Anda dapat mentranskripsikan ulang menggunakan ikon coba lagi." + "transcriptionFailed": "Transkripsi gagal. Anda dapat mentranskripsikan ulang menggunakan ikon coba lagi.", + "postProcessProfile": "Profil: {{name}}", + "deletedProfile": "Profil dihapus" }, "debug": { "title": "Debug", @@ -702,7 +721,8 @@ }, "overlay": { "transcribing": "Mentranskripsikan...", - "processing": "Memproses..." + "processing": "Memproses...", + "processingProfile": "Memproses ({{name}})..." }, "secureInput": { "blockedWithCulprit_one": "{{name}} mungkin memblokir 1 pintasan", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 6f1a2c44ed..038a4506b1 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -186,6 +186,7 @@ "loading": "Caricamento delle scorciatoie...", "none": "Nessuna scorciatoia configurata", "notFound": "Scorciatoia non trovata", + "unassigned": "Non impostata", "pressKeys": "Premi i tasti...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Profili di post-elaborazione", + "add": "Aggiungi profilo", + "delete": "Elimina il profilo {{name}}", + "deleteTitle": "Elimina profilo", + "deleteConfirm": "Eliminare il profilo \"{{name}}\"? Le sue impostazioni verranno rimosse e la sua scorciatoia smetterà di funzionare.", + "defaultName": "Predefinito", + "renameHint": "Fai doppio clic per rinominare", + "nameLabel": "Nome del profilo", + "shortcutTitle": "Tasto di post-elaborazione ({{name}})", + "errors": { + "add": "Impossibile aggiungere il profilo", + "rename": "Impossibile rinominare il profilo", + "delete": "Impossibile eliminare il profilo" + } + }, "hotkey": { "title": "Tasto di scelta rapida" }, @@ -468,7 +485,9 @@ "retranscribe": "Ri-trascrivere", "retranscribeError": "Impossibile ri-trascrivere. Riprova.", "transcribing": "Trascrizione in corso...", - "transcriptionFailed": "Trascrizione fallita. Puoi ri-trascrivere usando l'icona di ripetizione." + "transcriptionFailed": "Trascrizione fallita. Puoi ri-trascrivere usando l'icona di ripetizione.", + "postProcessProfile": "Profilo: {{name}}", + "deletedProfile": "Profilo eliminato" }, "debug": { "title": "Debug", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Trascrizione...", - "processing": "Elaborazione..." + "processing": "Elaborazione...", + "processingProfile": "Elaborazione ({{name}})..." }, "whatsNew": { "title": "Novità in Handy v{{version}}" diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index ff05312b83..d62f07b6b8 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -186,6 +186,7 @@ "loading": "ショートカットを読み込み中…", "none": "ショートカットが設定されていません", "notFound": "ショートカットが見つかりません", + "unassigned": "未設定", "pressKeys": "キーを押してください…", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "後処理プロファイル", + "add": "プロファイルを追加", + "delete": "プロファイル「{{name}}」を削除", + "deleteTitle": "プロファイルを削除", + "deleteConfirm": "プロファイル「{{name}}」を削除しますか?設定は削除され、ショートカットは機能しなくなります。", + "defaultName": "デフォルト", + "renameHint": "ダブルクリックで名前を変更", + "nameLabel": "プロファイル名", + "shortcutTitle": "後処理のショートカット({{name}})", + "errors": { + "add": "プロファイルを追加できませんでした", + "rename": "プロファイル名を変更できませんでした", + "delete": "プロファイルを削除できませんでした" + } + }, "hotkey": { "title": "ショートカット" }, @@ -468,7 +485,9 @@ "retranscribe": "再文字起こし", "retranscribeError": "再文字起こしに失敗しました。もう一度お試しください。", "transcribing": "文字起こし中…", - "transcriptionFailed": "文字起こしに失敗しました。リトライアイコンを使用して再文字起こしできます。" + "transcriptionFailed": "文字起こしに失敗しました。リトライアイコンを使用して再文字起こしできます。", + "postProcessProfile": "プロファイル: {{name}}", + "deletedProfile": "削除されたプロファイル" }, "debug": { "title": "デバッグ", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "文字起こし中…", - "processing": "処理中…" + "processing": "処理中…", + "processingProfile": "処理中({{name}})…" }, "whatsNew": { "title": "Handy v{{version}} の新機能" diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 942e3ced0d..0c4ce875ce 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -166,6 +166,7 @@ "loading": "단축키 로딩 중...", "none": "설정된 단축키 없음", "notFound": "단축키를 찾을 수 없음", + "unassigned": "설정 안 됨", "pressKeys": "키를 눌러주세요...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "후처리 프로필", + "add": "프로필 추가", + "delete": "{{name}} 프로필 삭제", + "deleteTitle": "프로필 삭제", + "deleteConfirm": "\"{{name}}\" 프로필을 삭제할까요? 설정이 제거되고 단축키가 더 이상 작동하지 않습니다.", + "defaultName": "기본", + "renameHint": "두 번 클릭하여 이름 변경", + "nameLabel": "프로필 이름", + "shortcutTitle": "후처리 단축키 ({{name}})", + "errors": { + "add": "프로필을 추가하지 못했습니다", + "rename": "프로필 이름을 변경하지 못했습니다", + "delete": "프로필을 삭제하지 못했습니다" + } + }, "hotkey": { "title": "단축키" }, @@ -468,7 +485,9 @@ "retranscribe": "다시 전사", "retranscribeError": "다시 전사에 실패했습니다. 다시 시도해주세요.", "transcribing": "전사 중...", - "transcriptionFailed": "전사에 실패했습니다. 재시도 아이콘을 사용하여 다시 전사할 수 있습니다." + "transcriptionFailed": "전사에 실패했습니다. 재시도 아이콘을 사용하여 다시 전사할 수 있습니다.", + "postProcessProfile": "프로필: {{name}}", + "deletedProfile": "삭제된 프로필" }, "debug": { "title": "디버그", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "텍스트로 변환 중...", - "processing": "처리 중..." + "processing": "처리 중...", + "processingProfile": "처리 중 ({{name}})..." }, "whatsNew": { "title": "Handy v{{version}}의 새 소식" diff --git a/src/i18n/locales/ne/translation.json b/src/i18n/locales/ne/translation.json index de5fd21241..21f7d18c5b 100644 --- a/src/i18n/locales/ne/translation.json +++ b/src/i18n/locales/ne/translation.json @@ -166,6 +166,7 @@ "loading": "सर्टकटहरू लोड गरिँदैछन्...", "none": "कुनै सर्टकट सेट गरिएको छैन", "notFound": "सर्टकट भेटिएन", + "unassigned": "सेट गरिएको छैन", "pressKeys": "किहरू थिच्नुहोस्...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "पोस्ट-प्रशोधन प्रोफाइलहरू", + "add": "प्रोफाइल थप्नुहोस्", + "delete": "प्रोफाइल {{name}} मेटाउनुहोस्", + "deleteTitle": "प्रोफाइल मेटाउनुहोस्", + "deleteConfirm": "\"{{name}}\" प्रोफाइल मेटाउने? यसका सेटिङहरू हटाइनेछन् र यसको सर्टकटले काम गर्न छोड्नेछ।", + "defaultName": "पूर्वनिर्धारित", + "renameHint": "नाम बदल्न डबल-क्लिक गर्नुहोस्", + "nameLabel": "प्रोफाइलको नाम", + "shortcutTitle": "पोस्ट-प्रोसेसिङ हटकी ({{name}})", + "errors": { + "add": "प्रोफाइल थप्न सकिएन", + "rename": "प्रोफाइलको नाम बदल्न सकिएन", + "delete": "प्रोफाइल मेटाउन सकिएन" + } + }, "hotkey": { "title": "हटकी" }, @@ -468,7 +485,9 @@ "retranscribe": "फेरि ट्रान्सक्राइब गर्नुहोस्", "retranscribeError": "फेरि ट्रान्सक्राइब गर्न सकिएन। कृपया फेरि प्रयास गर्नुहोस्।", "transcribing": "ट्रान्सक्राइब गरिँदैछ...", - "transcriptionFailed": "ट्रान्सक्रिप्सन गर्न सकिएन। रिट्राई आइकन प्रयोग गरेर फेरि ट्रान्सक्राइब गर्न सक्नुहुन्छ।" + "transcriptionFailed": "ट्रान्सक्रिप्सन गर्न सकिएन। रिट्राई आइकन प्रयोग गरेर फेरि ट्रान्सक्राइब गर्न सक्नुहुन्छ।", + "postProcessProfile": "प्रोफाइल: {{name}}", + "deletedProfile": "प्रोफाइल मेटाइयो" }, "debug": { "title": "डिबग", @@ -702,7 +721,8 @@ }, "overlay": { "transcribing": "ट्रान्सक्राइब गरिँदैछ...", - "processing": "प्रशोधन गरिँदैछ..." + "processing": "प्रशोधन गरिँदैछ...", + "processingProfile": "प्रशोधन गरिँदैछ ({{name}})..." }, "secureInput": { "blockedWithCulprit_one": "{{name}} ले 1 सर्टकट रोकिरहेको हुन सक्छ", diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index 51be3c50ca..960d0abedc 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -166,6 +166,7 @@ "loading": "Sneltoetsen laden...", "none": "Geen sneltoetsen geconfigureerd", "notFound": "Sneltoets niet gevonden", + "unassigned": "Niet ingesteld", "pressKeys": "Druk op toetsen...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Nabewerkingsprofielen", + "add": "Profiel toevoegen", + "delete": "Profiel {{name}} verwijderen", + "deleteTitle": "Profiel verwijderen", + "deleteConfirm": "Profiel \"{{name}}\" verwijderen? De instellingen worden verwijderd en de sneltoets werkt niet meer.", + "defaultName": "Standaard", + "renameHint": "Dubbelklik om te hernoemen", + "nameLabel": "Profielnaam", + "shortcutTitle": "Sneltoets voor naverwerking ({{name}})", + "errors": { + "add": "Profiel kon niet worden toegevoegd", + "rename": "Profiel kon niet worden hernoemd", + "delete": "Profiel kon niet worden verwijderd" + } + }, "hotkey": { "title": "Sneltoets" }, @@ -468,7 +485,9 @@ "retranscribe": "Opnieuw transcriberen", "retranscribeError": "Opnieuw transcriberen mislukt. Probeer het opnieuw.", "transcribing": "Transcriberen...", - "transcriptionFailed": "Transcriptie mislukt. Je kunt het opnieuw proberen via het herlaad-pictogram." + "transcriptionFailed": "Transcriptie mislukt. Je kunt het opnieuw proberen via het herlaad-pictogram.", + "postProcessProfile": "Profiel: {{name}}", + "deletedProfile": "Profiel verwijderd" }, "debug": { "title": "Probleemoplossing", @@ -702,7 +721,8 @@ }, "overlay": { "transcribing": "Transcriberen...", - "processing": "Verwerken..." + "processing": "Verwerken...", + "processingProfile": "Verwerken ({{name}})..." }, "secureInput": { "blockedWithCulprit_one": "{{name}} blokkeert mogelijk 1 sneltoets", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 6d95b4cc76..ef6e8a23d8 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -186,6 +186,7 @@ "loading": "Wczytywanie skrótów...", "none": "Brak skonfigurowanych skrótów", "notFound": "Nie znaleziono skrótu", + "unassigned": "Nie ustawiono", "pressKeys": "Naciśnij klawisze...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Profile przetwarzania końcowego", + "add": "Dodaj profil", + "delete": "Usuń profil {{name}}", + "deleteTitle": "Usuń profil", + "deleteConfirm": "Usunąć profil „{{name}}”? Jego ustawienia zostaną usunięte, a skrót przestanie działać.", + "defaultName": "Domyślny", + "renameHint": "Kliknij dwukrotnie, aby zmienić nazwę", + "nameLabel": "Nazwa profilu", + "shortcutTitle": "Skrót postprocessingu ({{name}})", + "errors": { + "add": "Nie udało się dodać profilu", + "rename": "Nie udało się zmienić nazwy profilu", + "delete": "Nie udało się usunąć profilu" + } + }, "hotkey": { "title": "Skrót klawiszowy" }, @@ -468,7 +485,9 @@ "retranscribe": "Transkrybuj ponownie", "retranscribeError": "Nie udało się ponownie transkrybować. Spróbuj ponownie.", "transcribing": "Transkrybuję...", - "transcriptionFailed": "Transkrypcja nie powiodła się. Możesz transkrybować ponownie za pomocą ikony ponowienia." + "transcriptionFailed": "Transkrypcja nie powiodła się. Możesz transkrybować ponownie za pomocą ikony ponowienia.", + "postProcessProfile": "Profil: {{name}}", + "deletedProfile": "Profil usunięty" }, "debug": { "title": "Debugowanie", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Transkrypcja...", - "processing": "Przetwarzanie..." + "processing": "Przetwarzanie...", + "processingProfile": "Przetwarzanie ({{name}})..." }, "whatsNew": { "title": "Nowości w Handy v{{version}}" diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 1ade6f0de7..8b14228e6e 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -166,6 +166,7 @@ "loading": "Carregando atalhos...", "none": "Nenhum atalho configurado", "notFound": "Atalho não encontrado", + "unassigned": "Não definido", "pressKeys": "Pressione as teclas...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Perfis de pós-processamento", + "add": "Adicionar perfil", + "delete": "Excluir o perfil {{name}}", + "deleteTitle": "Excluir Perfil", + "deleteConfirm": "Excluir o perfil \"{{name}}\"? As configurações dele serão removidas e o atalho deixará de funcionar.", + "defaultName": "Padrão", + "renameHint": "Clique duas vezes para renomear", + "nameLabel": "Nome do perfil", + "shortcutTitle": "Tecla de Pós-Processamento ({{name}})", + "errors": { + "add": "Não foi possível adicionar o perfil", + "rename": "Não foi possível renomear o perfil", + "delete": "Não foi possível excluir o perfil" + } + }, "hotkey": { "title": "Tecla de atalho" }, @@ -468,7 +485,9 @@ "retranscribe": "Re-transcrever", "retranscribeError": "Falha ao re-transcrever. Por favor, tente novamente.", "transcribing": "Transcrevendo...", - "transcriptionFailed": "A transcrição falhou. Você pode re-transcrever usando o ícone de tentar novamente." + "transcriptionFailed": "A transcrição falhou. Você pode re-transcrever usando o ícone de tentar novamente.", + "postProcessProfile": "Perfil: {{name}}", + "deletedProfile": "Perfil excluído" }, "debug": { "title": "Depuração", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Transcrevendo...", - "processing": "Processando..." + "processing": "Processando...", + "processingProfile": "Processando ({{name}})..." }, "whatsNew": { "title": "Novidades no Handy v{{version}}" diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index d655b1512b..1d3d7b887c 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -186,6 +186,7 @@ "loading": "Загрузка ярлыков...", "none": "Ярлыки не настроены", "notFound": "Ярлык не найден", + "unassigned": "Не задано", "pressKeys": "Нажимайте клавиши...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Профили постобработки", + "add": "Добавить профиль", + "delete": "Удалить профиль {{name}}", + "deleteTitle": "Удалить профиль", + "deleteConfirm": "Удалить профиль «{{name}}»? Его настройки будут удалены, а горячая клавиша перестанет работать.", + "defaultName": "По умолчанию", + "renameHint": "Дважды щёлкните, чтобы переименовать", + "nameLabel": "Название профиля", + "shortcutTitle": "Горячая клавиша постобработки ({{name}})", + "errors": { + "add": "Не удалось добавить профиль", + "rename": "Не удалось переименовать профиль", + "delete": "Не удалось удалить профиль" + } + }, "hotkey": { "title": "Горячая клавиша" }, @@ -468,7 +485,9 @@ "retranscribe": "Перетранскрибировать", "retranscribeError": "Не удалось перетранскрибировать. Пожалуйста, попробуйте снова.", "transcribing": "Транскрибирование...", - "transcriptionFailed": "Транскрипция не удалась. Вы можете перетранскрибировать, используя значок повтора." + "transcriptionFailed": "Транскрипция не удалась. Вы можете перетранскрибировать, используя значок повтора.", + "postProcessProfile": "Профиль: {{name}}", + "deletedProfile": "Профиль удалён" }, "debug": { "title": "Отлаживать", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Расшифровка...", - "processing": "Обработка..." + "processing": "Обработка...", + "processingProfile": "Обработка ({{name}})..." }, "whatsNew": { "title": "Новое в Handy v{{version}}" diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index 3e554e465b..7ce63cdec6 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -166,6 +166,7 @@ "loading": "Laddar kortkommandon...", "none": "Inga kortkommandon konfigurerade", "notFound": "Kortkommando hittades inte", + "unassigned": "Inte angivet", "pressKeys": "Tryck på tangenter...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Efterbearbetningsprofiler", + "add": "Lägg till profil", + "delete": "Ta bort profilen {{name}}", + "deleteTitle": "Ta bort profil", + "deleteConfirm": "Ta bort profilen \"{{name}}\"? Dess inställningar tas bort och dess kortkommando slutar fungera.", + "defaultName": "Standard", + "renameHint": "Dubbelklicka för att byta namn", + "nameLabel": "Profilnamn", + "shortcutTitle": "Snabbkommando för efterbearbetning ({{name}})", + "errors": { + "add": "Det gick inte att lägga till profilen", + "rename": "Det gick inte att byta namn på profilen", + "delete": "Det gick inte att ta bort profilen" + } + }, "hotkey": { "title": "Snabbkommando" }, @@ -468,7 +485,9 @@ "retranscribe": "Transkribera igen", "retranscribeError": "Misslyckades med att transkribera igen. Försök igen.", "transcribing": "Transkriberar...", - "transcriptionFailed": "Transkribering misslyckades. Du kan transkribera igen med hjälp av ikonen för nytt försök." + "transcriptionFailed": "Transkribering misslyckades. Du kan transkribera igen med hjälp av ikonen för nytt försök.", + "postProcessProfile": "Profil: {{name}}", + "deletedProfile": "Profilen har tagits bort" }, "debug": { "title": "Felsökning", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Transkriberar...", - "processing": "Bearbetar..." + "processing": "Bearbetar...", + "processingProfile": "Bearbetar ({{name}})..." }, "whatsNew": { "title": "Nytt i Handy v{{version}}" diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index e809b5cf9b..5a72c34ecc 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -186,6 +186,7 @@ "loading": "Kısayollar yükleniyor...", "none": "Kısayol yapılandırılmadı", "notFound": "Kısayol bulunamadı", + "unassigned": "Ayarlanmadı", "pressKeys": "Tuşlara basın...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Son işleme profilleri", + "add": "Profil ekle", + "delete": "{{name}} profilini sil", + "deleteTitle": "Profili Sil", + "deleteConfirm": "\"{{name}}\" profili silinsin mi? Ayarları kaldırılacak ve kısayolu çalışmayı durduracak.", + "defaultName": "Varsayılan", + "renameHint": "Yeniden adlandırmak için çift tıklayın", + "nameLabel": "Profil adı", + "shortcutTitle": "Son İşlem Kısayolu ({{name}})", + "errors": { + "add": "Profil eklenemedi", + "rename": "Profil yeniden adlandırılamadı", + "delete": "Profil silinemedi" + } + }, "hotkey": { "title": "Kısayol Tuşu" }, @@ -468,7 +485,9 @@ "retranscribe": "Yeniden transkribe et", "retranscribeError": "Yeniden transkripsiyon başarısız oldu. Lütfen tekrar deneyin.", "transcribing": "Transkribe ediliyor...", - "transcriptionFailed": "Transkripsiyon başarısız oldu. Yeniden deneme simgesini kullanarak tekrar transkribe edebilirsiniz." + "transcriptionFailed": "Transkripsiyon başarısız oldu. Yeniden deneme simgesini kullanarak tekrar transkribe edebilirsiniz.", + "postProcessProfile": "Profil: {{name}}", + "deletedProfile": "Profil silindi" }, "debug": { "title": "Hata Ayıklama", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Transkribe ediliyor...", - "processing": "İşleniyor..." + "processing": "İşleniyor...", + "processingProfile": "İşleniyor ({{name}})..." }, "whatsNew": { "title": "Handy v{{version}} yenilikleri" diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 8ee3c8a3c3..d845133e93 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -166,6 +166,7 @@ "loading": "Завантаження скорочень...", "none": "Скорочення не налаштовані", "notFound": "Скорочення не знайдено", + "unassigned": "Не задано", "pressKeys": "Натисніть клавіші...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Профілі постобробки", + "add": "Додати профіль", + "delete": "Видалити профіль {{name}}", + "deleteTitle": "Видалити профіль", + "deleteConfirm": "Видалити профіль «{{name}}»? Його налаштування буде видалено, а гаряча клавіша перестане працювати.", + "defaultName": "За замовчуванням", + "renameHint": "Двічі клацніть, щоб перейменувати", + "nameLabel": "Назва профілю", + "shortcutTitle": "Гаряча клавіша постобробки ({{name}})", + "errors": { + "add": "Не вдалося додати профіль", + "rename": "Не вдалося перейменувати профіль", + "delete": "Не вдалося видалити профіль" + } + }, "hotkey": { "title": "Гаряча клавіша" }, @@ -468,7 +485,9 @@ "retranscribe": "Перетранскрибувати", "retranscribeError": "Не вдалося перетранскрибувати. Будь ласка, спробуйте ще раз.", "transcribing": "Транскрибування...", - "transcriptionFailed": "Транскрипція не вдалася. Ви можете перетранскрибувати за допомогою іконки повтору." + "transcriptionFailed": "Транскрипція не вдалася. Ви можете перетранскрибувати за допомогою іконки повтору.", + "postProcessProfile": "Профіль: {{name}}", + "deletedProfile": "Профіль видалено" }, "debug": { "title": "Дебаг", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Обробка...", - "processing": "Постобробка..." + "processing": "Постобробка...", + "processingProfile": "Постобробка ({{name}})..." }, "whatsNew": { "title": "Нове в Handy v{{version}}" diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index c0e5d04a1d..a5fbc44298 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -186,6 +186,7 @@ "loading": "Đang tải phím tắt...", "none": "Chưa cấu hình phím tắt", "notFound": "Không tìm thấy phím tắt", + "unassigned": "Chưa đặt", "pressKeys": "Nhấn phím...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "Hồ sơ hậu xử lý", + "add": "Thêm hồ sơ", + "delete": "Xóa hồ sơ {{name}}", + "deleteTitle": "Xóa hồ sơ", + "deleteConfirm": "Xóa hồ sơ \"{{name}}\"? Cài đặt của hồ sơ sẽ bị xóa và phím tắt của nó sẽ ngừng hoạt động.", + "defaultName": "Mặc định", + "renameHint": "Nhấp đúp để đổi tên", + "nameLabel": "Tên hồ sơ", + "shortcutTitle": "Phím tắt xử lý sau ({{name}})", + "errors": { + "add": "Không thể thêm hồ sơ", + "rename": "Không thể đổi tên hồ sơ", + "delete": "Không thể xóa hồ sơ" + } + }, "hotkey": { "title": "Phím tắt" }, @@ -468,7 +485,9 @@ "retranscribe": "Phiên âm lại", "retranscribeError": "Không thể phiên âm lại. Vui lòng thử lại.", "transcribing": "Đang phiên âm...", - "transcriptionFailed": "Phiên âm thất bại. Bạn có thể phiên âm lại bằng biểu tượng thử lại." + "transcriptionFailed": "Phiên âm thất bại. Bạn có thể phiên âm lại bằng biểu tượng thử lại.", + "postProcessProfile": "Hồ sơ: {{name}}", + "deletedProfile": "Hồ sơ đã bị xóa" }, "debug": { "title": "Gỡ lỗi", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "Đang chuyển đổi...", - "processing": "Đang xử lý..." + "processing": "Đang xử lý...", + "processingProfile": "Đang xử lý ({{name}})..." }, "whatsNew": { "title": "Điểm mới trong Handy v{{version}}" diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 4eb969ec36..c1e08b18f0 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -166,6 +166,7 @@ "loading": "載入快捷鍵中...", "none": "未設定快捷鍵", "notFound": "未找到快捷鍵", + "unassigned": "未設定", "pressKeys": "請按鍵...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "後處理設定檔", + "add": "新增設定檔", + "delete": "刪除設定檔 {{name}}", + "deleteTitle": "刪除設定檔", + "deleteConfirm": "要刪除設定檔「{{name}}」嗎?其設定將被移除,快捷鍵也將失效。", + "defaultName": "預設", + "renameHint": "按兩下以重新命名", + "nameLabel": "設定檔名稱", + "shortcutTitle": "後處理快捷鍵({{name}})", + "errors": { + "add": "無法新增設定檔", + "rename": "無法重新命名設定檔", + "delete": "無法刪除設定檔" + } + }, "hotkey": { "title": "快捷鍵" }, @@ -468,7 +485,9 @@ "retranscribe": "重新轉錄", "retranscribeError": "重新轉錄失敗,請重試。", "transcribing": "轉錄中...", - "transcriptionFailed": "轉錄失敗。您可以使用重試圖示重新轉錄。" + "transcriptionFailed": "轉錄失敗。您可以使用重試圖示重新轉錄。", + "postProcessProfile": "設定檔:{{name}}", + "deletedProfile": "設定檔已刪除" }, "debug": { "title": "偵錯", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "正在轉錄...", - "processing": "處理中..." + "processing": "處理中...", + "processingProfile": "處理中({{name}})..." }, "whatsNew": { "title": "Handy v{{version}} 新功能" diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 36c971e9c0..2e5443cf4c 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -186,6 +186,7 @@ "loading": "加载快捷键中...", "none": "未配置快捷键", "notFound": "未找到快捷键", + "unassigned": "未设置", "pressKeys": "请按键...", "bindings": { "transcribe": { @@ -401,6 +402,22 @@ } }, "postProcessing": { + "profiles": { + "title": "后处理配置", + "add": "添加配置", + "delete": "删除配置 {{name}}", + "deleteTitle": "删除配置", + "deleteConfirm": "删除配置“{{name}}”?其设置将被移除,快捷键也将失效。", + "defaultName": "默认", + "renameHint": "双击重命名", + "nameLabel": "配置名称", + "shortcutTitle": "后处理快捷键({{name}})", + "errors": { + "add": "无法添加配置", + "rename": "无法重命名配置", + "delete": "无法删除配置" + } + }, "hotkey": { "title": "快捷键" }, @@ -468,7 +485,9 @@ "retranscribe": "重新转录", "retranscribeError": "重新转录失败。请重试。", "transcribing": "转录中...", - "transcriptionFailed": "转录失败。您可以使用重试图标重新转录。" + "transcriptionFailed": "转录失败。您可以使用重试图标重新转录。", + "postProcessProfile": "配置:{{name}}", + "deletedProfile": "配置已删除" }, "debug": { "title": "调试", @@ -699,7 +718,8 @@ }, "overlay": { "transcribing": "正在转录...", - "processing": "处理中..." + "processing": "处理中...", + "processingProfile": "处理中({{name}})..." }, "whatsNew": { "title": "Handy v{{version}} 新功能" diff --git a/src/lib/utils/postProcessProfile.ts b/src/lib/utils/postProcessProfile.ts new file mode 100644 index 0000000000..9bcfb851e9 --- /dev/null +++ b/src/lib/utils/postProcessProfile.ts @@ -0,0 +1,19 @@ +import type { TFunction } from "i18next"; +import type { PostProcessProfile } from "@/bindings"; + +export const DEFAULT_PROFILE_ID = "default"; + +/** Longest profile name the backend accepts (MAX_POST_PROCESS_PROFILE_NAME_LEN). */ +export const MAX_PROFILE_NAME_LENGTH = 40; + +/** + * Label of a post-processing profile. The built-in profile stores no name + * until renamed, so it is shown as "Default" in the current language. + */ +export function getProfileDisplayName( + profile: Pick, + t: TFunction, +): string { + const name = profile.name?.trim(); + return name || t("settings.postProcessing.profiles.defaultName"); +} diff --git a/src/overlay/RecordingOverlay.tsx b/src/overlay/RecordingOverlay.tsx index dbab41e1a5..41c7a81ab1 100644 --- a/src/overlay/RecordingOverlay.tsx +++ b/src/overlay/RecordingOverlay.tsx @@ -11,6 +11,7 @@ import type { } from "@/bindings"; import i18n, { syncLanguageFromSettings } from "@/i18n"; import { getLanguageDirection } from "@/lib/utils/rtl"; +import { getProfileDisplayName } from "@/lib/utils/postProcessProfile"; type OverlayState = "recording" | "streaming" | "transcribing" | "processing"; @@ -33,6 +34,8 @@ const RecordingOverlay: React.FC = () => { }); const [phase, setPhase] = useState("listening"); const [workKind, setWorkKind] = useState("transcribing"); + // Name of the post-processing profile being applied, if any. + const [profileName, setProfileName] = useState(null); const [elapsed, setElapsed] = useState(0); // Bumped on each new streaming session so the Live card remounts fresh (replays // the pop-in, and never animates in from the previous panel's open size). @@ -64,6 +67,7 @@ const RecordingOverlay: React.FC = () => { smoothedLevelsRef.current = Array(16).fill(0); setLevels(Array(WAVE_BARS).fill(0)); setStreamText({ committed: "", tentative: "" }); + setProfileName(null); } await syncLanguageFromSettings(); @@ -89,6 +93,11 @@ const RecordingOverlay: React.FC = () => { setIsVisible(true); }); + const unlistenProfile = await listen( + "overlay-post-process-profile", + (event) => setProfileName(event.payload), + ); + const unlistenHide = await listen("hide-overlay", () => { setIsVisible(false); setCaptureReady(false); @@ -123,6 +132,7 @@ const RecordingOverlay: React.FC = () => { return () => { unlistenShow(); + unlistenProfile(); unlistenHide(); unlistenReady(); unlistenLevel(); @@ -217,6 +227,14 @@ const RecordingOverlay: React.FC = () => { // spinner (left) | label (center) | cancel (right) — same 3-zone grid as the // listening row, so the label is centered. + // null: no profile known (plain label); "": the unrenamed Default profile. + const processingLabel = + profileName === null + ? t("overlay.processing") + : t("overlay.processingProfile", { + name: getProfileDisplayName({ name: profileName }, t), + }); + const workingRow = (label: string, showCancel: boolean) => (
@@ -269,7 +287,7 @@ const RecordingOverlay: React.FC = () => { {working ? workingRow( workKind === "polishing" - ? t("overlay.processing") + ? processingLabel : t("overlay.transcribing"), true, ) @@ -284,9 +302,7 @@ const RecordingOverlay: React.FC = () => { // width between them; the cancel button is in both rows so it stays put. const working = state === "transcribing" || state === "processing"; const workLabel = - state === "processing" - ? t("overlay.processing") - : t("overlay.transcribing"); + state === "processing" ? processingLabel : t("overlay.transcribing"); return (
boolean; playTestSound: (soundType: "start" | "stop") => Promise; checkCustomSounds: () => Promise; - setPostProcessProvider: (providerId: string) => Promise; + setPostProcessProvider: ( + profileId: string, + providerId: string, + ) => Promise; updatePostProcessSetting: ( settingType: "base_url" | "api_key" | "model", + profileId: string, providerId: string, value: string, ) => Promise; updatePostProcessBaseUrl: ( + profileId: string, providerId: string, baseUrl: string, ) => Promise; updatePostProcessApiKey: ( + profileId: string, providerId: string, apiKey: string, ) => Promise; - updatePostProcessModel: (providerId: string, model: string) => Promise; - fetchPostProcessModels: (providerId: string) => Promise; - setPostProcessModelOptions: (providerId: string, models: string[]) => void; + updatePostProcessModel: ( + profileId: string, + providerId: string, + model: string, + ) => Promise; + fetchPostProcessModels: ( + profileId: string, + providerId: string, + ) => Promise; + setPostProcessModelOptions: ( + profileId: string, + providerId: string, + models: string[], + ) => void; // Internal state setters setSettings: (settings: Settings | null) => void; @@ -79,6 +96,24 @@ const DEFAULT_AUDIO_DEVICE: AudioDevice = { is_default: true, }; +// Post-processing state is per profile, so cached model lists and in-flight +// update flags are keyed by profile as well as provider. +export const postProcessKey = (profileId: string, providerId: string) => + `${profileId}:${providerId}`; + +const withProfileProvider = ( + settings: Settings, + profileId: string, + providerId: string, +): Settings => ({ + ...settings, + post_process_profiles: settings.post_process_profiles?.map((profile) => + profile.id === profileId + ? { ...profile, provider_id: providerId } + : profile, + ), +}); + const settingUpdaters: { [K in keyof Settings]?: (value: Settings[K]) => Promise; } = { @@ -156,8 +191,6 @@ const settingUpdaters: { history_limit: (value) => commands.updateHistoryLimit(value as number), post_process_enabled: (value) => commands.changePostProcessEnabledSetting(value as boolean), - post_process_selected_prompt_id: (value) => - commands.setPostProcessSelectedPrompt(value as string), mute_while_recording: (value) => commands.changeMuteWhileRecordingSetting(value as boolean), append_trailing_space: (value) => @@ -433,39 +466,41 @@ export const useSettingsStore = create()( } }, - setPostProcessProvider: async (providerId) => { + setPostProcessProvider: async (profileId, providerId) => { const { settings, setUpdating, refreshSettings, setPostProcessModelOptions, } = get(); - const updateKey = "post_process_provider_id"; - const previousId = settings?.post_process_provider_id ?? null; + const updateKey = `post_process_provider_id:${profileId}`; + const previousId = + settings?.post_process_profiles?.find((p) => p.id === profileId) + ?.provider_id ?? null; setUpdating(updateKey, true); if (settings) { set((state) => ({ settings: state.settings - ? { ...state.settings, post_process_provider_id: providerId } + ? withProfileProvider(state.settings, profileId, providerId) : null, })); } // Clear cached model options for the new provider so the dropdown // doesn't show stale models from a previous fetch or base_url. - setPostProcessModelOptions(providerId, []); + setPostProcessModelOptions(profileId, providerId, []); try { - await commands.setPostProcessProvider(providerId); + await commands.setPostProcessProvider(profileId, providerId); await refreshSettings(); } catch (error) { console.error("Failed to set post-process provider:", error); if (previousId !== null) { set((state) => ({ settings: state.settings - ? { ...state.settings, post_process_provider_id: previousId } + ? withProfileProvider(state.settings, profileId, previousId) : null, })); } @@ -477,21 +512,34 @@ export const useSettingsStore = create()( // Generic updater for post-processing provider settings updatePostProcessSetting: async ( settingType: "base_url" | "api_key" | "model", + profileId: string, providerId: string, value: string, ) => { const { setUpdating, refreshSettings } = get(); - const updateKey = `post_process_${settingType}:${providerId}`; + const updateKey = `post_process_${settingType}:${postProcessKey(profileId, providerId)}`; setUpdating(updateKey, true); try { if (settingType === "base_url") { - await commands.changePostProcessBaseUrlSetting(providerId, value); + await commands.changePostProcessBaseUrlSetting( + profileId, + providerId, + value, + ); } else if (settingType === "api_key") { - await commands.changePostProcessApiKeySetting(providerId, value); + await commands.changePostProcessApiKeySetting( + profileId, + providerId, + value, + ); } else if (settingType === "model") { - await commands.changePostProcessModelSetting(providerId, value); + await commands.changePostProcessModelSetting( + profileId, + providerId, + value, + ); } await refreshSettings(); } catch (error) { @@ -504,15 +552,17 @@ export const useSettingsStore = create()( } }, - updatePostProcessBaseUrl: async (providerId, baseUrl) => { - const { setUpdating, refreshSettings } = get(); - const updateKey = `post_process_base_url:${providerId}`; + updatePostProcessBaseUrl: async (profileId, providerId, baseUrl) => { + const { setUpdating, refreshSettings, setPostProcessModelOptions } = + get(); + const updateKey = `post_process_base_url:${postProcessKey(profileId, providerId)}`; setUpdating(updateKey, true); try { // Persist the new base URL first. const urlResult = await commands.changePostProcessBaseUrlSetting( + profileId, providerId, baseUrl, ); @@ -525,6 +575,7 @@ export const useSettingsStore = create()( // invalid for the new endpoint (e.g. switching Custom from Groq to // Cerebras). Only proceed if the reset succeeds. const modelResult = await commands.changePostProcessModelSetting( + profileId, providerId, "", ); @@ -534,12 +585,7 @@ export const useSettingsStore = create()( } // Clear cached model options only after both backend writes succeed. - set((state) => ({ - postProcessModelOptions: { - ...state.postProcessModelOptions, - [providerId]: [], - }, - })); + setPostProcessModelOptions(profileId, providerId, []); // Single refresh after both backend writes. await refreshSettings(); @@ -550,32 +596,40 @@ export const useSettingsStore = create()( } }, - updatePostProcessApiKey: async (providerId, apiKey) => { + updatePostProcessApiKey: async (profileId, providerId, apiKey) => { // Clear cached models when API key changes - user should click refresh after - set((state) => ({ - postProcessModelOptions: { - ...state.postProcessModelOptions, - [providerId]: [], - }, - })); - return get().updatePostProcessSetting("api_key", providerId, apiKey); + get().setPostProcessModelOptions(profileId, providerId, []); + return get().updatePostProcessSetting( + "api_key", + profileId, + providerId, + apiKey, + ); }, - updatePostProcessModel: async (providerId, model) => { - return get().updatePostProcessSetting("model", providerId, model); + updatePostProcessModel: async (profileId, providerId, model) => { + return get().updatePostProcessSetting( + "model", + profileId, + providerId, + model, + ); }, - fetchPostProcessModels: async (providerId) => { - const updateKey = `post_process_models_fetch:${providerId}`; + fetchPostProcessModels: async (profileId, providerId) => { + const updateKey = `post_process_models_fetch:${postProcessKey(profileId, providerId)}`; const { setUpdating, setPostProcessModelOptions } = get(); setUpdating(updateKey, true); try { // Call Tauri backend command instead of fetch - const result = await commands.fetchPostProcessModels(providerId); + const result = await commands.fetchPostProcessModels( + profileId, + providerId, + ); if (result.status === "ok") { - setPostProcessModelOptions(providerId, result.data); + setPostProcessModelOptions(profileId, providerId, result.data); return result.data; } else { console.error("Failed to fetch models:", result.error); @@ -590,11 +644,11 @@ export const useSettingsStore = create()( } }, - setPostProcessModelOptions: (providerId, models) => + setPostProcessModelOptions: (profileId, providerId, models) => set((state) => ({ postProcessModelOptions: { ...state.postProcessModelOptions, - [providerId]: models, + [postProcessKey(profileId, providerId)]: models, }, })),