diff options
Diffstat (limited to 'names/translate.js')
| -rw-r--r-- | names/translate.js | 123 |
1 files changed, 0 insertions, 123 deletions
diff --git a/names/translate.js b/names/translate.js deleted file mode 100644 index 36ab8cf..0000000 --- a/names/translate.js +++ /dev/null @@ -1,123 +0,0 @@ -// Post translation for coinos v3: a thin, cached front for a LibreTranslate -// container on the same docker network. Notes are public, so nothing private -// passes through here; the cache keys on the text itself, so a popular post -// is translated once whoever asks. -import { createHash } from 'node:crypto'; -import { translatable, translatePieces } from '../src/translate-split.js'; - -const LT = process.env.LT_URL || 'http://libretranslate:5000'; -const MAX_CHARS = 5000; -const CACHE_MAX = 20000; -const BUSY_MAX = 4; // LibreTranslate is CPU-bound: a few at once, the rest wait their turn -const cache = new Map(); // sha256(target|text) -> { text, source } -let busy = 0; -const waiting = []; -let langs = null, langsAt = 0; - -async function slot() { - if (busy < BUSY_MAX) { busy++; return; } - await new Promise((r) => waiting.push(r)); - busy++; -} -function release() { busy--; const next = waiting.shift(); if (next) next(); } - -// The languages the container has models for: { code: [targets] } -export async function languages() { - if (langs && Date.now() - langsAt < 3600_000) return langs; - const r = await fetch(LT + '/languages', { signal: AbortSignal.timeout(5000) }); - if (!r.ok) throw new Error('languages ' + r.status); - const list = await r.json(); - langs = Object.fromEntries(list.map((l) => [l.code, l.targets || []])); - if (langs['zh-Hans']) langs.zh = langs['zh-Hans']; // the app says 'zh' - langsAt = Date.now(); - return langs; -} - -// The app's locale codes ↔ LibreTranslate's ('zh' is Simplified there). -const norm = (c) => String(c || '').toLowerCase().split(/[-_]/)[0]; -const toLt = (c) => (c === 'zh' ? 'zh-Hans' : c); - -export async function translate(text, target, source = 'auto') { - text = String(text || ''); - target = norm(target); - source = source && source !== 'auto' ? norm(source) : 'auto'; - if (!text.trim()) return { text: '', source }; - if (text.length > MAX_CHARS) throw Object.assign(new Error('too long'), { status: 413 }); - const have = await languages(); - if (!have[toLt(target)]) throw Object.assign(new Error('unsupported language'), { status: 422 }); - const key = createHash('sha256').update(target + '|' + source + '|' + text).digest('hex'); - const hit = cache.get(key); - if (hit) { cache.delete(key); cache.set(key, hit); return hit; } - const { prose, tags } = translatable(text, target); - if (!prose.length && !tags.length) return { text, source: source === 'auto' ? null : source, nothing: true }; - await slot(); - try { - const call = async (path, body) => { - const r = await fetch(LT + path, { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), signal: AbortSignal.timeout(30_000), - }); - const out = await r.json().catch(() => ({})); - if (!r.ok) throw Object.assign(new Error(out.error || path + ' ' + r.status), { status: r.status === 400 ? 422 : 502 }); - return out; - }; - let from = source, confidence = null; - if (from === 'auto') { - const d = await call('/detect', { q: [...prose.map((p) => p.prose), ...tags.map((p) => p.keep.slice(1).replace(/_/g, ' '))].join('\n') }); - from = norm(d[0]?.language) || null; - confidence = d[0]?.confidence ?? null; - if (!from) throw Object.assign(new Error('unknown language'), { status: 422 }); - } - if (from === target) return { text, source: from, confidence }; - if (!have[toLt(from)]) throw Object.assign(new Error('unsupported language'), { status: 422 }); - const translated = await translatePieces(text, async (qs) => { - const r = await call('/translate', { q: qs, source: toLt(from), target: toLt(target), format: 'text' }); - return Array.isArray(r.translatedText) ? r.translatedText : [r.translatedText]; - }, target); - const out = { text: translated, source: from, confidence }; - cache.set(key, out); - if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value); - 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; -} |
