aboutsummaryrefslogtreecommitdiffstats
path: root/names
diff options
context:
space:
mode:
Diffstat (limited to 'names')
-rw-r--r--names/server.js10
-rw-r--r--names/translate.js2
-rw-r--r--names/vendor/lang-guess.js99
-rw-r--r--names/vendor/translate-split.js61
4 files changed, 166 insertions, 6 deletions
diff --git a/names/server.js b/names/server.js
index dacbda6..9d94b51 100644
--- a/names/server.js
+++ b/names/server.js
@@ -26,13 +26,13 @@ import { createHash, randomBytes } from 'node:crypto';
import { HDKey } from '@scure/bip32';
import { mnemonicToSeedSync } from '@scure/bip39';
import { lnBackend } from '../bridge/ln.js';
-import { ArkManager } from '../src/ark/manager.js';
+import { ArkManager } from '../coinosv3/src/ark/manager.js';
import { fetchPreview } from './preview.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';
-import { decodeOffer } from '../src/bolt12.js';
+import { decodeAddress } from '../coinosv3/src/ark/proto.js';
+import { decodeNoffer } from '../coinosv3/src/noffer.js';
+import { decodeBolt11 } from '../coinosv3/src/ark/lightning.js';
+import { decodeOffer } from '../coinosv3/src/bolt12.js';
import { registrationIdentity } from './ownership.js';
const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG
diff --git a/names/translate.js b/names/translate.js
index 36ab8cf..bcc47d9 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 { translatable, translatePieces } from '../src/translate-split.js';
+import { translatable, translatePieces } from './vendor/translate-split.js';
const LT = process.env.LT_URL || 'http://libretranslate:5000';
const MAX_CHARS = 5000;
diff --git a/names/vendor/lang-guess.js b/names/vendor/lang-guess.js
new file mode 100644
index 0000000..6d62e30
--- /dev/null
+++ b/names/vendor/lang-guess.js
@@ -0,0 +1,99 @@
+// A rough guess at the language a post is written in, cheap enough to run
+// on every post of a public feed. Script first (a post in Han or Arabic
+// letters is not English), then, among the Latin-script languages the app
+// speaks, a handful of function words each. Short or ambiguous text is
+// "unknown", and unknown is never held against a post.
+const SCRIPTS = [
+ ['latin', /[A-Za-zÀ-ɏḀ-ỿ]/g],
+ ['cyrillic', /[Ѐ-ӿ]/g],
+ ['arabic', /[؀-ۿݐ-ݿ]/g],
+ ['devanagari', /[ऀ-ॿ]/g],
+ ['bengali', /[ঀ-৿]/g],
+ ['tamil', /[஀-௿]/g],
+ ['telugu', /[ఀ-౿]/g],
+ ['kana', /[぀-ヿ]/g],
+ ['han', /[一-鿿㐀-䶿]/g],
+ ['hangul', /[가-힯ᄀ-ᇿ]/g],
+];
+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(' '),
+ es: 'el la de que y en los es un una por con para las del se no como más pero'.split(' '),
+ fr: 'le la les de des et est un une pour que qui dans pas sur avec ce il nous vous'.split(' '),
+ de: 'der die und das ist nicht ein eine ich zu mit den auf für sich dem des auch wir'.split(' '),
+ pt: 'o a de que e do da em um uma para com não os as se mais por isso você'.split(' '),
+ id: 'yang dan di ini itu dengan untuk tidak dari ada saya akan ke juga bisa kita sudah atau kalau aku'.split(' '),
+ 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)]));
+
+// The text a reader actually reads: no links, no nostr references, no tags.
+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, 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 < min ? null : best;
+}
+
+// A language code from the app's list, or null when it cannot tell.
+export function guessLang(text) {
+ const prose = proseOf(text);
+ const script = scriptOf(prose);
+ if (!script) return null;
+ if (script === 'kana') return 'ja';
+ if (script === 'han') return 'zh';
+ if (script !== 'latin') {
+ const langs = Object.entries(LANG_SCRIPT).filter(([, s]) => s === script).map(([l]) => l);
+ return langs.length === 1 ? langs[0] : null; // ar/ur, hi/mr share a script: unknown between them
+ }
+ const words = prose.toLowerCase().match(/[\p{L}']+/gu) || [];
+ if (words.length < 4) return null;
+ const score = {};
+ for (const w of words) for (const [l, set] of Object.entries(STOPSETS)) if (set.has(w)) score[l] = (score[l] || 0) + 1;
+ const ranked = Object.entries(score).sort((a, b) => b[1] - a[1]);
+ if (!ranked.length || ranked[0][1] < 2) return null;
+ if (ranked[1] && ranked[0][1] < ranked[1][1] * 1.5) return null;
+ return ranked[0][0];
+}
+
+// Does a post read in this language, as far as we can tell? Unknown passes;
+// a wrong script or a clear other language fails.
+export function inLanguage(text, lang) {
+ const want = LANG_SCRIPT[lang];
+ if (!want) return true;
+ const prose = proseOf(text);
+ const script = scriptOf(prose);
+ if (!script) return true; // nothing readable to judge (a picture post, an emoji)
+ if (script !== want) return false;
+ if (want === 'han' && lang === 'zh' && /[぀-ヿ]/.test(prose)) return false; // kana means Japanese
+ const g = guessLang(prose);
+ return g === null || g === lang;
+}
+
+// Machine output dressed as a post: JSON, a hash dump, a wall of symbols.
+export function isMachinePost(text) {
+ const s = String(text || '').trim();
+ if (!s) return false;
+ if (/^[\[{]/.test(s)) { try { JSON.parse(s); return true; } catch {} }
+ const prose = proseOf(s).replace(/\s+/g, '');
+ if (prose.length < 24) return false;
+ const letters = (prose.match(/\p{L}/gu) || []).length;
+ return letters / prose.length < 0.4;
+}
diff --git a/names/vendor/translate-split.js b/names/vendor/translate-split.js
new file mode 100644
index 0000000..95f6468
--- /dev/null
+++ b/names/vendor/translate-split.js
@@ -0,0 +1,61 @@
+// What must come back from a translation exactly as it went in: links,
+// nostr: mentions, custom emoji shortcodes, hashtags, @handles, invoices and
+// other long codes. Translation models mangle them ("https: / / example.com
+// / 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) {
+ const parts = [];
+ let last = 0;
+ for (const m of String(text || '').matchAll(KEEP)) {
+ if (m.index > last) parts.push({ prose: text.slice(last, m.index) });
+ parts.push({ keep: m[0] });
+ last = m.index + m[0].length;
+ }
+ if (last < text.length) parts.push({ prose: text.slice(last) });
+ return parts;
+}
+// a piece worth translating: has words, not just spaces and punctuation
+export const hasWords = (s) => /\p{L}{2,}/u.test(s || '');
+
+// 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);
+ 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;
+ });
+ 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('');
+}