Typing never triggered a translation. The input handler was wired only to the language detector, so nothing happened until Enter, the Translate button or a language change -- the panel looked broken to anyone who simply typed and waited. Translation now runs 500 ms after the text settles. Each request carries a sequence number. Typing outruns the network, and without that guard a slower earlier response paints its stale text over a newer one. Detection already worked, but wrote its answer only into the text of the collapsed "Auto-detect" option, which a closed select hides -- so it read as "detection is broken" when it had in fact succeeded. Name the language in a row under the selects, and lower the 12-character floor to 5, since short phrases never cleared it. With translate-as-you-type on, skip the standalone detect call: the translation response already carries the detected language, so asking twice only doubled the load on a public mirror. Two of the three fallback mirrors were dead, checked by posting to both endpoints: translate.plausibility.cloud answers 405, and lingva.ml serves an HTML page because it is not a LibreTranslate API at all, so it could only ever produce the "HTML instead of JSON" failure. Drop both and add silentmode.st/libre, which stays a fallback rather than the default because it covers six languages against disroot's fifty. Auto-translate sends text to a translation server as it is typed, so it is a visible toggle rather than silent behaviour. Default on.
178 lines
7.6 KiB
JavaScript
178 lines
7.6 KiB
JavaScript
// Translate — right-click a selection, get a translation in the sidebar.
|
|
//
|
|
// One sidebar panel; the actual HTTP call runs here on the Node side, not in
|
|
// the panel's browser context, so a captive portal, anti-bot page or CORS
|
|
// preflight cannot substitute HTML for the JSON the panel expects. The panel
|
|
// invokes "translate" with { text, source, target, backend, ltUrl, ltKey }
|
|
// and this handler picks the backend, tries the requested URL, then falls
|
|
// through a small list of known mirrors if that URL returns HTML or a network
|
|
// error (a single mirror going down shouldn't take the whole feature with it).
|
|
|
|
// Ordered list of LibreTranslate mirrors used as automatic fallbacks when the
|
|
// user's chosen URL fails. Kept short on purpose — three tries is plenty to
|
|
// route around one mirror being down, and we don't want to spam a chain of
|
|
// public instances for one click.
|
|
// Checked 2026-10-22 by POSTing /translate and /detect to each:
|
|
// translate.disroot.org 200, detects + translates, 50 languages
|
|
// silentmode.st/libre 200, our own instance, 6 languages (en/fr/de/el/ru/es)
|
|
// Removed at the same time, both verified broken rather than merely slow:
|
|
// translate.plausibility.cloud 405 on both endpoints
|
|
// lingva.ml 200 but serves an HTML page — it is not a
|
|
// LibreTranslate API at all, so it could only
|
|
// ever produce the "HTML instead of JSON" error
|
|
// Our own instance is second, not first: it answers reliably but only covers
|
|
// six languages, so it is the right safety net and the wrong default.
|
|
const LT_FALLBACKS = [
|
|
"https://translate.disroot.org",
|
|
"https://silentmode.st/libre",
|
|
];
|
|
|
|
function looksLikeHtml(s) {
|
|
const head = String(s || "").trimStart().slice(0, 32).toLowerCase();
|
|
return head.startsWith("<!doctype") || head.startsWith("<html") || head.startsWith("<?xml");
|
|
}
|
|
|
|
async function fetchJson(url, init) {
|
|
const r = await fetch(url, { ...init, redirect: "follow" });
|
|
const text = await r.text();
|
|
if (looksLikeHtml(text)) {
|
|
throw new Error(`server returned an HTML page instead of JSON (probably a captive-portal or anti-bot interstitial in front of ${new URL(url).host})`);
|
|
}
|
|
if (!r.ok) {
|
|
let msg = `HTTP ${r.status}`;
|
|
try { const j = JSON.parse(text); if (j?.error) msg = j.error; } catch {}
|
|
throw new Error(msg);
|
|
}
|
|
try { return JSON.parse(text); }
|
|
catch { throw new Error(`server returned invalid JSON (${text.slice(0, 80)}…)`); }
|
|
}
|
|
|
|
async function translateLibre({ text, source, target }, urlBase, apiKey) {
|
|
const url = urlBase.replace(/\/+$/, "") + "/translate";
|
|
const body = { q: text, source: source === "auto" ? "auto" : source, target, format: "text" };
|
|
if (apiKey) body.api_key = apiKey;
|
|
const j = await fetchJson(url, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
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})`,
|
|
};
|
|
}
|
|
|
|
async function translateGoogle({ text, source, target }) {
|
|
// Unofficial free endpoint used by browser translation extensions. Runs from
|
|
// Node so no browser anti-bot page can slot itself in front of the response.
|
|
const params = new URLSearchParams({
|
|
client: "gtx",
|
|
sl: source === "auto" ? "auto" : source,
|
|
tl: target, dt: "t", q: text,
|
|
});
|
|
const url = `https://translate.googleapis.com/translate_a/single?${params}`;
|
|
const j = await fetchJson(url, {});
|
|
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, 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) {
|
|
const p = payload || {};
|
|
const text = String(p.text || "").trim();
|
|
if (!text) throw new Error("no text");
|
|
const source = String(p.source || "auto");
|
|
const target = String(p.target || "en");
|
|
if (source !== "auto" && source === target) {
|
|
return { text, detected: null, via: "identity" };
|
|
}
|
|
if (p.backend === "google") {
|
|
return translateGoogle({ text, source, target });
|
|
}
|
|
const primary = String(p.ltUrl || LT_FALLBACKS[0]).replace(/\/+$/, "");
|
|
const tried = new Set();
|
|
const order = [primary, ...LT_FALLBACKS.filter((u) => u !== primary)];
|
|
const errs = [];
|
|
for (const url of order) {
|
|
if (tried.has(url)) continue;
|
|
tried.add(url);
|
|
try {
|
|
return await translateLibre({ text, source, target }, url, p.ltKey || "");
|
|
} catch (e) {
|
|
errs.push(`${new URL(url).host}: ${e?.message || e}`);
|
|
}
|
|
}
|
|
throw new Error(`all mirrors failed — ${errs.join(" | ")}`);
|
|
}
|
|
|
|
module.exports = {
|
|
activate(api) {
|
|
// No `icon` here on purpose — registerSidebarPanel defaults to
|
|
// manifest.icon, and passing one would shadow the data-URI mark.
|
|
api.registerSidebarPanel({
|
|
id: "main",
|
|
title: "Translate",
|
|
page: "panel.html",
|
|
});
|
|
|
|
api.onMessage("context-menu", async (payload) => {
|
|
const text = String(payload && payload.selectionText || "").trim();
|
|
if (!text) { api.log("context-menu fired with no selection"); return; }
|
|
// Cap what we stash to keep storage tiny; the address bar and menu already
|
|
// truncate visually, but the raw selection can be huge.
|
|
const clip = text.length > 12_000 ? text.slice(0, 12_000) : text;
|
|
api.storage.set("__pending", { text: clip, host: payload.host || "", at: Date.now() });
|
|
api.revealSidebar("main");
|
|
api.log(`context-menu → translate ${clip.length} chars from ${payload.host || "?"}`);
|
|
return { ok: true };
|
|
});
|
|
|
|
api.onMessage("translate", async (payload) => doTranslate(payload));
|
|
api.onMessage("detect", async (payload) => doDetect(payload));
|
|
|
|
api.log("registered translate panel + context-menu item");
|
|
},
|
|
};
|