From 51112d906e9b25c8dcae9b3c55706e2718b2984c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Wed, 26 Aug 2026 22:35:11 +0800 Subject: [PATCH 1/2] feat(vocabulary): persist selection examples and English-anchor related words Capture the containing sentence with native selections, save it on the wordbook entry, and store Chinese-to-English pairs as English-led so Related Words can study from the English term. --- src-tauri/src/contracts.rs | 23 ++ src-tauri/src/coordinator.rs | 1 + src-tauri/src/integration_tests.rs | 5 + src-tauri/src/platform/macos/selection.rs | 293 +++++++++++++++++- src-tauri/src/platform/macos/window.rs | 1 + src-tauri/src/platform/windows/selection.rs | 1 + src-tauri/src/services/translation.rs | 2 + src-tauri/src/services/vocabulary.rs | 226 +++++++++++++- src/components/context/ContextualOverlay.tsx | 1 + .../vocabulary/RelatedWordsView.tsx | 29 +- .../vocabulary/VocabularyWindow.test.tsx | 26 +- src/contracts/fixtures.json | 2 + src/contracts/ipc.test.ts | 4 + src/contracts/ipc.ts | 9 + src/styles/app.css | 9 +- 15 files changed, 562 insertions(+), 70 deletions(-) diff --git a/src-tauri/src/contracts.rs b/src-tauri/src/contracts.rs index e89d719..6058f8c 100644 --- a/src-tauri/src/contracts.rs +++ b/src-tauri/src/contracts.rs @@ -110,6 +110,8 @@ pub struct PhysicalRect { pub struct SelectionSnapshot { pub id: u64, pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub example_sentence: Option, #[serde(skip_serializing_if = "Option::is_none")] pub source_application_id: Option, pub bounds_physical_px: Vec, @@ -186,6 +188,8 @@ impl UserSettings { pub struct TranslationRequest { pub selection_id: u64, pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub example_sentence: Option, pub source_language: LanguageCode, pub target_language: LanguageCode, } @@ -211,6 +215,8 @@ pub struct VocabularyEntry { pub id: i64, pub source_text: String, pub translated_text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub example_sentence: Option, pub requested_source_language: LanguageCode, pub effective_source_language: LanguageCode, pub target_language: LanguageCode, @@ -502,6 +508,7 @@ impl ValidateContract for SelectionSnapshot { if self.id > JS_SAFE_INTEGER_MAX || self.captured_at_epoch_ms > JS_SAFE_INTEGER_MAX || self.text.trim().is_empty() + || !valid_example_sentence(self.example_sentence.as_deref()) { return Err(validation_error("selection violates schema constraints")); } @@ -553,6 +560,7 @@ impl ValidateContract for TranslationRequest { || self.text.trim().is_empty() || self.source_language.trim().is_empty() || self.target_language.trim().is_empty() + || !valid_example_sentence(self.example_sentence.as_deref()) { return Err(validation_error( "translation request contains an empty field", @@ -562,6 +570,10 @@ impl ValidateContract for TranslationRequest { } } +fn valid_example_sentence(value: Option<&str>) -> bool { + value.is_none_or(|value| !value.trim().is_empty() && value.chars().count() <= 5_000) +} + impl ValidateContract for TranslationResult { fn validate(&self) -> Result<(), AppError> { if self.selection_id > JS_SAFE_INTEGER_MAX @@ -696,6 +708,16 @@ mod tests { assert!(decode_validated::(invalid).is_err()); + let invalid_example = r#"{ + "id": 1, + "text": "word", + "exampleSentence": " ", + "boundsPhysicalPx": [{"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0}], + "anchorPhysicalPx": {"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0}, + "capturedAtEpochMs": 1 + }"#; + assert!(decode_validated::(invalid_example).is_err()); + let invalid_result = r#"{ "selectionId": 1, "translatedText": "hola", @@ -724,6 +746,7 @@ mod tests { let request = TranslationRequest { selection_id: 7, text: "persistence".into(), + example_sentence: None, source_language: "auto".into(), target_language: "en".into(), }; diff --git a/src-tauri/src/coordinator.rs b/src-tauri/src/coordinator.rs index e23b10e..340e144 100644 --- a/src-tauri/src/coordinator.rs +++ b/src-tauri/src/coordinator.rs @@ -234,6 +234,7 @@ mod tests { SelectionSnapshot { id, text: "selected".into(), + example_sentence: None, source_application_id: None, bounds_physical_px: vec![bounds], anchor_physical_px: bounds, diff --git a/src-tauri/src/integration_tests.rs b/src-tauri/src/integration_tests.rs index d10b08e..55c6262 100644 --- a/src-tauri/src/integration_tests.rs +++ b/src-tauri/src/integration_tests.rs @@ -162,6 +162,7 @@ fn selection() -> SelectionSnapshot { SelectionSnapshot { id: 7, text: "hello".into(), + example_sentence: None, source_application_id: Some("fixture.app".into()), bounds_physical_px: vec![bounds], anchor_physical_px: bounds, @@ -173,6 +174,7 @@ fn request() -> TranslationRequest { TranslationRequest { selection_id: 7, text: "hello".into(), + example_sentence: None, source_language: "auto".into(), target_language: "es".into(), } @@ -287,6 +289,7 @@ async fn typed_input_translates_without_a_native_selection() { .translate_input(TranslationRequest { selection_id: 0, text: "hello".into(), + example_sentence: None, source_language: "auto".into(), target_language: "es".into(), }) @@ -314,6 +317,7 @@ async fn typed_input_rejects_an_empty_request() { .translate_input(TranslationRequest { selection_id: 0, text: " ".into(), + example_sentence: None, source_language: "auto".into(), target_language: "es".into(), }) @@ -389,6 +393,7 @@ async fn selection_does_not_call_provider_until_explicit_translate() { let request = TranslationRequest { selection_id: selected.id, text: selected.text.clone(), + example_sentence: selected.example_sentence.clone(), source_language: "auto".into(), target_language: "es".into(), }; diff --git a/src-tauri/src/platform/macos/selection.rs b/src-tauri/src/platform/macos/selection.rs index d71ac0f..b9ad626 100644 --- a/src-tauri/src/platform/macos/selection.rs +++ b/src-tauri/src/platform/macos/selection.rs @@ -5,12 +5,13 @@ use std::{ borrow::Cow, + collections::HashSet, ffi::{c_char, c_double, c_float, c_int, c_long, c_void, CStr, CString}, mem, ptr::{self, NonNull}, sync::{ atomic::{AtomicU64, Ordering}, - Arc, + Arc, Mutex, }, time::{SystemTime, UNIX_EPOCH}, }; @@ -35,6 +36,7 @@ type Id = *mut c_void; type Sel = *mut c_void; const AX_SUCCESS: AXError = 0; +const AX_ERROR_ATTRIBUTE_UNSUPPORTED: AXError = -25205; const UTF8_ENCODING: u32 = 0x0800_0100; const AX_VALUE_CG_RECT: u32 = 3; const AX_VALUE_CF_RANGE: u32 = 4; @@ -78,10 +80,28 @@ enum CandidateFailure { struct ResolvedSelection { text: String, + example_sentence: Option, source_application_id: Option, bounds_physical_px: Vec, } +#[derive(Default)] +struct AccessibilityWakeCache(Mutex>); + +impl AccessibilityWakeCache { + fn enable_once(&self, pid: c_int, enable: impl FnOnce() -> bool) -> bool { + let mut enabled = self.0.lock().expect("accessibility wake cache"); + if enabled.contains(&pid) { + return true; + } + if !enable() { + return false; + } + enabled.insert(pid); + true + } +} + #[link(name = "ApplicationServices", kind = "framework")] unsafe extern "C" { fn AXIsProcessTrusted() -> Boolean; @@ -220,6 +240,7 @@ impl DisplaySource { pub struct MacSelectionAdapter { next_id: Arc, displays: DisplaySource, + accessibility_wakes: Arc, } impl MacSelectionAdapter { @@ -228,6 +249,7 @@ impl MacSelectionAdapter { Self { next_id: Arc::new(AtomicU64::new(1)), displays: DisplaySource::Live, + accessibility_wakes: Arc::new(AccessibilityWakeCache::default()), } } @@ -235,6 +257,7 @@ impl MacSelectionAdapter { Self { next_id: Arc::new(AtomicU64::new(1)), displays: DisplaySource::Fixed(Arc::new(displays)), + accessibility_wakes: Arc::new(AccessibilityWakeCache::default()), } } @@ -299,6 +322,7 @@ impl MacSelectionAdapter { Ok(SelectionSnapshot { id: self.next_id.fetch_add(1, Ordering::Relaxed), text: resolved.text, + example_sentence: resolved.example_sentence, source_application_id: resolved.source_application_id, bounds_physical_px: resolved.bounds_physical_px, anchor_physical_px, @@ -327,7 +351,10 @@ impl MacSelectionAdapter { let mut pid: c_int = 0; // SAFETY: the element is live and the pid is written only on success. if unsafe { AXUIElementGetPid(element.as_raw(), &mut pid) } == 0 { - enable_chromium_accessibility(pid); + let application_id = bundle_identifier(pid); + self.accessibility_wakes.enable_once(pid, || { + enable_chromium_accessibility(pid, application_id.as_deref()) + }); } // Reading a selection attribute is what makes a surface that builds its // tree lazily start building it. @@ -335,19 +362,21 @@ impl MacSelectionAdapter { } } -fn enable_chromium_accessibility(pid: c_int) { +fn enable_chromium_accessibility(pid: c_int, application_id: Option<&str>) -> bool { // SAFETY: create rule returns an owned AXUIElementRef for the process. let Some(application) = (unsafe { OwnedCf::from_create(AXUIElementCreateApplication(pid)) }) else { - return; - }; - let Some(attribute) = CfString::new(CHROMIUM_ACCESSIBILITY_ATTRIBUTE) else { - return; - }; - // SAFETY: both references are live and the value is a constant CFBoolean. - let _ = unsafe { - AXUIElementSetAttributeValue(application.as_raw(), attribute.as_raw(), kCFBooleanTrue) + return false; }; + try_accessibility_wake(accessibility_wake_attributes(application_id), |attribute| { + let Some(attribute) = CfString::new(attribute) else { + return -1; + }; + // SAFETY: both references are live and the value is a constant CFBoolean. + unsafe { + AXUIElementSetAttributeValue(application.as_raw(), attribute.as_raw(), kCFBooleanTrue) + } + }) } fn selection_from_lineage( @@ -370,8 +399,10 @@ fn selection_from_lineage( if let Some((text, bounds_physical_px)) = selection_on_element(element.as_raw(), policy.max_code_points, displays) { + let example_sentence = example_sentence_on_element(element.as_raw(), &text); return Ok(ResolvedSelection { text, + example_sentence, source_application_id, bounds_physical_px, }); @@ -468,6 +499,86 @@ fn eligible_text(text: &str, max_code_points: usize) -> bool { !text.trim().is_empty() && text.chars().count() <= max_code_points } +fn example_sentence_on_element(element: AXUIElementRef, selected_text: &str) -> Option { + let full_text = copy_attribute(element, "AXValue") + .ok() + .and_then(|value| cf_string_to_string(value.as_raw()))?; + let selected_range = copy_attribute(element, "AXSelectedTextRange") + .ok() + .and_then(|value| ax_range(value.as_raw())); + sentence_containing_selection(&full_text, selected_text, selected_range) +} + +fn sentence_containing_selection( + full_text: &str, + selected_text: &str, + selected_range: Option, +) -> Option { + let selected_text = selected_text.trim(); + if selected_text.is_empty() { + return None; + } + let (selection_start, selection_end) = selected_range + .and_then(|range| utf16_range_to_byte_range(full_text, range)) + .filter(|(start, end)| { + full_text + .get(*start..*end) + .is_some_and(|value| value.trim() == selected_text) + }) + .or_else(|| { + let matches = full_text + .match_indices(selected_text) + .map(|(start, value)| (start, start + value.len())) + .collect::>(); + (matches.len() == 1).then_some(matches[0]) + })?; + + let sentence_start = full_text[..selection_start] + .char_indices() + .filter(|(_, character)| is_sentence_boundary(*character)) + .map(|(index, character)| index + character.len_utf8()) + .next_back() + .unwrap_or(0); + let sentence_end = full_text[selection_end..] + .char_indices() + .find(|(_, character)| is_sentence_boundary(*character)) + .map(|(index, character)| selection_end + index + character.len_utf8()) + .unwrap_or(full_text.len()); + let sentence = full_text.get(sentence_start..sentence_end)?.trim(); + (!sentence.is_empty() && sentence.chars().count() <= 5_000).then(|| sentence.to_owned()) +} + +fn utf16_range_to_byte_range(text: &str, range: CFRange) -> Option<(usize, usize)> { + let location = usize::try_from(range.location).ok()?; + let length = usize::try_from(range.length).ok()?; + let end = location.checked_add(length)?; + Some(( + utf16_offset_to_byte(text, location)?, + utf16_offset_to_byte(text, end)?, + )) +} + +fn utf16_offset_to_byte(text: &str, target: usize) -> Option { + let mut units = 0; + for (index, character) in text.char_indices() { + if units == target { + return Some(index); + } + units += character.len_utf16(); + if units > target { + return None; + } + } + (units == target).then_some(text.len()) +} + +fn is_sentence_boundary(character: char) -> bool { + matches!( + character, + '.' | '!' | '?' | '。' | '!' | '?' | '\n' | '\r' + ) +} + #[async_trait] impl SelectionAdapter for MacSelectionAdapter { async fn resolve_selection( @@ -491,6 +602,47 @@ impl SelectionAdapter for MacSelectionAdapter { /// sets this attribute on the application element. Other applications reject it /// harmlessly. const CHROMIUM_ACCESSIBILITY_ATTRIBUTE: &str = "AXManualAccessibility"; +const ENHANCED_ACCESSIBILITY_ATTRIBUTE: &str = "AXEnhancedUserInterface"; +const MANUAL_ACCESSIBILITY_ONLY: &[&str] = &[CHROMIUM_ACCESSIBILITY_ATTRIBUTE]; +const ENHANCED_ACCESSIBILITY_ONLY: &[&str] = &[ENHANCED_ACCESSIBILITY_ATTRIBUTE]; +const MANUAL_THEN_ENHANCED_ACCESSIBILITY: &[&str] = &[ + CHROMIUM_ACCESSIBILITY_ATTRIBUTE, + ENHANCED_ACCESSIBILITY_ATTRIBUTE, +]; + +/// Electron documents `AXManualAccessibility`, while plain Chromium browsers +/// observe the screen-reader attribute. Keep the broader attribute limited to +/// the requested targets and cache successful activation per process. +fn accessibility_wake_attributes(application_id: Option<&str>) -> &'static [&'static str] { + if application_id.is_some_and(|application_id| { + application_id == "com.google.Chrome" + || application_id.starts_with("com.google.Chrome.") + || application_id == "com.microsoft.edgemac" + || application_id.starts_with("com.microsoft.edgemac.") + }) { + ENHANCED_ACCESSIBILITY_ONLY + } else if application_id.is_some_and(|application_id| { + matches!(application_id, "com.openai.codex" | "com.openai.chat") + }) { + MANUAL_THEN_ENHANCED_ACCESSIBILITY + } else { + MANUAL_ACCESSIBILITY_ONLY + } +} + +fn try_accessibility_wake( + attributes: &[&str], + mut set_attribute: impl FnMut(&str) -> AXError, +) -> bool { + for attribute in attributes { + match set_attribute(attribute) { + AX_SUCCESS => return true, + AX_ERROR_ATTRIBUTE_UNSUPPORTED => continue, + _ => return false, + } + } + false +} pub fn normalize_rect( logical: PhysicalRect, @@ -1097,9 +1249,10 @@ fn create_dictionary(keys: &[CFTypeRef], values: &[CFTypeRef]) -> Option(1)?; + let original_source_language = row.get::<_, String>(2)?; + let english_language = row.get::<_, String>(3)?; + Ok(( + row.get::<_, i64>(0)?, + source_text, + Some(original_source_language.clone()), + original_source_language, + english_language, + row.get::<_, Option>(4)?, + )) + }) + .map_err(storage_error)?; + let mut matches = rows.collect::, _>>().map_err(storage_error)?; + (matches.len() == 1).then(|| matches.remove(0)) + } else { + transaction.query_row( + "SELECT id, translated_text, detected_source_language, effective_source_language, target_language, part_of_speech FROM vocabulary_entries WHERE normalized_text = ?1 AND target_language = ?3 AND ( @@ -109,9 +148,10 @@ impl VocabularyStore { ) ORDER BY CASE WHEN requested_source_language = ?2 COLLATE NOCASE THEN 0 ELSE 1 END, id LIMIT 1", - params![normalized, request.source_language.trim(), request.target_language.trim()], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, Option>(5)?)), - ).optional().map_err(storage_error)?; + params![normalized, request.source_language.trim(), request.target_language.trim()], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, Option>(5)?)), + ).optional().map_err(storage_error)? + }; let Some(( entry_id, translated_text, @@ -123,10 +163,20 @@ impl VocabularyStore { else { return Ok(None); }; - transaction.execute( - "UPDATE vocabulary_entries SET lookup_count = lookup_count + 1, last_seen_epoch_ms = ?1 WHERE id = ?2", - params![to_i64(now_ms), entry_id], - ).map_err(storage_error)?; + transaction + .execute( + "UPDATE vocabulary_entries + SET lookup_count = lookup_count + 1, + last_seen_epoch_ms = ?1, + example_sentence = coalesce(?3, example_sentence) + WHERE id = ?2", + params![ + to_i64(now_ms), + entry_id, + clean_example_sentence(request.example_sentence.as_deref()) + ], + ) + .map_err(storage_error)?; insert_event(&transaction, entry_id, "lookup-hit", now_ms, None, None)?; transaction.commit().map_err(storage_error)?; Ok(Some(TranslationResult { @@ -148,6 +198,39 @@ impl VocabularyStore { if normalize_text(&request.text) == normalize_text(&result.translated_text) { return Ok(()); } + let reverse_pair = primary_language(&result.effective_source_language) + .eq_ignore_ascii_case("zh") + && primary_language(&result.target_language).eq_ignore_ascii_case("en"); + let source_text = if reverse_pair { + result.translated_text.trim() + } else { + request.text.trim() + }; + let translated_text = if reverse_pair { + request.text.trim() + } else { + result.translated_text.trim() + }; + let requested_source_language = if reverse_pair { + "en" + } else { + request.source_language.trim() + }; + let target_language = if reverse_pair { + result.effective_source_language.trim() + } else { + request.target_language.trim() + }; + let effective_source_language = if reverse_pair { + "en" + } else { + result.effective_source_language.trim() + }; + let detected_source_language = if reverse_pair { + Some("en") + } else { + result.detected_source_language.as_deref() + }; let mut connection = self .connection .lock() @@ -157,17 +240,18 @@ impl VocabularyStore { "INSERT INTO vocabulary_entries ( normalized_text, source_text, requested_source_language, target_language, translated_text, detected_source_language, effective_source_language, - first_seen_epoch_ms, last_seen_epoch_ms, part_of_speech - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, ?9) + first_seen_epoch_ms, last_seen_epoch_ms, part_of_speech, example_sentence + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, ?9, ?10) ON CONFLICT(normalized_text, requested_source_language, target_language) DO UPDATE SET lookup_count = lookup_count + 1, last_seen_epoch_ms = excluded.last_seen_epoch_ms, - part_of_speech = coalesce(excluded.part_of_speech, vocabulary_entries.part_of_speech)", - params![normalize_text(&request.text), request.text.trim(), request.source_language.trim(), request.target_language.trim(), result.translated_text, result.detected_source_language, result.effective_source_language, to_i64(now_ms), result.part_of_speech.map(PartOfSpeech::as_str)], + part_of_speech = coalesce(excluded.part_of_speech, vocabulary_entries.part_of_speech), + example_sentence = coalesce(excluded.example_sentence, vocabulary_entries.example_sentence)", + params![normalize_text(source_text), source_text, requested_source_language, target_language, translated_text, detected_source_language, effective_source_language, to_i64(now_ms), result.part_of_speech.map(PartOfSpeech::as_str), clean_example_sentence(request.example_sentence.as_deref())], ).map_err(storage_error)?; let entry_id = transaction.query_row( "SELECT id FROM vocabulary_entries WHERE normalized_text = ?1 AND requested_source_language = ?2 AND target_language = ?3", - params![normalize_text(&request.text), request.source_language.trim(), request.target_language.trim()], + params![normalize_text(source_text), requested_source_language, target_language], |row| row.get::<_, i64>(0), ).map_err(storage_error)?; insert_event(&transaction, entry_id, "lookup-miss", now_ms, None, None)?; @@ -191,7 +275,7 @@ impl VocabularyStore { "SELECT id, source_text, translated_text, requested_source_language, effective_source_language, target_language, lookup_count, recall_score, review_count, correct_count, wrong_count, correct_streak, wrong_streak, last_seen_epoch_ms, last_reviewed_epoch_ms - , part_of_speech + , part_of_speech, example_sentence FROM vocabulary_entries WHERE ?1 IS NULL OR normalized_text LIKE ?1 OR lower(translated_text) LIKE ?1 ORDER BY last_seen_epoch_ms DESC, id DESC", @@ -763,6 +847,7 @@ fn row_to_entry(row: &Row<'_>, now_ms: u64) -> rusqlite::Result id: row.get(0)?, source_text: row.get(1)?, translated_text: row.get(2)?, + example_sentence: row.get(16)?, requested_source_language: row.get(3)?, effective_source_language: row.get(4)?, target_language: row.get(5)?, @@ -797,6 +882,27 @@ fn table_has_column(connection: &Connection, table: &str, column: &str) -> Resul Ok(false) } +fn clean_example_sentence(value: Option<&str>) -> Option<&str> { + value + .map(str::trim) + .filter(|value| !value.is_empty() && value.chars().count() <= 5_000) +} + +fn primary_language(value: &str) -> &str { + value.split('-').next().unwrap_or(value) +} + +fn is_chinese_to_english_request(request: &TranslationRequest) -> bool { + primary_language(&request.target_language).eq_ignore_ascii_case("en") + && (primary_language(&request.source_language).eq_ignore_ascii_case("zh") + || (request.source_language.eq_ignore_ascii_case("auto") + && request.text.chars().any(is_han_character))) +} + +fn is_han_character(character: char) -> bool { + matches!(character as u32, 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x2FA1F) +} + fn conservative_root(text: &str) -> Option { let normalized = normalize_text(text); if normalized.contains(' ') @@ -906,6 +1012,7 @@ mod tests { TranslationRequest { selection_id: id, text: text.into(), + example_sentence: None, source_language: "auto".into(), target_language: target_language.into(), } @@ -997,6 +1104,7 @@ mod tests { let explicit_source = TranslationRequest { selection_id: 2, text: "ephemeral".into(), + example_sentence: None, source_language: "en".into(), target_language: "zh-CN".into(), }; @@ -1111,6 +1219,93 @@ mod tests { assert_eq!(entries.len(), 1); assert_eq!(entries[0].source_text, "legacy"); assert_eq!(entries[0].part_of_speech, None); + assert_eq!(entries[0].example_sentence, None); + } + + #[tokio::test] + async fn chinese_to_english_entries_are_saved_with_an_english_source_and_example() { + struct ChineseProvider { + calls: AtomicUsize, + } + #[async_trait] + impl TranslationProvider for ChineseProvider { + async fn translate( + &self, + request: &TranslationRequest, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(TranslationResult { + selection_id: request.selection_id, + translated_text: "deception".into(), + detected_source_language: Some("zh-CN".into()), + effective_source_language: "zh-CN".into(), + target_language: "en".into(), + part_of_speech: None, + }) + } + + async fn supported_languages(&self) -> Result, AppError> { + Ok(vec!["en".into(), "zh-CN".into()]) + } + } + + let store = Arc::new(VocabularyStore::in_memory().expect("store")); + let upstream = Arc::new(ChineseProvider { + calls: AtomicUsize::new(0), + }); + let provider = VocabularyTranslationProvider::new(upstream.clone(), store.clone()); + let request = TranslationRequest { + selection_id: 1, + text: "欺骗".into(), + example_sentence: Some("这是一个欺骗性的说法。".into()), + source_language: "auto".into(), + target_language: "en".into(), + }; + + let result = provider.translate(&request).await.expect("translation"); + assert_eq!(result.translated_text, "deception"); + let cached = provider + .translate(&TranslationRequest { + selection_id: 2, + ..request.clone() + }) + .await + .expect("cached translation"); + assert_eq!(cached.translated_text, "deception"); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); + let entries = store.list(None, 2).expect("entries"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].source_text, "deception"); + assert_eq!(entries[0].effective_source_language, "en"); + assert_eq!(entries[0].translated_text, "欺骗"); + assert_eq!(entries[0].target_language, "zh-CN"); + assert_eq!( + entries[0].example_sentence.as_deref(), + Some("这是一个欺骗性的说法。") + ); + } + + #[tokio::test] + async fn later_lookup_refreshes_the_saved_example_without_reorienting_english_pairs() { + let store = Arc::new(VocabularyStore::in_memory().expect("store")); + let upstream = Arc::new(FakeProvider { + calls: AtomicUsize::new(0), + }); + let provider = VocabularyTranslationProvider::new(upstream, store.clone()); + let mut first = request(1, "hello"); + first.example_sentence = Some("Hello is used here.".into()); + provider.translate(&first).await.expect("first"); + let mut second = request(2, "hello"); + second.example_sentence = Some("She said hello before leaving.".into()); + provider.translate(&second).await.expect("hit"); + + let entry = store.list(None, 3).expect("entries").remove(0); + assert_eq!(entry.source_text, "hello"); + assert_eq!(entry.effective_source_language, "en"); + assert_eq!( + entry.example_sentence.as_deref(), + Some("She said hello before leaving.") + ); } #[tokio::test] @@ -1490,6 +1685,7 @@ mod tests { let explicit = TranslationRequest { selection_id: 9, text: "bonjour".into(), + example_sentence: None, source_language: "fr".into(), target_language: "zh-CN".into(), }; diff --git a/src/components/context/ContextualOverlay.tsx b/src/components/context/ContextualOverlay.tsx index 7fdd692..634af27 100644 --- a/src/components/context/ContextualOverlay.tsx +++ b/src/components/context/ContextualOverlay.tsx @@ -85,6 +85,7 @@ export function ContextualOverlay({ const request = (correctedSource = sourceLanguage): TranslationRequest => ({ selectionId: state.selection.id, text: state.selection.text, + exampleSentence: state.selection.exampleSentence, sourceLanguage: correctedSource, targetLanguage, }); diff --git a/src/components/vocabulary/RelatedWordsView.tsx b/src/components/vocabulary/RelatedWordsView.tsx index 1a69fd3..fe916d4 100644 --- a/src/components/vocabulary/RelatedWordsView.tsx +++ b/src/components/vocabulary/RelatedWordsView.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; -import type { RelatedWord, UiLocale, VocabularyEntry, VocabularyProvenance } from "../../contracts/ipc"; +import type { RelatedWord, UiLocale, VocabularyEntry } from "../../contracts/ipc"; import { PartOfSpeechBadge } from "./PracticeView"; import type { StudyApi } from "./VocabularyWindow"; @@ -18,19 +18,8 @@ export function RelatedWordsView({ anchor, api, revision, locale = "en", onBack const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [adding, setAdding] = useState(); - const [provenance, setProvenance] = useState([]); const relatedRequest = useRef(0); - useEffect(() => { - let current = true; - setProvenance([]); - if (!anchor) return () => { current = false; }; - void api.listVocabularyProvenance(anchor.id) - .then((items) => { if (current) setProvenance(items); }) - .catch(() => undefined); - return () => { current = false; }; - }, [anchor, api]); - useEffect(() => { const request = ++relatedRequest.current; if (!anchor) { @@ -45,17 +34,11 @@ export function RelatedWordsView({ anchor, api, revision, locale = "en", onBack .finally(() => { if (request === relatedRequest.current) setLoading(false); }); }, [anchor, api, revision, zh]); - return
-

{zh ? "相关词" : "Related words"}

{zh ? "关联结果来自你的词汇本和所有兼容的已下载词书。" : "Connections combine your wordbook and every compatible downloaded textbook."}

- {provenance.length > 0 &&
- {zh ? "词书来源详情" : "Textbook source details"} {provenance.length} -
{provenance.map((item) =>
- {item.textbookTitle} - {zh ? "版本" : "Version"} {item.textbookVersion} · {item.license} - {item.attribution} - {zh ? "查看来源" : "View source"} -
)}
-
} + const subtitle = anchor?.exampleSentence + ?? (zh ? "关联来自你的词汇本和已下载词书。" : "Connections use your wordbook and downloaded textbooks."); + + return
+

{zh ? "相关词" : "Related words"}

{subtitle}

{error &&
{error}
} {!anchor ?
{zh ? "请先选择一个词。" : "Choose a word first."}{zh ? "从“我的词汇本”打开一张卡片作为关联词。" : "Open a card in My wordbook to make it the connection anchor."}
: loading ?
{zh ? "正在查找关联…" : "Tracing connections…"}
: items.length === 0 ?
{zh ? "暂未找到兼容的关联。" : "No compatible connections yet."}{zh ? "随着本地词汇积累,可以尝试其他词。" : "Try another word as your local collection grows."}
:
{items.map((item) =>
{item.reason === "root" ? (zh ? "同词根" : "shared root") : (zh ? "同义项" : "shared meaning")}{item.sourceText}{item.translatedText} diff --git a/src/components/vocabulary/VocabularyWindow.test.tsx b/src/components/vocabulary/VocabularyWindow.test.tsx index 9cc6f7c..711509b 100644 --- a/src/components/vocabulary/VocabularyWindow.test.tsx +++ b/src/components/vocabulary/VocabularyWindow.test.tsx @@ -18,6 +18,7 @@ const entry: VocabularyEntry = { id: 1, sourceText: "hello", translatedText: "hola", + exampleSentence: "She said hello before leaving.", requestedSourceLanguage: "auto", effectiveSourceLanguage: "en", targetLanguage: "es", @@ -243,7 +244,7 @@ describe("VocabularyWindow", () => { expect(open).toHaveBeenCalledWith(1); }); - it("shows retained textbook provenance and its source for an opened personal word", async () => { + it("uses the saved example as the related subtitle and removes textbook source details", async () => { const api = makeStudyApi({ listVocabularyProvenance: vi.fn().mockResolvedValue([{ textbookId: "wikdict-en-zh", @@ -263,17 +264,18 @@ describe("VocabularyWindow", () => { act(() => cardAction?.click()); await flushEffects(); - expect(api.listVocabularyProvenance).toHaveBeenCalledWith(1); - const disclosure = container.querySelector(".word-provenance"); - expect(disclosure?.open).toBe(false); - expect(disclosure?.querySelector("summary")?.textContent).toContain("Textbook source details"); - act(() => disclosure?.querySelector("summary")?.click()); - expect(disclosure?.open).toBe(true); - expect(container.textContent).toContain("WikDict English - Chinese"); - expect(container.textContent).toContain("2_2026-06"); - expect(container.textContent).toContain("CC BY-SA 4.0"); - expect(container.textContent).toContain("WikDict, Wiktionary and DBnary contributors"); - expect(container.querySelector('a[href="https://www.wikdict.com/page/download"]')?.textContent).toBe("View source"); + expect(container.querySelector(".related-example")?.textContent).toBe("She said hello before leaving."); + expect(api.listVocabularyProvenance).not.toHaveBeenCalled(); + expect(container.querySelector(".word-provenance")).toBeNull(); + expect(container.textContent).not.toContain("Textbook source details"); + }); + + it("keeps concise corpus guidance when the related anchor has no saved example", async () => { + const api = makeStudyApi(); + act(() => root.render()); + act(() => container.querySelector('[aria-label="Open related words for hello"]')?.click()); + await flushEffects(); + expect(container.querySelector(".related-example")?.textContent).toContain("wordbook and downloaded textbooks"); }); it("explains unavailable pronunciation states", () => { diff --git a/src/contracts/fixtures.json b/src/contracts/fixtures.json index bbc4c58..8b09686 100644 --- a/src/contracts/fixtures.json +++ b/src/contracts/fixtures.json @@ -2,6 +2,7 @@ "selection": { "id": 42, "text": "hello", + "exampleSentence": "She said hello before leaving.", "sourceApplicationId": "fixture.app", "boundsPhysicalPx": [ { "x": 100.0, "y": 200.0, "width": 80.0, "height": 24.0 } @@ -24,6 +25,7 @@ "translationRequest": { "selectionId": 42, "text": "hello", + "exampleSentence": "She said hello before leaving.", "sourceLanguage": "auto", "targetLanguage": "zh-CN" }, diff --git a/src/contracts/ipc.test.ts b/src/contracts/ipc.test.ts index 6680bde..381dc50 100644 --- a/src/contracts/ipc.test.ts +++ b/src/contracts/ipc.test.ts @@ -118,8 +118,12 @@ describe("IPC contracts", () => { it("rejects malformed or unsafe values", () => { expect(isSelectionSnapshot({ ...fixtures.selection, text: "" })).toBe(false); + expect(isSelectionSnapshot({ ...fixtures.selection, exampleSentence: " " })).toBe(false); + expect(isSelectionSnapshot({ ...fixtures.selection, exampleSentence: "x".repeat(5_001) })).toBe(false); expect(isUserSettings({ ...fixtures.settings, maxSelectionCodePoints: 0 })).toBe(false); expect(isTranslationRequest({ ...fixtures.translationRequest, selectionId: -1 })).toBe(false); + expect(isTranslationRequest({ ...fixtures.translationRequest, exampleSentence: " " })).toBe(false); + expect(isTranslationRequest({ ...fixtures.translationRequest, exampleSentence: "x".repeat(5_001) })).toBe(false); expect( isTranslationRequest({ ...fixtures.translationRequest, diff --git a/src/contracts/ipc.ts b/src/contracts/ipc.ts index d599d6f..7dd8c84 100644 --- a/src/contracts/ipc.ts +++ b/src/contracts/ipc.ts @@ -42,6 +42,7 @@ export interface PhysicalRect { export interface SelectionSnapshot { id: number; text: string; + exampleSentence?: string; sourceApplicationId?: string; boundsPhysicalPx: PhysicalRect[]; anchorPhysicalPx: PhysicalRect; @@ -67,6 +68,7 @@ export interface UserSettings { export interface TranslationRequest { selectionId: number; text: string; + exampleSentence?: string; sourceLanguage: "auto" | LanguageCode; targetLanguage: LanguageCode; } @@ -85,6 +87,7 @@ export interface VocabularyEntry { id: number; sourceText: string; translatedText: string; + exampleSentence?: string; requestedSourceLanguage: LanguageCode; effectiveSourceLanguage: LanguageCode; targetLanguage: LanguageCode; @@ -298,6 +301,10 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +function isOptionalExampleSentence(value: unknown): value is string | undefined { + return value === undefined || (isNonEmptyString(value) && [...value].length <= 5_000); +} + export function isPartOfSpeech(value: unknown): value is PartOfSpeech { return ( typeof value === "string" && @@ -348,6 +355,7 @@ export function isSelectionSnapshot(value: unknown): value is SelectionSnapshot isRecord(value) && isNonNegativeInteger(value.id) && isNonEmptyString(value.text) && + isOptionalExampleSentence(value.exampleSentence) && Array.isArray(value.boundsPhysicalPx) && value.boundsPhysicalPx.length > 0 && value.boundsPhysicalPx.every(isPhysicalRect) && @@ -382,6 +390,7 @@ export function isTranslationRequest(value: unknown): value is TranslationReques isRecord(value) && isNonNegativeInteger(value.selectionId) && isNonEmptyString(value.text) && + isOptionalExampleSentence(value.exampleSentence) && isNonEmptyString(value.sourceLanguage) && isNonEmptyString(value.targetLanguage) ); diff --git a/src/styles/app.css b/src/styles/app.css index e670d5d..e0aa506 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -207,6 +207,8 @@ input:focus-visible, .recall-ruler__value { color: var(--study-ink); font-family: var(--font-utility); font-size: 0.72rem; font-variant-numeric: tabular-nums; font-weight: 700; line-height: 1; text-align: center; } .relation-list { display: grid; gap: 10px; } +.related-view .study-header { margin-bottom: 18px; } +.related-example.is-saved { max-width: 720px; border-left: 2px solid var(--accent); padding-left: 12px; color: var(--study-ink); font-family: var(--font-lexical); font-size: 0.94rem; font-style: italic; } .relation-list article { display: grid; grid-template-columns: 120px minmax(120px, 0.7fr) minmax(0, 1fr) auto; align-items: center; gap: 16px; border-bottom: 1px solid var(--study-rule); padding: 16px 4px; } .relation-list article strong { color: var(--study-ink); font-family: var(--font-lexical); font-size: 1.2rem; font-weight: 500; } .relation-lexeme, .textbook-entry__lexeme { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; gap: 5px 8px; } @@ -227,12 +229,7 @@ input:focus-visible, .word-manage__confirm { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 4px; width: 100%; font-size: 0.68rem; } .word-manage__confirm .button, .word-manage__confirm .text-button { min-width: 44px; padding-inline: 9px; font-size: 0.7rem; } .word-manage .study-notice { margin: 0; } -.word-provenance { margin-bottom: 18px; border: 1px solid var(--study-rule); border-radius: 16px; background: color-mix(in srgb, var(--study-accent) 7%, transparent); } -.word-provenance summary { min-height: 44px; padding: 12px 16px; color: var(--study-ink); cursor: pointer; font-family: var(--font-utility); font-size: 0.72rem; font-weight: 700; } -.word-provenance summary span { margin-left: 6px; color: var(--accent-strong); } -.word-provenance__content { display: grid; gap: 9px; border-top: 1px solid var(--study-rule); padding: 14px 18px 16px; } -.word-provenance__content > div { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; } -.word-provenance a, .textbook-volume__copy a { color: var(--study-accent); text-underline-offset: 3px; } +.textbook-volume__copy a { color: var(--study-accent); text-underline-offset: 3px; } .source-selector, .direction-selector { From 39a801f16b94661ff008da626fee5da05ff8b94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Wed, 26 Aug 2026 22:35:19 +0800 Subject: [PATCH 2/2] chore(release): prepare v0.5.0 Bump the application version and document selection examples plus English-anchored related words. --- README.md | 8 +++++--- README.zh-CN.md | 2 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2c4ddad..6021dd2 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ tab. directly, without selecting anything. - **Local vocabulary study.** Eligible translated words and short phrases build a private on-device wordbook with pronunciation, recall tracking, related-word - discovery, downloaded textbooks, and bidirectional practice. + discovery, downloaded textbooks, and bidirectional practice. When a selection + sits in a clear sentence, that sentence is saved as the word's example. - **Stays out of the way.** Lives in the menu bar with no Dock icon, and can start at login. - **English and Simplified Chinese UI.** Switch immediately from Settings or @@ -72,8 +73,9 @@ The study window brings four tools together: including everyday, academic, TOEIC, business, and general-reference vocabulary. A textbook hit is copied into your personal wordbook. - **Related words** combines roots and shared meanings across your wordbook and - compatible downloaded textbooks, shows where every result came from, and lets - you add useful connections with one click. + compatible downloaded textbooks, shows the saved selection sentence when one + exists, and keeps English as the study anchor when Chinese is translated to + English. - **Practice** tests word-to-meaning, meaning-to-word, or a random mix. Related words and the active textbook supply more challenging distractors, while mastered words leave the active queue until their recall fades. diff --git a/README.zh-CN.md b/README.zh-CN.md index e201554..35ad558 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,7 +20,7 @@ - 支持 Google Cloud、百度翻译和微软翻译;微软可选择全球或中国区云环境。 - API 凭据通过原生安全输入框保存到系统钥匙串,不会进入 WebView 或设置文件。 - 原文和译文均可使用系统语音朗读。 -- 自动建立本地个人词库,支持词性、发音、相关词、双向测试和记忆分数。 +- 自动建立本地个人词库,支持词性、发音、相关词、双向测试和记忆分数;划词时若能确定所在句子,会把该句保存为例句,中译英词条以英文为相关词锚点。 - 内置一册离线的英汉入门词书;无需下载、无需配置 API 即可开始学习。 ![个人词库、记忆分数、词性和发音按钮](docs/screenshots/vocabulary-study-wordbook.png) diff --git a/package.json b/package.json index 374b8fc..08d4b69 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "desktop-translator", "private": true, - "version": "0.4.1", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 683cda6..4dcb94e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "desktop-translator" -version = "0.4.1" +version = "0.5.0" dependencies = [ "apple-native-keyring-store", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 270db8f..fc7baad 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "desktop-translator" -version = "0.4.1" +version = "0.5.0" description = "Lightweight cross-platform selection translator" authors = ["Desktop Translator Contributors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7b00ce1..ca3db4b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Desktop Translator", - "version": "0.4.1", + "version": "0.5.0", "identifier": "com.desktoptranslator.desktop", "build": { "beforeDevCommand": "pnpm dev",