Auto-translate: changing either language only called saveUi(), so the
pane below kept showing the previous language's result with nothing to
say it was stale — you had to notice and press Translate. Both selects
now re-run the translation, guarded on empty input and on
source === target (which would only echo the input back).
Text size: the panes were 13.5px, small for reading a paragraph in a
language you don't know well, which is the entire job. Base is now
15px, driven by a --tsize custom property so both panes stay matched.
Zoom: −/+ either side of a percentage in the header, 70–220% in steps
of 10, clamped with the buttons disabling at each end. Click the
percentage to reset. Ctrl/Cmd with +, - or 0 does the same from either
pane. Persists per-machine alongside the language choice.
Icon: a globe with an A tile and a 文 tile, replacing the 🌐 emoji that
was indistinguishable from every other globe in the dock. Drawn against
the 16px and 18px rasterizations rather than at a comfortable size —
the first pass used a font glyph for 文 and it turned to grey mush at
16px, so the strokes are hand-drawn paths thick enough to survive. Also
drops the hardcoded icon in registerSidebarPanel, which would otherwise
shadow the manifest's mark.
Verified in a harness with a stubbed host API: language change fires
exactly one request, the two guards fire none, zoom clamps and persists,
and the layout holds at sidebar width.
127 lines
5.1 KiB
JavaScript
127 lines
5.1 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.
|
|
const LT_FALLBACKS = [
|
|
"https://translate.disroot.org",
|
|
"https://translate.plausibility.cloud",
|
|
"https://lingva.ml",
|
|
];
|
|
|
|
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,
|
|
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, via: "google" };
|
|
}
|
|
|
|
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.log("registered translate panel + context-menu item");
|
|
},
|
|
};
|