diff options
| author | Adam Soltys <[email protected]> | 2026-10-10 08:39:24 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-10 08:39:24 -0700 |
| commit | 336fd6da53ace71af2c949e35edd9f0aac664df7 (patch) | |
| tree | e2212759231fd6f02a397e1816bd1c5646d28614 /names | |
| parent | 2338345063b0f1c816469401264b0cd0d7b7636b (diff) | |
| download | coinosv3-336fd6da53ace71af2c949e35edd9f0aac664df7.tar.gz coinosv3-336fd6da53ace71af2c949e35edd9f0aac664df7.zip | |
Translation: into the phone's language, and DeepL with your own key. isolabellart saw the Translate button but nothing became Italian: the target was the app's language, coinos has no Italian UI (so it ran in English), and the language guess didn't know Italian. Posts now translate into the reader's choice (Settings → Nostr → Translation, any of 31 languages), else the phone's language when the app has no UI for it, else the app's; the guess knows Italian, Dutch and Polish. And, as Jumble offers: a DeepL API key of your own, kept on the device and sent per request as x-deepl-key — the registrar passes it to DeepL (api-free for ":fx" keys; DeepL refuses browsers) and never stores or logs it; a refused key or used-up quota says so once. names: translateDeepl (prose only, links/tags kept, detected source back, 401/402/422 statuses), CORS allows the header. Deployed to cs names. tools/translate-italian-test.js, tools/deepl-translate.test.js.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
Diffstat (limited to 'names')
| -rw-r--r-- | names/server.js | 8 | ||||
| -rw-r--r-- | names/translate.js | 41 |
2 files changed, 46 insertions, 3 deletions
diff --git a/names/server.js b/names/server.js index aff4e54..77d758c 100644 --- a/names/server.js +++ b/names/server.js @@ -28,7 +28,7 @@ import { mnemonicToSeedSync } from '@scure/bip39'; import { lnBackend } from '../bridge/ln.js'; import { ArkManager } from '../src/ark/manager.js'; import { fetchPreview } from './preview.js'; -import { translate, languages } from './translate.js'; +import { translate, translateDeepl, languages } from './translate.js'; import { decodeAddress } from '../src/ark/proto.js'; import { decodeNoffer } from '../src/noffer.js'; import { decodeBolt11 } from '../src/ark/lightning.js'; @@ -904,7 +904,7 @@ const json = (body, status = 200) => { headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*', - 'access-control-allow-headers': 'content-type,authorization', + 'access-control-allow-headers': 'content-type,authorization,x-deepl-key', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', }, }); @@ -1056,7 +1056,9 @@ Bun.serve({ let body = null; try { body = await req.json(); } catch {} if (!body || typeof body.text !== 'string' || typeof body.target !== 'string') return json({ error: 'bad request' }, 400); - try { return json(await translate(body.text, body.target, body.source)); } + // a reader's own DeepL key rides in a header: passed on, never kept or logged + const dk = (req.headers.get('x-deepl-key') || '').trim(); + try { return json(dk ? await translateDeepl(body.text, body.target, dk) : await translate(body.text, body.target, body.source)); } catch (e) { return json({ error: e.message || 'translation failed' }, e.status || 503); } } diff --git a/names/translate.js b/names/translate.js index 5382711..36ab8cf 100644 --- a/names/translate.js +++ b/names/translate.js @@ -80,3 +80,44 @@ export async function translate(text, target, source = 'auto') { return out; } finally { release(); } } + +// DeepL, with the reader's own API key (sent per request; never stored, and +// not part of the cache key beyond the provider). DeepL refuses browsers +// (no CORS), which is the only reason this passes through here. A free key +// ends in ":fx" and has its own host. +const DEEPL_TARGET = { en: 'EN-US', pt: 'PT-BR', zh: 'ZH-HANS' }; +export async function translateDeepl(text, target, key, fetchImpl = fetch) { + text = String(text || ''); + target = norm(target); + if (!text.trim()) return { text: '', source: null }; + if (text.length > MAX_CHARS) throw Object.assign(new Error('too long'), { status: 413 }); + if (!/^[0-9a-f-]{20,}(:fx)?$/i.test(key)) throw Object.assign(new Error('bad DeepL key'), { status: 401 }); + const ck = createHash('sha256').update('deepl|' + target + '|' + text).digest('hex'); + const hit = cache.get(ck); + if (hit) { cache.delete(ck); cache.set(ck, hit); return hit; } + const { prose, tags } = translatable(text, target); + if (!prose.length && !tags.length) return { text, source: null, nothing: true }; + const host = /:fx$/i.test(key) ? 'https://api-free.deepl.com' : 'https://api.deepl.com'; + let from = null; + const translated = await translatePieces(text, async (qs) => { + const r = await fetchImpl(host + '/v2/translate', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'DeepL-Auth-Key ' + key }, + body: JSON.stringify({ text: qs, target_lang: DEEPL_TARGET[target] || target.toUpperCase() }), + signal: AbortSignal.timeout(20_000), + }); + if (r.status === 403 || r.status === 401) throw Object.assign(new Error('DeepL refused the key'), { status: 401 }); + if (r.status === 456) throw Object.assign(new Error('DeepL quota exceeded'), { status: 402 }); + if (r.status === 400) throw Object.assign(new Error('DeepL can’t translate into that language'), { status: 422 }); + if (!r.ok) throw Object.assign(new Error('DeepL ' + r.status), { status: 502 }); + const j = await r.json(); + const out = (j.translations || []).map((x) => x.text); + from ||= norm(j.translations?.[0]?.detected_source_language) || null; + if (out.length !== qs.length) throw Object.assign(new Error('DeepL answered short'), { status: 502 }); + return out; + }, target); + const out = { text: from === target ? text : translated, source: from }; + cache.set(ck, out); + if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value); + return out; +} |
