diff --git a/main.js b/main.js index 838411b9..0aa1c80b 100644 --- a/main.js +++ b/main.js @@ -524,7 +524,20 @@ const SETTINGS_DEFAULTS = { // (same contract: a function that takes an array of strings and a target // tag, returns an array of translations). translateBackend: "libretranslate", // libretranslate | (future) bergamot - translateEndpoint: "https://libretranslate.com/translate", + // Ordered peer list — tried in sequence, the first one that answers + // wins. The free public LibreTranslate tiers go dark every few months + // (the libretranslate.com free tier moved behind an API key in late + // 2026, several mirrors 502 at any given time), so a list beats one + // endpoint: a dead mirror doesn't kill the feature, it just loses the + // round. Silent Mode's own instances (silentmode.st + the BNS name) + // come first so they take the load whenever they're up; the public + // mirrors are a safety net for when they're not. Users can edit the + // list in Settings › General › Translate pages. + translateEndpoints: [ + "https://translate.silentmode.st/translate", + "https://translate.x/translate", + "https://libretranslate.com/translate", + ], translateApiKey: "", // Light up the URL-bar translate chip when the loaded page's language // differs from the user's preferred one. Clicking the chip translates @@ -607,6 +620,19 @@ function loadSettings() { settings.quickLinks = SETTINGS_DEFAULTS.quickLinks.map((L) => ({ ...L })); } } catch (e) { console.warn("quickLinks migrate:", e?.message); } + // Translator moved from a single `translateEndpoint` to a peer list in + // the same release that shipped the chip. A custom endpoint comes along + // as the primary peer; the historical LibreTranslate.com default is + // dropped so the user picks up the fresh default list (silentmode.st + + // the BNS name + the public mirror). + try { + const legacy = typeof settings.translateEndpoint === "string" ? settings.translateEndpoint.trim() : ""; + if (legacy && legacy !== "https://libretranslate.com/translate") { + const have = Array.isArray(settings.translateEndpoints) ? settings.translateEndpoints : SETTINGS_DEFAULTS.translateEndpoints.slice(); + if (!have.includes(legacy)) settings.translateEndpoints = [legacy, ...have]; + } + if ("translateEndpoint" in settings) delete settings.translateEndpoint; + } catch (e) { console.warn("translateEndpoints migrate:", e?.message); } } function saveSettings() { try { fs.writeFileSync(settingsFile(), JSON.stringify(settings, null, 2)); } catch (e) { console.error("settings save failed:", e.message); } @@ -4543,8 +4569,7 @@ function translationTargetBase() { // LibreTranslate POST /translate. `q` may be a single string or an array; the // response's `translatedText` is a string or array to match. One POST per // call — the caller chunks when the batch would exceed the request budget. -async function translatorLibreTranslate(texts, from, to) { - const url = settings.translateEndpoint || SETTINGS_DEFAULTS.translateEndpoint; +async function translatorPostOnce(url, texts, from, to) { const body = { q: texts, source: from || "auto", @@ -4564,14 +4589,37 @@ async function translatorLibreTranslate(texts, from, to) { }); if (!r.ok) { let detail = ""; try { detail = (await r.text()).slice(0, 300); } catch {} - throw new Error(`translator HTTP ${r.status}${detail ? ": " + detail : ""}`); + throw new Error(`HTTP ${r.status}${detail ? ": " + detail : ""}`); } const data = await r.json(); if (Array.isArray(data.translatedText)) return data.translatedText; if (typeof data.translatedText === "string") return [data.translatedText]; // Some LibreTranslate deployments return a bare array of {translatedText}. if (Array.isArray(data)) return data.map((d) => d?.translatedText ?? ""); - throw new Error("translator: unexpected response shape"); + throw new Error("unexpected response shape"); +} +// The configured peer list, sanitised: a legacy single `translateEndpoint` +// migrates to list[0], an empty list falls back to the shipped defaults. +function translatorPeers() { + const list = Array.isArray(settings.translateEndpoints) ? settings.translateEndpoints : []; + const peers = list.map((s) => String(s || "").trim()).filter(Boolean); + if (peers.length) return peers; + const legacy = String(settings.translateEndpoint || "").trim(); + if (legacy) return [legacy]; + return SETTINGS_DEFAULTS.translateEndpoints.slice(); +} +// Try each peer in order; return the first good answer, or throw the last +// error. A peer that answers with a non-2xx (needs-API-key, 502, rate limit) +// is skipped. The ordering is preserved — a user who put their own instance +// first keeps it as the primary; the list is a fallback chain, not a pool. +async function translatorLibreTranslate(texts, from, to) { + const peers = translatorPeers(); + const errors = []; + for (const url of peers) { + try { return await translatorPostOnce(url, texts, from, to); } + catch (e) { errors.push(`${url}: ${e?.message || e}`); console.warn(`[translate] peer failed ${url}:`, e?.message || e); } + } + throw new Error(`all ${peers.length} translator peer(s) failed — ${errors[errors.length - 1] || "no detail"}`); } // Chunk a texts array so each POST stays under a reasonable size — LibreTranslate // instances vary (3-5 KB is a safe shared floor), and a monolithic 50-page POST diff --git a/settings.html b/settings.html index 6764bfaf..286081b9 100644 --- a/settings.html +++ b/settings.html @@ -284,12 +284,17 @@
Offer to translate
Light up a translate chip in the address bar when the page's declared language is different from the one you picked above.
-
-
Translation engine
LibreTranslate-compatible endpoint. The public free tier is rate-limited; point this at a self-hosted LibreTranslate for unlimited use and privacy. On-device Bergamot (WASM) is coming in a later release.
- +
+
Translation peers
+
Ordered list of LibreTranslate-compatible endpoints. The first one that answers wins; a dead or rate-limited peer is skipped and the next is tried. Keep Silent Mode's own instances at the top; add your own self-hosted LibreTranslate for privacy or unlimited use. On-device Bergamot (WASM) is coming in a later release.
+
+
+ + +
-
API key (optional)
Some LibreTranslate instances need an API key for the paid tier or to raise the rate limit. Leave blank for the public endpoint.
+
API key (optional)
Shared across all peers in the list. Some LibreTranslate instances need a key for the paid tier or to raise the rate limit; leave blank for peers that don't.

Startup

@@ -1042,7 +1047,7 @@ // Text inputs that round-trip through settings-set on change. Trimmed; a // cleared field writes an empty string, which main re-defaults from // SETTINGS_DEFAULTS on next launch. - const TEXT_FIELDS = ["translateEndpoint", "translateApiKey"]; + const TEXT_FIELDS = ["translateApiKey"]; C.get().then((s) => { for (const k of TOGGLES) { const el = document.getElementById(k); if (!el) continue; @@ -1101,6 +1106,53 @@ // Live-sync from main (edits from other surfaces, or the strip's own add). if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.quickLinks || [])); })(); + // ---- Translation-peer list editor (General) --------------------------- + // Same shape as the Quick-links editor above: add a row, trash a row, + // each change writes the whole array. The chip + translator service read + // the list in order and skip dead peers; the top entry is the primary. + (function () { + const list = document.getElementById("translateEndpointList"); + const urlIn = document.getElementById("translatePeerUrl"); + const addBtn = document.getElementById("translatePeerAdd"); + if (!list || !addBtn) return; + const esc = (v) => String(v || "").replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">","\"":""","'":"'" })[c]); + function hostOf(u) { try { return new URL(u).host; } catch { return u; } } + function render(peers) { + list.innerHTML = ""; + if (!peers.length) { + const empty = document.createElement("div"); + empty.className = "pmuted"; empty.style.fontSize = "12.5px"; + empty.textContent = "No peers configured — add one below, or restart to restore the defaults."; + list.appendChild(empty); + return; + } + for (let i = 0; i < peers.length; i++) { + const url = peers[i]; + const row = document.createElement("div"); + row.style.cssText = "display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px"; + const primary = i === 0 ? 'PRIMARY' : ''; + row.innerHTML = `
${primary}${esc(hostOf(url))}
${esc(url)}
+ `; + list.appendChild(row); + } + list.querySelectorAll("[data-del]").forEach((b) => b.onclick = async () => { + const i = Number(b.dataset.del); + const cur = (await C.get()).translateEndpoints || []; + C.set("translateEndpoints", cur.filter((_, idx) => idx !== i)); + }); + } + render(s.translateEndpoints || []); + addBtn.onclick = async () => { + const u = String(urlIn.value || "").trim(); + if (!u) return; + const url = /^https?:\/\//i.test(u) || /^bns:\/\//i.test(u) ? u : "https://" + u; + const cur = (await C.get()).translateEndpoints || []; + if (cur.includes(url)) { urlIn.value = ""; return; } + C.set("translateEndpoints", [...cur, url]); + urlIn.value = ""; + }; + if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.translateEndpoints || [])); + })(); // ---- website language (General page) — friendly wrapper over the // same languageMode/languageValue setting the Anti-fingerprinting Language // row edits. "Auto" = languageMode="show" (follow OS); anything else