@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.
- package/README.en.md +90 -0
- package/README.i18n.yaml +6 -0
- package/README.md +90 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +15119 -0
- package/lib/index.js +3660 -0
- package/lib/invariant.js +38 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1738 -0
- package/lib/typert.remote-client.d.ts +82 -0
- package/lib/typert.remote-client.js +1115 -0
- package/lib/types/arxiv.d.ts +47 -0
- package/lib/types/arxiv.js +76 -0
- package/lib/types/client/ReaderPane.d.ts +71 -0
- package/lib/types/client/ReaderPane.js +3135 -0
- package/lib/types/client/contract.d.ts +254 -0
- package/lib/types/client/contract.js +1 -0
- package/lib/types/client/definition.d.ts +27 -0
- package/lib/types/client/definition.js +32 -0
- package/lib/types/client/extract-article.d.ts +102 -0
- package/lib/types/client/extract-article.js +1024 -0
- package/lib/types/client/icons.d.ts +27 -0
- package/lib/types/client/icons.js +11 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/index.js +222 -0
- package/lib/types/client/locales.d.ts +391 -0
- package/lib/types/client/locales.js +382 -0
- package/lib/types/client/parse-rss.d.ts +69 -0
- package/lib/types/client/parse-rss.js +656 -0
- package/lib/types/client/quote.d.ts +89 -0
- package/lib/types/client/quote.js +119 -0
- package/lib/types/client/selectors.d.ts +167 -0
- package/lib/types/client/selectors.js +413 -0
- package/lib/types/client/session.d.ts +171 -0
- package/lib/types/client/session.js +223 -0
- package/lib/types/client/store.d.ts +181 -0
- package/lib/types/client/store.js +226 -0
- package/lib/types/client/translate.d.ts +379 -0
- package/lib/types/client/translate.js +873 -0
- package/lib/types/index.d.ts +48 -0
- package/lib/types/index.js +25 -0
- package/lib/types/invariant.d.ts +29 -0
- package/lib/types/invariant.js +38 -0
- package/lib/types/link-resolvers.d.ts +107 -0
- package/lib/types/link-resolvers.js +299 -0
- package/lib/types/remote.d.ts +224 -0
- package/lib/types/remote.js +275 -0
- package/lib/types/schedule.d.ts +78 -0
- package/lib/types/schedule.js +114 -0
- package/lib/types/service.d.ts +633 -0
- package/lib/types/service.js +1697 -0
- package/lib/types/store.d.ts +224 -0
- package/lib/types/store.js +916 -0
- package/lib/types/types.d.ts +710 -0
- package/lib/types/types.js +182 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- 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
|
+
}
|