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.
|
|
|
|
|
//
|
2026-09-21 03:34:14 +02:00
|
|
|
// 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,
|
2026-09-21 03:34:14 +02:00
|
|
|
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;
|
2026-09-21 03:34:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-21 03:34:14 +02:00
|
|
|
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));
|
2026-09-21 03:34:14 +02:00
|
|
|
|
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");
|
|
|
|
|
},
|
|
|
|
|
};
|