// 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(" (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) { 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)); api.log("registered translate panel + context-menu item"); }, };