translate 0.1.5 → 0.1.6: name the detected language in the source select

Auto-detect already worked — the backend returns
detectedLanguage:{language,confidence} and the panel put it in the
status line. But that only appeared after a translation had already
run, in small text, away from the control that raised the question. You
could not tell what "Auto-detect" had decided before committing to it.

The first row of the source select now says "Auto-detect · German", and
it says so while you are still typing. Detection runs on its own via
LibreTranslate's /detect, debounced 700 ms and gated at 12 characters,
because a detector given two words is guessing and firing per keystroke
would pound a public mirror for nothing. It chains the same mirror
fallback as translation, so a dead primary does not make detection look
broken while translating still works.

Confidence below 60 renders as "German?" rather than silently asserting
a coin-flip. The Google backend has no detect-only route, so there
doDetect returns null instead of burning a request, and the label is
filled from the translation response — which every backend returns
anyway, so a skipped or failed detect is never worse than before.

Detection retires when a source is named explicitly, comes back on
returning to Auto-detect, and is wiped by clear. A right-click
selection schedules one too, since that text arrives with no keystroke.

Verified against the real mirror (de/fr/ja at 100/100/90%) and in the
harness: short text fires nothing, long text fires once, four rapid
edits debounce to one call, and every transition above lands.

Also adds the xray removal script used to take the old engine off all
three exits now that they run sing-box.
This commit is contained in:
Local Dev 2026-09-28 21:50:00 +02:00
parent 18fce62a11
commit c9c5d18ca2
3 changed files with 115 additions and 3 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "translate", "id": "translate",
"name": "Translate", "name": "Translate",
"version": "0.1.5", "version": "0.1.6",
"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

@ -50,6 +50,29 @@ async function translateLibre({ text, source, target }, urlBase, apiKey) {
return { return {
text: String(j.translatedText || j.translated_text || ""), text: String(j.translatedText || j.translated_text || ""),
detected: j.detectedLanguage?.language || null, detected: j.detectedLanguage?.language || null,
confidence: typeof j.detectedLanguage?.confidence === "number" ? j.detectedLanguage.confidence : null,
via: `libretranslate (${new URL(urlBase).host})`,
};
}
// Standalone detection, so the panel can name the language before anyone
// presses Translate. LibreTranslate exposes /detect; the Google endpoint has
// no detect-only route, so there the language comes back with the
// translation instead and this returns null rather than burning a request.
async function detectLibre(text, urlBase, apiKey) {
const url = urlBase.replace(/\/+$/, "") + "/detect";
const body = { q: text };
if (apiKey) body.api_key = apiKey;
const j = await fetchJson(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const best = Array.isArray(j) ? j[0] : null;
if (!best || !best.language) return null;
return {
language: String(best.language),
confidence: typeof best.confidence === "number" ? best.confidence : null,
via: `libretranslate (${new URL(urlBase).host})`, via: `libretranslate (${new URL(urlBase).host})`,
}; };
} }
@ -67,7 +90,25 @@ async function translateGoogle({ text, source, target }) {
const chunks = Array.isArray(j?.[0]) ? j[0] : []; const chunks = Array.isArray(j?.[0]) ? j[0] : [];
const translated = chunks.map((c) => (Array.isArray(c) ? String(c[0] || "") : "")).join(""); const translated = chunks.map((c) => (Array.isArray(c) ? String(c[0] || "") : "")).join("");
const detected = typeof j?.[2] === "string" ? j[2] : null; const detected = typeof j?.[2] === "string" ? j[2] : null;
return { text: translated, detected, via: "google" }; return { text: translated, detected, confidence: null, via: "google" };
}
// Mirror-chaining detection, same fall-through as doTranslate: the user's
// chosen mirror first, then the known-good list, so a dead mirror does not
// make detection look broken when translation still works.
async function doDetect(payload) {
const p = payload || {};
const text = String(p.text || "").trim();
if (!text) return null;
if (p.backend === "google") return null; // no detect-only route; comes with the translation
const primary = String(p.ltUrl || LT_FALLBACKS[0]).replace(/\/+$/, "");
const tried = new Set();
for (const url of [primary, ...LT_FALLBACKS.filter((u) => u !== primary)]) {
if (tried.has(url)) continue;
tried.add(url);
try { return await detectLibre(text, url, p.ltKey || ""); } catch {}
}
return null;
} }
async function doTranslate(payload) { async function doTranslate(payload) {
@ -121,6 +162,7 @@ module.exports = {
}); });
api.onMessage("translate", async (payload) => doTranslate(payload)); api.onMessage("translate", async (payload) => doTranslate(payload));
api.onMessage("detect", async (payload) => doDetect(payload));
api.log("registered translate panel + context-menu item"); api.log("registered translate panel + context-menu item");
}, },

View file

