From 336fd6da53ace71af2c949e35edd9f0aac664df7 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 10 Oct 2026 08:39:24 -0700 Subject: 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. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3 --- src/features/messages.js | 75 +++++++++++++++++++++++++++++++++++++++--------- src/i18n.js | 9 ++++++ src/lang-guess.js | 5 ++++ 3 files changed, 76 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index be5f785..ff02304 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4149,6 +4149,7 @@ export function messagesFeature(ctx) { const TRANSLATE_API = 'https://names.coinos.io/translate'; const TRANSLATE_KEEP = 300; const AUTO_TRANSLATE_KEY = 'coinos-auto-translate'; + let deeplWarned = false; const autoTranslateOn = () => { try { return localStorage.getItem(AUTO_TRANSLATE_KEY) !== '0'; } catch { return true; } }; const translations = new Map(); // key -> { text, source } (done) — remembered const translating = new Map(); // key -> 'loading' | 'fail' @@ -4168,16 +4169,35 @@ export function messagesFeature(ctx) { // in those get no offer rather than one that always fails const TRANSLATE_NO = new Set(['sw', 'mr', 'ta', 'te']); const canTranslateTo = (lang) => !!lang && !TRANSLATE_NO.has(lang); - const transKey = (ev) => ev.id + '|' + getLang() + '|' + (editOf(ev)?.at || 0); + // The language posts are translated INTO: the reader's choice (Settings), + // else the phone's own language when the app has no such UI language + // (an Italian phone reads coinos in English, but wants posts in Italian), + // else the app's. + const TRANSLATE_TO_KEY = 'coinos-translate-to'; + const TRANSLATE_TARGETS = ['ar', 'bn', 'cs', 'da', 'de', 'el', 'en', 'es', 'fa', 'fi', 'fr', 'he', 'hi', 'hu', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'sv', 'th', 'tr', 'uk', 'ur', 'vi', 'zh']; + const APP_LANGS = new Set(['en', 'zh', 'hi', 'es', 'fr', 'ar', 'bn', 'pt', 'ru', 'ur', 'id', 'de', 'ja', 'sw', 'mr', 'te', 'tr', 'ta', 'vi', 'ko']); + function transLang() { + try { const v = localStorage.getItem(TRANSLATE_TO_KEY); if (v) return v; } catch {} + const phone = (typeof navigator !== 'undefined' && (navigator.languages || [navigator.language])[0] || '').toLowerCase().split(/[-_]/)[0]; + if (phone && !APP_LANGS.has(phone) && TRANSLATE_TARGETS.includes(phone)) return phone; + return getLang(); + } + // A DeepL API key of the reader's own (Jumble offers the same): kept on + // this device, sent with each request — DeepL won't answer a browser, so + // the registrar passes it on, never storing or logging it. + const DEEPL_KEY = 'coinos-deepl-key'; + const deeplKey = () => { try { return (localStorage.getItem(DEEPL_KEY) || '').trim(); } catch { return ''; } }; + const transKey = (ev) => ev.id + '|' + transLang() + (deeplKey() ? '|d' : '') + '|' + (editOf(ev)?.at || 0); const translatable = (ev) => !!ev && !ev.pending && (ev.kind === 1 || ev.kind === COMMENT); // foreign as far as the cheap guess can tell (a script, or a clear other // language); unknown is never translated unasked // A post that is only hashtags is judged by them: its tags in a script // the reader can't read are what wants translating (as glosses). const looksForeign = (ev) => { - if (!translatable(ev) || !canTranslateTo(getLang())) return false; - const text = noteText(ev), { prose, tags } = transParts(text, getLang()); - return prose.length ? !inLanguage(text, getLang()) : tags.length > 0; + const to = transLang(); + if (!translatable(ev) || !(deeplKey() || canTranslateTo(to))) return false; + const text = noteText(ev), { prose, tags } = transParts(text, to); + return prose.length ? !inLanguage(text, to) : tags.length > 0; }; const langName = (code) => { try { return new Intl.DisplayNames([getLang()], { type: 'language' }).of(code) || code; } catch { return code; } }; // the translation to SHOW for this post now, or null @@ -4214,18 +4234,24 @@ export function messagesFeature(ctx) { translating.set(k, 'loading'); scheduleRepaint(); const text = noteText(ev); - const to = getLang(); + const to = transLang(); + const dk = deeplKey(); try { let out = null; const guess = guessLang(text); - try { const t2 = await browserTranslate(text, guess, to); if (t2) out = { text: t2, source: guess }; } catch {} + // with a DeepL key, DeepL is what the reader chose: not the browser's + if (!dk) { try { const t2 = await browserTranslate(text, guess, to); if (t2) out = { text: t2, source: guess }; } catch {} } if (!out) { const r = await fetch(TRANSLATE_API, { - method: 'POST', headers: { 'content-type': 'application/json' }, + method: 'POST', headers: { 'content-type': 'application/json', ...(dk ? { 'x-deepl-key': dk } : {}) }, body: JSON.stringify({ text, target: to }), signal: AbortSignal.timeout(20_000), }); const j = await r.json().catch(() => ({})); - if (!r.ok) throw new Error(j.error || 'translate ' + r.status); + if (!r.ok) { + // a key DeepL refused, or out of quota: say so once, not "couldn't translate" on every post + if (dk && (r.status === 401 || r.status === 402) && !deeplWarned) { deeplWarned = true; toast(t(r.status === 401 ? 'deeplBadKey' : 'deeplQuota')); } + throw new Error(j.error || 'translate ' + r.status); + } out = { text: String(j.text || ''), source: j.source || null, nothing: !!j.nothing }; } // already in the reader's language after all: nothing to show, and @@ -9233,14 +9259,37 @@ export function messagesFeature(ctx) { } function translateCard() { - if (!canTranslateTo(getLang())) return null; + const to = transLang(); + const e = ui.deeplEdit || (ui.deeplEdit = { key: '' }); + const names = TRANSLATE_TARGETS.map((c) => [c, langName(c)]).sort((a, b) => a[1].localeCompare(b[1])); + const dk = deeplKey(); return h('div', { class: 'card col translate-card', style: 'gap:8px' }, h('h3', {}, t('translateTitle')), + h('label', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'grow' }, t('translateTo')), + h('select', { class: 'translate-to', style: 'width:auto', + onChange: (ev) => { try { localStorage.setItem(TRANSLATE_TO_KEY, ev.target.value); } catch {} render(); } }, + ...names.map(([c, n]) => h('option', { value: c, selected: c === to }, n)))), h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' }, h('input', { type: 'checkbox', class: 'auto-translate', checked: autoTranslateOn(), style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', - onChange: (e) => { try { localStorage.setItem(AUTO_TRANSLATE_KEY, e.target.checked ? '1' : '0'); } catch {} render(); } }), - h('span', {}, t('translateAutoToggle', { lang: langName(getLang()) }))), - h('div', { class: 'small faint' }, t('translateHelp'))); + onChange: (ev) => { try { localStorage.setItem(AUTO_TRANSLATE_KEY, ev.target.checked ? '1' : '0'); } catch {} render(); } }), + h('span', {}, t('translateAutoToggle', { lang: langName(to) }))), + h('div', { class: 'small faint' }, t(dk ? 'translateHelpDeepl' : 'translateHelp')), + // DeepL, with the reader's own key + dk + ? h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small grow' }, t('deeplOn', { key: '…' + dk.slice(-6) })), + h('button', { class: 'btn-sm deepl-remove', onClick: () => { try { localStorage.removeItem(DEEPL_KEY); } catch {} deeplWarned = false; render(); } }, t('deeplRemove'))) + : h('div', { class: 'row gap6' }, + h('input', { type: 'password', class: 'grow deepl-key', placeholder: t('deeplKeyPh'), value: e.key, autocomplete: 'off', spellcheck: 'false', + onInput: (ev) => { e.key = ev.target.value.trim(); } }), + h('button', { class: 'btn-sm deepl-save', onClick: () => { + if (!/^[0-9a-f-]{20,}(:fx)?$/i.test(e.key)) { toast(t('deeplBadKey')); return; } + try { localStorage.setItem(DEEPL_KEY, e.key); } catch {} + e.key = ''; deeplWarned = false; + toast(t('deeplSaved')); + render(); + } }, t('deeplSave')))); } function moderationCard() { @@ -9369,7 +9418,7 @@ export function messagesFeature(ctx) { typeof navigator !== 'undefined' && navigator.share ? item('\u2197', t('postShare'), () => { navigator.share({ url: noteLink(ev) }).catch(() => {}); }) : null, - translatable(ev) && canTranslateTo(getLang()) + translatable(ev) && (deeplKey() || canTranslateTo(transLang())) ? shownTranslation(ev) ? item('\u{1F310}', t('translateShowOriginal'), () => { const k = transKey(ev); transOriginal.add(k); transAsked.delete(k); render(); }) : item('\u{1F310}', t('translateBtn'), () => translateNote(ev, { asked: true })) diff --git a/src/i18n.js b/src/i18n.js index 2d2c976..17f1107 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -445,6 +445,15 @@ const STR = { translateNothing: 'There are no words in this post to translate', translateFailed: 'Couldn’t translate this post', translateTitle: 'Translation', + translateTo: 'Translate posts into', + translateHelpDeepl: 'Posts that look like they’re in another language are translated by DeepL with your own key. The key stays on this device; coinos’s server passes each request on to DeepL (DeepL doesn’t accept requests from browsers) and never keeps it.', + deeplKeyPh: 'DeepL API key (optional)', + deeplSave: 'Use DeepL', + deeplSaved: 'Translations now come from DeepL', + deeplOn: 'Using your DeepL key {key}', + deeplRemove: 'Remove', + deeplBadKey: 'That DeepL key wasn’t accepted', + deeplQuota: 'Your DeepL key is out of quota for this month', translateAutoToggle: 'Translate posts into {lang} automatically', translateHelp: 'Posts that look like they’re in another language are shown in yours, with a way back to the original. Translations come from your browser when it can, otherwise from coinos’s own LibreTranslate server — posts are public, but the text passes through it.', postEditTitle: 'Edit post', diff --git a/src/lang-guess.js b/src/lang-guess.js index 1d60c59..6d62e30 100644 --- a/src/lang-guess.js +++ b/src/lang-guess.js @@ -19,6 +19,8 @@ export const LANG_SCRIPT = { en: 'latin', es: 'latin', fr: 'latin', de: 'latin', pt: 'latin', id: 'latin', sw: 'latin', tr: 'latin', vi: 'latin', ru: 'cyrillic', ar: 'arabic', ur: 'arabic', hi: 'devanagari', mr: 'devanagari', bn: 'bengali', ta: 'tamil', te: 'telugu', ja: 'kana', ko: 'hangul', zh: 'han', + // not app languages, but languages people read posts in (a translation target) + it: 'latin', nl: 'latin', pl: 'latin', }; const STOP = { en: 'the and is to of in that it for you with this on are be have not was but they'.split(' '), @@ -30,6 +32,9 @@ const STOP = { tr: 've bir bu için ile de da çok ne gibi daha ama var ben sen mi değil kadar her olan'.split(' '), vi: 'và của là có không được một trong cho những này với các để người đã tôi khi cũng như'.split(' '), sw: 'na ya wa kwa ni za la katika hii kuwa kama hata lakini sana yake wote sasa tu huo hapa'.split(' '), + it: 'il di che è e la un una per non sono con del della gli anche ma questo mi più nel'.split(' '), + nl: 'de het een en van is dat niet op te zijn met voor ik je die maar ook wat'.split(' '), + pl: 'i w nie na się z to że jest do co jak ale tak o po czy już'.split(' '), }; const STOPSETS = Object.fromEntries(Object.entries(STOP).map(([k, v]) => [k, new Set(v)])); -- cgit v1.2.3