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.
This commit is contained in:
parent
b7b705827a
commit
501c7cf273
3 changed files with 401 additions and 0 deletions
19
bundled-addons/translate/addon.json
Normal file
19
bundled-addons/translate/addon.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"id": "translate",
|
||||
"name": "Translate",
|
||||
"version": "0.1.0",
|
||||
"description": "Right-click a selection to translate it. Sidebar panel with LibreTranslate or Google as the backend.",
|
||||
"author": "Silent Mode",
|
||||
"icon": "🌐",
|
||||
"main": "index.js",
|
||||
"capabilities": ["sidebar-panel", "context-menu-item"],
|
||||
"context-menu-items": [
|
||||
{
|
||||
"id": "translate-selection",
|
||||
"label": "Translate selection",
|
||||
"when": "selectionText",
|
||||
"icon": "🌐"
|
||||
}
|
||||
],
|
||||
"updateURL": "https://navigate.st/bns/theseus.x/extensions/translate/updates.json"
|
||||
}
|
||||
32
bundled-addons/translate/index.js
Normal file
32
bundled-addons/translate/index.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Translate — right-click a selection, get a translation in the sidebar.
|
||||
//
|
||||
// One sidebar panel; the panel HTML does the actual translation (LibreTranslate
|
||||
// or Google unofficial free endpoint, user's pick). When the user picks
|
||||
// "Translate selection" from a page's right-click menu we stash the selection
|
||||
// under storage.__pending and reveal our sidebar; the panel reads __pending on
|
||||
// load / on visibility change and translates immediately.
|
||||
|
||||
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.log("registered translate panel + context-menu item");
|
||||
},
|
||||
};
|
||||
350
bundled-addons/translate/panel.html
Normal file
350
bundled-addons/translate/panel.html
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Translate</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark;
|
||||
--bg:#0e131c; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
|
||||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
||||
--btn:#1c2432; --btn-h:#242e40; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#f0f3fa; --line:rgba(0,0,0,.10);
|
||||
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
|
||||
--btn:#f0f3fa; --btn-h:#e3e8f2; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body { background: var(--bg); color: var(--ink);
|
||||
font: 13px/1.55 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
display: flex; flex-direction: column; }
|
||||
header { display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
||||
background: var(--panel); }
|
||||
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; }
|
||||
header .t .em { font-size: 15px; }
|
||||
header .m { color: var(--dim); font-size: 11.5px; }
|
||||
header .m.err { color: #ff9081; }
|
||||
header .m.ok { color: var(--acid); }
|
||||
.langs { display: flex; gap: 6px; align-items: center;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel2); }
|
||||
.langs select { flex: 1; min-width: 0; padding: 6px 8px; font: inherit;
|
||||
background: var(--bg); color: var(--ink);
|
||||
border: 1px solid var(--line); border-radius: 6px; }
|
||||
.langs .swap { padding: 6px 8px; background: var(--btn); color: var(--ink);
|
||||
border: 1px solid var(--line); border-radius: 6px; cursor: pointer; font: inherit; }
|
||||
.langs .swap:hover { background: var(--btn-h); }
|
||||
.body { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.zone { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
.zone + .zone { border-top: 1px solid var(--line); }
|
||||
.zone label { color: var(--dim); font-size: 10.5px; letter-spacing: .18em;
|
||||
text-transform: uppercase; padding: 8px 14px 4px; display: flex;
|
||||
justify-content: space-between; align-items: baseline; }
|
||||
.zone label .action { color: var(--mut); font-size: 10.5px; cursor: pointer;
|
||||
letter-spacing: normal; text-transform: none; }
|
||||
.zone label .action:hover { color: var(--acid); }
|
||||
textarea, .output {
|
||||
flex: 1; padding: 8px 14px 14px; border: none; outline: none; resize: none;
|
||||
background: transparent; color: var(--ink);
|
||||
font: 13.5px/1.55 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
overflow-y: auto; white-space: pre-wrap; word-wrap: break-word;
|
||||
}
|
||||
textarea::placeholder { color: var(--dim); }
|
||||
.output.empty { color: var(--dim); font-style: italic; }
|
||||
.actions { display: flex; gap: 6px; padding: 8px 12px;
|
||||
border-top: 1px solid var(--line); background: var(--panel); }
|
||||
.actions .btn {
|
||||
flex: 1; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
|
||||
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
|
||||
}
|
||||
.actions .btn.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
|
||||
.actions .btn:hover:not(:disabled) { background: var(--btn-h); }
|
||||
.actions .btn.primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
.actions .btn:disabled { opacity: .45; cursor: default; }
|
||||
.actions .btn.gear { flex: 0 0 auto; padding: 8px 10px; }
|
||||
.settings {
|
||||
display: none; padding: 10px 14px; background: var(--panel2);
|
||||
border-top: 1px solid var(--line); font-size: 12px; color: var(--mut);
|
||||
flex-direction: column; gap: 8px;
|
||||
}
|
||||
.settings.open { display: flex; }
|
||||
.settings label { display: flex; flex-direction: column; gap: 4px; color: var(--dim); font-size: 11px; }
|
||||
.settings input, .settings select { padding: 6px 8px; font: inherit;
|
||||
background: var(--bg); color: var(--ink);
|
||||
border: 1px solid var(--line); border-radius: 6px; }
|
||||
.settings .hint { color: var(--dim); font-size: 11px; line-height: 1.5; }
|
||||
.settings .hint code { background: var(--bg); border: 1px solid var(--line);
|
||||
padding: 1px 4px; border-radius: 3px; font-size: 10.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="t"><span class="em">🌐</span> <span>Translate</span></div>
|
||||
<div class="m" id="status">ready</div>
|
||||
</header>
|
||||
|
||||
<div class="langs">
|
||||
<select id="src" title="Source language"></select>
|
||||
<button class="swap" id="swap" title="Swap languages">⇄</button>
|
||||
<select id="tgt" title="Target language"></select>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="zone">
|
||||
<label>Source <span class="action" id="clearIn">clear</span></label>
|
||||
<textarea id="in" placeholder="Right-click a selection on a page, or paste text here." spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="zone">
|
||||
<label>Translation <span class="action" id="copyOut">copy</span></label>
|
||||
<div class="output empty" id="out">The translation will appear here.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" id="go">Translate</button>
|
||||
<button class="btn gear" id="gear" title="Settings">⚙</button>
|
||||
</div>
|
||||
|
||||
<div class="settings" id="settings">
|
||||
<label>
|
||||
Backend
|
||||
<select id="backend">
|
||||
<option value="libretranslate">LibreTranslate</option>
|
||||
<option value="google">Google (unofficial free endpoint)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="ltUrlLabel">
|
||||
LibreTranslate server URL
|
||||
<input id="ltUrl" type="url" placeholder="https://translate.argosopentech.com" />
|
||||
</label>
|
||||
<label id="ltKeyLabel">
|
||||
LibreTranslate API key (optional)
|
||||
<input id="ltKey" type="text" placeholder="only if your instance requires one" />
|
||||
</label>
|
||||
<div class="hint">
|
||||
LibreTranslate is a free MIT-licensed engine. Public instances rate-limit; if you
|
||||
hit "too many requests", pick another URL or self-host. Google (unofficial) uses
|
||||
<code>translate.googleapis.com/translate_a/single</code> — free, no key, but
|
||||
unofficial and Google can break it at any time.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const inp = $("in"), out = $("out"), status = $("status");
|
||||
const srcSel = $("src"), tgtSel = $("tgt");
|
||||
const goBtn = $("go"), swapBtn = $("swap"), gearBtn = $("gear");
|
||||
const settings = $("settings");
|
||||
const backendSel = $("backend"), ltUrl = $("ltUrl"), ltKey = $("ltKey");
|
||||
const ltUrlLabel = $("ltUrlLabel"), ltKeyLabel = $("ltKeyLabel");
|
||||
|
||||
// ISO-639-1 short list. First `auto` for source only. Users can add more
|
||||
// via settings later; for v1 this covers ~99% of use.
|
||||
const LANGS = [
|
||||
["ar","Arabic"],["bg","Bulgarian"],["cs","Czech"],["da","Danish"],
|
||||
["de","German"],["el","Greek"],["en","English"],["es","Spanish"],
|
||||
["et","Estonian"],["fi","Finnish"],["fr","French"],["he","Hebrew"],
|
||||
["hi","Hindi"],["hu","Hungarian"],["id","Indonesian"],["it","Italian"],
|
||||
["ja","Japanese"],["ko","Korean"],["lt","Lithuanian"],["lv","Latvian"],
|
||||
["nb","Norwegian"],["nl","Dutch"],["pl","Polish"],["pt","Portuguese"],
|
||||
["ro","Romanian"],["ru","Russian"],["sk","Slovak"],["sl","Slovenian"],
|
||||
["sv","Swedish"],["th","Thai"],["tr","Turkish"],["uk","Ukrainian"],
|
||||
["vi","Vietnamese"],["zh","Chinese"],
|
||||
];
|
||||
function fillSelect(el, includeAuto) {
|
||||
el.innerHTML = "";
|
||||
if (includeAuto) el.appendChild(new Option("Auto-detect", "auto"));
|
||||
for (const [code, label] of LANGS) el.appendChild(new Option(label, code));
|
||||
}
|
||||
fillSelect(srcSel, true);
|
||||
fillSelect(tgtSel, false);
|
||||
|
||||
// Default target = user's browser language, falling back to English.
|
||||
const browserLang = (navigator.language || "en").split("-")[0].toLowerCase();
|
||||
const defaultTarget = LANGS.some(([c]) => c === browserLang) && browserLang !== "en" ? browserLang : "en";
|
||||
|
||||
const S = window.silentmode?.storage;
|
||||
const settingsState = { backend: "libretranslate", ltUrl: "https://translate.argosopentech.com", ltKey: "" };
|
||||
const uiState = { src: "auto", tgt: defaultTarget };
|
||||
|
||||
async function loadState() {
|
||||
if (!S) return;
|
||||
try {
|
||||
const saved = await S.get("settings", null);
|
||||
if (saved && typeof saved === "object") Object.assign(settingsState, saved);
|
||||
const ui = await S.get("ui", null);
|
||||
if (ui && typeof ui === "object") Object.assign(uiState, ui);
|
||||
} catch (e) { console.warn("load state failed:", e); }
|
||||
backendSel.value = settingsState.backend;
|
||||
ltUrl.value = settingsState.ltUrl;
|
||||
ltKey.value = settingsState.ltKey;
|
||||
srcSel.value = uiState.src;
|
||||
tgtSel.value = uiState.tgt;
|
||||
onBackendChange();
|
||||
}
|
||||
async function saveSettings() {
|
||||
settingsState.backend = backendSel.value;
|
||||
settingsState.ltUrl = ltUrl.value.trim().replace(/\/+$/, "") || "https://translate.argosopentech.com";
|
||||
settingsState.ltKey = ltKey.value.trim();
|
||||
if (S) try { await S.set("settings", settingsState); } catch {}
|
||||
}
|
||||
async function saveUi() {
|
||||
uiState.src = srcSel.value;
|
||||
uiState.tgt = tgtSel.value;
|
||||
if (S) try { await S.set("ui", uiState); } catch {}
|
||||
}
|
||||
|
||||
function onBackendChange() {
|
||||
const isLt = backendSel.value === "libretranslate";
|
||||
ltUrlLabel.style.display = isLt ? "" : "none";
|
||||
ltKeyLabel.style.display = isLt ? "" : "none";
|
||||
}
|
||||
|
||||
function setStatus(text, cls = "") {
|
||||
status.textContent = text;
|
||||
status.className = "m" + (cls ? " " + cls : "");
|
||||
}
|
||||
|
||||
// --- backends ----------------------------------------------------------
|
||||
async function translateLibre(text, source, target) {
|
||||
const url = settingsState.ltUrl.replace(/\/+$/, "") + "/translate";
|
||||
const body = { q: text, source: source === "auto" ? "auto" : source, target, format: "text" };
|
||||
if (settingsState.ltKey) body.api_key = settingsState.ltKey;
|
||||
const r = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) {
|
||||
let msg = "HTTP " + r.status;
|
||||
try { const j = await r.json(); if (j?.error) msg = j.error; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
const j = await r.json();
|
||||
return {
|
||||
text: String(j.translatedText || j.translated_text || ""),
|
||||
detected: j.detectedLanguage?.language || null,
|
||||
};
|
||||
}
|
||||
async function translateGoogle(text, source, target) {
|
||||
// Unofficial free endpoint used by every browser translation extension.
|
||||
// Google can break this at any time; it's an opt-in fallback.
|
||||
const params = new URLSearchParams({
|
||||
client: "gtx",
|
||||
sl: source === "auto" ? "auto" : source,
|
||||
tl: target,
|
||||
dt: "t",
|
||||
q: text,
|
||||
});
|
||||
const r = await fetch(`https://translate.googleapis.com/translate_a/single?${params}`);
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
const j = await r.json();
|
||||
// Response shape: [ [ [translated, original, null, null, ...], ... ], null, detectedLang ]
|
||||
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 };
|
||||
}
|
||||
|
||||
async function doTranslate() {
|
||||
const text = inp.value.trim();
|
||||
if (!text) { setStatus("nothing to translate"); return; }
|
||||
const source = srcSel.value, target = tgtSel.value;
|
||||
if (source && source !== "auto" && source === target) {
|
||||
setStatus("source = target", "err");
|
||||
out.textContent = text;
|
||||
out.classList.remove("empty");
|
||||
return;
|
||||
}
|
||||
goBtn.disabled = true; const orig = goBtn.textContent;
|
||||
goBtn.textContent = "Translating…";
|
||||
setStatus("translating…");
|
||||
out.classList.remove("empty");
|
||||
try {
|
||||
const fn = settingsState.backend === "google" ? translateGoogle : translateLibre;
|
||||
const res = await fn(text, source, target);
|
||||
out.textContent = res.text || "(empty response)";
|
||||
const via = settingsState.backend === "google" ? "google" : "libretranslate";
|
||||
setStatus(res.detected ? `${via} · detected ${res.detected}` : via, "ok");
|
||||
} catch (e) {
|
||||
out.textContent = "";
|
||||
out.classList.add("empty");
|
||||
out.textContent = "Failed: " + (e?.message || String(e));
|
||||
setStatus("failed", "err");
|
||||
} finally {
|
||||
goBtn.disabled = false; goBtn.textContent = orig;
|
||||
}
|
||||
saveUi();
|
||||
}
|
||||
|
||||
// Buttons ---------------------------------------------------------------
|
||||
goBtn.addEventListener("click", doTranslate);
|
||||
swapBtn.addEventListener("click", () => {
|
||||
if (srcSel.value === "auto") return;
|
||||
const a = srcSel.value, b = tgtSel.value;
|
||||
srcSel.value = b; tgtSel.value = a;
|
||||
// Swap the text too if there's already output.
|
||||
if (out.textContent && !out.classList.contains("empty")) {
|
||||
inp.value = out.textContent;
|
||||
out.textContent = ""; out.classList.add("empty");
|
||||
}
|
||||
saveUi();
|
||||
});
|
||||
$("clearIn").addEventListener("click", () => {
|
||||
inp.value = ""; out.textContent = "The translation will appear here.";
|
||||
out.classList.add("empty");
|
||||
setStatus("ready");
|
||||
inp.focus();
|
||||
});
|
||||
$("copyOut").addEventListener("click", async () => {
|
||||
if (out.classList.contains("empty") || !out.textContent) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(out.textContent);
|
||||
setStatus("copied", "ok");
|
||||
setTimeout(() => setStatus("ready"), 1200);
|
||||
} catch (e) { setStatus("copy failed", "err"); }
|
||||
});
|
||||
gearBtn.addEventListener("click", () => settings.classList.toggle("open"));
|
||||
backendSel.addEventListener("change", async () => { onBackendChange(); await saveSettings(); });
|
||||
ltUrl.addEventListener("change", saveSettings);
|
||||
ltKey.addEventListener("change", saveSettings);
|
||||
srcSel.addEventListener("change", saveUi);
|
||||
tgtSel.addEventListener("change", saveUi);
|
||||
|
||||
// Ctrl/Cmd+Enter to translate.
|
||||
inp.addEventListener("keydown", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); doTranslate(); }
|
||||
});
|
||||
|
||||
// Pending selection from a right-click. When the addon dispatches
|
||||
// "context-menu", the addon's index.js writes __pending to storage and
|
||||
// reveals the sidebar; we pull it here.
|
||||
async function drainPending() {
|
||||
if (!S) return;
|
||||
let pending = null;
|
||||
try { pending = await S.get("__pending", null); } catch {}
|
||||
if (!pending || !pending.text) return;
|
||||
const age = Date.now() - (pending.at || 0);
|
||||
if (age > 5 * 60_000) { try { await S.set("__pending", null); } catch {}; return; } // stale
|
||||
inp.value = pending.text;
|
||||
try { await S.set("__pending", null); } catch {}
|
||||
setStatus(pending.host ? `from ${pending.host}` : "from page selection");
|
||||
doTranslate();
|
||||
}
|
||||
|
||||
// First paint. loadState → drainPending, and re-drain whenever the panel
|
||||
// becomes visible again (the sidebar preload dispatches a "sidebar-
|
||||
// visibility" event on its window).
|
||||
(async () => {
|
||||
await loadState();
|
||||
await drainPending();
|
||||
inp.focus();
|
||||
})();
|
||||
window.addEventListener("visibilitychange", () => { if (!document.hidden) drainPending(); });
|
||||
// Sidebar preload emits this custom event on the panel's window when the
|
||||
// sidebar is shown/hidden — see main.js's send("sidebar-visibility").
|
||||
// Not all preload versions expose it as an event; if not, visibilitychange
|
||||
// above covers most cases.
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue