translate 0.1.4 → 0.1.5: most-used languages pinned above the list

Both selects grow a "Frequently used" optgroup above "All languages",
holding up to ten entries ranked by how often each language has actually
been translated to or from.

Counted on a successful translation, not on a dropdown change: picking
your way down the list looking for something would otherwise rank every
language you skimmed past as highly as the ones you work in. A
detected source counts too — with Auto-detect on you never pick that
language explicitly, but it is one you read.

Ties break on the language's display name so the order is stable rather
than dependent on object key order. Counts live in the existing uiState,
so they persist through the saveUi/loadState path already there, and the
group only appears once there is something to put in it.

Verified in the harness: one translation records exactly one use for the
target and one for the detected source; the cap holds at ten with
fifteen tracked; and a pre-seeded profile comes back with its group,
target and zoom restored, Auto-detect still first in the source select.
This commit is contained in:
Local Dev 2026-09-27 21:02:22 +02:00
parent 3cb5081bdb
commit 5946923547
2 changed files with 61 additions and 6 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "translate", "id": "translate",
"name": "Translate", "name": "Translate",
"version": "0.1.4", "version": "0.1.5",
"description": "Right-click a selection to translate it. Sidebar panel with LibreTranslate or Google as the backend.", "description": "Right-click a selection to translate it. Sidebar panel with LibreTranslate or Google as the backend.",
"author": "Silent Mode", "author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4gPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTEuNSIgZmlsbD0iIzFkNGVkOCIvPiA8cmVjdCB4PSIwIiB5PSIwLjYiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiNmZmYiLz4gPHRleHQgeD0iNi43IiB5PSIxMi4xIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0ic3lzdGVtLXVpLFNlZ29lIFVJLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTIuNCIgZm9udC13ZWlnaHQ9IjgwMCIgZmlsbD0iIzFkNGVkOCI+QTwvdGV4dD4gPHJlY3QgeD0iMTAuNiIgeT0iMTAiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiMwYjEyMjAiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjEiLz4gPGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj4gPHBhdGggZD0iTTE3LjMgMTIuMnYxLjEiLz4gPHBhdGggZD0iTTEzLjUgMTQuNmg3LjYiLz4gPHBhdGggZD0iTTE4LjIgMTUuNyAxMy45IDIxIi8+IDxwYXRoIGQ9Ik0xNi41IDE1LjcgMjAuOCAyMSIvPiA8L2c+IDwvc3ZnPg==", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4gPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTEuNSIgZmlsbD0iIzFkNGVkOCIvPiA8cmVjdCB4PSIwIiB5PSIwLjYiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiNmZmYiLz4gPHRleHQgeD0iNi43IiB5PSIxMi4xIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0ic3lzdGVtLXVpLFNlZ29lIFVJLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTIuNCIgZm9udC13ZWlnaHQ9IjgwMCIgZmlsbD0iIzFkNGVkOCI+QTwvdGV4dD4gPHJlY3QgeD0iMTAuNiIgeT0iMTAiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiMwYjEyMjAiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjEiLz4gPGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj4gPHBhdGggZD0iTTE3LjMgMTIuMnYxLjEiLz4gPHBhdGggZD0iTTEzLjUgMTQuNmg3LjYiLz4gPHBhdGggZD0iTTE4LjIgMTUuNyAxMy45IDIxIi8+IDxwYXRoIGQ9Ik0xNi41IDE1LjcgMjAuOCAyMSIvPiA8L2c+IDwvc3ZnPg==",

View file