@ -206,10 +206,26 @@
.slice(0, RECENT_MAX) .slice(0, RECENT_MAX)
.map(([c]) => c); .map(([c]) => c);
} }
// What the source select's first row says. Once a language has been
// detected it names it inline — "Auto-detect · German" — so the answer is
// visible where the question was asked, instead of only in the status line
// after a translation has already happened.
let detectedCode = null, detectedConfidence = null;
function autoLabel() {
if (!detectedCode) return "Auto-detect";
const name = LANG_NAME.get(detectedCode) || detectedCode;
const low = detectedConfidence !== null && detectedConfidence < 60;
return `Auto-detect · ${name}${low ? "?" : ""}`;
}
function paintAutoLabel() {
const opt = srcSel.querySelector('option[value="auto"]');
if (opt) opt.textContent = autoLabel();
}
function fillSelect(el, includeAuto) { function fillSelect(el, includeAuto) {
const keep = el.value; const keep = el.value;
el.innerHTML = ""; el.innerHTML = "";
if (includeAuto) el.appendChild(new Option("Auto-detect", "auto")); if (includeAuto) el.appendChild(new Option(autoLabel(), "auto"));
const top = topLangs(); const top = topLangs();
if (top.length) { if (top.length) {
const g = document.createElement("optgroup"); const g = document.createElement("optgroup");
@ -306,6 +322,43 @@
ltKeyLabel.style.display = isLt ? "" : "none"; ltKeyLabel.style.display = isLt ? "" : "none";
} }
// Detection runs on the text as it settles, so the source language is named
// before Translate is pressed. Debounced and length-gated: a couple of words
// is not enough for a detector to be right, and firing per keystroke would
// hammer a public mirror for no benefit. Nothing here blocks translating —
// a failed or slow detect just leaves the label alone.
const DETECT_DEBOUNCE_MS = 700, DETECT_MIN_CHARS = 12;
let detectTimer = null, detectSeq = 0;
function setDetected(code, confidence) {
if (code === detectedCode && confidence === detectedConfidence) return;
detectedCode = code || null;
detectedConfidence = typeof confidence === "number" ? confidence : null;
paintAutoLabel();
}
function clearDetected() { setDetected(null, null); }
async function runDetect() {
if (srcSel.value !== "auto") return;
const text = inp.value.trim();
if (text.length < DETECT_MIN_CHARS) { clearDetected(); return; }
const seq = ++detectSeq;
try {
const r = await window.silentmode.invoke("detect", {
text,
backend: settingsState.backend,
ltUrl: settingsState.ltUrl,
ltKey: settingsState.ltKey,
});
if (seq !== detectSeq) return; // a newer edit already superseded this
if (r && r.language) setDetected(r.language, r.confidence);
} catch { /* detection is a nicety; never surface it as an error */ }
}
function scheduleDetect() {
clearTimeout(detectTimer);
if (srcSel.value !== "auto") { clearDetected(); return; }
if (inp.value.trim().length < DETECT_MIN_CHARS) { clearDetected(); return; }
detectTimer = setTimeout(runDetect, DETECT_DEBOUNCE_MS);
}
function setStatus(text, cls = "") { function setStatus(text, cls = "") {
status.textContent = text; status.textContent = text;
status.className = "m" + (cls ? " " + cls : ""); status.className = "m" + (cls ? " " + cls : "");
@ -345,6 +398,10 @@
// dropdown change would rank languages the user skimmed past as highly // 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 // 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. // is a language they read, even though they never picked it.
// The translation response carries the detection for free — take it,
// so the label is right even when the standalone detect was skipped
// (short text, Google backend, or a mirror that refused /detect).
if (source === "auto" && res.detected) setDetected(res.detected, res.confidence);
const before = topLangs().join(","); const before = topLangs().join(",");
noteLangUse(target); noteLangUse(target);
noteLangUse(source === "auto" ? res.detected : source); noteLangUse(source === "auto" ? res.detected : source);
@ -377,8 +434,14 @@
inp.value = ""; out.textContent = "The translation will appear here."; inp.value = ""; out.textContent = "The translation will appear here.";
out.classList.add("empty"); out.classList.add("empty");
setStatus("ready"); setStatus("ready");
clearTimeout(detectTimer);
clearDetected();
inp.focus(); inp.focus();
}); });
// Detect as the text settles, and on paste (which fires input too, but the
// value is only current after the event loop turn).
inp.addEventListener("input", scheduleDetect);
inp.addEventListener("paste", () => setTimeout(scheduleDetect, 0));
$("copyOut").addEventListener("click", async () => { $("copyOut").addEventListener("click", async () => {
if (out.classList.contains("empty") || !out.textContent) return; if (out.classList.contains("empty") || !out.textContent) return;
try { try {
@ -397,6 +460,9 @@
// there, so the pane below kept showing the previous language's text with // there, so the pane below kept showing the previous language's text with
// no sign it was stale. // no sign it was stale.
async function onLangChange() { async function onLangChange() {
// Naming a source explicitly retires the detection; going back to
// Auto-detect re-runs it against whatever is in the box.
if (srcSel.value !== "auto") clearDetected(); else scheduleDetect();
await saveUi(); await saveUi();
if (!inp.value.trim()) return; // nothing to re-translate if (!inp.value.trim()) return; // nothing to re-translate
const src = srcSel.value, tgt = tgtSel.value; const src = srcSel.value, tgt = tgtSel.value;
@ -438,6 +504,10 @@
inp.value = pending.text; inp.value = pending.text;
try { await S.set("__pending", null); } catch {} try { await S.set("__pending", null); } catch {}
setStatus(pending.host ? `from ${pending.host}` : "from page selection"); setStatus(pending.host ? `from ${pending.host}` : "from page selection");
// Text arrived without a keystroke, so nothing scheduled a detect. The
// translation below reports the language anyway, but kicking one off now
// names it while that request is still in flight.
scheduleDetect();
doTranslate(); doTranslate();
} }