aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-10 08:39:24 -0700
committerAdam Soltys <[email protected]>2026-10-10 08:39:24 -0700
commit336fd6da53ace71af2c949e35edd9f0aac664df7 (patch)
treee2212759231fd6f02a397e1816bd1c5646d28614 /tools
parent2338345063b0f1c816469401264b0cd0d7b7636b (diff)
downloadcoinosv3-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
Diffstat (limited to 'tools')
-rw-r--r--tools/deepl-translate.test.js38
-rw-r--r--tools/translate-italian-test.js101
2 files changed, 139 insertions, 0 deletions
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);