@ -186,14 +186,53 @@
["sv","Swedish"],["th","Thai"],["tr","Turkish"],["uk","Ukrainian"], ["sv","Swedish"],["th","Thai"],["tr","Turkish"],["uk","Ukrainian"],
["vi","Vietnamese"],["zh","Chinese"], ["vi","Vietnamese"],["zh","Chinese"],
]; ];
const LANG_NAME = new Map(LANGS);
// Most-used languages, pinned above the full list. Ranked by use count and
// capped at RECENT_MAX so the group stays a shortcut rather than a second
// copy of the alphabet. "auto" is excluded — it is already the first row of
// the source select and is not a language anyone is "choosing".
const RECENT_MAX = 10;
function noteLangUse(code) {
if (!code || code === "auto") return;
const r = uiState.recent || (uiState.recent = {});
r[code] = (r[code] || 0) + 1;
}
function topLangs() {
const r = uiState.recent || {};
return Object.entries(r)
.filter(([c]) => LANG_NAME.has(c))
.sort((a, b) => b[1] - a[1] || LANG_NAME.get(a[0]).localeCompare(LANG_NAME.get(b[0])))
.slice(0, RECENT_MAX)
.map(([c]) => c);
}
function fillSelect(el, includeAuto) { function fillSelect(el, includeAuto) {
const keep = el.value;
el.innerHTML = ""; el.innerHTML = "";
if (includeAuto) el.appendChild(new Option("Auto-detect", "auto")); if (includeAuto) el.appendChild(new Option("Auto-detect", "auto"));
for (const [code, label] of LANGS) el.appendChild(new Option(label, code)); const top = topLangs();
if (top.length) {
const g = document.createElement("optgroup");
g.label = "Frequently used";
for (const c of top) g.appendChild(new Option(LANG_NAME.get(c), c));
el.appendChild(g);
const all = document.createElement("optgroup");
all.label = "All languages";
for (const [code, label] of LANGS) all.appendChild(new Option(label, code));
el.appendChild(all);
} else {
for (const [code, label] of LANGS) el.appendChild(new Option(label, code));
}
// A code appears twice once it is in the pinned group; assigning the value
// selects the first match, which is the pinned one. That is what we want.
if (keep) el.value = keep;
}
function refillLangSelects() {
const s = srcSel.value, t = tgtSel.value;
fillSelect(srcSel, true);
fillSelect(tgtSel, false);
srcSel.value = s; tgtSel.value = t;
} }
fillSelect(srcSel, true);
fillSelect(tgtSel, false);
// Default target = user's browser language, falling back to English. // Default target = user's browser language, falling back to English.
const browserLang = (navigator.language || "en").split("-")[0].toLowerCase(); const browserLang = (navigator.language || "en").split("-")[0].toLowerCase();
const defaultTarget = LANGS.some(([c]) => c === browserLang) && browserLang !== "en" ? browserLang : "en"; const defaultTarget = LANGS.some(([c]) => c === browserLang) && browserLang !== "en" ? browserLang : "en";
@ -205,7 +244,12 @@
// current working default so the panel isn't broken out of the box. // current working default so the panel isn't broken out of the box.
const DEAD_LT_URLS = new Set(["https://translate.argosopentech.com"]); const DEAD_LT_URLS = new Set(["https://translate.argosopentech.com"]);
const settingsState = { backend: "libretranslate", ltUrl: DEFAULT_LT_URL, ltKey: "" }; const settingsState = { backend: "libretranslate", ltUrl: DEFAULT_LT_URL, ltKey: "" };
const uiState = { src: "auto", tgt: defaultTarget, zoom: 100 }; const uiState = { src: "auto", tgt: defaultTarget, zoom: 100, recent: {} };
// Populate the selects now that uiState exists — fillSelect reads the
// frequency table to build the pinned group, so it cannot run before this.
fillSelect(srcSel, true);
fillSelect(tgtSel, false);
// ---- text zoom --------------------------------------------------------- // ---- text zoom ---------------------------------------------------------
// Percent of the 15px base, clamped so the panes stay usable at either end. // Percent of the 15px base, clamped so the panes stay usable at either end.
@ -233,6 +277,9 @@
settingsState.ltUrl = DEFAULT_LT_URL; settingsState.ltUrl = DEFAULT_LT_URL;
if (S) try { await S.set("settings", settingsState); } catch {} if (S) try { await S.set("settings", settingsState); } catch {}
} }
// Saved counts arrived, so the pinned group can be built for real.
if (!uiState.recent || typeof uiState.recent !== "object") uiState.recent = {};
refillLangSelects();
backendSel.value = settingsState.backend; backendSel.value = settingsState.backend;
ltUrl.value = settingsState.ltUrl; ltUrl.value = settingsState.ltUrl;
ltKey.value = settingsState.ltKey; ltKey.value = settingsState.ltKey;
@ -294,6 +341,14 @@
out.textContent = res.text || "(empty response)"; out.textContent = res.text || "(empty response)";
const via = res.via || settingsState.backend; const via = res.via || settingsState.backend;
setStatus(res.detected ? `${via} · detected ${res.detected}` : via, "ok"); setStatus(res.detected ? `${via} · detected ${res.detected}` : via, "ok");
// Count only translations that actually landed. Counting on every
// dropdown change would rank languages the user skimmed past as highly
// as the ones they work in. A detected source counts as used too — it
// is a language they read, even though they never picked it.
const before = topLangs().join(",");
noteLangUse(target);
noteLangUse(source === "auto" ? res.detected : source);
if (topLangs().join(",") !== before) refillLangSelects();
} catch (e) { } catch (e) {
out.textContent = ""; out.textContent = "";
out.classList.add("empty"); out.classList.add("empty");