aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 10:47:18 -0700
committerAdam Soltys <[email protected]>2026-10-09 10:47:18 -0700
commit7e064d5c8b33f788bda2755a32e71e2fb01a00ba (patch)
tree66a3b32f07a98b5646ffc9b662362bc912eb7553
parent41d7b9d4e5d0e10cdac708d9c9cd4838e47240ec (diff)
downloadcoinosv3-7e064d5c8b33f788bda2755a32e71e2fb01a00ba.tar.gz
coinosv3-7e064d5c8b33f788bda2755a32e71e2fb01a00ba.zip
Translation: a hashtag in a script the reader can't read gets its translation in brackets after it — "#総合部絵チャ (…)" — the tag itself kept as the link it is. A post that is only such hashtags now counts as foreign (it was judged by its prose alone, of which it has none, so nothing ever offered to translate it), and ⋯ → Translate on a post with no words says so instead of "already in your language". The registrar's language detection reads the glossed tags too; lang-guess's scriptOf takes a minimum (a two-character tag has a script too).
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
-rw-r--r--names/translate.js10
-rw-r--r--src/features/messages.js16
-rw-r--r--src/i18n.js1
-rw-r--r--src/lang-guess.js6
-rw-r--r--src/translate-split.js40
-rw-r--r--tools/translate-test.js7
6 files changed, 58 insertions, 22 deletions
diff --git a/names/translate.js b/names/translate.js
index 7d6181d..5382711 100644
--- a/names/translate.js
+++ b/names/translate.js
@@ -3,7 +3,7 @@
// passes through here; the cache keys on the text itself, so a popular post
// is translated once whoever asks.
import { createHash } from 'node:crypto';
-import { splitProse, hasWords, translatePieces } from '../src/translate-split.js';
+import { translatable, translatePieces } from '../src/translate-split.js';
const LT = process.env.LT_URL || 'http://libretranslate:5000';
const MAX_CHARS = 5000;
@@ -48,8 +48,8 @@ export async function translate(text, target, source = 'auto') {
const key = createHash('sha256').update(target + '|' + source + '|' + text).digest('hex');
const hit = cache.get(key);
if (hit) { cache.delete(key); cache.set(key, hit); return hit; }
- const prose = splitProse(text).filter((p) => p.prose && hasWords(p.prose));
- if (!prose.length) return { text, source: source === 'auto' ? null : source };
+ const { prose, tags } = translatable(text, target);
+ if (!prose.length && !tags.length) return { text, source: source === 'auto' ? null : source, nothing: true };
await slot();
try {
const call = async (path, body) => {
@@ -63,7 +63,7 @@ export async function translate(text, target, source = 'auto') {
};
let from = source, confidence = null;
if (from === 'auto') {
- const d = await call('/detect', { q: prose.map((p) => p.prose).join('\n') });
+ const d = await call('/detect', { q: [...prose.map((p) => p.prose), ...tags.map((p) => p.keep.slice(1).replace(/_/g, ' '))].join('\n') });
from = norm(d[0]?.language) || null;
confidence = d[0]?.confidence ?? null;
if (!from) throw Object.assign(new Error('unknown language'), { status: 422 });
@@ -73,7 +73,7 @@ export async function translate(text, target, source = 'auto') {
const translated = await translatePieces(text, async (qs) => {
const r = await call('/translate', { q: qs, source: toLt(from), target: toLt(target), format: 'text' });
return Array.isArray(r.translatedText) ? r.translatedText : [r.translatedText];
- });
+ }, target);
const out = { text: translated, source: from, confidence };
cache.set(key, out);
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
diff --git a/src/features/messages.js b/src/features/messages.js
index 8596f23..133b0b3 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -44,7 +44,7 @@ import { sha256 } from '@noble/hashes/sha256';
import { base64urlnopad } from '@scure/base';
import { t, getLang } from '../i18n.js';
import { inLanguage, isMachinePost, guessLang } from '../lang-guess.js';
-import { translatePieces } from '../translate-split.js';
+import { translatePieces, translatable as transParts } from '../translate-split.js';
import { animateZap, warmZapSound } from '../zap-animation.js';
import { SIGNER_SILENT } from '../dm.js';
import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js';
@@ -4083,7 +4083,13 @@ export function messagesFeature(ctx) {
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
- const looksForeign = (ev) => translatable(ev) && canTranslateTo(getLang()) && !inLanguage(noteText(ev), getLang());
+ // 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 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
function shownTranslation(ev) {
@@ -4109,7 +4115,7 @@ export function messagesFeature(ctx) {
}
const tr = await browserTranslators.get(pair);
if (!tr) return null;
- return translatePieces(text, (qs) => Promise.all(qs.map((q) => tr.translate(q))));
+ return translatePieces(text, (qs) => Promise.all(qs.map((q) => tr.translate(q))), to);
}
async function translateNote(ev, { asked = false } = {}) {
const k = transKey(ev);
@@ -4131,13 +4137,13 @@ export function messagesFeature(ctx) {
});
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || 'translate ' + r.status);
- out = { text: String(j.text || ''), source: j.source || null };
+ out = { text: String(j.text || ''), source: j.source || null, nothing: !!j.nothing };
}
// already in the reader's language after all: nothing to show, and
// nothing to offer again
if (!out.text || out.source === to || out.text.trim() === text.trim()) {
translating.set(k, 'same');
- if (asked) toast(t('translateSame'));
+ if (asked) toast(t(out.nothing ? 'translateNothing' : 'translateSame'));
} else {
translations.set(k, out);
translating.delete(k);
diff --git a/src/i18n.js b/src/i18n.js
index a1b0a79..894012e 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -442,6 +442,7 @@ const STR = {
translatedFrom: 'Translated from {lang}',
translateShowOriginal: 'Show original',
translateSame: 'This post is already in your language',
+ translateNothing: 'There are no words in this post to translate',
translateFailed: 'Couldn’t translate this post',
translateTitle: 'Translation',
translateAutoToggle: 'Translate posts into {lang} automatically',
diff --git a/src/lang-guess.js b/src/lang-guess.js
index f688cac..1d60c59 100644
--- a/src/lang-guess.js
+++ b/src/lang-guess.js
@@ -15,7 +15,7 @@ const SCRIPTS = [
['han', /[一-鿿㐀-䶿]/g],
['hangul', /[가-힯ᄀ-ᇿ]/g],
];
-const LANG_SCRIPT = {
+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',
@@ -37,14 +37,14 @@ const STOPSETS = Object.fromEntries(Object.entries(STOP).map(([k, v]) => [k, new
export const proseOf = (text) => String(text || '')
.replace(/https?:\/\/\S+/g, ' ').replace(/nostr:[a-z0-9]+/gi, ' ').replace(/#[\p{L}\p{N}_]+/gu, ' ');
-export function scriptOf(text) {
+export function scriptOf(text, min = 3) {
let best = null, bestN = 0, total = 0;
for (const [name, re] of SCRIPTS) {
const n = (text.match(re) || []).length;
total += n;
if (n > bestN) { best = name; bestN = n; }
}
- return total < 3 ? null : best;
+ return total < min ? null : best;
}
// A language code from the app's list, or null when it cannot tell.
diff --git a/src/translate-split.js b/src/translate-split.js
index 78833da..95f6468 100644
--- a/src/translate-split.js
+++ b/src/translate-split.js
@@ -4,6 +4,8 @@
// / fot.jpg"), so only the prose between them is translated and the text is
// put back together around the untouched tokens. Shared by the registrar
// (names/translate.js) and the browser's own translator (messages.js).
+import { scriptOf, LANG_SCRIPT } from './lang-guess.js';
+
const KEEP = /(https?:\/\/\S+|nostr:[a-z0-9]+|:[a-z0-9_+-]{1,40}:|#[\p{L}\p{N}_]+|@[\w.-]+(?:@[\w.-]+)?|\b(?:ln|lightning:|cashu|bitcoin:|nevent|npub|nprofile|note|naddr)[a-z0-9]{20,}|\S{40,})/giu;
export function splitProse(text) {
@@ -20,16 +22,40 @@ export function splitProse(text) {
// a piece worth translating: has words, not just spaces and punctuation
export const hasWords = (s) => /\p{L}{2,}/u.test(s || '');
-// Translate the prose pieces (one call for all of them) and reassemble.
-// Each piece keeps its own leading/trailing whitespace — line breaks matter.
-export async function translatePieces(text, translateMany) {
+// A hashtag stays a hashtag (it's a link to its topic), but one written in
+// another script than the reader's means nothing to them: such a tag gets
+// its translation in brackets after it, "#総合部絵チャ (general art chat)".
+const tagWords = (tag) => tag.slice(1).replace(/_/g, ' ');
+export function glossable(tag, target) {
+ if (!tag.startsWith('#') || !target) return false;
+ const s = scriptOf(tagWords(tag), 1);
+ return !!s && !!LANG_SCRIPT[target] && s !== LANG_SCRIPT[target];
+}
+// What a translator gets to see of a post: its prose, then the words of the
+// hashtags the reader can't read (also what language detection runs on).
+export function translatable(text, target) {
const parts = splitProse(text);
- const prose = parts.filter((p) => p.prose && hasWords(p.prose));
- if (!prose.length) return text;
- const outs = await translateMany(prose.map((p) => p.prose.trim()));
+ return {
+ parts,
+ prose: parts.filter((p) => p.prose && hasWords(p.prose)),
+ tags: parts.filter((p) => p.keep && glossable(p.keep, target)),
+ };
+}
+
+// Translate the prose pieces and the glossed tags (one call for all of them)
+// and reassemble. Each piece keeps its own leading/trailing whitespace —
+// line breaks matter.
+export async function translatePieces(text, translateMany, target = null) {
+ const { parts, prose, tags } = translatable(text, target);
+ if (!prose.length && !tags.length) return text;
+ const outs = await translateMany([...prose.map((p) => p.prose.trim()), ...tags.map((p) => tagWords(p.keep))]);
prose.forEach((p, i) => {
const lead = p.prose.match(/^\s*/)[0], trail = p.prose.match(/\s*$/)[0];
p.out = lead + String(outs[i] ?? p.prose.trim()) + trail;
});
- return parts.map((p) => p.keep ?? p.out ?? p.prose).join('');
+ tags.forEach((p, i) => {
+ const g = String(outs[prose.length + i] ?? '').trim();
+ p.out = g && g.toLowerCase() !== tagWords(p.keep).toLowerCase() ? p.keep + ' (' + g + ')' : p.keep;
+ });
+ return parts.map((p) => p.out ?? p.keep ?? p.prose).join('');
}
diff --git a/tools/translate-test.js b/tools/translate-test.js
index 2760693..d3fe095 100644
--- a/tools/translate-test.js
+++ b/tools/translate-test.js
@@ -15,11 +15,13 @@ const now = Math.floor(Date.now() / 1000);
const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, AUTHOR.sk);
const ES = post('Hola a todos, hoy compré café con bitcoin en el mercado y fue muy fácil para todos los que estaban allí', 100);
const JA = post('ビットコインは素晴らしいです。明日また会いましょう。', 200);
+const TAG = post('#総合部絵チャ', 250);
const EN = post('An ordinary post in English about the weather and the market today', 300);
const ANSWERS = {
[ES.content]: { text: 'Hello everyone, today I bought coffee with bitcoin at the market and it was very easy for everyone there', source: 'es' },
[JA.content]: { text: 'Bitcoin is great. Let’s meet again tomorrow.', source: 'ja' },
[EN.content]: { text: EN.content, source: 'en' },
+ [TAG.content]: { text: '#総合部絵チャ (general art chat)', source: 'ja' },
};
const html = await buildHtml({ minify: true, pwa: false });
@@ -78,7 +80,7 @@ try {
localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Globetrotter', t: Date.now() } }));
localStorage.setItem('btc-wallet-feed:' + k, 'following');
- }, [base, AUTHOR.pk, [ES, JA, EN]]);
+ }, [base, AUTHOR.pk, [ES, JA, TAG, EN]]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await openFeed();
@@ -87,8 +89,9 @@ try {
check('the Spanish post is shown in English', await waitText('today I bought coffee with bitcoin', 10000) && !(await has('compré café')));
check('...saying where it came from', /Translated from Spanish/.test(await row('today I bought coffee') || ''), (await row('today I bought coffee') || '').split('\n').slice(-6).join(' | '));
check('the Japanese post too', await waitText('Bitcoin is great', 5000) && /Translated from Japanese/.test(await row('Bitcoin is great') || ''));
+ check('a post that is only a Japanese hashtag gets its tag glossed, the tag kept', await waitText('#総合部絵チャ (general art chat)', 5000));
const asked = await page.evaluate(() => window.__tr.map((b) => b.text.slice(0, 12) + '>' + b.target));
- check('the English post is left alone (never sent)', asked.length === 2 && !asked.some((x) => x.startsWith('An ordinary')), asked.join(', '));
+ check('the English post is left alone (never sent)', asked.length === 3 && !asked.some((x) => x.startsWith('An ordinary')), asked.join(', '));
await page.evaluate(() => { const r = [...document.querySelectorAll('[data-zap-post]')].find((n) => n.textContent.includes('today I bought coffee')); [...r.querySelectorAll('.note-translate-btn')].find((b) => /original/i.test(b.textContent)).click(); });
await sleep(400);
check('Show original brings the Spanish back', await has('compré café') && !(await has('today I bought coffee')));