From d3dfa53500f98effebef87b02dd2157033b49194 Mon Sep 17 00:00:00 2001 From: BLC Core Studio <207100624+BLCCoreStudio@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:28:57 +0300 Subject: [PATCH 01/16] feat: add local dictionary and replacement service --- apps/desktop/src-tauri/src/text_rules.rs | 613 +++++++++++++++++++++++ 1 file changed, 613 insertions(+) create mode 100644 apps/desktop/src-tauri/src/text_rules.rs diff --git a/apps/desktop/src-tauri/src/text_rules.rs b/apps/desktop/src-tauri/src/text_rules.rs new file mode 100644 index 0000000..7bc368f --- /dev/null +++ b/apps/desktop/src-tauri/src/text_rules.rs @@ -0,0 +1,613 @@ +use std::error::Error; +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; + +use serde::{Deserialize, Serialize}; + +const TEXT_RULES_FILE_NAME: &str = "text-rules.json"; +const TEXT_RULES_SCHEMA_VERSION: u32 = 1; + +static TEXT_RULES: OnceLock> = OnceLock::new(); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DictionaryEntry { + id: u64, + term: String, + aliases: Vec, + enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReplacementRule { + id: u64, + from: String, + to: String, + enabled: bool, + whole_word: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +struct TextRulesDocument { + schema_version: u32, + next_id: u64, + dictionary: Vec, + replacements: Vec, +} + +impl Default for TextRulesDocument { + fn default() -> Self { + Self { + schema_version: TEXT_RULES_SCHEMA_VERSION, + next_id: 1, + dictionary: Vec::new(), + replacements: Vec::new(), + } + } +} + +impl TextRulesDocument { + fn normalize(&mut self) -> Result<(), TextRulesError> { + self.schema_version = TEXT_RULES_SCHEMA_VERSION; + let mut highest_id = 0_u64; + + for entry in &mut self.dictionary { + entry.term = normalize_required(&entry.term, "dictionary term")?; + entry.aliases = normalize_list(&entry.aliases); + entry.aliases.retain(|alias| alias != &entry.term); + highest_id = highest_id.max(entry.id); + } + for rule in &mut self.replacements { + rule.from = normalize_required(&rule.from, "replacement source")?; + rule.to = normalize_required(&rule.to, "replacement destination")?; + highest_id = highest_id.max(rule.id); + } + + self.next_id = self.next_id.max(highest_id.saturating_add(1)).max(1); + Ok(()) + } + + fn allocate_id(&mut self) -> u64 { + let id = self.next_id.max(1); + self.next_id = id.saturating_add(1); + id + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextRulesSnapshot { + dictionary: Vec, + replacements: Vec, +} + +impl From<&TextRulesDocument> for TextRulesSnapshot { + fn from(document: &TextRulesDocument) -> Self { + Self { + dictionary: document.dictionary.clone(), + replacements: document.replacements.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextRulesError { + message: String, +} + +impl TextRulesError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + #[must_use] + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for TextRulesError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for TextRulesError {} + +#[derive(Debug)] +pub struct TextRulesService { + path: PathBuf, + state: Mutex, +} + +impl TextRulesService { + pub fn open(config_dir: impl Into) -> Result { + let config_dir = config_dir.into(); + fs::create_dir_all(&config_dir).map_err(|error| { + TextRulesError::new(format!( + "could not create BLCVoice config directory {}: {error}", + config_dir.display() + )) + })?; + let path = config_dir.join(TEXT_RULES_FILE_NAME); + let mut document = match fs::read(&path) { + Ok(bytes) => serde_json::from_slice::(&bytes).map_err(|error| { + TextRulesError::new(format!( + "could not parse BLCVoice text rules {}: {error}", + path.display() + )) + })?, + Err(error) if error.kind() == io::ErrorKind::NotFound => TextRulesDocument::default(), + Err(error) => { + return Err(TextRulesError::new(format!( + "could not read BLCVoice text rules {}: {error}", + path.display() + ))); + } + }; + + if document.schema_version > TEXT_RULES_SCHEMA_VERSION { + return Err(TextRulesError::new(format!( + "text-rules schema {} is newer than this BLCVoice build supports ({TEXT_RULES_SCHEMA_VERSION})", + document.schema_version + ))); + } + document.normalize()?; + + Ok(Self { + path, + state: Mutex::new(document), + }) + } + + #[must_use] + pub fn snapshot(&self) -> TextRulesSnapshot { + TextRulesSnapshot::from(&*self.lock_state()) + } + + pub fn save_dictionary( + &self, + id: Option, + term: String, + aliases: Vec, + enabled: Option, + ) -> Result { + let term = normalize_required(&term, "dictionary term")?; + let mut aliases = normalize_list(&aliases); + aliases.retain(|alias| alias != &term); + self.update(|document| { + if let Some(id) = id { + let entry = document + .dictionary + .iter_mut() + .find(|entry| entry.id == id) + .ok_or_else(|| TextRulesError::new(format!("dictionary entry {id} does not exist")))?; + entry.term = term; + entry.aliases = aliases; + entry.enabled = enabled.unwrap_or(entry.enabled); + return Ok(entry.clone()); + } + + let entry = DictionaryEntry { + id: document.allocate_id(), + term, + aliases, + enabled: enabled.unwrap_or(true), + }; + document.dictionary.push(entry.clone()); + Ok(entry) + }) + } + + pub fn delete_dictionary(&self, id: u64) -> Result { + self.update(|document| { + let before = document.dictionary.len(); + document.dictionary.retain(|entry| entry.id != id); + Ok(document.dictionary.len() != before) + }) + } + + pub fn save_replacement( + &self, + id: Option, + from: String, + to: String, + enabled: Option, + whole_word: Option, + ) -> Result { + let from = normalize_required(&from, "replacement source")?; + let to = normalize_required(&to, "replacement destination")?; + self.update(|document| { + if let Some(id) = id { + let rule = document + .replacements + .iter_mut() + .find(|rule| rule.id == id) + .ok_or_else(|| TextRulesError::new(format!("replacement rule {id} does not exist")))?; + rule.from = from; + rule.to = to; + rule.enabled = enabled.unwrap_or(rule.enabled); + rule.whole_word = whole_word.unwrap_or(rule.whole_word); + return Ok(rule.clone()); + } + + let rule = ReplacementRule { + id: document.allocate_id(), + from, + to, + enabled: enabled.unwrap_or(true), + whole_word: whole_word.unwrap_or(true), + }; + document.replacements.push(rule.clone()); + Ok(rule) + }) + } + + pub fn delete_replacement(&self, id: u64) -> Result { + self.update(|document| { + let before = document.replacements.len(); + document.replacements.retain(|rule| rule.id != id); + Ok(document.replacements.len() != before) + }) + } + + #[must_use] + pub fn apply(&self, text: &str) -> String { + let state = self.lock_state(); + apply_document(text, &state) + } + + fn update( + &self, + mutate: impl FnOnce(&mut TextRulesDocument) -> Result, + ) -> Result { + let mut state = self.lock_state(); + let mut next = state.clone(); + let result = mutate(&mut next)?; + next.normalize()?; + write_document(&self.path, &next)?; + *state = next; + Ok(result) + } + + fn lock_state(&self) -> MutexGuard<'_, TextRulesDocument> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +pub fn initialize(config_dir: PathBuf) -> Result<(), TextRulesError> { + if TEXT_RULES.get().is_some() { + return Ok(()); + } + let service = Arc::new(TextRulesService::open(config_dir)?); + TEXT_RULES + .set(service) + .map_err(|_| TextRulesError::new("text rules service was initialized twice")) +} + +fn global() -> Result<&'static Arc, TextRulesError> { + TEXT_RULES + .get() + .ok_or_else(|| TextRulesError::new("text rules service is not initialized")) +} + +#[must_use] +pub fn apply_text_rules(text: &str) -> String { + global() + .map(|service| service.apply(text)) + .unwrap_or_else(|_| text.to_owned()) +} + +#[tauri::command] +pub fn text_rules_snapshot() -> Result { + global() + .map(|service| service.snapshot()) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn dictionary_save( + id: Option, + term: String, + aliases: Vec, + enabled: Option, +) -> Result { + global()? + .save_dictionary(id, term, aliases, enabled) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn dictionary_delete(id: u64) -> Result { + global()? + .delete_dictionary(id) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn replacement_save( + id: Option, + from: String, + to: String, + enabled: Option, + whole_word: Option, +) -> Result { + global()? + .save_replacement(id, from, to, enabled, whole_word) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn replacement_delete(id: u64) -> Result { + global()? + .delete_replacement(id) + .map_err(|error| error.to_string()) +} + +fn apply_document(text: &str, document: &TextRulesDocument) -> String { + let mut output = text.to_owned(); + + for entry in document.dictionary.iter().filter(|entry| entry.enabled) { + let mut sources = entry.aliases.clone(); + sources.push(entry.term.clone()); + sources.sort_by_key(|source| std::cmp::Reverse(source.chars().count())); + sources.dedup(); + for source in sources { + output = replace_literal(&output, &source, &entry.term, true, false); + } + } + + for rule in document.replacements.iter().filter(|rule| rule.enabled) { + output = replace_literal(&output, &rule.from, &rule.to, rule.whole_word, false); + } + output +} + +fn replace_literal( + input: &str, + needle: &str, + replacement: &str, + whole_word: bool, + case_sensitive: bool, +) -> String { + if needle.is_empty() || input.is_empty() { + return input.to_owned(); + } + + let folded_input; + let folded_needle; + let (haystack, search) = if case_sensitive { + (input, needle) + } else { + folded_input = input.to_lowercase(); + folded_needle = needle.to_lowercase(); + if folded_input.len() == input.len() && folded_needle.len() == needle.len() { + (folded_input.as_str(), folded_needle.as_str()) + } else { + // Unicode lowercase can change byte length for a small set of characters. + // Falling back to exact matching keeps byte offsets correct and UTF-8 safe. + (input, needle) + } + }; + + let mut output = String::with_capacity(input.len()); + let mut last = 0; + let mut changed = false; + for (start, _) in haystack.match_indices(search) { + let end = start + search.len(); + if start < last || (whole_word && !has_word_boundaries(input, start, end)) { + continue; + } + output.push_str(&input[last..start]); + output.push_str(replacement); + last = end; + changed = true; + } + if !changed { + return input.to_owned(); + } + output.push_str(&input[last..]); + output +} + +fn has_word_boundaries(input: &str, start: usize, end: usize) -> bool { + let previous = input[..start].chars().next_back(); + let next = input[end..].chars().next(); + !previous.is_some_and(is_word_character) && !next.is_some_and(is_word_character) +} + +fn is_word_character(character: char) -> bool { + character.is_alphanumeric() || character == '_' +} + +fn normalize_required(value: &str, label: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + Err(TextRulesError::new(format!("{label} cannot be blank"))) + } else { + Ok(value.to_owned()) + } +} + +fn normalize_list(values: &[String]) -> Vec { + let mut normalized = Vec::new(); + for value in values { + let value = value.trim(); + if value.is_empty() || normalized.iter().any(|existing| existing == value) { + continue; + } + normalized.push(value.to_owned()); + } + normalized +} + +fn write_document(path: &Path, document: &TextRulesDocument) -> Result<(), TextRulesError> { + let parent = path.parent().ok_or_else(|| { + TextRulesError::new(format!("text-rules path {} has no parent directory", path.display())) + })?; + fs::create_dir_all(parent).map_err(|error| { + TextRulesError::new(format!( + "could not create text-rules directory {}: {error}", + parent.display() + )) + })?; + + let temporary = path.with_extension("json.tmp"); + let bytes = serde_json::to_vec_pretty(document) + .map_err(|error| TextRulesError::new(format!("could not encode text rules: {error}")))?; + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&temporary) + .map_err(|error| { + TextRulesError::new(format!( + "could not open temporary text-rules file {}: {error}", + temporary.display() + )) + })?; + file.write_all(&bytes).map_err(|error| { + TextRulesError::new(format!( + "could not write temporary text-rules file {}: {error}", + temporary.display() + )) + })?; + file.write_all(b"\n").map_err(|error| { + TextRulesError::new(format!( + "could not finalize temporary text-rules file {}: {error}", + temporary.display() + )) + })?; + file.sync_all().map_err(|error| { + TextRulesError::new(format!( + "could not sync temporary text-rules file {}: {error}", + temporary.display() + )) + })?; + drop(file); + + #[cfg(target_os = "windows")] + if path.exists() { + fs::remove_file(path).map_err(|error| { + TextRulesError::new(format!( + "could not replace existing text-rules file {}: {error}", + path.display() + )) + })?; + } + + fs::rename(&temporary, path).map_err(|error| { + let _ = fs::remove_file(&temporary); + TextRulesError::new(format!( + "could not commit text-rules file {}: {error}", + path.display() + )) + }) +} + +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + + fn temporary_directory(test_name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock must be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "blcvoice-text-rules-{test_name}-{}-{nonce}", + std::process::id() + )) + } + + #[test] + fn replacement_is_case_insensitive_and_respects_word_boundaries() { + let document = TextRulesDocument { + replacements: vec![ReplacementRule { + id: 1, + from: "paysa".to_owned(), + to: "yapay zeka".to_owned(), + enabled: true, + whole_word: true, + }], + ..TextRulesDocument::default() + }; + assert_eq!( + apply_document("Paysa güzel ama paysalık değil.", &document), + "yapay zeka güzel ama paysalık değil." + ); + } + + #[test] + fn dictionary_aliases_preserve_turkish_and_emoji_text() { + let document = TextRulesDocument { + dictionary: vec![DictionaryEntry { + id: 1, + term: "BLCVoice".to_owned(), + aliases: vec!["blc voice".to_owned()], + enabled: true, + }], + ..TextRulesDocument::default() + }; + assert_eq!( + apply_document("Merhaba blc voice, nasılsın? 👋 Şimdi Türkçe.", &document), + "Merhaba BLCVoice, nasılsın? 👋 Şimdi Türkçe." + ); + } + + #[test] + fn disabled_rules_are_ignored() { + let document = TextRulesDocument { + replacements: vec![ReplacementRule { + id: 1, + from: "yanlış".to_owned(), + to: "doğru".to_owned(), + enabled: false, + whole_word: true, + }], + ..TextRulesDocument::default() + }; + assert_eq!(apply_document("yanlış", &document), "yanlış"); + } + + #[test] + fn service_round_trips_dictionary_and_replacements() { + let directory = temporary_directory("round-trip"); + let service = TextRulesService::open(&directory).expect("service must open"); + service + .save_dictionary( + None, + "OpenAI".to_owned(), + vec!["open ai".to_owned()], + None, + ) + .expect("dictionary entry must save"); + service + .save_replacement( + None, + "Paysa".to_owned(), + "yapay zeka".to_owned(), + None, + None, + ) + .expect("replacement must save"); + + let reopened = TextRulesService::open(&directory).expect("service must reopen"); + let snapshot = reopened.snapshot(); + assert_eq!(snapshot.dictionary.len(), 1); + assert_eq!(snapshot.replacements.len(), 1); + assert_eq!(reopened.apply("open ai ve PAYSA"), "OpenAI ve yapay zeka"); + let _ = fs::remove_dir_all(directory); + } +} From baa6c9663e328641c648915fce5a3dcf2538503a Mon Sep 17 00:00:00 2001 From: BLC Core Studio <207100624+BLCCoreStudio@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:29:22 +0300 Subject: [PATCH 02/16] feat: apply local text rules before insertion --- apps/desktop/src-tauri/src/insertion.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/insertion.rs b/apps/desktop/src-tauri/src/insertion.rs index 095e17c..f702d65 100644 --- a/apps/desktop/src-tauri/src/insertion.rs +++ b/apps/desktop/src-tauri/src/insertion.rs @@ -9,6 +9,8 @@ use blcvoice_insertion_native::{NativeInserter, NativeInsertionOptions}; use blcvoice_insertion_x11::{X11Inserter, X11Options}; use blcvoice_platform::{DesktopEnvironment, current_desktop_environment}; +use crate::text_rules::apply_text_rules; + struct InsertionState { inserter: Option>, wayland_restore_token: Option, @@ -72,6 +74,7 @@ impl DesktopInsertionService { } pub fn insert_text(&self, text: &str) -> Result { + let processed_text = apply_text_rules(text); let capability = self.capability()?; let mut state = self.lock_state(); if state.inserter.is_none() { @@ -82,7 +85,7 @@ impl DesktopInsertionService { .inserter .as_mut() .expect("inserter must exist after connection") - .insert_text(text); + .insert_text(&processed_text); if result.as_ref().is_err_and(|error| { matches!( From 16b8ee7b21af1e00e6113d8f7261bca80e5a5100 Mon Sep 17 00:00:00 2001 From: BLC Core Studio <207100624+BLCCoreStudio@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:29:59 +0300 Subject: [PATCH 03/16] feat: persist post-processed transcript text --- apps/desktop/src-tauri/src/history.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/history.rs b/apps/desktop/src-tauri/src/history.rs index 5134074..d00186d 100644 --- a/apps/desktop/src-tauri/src/history.rs +++ b/apps/desktop/src-tauri/src/history.rs @@ -11,6 +11,7 @@ use serde_json::Value; use tauri::{AppHandle, Manager, Runtime, State}; use crate::ipc::{CommandErrorDto, DesktopState, DictationReportDto}; +use crate::text_rules::apply_text_rules; const DEFAULT_HISTORY_LIMIT: u32 = 100; @@ -62,7 +63,7 @@ impl HistoryService { let result = report_history_metadata(report).and_then(|metadata| { self.append(NewHistoryEntry { created_at_unix_ms: unix_time_ms(), - transcript: report.text().to_owned(), + transcript: apply_text_rules(report.text()), invocation_source: origin.into(), engine_id: metadata.engine_id, model_id: metadata.model_id, @@ -90,7 +91,7 @@ impl HistoryService { // Preserve it without inventing recognizer provenance. let result = self.append(NewHistoryEntry { created_at_unix_ms: unix_time_ms(), - transcript: text.to_owned(), + transcript: apply_text_rules(text), invocation_source: origin.into(), engine_id: "unknown".to_owned(), model_id: "unknown".to_owned(), From fd1fbd3798e9063fdfc9b16e393092cd10ab7a7c Mon Sep 17 00:00:00 2001 From: BLC Core Studio <207100624+BLCCoreStudio@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:30:15 +0300 Subject: [PATCH 04/16] feat: expose dictionary and replacement commands --- apps/desktop/src-tauri/src/lib.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index deceec4..3dccdf0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod ipc; mod models; mod settings; mod shortcut; +mod text_rules; use coordinator::ShortcutDictationCoordinator; use history::{ @@ -24,6 +25,9 @@ use shortcut::{ShortcutService, install_shortcut_backend, shortcut_capability}; use tauri::menu::{Menu, MenuItem}; use tauri::tray::TrayIconBuilder; use tauri::{App, AppHandle, Manager, Runtime, WindowEvent}; +use text_rules::{ + dictionary_delete, dictionary_save, replacement_delete, replacement_save, text_rules_snapshot, +}; #[tauri::command] fn core_status() -> String { @@ -69,6 +73,7 @@ pub fn run() { .setup(|app| { let config_dir = app.path().app_config_dir()?; let data_dir = app.path().app_data_dir()?; + text_rules::initialize(config_dir.clone()).map_err(std::io::Error::other)?; let desktop = DesktopState::production(config_dir, data_dir.clone()) .map_err(std::io::Error::other)?; app.manage(HistoryService::production(data_dir)); @@ -108,6 +113,11 @@ pub fn run() { history_list, history_delete, shortcut_capability, + text_rules_snapshot, + dictionary_save, + dictionary_delete, + replacement_save, + replacement_delete, ]) .run(tauri::generate_context!()) .expect("failed to run BLCVoice desktop shell"); From 22aaa2e7a3a84f1d6cb7d94b87d1a6b7dd3d0a1d Mon Sep 17 00:00:00 2001 From: BLC Core Studio <207100624+BLCCoreStudio@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:32:48 +0300 Subject: [PATCH 05/16] fix: map text rule service errors in commands --- apps/desktop/src-tauri/src/text_rules.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/text_rules.rs b/apps/desktop/src-tauri/src/text_rules.rs index 7bc368f..295bee0 100644 --- a/apps/desktop/src-tauri/src/text_rules.rs +++ b/apps/desktop/src-tauri/src/text_rules.rs @@ -321,14 +321,16 @@ pub fn dictionary_save( aliases: Vec, enabled: Option, ) -> Result { - global()? + let service = global().map_err(|error| error.to_string())?; + service .save_dictionary(id, term, aliases, enabled) .map_err(|error| error.to_string()) } #[tauri::command] pub fn dictionary_delete(id: u64) -> Result { - global()? + let service = global().map_err(|error| error.to_string())?; + service .delete_dictionary(id) .map_err(|error| error.to_string()) } @@ -341,14 +343,16 @@ pub fn replacement_save( enabled: Option, whole_word: Option, ) -> Result { - global()? + let service = global().map_err(|error| error.to_string())?; + service .save_replacement(id, from, to, enabled, whole_word) .map_err(|error| error.to_string()) } #[tauri::command] pub fn replacement_delete(id: u64) -> Result { - global()? + let service = global().map_err(|error| error.to_string())?; + service .delete_replacement(id) .map_err(|error| error.to_string()) } @@ -452,7 +456,10 @@ fn normalize_list(values: &[String]) -> Vec { fn write_document(path: &Path, document: &TextRulesDocument) -> Result<(), TextRulesError> { let parent = path.parent().ok_or_else(|| { - TextRulesError::new(format!("text-rules path {} has no parent directory", path.display())) + TextRulesError::new(format!( + "text-rules path {} has no parent directory", + path.display() + )) })?; fs::create_dir_all(parent).map_err(|error| { TextRulesError::new(format!( From 6f986a4038269c12ee9a43a1fdb50e17fbe922f3 Mon Sep 17 00:00:00 2001 From: BLC Core Studio <207100624+BLCCoreStudio@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:33:54 +0300 Subject: [PATCH 06/16] feat: add dictionary and replacement UI behavior --- apps/desktop/ui/lexicon.js | 351 +++++++++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 apps/desktop/ui/lexicon.js diff --git a/apps/desktop/ui/lexicon.js b/apps/desktop/ui/lexicon.js new file mode 100644 index 0000000..18a6c9a --- /dev/null +++ b/apps/desktop/ui/lexicon.js @@ -0,0 +1,351 @@ +"use strict"; + +(() => { + const invokeCommand = window.__TAURI__?.core?.invoke; + const elements = { + search: document.getElementById("lexicon-search"), + message: document.getElementById("lexicon-message"), + dictionaryList: document.getElementById("dictionary-list"), + dictionaryForm: document.getElementById("dictionary-form"), + dictionaryId: document.getElementById("dictionary-id"), + dictionaryTerm: document.getElementById("dictionary-term"), + dictionaryAliases: document.getElementById("dictionary-aliases"), + dictionaryEnabled: document.getElementById("dictionary-enabled"), + dictionarySubmit: document.getElementById("dictionary-submit"), + dictionaryCancel: document.getElementById("dictionary-cancel-edit"), + replacementList: document.getElementById("replacement-list"), + replacementForm: document.getElementById("replacement-form"), + replacementId: document.getElementById("replacement-id"), + replacementFrom: document.getElementById("replacement-from"), + replacementTo: document.getElementById("replacement-to"), + replacementEnabled: document.getElementById("replacement-enabled"), + replacementWholeWord: document.getElementById("replacement-whole-word"), + replacementSubmit: document.getElementById("replacement-submit"), + replacementCancel: document.getElementById("replacement-cancel-edit"), + refresh: document.getElementById("refresh-lexicon"), + }; + + if (!elements.dictionaryForm || !elements.replacementForm) return; + + const state = { + dictionary: [], + replacements: [], + busy: false, + }; + + function errorMessage(error) { + if (typeof error === "string") return error; + if (error && typeof error.message === "string") return error.message; + return "The local vocabulary operation failed."; + } + + function setMessage(message, failed = false) { + elements.message.textContent = message || ""; + elements.message.classList.toggle("error", failed); + } + + function aliasesFromInput(value) { + return value + .split(/[\n,]/u) + .map((item) => item.trim()) + .filter(Boolean); + } + + function resetDictionaryForm() { + elements.dictionaryId.value = ""; + elements.dictionaryTerm.value = ""; + elements.dictionaryAliases.value = ""; + elements.dictionaryEnabled.checked = true; + elements.dictionarySubmit.textContent = "Add term"; + elements.dictionaryCancel.hidden = true; + } + + function resetReplacementForm() { + elements.replacementId.value = ""; + elements.replacementFrom.value = ""; + elements.replacementTo.value = ""; + elements.replacementEnabled.checked = true; + elements.replacementWholeWord.checked = true; + elements.replacementSubmit.textContent = "Add replacement"; + elements.replacementCancel.hidden = true; + } + + function matchesSearch(...values) { + const query = (elements.search.value || "").trim().toLocaleLowerCase(); + if (!query) return true; + return values + .flat() + .filter((value) => typeof value === "string") + .some((value) => value.toLocaleLowerCase().includes(query)); + } + + function actionButton(label, className, action) { + const button = document.createElement("button"); + button.type = "button"; + button.className = className; + button.textContent = label; + button.disabled = state.busy; + button.addEventListener("click", action); + return button; + } + + function statusBadge(enabled) { + const badge = document.createElement("span"); + badge.className = `lexicon-badge ${enabled ? "enabled" : "disabled"}`; + badge.textContent = enabled ? "Active" : "Paused"; + return badge; + } + + function renderDictionary() { + elements.dictionaryList.replaceChildren(); + const entries = state.dictionary.filter((entry) => + matchesSearch(entry.term, entry.aliases || []), + ); + if (!entries.length) { + const empty = document.createElement("p"); + empty.className = "lexicon-empty"; + empty.textContent = state.dictionary.length + ? "No dictionary terms match your search." + : "No custom terms yet. Add names, brands or words the recognizer often misses."; + elements.dictionaryList.append(empty); + return; + } + + for (const entry of entries) { + const card = document.createElement("article"); + card.className = "lexicon-item"; + const header = document.createElement("div"); + header.className = "lexicon-item-header"; + const title = document.createElement("strong"); + title.textContent = entry.term; + header.append(title, statusBadge(entry.enabled)); + card.append(header); + + const detail = document.createElement("p"); + detail.textContent = entry.aliases?.length + ? `Also recognize: ${entry.aliases.join(", ")}` + : "Canonical spelling only"; + card.append(detail); + + const actions = document.createElement("div"); + actions.className = "lexicon-actions"; + actions.append( + actionButton("Edit", "button secondary compact", () => { + elements.dictionaryId.value = String(entry.id); + elements.dictionaryTerm.value = entry.term; + elements.dictionaryAliases.value = (entry.aliases || []).join(", "); + elements.dictionaryEnabled.checked = entry.enabled; + elements.dictionarySubmit.textContent = "Save term"; + elements.dictionaryCancel.hidden = false; + elements.dictionaryTerm.focus(); + }), + actionButton( + entry.enabled ? "Pause" : "Enable", + "button secondary compact", + () => void saveDictionary({ ...entry, enabled: !entry.enabled }), + ), + actionButton("Delete", "button danger compact", () => void deleteDictionary(entry.id)), + ); + card.append(actions); + elements.dictionaryList.append(card); + } + } + + function renderReplacements() { + elements.replacementList.replaceChildren(); + const rules = state.replacements.filter((rule) => + matchesSearch(rule.from, rule.to), + ); + if (!rules.length) { + const empty = document.createElement("p"); + empty.className = "lexicon-empty"; + empty.textContent = state.replacements.length + ? "No replacement rules match your search." + : "No replacements yet. Add deterministic fixes for recurring recognition mistakes."; + elements.replacementList.append(empty); + return; + } + + for (const rule of rules) { + const card = document.createElement("article"); + card.className = "lexicon-item replacement-item"; + const header = document.createElement("div"); + header.className = "lexicon-item-header"; + const mapping = document.createElement("div"); + mapping.className = "replacement-mapping"; + const source = document.createElement("code"); + source.textContent = rule.from; + const arrow = document.createElement("span"); + arrow.textContent = "→"; + const target = document.createElement("code"); + target.textContent = rule.to; + mapping.append(source, arrow, target); + header.append(mapping, statusBadge(rule.enabled)); + card.append(header); + + const detail = document.createElement("p"); + detail.textContent = rule.wholeWord ? "Whole words only" : "Replace inside longer text too"; + card.append(detail); + + const actions = document.createElement("div"); + actions.className = "lexicon-actions"; + actions.append( + actionButton("Edit", "button secondary compact", () => { + elements.replacementId.value = String(rule.id); + elements.replacementFrom.value = rule.from; + elements.replacementTo.value = rule.to; + elements.replacementEnabled.checked = rule.enabled; + elements.replacementWholeWord.checked = rule.wholeWord; + elements.replacementSubmit.textContent = "Save replacement"; + elements.replacementCancel.hidden = false; + elements.replacementFrom.focus(); + }), + actionButton( + rule.enabled ? "Pause" : "Enable", + "button secondary compact", + () => void saveReplacement({ ...rule, enabled: !rule.enabled }), + ), + actionButton("Delete", "button danger compact", () => void deleteReplacement(rule.id)), + ); + card.append(actions); + elements.replacementList.append(card); + } + } + + function render() { + renderDictionary(); + renderReplacements(); + } + + async function refresh() { + if (!invokeCommand || state.busy) return; + elements.refresh.disabled = true; + try { + const snapshot = await invokeCommand("text_rules_snapshot"); + state.dictionary = Array.isArray(snapshot?.dictionary) ? snapshot.dictionary : []; + state.replacements = Array.isArray(snapshot?.replacements) ? snapshot.replacements : []; + setMessage( + `${state.dictionary.length} custom term${state.dictionary.length === 1 ? "" : "s"} · ${state.replacements.length} replacement${state.replacements.length === 1 ? "" : "s"}`, + ); + render(); + } catch (error) { + setMessage(errorMessage(error), true); + } finally { + elements.refresh.disabled = false; + } + } + + async function saveDictionary(entry) { + if (!invokeCommand || state.busy) return; + state.busy = true; + render(); + try { + await invokeCommand("dictionary_save", { + id: entry.id ?? null, + term: entry.term, + aliases: entry.aliases || [], + enabled: entry.enabled ?? true, + }); + resetDictionaryForm(); + await refresh(); + } catch (error) { + setMessage(errorMessage(error), true); + } finally { + state.busy = false; + render(); + } + } + + async function deleteDictionary(id) { + if (!invokeCommand || state.busy) return; + state.busy = true; + try { + await invokeCommand("dictionary_delete", { id }); + resetDictionaryForm(); + await refresh(); + } catch (error) { + setMessage(errorMessage(error), true); + } finally { + state.busy = false; + render(); + } + } + + async function saveReplacement(rule) { + if (!invokeCommand || state.busy) return; + state.busy = true; + render(); + try { + await invokeCommand("replacement_save", { + id: rule.id ?? null, + from: rule.from, + to: rule.to, + enabled: rule.enabled ?? true, + wholeWord: rule.wholeWord ?? true, + }); + resetReplacementForm(); + await refresh(); + } catch (error) { + setMessage(errorMessage(error), true); + } finally { + state.busy = false; + render(); + } + } + + async function deleteReplacement(id) { + if (!invokeCommand || state.busy) return; + state.busy = true; + try { + await invokeCommand("replacement_delete", { id }); + resetReplacementForm(); + await refresh(); + } catch (error) { + setMessage(errorMessage(error), true); + } finally { + state.busy = false; + render(); + } + } + + elements.dictionaryForm.addEventListener("submit", (event) => { + event.preventDefault(); + const term = elements.dictionaryTerm.value.trim(); + if (!term) { + setMessage("Dictionary term cannot be blank.", true); + return; + } + void saveDictionary({ + id: elements.dictionaryId.value ? Number(elements.dictionaryId.value) : null, + term, + aliases: aliasesFromInput(elements.dictionaryAliases.value), + enabled: elements.dictionaryEnabled.checked, + }); + }); + + elements.replacementForm.addEventListener("submit", (event) => { + event.preventDefault(); + const from = elements.replacementFrom.value.trim(); + const to = elements.replacementTo.value.trim(); + if (!from || !to) { + setMessage("Replacement source and destination are required.", true); + return; + } + void saveReplacement({ + id: elements.replacementId.value ? Number(elements.replacementId.value) : null, + from, + to, + enabled: elements.replacementEnabled.checked, + wholeWord: elements.replacementWholeWord.checked, + }); + }); + + elements.dictionaryCancel.addEventListener("click", resetDictionaryForm); + elements.replacementCancel.addEventListener("click", resetReplacementForm); + elements.search.addEventListener("input", render); + elements.refresh.addEventListener("click", () => void refresh()); + + resetDictionaryForm(); + resetReplacementForm(); + void refresh(); +})(); From 47b9eddac098b83ab5706bc81a50507c2dc91a39 Mon Sep 17 00:00:00 2001 From: BLC Core Studio <207100624+BLCCoreStudio@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:34:18 +0300 Subject: [PATCH 07/16] style: add dictionary and replacement surfaces --- apps/desktop/ui/lexicon.css | 193 ++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 apps/desktop/ui/lexicon.css diff --git a/apps/desktop/ui/lexicon.css b/apps/desktop/ui/lexicon.css new file mode 100644 index 0000000..f7b968b --- /dev/null +++ b/apps/desktop/ui/lexicon.css @@ -0,0 +1,193 @@ +.lexicon-toolbar { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 16px; +} + +.lexicon-search { + flex: 1; + min-width: 0; + padding: 11px 13px; + border: 1px solid var(--border, rgba(255, 255, 255, 0.1)); + border-radius: 11px; + color: inherit; + background: rgba(255, 255, 255, 0.035); + font: inherit; +} + +.lexicon-search:focus, +.lexicon-field input:focus, +.lexicon-field textarea:focus { + outline: 2px solid rgba(137, 122, 255, 0.45); + outline-offset: 1px; +} + +.lexicon-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 14px; +} + +.lexicon-panel { + min-width: 0; +} + +.lexicon-panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.lexicon-panel-heading h2 { + margin: 3px 0 0; +} + +.lexicon-form { + display: grid; + gap: 10px; + padding: 13px; + margin-bottom: 14px; + border: 1px solid rgba(255, 255, 255, 0.075); + border-radius: 12px; + background: rgba(255, 255, 255, 0.025); +} + +.lexicon-field { + display: grid; + gap: 6px; +} + +.lexicon-field > span { + color: var(--muted, #a4a4a7); + font-size: 0.72rem; + font-weight: 650; +} + +.lexicon-field input, +.lexicon-field textarea { + width: 100%; + padding: 10px 11px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 9px; + color: inherit; + background: rgba(255, 255, 255, 0.035); + font: inherit; + resize: vertical; +} + +.lexicon-field textarea { + min-height: 64px; +} + +.lexicon-check-row { + display: flex; + flex-wrap: wrap; + gap: 14px; + align-items: center; + color: var(--muted, #a4a4a7); + font-size: 0.76rem; +} + +.lexicon-check-row label { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.lexicon-form-actions, +.lexicon-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + align-items: center; +} + +.lexicon-list { + display: grid; + gap: 9px; +} + +.lexicon-item { + min-width: 0; + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.085); + border-radius: 11px; + background: rgba(255, 255, 255, 0.025); +} + +.lexicon-item-header { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.lexicon-item-header > strong { + overflow-wrap: anywhere; +} + +.lexicon-item p { + margin: 7px 0 10px; + color: var(--muted, #a4a4a7); + font-size: 0.75rem; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.lexicon-badge { + flex: none; + padding: 3px 7px; + border-radius: 999px; + font-size: 0.61rem; + font-weight: 750; +} + +.lexicon-badge.enabled { + color: #65dba0; + background: rgba(65, 205, 139, 0.1); + border: 1px solid rgba(65, 205, 139, 0.22); +} + +.lexicon-badge.disabled { + color: #a7a7ac; + background: rgba(255, 255, 255, 0.045); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.replacement-mapping { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} + +.replacement-mapping code { + max-width: 46%; + padding: 3px 6px; + overflow: hidden; + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lexicon-empty { + margin: 0; + padding: 15px; + border: 1px dashed rgba(255, 255, 255, 0.09); + border-radius: 11px; + color: var(--muted, #a4a4a7); + font-size: 0.77rem; + line-height: 1.5; +} + +@media (max-width: 900px) { + .lexicon-grid { + grid-template-columns: 1fr; + } +} From 4376281429975186a531947cd87fcb6e816405b6 Mon Sep 17 00:00:00 2001 From: BLC Core Studio <207100624+BLCCoreStudio@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:35:05 +0300 Subject: [PATCH 08/16] feat: add dictionary and replacements view --- apps/desktop/ui/index.html | 78 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/apps/desktop/ui/index.html b/apps/desktop/ui/index.html index cfa6ac7..520de59 100644 --- a/apps/desktop/ui/index.html +++ b/apps/desktop/ui/index.html @@ -7,8 +7,10 @@ BLCVoice + +
@@ -24,6 +26,7 @@