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 | |
| 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
| -rw-r--r-- | names/server.js | 8 | ||||
| -rw-r--r-- | names/translate.js | 41 | ||||
| -rw-r--r-- | src/features/messages.js | 75 | ||||
| -rw-r--r-- | src/i18n.js | 9 | ||||
| -rw-r--r-- | src/lang-guess.js | 5 | ||||
| -rw-r--r-- | tools/deepl-translate.test.js | 38 | ||||
| -rw-r--r-- | tools/translate-italian-test.js | 101 |
7 files changed, 261 insertions, 16 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; +} 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)])); diff --git a/tools/deepl-translate.test.js b/tools/deepl-translate.test.js new file mode 100644 index 0000000..39975d0 --- /dev/null +++ b/tools/deepl-translate.test.js @@ -0,0 +1,38 @@ +// names/translate.js translateDeepl: the reader's key goes to the right +// DeepL host as a DeepL-Auth-Key header, only the prose is sent (links and +// tags kept), the detected language comes back, and DeepL's refusals turn +// into statuses the client can word (401 bad key, 402 quota). +// Run: bun test tools/deepl-translate.test.js +import { test, expect } from 'bun:test'; +import { translateDeepl } from '../names/translate.js'; + +const fake = (status = 200, seen = []) => async (url, init) => { + const body = JSON.parse(init.body); + seen.push({ url, auth: init.headers.authorization, body }); + if (status !== 200) return new Response('{}', { status }); + return new Response(JSON.stringify({ translations: body.text.map((t) => ({ text: '[it] ' + t, detected_source_language: 'EN' })) }), { status: 200 }); +}; + +test('a free key goes to api-free, as DeepL-Auth-Key, prose only', async () => { + const seen = []; + const out = await translateDeepl('Good morning everyone, look at this https://example.com/a.png #nostr', 'it', 'abcdef01-2345-6789-abcd-ef0123456789:fx', fake(200, seen)); + expect(seen[0].url).toBe('https://api-free.deepl.com/v2/translate'); + expect(seen[0].auth).toBe('DeepL-Auth-Key abcdef01-2345-6789-abcd-ef0123456789:fx'); + expect(seen[0].body.target_lang).toBe('IT'); + expect(seen[0].body.text.join(' ')).not.toContain('https://'); + expect(out.source).toBe('en'); + expect(out.text).toContain('[it] '); + expect(out.text).toContain('https://example.com/a.png'); + expect(out.text).toContain('#nostr'); +}); +test('a paid key goes to api.deepl.com; English means EN-US', async () => { + const seen = []; + await translateDeepl('Buongiorno a tutti, come state oggi?', 'en', 'abcdef01-2345-6789-abcd-ef0123456780', fake(200, seen)); + expect(seen[0].url).toBe('https://api.deepl.com/v2/translate'); + expect(seen[0].body.target_lang).toBe('EN-US'); +}); +test('refusals: bad key 401, quota 402', async () => { + await expect(translateDeepl('Hello there my friends, how are you', 'it', 'abcdef01-2345-6789-abcd-ef0123456781:fx', fake(403))).rejects.toMatchObject({ status: 401 }); + await expect(translateDeepl('Hello there my friends, how are you all', 'it', 'abcdef01-2345-6789-abcd-ef0123456781:fx', fake(456))).rejects.toMatchObject({ status: 402 }); + await expect(translateDeepl('Hello', 'it', 'not a key')).rejects.toMatchObject({ status: 401 }); +}); diff --git a/tools/translate-italian-test.js b/tools/translate-italian-test.js new file mode 100644 index 0000000..ab26e48 --- /dev/null +++ b/tools/translate-italian-test.js @@ -0,0 +1,101 @@ +// Translating into the phone's language: isolabellart's phone is Italian, +// coinos has no Italian UI (so it ran in English), and posts were never +// translated for them — the target was the app's language, and the guess +// didn't know Italian. Now an English post is translated into Italian on an +// Italian phone; an Italian one is left alone; Settings → Translation names +// the target and takes a DeepL key, which then rides with each request. +// The registrar's /translate is stubbed (window.fetch shim). +// Run: bun tools/translate-italian-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const AUTHOR = (() => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; })(); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, AUTHOR.sk); +const EN = post('I think that the market is going to be very busy today and you should come with us', 100); +const IT = post('Penso che il mercato sarà molto affollato oggi e anche tu dovresti venire con noi per il caffè', 200); +const EN2 = post('This is another post in English that is not yet translated for you and the others', 300); +const ANSWERS = { + [EN.content]: { text: 'Penso che il mercato sarà molto affollato oggi e dovresti venire con noi', source: 'en' }, + [EN2.content]: { text: 'Questo è un altro post in inglese', source: 'en' }, +}; +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5317, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument((answers) => { + Object.defineProperty(navigator, 'languages', { get: () => ['it-IT', 'it', 'en'] }); + Object.defineProperty(navigator, 'language', { get: () => 'it-IT' }); + window.__tr = []; + try { delete self.Translator; } catch {} + const real = window.fetch; + window.fetch = async (url, opts) => { + if (String(url) === 'https://names.coinos.io/translate') { + const body = JSON.parse(opts.body); + window.__tr.push({ ...body, deepl: (opts.headers || {})['x-deepl-key'] || null }); + const a = answers[body.text]; + return new Response(JSON.stringify(a || { error: 'nope' }), { status: a ? 200 : 503, headers: { 'content-type': 'application/json' } }); + } + return real(url, opts); + }; + const Real = window.WebSocket; + window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}, ANSWERS); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const text = () => page.evaluate(() => document.body.innerText); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5317/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { localStorage.setItem('btc-wallet-network', 'regtest'); localStorage.setItem('btc-wallet-lang', 'en'); }); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Mixed', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR.pk, [EN, IT]]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click()); + + console.log('\n[an Italian phone, coinos in English]'); + check('the English post is shown in Italian', await waitText('Penso che il mercato sarà molto affollato oggi e dovresti', 15000)); + const asked = await page.evaluate(() => window.__tr); + check('...asked of the registrar with target it', asked.some((b) => b.target === 'it' && b.text === EN.content), JSON.stringify(asked.map((b) => b.target))); + check('the Italian post is left alone', !asked.some((b) => b.text === IT.content)); + + console.log('\n[Settings → Translation, with a DeepL key]'); + await page.evaluate(() => { const b = [...document.querySelectorAll('button, a')].find((x) => x.getAttribute('aria-label') === 'Settings' || x.title === 'Settings'); b && b.click(); }); + await sleep(800); + await click('nostr'); await sleep(1000); + check('the card names Italian as the target', await page.evaluate(() => { const s = document.querySelector('.translate-to'); return !!s && s.value === 'it'; })); + await page.evaluate(() => document.querySelector('.deepl-key')?.scrollIntoView()); + await page.type('.deepl-key', 'abcdef01-2345-6789-abcd-ef0123456789:fx'); + await page.evaluate(() => document.querySelector('.deepl-save').click()); + check('the key is taken', await waitText('Using your DeepL key', 4000)); + await page.evaluate(() => { window.__tr = []; }); + await page.evaluate(([k, ns]) => localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)), [base, [EN2, EN, IT]]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click()); + await waitText('Questo è un altro post', 15000); + const asked2 = await page.evaluate(() => window.__tr); + check('translations now carry the DeepL key', asked2.length > 0 && asked2.every((b) => b.deepl === 'abcdef01-2345-6789-abcd-ef0123456789:fx'), JSON.stringify(asked2.map((b) => !!b.deepl))); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ translations into the phone\'s language, and DeepL' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
