Theseus: in-page translator (LibreTranslate client)
The Website-language setting only tells servers what the user prefers via
Accept-Language — many static sites (including names on BCDN) serve one
language and ignore it, so e.g. hello.bch loads in English for every user,
in every language. This adds a translator that converts the page's visible
text in place, so a Lithuanian user reads hello.bch in Lithuanian without
asking the server for anything.
The URL-bar grows a translate chip next to the website-language globe. The
chip lights up when the page's declared `<html lang>` differs from the
user's preferred language. Click it once to translate in place; click again
to revert — originals are kept in a renderer-local state slot and swapped
back without a reload. Right-click opens the chip menu (change target /
translator settings).
The engine lives behind a swappable adapter in main — this ships with the
LibreTranslate backend (POST /translate with {q, source, target, format}).
The endpoint defaults to the LibreTranslate public tier but is settable in
Settings › General › Translate pages, so a user with a self-hosted
LibreTranslate (or Silent Mode's own translate.silentmode.st once it is
up) swaps it there without a code change. On-device Bergamot (the WASM
engine Firefox Translations uses) will plug into the same adapter in a
later release — same contract (array of texts in, array of translations
out), the chip and revert path are already engine-agnostic.
The fetch goes through session.defaultSession.fetch so Tor and add-on
proxy rules apply uniformly, chunks the batch at ~3.8 KB per POST so a
large page spreads across several requests, times each one out at 45 s,
and reports a failure to the chip's tooltip so a dead endpoint reads as
such and not as a silent no-op. The injected walker skips SCRIPT / STYLE
/ CODE / PRE / NOSCRIPT / TEXTAREA and contentEditable subtrees, keeps a
reference to each text node and the original text, and reverts by
restoring from that pair.
This commit is contained in:
parent
8b0cc00290
commit
f3c8998ecc
4 changed files with 325 additions and 4 deletions
60
chrome.html
60
chrome.html
|
|
@ -513,6 +513,14 @@
|
|||
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M2 8 H14"/><path d="M8 2 C5 5 5 11 8 14 C11 11 11 5 8 2"/></svg>
|
||||
<span class="lcode" id="langCode">AUTO</span>
|
||||
</button>
|
||||
<!-- Translate chip: lights up when the page's declared language
|
||||
differs from the user's preferred one. Click translates in
|
||||
place; click again reverts. Right-click opens the chip menu
|
||||
(change target, translator settings). -->
|
||||
<button class="langchip" id="trBtn" title="Translate this page" hidden>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 4 H8 M5 2 V4 M3 4 C3 7 5 10 8 10 M8 10 C5 10 3 11 2 13 M3 10 C6 10 8 7 8 4"/><path d="M8.5 14 L11 7 L13.5 14 M9.3 12 H12.7"/></svg>
|
||||
<span class="lcode" id="trCode">EN</span>
|
||||
</button>
|
||||
<button class="star" id="star" title="Save this page">☆</button>
|
||||
</div>
|
||||
<div class="bardrag" id="bardrag" title="Drag to shift the address / search bar ratio"></div>
|
||||
|
|
@ -760,6 +768,58 @@
|
|||
.then(renderLang).catch(() => {});
|
||||
T.onSettingsUpdate && T.onSettingsUpdate(renderLang);
|
||||
|
||||
// ---- page-translate chip ----
|
||||
// Shows when settings.translateAutoOffer is on and the loaded page's
|
||||
// declared language (<html lang>) differs from the user's target. Click
|
||||
// toggles between translated and original. Right-click (or long-press)
|
||||
// opens the chip menu.
|
||||
const trBtn = $("trBtn"), trCode = $("trCode");
|
||||
let trSettingsCached = null, trStateCached = null;
|
||||
function trTargetBase() {
|
||||
const s = trSettingsCached || {};
|
||||
const tag = s.languageMode === "manual" && s.languageValue ? s.languageValue : systemLocaleCached;
|
||||
return String(tag || "en").split("-")[0].toLowerCase();
|
||||
}
|
||||
function renderTr() {
|
||||
if (!trBtn) return;
|
||||
const s = trSettingsCached || {};
|
||||
const st = trStateCached || {};
|
||||
const target = trTargetBase();
|
||||
const page = String(st.pageLang || "").toLowerCase();
|
||||
const translated = !!st.translated;
|
||||
// Hide when auto-offer is off AND we aren't already translated, OR when
|
||||
// the page is already in the user's target language.
|
||||
const shouldShow = translated || (s.translateAutoOffer !== false && page && page !== target);
|
||||
trBtn.hidden = !shouldShow;
|
||||
if (!shouldShow) return;
|
||||
trCode.textContent = (target || "??").toUpperCase().slice(0, 2);
|
||||
if (translated) {
|
||||
trBtn.classList.add("on");
|
||||
trBtn.title = `Translated to ${langName(target)}. Click to show the original.`;
|
||||
} else if (st.error) {
|
||||
trBtn.classList.remove("on");
|
||||
trBtn.title = `Translation failed: ${st.error}. Right-click for settings.`;
|
||||
} else {
|
||||
trBtn.classList.remove("on");
|
||||
trBtn.title = `Translate this page from ${langName(page) || page} to ${langName(target)}`;
|
||||
}
|
||||
}
|
||||
if (trBtn) {
|
||||
trBtn.onclick = () => {
|
||||
if (trStateCached?.translated) T.pageTranslateRevert && T.pageTranslateRevert();
|
||||
else T.pageTranslate && T.pageTranslate();
|
||||
};
|
||||
trBtn.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const r = trBtn.getBoundingClientRect();
|
||||
T.pageTranslateMenu && T.pageTranslateMenu({ x: Math.round(r.left), y: Math.round(r.bottom + 4) });
|
||||
});
|
||||
}
|
||||
T.onPageTranslateState && T.onPageTranslateState((d) => { trStateCached = d; renderTr(); });
|
||||
T.onSettingsUpdate && T.onSettingsUpdate((s) => { trSettingsCached = s; renderTr(); });
|
||||
T.getSettings().then((s) => { trSettingsCached = s; renderTr(); }).catch(() => {});
|
||||
if (T.pageTranslateState) T.pageTranslateState().then((d) => { trStateCached = d; renderTr(); }).catch(() => {});
|
||||
|
||||
// ---- extension dock ----
|
||||
// One toolbar button per registered addon sidebar-panel, plus a static
|
||||
// "coming soon" placeholder for Aegis (the built-in BCH wallet, in
|
||||
|
|
|
|||
230
main.js
230
main.js
|
|
@ -516,6 +516,23 @@ const SETTINGS_DEFAULTS = {
|
|||
// Global Privacy Control: the Sec-GPC header + navigator.globalPrivacyControl,
|
||||
// a legally meaningful "do not sell or share" signal in several jurisdictions.
|
||||
gpc: true,
|
||||
// Page translator. Converts a page's visible text to the user's own
|
||||
// language — Accept-Language only asks the server for a translated body
|
||||
// (and many static sites, including BCNR names, serve only one). The
|
||||
// engine is swappable; v0.3.70 ships a LibreTranslate client that talks
|
||||
// to any API-compatible endpoint. On-device Bergamot/WASM is a follow-up
|
||||
// (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",
|
||||
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
|
||||
// the page in place; clicking it again reverts.
|
||||
translateAutoOffer: true,
|
||||
// Hosts the user never wants auto-offered translation on — their own
|
||||
// webmail, docs apps, anything they prefer in its original language.
|
||||
translateExcludedHosts: [],
|
||||
// Sidebar width in px. Adjusted by dragging the grip on the panel's left
|
||||
// edge; persisted across launches. Clamped to [200, 800] on load.
|
||||
sidebarWidth: 340,
|
||||
|
|
@ -3364,6 +3381,7 @@ function setActive(id) {
|
|||
syncJsDialogVisibility();
|
||||
notifyTabChange();
|
||||
emitTabs();
|
||||
if (t) emitTranslateState(t);
|
||||
}
|
||||
function emitTabs() {
|
||||
const t = activeTab();
|
||||
|
|
@ -3654,6 +3672,24 @@ function createTab(initial, opts = {}) {
|
|||
});
|
||||
wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } });
|
||||
wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); });
|
||||
// Translator state is per-document: a new navigation drops any "translated"
|
||||
// flag and clears the cached page language. The chip then re-decides on
|
||||
// the next pageLang read whether to light up for this new page.
|
||||
wc.on("did-start-navigation", (_e, _url, _ihr, isMainFrame) => {
|
||||
if (!isMainFrame) return;
|
||||
tab._tr = null; tab.pageLang = "";
|
||||
if (tab.id === activeId) emitTranslateState(tab);
|
||||
});
|
||||
// After the page has committed, read <html lang> once so the chip knows
|
||||
// what language the server actually served (which may not match whatever
|
||||
// we asked for via Accept-Language).
|
||||
wc.on("did-finish-load", async () => {
|
||||
try {
|
||||
const lang = await wc.executeJavaScript(`document.documentElement.lang || ""`);
|
||||
tab.pageLang = String(lang || "").toLowerCase().split("-")[0];
|
||||
} catch { tab.pageLang = ""; }
|
||||
if (tab.id === activeId) emitTranslateState(tab);
|
||||
});
|
||||
// Ctrl+wheel / pinch: Chromium only reports the intent on Windows and
|
||||
// Linux, the zoom itself is up to us.
|
||||
wc.on("zoom-changed", (_e, dir) => zoomStep(tab, dir === "in" ? 1 : -1));
|
||||
|
|
@ -4493,6 +4529,200 @@ function languageNameFor(tag) {
|
|||
try { return new Intl.DisplayNames(["en"], { type: "language" }).of(base) || tag; }
|
||||
catch { return tag; }
|
||||
}
|
||||
// ---- page translator -------------------------------------------------------
|
||||
// The user's own language, as a BCP-47 base tag ("en", "lt", "pt"): the
|
||||
// preferred-language setting if they picked one; otherwise their OS locale.
|
||||
// Base-only, because the translator cares about language, not region — a
|
||||
// user in "en-GB" wants English, not British-English-but-not-American.
|
||||
function translationTargetBase() {
|
||||
const tag = settings.languageMode === "manual" && settings.languageValue
|
||||
? settings.languageValue
|
||||
: app.getLocale() || "en-US";
|
||||
return String(tag).split("-")[0].toLowerCase() || "en";
|
||||
}
|
||||
// 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;
|
||||
const body = {
|
||||
q: texts,
|
||||
source: from || "auto",
|
||||
target: to,
|
||||
format: "text",
|
||||
};
|
||||
if (settings.translateApiKey) body.api_key = settings.translateApiKey;
|
||||
// session.defaultSession.fetch goes through Electron's own network stack,
|
||||
// so Tor (session proxy) and any add-on proxy settings apply the same way
|
||||
// they do for a tab's fetch; Node's global fetch would bypass both.
|
||||
const sfetch = (session.defaultSession.fetch || fetch).bind(session.defaultSession);
|
||||
const r = await sfetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(45000),
|
||||
});
|
||||
if (!r.ok) {
|
||||
let detail = ""; try { detail = (await r.text()).slice(0, 300); } catch {}
|
||||
throw new Error(`translator 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");
|
||||
}
|
||||
// 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
|
||||
// is also slower to recover from an upstream drop than four 12-page POSTs.
|
||||
const TRANSLATE_CHUNK_CHARS = 3800;
|
||||
function chunkTexts(texts) {
|
||||
const chunks = [[]]; let charsInLast = 0;
|
||||
for (const t of texts) {
|
||||
const len = t.length + 2;
|
||||
if (charsInLast + len > TRANSLATE_CHUNK_CHARS && chunks[chunks.length - 1].length > 0) {
|
||||
chunks.push([]); charsInLast = 0;
|
||||
}
|
||||
chunks[chunks.length - 1].push(t);
|
||||
charsInLast += len;
|
||||
}
|
||||
return chunks.filter((c) => c.length > 0);
|
||||
}
|
||||
async function translateAll(texts, from, to) {
|
||||
if (!Array.isArray(texts) || texts.length === 0) return [];
|
||||
const chunks = chunkTexts(texts);
|
||||
const out = [];
|
||||
for (const chunk of chunks) {
|
||||
const got = await translatorLibreTranslate(chunk, from, to);
|
||||
for (const s of got) out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Injected into the target page's renderer. Walks the body for visible text
|
||||
// nodes, filters out script/style/code/pre and blank runs, saves originals
|
||||
// on a window-scoped slot so revert can put them back without reloading, and
|
||||
// returns the texts + the page's declared language. The slots never leak
|
||||
// across pages (did-navigate drops the renderer context).
|
||||
const PAGE_TRANSLATE_EXTRACT = `(() => {
|
||||
const SKIP = new Set(["SCRIPT","STYLE","NOSCRIPT","CODE","PRE","TEXTAREA"]);
|
||||
const nodes = [], texts = [];
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
let n; while ((n = walker.nextNode())) {
|
||||
const s = n.nodeValue; if (!s || !s.trim()) continue;
|
||||
let p = n.parentElement, skip = false;
|
||||
while (p && p !== document.body) {
|
||||
if (SKIP.has(p.tagName) || p.isContentEditable) { skip = true; break; }
|
||||
p = p.parentElement;
|
||||
}
|
||||
if (skip) continue;
|
||||
nodes.push(n); texts.push(s);
|
||||
}
|
||||
window.__theseusTranslate = { nodes, originals: texts.slice(), translated: null };
|
||||
return { sourceLang: (document.documentElement.lang || "").toLowerCase(), texts };
|
||||
})()`;
|
||||
// Second-phase apply. Takes a translated-strings array (same order as the
|
||||
// extract output), substitutes each node's nodeValue, and remembers the
|
||||
// translated set so a second call to this script with "revert" can restore.
|
||||
function pageTranslateApplyScript(translated) {
|
||||
const payload = JSON.stringify(translated);
|
||||
return `(() => {
|
||||
const state = window.__theseusTranslate; if (!state) return 0;
|
||||
const arr = ${payload};
|
||||
for (let i = 0; i < state.nodes.length && i < arr.length; i++) {
|
||||
if (arr[i] != null) state.nodes[i].nodeValue = arr[i];
|
||||
}
|
||||
state.translated = arr.slice();
|
||||
return state.nodes.length;
|
||||
})()`;
|
||||
}
|
||||
const PAGE_TRANSLATE_REVERT = `(() => {
|
||||
const state = window.__theseusTranslate; if (!state) return 0;
|
||||
for (let i = 0; i < state.nodes.length && i < state.originals.length; i++) {
|
||||
state.nodes[i].nodeValue = state.originals[i];
|
||||
}
|
||||
state.translated = null;
|
||||
return state.nodes.length;
|
||||
})()`;
|
||||
// A tab tracks its own translator state so chip + context menu + revert know
|
||||
// what to show. { translated: false, source: "", target: "", error?: "" } —
|
||||
// mutated in-place and broadcast via emitTranslateState so chrome can react.
|
||||
function tabTranslateState(t) {
|
||||
if (!t._tr) t._tr = { translated: false, source: "", target: "", error: "" };
|
||||
return t._tr;
|
||||
}
|
||||
function emitTranslateState(t) {
|
||||
if (!t || t.id !== activeId) return;
|
||||
try { chrome?.webContents.send("page-translate-state", { ...tabTranslateState(t), pageLang: t.pageLang || "" }); } catch {}
|
||||
}
|
||||
async function translateActiveTab(target) {
|
||||
const t = activeTab(); if (!t) return { ok: false, error: "no tab" };
|
||||
if (t.settings || t.addonId || t.pending) return { ok: false, error: "not a web page" };
|
||||
const st = tabTranslateState(t);
|
||||
const to = (String(target || translationTargetBase()).split("-")[0] || "en").toLowerCase();
|
||||
const wc = t.view.webContents;
|
||||
try {
|
||||
const extracted = await wc.executeJavaScript(PAGE_TRANSLATE_EXTRACT);
|
||||
const texts = extracted?.texts || [];
|
||||
if (!texts.length) { st.error = "nothing to translate"; emitTranslateState(t); return { ok: false, error: st.error }; }
|
||||
const from = extracted.sourceLang || "auto";
|
||||
const translated = await translateAll(texts, from, to);
|
||||
await wc.executeJavaScript(pageTranslateApplyScript(translated));
|
||||
st.translated = true; st.source = from; st.target = to; st.error = "";
|
||||
emitTranslateState(t);
|
||||
return { ok: true, source: from, target: to, count: translated.length };
|
||||
} catch (e) {
|
||||
st.translated = false; st.error = e?.message || String(e);
|
||||
emitTranslateState(t);
|
||||
console.warn("[translate] failed:", st.error);
|
||||
return { ok: false, error: st.error };
|
||||
}
|
||||
}
|
||||
async function revertActiveTab() {
|
||||
const t = activeTab(); if (!t) return { ok: false };
|
||||
if (t.settings || t.addonId || t.pending) return { ok: false };
|
||||
const st = tabTranslateState(t);
|
||||
try { await t.view.webContents.executeJavaScript(PAGE_TRANSLATE_REVERT); } catch (e) { console.warn("[translate] revert failed:", e?.message); }
|
||||
st.translated = false; st.error = "";
|
||||
emitTranslateState(t);
|
||||
return { ok: true };
|
||||
}
|
||||
ipcMain.handle("page-translate", (e, target) => {
|
||||
if (chrome && e.sender !== chrome.webContents) throw new Error("page-translate: untrusted sender");
|
||||
return translateActiveTab(target);
|
||||
});
|
||||
ipcMain.handle("page-translate-revert", (e) => {
|
||||
if (chrome && e.sender !== chrome.webContents) throw new Error("page-translate-revert: untrusted sender");
|
||||
return revertActiveTab();
|
||||
});
|
||||
ipcMain.handle("page-translate-state", (e) => {
|
||||
if (chrome && e.sender !== chrome.webContents) throw new Error("page-translate-state: untrusted sender");
|
||||
const t = activeTab(); if (!t) return null;
|
||||
return { ...tabTranslateState(t), pageLang: t.pageLang || "" };
|
||||
});
|
||||
// Dropdown off the URL-bar translate chip. "Translate to <target>" is the
|
||||
// primary action; "Revert" shows while the page is translated; "More
|
||||
// languages…" opens Settings › General so the user can pick a different
|
||||
// target or edit the backend.
|
||||
ipcMain.handle("page-translate-menu-popup", (e, rect) => {
|
||||
if (chrome && e.sender !== chrome.webContents) throw new Error("page-translate-menu-popup: untrusted sender");
|
||||
const t = activeTab(); const st = t ? tabTranslateState(t) : null;
|
||||
const target = translationTargetBase();
|
||||
const template = [
|
||||
st?.translated
|
||||
? { label: `Revert to ${languageNameFor(st.source || "auto")}`, click: () => revertActiveTab() }
|
||||
: { label: `Translate this page to ${languageNameFor(target)}`, click: () => translateActiveTab() },
|
||||
{ type: "separator" },
|
||||
{ label: "Change target language…", click: () => { try { openSettingsTab("general"); } catch {} } },
|
||||
{ label: "Translator settings…", click: () => { try { openSettingsTab("general"); } catch {} } },
|
||||
];
|
||||
const popup = Menu.buildFromTemplate(template);
|
||||
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
|
||||
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
|
||||
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
|
||||
popup.popup({ window: win, x, y });
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("system-locale", () => app.getLocale() || "en-US");
|
||||
ipcMain.handle("website-language-menu-popup", (e, rect) => {
|
||||
if (chrome && e.sender !== chrome.webContents) throw new Error("website-language-menu-popup: untrusted sender");
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@ contextBridge.exposeInMainWorld("theseus", {
|
|||
onSettingsUpdate: (cb) => ipcRenderer.on("settings-update", (_e, d) => cb(d)),
|
||||
systemLocale: () => ipcRenderer.invoke("system-locale"),
|
||||
websiteLanguageMenu: (rect) => ipcRenderer.invoke("website-language-menu-popup", rect),
|
||||
// Page translator chip: click translates to the user's language, click
|
||||
// again reverts. The chip shows the target code; its "on" state is driven
|
||||
// by the translated flag arriving via onPageTranslateState.
|
||||
pageTranslate: (target) => ipcRenderer.invoke("page-translate", target),
|
||||
pageTranslateRevert: () => ipcRenderer.invoke("page-translate-revert"),
|
||||
pageTranslateState: () => ipcRenderer.invoke("page-translate-state"),
|
||||
pageTranslateMenu: (rect) => ipcRenderer.invoke("page-translate-menu-popup", rect),
|
||||
onPageTranslateState: (cb) => ipcRenderer.on("page-translate-state", (_e, d) => cb(d)),
|
||||
// Find-in-page. main.js fires 'find-open' on Ctrl+F; chrome renderer
|
||||
// owns the bar UI and drives findInPage / stopFindInPage via these
|
||||
// wrappers. Match count / active ordinal comes back through onFindResult.
|
||||
|
|
|
|||
|
|
@ -278,6 +278,20 @@
|
|||
<input id="webLangOther" type="text" placeholder="BCP-47, e.g. cs-CZ" hidden style="width:150px">
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="sub">Translate pages</h2>
|
||||
<p class="subd">Translate a page's visible text in place to your preferred language. The <b>Accept-Language</b> row above only asks the server for a translated body — many static sites (including names on BCDN) serve only one language, and translation turns them readable without a reload.</p>
|
||||
<div class="row">
|
||||
<div class="txt"><div class="t">Offer to translate</div><div class="d">Light up a translate chip in the address bar when the page's declared language is different from the one you picked above.</div></div>
|
||||
<label class="sw"><input type="checkbox" id="translateAutoOffer"><span class="track"><span class="knob"></span></span></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="txt"><div class="t">Translation engine</div><div class="d">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.</div></div>
|
||||
<input id="translateEndpoint" type="text" placeholder="https://libretranslate.com/translate" style="min-width:280px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="txt"><div class="t">API key (optional)</div><div class="d">Some LibreTranslate instances need an API key for the paid tier or to raise the rate limit. Leave blank for the public endpoint.</div></div>
|
||||
<input id="translateApiKey" type="text" placeholder="(none)" style="min-width:200px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
|
||||
</div>
|
||||
<h2 class="sub">Startup</h2>
|
||||
<div class="row">
|
||||
<div class="txt"><div class="t">Open previous windows and tabs</div><div class="d">Restore the tabs from your last session when Theseus starts.</div></div>
|
||||
|
|
@ -1024,13 +1038,22 @@
|
|||
})();
|
||||
const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
|
||||
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
|
||||
"quickLinksShow"];
|
||||
"quickLinksShow", "translateAutoOffer"];
|
||||
// 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"];
|
||||
C.get().then((s) => {
|
||||
for (const k of TOGGLES) {
|
||||
const el = document.getElementById(k); if (!el) continue;
|
||||
el.checked = !!s[k];
|
||||
el.addEventListener("change", () => C.set(k, el.checked));
|
||||
}
|
||||
for (const k of TEXT_FIELDS) {
|
||||
const el = document.getElementById(k); if (!el) continue;
|
||||
el.value = String(s[k] ?? "");
|
||||
el.addEventListener("change", () => C.set(k, el.value.trim()));
|
||||
}
|
||||
// ---- Quick-links list editor (General) --------------------------------
|
||||
// Add/remove rows; each change writes the whole settings.quickLinks array.
|
||||
// The strip view and the main window layout react through settings-set.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue