Skip to content

Commit f152e16

Browse files
committed
Add online dictionary lookup (Free Dictionary API) and macOS Edit menu
- Add lookup_word_online command using dictionaryapi.dev (free, no auth) - Returns structured DictionaryEntry (same as AI) with source name extracted from sourceUrls (e.g. "Wiktionary") - Unify online + AI rendering into single renderDictEntry() function - Lookup chain: offline → online → AI, with green badge for online source - Add macOS Edit menu with Undo/Redo/Cut/Copy/Paste/Select All so Cmd+C/V/X/A/Z work natively - Fix offline dict content overflow with CSS containment
1 parent 2f8cfb9 commit f152e16

File tree

5 files changed

+239
-17
lines changed

5 files changed

+239
-17
lines changed

crates/markdown_preview_core/js/styles.css

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3042,6 +3042,7 @@ body.presentation-mode #reading-progress {
30423042
}
30433043

30443044
.dict-source-badge.offline { background: #0d2240; color: #58a6ff; }
3045+
.dict-source-badge.online { background: #0d2d1a; color: #3fb950; }
30453046
.dict-source-badge.ai { background: #3d2e00; color: #d29922; }
30463047
.dict-offline-entry { border-bottom-color: #30363d; }
30473048
.dict-ai-loading { color: #8b949e; }
@@ -3064,6 +3065,7 @@ body.presentation-mode #reading-progress {
30643065
margin-bottom: 12px;
30653066
}
30663067
.dict-source-badge.offline { background: #ddf4ff; color: #0969da; }
3068+
.dict-source-badge.online { background: #dafbe1; color: #116329; }
30673069
.dict-source-badge.ai { background: #fff8c5; color: #9a6700; }
30683070

30693071
/* Offline dictionary entry container */
@@ -3072,14 +3074,14 @@ body.presentation-mode #reading-progress {
30723074
padding-bottom: 20px;
30733075
border-bottom: 1px solid #d0d7de;
30743076
}
3075-
.dict-offline-content { font-size: 14px; line-height: 1.6; }
3077+
.dict-offline-content { font-size: 14px; line-height: 1.6; overflow: auto; position: relative; }
30763078

30773079
/* AI loading indicator */
30783080
.dict-ai-loading {
30793081
font-size: 12px; color: #656d76; padding: 12px 0;
30803082
font-style: italic;
30813083
}
3082-
.dict-ai-entry {
3084+
.dict-entry {
30833085
margin-top: 8px;
30843086
}
30853087

crates/markdown_preview_core/js/tauri-app.js

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2352,8 +2352,12 @@
23522352
if (btn) btn.disabled = true;
23532353

23542354
let hasOfflineResults = false;
2355+
let hasOnlineResults = false;
23552356
let hasAiResults = false;
23562357

2358+
// Helper: returns true when we already have at least one source of results.
2359+
const hasAnyResults = () => hasOfflineResults || hasOnlineResults;
2360+
23572361
// 1. Offline lookup (fast, in-memory)
23582362
try {
23592363
const offlineResults = await invoke('lookup_word_offline', { word });
@@ -2369,9 +2373,28 @@
23692373
if (isStale()) { if (btn) btn.disabled = false; return; }
23702374
}
23712375

2372-
// 2. AI lookup (slow, async) — only if configured
2376+
// 2. Online dictionary API (Free Dictionary)
2377+
try {
2378+
const onlineEntry = await invoke('lookup_word_online', { word });
2379+
if (isStale()) { if (btn) btn.disabled = false; return; }
2380+
if (onlineEntry) {
2381+
hasOnlineResults = true;
2382+
const onlineHtml = renderDictEntry(onlineEntry, 'online', onlineEntry.source || 'Online');
2383+
if (hasOfflineResults) {
2384+
resultsEl.insertAdjacentHTML('beforeend', onlineHtml);
2385+
} else {
2386+
resultsEl.innerHTML = onlineHtml;
2387+
}
2388+
wireUpDictTags(resultsEl);
2389+
wireUpPronounceButtons(resultsEl);
2390+
}
2391+
} catch (_err) {
2392+
if (isStale()) { if (btn) btn.disabled = false; return; }
2393+
}
2394+
2395+
// 3. AI lookup (slow, async) — only if configured
23732396
try {
2374-
if (hasOfflineResults) {
2397+
if (hasAnyResults()) {
23752398
resultsEl.insertAdjacentHTML('beforeend',
23762399
'<div class="dict-ai-loading">Loading AI definition...</div>');
23772400
}
@@ -2389,22 +2412,23 @@
23892412
const aiLoadingEl = resultsEl.querySelector('.dict-ai-loading');
23902413
if (aiLoadingEl) aiLoadingEl.remove();
23912414

2392-
if (hasOfflineResults) {
2393-
resultsEl.insertAdjacentHTML('beforeend', renderAiDictEntry(entry));
2415+
const aiHtml = renderDictEntry(entry, 'ai', 'AI');
2416+
if (hasAnyResults()) {
2417+
resultsEl.insertAdjacentHTML('beforeend', aiHtml);
23942418
} else {
2395-
resultsEl.innerHTML = renderAiDictEntry(entry);
2419+
resultsEl.innerHTML = aiHtml;
23962420
}
23972421
wireUpDictTags(resultsEl);
23982422
wireUpPronounceButtons(resultsEl);
23992423
} catch (err) {
24002424
if (isStale()) { if (btn) btn.disabled = false; return; }
24012425
const aiLoadingEl = resultsEl.querySelector('.dict-ai-loading');
24022426
if (aiLoadingEl) aiLoadingEl.remove();
2403-
if (!hasOfflineResults) {
2427+
if (!hasAnyResults()) {
24042428
const errStr = String(err);
24052429
if (errStr.includes('not configured')) {
24062430
resultsEl.innerHTML = '<div class="dict-empty-state">'
2407-
+ '<p>No offline dictionaries matched.</p>'
2431+
+ '<p>No dictionaries matched.</p>'
24082432
+ '<p>AI provider not configured. Set one in Settings.</p></div>';
24092433
} else {
24102434
resultsEl.innerHTML = `<div class="dict-error">${escapeHtml(errStr)}</div>`;
@@ -2415,7 +2439,7 @@
24152439
}
24162440

24172441
if (isStale()) return;
2418-
if (!hasOfflineResults && !hasAiResults) {
2442+
if (!hasAnyResults() && !hasAiResults) {
24192443
resultsEl.innerHTML = '<div class="dict-empty-state">No results found</div>';
24202444
}
24212445
}
@@ -2430,9 +2454,15 @@
24302454
}).join('');
24312455
}
24322456

2433-
function renderAiDictEntry(entry) {
2434-
let html = '<div class="dict-ai-entry">';
2435-
html += '<div class="dict-source-badge ai">AI</div>';
2457+
/**
2458+
* Render a structured dictionary entry (used for both online and AI sources).
2459+
* @param {Object} entry - DictionaryEntry {word, phonetic, definitions, synonyms, antonyms}
2460+
* @param {string} badgeClass - CSS class for the source badge (e.g. 'ai', 'online')
2461+
* @param {string} badgeLabel - Display text for the source badge
2462+
*/
2463+
function renderDictEntry(entry, badgeClass, badgeLabel) {
2464+
let html = `<div class="dict-entry">`;
2465+
html += `<div class="dict-source-badge ${escapeHtml(badgeClass)}">${escapeHtml(badgeLabel)}</div>`;
24362466

24372467
// Word header with phonetic and pronounce button
24382468
html += '<div class="dict-word-header">';

maple_desk/src/commands/dictionary.rs

Lines changed: 180 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
//! Dictionary lookup commands: AI-powered and offline (StarDict/MDict).
1+
//! Dictionary lookup commands: AI-powered, online (Free Dictionary API),
2+
//! and offline (StarDict/MDict).
23
34
use std::sync::Arc;
45

5-
use serde::Serialize;
6+
use serde::{Deserialize, Serialize};
67
use tauri::State;
78
use tokio::sync::RwLock;
89

9-
use crate::ai::{self, AiConfig, DictionaryEntry};
10+
use crate::ai::{self, AiConfig, DictionaryDefinition, DictionaryEntry};
1011
use crate::state::{AppState, DictLoadResult, OfflineDictState};
1112

1213
/// Type alias for the managed dictionary load serializer.
@@ -118,6 +119,182 @@ pub async fn ask_ai(
118119
ai::ask_ai(&config, &question).await
119120
}
120121

122+
// ---------------------------------------------------------------------------
123+
// Free Dictionary API (dictionaryapi.dev) — online lookup
124+
// ---------------------------------------------------------------------------
125+
126+
/// Response from an online dictionary lookup, pairing the structured entry
127+
/// with the original source name (e.g. "Wiktionary").
128+
#[derive(Serialize)]
129+
pub struct OnlineLookupResult {
130+
/// The dictionary entry (same shape as AI lookups).
131+
#[serde(flatten)]
132+
pub entry: DictionaryEntry,
133+
/// Human-readable source name extracted from `sourceUrls`.
134+
pub source: String,
135+
}
136+
137+
/// Top-level entry from the Free Dictionary API response.
138+
#[derive(Deserialize)]
139+
struct FreeDictEntry {
140+
phonetic: Option<String>,
141+
#[serde(default)]
142+
meanings: Vec<FreeDictMeaning>,
143+
#[serde(default, rename = "sourceUrls")]
144+
source_urls: Vec<String>,
145+
}
146+
147+
/// A meaning group (one per part of speech).
148+
#[derive(Deserialize)]
149+
#[serde(rename_all = "camelCase")]
150+
struct FreeDictMeaning {
151+
part_of_speech: String,
152+
#[serde(default)]
153+
definitions: Vec<FreeDictDefinition>,
154+
#[serde(default)]
155+
synonyms: Vec<String>,
156+
#[serde(default)]
157+
antonyms: Vec<String>,
158+
}
159+
160+
/// A single definition inside a meaning group.
161+
#[derive(Deserialize)]
162+
struct FreeDictDefinition {
163+
definition: String,
164+
example: Option<String>,
165+
#[serde(default)]
166+
synonyms: Vec<String>,
167+
#[serde(default)]
168+
antonyms: Vec<String>,
169+
}
170+
171+
/// Convert Free Dictionary API entries into a [`DictionaryEntry`].
172+
///
173+
/// Merges all entries into a single result, collecting synonyms/antonyms from
174+
/// both the meaning-group and per-definition levels.
175+
fn free_dict_to_entry(word: String, raw: &[FreeDictEntry]) -> DictionaryEntry {
176+
let phonetic = raw
177+
.iter()
178+
.find_map(|e| e.phonetic.clone())
179+
.unwrap_or_default();
180+
181+
let mut definitions = Vec::new();
182+
let mut synonyms = Vec::new();
183+
let mut antonyms = Vec::new();
184+
185+
for entry in raw {
186+
for meaning in &entry.meanings {
187+
// Collect top-level synonyms/antonyms from the meaning group.
188+
synonyms.extend(meaning.synonyms.iter().cloned());
189+
antonyms.extend(meaning.antonyms.iter().cloned());
190+
191+
for def in &meaning.definitions {
192+
definitions.push(DictionaryDefinition {
193+
part_of_speech: meaning.part_of_speech.clone(),
194+
meaning: def.definition.clone(),
195+
example: def.example.clone().unwrap_or_default(),
196+
});
197+
synonyms.extend(def.synonyms.iter().cloned());
198+
antonyms.extend(def.antonyms.iter().cloned());
199+
}
200+
}
201+
}
202+
203+
// De-duplicate while preserving order.
204+
synonyms.dedup();
205+
antonyms.dedup();
206+
207+
DictionaryEntry {
208+
word,
209+
phonetic,
210+
definitions,
211+
synonyms,
212+
antonyms,
213+
}
214+
}
215+
216+
/// Extract a human-readable source name from a URL.
217+
///
218+
/// For example, `https://en.wiktionary.org/wiki/hello` → `"Wiktionary"`.
219+
/// Falls back to the domain segment if the name isn't recognised.
220+
fn source_name_from_url(url: &str) -> Option<String> {
221+
// Extract host from URL: skip "https://" or "http://", take up to next '/'.
222+
let after_scheme = url.split("://").nth(1)?;
223+
let host = after_scheme.split('/').next()?;
224+
225+
// Strip leading "en." / "www." etc.
226+
let base = host
227+
.strip_prefix("en.")
228+
.or_else(|| host.strip_prefix("www."))
229+
.unwrap_or(host);
230+
231+
// Capitalise the first segment before the first dot.
232+
let name = base.split('.').next().unwrap_or(base);
233+
let mut chars = name.chars();
234+
let capitalised: String = match chars.next() {
235+
Some(c) => c.to_uppercase().chain(chars).collect(),
236+
None => return None,
237+
};
238+
Some(capitalised)
239+
}
240+
241+
/// Look up a word using the Free Dictionary API (dictionaryapi.dev).
242+
///
243+
/// Returns an [`OnlineLookupResult`] containing the structured entry and the
244+
/// original source name (e.g. "Wiktionary").
245+
/// Returns `None` when the word is not found (HTTP 404).
246+
#[tauri::command]
247+
pub async fn lookup_word_online(word: String) -> Result<Option<OnlineLookupResult>, String> {
248+
let word = word.trim().to_string();
249+
if word.is_empty() {
250+
return Err("No word provided".to_string());
251+
}
252+
253+
let url = format!("https://api.dictionaryapi.dev/api/v2/entries/en/{word}");
254+
255+
let client = reqwest::Client::builder()
256+
.timeout(std::time::Duration::from_secs(10))
257+
.build()
258+
.map_err(|error| format!("HTTP client error: {error}"))?;
259+
260+
let response = client
261+
.get(&url)
262+
.send()
263+
.await
264+
.map_err(|error| format!("Network error: {error}"))?;
265+
266+
if response.status() == reqwest::StatusCode::NOT_FOUND {
267+
return Ok(None);
268+
}
269+
270+
if !response.status().is_success() {
271+
return Err(format!(
272+
"Free Dictionary API returned status {}",
273+
response.status()
274+
));
275+
}
276+
277+
let entries: Vec<FreeDictEntry> = response
278+
.json()
279+
.await
280+
.map_err(|error| format!("Failed to parse Free Dictionary response: {error}"))?;
281+
282+
if entries.is_empty() {
283+
return Ok(None);
284+
}
285+
286+
let source = entries
287+
.iter()
288+
.flat_map(|e| e.source_urls.iter())
289+
.find_map(|u| source_name_from_url(u))
290+
.unwrap_or_else(|| "Free Dictionary".to_string());
291+
292+
Ok(Some(OnlineLookupResult {
293+
entry: free_dict_to_entry(word, &entries),
294+
source,
295+
}))
296+
}
297+
121298
/// Look up a word in all loaded offline dictionaries.
122299
#[tauri::command]
123300
pub async fn lookup_word_offline(

maple_desk/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ fn main() {
196196
commands::terminal::resize_terminal,
197197
commands::terminal::kill_terminal,
198198
commands::dictionary::lookup_word,
199+
commands::dictionary::lookup_word_online,
199200
commands::dictionary::lookup_word_offline,
200201
commands::dictionary::get_loaded_dictionaries,
201202
commands::dictionary::ask_ai,

maple_desk/src/menu.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Native menu implementation for the markdown preview app.
22
3-
use tauri::menu::{Menu, MenuBuilder, MenuEvent, MenuItem, SubmenuBuilder};
3+
use tauri::menu::{Menu, MenuBuilder, MenuEvent, MenuItem, PredefinedMenuItem, SubmenuBuilder};
44
use tauri::{AppHandle, Emitter, Manager, Runtime};
55

66
/// Create the application menu.
@@ -23,6 +23,17 @@ pub fn create_menu<R: Runtime>(app: &AppHandle<R>) -> Result<Menu<R>, tauri::Err
2323
.item(&close_item)
2424
.build()?;
2525

26+
// Edit menu (required on macOS for Cmd+C/V/X/A/Z to work)
27+
let edit_menu = SubmenuBuilder::new(app, "Edit")
28+
.item(&PredefinedMenuItem::undo(app, None)?)
29+
.item(&PredefinedMenuItem::redo(app, None)?)
30+
.separator()
31+
.item(&PredefinedMenuItem::cut(app, None)?)
32+
.item(&PredefinedMenuItem::copy(app, None)?)
33+
.item(&PredefinedMenuItem::paste(app, None)?)
34+
.item(&PredefinedMenuItem::select_all(app, None)?)
35+
.build()?;
36+
2637
// View menu
2738
let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
2839

@@ -75,6 +86,7 @@ pub fn create_menu<R: Runtime>(app: &AppHandle<R>) -> Result<Menu<R>, tauri::Err
7586
// Build the complete menu
7687
MenuBuilder::new(app)
7788
.item(&file_menu)
89+
.item(&edit_menu)
7890
.item(&view_menu)
7991
.item(&theme_menu)
8092
.item(&help_menu)

0 commit comments

Comments
 (0)