aboutsummaryrefslogtreecommitdiffstats
path: root/src/translate-split.js
blob: 95f64680ec484b9db6a6a3a8fc55a2940b36ec22 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
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('');
}