diff options
| -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); |
