diff --git a/bundled-addons/translate/addon.json b/bundled-addons/translate/addon.json index 4e6e65c8..d0a2ebde 100644 --- a/bundled-addons/translate/addon.json +++ b/bundled-addons/translate/addon.json @@ -1,7 +1,7 @@ { "id": "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.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4gPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTEuNSIgZmlsbD0iIzFkNGVkOCIvPiA8cmVjdCB4PSIwIiB5PSIwLjYiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiNmZmYiLz4gPHRleHQgeD0iNi43IiB5PSIxMi4xIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0ic3lzdGVtLXVpLFNlZ29lIFVJLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTIuNCIgZm9udC13ZWlnaHQ9IjgwMCIgZmlsbD0iIzFkNGVkOCI+QTwvdGV4dD4gPHJlY3QgeD0iMTAuNiIgeT0iMTAiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiMwYjEyMjAiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjEiLz4gPGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj4gPHBhdGggZD0iTTE3LjMgMTIuMnYxLjEiLz4gPHBhdGggZD0iTTEzLjUgMTQuNmg3LjYiLz4gPHBhdGggZD0iTTE4LjIgMTUuNyAxMy45IDIxIi8+IDxwYXRoIGQ9Ik0xNi41IDE1LjcgMjAuOCAyMSIvPiA8L2c+IDwvc3ZnPg==", diff --git a/bundled-addons/translate/index.js b/bundled-addons/translate/index.js index 8bb98dce..83191708 100644 --- a/bundled-addons/translate/index.js +++ b/bundled-addons/translate/index.js @@ -50,6 +50,29 @@ async function translateLibre({ text, source, target }, urlBase, apiKey) { return { text: String(j.translatedText || j.translated_text || ""), 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})`, }; } @@ -67,7 +90,25 @@ async function translateGoogle({ text, source, target }) { const chunks = Array.isArray(j?.[0]) ? j[0] : []; const translated = chunks.map((c) => (Array.isArray(c) ? String(c[0] || "") : "")).join(""); 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) { @@ -121,6 +162,7 @@ module.exports = { }); api.onMessage("translate", async (payload) => doTranslate(payload)); + api.onMessage("detect", async (payload) => doDetect(payload)); api.log("registered translate panel + context-menu item"); }, diff --git a/bundled-addons/translate/panel.html b/bundled-addons/translate/panel.html index 503fd742..d7e3451c 100644 --- a/bundled-addons/translate/panel.html +++ b/bundled-addons/translate/panel.html @@ -206,10 +206,26 @@ .slice(0, RECENT_MAX) .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) { const keep = el.value; el.innerHTML = ""; - if (includeAuto) el.appendChild(new Option("Auto-detect", "auto")); + if (includeAuto) el.appendChild(new Option(autoLabel(), "auto")); const top = topLangs(); if (top.length) { const g = document.createElement("optgroup"); @@ -306,6 +322,43 @@ 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 = "") { status.textContent = text; status.className = "m" + (cls ? " " + cls : ""); @@ -345,6 +398,10 @@ // 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. + // 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(","); noteLangUse(target); noteLangUse(source === "auto" ? res.detected : source); @@ -377,8 +434,14 @@ inp.value = ""; out.textContent = "The translation will appear here."; out.classList.add("empty"); setStatus("ready"); + clearTimeout(detectTimer); + clearDetected(); 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 () => { if (out.classList.contains("empty") || !out.textContent) return; try { @@ -397,6 +460,9 @@ // there, so the pane below kept showing the previous language's text with // no sign it was stale. 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(); if (!inp.value.trim()) return; // nothing to re-translate const src = srcSel.value, tgt = tgtSel.value; @@ -438,6 +504,10 @@ inp.value = pending.text; try { await S.set("__pending", null); } catch {} 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(); }