Skip to content

Commit 72afaff

Browse files
committed
Cache online dictionary and etymology lookups with refresh support
Add persistent caches for Free Dictionary API and Wiktionary etymology lookups, following the existing AI dict cache pattern. Both caches use HashMap<String, Option<T>> to support negative caching (word not found). Backend: - New OnlineDictCache and EtymologyCache structs in state.rs - Response wrappers (OnlineLookupResponse, EtymologyResponse) that always carry `cached: bool`, even for not-found results - Add `refresh` parameter to all three lookup commands to bypass cache - Negative results are cached to avoid repeated API calls for nonexistent words Frontend: - Refresh button in input bar (hidden until first lookup, then persists) - Per-source "CACHED" pill badge on source headers and etymology label - All three invoke() calls pass the refresh flag
1 parent b66b8db commit 72afaff

File tree

5 files changed

+520
-65
lines changed

5 files changed

+520
-65
lines changed

crates/markdown_preview_core/js/dictionary.css

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,45 @@
299299
color: #0969da;
300300
}
301301

302+
/* Refresh button in input bar */
303+
.dict-refresh-btn {
304+
width: 38px;
305+
height: 38px;
306+
display: flex;
307+
align-items: center;
308+
justify-content: center;
309+
background: #f6f8fa;
310+
border: 1px solid #d0d7de;
311+
border-radius: 8px;
312+
cursor: pointer;
313+
font-size: 18px;
314+
color: #656d76;
315+
flex-shrink: 0;
316+
transition: background 0.15s, color 0.15s, border-color 0.15s;
317+
}
318+
.dict-refresh-btn:hover {
319+
background: #eaeef2;
320+
color: #24292f;
321+
border-color: #afb8c1;
322+
}
323+
.dict-refresh-btn:active {
324+
background: #d0d7de;
325+
}
326+
327+
/* Cached badge (pill, shown per-source when result is from cache) */
328+
.dict-cached-badge {
329+
display: inline-block;
330+
font-size: 10px;
331+
font-weight: 600;
332+
text-transform: uppercase;
333+
letter-spacing: 0.3px;
334+
color: #656d76;
335+
background: #eaeef2;
336+
border-radius: 10px;
337+
padding: 1px 7px;
338+
vertical-align: middle;
339+
}
340+
302341
/* Dark mode for dictionary */
303342
@media (prefers-color-scheme: dark) {
304343
.dict-ai-stats {
@@ -426,6 +465,24 @@
426465
.dict-entry { border-top-color: #30363d; }
427466
.dict-ai-loading { color: #8b949e; }
428467

468+
.dict-refresh-btn {
469+
background: #21262d;
470+
border-color: #30363d;
471+
color: #8b949e;
472+
}
473+
.dict-refresh-btn:hover {
474+
background: #30363d;
475+
color: #e6edf3;
476+
border-color: #484f58;
477+
}
478+
.dict-refresh-btn:active {
479+
background: #484f58;
480+
}
481+
.dict-cached-badge {
482+
color: #8b949e;
483+
background: #21262d;
484+
}
485+
429486
/* Etymology dark mode */
430487
.dict-etymology { background: #161b22; border-left-color: #58a6ff; }
431488
.dict-etymology-label { color: #58a6ff; }

crates/markdown_preview_core/js/dictionary.js

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@
6969
const btn = document.getElementById('dict-lookup-btn');
7070
if (!input || !btn) return;
7171

72+
// Create refresh button (hidden until first lookup)
73+
const refreshBtn = document.createElement('button');
74+
refreshBtn.id = 'dict-refresh-btn';
75+
refreshBtn.className = 'dict-refresh-btn';
76+
refreshBtn.title = 'Re-fetch from all sources';
77+
refreshBtn.innerHTML = '&#x21bb;';
78+
refreshBtn.style.display = 'none';
79+
btn.insertAdjacentElement('afterend', refreshBtn);
80+
refreshBtn.addEventListener('click', () => {
81+
const word = input.value.trim();
82+
if (word) performLookup(word, true);
83+
});
84+
7285
input.addEventListener('keydown', (e) => {
7386
if (e.key === 'Enter') {
7487
e.preventDefault();
@@ -84,7 +97,7 @@
8497
renderAiUsageStats();
8598
}
8699

87-
async function performLookup(word) {
100+
async function performLookup(word, refresh = false) {
88101
if (!word) return;
89102

90103
const thisLookupId = ++lookupCounter;
@@ -124,35 +137,46 @@
124137

125138
// 2. Online dictionary + Wiktionary etymology (parallel)
126139
let etymologyHtml = null;
140+
let etymologyCached = false;
127141
try {
128142
const [onlineResult, etymResult] = await Promise.allSettled([
129-
invoke('lookup_word_online', { word }),
130-
invoke('lookup_etymology', { word }),
143+
invoke('lookup_word_online', { word, refresh }),
144+
invoke('lookup_etymology', { word, refresh }),
131145
]);
132146
if (isStale()) { if (btn) btn.disabled = false; return; }
133147

134-
// Process online dictionary result
148+
// Process online dictionary result (new shape: { result, cached })
135149
if (onlineResult.status === 'fulfilled' && onlineResult.value) {
136-
const onlineEntry = onlineResult.value;
137-
hasOnlineResults = true;
138-
const onlineHtml = renderDictEntry(onlineEntry, 'online', onlineEntry.source || 'Online');
139-
if (hasOfflineResults) {
140-
resultsEl.insertAdjacentHTML('beforeend', onlineHtml);
141-
} else {
142-
resultsEl.innerHTML = onlineHtml;
150+
const onlineResp = onlineResult.value;
151+
if (onlineResp.result) {
152+
const onlineEntry = onlineResp.result;
153+
hasOnlineResults = true;
154+
const onlineHtml = renderDictEntry(onlineEntry, 'online', onlineEntry.source || 'Online', onlineResp.cached);
155+
if (hasOfflineResults) {
156+
resultsEl.insertAdjacentHTML('beforeend', onlineHtml);
157+
} else {
158+
resultsEl.innerHTML = onlineHtml;
159+
}
160+
wireUpDictTags(resultsEl);
161+
wireUpPronounceButtons(resultsEl);
143162
}
144-
wireUpDictTags(resultsEl);
145-
wireUpPronounceButtons(resultsEl);
146163
}
147164

148-
// Process Wiktionary etymology result
165+
// Process Wiktionary etymology result (new shape: { result, cached })
149166
if (etymResult.status === 'fulfilled' && etymResult.value) {
150-
etymologyHtml = etymResult.value.etymology_html;
151-
const etymSection = '<div class="dict-etymology dict-etymology-wiktionary">'
152-
+ '<div class="dict-etymology-label">Etymology <span class="dict-etymology-source">'
153-
+ `(${escapeHtml(etymResult.value.source)})</span></div>`
154-
+ `<div class="dict-etymology-text">${sanitizeHtml(etymologyHtml)}</div></div>`;
155-
resultsEl.insertAdjacentHTML('beforeend', etymSection);
167+
const etymResp = etymResult.value;
168+
etymologyCached = etymResp.cached;
169+
if (etymResp.result) {
170+
etymologyHtml = etymResp.result.etymology_html;
171+
const cachedTag = etymResp.cached
172+
? ' <span class="dict-cached-badge">cached</span>'
173+
: '';
174+
const etymSection = '<div class="dict-etymology dict-etymology-wiktionary">'
175+
+ '<div class="dict-etymology-label">Etymology <span class="dict-etymology-source">'
176+
+ `(${escapeHtml(etymResp.result.source)})</span>${cachedTag}</div>`
177+
+ `<div class="dict-etymology-text">${sanitizeHtml(etymologyHtml)}</div></div>`;
178+
resultsEl.insertAdjacentHTML('beforeend', etymSection);
179+
}
156180
}
157181
} catch (_err) {
158182
if (isStale()) { if (btn) btn.disabled = false; return; }
@@ -165,7 +189,7 @@
165189
'<div class="dict-ai-loading">Loading AI definition...</div>');
166190
}
167191

168-
const response = await invoke('lookup_word', { word });
192+
const response = await invoke('lookup_word', { word, refresh });
169193
if (isStale()) { if (btn) btn.disabled = false; return; }
170194

171195
const entry = response;
@@ -178,7 +202,7 @@
178202
const aiLoadingEl = resultsEl.querySelector('.dict-ai-loading');
179203
if (aiLoadingEl) aiLoadingEl.remove();
180204

181-
const aiHtml = renderDictEntry(entry, 'ai', 'AI');
205+
const aiHtml = renderDictEntry(entry, 'ai', 'AI', response.cached);
182206
if (hasAnyResults()) {
183207
resultsEl.insertAdjacentHTML('beforeend', aiHtml);
184208
} else {
@@ -220,6 +244,12 @@
220244
if (!hasAnyResults() && !hasAiResults) {
221245
resultsEl.innerHTML = '<div class="dict-empty-state">No results found</div>';
222246
}
247+
248+
// Show the refresh button in the input bar
249+
if (!isStale()) {
250+
const refreshBtn = document.getElementById('dict-refresh-btn');
251+
if (refreshBtn) refreshBtn.style.display = '';
252+
}
223253
}
224254

225255
function renderOfflineResults(results) {
@@ -239,13 +269,17 @@
239269
* @param {Object} entry - DictionaryEntry {word, phonetic, definitions, synonyms, antonyms}
240270
* @param {string} badgeClass - CSS class for the source badge (e.g. 'ai', 'online')
241271
* @param {string} badgeLabel - Display text for the source badge
272+
* @param {boolean} [cached=false] - Whether this result was served from cache
242273
*/
243-
function renderDictEntry(entry, badgeClass, badgeLabel) {
274+
function renderDictEntry(entry, badgeClass, badgeLabel, cached) {
244275
let html = `<div class="dict-entry">`;
245276

246277
// Word header with source badge, phonetic and pronounce button
247278
html += '<div class="dict-word-header">';
248279
html += `<div class="dict-source-badge ${escapeHtml(badgeClass)}">${escapeHtml(badgeLabel)}</div>`;
280+
if (cached) {
281+
html += '<span class="dict-cached-badge">cached</span>';
282+
}
249283
html += `<h1 class="dict-word-title">${escapeHtml(entry.word)}</h1>`;
250284
html += `<button class="dict-pronounce-btn" data-word="${escapeHtml(entry.word)}" title="Pronounce">&#x1f50a;</button>`;
251285
if (entry.phonetic) {

0 commit comments

Comments
 (0)