@khorsheed/dsh-reader 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.en.md +90 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +90 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +15119 -0
  6. package/lib/index.js +3660 -0
  7. package/lib/invariant.js +38 -0
  8. package/lib/typert.host.d.ts +3 -0
  9. package/lib/typert.host.js +1738 -0
  10. package/lib/typert.remote-client.d.ts +82 -0
  11. package/lib/typert.remote-client.js +1115 -0
  12. package/lib/types/arxiv.d.ts +47 -0
  13. package/lib/types/arxiv.js +76 -0
  14. package/lib/types/client/ReaderPane.d.ts +71 -0
  15. package/lib/types/client/ReaderPane.js +3135 -0
  16. package/lib/types/client/contract.d.ts +254 -0
  17. package/lib/types/client/contract.js +1 -0
  18. package/lib/types/client/definition.d.ts +27 -0
  19. package/lib/types/client/definition.js +32 -0
  20. package/lib/types/client/extract-article.d.ts +102 -0
  21. package/lib/types/client/extract-article.js +1024 -0
  22. package/lib/types/client/icons.d.ts +27 -0
  23. package/lib/types/client/icons.js +11 -0
  24. package/lib/types/client/index.d.ts +34 -0
  25. package/lib/types/client/index.js +222 -0
  26. package/lib/types/client/locales.d.ts +391 -0
  27. package/lib/types/client/locales.js +382 -0
  28. package/lib/types/client/parse-rss.d.ts +69 -0
  29. package/lib/types/client/parse-rss.js +656 -0
  30. package/lib/types/client/quote.d.ts +89 -0
  31. package/lib/types/client/quote.js +119 -0
  32. package/lib/types/client/selectors.d.ts +167 -0
  33. package/lib/types/client/selectors.js +413 -0
  34. package/lib/types/client/session.d.ts +171 -0
  35. package/lib/types/client/session.js +223 -0
  36. package/lib/types/client/store.d.ts +181 -0
  37. package/lib/types/client/store.js +226 -0
  38. package/lib/types/client/translate.d.ts +379 -0
  39. package/lib/types/client/translate.js +873 -0
  40. package/lib/types/index.d.ts +48 -0
  41. package/lib/types/index.js +25 -0
  42. package/lib/types/invariant.d.ts +29 -0
  43. package/lib/types/invariant.js +38 -0
  44. package/lib/types/link-resolvers.d.ts +107 -0
  45. package/lib/types/link-resolvers.js +299 -0
  46. package/lib/types/remote.d.ts +224 -0
  47. package/lib/types/remote.js +275 -0
  48. package/lib/types/schedule.d.ts +78 -0
  49. package/lib/types/schedule.js +114 -0
  50. package/lib/types/service.d.ts +633 -0
  51. package/lib/types/service.js +1697 -0
  52. package/lib/types/store.d.ts +224 -0
  53. package/lib/types/store.js +916 -0
  54. package/lib/types/types.d.ts +710 -0
  55. package/lib/types/types.js +182 -0
  56. package/locale/en.json +6 -0
  57. package/locale/zh.json +6 -0
  58. package/package.json +128 -0
