Let users repair recurring ASR classifier mistakes
Issue #372 reports repeated classifier errors such as 2粒 being intended as 2例. The existing vocabulary hotword path is probabilistic, so this adds a separate deterministic correction-rules store and applies supported literal/{num} rules in the coordinator before polish and before insertion. The review follow-up keeps the UI refresh path consistent, handles remove failures, rejects rules that would otherwise save as enabled but never run, and fixes the Traditional Chinese wording. Constraint: Keep correction rules separate from dictionary.json and vocab presets. Constraint: Only support literal replacement and one {num} wildcard; do not expose arbitrary regex. Rejected: Add import/export or a new top-level page | outside the approved scheme-C scope. Rejected: Change history rawTranscript schema | product/data-model decision not approved in this review round. Confidence: high Scope-risk: moderate Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib correction Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib correction_rule_syntax_rejects_silent_noops Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml Tested: cd openless-all/app && npm run build Tested: git diff --check Related: #372 Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
1 parent
225859e18d
commit
95237d2c49
15 files changed
+721
-29
No files matched your search
@@ -19,9 +19,9 @@ use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVaul
|
||||
use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
|
||||
use crate::recorder::{AudioConsumer, Recorder};
|
||||
use crate::types::{
|
||||
ChineseScriptPreference, ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry,
|
||||
HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding,
|
||||
UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus,
|
||||
ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus, DictationSession,
|
||||
DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode,
|
||||
ShortcutBinding, UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus,
|
||||
};
|
||||
|
||||
type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
|
||||
@@ -160,7 +160,10 @@ pub fn set_settings(
|
||||
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
|
||||
log::warn!("[tray] refresh microphone menu after settings save failed: {err}");
|
||||
let tray_state = app_for_main.state::<TrayMicrophoneMenuState>();
|
||||
sync_tray_microphone_selection(&tray_state.lock(), &prefs_for_main.microphone_device_name);
|
||||
sync_tray_microphone_selection(
|
||||
&tray_state.lock(),
|
||||
&prefs_for_main.microphone_device_name,
|
||||
);
|
||||
}
|
||||
});
|
||||
// 抑制 unused 警告:tray_microphones 现在改在闭包里通过 app.state 取,
|
||||
@@ -246,7 +249,10 @@ pub async fn fetch_latest_beta_release() -> Result<Option<LatestBetaRelease>, St
|
||||
/// 用 `/releases/tag/` 这个唯一锚点抓 tag。
|
||||
fn parse_latest_beta_from_atom(body: &str) -> Option<LatestBetaRelease> {
|
||||
for entry in body.split("<entry>").skip(1) {
|
||||
let entry_body = entry.split_once("</entry>").map(|(b, _)| b).unwrap_or(entry);
|
||||
let entry_body = entry
|
||||
.split_once("</entry>")
|
||||
.map(|(b, _)| b)
|
||||
.unwrap_or(entry);
|
||||
let needle = "/releases/tag/";
|
||||
let tag_start = match entry_body.find(needle) {
|
||||
Some(i) => i + needle.len(),
|
||||
@@ -260,11 +266,9 @@ fn parse_latest_beta_from_atom(body: &str) -> Option<LatestBetaRelease> {
|
||||
if !tag_name.ends_with("-beta-tauri") {
|
||||
continue;
|
||||
}
|
||||
let html_url = format!(
|
||||
"https://github.com/appergb/openless/releases/tag/{tag_name}"
|
||||
);
|
||||
let published_at = extract_between(entry_body, "<updated>", "</updated>")
|
||||
.unwrap_or_default();
|
||||
let html_url = format!("https://github.com/appergb/openless/releases/tag/{tag_name}");
|
||||
let published_at =
|
||||
extract_between(entry_body, "<updated>", "</updated>").unwrap_or_default();
|
||||
return Some(LatestBetaRelease {
|
||||
tag_name,
|
||||
html_url,
|
||||
@@ -926,6 +930,43 @@ pub fn set_vocab_enabled(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_correction_rules(coord: CoordinatorState<'_>) -> Result<Vec<CorrectionRule>, String> {
|
||||
coord.correction_rules().list().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_correction_rule(
|
||||
coord: CoordinatorState<'_>,
|
||||
pattern: String,
|
||||
replacement: String,
|
||||
) -> Result<CorrectionRule, String> {
|
||||
coord
|
||||
.correction_rules()
|
||||
.add(pattern, replacement)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_correction_rule(coord: CoordinatorState<'_>, id: String) -> Result<(), String> {
|
||||
coord
|
||||
.correction_rules()
|
||||
.remove(&id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_correction_rule_enabled(
|
||||
coord: CoordinatorState<'_>,
|
||||
id: String,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
coord
|
||||
.correction_rules()
|
||||
.set_enabled(&id, enabled)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_vocab_presets() -> Result<VocabPresetStore, String> {
|
||||
crate::persistence::list_vocab_presets().map_err(|e| e.to_string())
|
||||
@@ -991,7 +1032,10 @@ pub fn set_default_polish_mode(
|
||||
) -> Result<(), String> {
|
||||
let mut prefs = coord.prefs().get();
|
||||
prefs.default_mode = mode;
|
||||
coord.prefs().set(prefs.clone()).map_err(|e| e.to_string())?;
|
||||
coord
|
||||
.prefs()
|
||||
.set(prefs.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 跟 set_settings 同样:refresh_tray_microphone_menu 里 tray.set_menu 改 NSStatusItem,
|
||||
// 必须主线程;这里是同步 Tauri command 跑在 IPC 线程,直调会让 macOS 死锁。
|
||||
let app_for_main = app.clone();
|
||||
@@ -1975,14 +2019,20 @@ mod tests {
|
||||
};
|
||||
assert!(llm_configured_for_provider("custom", &keyless_ready));
|
||||
assert!(llm_configured_for_provider("self-hosted", &keyless_ready));
|
||||
assert!(llm_configured_for_provider("openrouterFree", &keyless_ready));
|
||||
assert!(llm_configured_for_provider(
|
||||
"openrouterFree",
|
||||
&keyless_ready
|
||||
));
|
||||
|
||||
let hosted_keyless = CredentialsSnapshot {
|
||||
ark_endpoint: Some("https://openrouter.ai/api/v1".into()),
|
||||
ark_model_id: Some("qwen/qwen3-coder:free".into()),
|
||||
..snapshot()
|
||||
};
|
||||
assert!(!llm_configured_for_provider("openrouterFree", &hosted_keyless));
|
||||
assert!(!llm_configured_for_provider(
|
||||
"openrouterFree",
|
||||
&hosted_keyless
|
||||
));
|
||||
|
||||
let hosted_ready = CredentialsSnapshot {
|
||||
ark_api_key: Some("key".into()),
|
||||
@@ -1997,13 +2047,19 @@ mod tests {
|
||||
ark_model_id: Some("qwen".into()),
|
||||
..snapshot()
|
||||
};
|
||||
assert!(!llm_configured_for_provider("custom", &key_without_endpoint));
|
||||
assert!(!llm_configured_for_provider(
|
||||
"custom",
|
||||
&key_without_endpoint
|
||||
));
|
||||
|
||||
let endpoint_without_model = CredentialsSnapshot {
|
||||
ark_endpoint: Some("http://localhost:11434/v1".into()),
|
||||
..snapshot()
|
||||
};
|
||||
assert!(!llm_configured_for_provider("custom", &endpoint_without_model));
|
||||
assert!(!llm_configured_for_provider(
|
||||
"custom",
|
||||
&endpoint_without_model
|
||||
));
|
||||
}
|
||||
|
||||
impl SettingsWriter for FakeSettingsWriter {
|
||||
|
||||
@@ -32,20 +32,21 @@ use crate::coordinator_state::{
|
||||
use crate::hotkey::{HotkeyEvent, HotkeyMonitor};
|
||||
use crate::insertion::TextInserter;
|
||||
use crate::persistence::{
|
||||
CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore, PreferencesStore,
|
||||
CorrectionRuleStore, CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore,
|
||||
PreferencesStore,
|
||||
};
|
||||
|
||||
use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
|
||||
use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor};
|
||||
use crate::recorder::{Recorder, RecorderError};
|
||||
use crate::selection::capture_selection;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::types::PasteShortcut;
|
||||
use crate::types::{
|
||||
CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability,
|
||||
HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, PolishMode,
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::types::PasteShortcut;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::windows_ime_ipc::ImeSubmitTarget;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController};
|
||||
@@ -96,6 +97,7 @@ struct Inner {
|
||||
history: HistoryStore,
|
||||
prefs: PreferencesStore,
|
||||
vocab: DictionaryStore,
|
||||
correction_rules: CorrectionRuleStore,
|
||||
inserter: TextInserter,
|
||||
#[cfg(target_os = "windows")]
|
||||
windows_ime: WindowsImeSessionController,
|
||||
@@ -176,6 +178,7 @@ impl Coordinator {
|
||||
});
|
||||
let prefs = PreferencesStore::new().expect("preferences store init");
|
||||
let vocab = DictionaryStore::new().expect("dictionary store init");
|
||||
let correction_rules = CorrectionRuleStore::new().expect("correction rule store init");
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
@@ -183,6 +186,7 @@ impl Coordinator {
|
||||
history,
|
||||
prefs,
|
||||
vocab,
|
||||
correction_rules,
|
||||
inserter: TextInserter::new(),
|
||||
state: Mutex::new(SessionState::default()),
|
||||
asr: Mutex::new(None),
|
||||
@@ -218,6 +222,7 @@ impl Coordinator {
|
||||
});
|
||||
let prefs = PreferencesStore::new().expect("preferences store init");
|
||||
let vocab = DictionaryStore::new().expect("dictionary store init");
|
||||
let correction_rules = CorrectionRuleStore::new().expect("correction rule store init");
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
@@ -225,6 +230,7 @@ impl Coordinator {
|
||||
history,
|
||||
prefs,
|
||||
vocab,
|
||||
correction_rules,
|
||||
inserter: TextInserter::new(),
|
||||
windows_ime: WindowsImeSessionController::new(),
|
||||
prepared_windows_ime_session: Arc::new(Mutex::new(Vec::new())),
|
||||
@@ -642,6 +648,9 @@ impl Coordinator {
|
||||
pub fn vocab(&self) -> &DictionaryStore {
|
||||
&self.inner.vocab
|
||||
}
|
||||
pub fn correction_rules(&self) -> &CorrectionRuleStore {
|
||||
&self.inner.correction_rules
|
||||
}
|
||||
|
||||
pub fn update_hotkey_binding(&self) {
|
||||
let prefs = self.inner.prefs.get();
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::coordinator_state::request_stop_during_starting_state;
|
||||
use crate::correction::apply_correction_rules;
|
||||
use crate::types::HotkeyMode;
|
||||
|
||||
use super::qa::handle_qa_option_edge;
|
||||
@@ -906,6 +907,25 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
return Err("ASR returned empty transcript".to_string());
|
||||
}
|
||||
|
||||
let correction_rules = match inner.correction_rules.list() {
|
||||
Ok(rules) => rules,
|
||||
Err(e) => {
|
||||
log::warn!("[coord] load correction rules failed: {e}; continue without correction");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if !correction_rules.is_empty() {
|
||||
let corrected = apply_correction_rules(&raw.text, &correction_rules);
|
||||
if corrected != raw.text {
|
||||
log::info!(
|
||||
"[coord] correction rules adjusted raw transcript ({} → {} chars)",
|
||||
raw.text.chars().count(),
|
||||
corrected.chars().count()
|
||||
);
|
||||
raw.text = corrected;
|
||||
}
|
||||
}
|
||||
|
||||
emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None);
|
||||
|
||||
let prefs = inner.prefs.get();
|
||||
@@ -988,6 +1008,19 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
} else {
|
||||
polished
|
||||
};
|
||||
let polished = if correction_rules.is_empty() {
|
||||
polished
|
||||
} else {
|
||||
let corrected = apply_correction_rules(&polished, &correction_rules);
|
||||
if corrected != polished {
|
||||
log::info!(
|
||||
"[coord] correction rules adjusted final text ({} → {} chars)",
|
||||
polished.chars().count(),
|
||||
corrected.chars().count()
|
||||
);
|
||||
}
|
||||
corrected
|
||||
};
|
||||
|
||||
// 原子化最后一次 cancel 检查 + 转 Inserting:
|
||||
// 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting,
|
||||
@@ -1035,7 +1068,9 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
inner.inserter.insert(&polished, restore_clipboard, paste_shortcut)
|
||||
inner
|
||||
.inserter
|
||||
.insert(&polished, restore_clipboard, paste_shortcut)
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! 用户自定义纠正规则。
|
||||
//!
|
||||
//! 规则独立于词汇表:词汇表负责 ASR/LLM 热词提示,纠正规则负责在听写流水线里
|
||||
//! 做确定性的文本替换。当前只支持一个保守通配符 `{num}`,避免把任意正则暴露给
|
||||
//! 用户造成误替换。
|
||||
|
||||
use crate::types::CorrectionRule;
|
||||
|
||||
const NUM_TOKEN: &str = "{num}";
|
||||
|
||||
pub fn apply_correction_rules(text: &str, rules: &[CorrectionRule]) -> String {
|
||||
let mut current = text.to_string();
|
||||
for rule in rules {
|
||||
if !rule.enabled {
|
||||
continue;
|
||||
}
|
||||
let pattern = rule.pattern.trim();
|
||||
if pattern.is_empty() {
|
||||
continue;
|
||||
}
|
||||
current = apply_rule(¤t, pattern, &rule.replacement);
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
fn apply_rule(text: &str, pattern: &str, replacement: &str) -> String {
|
||||
let token_count = pattern.matches(NUM_TOKEN).count();
|
||||
if token_count == 0 {
|
||||
if replacement.contains(NUM_TOKEN) {
|
||||
return text.to_string();
|
||||
}
|
||||
return text.replace(pattern, replacement);
|
||||
}
|
||||
if token_count != 1 {
|
||||
return text.to_string();
|
||||
}
|
||||
apply_num_rule(text, pattern, replacement)
|
||||
}
|
||||
|
||||
fn apply_num_rule(text: &str, pattern: &str, replacement: &str) -> String {
|
||||
let Some((prefix, suffix)) = pattern.split_once(NUM_TOKEN) else {
|
||||
return text.to_string();
|
||||
};
|
||||
if prefix.is_empty() && suffix.is_empty() {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
let mut output = String::with_capacity(text.len());
|
||||
let mut cursor = 0usize;
|
||||
while cursor < text.len() {
|
||||
let Some((match_start, token_start)) = next_prefix_match(text, cursor, prefix) else {
|
||||
break;
|
||||
};
|
||||
let Some(token_end) = consume_number_token(text, token_start) else {
|
||||
output.push_str(&text[cursor..next_char_boundary(text, match_start)]);
|
||||
cursor = next_char_boundary(text, match_start);
|
||||
continue;
|
||||
};
|
||||
let after_number = &text[token_end..];
|
||||
if !after_number.starts_with(suffix) {
|
||||
output.push_str(&text[cursor..next_char_boundary(text, match_start)]);
|
||||
cursor = next_char_boundary(text, match_start);
|
||||
continue;
|
||||
}
|
||||
|
||||
let match_end = token_end + suffix.len();
|
||||
output.push_str(&text[cursor..match_start]);
|
||||
output.push_str(&replacement.replace(NUM_TOKEN, &text[token_start..token_end]));
|
||||
cursor = match_end;
|
||||
}
|
||||
output.push_str(&text[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn next_prefix_match(text: &str, cursor: usize, prefix: &str) -> Option<(usize, usize)> {
|
||||
if prefix.is_empty() {
|
||||
let match_start = next_number_start(text, cursor)?;
|
||||
return Some((match_start, match_start));
|
||||
}
|
||||
let relative = text[cursor..].find(prefix)?;
|
||||
let match_start = cursor + relative;
|
||||
Some((match_start, match_start + prefix.len()))
|
||||
}
|
||||
|
||||
fn next_number_start(text: &str, cursor: usize) -> Option<usize> {
|
||||
text[cursor..]
|
||||
.char_indices()
|
||||
.find_map(|(offset, ch)| is_number_char(ch).then_some(cursor + offset))
|
||||
}
|
||||
|
||||
fn consume_number_token(text: &str, start: usize) -> Option<usize> {
|
||||
let mut end = start;
|
||||
let mut consumed = false;
|
||||
for (offset, ch) in text[start..].char_indices() {
|
||||
if !is_number_char(ch) {
|
||||
break;
|
||||
}
|
||||
consumed = true;
|
||||
end = start + offset + ch.len_utf8();
|
||||
}
|
||||
consumed.then_some(end)
|
||||
}
|
||||
|
||||
fn is_number_char(ch: char) -> bool {
|
||||
ch.is_ascii_digit()
|
||||
|| matches!(
|
||||
ch,
|
||||
'零' | '〇'
|
||||
| '一'
|
||||
| '二'
|
||||
| '两'
|
||||
| '兩'
|
||||
| '三'
|
||||
| '四'
|
||||
| '五'
|
||||
| '六'
|
||||
| '七'
|
||||
| '八'
|
||||
| '九'
|
||||
| '十'
|
||||
| '百'
|
||||
| '千'
|
||||
| '万'
|
||||
| '萬'
|
||||
| '亿'
|
||||
| '億'
|
||||
| '几'
|
||||
| '幾'
|
||||
)
|
||||
}
|
||||
|
||||
fn next_char_boundary(text: &str, start: usize) -> usize {
|
||||
text[start..]
|
||||
.chars()
|
||||
.next()
|
||||
.map(|ch| start + ch.len_utf8())
|
||||
.unwrap_or(text.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rule(pattern: &str, replacement: &str) -> CorrectionRule {
|
||||
CorrectionRule {
|
||||
id: "rule".into(),
|
||||
pattern: pattern.into(),
|
||||
replacement: replacement.into(),
|
||||
enabled: true,
|
||||
created_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_literal_replacement() {
|
||||
let rules = vec![rule("几粒", "几例")];
|
||||
assert_eq!(
|
||||
apply_correction_rules("这里有几粒样品", &rules),
|
||||
"这里有几例样品"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_num_wildcard_for_arabic_digits() {
|
||||
let rules = vec![rule("{num}粒", "{num}例")];
|
||||
assert_eq!(
|
||||
apply_correction_rules("2粒样品和10粒对照", &rules),
|
||||
"2例样品和10例对照"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_num_wildcard_for_chinese_numbers() {
|
||||
let rules = vec![rule("{num}粒", "{num}例")];
|
||||
assert_eq!(
|
||||
apply_correction_rules("两粒样品和幾粒对照", &rules),
|
||||
"两例样品和幾例对照"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_rules_are_ignored() {
|
||||
let mut disabled = rule("{num}粒", "{num}例");
|
||||
disabled.enabled = false;
|
||||
assert_eq!(apply_correction_rules("10粒样品", &[disabled]), "10粒样品");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_rules_are_inert() {
|
||||
let rules = vec![
|
||||
rule("{num}到{num}粒", "{num}例"),
|
||||
rule("几粒", "{num}例"),
|
||||
rule("{num}", "{num}例"),
|
||||
];
|
||||
assert_eq!(apply_correction_rules("几粒和10粒", &rules), "几粒和10粒");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_rules_sequentially() {
|
||||
let rules = vec![rule("{num}粒", "{num}例"), rule("样本", "样品")];
|
||||
assert_eq!(apply_correction_rules("10粒样本", &rules), "10例样品");
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod combo_hotkey;
|
||||
mod commands;
|
||||
mod coordinator;
|
||||
mod coordinator_state;
|
||||
mod correction;
|
||||
mod global_hotkey_runtime;
|
||||
mod hotkey;
|
||||
mod insertion;
|
||||
@@ -157,8 +158,8 @@ pub fn run() {
|
||||
// 登录都被主窗口打扰。OPENLESS_SHOW_MAIN_ON_START=1 仍保留
|
||||
// 老的强制 show 路径(手动 dispatch 测试 / dev 用),优先级高
|
||||
// 于 prefs。
|
||||
let force_show = std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref()
|
||||
== Some("1");
|
||||
let force_show =
|
||||
std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1");
|
||||
let suppress_show = !force_show && coordinator.prefs().get().start_minimized;
|
||||
if suppress_show {
|
||||
log::info!("[main] start_minimized=true → 跳过初始 show,等用户点托盘");
|
||||
@@ -254,6 +255,10 @@ pub fn run() {
|
||||
commands::add_vocab,
|
||||
commands::remove_vocab,
|
||||
commands::set_vocab_enabled,
|
||||
commands::list_correction_rules,
|
||||
commands::add_correction_rule,
|
||||
commands::remove_correction_rule,
|
||||
commands::set_correction_rule_enabled,
|
||||
commands::list_vocab_presets,
|
||||
commands::save_vocab_presets,
|
||||
commands::start_dictation,
|
||||
@@ -1197,8 +1202,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tray_style_menu_id_parsing_accepts_only_style_items() {
|
||||
assert_eq!(parse_tray_polish_mode_id("style-raw"), Some(PolishMode::Raw));
|
||||
assert_eq!(parse_tray_polish_mode_id("style-light"), Some(PolishMode::Light));
|
||||
assert_eq!(
|
||||
parse_tray_polish_mode_id("style-raw"),
|
||||
Some(PolishMode::Raw)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_tray_polish_mode_id("style-light"),
|
||||
Some(PolishMode::Light)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_tray_polish_mode_id("style-structured"),
|
||||
Some(PolishMode::Structured)
|
||||
|
||||
@@ -21,7 +21,9 @@ use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::types::{DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore};
|
||||
use crate::types::{
|
||||
CorrectionRule, DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore,
|
||||
};
|
||||
|
||||
const HISTORY_CAP: usize = 200;
|
||||
const HISTORY_FILE: &str = "history.json";
|
||||
@@ -29,6 +31,8 @@ const PREFERENCES_FILE: &str = "preferences.json";
|
||||
/// 与 Swift `Sources/OpenLessPersistence/DictionaryStore.swift` 同名,
|
||||
/// 让旧版词汇表在升级后无缝继承。**不要**改成 `vocab.json`,会丢用户数据。
|
||||
const VOCAB_FILE: &str = "dictionary.json";
|
||||
const CORRECTION_RULES_FILE: &str = "correction-rules.json";
|
||||
const CORRECTION_NUM_TOKEN: &str = "{num}";
|
||||
const VOCAB_PRESETS_FILE: &str = "vocab-presets.json";
|
||||
|
||||
/// 旧版 plaintext JSON 凭据路径。仅作为迁移来源;成功写入系统凭据库后会删除。
|
||||
@@ -1041,6 +1045,106 @@ pub fn save_vocab_presets(store: &VocabPresetStore) -> Result<()> {
|
||||
atomic_write(&path, &json)
|
||||
}
|
||||
|
||||
// ───────────────────────── CorrectionRuleStore ─────────────────────────
|
||||
|
||||
pub struct CorrectionRuleStore {
|
||||
path: PathBuf,
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl CorrectionRuleStore {
|
||||
pub fn new() -> Result<Self> {
|
||||
let dir = data_dir()?;
|
||||
ensure_dir(&dir)?;
|
||||
Ok(Self {
|
||||
path: dir.join(CORRECTION_RULES_FILE),
|
||||
lock: Mutex::new(()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Result<Vec<CorrectionRule>> {
|
||||
let _guard = self.lock.lock();
|
||||
self.read_locked()
|
||||
}
|
||||
|
||||
pub fn add(&self, pattern: String, replacement: String) -> Result<CorrectionRule> {
|
||||
let pattern = pattern.trim().to_string();
|
||||
let replacement = replacement.trim().to_string();
|
||||
validate_correction_rule_syntax(&pattern, &replacement)?;
|
||||
let _guard = self.lock.lock();
|
||||
let mut rules = self.read_locked()?;
|
||||
let rule = CorrectionRule {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
pattern,
|
||||
replacement,
|
||||
enabled: true,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
rules.insert(0, rule.clone());
|
||||
self.write_locked(&rules)?;
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &str) -> Result<()> {
|
||||
let _guard = self.lock.lock();
|
||||
let mut rules = self.read_locked()?;
|
||||
let before = rules.len();
|
||||
rules.retain(|r| r.id != id);
|
||||
if rules.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
self.write_locked(&rules)
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
|
||||
let _guard = self.lock.lock();
|
||||
let mut rules = self.read_locked()?;
|
||||
let mut found = false;
|
||||
for rule in rules.iter_mut() {
|
||||
if rule.id == id {
|
||||
rule.enabled = enabled;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err(anyhow!("correction rule {} not found", id));
|
||||
}
|
||||
self.write_locked(&rules)
|
||||
}
|
||||
|
||||
fn read_locked(&self) -> Result<Vec<CorrectionRule>> {
|
||||
read_or_default::<Vec<CorrectionRule>>(&self.path)
|
||||
}
|
||||
|
||||
fn write_locked(&self, rules: &[CorrectionRule]) -> Result<()> {
|
||||
let json = serde_json::to_vec_pretty(rules).context("encode correction rules failed")?;
|
||||
atomic_write(&self.path, &json)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_correction_rule_syntax(pattern: &str, replacement: &str) -> Result<()> {
|
||||
if pattern.is_empty() {
|
||||
return Err(anyhow!("correction rule pattern is empty"));
|
||||
}
|
||||
let pattern_token_count = pattern.matches(CORRECTION_NUM_TOKEN).count();
|
||||
if pattern_token_count > 1 {
|
||||
return Err(anyhow!("unsupported correction rule syntax"));
|
||||
}
|
||||
if replacement.contains(CORRECTION_NUM_TOKEN) && pattern_token_count == 0 {
|
||||
return Err(anyhow!("unsupported correction rule syntax"));
|
||||
}
|
||||
if pattern_token_count == 1 {
|
||||
let Some((prefix, suffix)) = pattern.split_once(CORRECTION_NUM_TOKEN) else {
|
||||
return Err(anyhow!("unsupported correction rule syntax"));
|
||||
};
|
||||
if prefix.is_empty() && suffix.is_empty() {
|
||||
return Err(anyhow!("unsupported correction rule syntax"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ───────────────────────── CredentialsVault ─────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -1185,7 +1289,8 @@ impl CredentialsVault {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
chunk_json_payload, list_vocab_presets, save_vocab_presets, KEYRING_CHUNK_MAX_UTF16_UNITS,
|
||||
chunk_json_payload, list_vocab_presets, save_vocab_presets,
|
||||
validate_correction_rule_syntax, KEYRING_CHUNK_MAX_UTF16_UNITS,
|
||||
};
|
||||
use crate::types::{VocabPreset, VocabPresetStore};
|
||||
use std::fs;
|
||||
@@ -1236,4 +1341,14 @@ mod tests {
|
||||
assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]);
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correction_rule_syntax_rejects_silent_noops() {
|
||||
assert!(validate_correction_rule_syntax("{num}粒", "{num}例").is_ok());
|
||||
assert!(validate_correction_rule_syntax("几粒", "几例").is_ok());
|
||||
assert!(validate_correction_rule_syntax("", "几例").is_err());
|
||||
assert!(validate_correction_rule_syntax("{num}", "{num}例").is_err());
|
||||
assert!(validate_correction_rule_syntax("{num}到{num}粒", "{num}例").is_err());
|
||||
assert!(validate_correction_rule_syntax("几粒", "{num}例").is_err());
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,18 @@ pub struct DictionaryEntry {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CorrectionRule {
|
||||
pub id: String,
|
||||
pub pattern: String,
|
||||
pub replacement: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VocabPreset {
|
||||
|
||||
@@ -178,6 +178,17 @@ export const en: typeof zhCN = {
|
||||
tipDisabled: 'Click to disable this entry',
|
||||
tipEnabled: 'Click to enable this entry',
|
||||
removeAria: 'Remove',
|
||||
corrections: {
|
||||
title: 'Correction rules',
|
||||
tip: 'Separate from hot words. Deterministically fixes common ASR mistakes; {num} matches numbers. Example: {num}粒 → {num}例.',
|
||||
patternPlaceholder: 'Mistaken text, e.g. {num}粒',
|
||||
replacementPlaceholder: 'Target text, e.g. {num}例',
|
||||
empty: 'No correction rules yet.',
|
||||
invalid: 'Only literal replacements or one {num} number wildcard are supported, for example {num}粒 → {num}例.',
|
||||
tipDisabled: 'Click to disable this rule',
|
||||
tipEnabled: 'Click to enable this rule',
|
||||
removeAria: 'Remove correction rule',
|
||||
},
|
||||
presets: {
|
||||
title: 'Scenario presets',
|
||||
tip: 'Multi-select to apply in batch; supports edit/create and keeps local preset structure ready for future import/export.',
|
||||
|
||||
@@ -180,6 +180,17 @@ export const ja: typeof zhCN = {
|
||||
tipDisabled: 'クリックで無効化',
|
||||
tipEnabled: 'クリックで有効化',
|
||||
removeAria: '削除',
|
||||
corrections: {
|
||||
title: '補正ルール',
|
||||
tip: 'ホットワードとは別に、ASR のよくある誤認識を文字列として補正します。{num} は数字に一致します。例:{num}粒 → {num}例。',
|
||||
patternPlaceholder: '誤認識された表記(例:{num}粒)',
|
||||
replacementPlaceholder: '修正後の表記(例:{num}例)',
|
||||
empty: '補正ルールはまだありません。',
|
||||
invalid: '文字列の置換、または {num} 数字ワイルドカードを 1 つだけ含むルールに対応しています。例:{num}粒 → {num}例。',
|
||||
tipDisabled: 'クリックしてこのルールを無効化',
|
||||
tipEnabled: 'クリックしてこのルールを有効化',
|
||||
removeAria: '補正ルールを削除',
|
||||
},
|
||||
presets: {
|
||||
title: 'シーンプリセット',
|
||||
tip: '複数選択して一括有効化できます。編集と新規作成にも対応し、将来のインポート/エクスポート用のローカル構造を予約しています。',
|
||||
|
||||
@@ -180,6 +180,17 @@ export const ko: typeof zhCN = {
|
||||
tipDisabled: '클릭하여 비활성화',
|
||||
tipEnabled: '클릭하여 활성화',
|
||||
removeAria: '삭제',
|
||||
corrections: {
|
||||
title: '교정 규칙',
|
||||
tip: '핫워드와 별도로 ASR의 흔한 오인식을 문자 그대로 교정합니다. {num}은 숫자와 일치합니다. 예: {num}粒 → {num}例.',
|
||||
patternPlaceholder: '오인식 표현, 예: {num}粒',
|
||||
replacementPlaceholder: '대상 표현, 예: {num}例',
|
||||
empty: '아직 교정 규칙이 없습니다.',
|
||||
invalid: '문자 그대로 바꾸기 또는 {num} 숫자 와일드카드 1개가 포함된 규칙만 지원합니다. 예: {num}粒 → {num}例.',
|
||||
tipDisabled: '이 규칙 비활성화',
|
||||
tipEnabled: '이 규칙 활성화',
|
||||
removeAria: '교정 규칙 삭제',
|
||||
},
|
||||
presets: {
|
||||
title: '시나리오 프리셋',
|
||||
tip: '여러 개 선택 후 일괄 활성화 가능. 편집과 새로 만들기 지원, 향후 가져오기/내보내기를 위한 로컬 구조가 예약되어 있습니다.',
|
||||
|
||||
@@ -176,6 +176,17 @@ export const zhCN = {
|
||||
tipDisabled: '点击禁用此词条',
|
||||
tipEnabled: '点击启用此词条',
|
||||
removeAria: '删除',
|
||||
corrections: {
|
||||
title: '纠正规则',
|
||||
tip: '独立于热词,按字面修正 ASR 常见误识别;支持 {num} 通配数字。例如:{num}粒 → {num}例。',
|
||||
patternPlaceholder: '误识别写法,如 {num}粒',
|
||||
replacementPlaceholder: '目标写法,如 {num}例',
|
||||
empty: '还没有纠正规则。',
|
||||
invalid: '仅支持字面替换,或一个 {num} 通配数字的规则,例如 {num}粒 → {num}例。',
|
||||
tipDisabled: '点击禁用此规则',
|
||||
tipEnabled: '点击启用此规则',
|
||||
removeAria: '删除纠正规则',
|
||||
},
|
||||
presets: {
|
||||
title: '场景预设',
|
||||
tip: '可多选后批量启用;支持编辑和新建,已为后续导入导出预留本地结构。',
|
||||
|
||||
@@ -178,6 +178,17 @@ export const zhTW: typeof zhCN = {
|
||||
tipDisabled: '點擊禁用此詞條',
|
||||
tipEnabled: '點擊啓用此詞條',
|
||||
removeAria: '刪除',
|
||||
corrections: {
|
||||
title: '糾正規則',
|
||||
tip: '獨立於熱詞,按字面修正 ASR 常見誤識別;支援 {num} 通配數字。例如:{num}粒 → {num}例。',
|
||||
patternPlaceholder: '誤識別寫法,如 {num}粒',
|
||||
replacementPlaceholder: '目標寫法,如 {num}例',
|
||||
empty: '還沒有糾正規則。',
|
||||
invalid: '僅支援字面替換,或一個 {num} 通配數字的規則,例如 {num}粒 → {num}例。',
|
||||
tipDisabled: '點擊停用此規則',
|
||||
tipEnabled: '點擊啟用此規則',
|
||||
removeAria: '刪除糾正規則',
|
||||
},
|
||||
presets: {
|
||||
title: '場景預設',
|
||||
tip: '可多選後批量啓用;支持編輯和新建,已爲後續導入導出預留本地結構。',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import type {
|
||||
ComboBinding,
|
||||
CorrectionRule,
|
||||
CredentialsStatus,
|
||||
DictationSession,
|
||||
DictionaryEntry,
|
||||
@@ -151,6 +152,16 @@ const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({
|
||||
createdAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const mockCorrectionRules: CorrectionRule[] = [
|
||||
{
|
||||
id: 'rule-quantity-classifier',
|
||||
pattern: '{num}粒',
|
||||
replacement: '{num}例',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────
|
||||
export function getSettings(): Promise<UserPreferences> {
|
||||
return invokeOrMock('get_settings', undefined, () => mockSettings);
|
||||
@@ -274,6 +285,28 @@ export function setVocabEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
return invokeOrMock('set_vocab_enabled', { id, enabled }, () => undefined);
|
||||
}
|
||||
|
||||
export function listCorrectionRules(): Promise<CorrectionRule[]> {
|
||||
return invokeOrMock('list_correction_rules', undefined, () => mockCorrectionRules);
|
||||
}
|
||||
|
||||
export function addCorrectionRule(pattern: string, replacement: string): Promise<CorrectionRule> {
|
||||
return invokeOrMock('add_correction_rule', { pattern, replacement }, () => ({
|
||||
id: `rule-new-${Date.now()}`,
|
||||
pattern,
|
||||
replacement,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export function removeCorrectionRule(id: string): Promise<void> {
|
||||
return invokeOrMock('remove_correction_rule', { id }, () => undefined);
|
||||
}
|
||||
|
||||
export function setCorrectionRuleEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
return invokeOrMock('set_correction_rule_enabled', { id, enabled }, () => undefined);
|
||||
}
|
||||
|
||||
export function listVocabPresets(): Promise<VocabPresetStore> {
|
||||
return invokeOrMock('list_vocab_presets', undefined, () => ({
|
||||
custom: [],
|
||||
|
||||
@@ -29,6 +29,14 @@ export interface DictionaryEntry {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CorrectionRule {
|
||||
id: string;
|
||||
pattern: string;
|
||||
replacement: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VocabPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -3,12 +3,35 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { addVocab, isTauri, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc';
|
||||
import type { DictionaryEntry, VocabPreset } from '../lib/types';
|
||||
import {
|
||||
addCorrectionRule,
|
||||
addVocab,
|
||||
isTauri,
|
||||
listCorrectionRules,
|
||||
listVocab,
|
||||
removeCorrectionRule,
|
||||
removeVocab,
|
||||
setCorrectionRuleEnabled,
|
||||
setVocabEnabled,
|
||||
} from '../lib/ipc';
|
||||
import type { CorrectionRule, DictionaryEntry, VocabPreset } from '../lib/types';
|
||||
import { DEFAULT_VOCAB_PRESETS, loadVocabPresets, persistVocabPresets } from '../lib/vocabPresets';
|
||||
import { Btn, Card, PageHeader } from './_atoms';
|
||||
|
||||
const NEW_PRESET_DRAFT_ID = '__new__';
|
||||
const NUM_TOKEN = '{num}';
|
||||
|
||||
function isSupportedCorrectionRule(pattern: string, replacement: string) {
|
||||
const tokenCount = pattern.split(NUM_TOKEN).length - 1;
|
||||
if (!pattern) return false;
|
||||
if (tokenCount > 1) return false;
|
||||
if (replacement.includes(NUM_TOKEN) && tokenCount === 0) return false;
|
||||
if (tokenCount === 1) {
|
||||
const [prefix, suffix] = pattern.split(NUM_TOKEN);
|
||||
return Boolean(prefix || suffix);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function Vocab() {
|
||||
const { t } = useTranslation();
|
||||
@@ -22,6 +45,9 @@ export function Vocab() {
|
||||
const [editingPresetId, setEditingPresetId] = useState<string | null>(null);
|
||||
const [presetNameDraft, setPresetNameDraft] = useState('');
|
||||
const [presetPhrasesDraft, setPresetPhrasesDraft] = useState('');
|
||||
const [correctionRules, setCorrectionRules] = useState<CorrectionRule[]>([]);
|
||||
const [rulePatternDraft, setRulePatternDraft] = useState('');
|
||||
const [ruleReplacementDraft, setRuleReplacementDraft] = useState('');
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
@@ -36,8 +62,22 @@ export function Vocab() {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshCorrectionRules = async () => {
|
||||
try {
|
||||
const data = await listCorrectionRules();
|
||||
setCorrectionRules(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAll = () => {
|
||||
void refresh();
|
||||
void refreshCorrectionRules();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
refreshAll();
|
||||
void loadVocabPresets()
|
||||
.then(setPresets)
|
||||
.catch(err => setError(err instanceof Error ? err.message : String(err)));
|
||||
@@ -75,6 +115,44 @@ export function Vocab() {
|
||||
}
|
||||
};
|
||||
|
||||
const onAddCorrectionRule = async () => {
|
||||
const pattern = rulePatternDraft.trim();
|
||||
if (!pattern) return;
|
||||
const replacement = ruleReplacementDraft.trim();
|
||||
if (!isSupportedCorrectionRule(pattern, replacement)) {
|
||||
setError(t('vocab.corrections.invalid'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const rule = await addCorrectionRule(pattern, replacement);
|
||||
setCorrectionRules(prev => [rule, ...prev]);
|
||||
setRulePatternDraft('');
|
||||
setRuleReplacementDraft('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveCorrectionRule = async (id: string) => {
|
||||
try {
|
||||
await removeCorrectionRule(id);
|
||||
setCorrectionRules(prev => prev.filter(r => r.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleCorrectionRule = async (rule: CorrectionRule) => {
|
||||
const next = !rule.enabled;
|
||||
setCorrectionRules(prev => prev.map(r => (r.id === rule.id ? { ...r, enabled: next } : r)));
|
||||
try {
|
||||
await setCorrectionRuleEnabled(rule.id, next);
|
||||
} catch (err) {
|
||||
setCorrectionRules(prev => prev.map(r => (r.id === rule.id ? { ...r, enabled: rule.enabled } : r)));
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async (id: string) => {
|
||||
await removeVocab(id);
|
||||
setEntries(prev => prev.filter(e => e.id !== id));
|
||||
@@ -184,7 +262,7 @@ export function Vocab() {
|
||||
desc={t('vocab.desc')}
|
||||
right={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Btn icon="refresh" variant="ghost" size="sm" onClick={refresh}>{t('common.refresh')}</Btn>
|
||||
<Btn icon="refresh" variant="ghost" size="sm" onClick={refreshAll}>{t('common.refresh')}</Btn>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -231,6 +309,43 @@ export function Vocab() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div>
|
||||
<strong style={{ fontSize: 12 }}>{t('vocab.corrections.title')}</strong>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 6 }}>{t('vocab.corrections.tip')}</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto minmax(0, 1fr) auto', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
value={rulePatternDraft}
|
||||
onChange={e => setRulePatternDraft(e.target.value)}
|
||||
placeholder={t('vocab.corrections.patternPlaceholder')}
|
||||
style={{ height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', borderRadius: 8, background: 'var(--ol-surface-2)' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--ol-ink-4)', fontSize: 12 }}>→</span>
|
||||
<input
|
||||
value={ruleReplacementDraft}
|
||||
onChange={e => setRuleReplacementDraft(e.target.value)}
|
||||
placeholder={t('vocab.corrections.replacementPlaceholder')}
|
||||
style={{ height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', borderRadius: 8, background: 'var(--ol-surface-2)' }}
|
||||
/>
|
||||
<Btn size="sm" variant="primary" onClick={() => void onAddCorrectionRule()}>{t('common.add')}</Btn>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, minHeight: correctionRules.length ? undefined : 20 }}>
|
||||
{correctionRules.length === 0 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('vocab.corrections.empty')}</span>
|
||||
)}
|
||||
{correctionRules.map(rule => (
|
||||
<CorrectionRuleChip
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
onToggle={() => void onToggleCorrectionRule(rule)}
|
||||
onRemove={() => void onRemoveCorrectionRule(rule.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
@@ -279,6 +394,46 @@ export function Vocab() {
|
||||
);
|
||||
}
|
||||
|
||||
interface CorrectionRuleChipProps {
|
||||
rule: CorrectionRule;
|
||||
onToggle: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function CorrectionRuleChip({ rule, onToggle, onRemove }: CorrectionRuleChipProps) {
|
||||
const { t } = useTranslation();
|
||||
const enabled = rule.enabled;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '5px 8px 5px 10px',
|
||||
borderRadius: 999,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background: enabled ? 'var(--ol-surface)' : 'var(--ol-surface-2)',
|
||||
opacity: enabled ? 1 : 0.55,
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--ol-font-mono)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
title={enabled ? t('vocab.corrections.tipDisabled') : t('vocab.corrections.tipEnabled')}
|
||||
style={{ background: 'transparent', border: 0, padding: 0, color: 'inherit', fontFamily: 'inherit', cursor: 'default' }}
|
||||
>
|
||||
{rule.pattern} → {rule.replacement}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
aria-label={t('vocab.corrections.removeAria')}
|
||||
style={{ width: 18, height: 18, borderRadius: 999, border: 0, background: 'rgba(0,0,0,0.06)', color: 'var(--ol-ink-4)', cursor: 'default' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface VocabChipProps {
|
||||
entry: DictionaryEntry;
|
||||
onRemove: () => void;
|
||||
|
||||
Reference in new issue
Block a user