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 --- tools/deepl-translate.test.js | 38 +++++++++++++++ tools/translate-italian-test.js | 101 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tools/deepl-translate.test.js create mode 100644 tools/translate-italian-test.js (limited to 'tools') 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); -- cgit v1.2.3