From 5946923547e3fdba72c7541f3a02bc47b7c395d3 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 27 Sep 2026 21:02:22 +0200 Subject: [PATCH] =?UTF-8?q?translate=200.1.4=20=E2=86=92=200.1.5:=20most-u?= =?UTF-8?q?sed=20languages=20pinned=20above=20the=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bundled-addons/translate/addon.json | 2 +- bundled-addons/translate/panel.html | 65 ++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/bundled-addons/translate/addon.json b/bundled-addons/translate/addon.json index e25c98a9..4e6e65c8 100644 --- a/bundled-addons/translate/addon.json +++ b/bundled-addons/translate/addon.json @@ -1,7 +1,7 @@ { "id": "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.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4gPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTEuNSIgZmlsbD0iIzFkNGVkOCIvPiA8cmVjdCB4PSIwIiB5PSIwLjYiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiNmZmYiLz4gPHRleHQgeD0iNi43IiB5PSIxMi4xIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0ic3lzdGVtLXVpLFNlZ29lIFVJLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTIuNCIgZm9udC13ZWlnaHQ9IjgwMCIgZmlsbD0iIzFkNGVkOCI+QTwvdGV4dD4gPHJlY3QgeD0iMTAuNiIgeT0iMTAiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiMwYjEyMjAiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjEiLz4gPGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj4gPHBhdGggZD0iTTE3LjMgMTIuMnYxLjEiLz4gPHBhdGggZD0iTTEzLjUgMTQuNmg3LjYiLz4gPHBhdGggZD0iTTE4LjIgMTUuNyAxMy45IDIxIi8+IDxwYXRoIGQ9Ik0xNi41IDE1LjcgMjAuOCAyMSIvPiA8L2c+IDwvc3ZnPg==", diff --git a/bundled-addons/translate/panel.html b/bundled-addons/translate/panel.html index 0d909fa6..503fd742 100644 --- a/bundled-addons/translate/panel.html +++ b/bundled-addons/translate/panel.html @@ -186,14 +186,53 @@ ["sv","Swedish"],["th","Thai"],["tr","Turkish"],["uk","Ukrainian"], ["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) { + const keep = el.value; el.innerHTML = ""; 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. const browserLang = (navigator.language || "en").split("-")[0].toLowerCase(); 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. const DEAD_LT_URLS = new Set(["https://translate.argosopentech.com"]); 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 --------------------------------------------------------- // Percent of the 15px base, clamped so the panes stay usable at either end. @@ -233,6 +277,9 @@ settingsState.ltUrl = DEFAULT_LT_URL; 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; ltUrl.value = settingsState.ltUrl; ltKey.value = settingsState.ltKey; @@ -294,6 +341,14 @@ out.textContent = res.text || "(empty response)"; const via = res.via || settingsState.backend; 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) { out.textContent = ""; out.classList.add("empty");