theseus/bundled-addons/translate/index.js

127 lines
5 KiB
JavaScript
Raw Permalink 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,
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(" | ")}`);
}
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) {
api.registerSidebarPanel({
id: "main",
title: "Translate",
icon: "🌐",
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));
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");
},
};