From 7e064d5c8b33f788bda2755a32e71e2fb01a00ba Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 9 Oct 2026 10:47:18 -0700 Subject: 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). 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 --- src/features/messages.js | 16 +++++++++++----- src/i18n.js | 1 + src/lang-guess.js | 6 +++--- src/translate-split.js | 40 +++++++++++++++++++++++++++++++++------- 4 files changed, 48 insertions(+), 15 deletions(-) (limited to 'src') 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(''); } -- cgit v1.2.3