theseus/bundled-addons/translate/index.js

170 lines
6.9 KiB
JavaScript
Raw Normal View History

feat(theseus/translate): new sidebar add-on — right-click Translate selection Adds a bundled add-on `translate` with a sidebar panel + a right-click "Translate selection" menu item. Two swappable backends: - LibreTranslate (default) — free MIT engine; the panel's Settings tab lets the user point at any instance (public or self-hosted) and drop in an API key if one's required. - Google (unofficial free endpoint at translate.googleapis.com/ translate_a/single) — no key, wide coverage, but unofficial and Google can break it any time. Opt-in fallback. Flow: user selects text on a page, right-clicks -> "Translate selection". Add-on's context-menu handler stashes the selection under storage.__pending and calls api.revealSidebar("main"); the panel loads, drains __pending on first paint, and translates. Ctrl/Cmd+Enter in the input textarea also translates. Source + target language choices, browser-language default target, swap button, copy-to- clipboard on the output, settings gear. Depends on a new "context-menu-item" capability + api.revealSidebar hook in addons-host.js / main.js. Those wiring changes are prepared but not committed here — a parallel session is refactoring the same functions concurrently, so the safe path is to land translate/ first and let the wiring go in alongside the next host-facing commit. Until the wiring lands, the manifest's "context-menu-item" cap is silently dropped (per validateManifest's unknown-caps policy) and the sidebar panel + the panel's translation UI still work standalone — the right-click entry point is what's gated.
2026-09-20 17:50:06 +02:00
// 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,
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.
2026-09-28 21:50:00 +02:00
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;
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.
2026-09-28 21:50:00 +02:00
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(" | ")}`);
}
feat(theseus/translate): new sidebar add-on — right-click Translate selection Adds a bundled add-on `translate` with a sidebar panel + a right-click "Translate selection" menu item. Two swappable backends: - LibreTranslate (default) — free MIT engine; the panel's Settings tab lets the user point at any instance (public or self-hosted) and drop in an API key if one's required. - Google (unofficial free endpoint at translate.googleapis.com/ translate_a/single) — no key, wide coverage, but unofficial and Google can break it any time. Opt-in fallback. Flow: user selects text on a page, right-clicks -> "Translate selection". Add-on's context-menu handler stashes the selection under storage.__pending and calls api.revealSidebar("main"); the panel loads, drains __pending on first paint, and translates. Ctrl/Cmd+Enter in the input textarea also translates. Source + target language choices, browser-language default target, swap button, copy-to- clipboard on the output, settings gear. Depends on a new "context-menu-item" capability + api.revealSidebar hook in addons-host.js / main.js. Those wiring changes are prepared but not committed here — a parallel session is refactoring the same functions concurrently, so the safe path is to land translate/ first and let the wiring go in alongside the next host-facing commit. Until the wiring lands, the manifest's "context-menu-item" cap is silently dropped (per validateManifest's unknown-caps policy) and the sidebar panel + the panel's translation UI still work standalone — the right-click entry point is what's gated.
2026-09-20 17:50:06 +02:00
module.exports = {
activate(api) {
translate 0.1.2 → 0.1.3: real icon, bigger text, zoom, auto-translate on language change 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.
2026-09-27 18:00:46 +02:00
// No `icon` here on purpose — registerSidebarPanel defaults to
// manifest.icon, and passing one would shadow the data-URI mark.
feat(theseus/translate): new sidebar add-on — right-click Translate selection Adds a bundled add-on `translate` with a sidebar panel + a right-click "Translate selection" menu item. Two swappable backends: - LibreTranslate (default) — free MIT engine; the panel's Settings tab lets the user point at any instance (public or self-hosted) and drop in an API key if one's required. - Google (unofficial free endpoint at translate.googleapis.com/ translate_a/single) — no key, wide coverage, but unofficial and Google can break it any time. Opt-in fallback. Flow: user selects text on a page, right-clicks -> "Translate selection". Add-on's context-menu handler stashes the selection under storage.__pending and calls api.revealSidebar("main"); the panel loads, drains __pending on first paint, and translates. Ctrl/Cmd+Enter in the input textarea also translates. Source + target language choices, browser-language default target, swap button, copy-to- clipboard on the output, settings gear. Depends on a new "context-menu-item" capability + api.revealSidebar hook in addons-host.js / main.js. Those wiring changes are prepared but not committed here — a parallel session is refactoring the same functions concurrently, so the safe path is to land translate/ first and let the wiring go in alongside the next host-facing commit. Until the wiring lands, the manifest's "context-menu-item" cap is silently dropped (per validateManifest's unknown-caps policy) and the sidebar panel + the panel's translation UI still work standalone — the right-click entry point is what's gated.
2026-09-20 17:50:06 +02:00
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));
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.
2026-09-28 21:50:00 +02:00
api.onMessage("detect", async (payload) => doDetect(payload));
feat(theseus/translate): new sidebar add-on — right-click Translate selection Adds a bundled add-on `translate` with a sidebar panel + a right-click "Translate selection" menu item. Two swappable backends: - LibreTranslate (default) — free MIT engine; the panel's Settings tab lets the user point at any instance (public or self-hosted) and drop in an API key if one's required. - Google (unofficial free endpoint at translate.googleapis.com/ translate_a/single) — no key, wide coverage, but unofficial and Google can break it any time. Opt-in fallback. Flow: user selects text on a page, right-clicks -> "Translate selection". Add-on's context-menu handler stashes the selection under storage.__pending and calls api.revealSidebar("main"); the panel loads, drains __pending on first paint, and translates. Ctrl/Cmd+Enter in the input textarea also translates. Source + target language choices, browser-language default target, swap button, copy-to- clipboard on the output, settings gear. Depends on a new "context-menu-item" capability + api.revealSidebar hook in addons-host.js / main.js. Those wiring changes are prepared but not committed here — a parallel session is refactoring the same functions concurrently, so the safe path is to land translate/ first and let the wiring go in alongside the next host-facing commit. Until the wiring lands, the manifest's "context-menu-item" cap is silently dropped (per validateManifest's unknown-caps policy) and the sidebar panel + the panel's translation UI still work standalone — the right-click entry point is what's gated.
2026-09-20 17:50:06 +02:00
api.log("registered translate panel + context-menu item");
},
};