@@ -0,0 +1,873 @@
1
+ /**
2
+ * The target tags to try, in order.
3
+ *
4
+ * Chrome's documented list spells Chinese `zh` (Simplified) and `zh-Hant`
5
+ * (Traditional), and `zh` is what works on the machines this was built on — but
6
+ * `Translator.create()` has been observed rejecting a pair that
7
+ * `availability()` had just blessed (`NotSupportedError`, "Unable to create
8
+ * translator for the given source and target language", macOS builds included).
9
+ * Trying the next spelling costs one rejected call and turns a dead button into
10
+ * a working one where a build prefers the other tag.
11
+ */
12
+ export const TARGET_CANDIDATES = ['zh', 'zh-Hans'];
13
+ /** The unit separator inside a batch. Rare, short, and verified on the way back. */
14
+ export const UNIT_SEPARATOR = '⟦|⟧';
15
+ /** Stop packing a batch once it holds this many characters (or units). */
16
+ const BATCH_MAX_CHARS = 1200;
17
+ const BATCH_MAX_UNITS = 12;
18
+ /** How many remembered sentences the translation memory keeps before evicting. */
19
+ const MEMORY_MAX_ENTRIES = 4000;
20
+ /** `NodeFilter.SHOW_TEXT`, spelled numerically so no global is required. */
21
+ const SHOW_TEXT = 4;
22
+ /** Tokens that end in a period without ending a sentence. */
23
+ const ABBREVIATIONS = new Set([
24
+ 'mr', 'mrs', 'ms', 'dr', 'prof', 'sr', 'jr', 'st', 'vs', 'etc', 'e.g', 'i.e', 'fig', 'no',
25
+ 'inc', 'ltd', 'co', 'corp', 'dept', 'univ', 'approx', 'cf', 'ed', 'eds', 'est', 'vol',
26
+ 'ch', 'sec', 'pp', 'u.s', 'u.k', 'u.n', 'al', 'eq', 'ref',
27
+ ]);
28
+ /** Read the page's Translator API, or null when this browser has none. */
29
+ export function detectTranslator(scope = globalThis) {
30
+ const candidate = scope.Translator;
31
+ if (candidate === undefined || candidate === null)
32
+ return null;
33
+ const api = candidate;
34
+ if (typeof api.availability !== 'function' || typeof api.create !== 'function')
35
+ return null;
36
+ return candidate;
37
+ }
38
+ /** Read the page's Language Detector, or null when this browser has none. */
39
+ export function detectLanguageDetector(scope = globalThis) {
40
+ const candidate = scope.LanguageDetector;
41
+ if (candidate === undefined || candidate === null)
42
+ return null;
43
+ const api = candidate;
44
+ if (typeof api.availability !== 'function' || typeof api.create !== 'function')
45
+ return null;
46
+ return candidate;
47
+ }
48
+ /**
49
+ * The body's source language: the browser's detector when it has one, else the
50
+ * caller's script guess.
51
+ *
52
+ * The probe is only worth it because a WRONG source is fatal in a way the
53
+ * reader cannot see: `create()` rejects for a pair the device does not have, and
54
+ * the error says "given source and target language" without naming either. The
55
+ * heuristic that decides this in the pane (`isCjk`) is a script test, not a
56
+ * language test — it cannot tell German from English.
57
+ *
58
+ * @param text - the body's leading text (a sample is enough).
59
+ * @param fallback - the caller's guess, used when detection is unavailable or unsure.
60
+ * @returns the BCP-47 tag to ask the translator for.
61
+ */
62
+ export async function detectSourceLanguage(text, fallback) {
63
+ const detector = detectLanguageDetector();
64
+ if (detector === null)
65
+ return fallback;
66
+ const sample = text.slice(0, 600).trim();
67
+ if (sample.length < 20)
68
+ return fallback;
69
+ try {
70
+ const state = await detector.availability();
71
+ if (state === 'unavailable')
72
+ return fallback;
73
+ const session = await detector.create();
74
+ const results = await session.detect(sample);
75
+ session.destroy?.();
76
+ const best = results[0];
77
+ if (best !== undefined && best.confidence >= 0.5 && typeof best.detectedLanguage === 'string') {
78
+ return best.detectedLanguage;
79
+ }
80
+ }
81
+ catch {
82
+ // Degrade to the caller's guess: a failed detection must never block a
83
+ // translation that would otherwise work.
84
+ }
85
+ return fallback;
86
+ }
87
+ /** True when the browser says this pair is not something it can ever build. */
88
+ export function isUnsupported(error) {
89
+ if (typeof DOMException !== 'undefined' && error instanceof DOMException && error.name === 'NotSupportedError')
90
+ return true;
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ return /given source and target|not supported|unsupported language/i.test(message);
93
+ }
94
+ /**
95
+ * Create a translator, trying every candidate pair in order.
96
+ *
97
+ * `availability()` is a hint, not a promise: this browser has been observed
98
+ * blessing a pair and then rejecting `create()` for it. So the pairs are tried
99
+ * for real, the failures are collected (the reader gets to see which pair was
100
+ * asked for), and `unsupported` says whether the browser rejected EVERY attempt
101
+ * as an unsupported language pair — which is a permanent condition, unlike a
102
+ * download or a quota failure.
103
+ *
104
+ * @param api - the page's Translator API.
105
+ * @param request - the source candidates, the target candidates, and the monitor.
106
+ * @returns the first working session, or the collected attempts.
107
+ */
108
+ export async function createSession(api, request) {
109
+ const attempts = [];
110
+ let unsupported = true;
111
+ let tried = 0;
112
+ for (const sourceLanguage of request.sources) {
113
+ for (const targetLanguage of request.targets) {
114
+ tried += 1;
115
+ try {
116
+ const state = await api.availability({ sourceLanguage, targetLanguage });
117
+ if (state === 'unavailable') {
118
+ attempts.push({ sourceLanguage, targetLanguage, reason: 'unavailable' });
119
+ continue;
120
+ }
121
+ }
122
+ catch (error) {
123
+ attempts.push({ sourceLanguage, targetLanguage, reason: error instanceof Error ? error.message : String(error) });
124
+ unsupported = false;
125
+ continue;
126
+ }
127
+ try {
128
+ const session = await api.create({ sourceLanguage, targetLanguage, ...(request.monitor === undefined ? {} : { monitor: request.monitor }) });
129
+ return { ok: true, session, sourceLanguage, targetLanguage };
130
+ }
131
+ catch (error) {
132
+ const reason = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
133
+ if (!isUnsupported(error))
134
+ unsupported = false;
135
+ attempts.push({ sourceLanguage, targetLanguage, reason });
136
+ }
137
+ }
138
+ }
139
+ return { ok: false, attempts, unsupported: unsupported && tried > 0 };
140
+ }
141
+ /**
142
+ * Split one run of text into sentence pieces.
143
+ *
144
+ * Conservative by construction: a split needs a terminator followed by
145
+ * whitespace (or the end), a token that is neither a known abbreviation nor a
146
+ * single initial, and a next character that does not continue a number. When
147
+ * any rule is unsure it does NOT split — one oversized unit only costs
148
+ * granularity, while a wrong split hands the model two broken half-sentences.
149
+ *
150
+ * @param text - the run's core text (already trimmed at both ends).
151
+ * @returns the pieces in order; a run with no terminator comes back as one.
152
+ */
153
+ export function splitSentences(text) {
154
+ if (text.length === 0)
155
+ return [];
156
+ const pieces = [];
157
+ let start = 0;
158
+ let i = 0;
159
+ while (i < text.length) {
160
+ const ch = text[i];
161
+ if (!'.!?。!?…'.includes(ch)) {
162
+ i += 1;
163
+ continue;
164
+ }
165
+ // A run of terminators ("?!", "…") ends at its last character.
166
+ let end = i;
167
+ while (end + 1 < text.length && '.!?。!?…'.includes(text[end + 1]))
168
+ end += 1;
169
+ const next = text[end + 1];
170
+ // Latin terminators need a following break, plus the abbreviation and
171
+ // decimal checks below. CJK terminators are unambiguous and are written
172
+ // without a following space, so they stand on their own.
173
+ if ('.!?…'.includes(ch)) {
174
+ if (next !== undefined && !/\s/.test(next)) {
175
+ i = end + 1;
176
+ continue;
177
+ }
178
+ const lastWord = /([A-Za-z][A-Za-z.]*)$/.exec(text.slice(start, end + 1))?.[1] ?? '';
179
+ const bare = lastWord.replace(/\.$/, '').toLowerCase();
180
+ if (ABBREVIATIONS.has(bare) || /^[a-z]$/.test(bare)) {
181
+ i = end + 1;
182
+ continue;
183
+ }
184
+ const after = text.slice(end + 1).trimStart()[0];
185
+ if (/\d$/.test(text[end]) && after !== undefined && /\d/.test(after)) {
186
+ i = end + 1;
187
+ continue;
188
+ }
189
+ }
190
+ // The piece keeps the whitespace that follows the terminator, so joining the
191
+ // pieces reproduces the run byte for byte.
192
+ let cut = end + 1;
193
+ while (cut < text.length && /\s/.test(text[cut]))
194
+ cut += 1;
195
+ pieces.push({ text: text.slice(start, cut), core: text.slice(start, end + 1).trim() });
196
+ start = cut;
197
+ i = cut;
198
+ }
199
+ if (start < text.length) {
200
+ const tail = text.slice(start);
201
+ pieces.push({ text: tail, core: tail.trim() });
202
+ }
203
+ return pieces.filter(piece => piece.core.length > 0);
204
+ }
205
+ /** The segmentation of each article root, so views can be re-applied cheaply. */
206
+ const BUILT = new WeakMap();
207
+ /** Translation memory: original sentence → translated sentence. */
208
+ const MEMORY = new Map();
209
+ /**
210
+ * The memory key for one sentence.
211
+ *
212
+ * The pair is part of the key: before the host-backed store existed, the page's
213
+ * memory keyed on the sentence ALONE, so a German title and its English
214
+ * homograph would have shared one translation. (The hash in the persisted tier
215
+ * is computed the same way per pair for the same reason.)
216
+ */
217
+ const memoryKey = (pair, text) => `${pair}\n${text}`;
218
+ /**
219
+ * The stable key fragment for one sentence (or a whole body).
220
+ *
221
+ * FNV-1a over 64 bits: synchronous, which the crypto API is not, and long
222
+ * enough that the memory's 50k entries never meet a collision (the birthday
223
+ * bound is ~2^32 entries). The host stores the digest opaquely — only the
224
+ * browser ever computes it.
225
+ */
226
+ export function translationHash(text) {
227
+ let hash = 0xcbf29ce484222325n;
228
+ for (let index = 0; index < text.length; index += 1) {
229
+ hash = (hash ^ BigInt(text.charCodeAt(index))) * 0x100000001b3n & 0xffffffffffffffffn;
230
+ }
231
+ return hash.toString(16).padStart(16, '0');
232
+ }
233
+ /** Blocks whose text is a code or data payload, never prose. */
234
+ const SKIP_ANCESTORS = 'pre, code, kbd, samp, [data-reader-no-translate]';
235
+ /** Elements that can host prose blocks. */
236
+ const BLOCK_SELECTOR = 'p, li, h1, h2, h3, h4, h5, h6, blockquote, dd, dt, figcaption, td, th';
237
+ /** True when a run carries prose worth translating. */
238
+ function translatable(core) {
239
+ if (core.length < 2)
240
+ return false;
241
+ if (!/[A-Za-z\u00c0-\u024f\u0400-\u04ff]/.test(core))
242
+ return false;
243
+ if (/^(https?:\/\/|www\.)\S+$/i.test(core))
244
+ return false;
245
+ return true;
246
+ }
247
+ /** The block a text node belongs to, and where its reveal should live. */
248
+ function blockFor(node) {
249
+ const parent = node.parentElement;
250
+ if (parent === null)
251
+ return null;
252
+ if (parent.closest(SKIP_ANCESTORS) !== null)
253
+ return null;
254
+ const found = parent.closest(BLOCK_SELECTOR);
255
+ if (found !== null) {
256
+ // A `p` or a heading cannot legally contain a block, so its reveal is a
257
+ // sibling; everything else (li, blockquote, td…) opens its own container.
258
+ const tag = found.tagName;
259
+ return { element: found, placement: tag === 'P' || /^H[1-6]$/.test(tag) ? 'sibling' : 'inside' };
260
+ }
261
+ // NO block between this run and the article root. `extract-article` really
262
+ // does emit a body as bare text (a one-line feed item), and skipping those
263
+ // runs meant a whole article silently refused to translate. The run's own
264
+ // parent is the block here, and the reveal follows the run's last sentence.
265
+ return { element: parent, placement: 'inline' };
266
+ }
267
+ /**
268
+ * Segment one rendered article into sentence spans.
269
+ *
270
+ * Idempotent: a second call for the same element returns the first result, so a
271
+ * view switch never double-wraps. Mutates the DOM under `root` (each prose text
272
+ * node is replaced by spans); {@link restoreArticle} undoes it completely.
273
+ *
274
+ * @param root - the container holding the normalized article markup.
275
+ * @param classes - the hashed class names the spans and reveals carry.
276
+ * @returns the segmentation, or null when there is nothing to translate.
277
+ */
278
+ export function buildArticle(root, classes) {
279
+ const existing = BUILT.get(root);
280
+ if (existing !== undefined)
281
+ return existing;
282
+ const walker = root.ownerDocument.createTreeWalker(root, SHOW_TEXT);
283
+ const nodes = [];
284
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode())
285
+ nodes.push(node);
286
+ const blocks = [];
287
+ const restores = [];
288
+ const blockIndex = new Map();
289
+ for (const node of nodes) {
290
+ const raw = node.nodeValue ?? '';
291
+ const lead = /^\s*/.exec(raw)?.[0] ?? '';
292
+ const tail = /\s*$/.exec(raw)?.[0] ?? '';
293
+ const core = raw.slice(lead.length, raw.length - tail.length);
294
+ if (!translatable(core))
295
+ continue;
296
+ const host = blockFor(node);
297
+ if (host === null)
298
+ continue;
299
+ const pieces = splitSentences(core);
300
+ if (pieces.length === 0)
301
+ continue;
302
+ const doc = root.ownerDocument;
303
+ const spans = [];
304
+ const segments = [];
305
+ // The original node STAYS in the DOM (holding the leading whitespace) so the
306
+ // restore path has something to write back to; the spans follow it.
307
+ node.nodeValue = lead;
308
+ let anchor = node;
309
+ for (const piece of pieces) {
310
+ const span = doc.createElement('span');
311
+ span.className = classes.unit;
312
+ span.setAttribute('data-reader-unit', '1');
313
+ span.textContent = piece.text;
314
+ anchor.after(span);
315
+ anchor = span;
316
+ spans.push(span);
317
+ const pieceTail = piece.text.slice(piece.core.length);
318
+ segments.push({
319
+ span,
320
+ original: piece.core,
321
+ lead: segments.length === 0 ? lead : '',
322
+ tail: pieceTail,
323
+ gap: segments.length === pieces.length - 1 ? tail : '',
324
+ translated: null,
325
+ open: false,
326
+ });
327
+ }
328
+ const tailNode = doc.createTextNode(tail);
329
+ anchor.after(tailNode);
330
+ let block = blockIndex.get(host.element);
331
+ if (block === undefined) {
332
+ block = { element: host.element, placement: host.placement, segments: [], flat: '', sentences: [], reveal: null };
333
+ blockIndex.set(host.element, block);
334
+ blocks.push(block);
335
+ }
336
+ block.segments.push(...segments);
337
+ restores.push({ node, lead, core, tail, tailNode, spans });
338
+ }
339
+ if (blocks.length === 0)
340
+ return null;
341
+ // Flatten each block's ORIGINAL prose and re-cut it into sentences. Inline
342
+ // markup (a link inside a sentence) split that sentence into several units;
343
+ // the reveal must still show the WHOLE sentence, or the original reads as
344
+ // fragments — the shape the reader reported as "sparse".
345
+ for (const block of blocks) {
346
+ let flat = '';
347
+ for (const segment of block.segments) {
348
+ flat += segment.lead;
349
+ flat += segment.original;
350
+ flat += segment.tail;
351
+ flat += segment.gap;
352
+ }
353
+ const sentences = [];
354
+ let cursor = 0;
355
+ for (const piece of splitSentences(flat)) {
356
+ const end = cursor + piece.text.length;
357
+ sentences.push({ start: cursor, end });
358
+ cursor = end;
359
+ }
360
+ if (sentences.length === 0)
361
+ sentences.push({ start: 0, end: flat.length });
362
+ block.flat = flat;
363
+ block.sentences = sentences;
364
+ }
365
+ const built = { root, blocks, restores, view: 'trans' };
366
+ BUILT.set(root, built);
367
+ return built;
368
+ }
369
+ /** Take the segmentation back out, restoring every original text node. */
370
+ export function restoreArticle(root) {
371
+ const built = BUILT.get(root);
372
+ if (built === undefined)
373
+ return;
374
+ for (const record of built.restores) {
375
+ for (const span of record.spans)
376
+ span.remove();
377
+ record.tailNode.remove();
378
+ record.node.nodeValue = record.lead + record.core + record.tail;
379
+ }
380
+ for (const block of built.blocks)
381
+ block.reveal?.remove();
382
+ BUILT.delete(root);
383
+ root.removeAttribute('data-reader-translated');
384
+ }
385
+ /** The text one segment shows in a view, whitespace included. */
386
+ function segmentText(segment, view) {
387
+ if (view === 'orig')
388
+ return segment.original + segment.tail;
389
+ return (segment.translated ?? segment.original) + segment.tail;
390
+ }
391
+ /** Where one unit starts inside its block's flattened original text. */
392
+ function offsetOf(block, segment) {
393
+ let offset = 0;
394
+ for (const candidate of block.segments) {
395
+ if (candidate === segment)
396
+ return offset + candidate.lead.length;
397
+ offset += candidate.lead.length + candidate.original.length + candidate.tail.length + candidate.gap.length;
398
+ }
399
+ return offset;
400
+ }
401
+ /** The sentence one unit belongs to. */
402
+ function sentenceIndexOf(block, segment) {
403
+ const offset = offsetOf(block, segment);
404
+ const index = block.sentences.findIndex(sentence => offset >= sentence.start && offset < sentence.end);
405
+ return index === -1 ? 0 : index;
406
+ }
407
+ /** The sentences of a block that a set of units belongs to, in order. */
408
+ function sentencesFor(block, segments) {
409
+ const indexes = new Set();
410
+ for (const segment of segments) {
411
+ const offset = offsetOf(block, segment);
412
+ let index = block.sentences.findIndex(sentence => offset >= sentence.start && offset < sentence.end);
413
+ if (index === -1)
414
+ index = 0;
415
+ indexes.add(index);
416
+ }
417
+ return [...indexes].sort((left, right) => left - right);
418
+ }
419
+ /** Render one block's reveal container for the sentences currently shown. */
420
+ function paintReveal(block, view, classes) {
421
+ const shown = view === 'both' ? block.sentences.map((_, index) => index) : sentencesFor(block, block.segments.filter(segment => segment.open));
422
+ if (shown.length === 0 || view === 'orig') {
423
+ block.reveal?.remove();
424
+ block.reveal = null;
425
+ return;
426
+ }
427
+ const doc = block.element.ownerDocument;
428
+ const reveal = block.reveal ?? doc.createElement('div');
429
+ reveal.className = classes.reveal;
430
+ reveal.setAttribute('data-reader-reveal', '1');
431
+ // The spacing depends on WHERE the container sits: a sibling of a paragraph
432
+ // can be pulled up into that paragraph's bottom margin, while one inside a
433
+ // list item has nothing to collapse with and would be dragged into the
434
+ // translation line (reported from the bulleted case).
435
+ reveal.setAttribute('data-placement', block.placement);
436
+ reveal.textContent = '';
437
+ for (const index of shown) {
438
+ const sentence = block.sentences[index];
439
+ if (sentence === undefined)
440
+ continue;
441
+ const line = doc.createElement('p');
442
+ line.className = classes.line;
443
+ line.textContent = block.flat.slice(sentence.start, sentence.end).trim();
444
+ line.setAttribute('data-reader-sentence', String(index));
445
+ // The pairing mark: this line is the original of an OPENED translation, so
446
+ // clicking a sentence lights both sides of the pair up.
447
+ const opened = block.segments.some(segment => segment.open && sentencesFor(block, [segment]).includes(index));
448
+ if (opened)
449
+ line.setAttribute('data-open', '1');
450
+ reveal.append(line);
451
+ }
452
+ if (block.reveal === null) {
453
+ block.reveal = reveal;
454
+ if (block.placement === 'sibling')
455
+ block.element.after(reveal);
456
+ else if (block.placement === 'inside')
457
+ block.element.append(reveal);
458
+ else
459
+ block.segments[block.segments.length - 1]?.span.after(reveal);
460
+ }
461
+ }
462
+ /** Paint the spans and the reveals for one view. */
463
+ export function setView(built, view, classes) {
464
+ built.view = view;
465
+ const anyTranslated = built.blocks.some(block => block.segments.some(segment => segment.translated !== null));
466
+ for (const block of built.blocks) {
467
+ for (const segment of block.segments) {
468
+ const text = segmentText(segment, view);
469
+ if (segment.span.textContent !== text)
470
+ segment.span.textContent = text;
471
+ if (segment.open)
472
+ segment.span.setAttribute('data-open', '1');
473
+ else
474
+ segment.span.removeAttribute('data-open');
475
+ // The pair is addressed by SENTENCE, not by unit: a sentence cut into
476
+ // fragments still has one original line, and hovering any of its
477
+ // fragments must light that one line.
478
+ segment.span.setAttribute('data-reader-sentence', String(sentenceIndexOf(block, segment)));
479
+ }
480
+ paintReveal(block, view, classes);
481
+ }
482
+ // `pending` until the first unit lands: the translated typography (CJK
483
+ // leading) must not be applied to text that is still English.
484
+ built.root.setAttribute('data-reader-translated', anyTranslated ? view : 'pending');
485
+ }
486
+ /** The units of one block sentence, in order. */
487
+ function spansOfSentence(block, index) {
488
+ return block.segments
489
+ .filter(segment => sentenceIndexOf(block, segment) === index)
490
+ .map(segment => segment.span);
491
+ }
492
+ /** The reveal line that shows one block sentence, when it is on screen. */
493
+ function lineOfSentence(block, index) {
494
+ if (block.reveal === null)
495
+ return null;
496
+ return block.reveal.querySelector(`[data-reader-sentence="${String(index)}"]`);
497
+ }
498
+ /**
499
+ * Mark (or clear) the pair under the pointer.
500
+ *
501
+ * The page is deliberately CLEAN by default: an expanded original carries no
502
+ * standing colour or bar, because a reader who explores a few sentences would
503
+ * otherwise leave the whole article speckled — reported as "the page gets
504
+ * messy". The pairing shows up on hover instead, from either side: point at a
505
+ * translated sentence and its original line lights up, point at an original
506
+ * line and the sentence it belongs to lights up.
507
+ *
508
+ * @param built - the live segmentation.
509
+ * @param target - what the pointer is over (or just left).
510
+ * @param on - true on hover, false when the pointer leaves.
511
+ */
512
+ export function setPairHover(built, target, on) {
513
+ if (!(target instanceof Element))
514
+ return;
515
+ const mark = (element) => {
516
+ if (element === null || element === undefined)
517
+ return;
518
+ if (on)
519
+ element.setAttribute('data-hover', '1');
520
+ else
521
+ element.removeAttribute('data-hover');
522
+ };
523
+ const unit = target.closest('[data-reader-unit]');
524
+ if (unit !== null) {
525
+ for (const block of built.blocks) {
526
+ const segment = block.segments.find(candidate => candidate.span === unit);
527
+ if (segment === undefined)
528
+ continue;
529
+ const index = sentenceIndexOf(block, segment);
530
+ for (const span of spansOfSentence(block, index))
531
+ mark(span);
532
+ mark(lineOfSentence(block, index));
533
+ return;
534
+ }
535
+ return;
536
+ }
537
+ const line = target.closest('[data-reader-sentence]');
538
+ if (line === null)
539
+ return;
540
+ for (const block of built.blocks) {
541
+ if (block.reveal === null || !block.reveal.contains(line))
542
+ continue;
543
+ const index = Number(line.getAttribute('data-reader-sentence'));
544
+ mark(line);
545
+ for (const span of spansOfSentence(block, index))
546
+ mark(span);
547
+ return;
548
+ }
549
+ }
550
+ /** The segment a click landed on, if any. */
551
+ export function segmentAt(built, target) {
552
+ if (!(target instanceof Element))
553
+ return null;
554
+ const span = target.closest('[data-reader-unit]');
555
+ if (span === null)
556
+ return null;
557
+ for (const block of built.blocks) {
558
+ const found = block.segments.find(segment => segment.span === span);
559
+ if (found !== undefined)
560
+ return found;
561
+ }
562
+ return null;
563
+ }
564
+ /** Flip one sentence's original open / closed (the click gesture). */
565
+ export function toggleSegment(built, segment, classes) {
566
+ segment.open = !segment.open;
567
+ if (segment.open)
568
+ segment.span.setAttribute('data-open', '1');
569
+ else
570
+ segment.span.removeAttribute('data-open');
571
+ for (const block of built.blocks) {
572
+ if (block.segments.includes(segment)) {
573
+ paintReveal(block, built.view, classes);
574
+ return;
575
+ }
576
+ }
577
+ }
578
+ /** Remember one translation, evicting oldest-first past the memory's cap. */
579
+ function remember(pair, text, translated) {
580
+ const key = memoryKey(pair, text);
581
+ if (text.length === 0 || MEMORY.has(key))
582
+ return;
583
+ MEMORY.set(key, translated);
584
+ if (MEMORY.size > MEMORY_MAX_ENTRIES) {
585
+ const oldest = MEMORY.keys().next().value;
586
+ if (oldest !== undefined)
587
+ MEMORY.delete(oldest);
588
+ }
589
+ }
590
+ /**
591
+ * Record a translated unit: in the DOM, and in the translation memory.
592
+ *
593
+ * @param pair - the `<src>→<tgt>` label this translation belongs to.
594
+ */
595
+ export function applyTranslation(built, segment, translated, pair) {
596
+ segment.translated = translated;
597
+ if (built.view !== 'orig')
598
+ segment.span.textContent = translated + segment.tail;
599
+ remember(pair, segment.original, translated);
600
+ }
601
+ /** A remembered translation for this sentence in this pair, when one exists. */
602
+ export function remembered(pair, text) {
603
+ return MEMORY.get(memoryKey(pair, text));
604
+ }
605
+ /**
606
+ * Pour translations the host persisted into the page's memory.
607
+ *
608
+ * The pane calls this before a translation run spends the model: every primed
609
+ * sentence paints from memory instead of a request. Entries already in the page
610
+ * are left alone (a fresher in-page write wins over the host's copy of it).
611
+ *
612
+ * @param pair - the pair label the translations belong to.
613
+ * @param entries - the sentences and their translations.
614
+ * @returns how many were new to the page.
615
+ */
616
+ export function primeMemory(pair, entries) {
617
+ let primed = 0;
618
+ for (const { text, translation } of entries) {
619
+ if (text.length === 0)
620
+ continue;
621
+ const key = memoryKey(pair, text);
622
+ if (MEMORY.has(key))
623
+ continue;
624
+ MEMORY.set(key, translation);
625
+ primed += 1;
626
+ }
627
+ while (MEMORY.size > MEMORY_MAX_ENTRIES) {
628
+ const oldest = MEMORY.keys().next().value;
629
+ if (oldest === undefined)
630
+ break;
631
+ MEMORY.delete(oldest);
632
+ }
633
+ return primed;
634
+ }
635
+ /** Drop the translation memory (the specs call this between cases). */
636
+ export function clearMemory() {
637
+ MEMORY.clear();
638
+ }
639
+ /* ------------------------------------------------------- text (not DOM) units */
640
+ /**
641
+ * True when a string is already (mostly) the target language, so translating it
642
+ * would be Chinese-into-Chinese.
643
+ *
644
+ * The wall is where this matters: a wall mixes Chinese and English entries, and
645
+ * one card can even mix them field by field (a Chinese title with an English
646
+ * summary). The ratio mirrors the pane's own script test, and it is deliberately
647
+ * the CHEAP synchronous answer — the alternative was a Language Detector round
648
+ * trip per field for a decision a character count already gets right.
649
+ *
650
+ * @param text - the field's text.
651
+ * @returns true when the field should be left alone.
652
+ */
653
+ export function isTargetLanguage(text) {
654
+ const trimmed = text.trim();
655
+ if (trimmed.length < 2)
656
+ return true;
657
+ const cjk = trimmed.match(/[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g);
658
+ return (cjk?.length ?? 0) / trimmed.length > 0.2;
659
+ }
660
+ /**
661
+ * One learned (or recalled) sentence, ready for the host's tiers.
662
+ *
663
+ * The hash is over the sentence alone; the pair joins the key on the HOST
664
+ * (`<src>→<tgt>:<hash>`), which is where the pair-keying invariant lives.
665
+ *
666
+ * @param source - the original sentence.
667
+ * @param target - its translation.
668
+ */
669
+ function learnOf(source, target) {
670
+ return { hash: translationHash(source), source, target };
671
+ }
672
+ /**
673
+ * Translate a list of whole strings — the wall's cards, where a field is the
674
+ * unit and there is no DOM to segment.
675
+ *
676
+ * The batching contract is the article's, unchanged: sentences joined by the
677
+ * separator, accepted only when the same number of parts comes back, and a
678
+ * rejected batch re-sent one string at a time so the fallback gives up
679
+ * granularity rather than correctness. Results land in the same translation
680
+ * memory the article uses, so a card title translated here is not translated
681
+ * again when the same entry is opened.
682
+ *
683
+ * @param texts - the strings to translate, in order.
684
+ * @param pair - the `<src>→<tgt>` label these translations belong to.
685
+ * @param session - a translator for this source language.
686
+ * @param cancelled - checked between batches.
687
+ * @param onProgress - called with (done, total) as results land.
688
+ * @returns the translations, the remembered count and the failures.
689
+ */
690
+ export async function translateTexts(texts, pair, session, cancelled, onProgress) {
691
+ const translated = new Map();
692
+ const unique = [...new Set(texts.filter(text => text.length > 0 && !isTargetLanguage(text)))];
693
+ const total = unique.length;
694
+ let done = 0;
695
+ let rememberedCount = 0;
696
+ let failed = 0;
697
+ const learned = [];
698
+ const recalled = [];
699
+ const report = () => { onProgress?.(done, total); };
700
+ const pending = [];
701
+ for (const text of unique) {
702
+ const hit = MEMORY.get(memoryKey(pair, text));
703
+ if (hit !== undefined) {
704
+ translated.set(text, hit);
705
+ recalled.push(learnOf(text, hit));
706
+ done += 1;
707
+ rememberedCount += 1;
708
+ }
709
+ else {
710
+ pending.push(text);
711
+ }
712
+ }
713
+ report();
714
+ let batch = [];
715
+ let chars = 0;
716
+ const flush = async () => {
717
+ if (batch.length === 0)
718
+ return;
719
+ const current = batch;
720
+ batch = [];
721
+ chars = 0;
722
+ if (cancelled())
723
+ return;
724
+ const parts = await translateBatch(current, session);
725
+ if (parts !== null) {
726
+ current.forEach((text, index) => {
727
+ const value = parts[index].trim();
728
+ translated.set(text, value);
729
+ remember(pair, text, value);
730
+ learned.push(learnOf(text, value));
731
+ done += 1;
732
+ });
733
+ report();
734
+ return;
735
+ }
736
+ for (const text of current) {
737
+ if (cancelled())
738
+ return;
739
+ try {
740
+ const value = (await session.translate(text)).trim();
741
+ translated.set(text, value);
742
+ remember(pair, text, value);
743
+ learned.push(learnOf(text, value));
744
+ done += 1;
745
+ }
746
+ catch {
747
+ failed += 1;
748
+ }
749
+ report();
750
+ }
751
+ };
752
+ for (const text of pending) {
753
+ if (cancelled())
754
+ break;
755
+ if (batch.length > 0 && (chars + text.length > BATCH_MAX_CHARS || batch.length >= BATCH_MAX_UNITS))
756
+ await flush();
757
+ if (cancelled())
758
+ break;
759
+ batch.push(text);
760
+ chars += text.length;
761
+ }
762
+ await flush();
763
+ return { translated, remembered: rememberedCount, failed, learned, recalled };
764
+ }
765
+ /**
766
+ * One batched request, or null when the separator did not survive (the caller
767
+ * falls back to single requests, which cannot be mis-aligned).
768
+ */
769
+ async function translateBatch(texts, session) {
770
+ if (texts.length === 1) {
771
+ try {
772
+ return [await session.translate(texts[0])];
773
+ }
774
+ catch {
775
+ return null;
776
+ }
777
+ }
778
+ try {
779
+ const parts = (await session.translate(texts.join(UNIT_SEPARATOR))).split(UNIT_SEPARATOR);
780
+ return parts.length === texts.length ? parts : null;
781
+ }
782
+ catch {
783
+ return null;
784
+ }
785
+ }
786
+ /** Every segment of a built article, in reading order. */
787
+ export function segmentsOf(built) {
788
+ return built.blocks.flatMap(block => block.segments);
789
+ }
790
+ /**
791
+ * Translate every segment of a built article, best effort.
792
+ *
793
+ * Remembered units paint immediately; the rest go out in batches of whole
794
+ * sentences. A batch whose separator comes back altered is re-sent one unit at
795
+ * a time — the fallback gives up granularity for that batch, never correctness.
796
+ *
797
+ * @param options - the built article, a created session, and the cancel flag.
798
+ * @returns how many units were translated, how many failed, and the total.
799
+ */
800
+ export async function runTranslation(options) {
801
+ const { built, session, pair, cancelled, onProgress } = options;
802
+ const segments = segmentsOf(built);
803
+ const total = segments.length;
804
+ let done = 0;
805
+ let failed = 0;
806
+ const learned = [];
807
+ const recalled = [];
808
+ const report = () => { onProgress?.(done, total); };
809
+ const pending = [];
810
+ for (const segment of segments) {
811
+ const hit = remembered(pair, segment.original);
812
+ if (hit !== undefined) {
813
+ applyTranslation(built, segment, hit, pair);
814
+ recalled.push(learnOf(segment.original, hit));
815
+ done += 1;
816
+ }
817
+ else {
818
+ pending.push(segment);
819
+ }
820
+ }
821
+ report();
822
+ let batch = [];
823
+ let chars = 0;
824
+ const flush = async () => {
825
+ if (batch.length === 0)
826
+ return;
827
+ const current = batch;
828
+ batch = [];
829
+ chars = 0;
830
+ if (cancelled())
831
+ return;
832
+ const parts = await translateBatch(current.map(segment => segment.original), session);
833
+ if (parts !== null) {
834
+ current.forEach((segment, index) => {
835
+ const value = parts[index].trim();
836
+ applyTranslation(built, segment, value, pair);
837
+ learned.push(learnOf(segment.original, value));
838
+ done += 1;
839
+ });
840
+ report();
841
+ return;
842
+ }
843
+ // The separator did not survive (or the call failed): fall back to one unit
844
+ // per request, which cannot be mis-aligned.
845
+ for (const segment of current) {
846
+ if (cancelled())
847
+ return;
848
+ try {
849
+ const translated = (await session.translate(segment.original)).trim();
850
+ applyTranslation(built, segment, translated, pair);
851
+ learned.push(learnOf(segment.original, translated));
852
+ done += 1;
853
+ }
854
+ catch {
855
+ failed += 1;
856
+ }
857
+ report();
858
+ }
859
+ };
860
+ for (const segment of pending) {
861
+ if (cancelled())
862
+ break;
863
+ const size = segment.original.length;
864
+ if (batch.length > 0 && (chars + size > BATCH_MAX_CHARS || batch.length >= BATCH_MAX_UNITS))
865
+ await flush();
866
+ if (cancelled())
867
+ break;
868
+ batch.push(segment);
869
+ chars += size;
870
+ }
871
+ await flush();
872
+ return { done, failed, total, learned, recalled };
873
+ }