@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,1697 @@
1
+ import { MAX_RECENT_ENTRIES, ReaderStore, ReaderStoreError, boundTranslations, emptyStateDoc, memoryTableChars, TRANSLATION_MEMORY_FILE, } from "./store.js";
2
+ import { defaultSourceLabel, errorMessage, isRetryablePreviewFailure, linkEntryId, DEFAULT_CACHE_POLICY, DEFAULT_TRANSLATION_BUDGET_CHARS, INLINE_BODY_MAX_CHARS, TRANSLATION_STORAGE_VERSION, } from "./types.js";
3
+ import { delayUntilNext, isCatchUpDue } from "./schedule.js";
4
+ import { arxivHtmlUrl } from "./arxiv.js";
5
+ import { resolveLink } from "./link-resolvers.js";
6
+ /**
7
+ * How many entries one automatic backfill run may fetch.
8
+ *
9
+ * A wall of 300 summary-only entries must not turn one refresh into 300
10
+ * requests to other people's servers; the run takes a slice and the next
11
+ * refresh takes the next one.
12
+ */
13
+ export const BACKFILL_MAX_PER_RUN = 8;
14
+ /** How long a FAILED fetch is left alone before it may be retried (6 hours). */
15
+ export const BACKFILL_RETRY_MS = 6 * 60 * 60 * 1000;
16
+ /** Cross-origin redirect hops the service will follow before giving up. */
17
+ const MAX_REDIRECT_HOPS = 3;
18
+ /** Upper bound on summaries handed to the browser in one call. */
19
+ const SUMMARY_LIMIT = 500;
20
+ /** How many sentence hashes one memory-slice read answers (the table never crosses). */
21
+ const TRANSLATION_SLICE_LIMIT = 2000;
22
+ /** How many sentences one write batch may carry (a long article is ~500). */
23
+ const TRANSLATION_WRITE_LIMIT = 2000;
24
+ /** One sentence's source or translation is never longer than a few paragraphs. */
25
+ const TRANSLATION_TEXT_LIMIT = 4_000;
26
+ /** Thrown when a payload cannot become a source. */
27
+ class UnsupportedContent extends Error {
28
+ }
29
+ /**
30
+ * Whether a failed fetch is the HTML endpoint's "this paper has no HTML
31
+ * version" — a bare 404 from the content fetch, and nothing else. A refusal
32
+ * (403/401) or a transport error is a fact about the REQUEST, not about which
33
+ * face of the paper exists, and must not silently reroute to another page.
34
+ */
35
+ function isNoHtmlVersion(error) {
36
+ return error instanceof UnsupportedContent && error.message === 'HTTP 404';
37
+ }
38
+ /**
39
+ * Thrown when the FETCH itself failed — a refused connection, a timeout, a
40
+ * response over the seam's byte cap. Carries the seam's own message: the
41
+ * difference between "the host has no egress" and "that host timed out" is the
42
+ * whole diagnosis, and a bare "failed" tells the reader neither.
43
+ */
44
+ export class FetchFailure extends Error {
45
+ /**
46
+ * @param message - the seam's message, verbatim.
47
+ * @param cause - the original error, when there was one.
48
+ */
49
+ constructor(message, cause) {
50
+ super(message, cause === undefined ? undefined : { cause });
51
+ this.name = 'FetchFailure';
52
+ }
53
+ }
54
+ /**
55
+ * Classify a fetched payload as a feed or an article page.
56
+ *
57
+ * The decision comes from the CONTENT, not from asking the user: nobody pasting
58
+ * a URL can be expected to know whether it is a feed, and guessing wrong used
59
+ * to mean silently creating an empty subscription.
60
+ *
61
+ * @param raw - the decoded payload.
62
+ * @returns which kind of source this payload can become.
63
+ */
64
+ export function classifyPayload(raw) {
65
+ const head = raw.slice(0, 512).trimStart().toLowerCase();
66
+ if (head.startsWith('<?xml'))
67
+ return 'feed';
68
+ if (/^<rss[\s>]/.test(head))
69
+ return 'feed';
70
+ if (/^<feed[\s>]/.test(head))
71
+ return 'feed';
72
+ if (/^<rdf:rdf[\s>]/.test(head))
73
+ return 'feed';
74
+ return 'page';
75
+ }
76
+ /** The reader service. */
77
+ export class ReaderService {
78
+ store;
79
+ ctx;
80
+ timer;
81
+ disposed = false;
82
+ /** The document of record when no filesystem is mounted. */
83
+ memoryDoc;
84
+ /**
85
+ * @param ctx - owning context.
86
+ * @param config - optional state-root override.
87
+ */
88
+ constructor(ctx, config = {}) {
89
+ this.ctx = ctx;
90
+ this.store = new ReaderStore(config);
91
+ ctx.effect(() => () => {
92
+ this.disposed = true;
93
+ if (this.timer !== undefined)
94
+ clearTimeout(this.timer);
95
+ }, 'reader: daily refresh');
96
+ // The store resolves its own root against the deployment (native fs, not
97
+ // the session-fenced `ctx.fs` — see the store's module note), so the
98
+ // schedule can be armed immediately.
99
+ void this.armSchedule();
100
+ }
101
+ /** Capabilities the browser may rely on. */
102
+ async capabilities() {
103
+ const doc = await this.currentDoc();
104
+ return {
105
+ protocolVersion: 1,
106
+ hasFs: this.store.available,
107
+ ...(doc.lastRefreshAt === undefined ? {} : { lastRefreshAt: doc.lastRefreshAt }),
108
+ hasSideChat: this.sideChat() !== undefined,
109
+ ...(doc.refresh.enabled
110
+ ? { nextRefreshAt: new Date(Date.now() + delayUntilNext(new Date(), doc.refresh.timeOfDay)).toISOString() }
111
+ : {}),
112
+ };
113
+ }
114
+ /** The source list, without payloads. */
115
+ async listSources() {
116
+ const doc = await this.currentDoc();
117
+ return { sources: doc.sources.slice(0, SUMMARY_LIMIT).map(summarize) };
118
+ }
119
+ /**
120
+ * Add a source, deciding what it is from what comes back.
121
+ *
122
+ * The one rule this method exists to enforce: **a URL the reader pasted is
123
+ * never thrown away.** A fetch that fails, a bot challenge, a login wall and
124
+ * a PDF all end the same way — a `link` source that exists, carries the
125
+ * reason it has no previewable body, and can be opened, tagged, refreshed
126
+ * manually or deleted. Only a URL that cannot be a source at all
127
+ * (`invalid-url`) or one already present (`duplicate`) is refused.
128
+ *
129
+ * @param request - the pasted URL and an optional label.
130
+ * @returns the decision, or a domain refusal.
131
+ */
132
+ async addSource(request) {
133
+ const url = normalizeUrl(request.url);
134
+ if (url === undefined)
135
+ return 'invalid-url';
136
+ const doc = await this.currentDoc();
137
+ if (doc.sources.some(source => sameTarget(source.url, url)))
138
+ return 'duplicate';
139
+ const now = new Date().toISOString();
140
+ // Paper links are recognized BEFORE the first page fetch (the ingest
141
+ // proposal's D3): a DOI is a cross-origin redirect this seam refuses by
142
+ // design, and OpenReview is measured unreadable server-side — for those
143
+ // hosts "the ordinary path" is a guaranteed or misleading failure.
144
+ const resolution = await this.resolvePaper(url);
145
+ if (resolution?.kind === 'link-only') {
146
+ return await this.saveLinkOnly({ url, ...(request.label === undefined ? {} : { label: request.label }) }, { code: resolution.code, message: resolution.message, at: now }, now);
147
+ }
148
+ // What the first fetch aims at: a resolved paper's HTML version, a pasted
149
+ // arXiv link's HTML upgrade, or the URL itself. A resolved candidate whose
150
+ // HTML version is missing (404) falls back to the paper's ABSTRACT page —
151
+ // the API vouched the paper exists — while a pasted link falls back to
152
+ // itself. The resolved URL joins the duplicate check either way.
153
+ const resolvedId = resolution?.kind === 'arxiv' ? resolution.id : undefined;
154
+ const upgrade = resolvedId === undefined ? arxivHtmlUrl(url) : `https://arxiv.org/html/${resolvedId}`;
155
+ const fallbackUrl = resolvedId === undefined ? url : `https://arxiv.org/abs/${resolvedId}`;
156
+ const resolvedFrom = resolvedId === undefined ? undefined : url;
157
+ if (upgrade !== undefined && doc.sources.some(source => sameTarget(source.url, upgrade)))
158
+ return 'duplicate';
159
+ let fetched;
160
+ try {
161
+ fetched = await this.fetchFollowing(upgrade ?? url);
162
+ }
163
+ catch (error) {
164
+ if (upgrade !== undefined && isNoHtmlVersion(error)) {
165
+ // The paper has no HTML version (the endpoint answers a bare 404). The
166
+ // fallback goes through the ordinary path: an /abs page is a fine
167
+ // article, a /pdf is the honest not-a-web-page card.
168
+ try {
169
+ fetched = await this.fetchFollowing(fallbackUrl);
170
+ }
171
+ catch (fallbackError) {
172
+ return await this.saveLinkOnly({ url, ...(request.label === undefined ? {} : { label: request.label }), ...(resolvedFrom === undefined ? {} : { resolvedFrom }) }, classifyFetchFailure(fallbackError, now), now);
173
+ }
174
+ }
175
+ else {
176
+ return await this.saveLinkOnly({ url, ...(request.label === undefined ? {} : { label: request.label }), ...(resolvedFrom === undefined ? {} : { resolvedFrom }) }, classifyFetchFailure(error, now), now);
177
+ }
178
+ }
179
+ const feed = classifyPayload(fetched.raw) === 'feed';
180
+ if (!feed) {
181
+ // A page that fetched fine can still have nothing to read: a bot
182
+ // challenge, a login interstitial, an empty shell. Classify BEFORE the
183
+ // payload is stored, so a challenge page never becomes the card's body —
184
+ // which is exactly how an OpenReview PDF link became a card showing
185
+ // somebody's anti-bot page.
186
+ const problem = inspectPreview(fetched.raw, fetched.url, upgrade ?? url);
187
+ if (problem !== undefined) {
188
+ return await this.saveLinkOnly({
189
+ url: fetched.url,
190
+ ...(request.label === undefined ? {} : { label: request.label }),
191
+ ...(resolvedFrom === undefined ? {} : { resolvedFrom }),
192
+ }, { code: problem.code, message: problem.message, at: now }, now);
193
+ }
194
+ }
195
+ const label = request.label ?? (resolution?.kind === 'arxiv' ? resolution.title : undefined) ?? defaultSourceLabel(fetched.url);
196
+ const source = {
197
+ id: `${feed ? 'rss' : 'link'}-${hash(fetched.url)}`,
198
+ kind: feed ? 'rss' : 'link',
199
+ url: fetched.url,
200
+ label,
201
+ enabled: true,
202
+ addedAt: now,
203
+ fetchedAt: now,
204
+ status: 'ok',
205
+ ...(fetched.truncated ? { truncated: true } : {}),
206
+ ...(resolvedFrom === undefined ? {} : { resolvedFrom }),
207
+ raw: fetched.raw,
208
+ };
209
+ await this.commit(current => ({ ...current, sources: [source, ...current.sources] }));
210
+ return { outcome: feed ? 'subscribed' : 'saved-link', kind: source.kind, id: source.id, label };
211
+ }
212
+ /**
213
+ * Persist a link the reader owns even though its body cannot be previewed.
214
+ *
215
+ * Two records, because two consumers ask different questions: the SOURCE
216
+ * carries the failure so the wall can mark the card and the backfill can
217
+ * leave a permanent wall alone, while the ENTRY annotation carries it so
218
+ * `getEntryBody` — the one call the detail view makes — explains itself
219
+ * instead of answering with a blank article.
220
+ *
221
+ * @param request - the final URL, an optional label, and the pasted URL when
222
+ * this link was resolved to a canonical version.
223
+ * @param failure - the classified reason, with its timestamp.
224
+ * @param now - the commit instant.
225
+ * @returns the add outcome the browser reports.
226
+ */
227
+ async saveLinkOnly(request, failure, now) {
228
+ const label = request.label ?? defaultSourceLabel(request.url);
229
+ const source = {
230
+ // A failed fetch has no content to classify, so the source is a `link`:
231
+ // claiming "subscription" for something never read as a feed would be a
232
+ // guess dressed as a fact.
233
+ id: `link-${hash(request.url)}`,
234
+ kind: 'link',
235
+ url: request.url,
236
+ label,
237
+ enabled: true,
238
+ addedAt: now,
239
+ fetchedAt: now,
240
+ status: 'error',
241
+ error: failure.message,
242
+ failure,
243
+ ...(request.resolvedFrom === undefined ? {} : { resolvedFrom: request.resolvedFrom }),
244
+ };
245
+ await this.commit(current => ({ ...current, sources: [source, ...current.sources] }));
246
+ await this.recordFetchFailure(linkEntryId(source.id), failure.message);
247
+ return {
248
+ outcome: 'saved-link',
249
+ kind: 'link',
250
+ id: source.id,
251
+ label,
252
+ failure: { code: failure.code, message: failure.message },
253
+ };
254
+ }
255
+ /**
256
+ * Update a source's mutable fields, or the global refresh time.
257
+ *
258
+ * @param request - the id plus the fields to change.
259
+ * @returns the outcome, or a domain refusal.
260
+ */
261
+ async updateSource(request) {
262
+ const doc = await this.currentDoc();
263
+ if (!doc.sources.some(source => source.id === request.id))
264
+ return 'not-found';
265
+ if (request.timeOfDay !== undefined && !/^\d{1,2}:\d{2}$/.test(request.timeOfDay))
266
+ return 'invalid-time';
267
+ // A URL edit is validated the same way an add is; an empty string means
268
+ // "back to the feed's own title", which the next fetch resolves.
269
+ const url = request.url === undefined || request.url === '' ? undefined : normalizeUrl(request.url);
270
+ if (request.url !== undefined && request.url !== '' && url === undefined)
271
+ return 'invalid-url';
272
+ await this.commit(current => ({
273
+ ...current,
274
+ sources: current.sources.map(source => source.id === request.id
275
+ ? {
276
+ ...source,
277
+ ...(request.enabled !== undefined ? { enabled: request.enabled } : {}),
278
+ ...(request.label !== undefined && request.label !== '' ? { label: request.label } : {}),
279
+ ...(url !== undefined ? { url, status: 'ok' } : {}),
280
+ }
281
+ : source),
282
+ ...(request.timeOfDay !== undefined
283
+ ? { refresh: { ...current.refresh, timeOfDay: request.timeOfDay } }
284
+ : {}),
285
+ }));
286
+ if (request.timeOfDay !== undefined)
287
+ await this.armSchedule();
288
+ return 'ok';
289
+ }
290
+ /**
291
+ * Remove a source.
292
+ *
293
+ * Its entries' TRANSLATIONS go with it: the entries themselves are unreachable
294
+ * once the source is gone (the recent page disables them), so their exact-fit
295
+ * maps are dead weight. The host never parses feeds, so the caller passes the
296
+ * entry ids it has parsed; a link source's single entry id is derivable here
297
+ * regardless. The cached BODIES stay — the body budget owns their lifetime,
298
+ * unchanged.
299
+ *
300
+ * @param request - the source id, plus the entries the caller knows belong to it.
301
+ * @returns the outcome, or a domain refusal.
302
+ */
303
+ async removeSource(request) {
304
+ const doc = await this.currentDoc();
305
+ if (!doc.sources.some(source => source.id === request.id))
306
+ return 'not-found';
307
+ const entryIds = new Set(request.entryIds ?? []);
308
+ entryIds.add(linkEntryId(request.id));
309
+ await this.commit(current => {
310
+ let annotations = current.annotations;
311
+ const touched = {};
312
+ let changed = false;
313
+ for (const id of entryIds) {
314
+ const annotation = annotations?.[id];
315
+ if (annotation?.translation === undefined)
316
+ continue;
317
+ const { translation: _gone, ...rest } = annotation;
318
+ touched[id] = rest;
319
+ changed = true;
320
+ }
321
+ if (changed)
322
+ annotations = { ...annotations, ...touched };
323
+ return {
324
+ ...current,
325
+ sources: current.sources.filter(source => source.id !== request.id),
326
+ ...(changed ? { annotations } : {}),
327
+ };
328
+ });
329
+ return 'ok';
330
+ }
331
+ /**
332
+ * Refresh the enabled sources, or the named ones.
333
+ *
334
+ * @param request - optional id list.
335
+ * @returns one result per attempted source.
336
+ */
337
+ async refresh(request = {}) {
338
+ const doc = await this.currentDoc();
339
+ const wanted = request.ids === undefined
340
+ ? doc.sources.filter(source => source.enabled)
341
+ : doc.sources.filter(source => request.ids?.includes(source.id) === true);
342
+ const results = [];
343
+ const patches = new Map();
344
+ const base = new Map(doc.sources.map(source => [source.id, source]));
345
+ for (const source of wanted) {
346
+ const fetchedAt = new Date().toISOString();
347
+ try {
348
+ const fetched = await this.fetchFollowing(source.url);
349
+ // The same classification the add performs: a refresh that comes back
350
+ // with a challenge page or a PDF must not replace a good body with it,
351
+ // and must record why there is nothing to read.
352
+ const problem = source.kind === 'link' ? inspectPreview(fetched.raw, fetched.url, source.url) : undefined;
353
+ if (problem !== undefined) {
354
+ const failure = { code: problem.code, message: problem.message, at: fetchedAt };
355
+ patches.set(source.id, {
356
+ // Keep the last-known-good payload: a site that starts refusing us
357
+ // must not erase the article the reader already had.
358
+ ...withoutFailure({ ...source, url: fetched.url, ...(source.raw === undefined ? {} : { raw: source.raw }) }),
359
+ fetchedAt,
360
+ status: 'error',
361
+ error: failure.message,
362
+ failure,
363
+ });
364
+ await this.recordFetchFailure(linkEntryId(source.id), failure.message);
365
+ results.push({ id: source.id, status: 'unavailable', message: failure.message });
366
+ continue;
367
+ }
368
+ patches.set(source.id, {
369
+ ...withoutFailure(source),
370
+ url: fetched.url,
371
+ raw: fetched.raw,
372
+ fetchedAt,
373
+ status: 'ok',
374
+ truncated: fetched.truncated,
375
+ });
376
+ results.push({
377
+ id: source.id,
378
+ status: fetched.truncated ? 'truncated' : 'ok',
379
+ ...(fetched.truncated ? { message: "payload hit the fetch seam's size cap" } : {}),
380
+ });
381
+ }
382
+ catch (error) {
383
+ const failure = classifyFetchFailure(error, fetchedAt);
384
+ // A failed refresh keeps the previous payload (last-known-good) and
385
+ // records why: a transient network error must not erase the reader's
386
+ // copy of an article.
387
+ const previous = base.get(source.id);
388
+ patches.set(source.id, {
389
+ ...(previous ?? source),
390
+ fetchedAt,
391
+ status: 'error',
392
+ error: failure.message,
393
+ failure,
394
+ });
395
+ results.push({
396
+ id: source.id,
397
+ status: error instanceof UnsupportedContent ? 'unavailable' : 'fetch-failed',
398
+ message: failure.message,
399
+ });
400
+ }
401
+ }
402
+ if (patches.size > 0) {
403
+ await this.commit(current => ({
404
+ ...current,
405
+ lastRefreshAt: new Date().toISOString(),
406
+ sources: current.sources.map(source => patches.get(source.id) ?? source),
407
+ }));
408
+ }
409
+ return { results };
410
+ }
411
+ /**
412
+ * Hand back raw payloads for the browser to parse.
413
+ *
414
+ * @param request - the source ids whose payloads are wanted.
415
+ * @returns one body per requested id; an error instead of a payload when
416
+ * nothing usable is stored.
417
+ */
418
+ async getBodies(request) {
419
+ const doc = await this.currentDoc();
420
+ const byId = new Map(doc.sources.map(source => [source.id, source]));
421
+ return {
422
+ bodies: request.ids.map(id => {
423
+ const source = byId.get(id);
424
+ if (source === undefined)
425
+ return { id, error: 'not-found' };
426
+ // A saved link's single entry: the captured title/excerpt join the
427
+ // answer, so the card can show the paper's own name even after the
428
+ // payload's eviction. Feed sources never join — their entries' titles
429
+ // are already the publisher's.
430
+ const meta = source.kind === 'link' ? doc.annotations?.[linkEntryId(source.id)] : undefined;
431
+ const carried = {
432
+ ...(meta?.title === undefined ? {} : { title: meta.title }),
433
+ ...(meta?.excerpt === undefined ? {} : { excerpt: meta.excerpt }),
434
+ };
435
+ if (source.raw === undefined)
436
+ return { id, error: source.error ?? 'no payload stored yet', ...carried };
437
+ return { id, raw: source.raw, ...(source.truncated === true ? { truncated: true } : {}), ...carried };
438
+ }),
439
+ };
440
+ }
441
+ /**
442
+ * Pass a ref block to the side-chat service when one is composed.
443
+ *
444
+ * @param request - the target context, its label, and the ref text.
445
+ * @returns `'ok'`, or `'unavailable'` when no side chat is mounted.
446
+ */
447
+ async quoteToSideChat(request) {
448
+ const sideChat = this.sideChat();
449
+ if (sideChat === undefined)
450
+ return 'unavailable';
451
+ try {
452
+ await sideChat.openWith({
453
+ contextKey: request.contextKey,
454
+ label: request.label,
455
+ refs: [{ label: request.label, text: request.text }],
456
+ });
457
+ return 'ok';
458
+ }
459
+ catch {
460
+ return 'unavailable';
461
+ }
462
+ }
463
+ /* -------------------------------------------------- entry bodies and tags */
464
+ /**
465
+ * The body the detail view should render for one entry, if any.
466
+ *
467
+ * The order is the whole contract: a fresh cached fetch wins (it is full text
468
+ * the reader already paid for), then the feed's own payload, and only when
469
+ * neither exists does the view offer to fetch. A STALE cache entry reports
470
+ * `fresh: false` and no html — the detail view then offers the fetch again
471
+ * rather than quietly serving yesterday's copy.
472
+ *
473
+ * @param request - the entry and the payload the feed gave for it.
474
+ * @returns what there is to show, and whether a fetch is worth offering.
475
+ */
476
+ async getEntryBody(request) {
477
+ const doc = await this.currentDoc();
478
+ const cached = doc.annotations?.[request.entryId]?.body;
479
+ if (cached !== undefined) {
480
+ const fresh = isFresh(cached.expiresAt);
481
+ // A large body lives in `bodies/`; a missing file means no cache, and the
482
+ // view then offers the fetch rather than pretending to have the text.
483
+ const html = cached.file === undefined
484
+ ? cached.html
485
+ : (fresh && cached.url === request.url ? this.store.readBody(cached.file) : undefined);
486
+ return {
487
+ entryId: request.entryId,
488
+ cached: true,
489
+ fresh,
490
+ fromFeed: false,
491
+ fetchedAt: cached.fetchedAt,
492
+ ...(cached.truncated === true ? { truncated: true } : {}),
493
+ ...(cached.scriptFigures === undefined ? {} : { scriptFigures: cached.scriptFigures }),
494
+ ...(cached.rendered === true ? { rendered: true } : {}),
495
+ ...(html === undefined || !fresh || cached.url !== request.url ? {} : { html }),
496
+ };
497
+ }
498
+ if (request.feedHtml !== undefined && request.feedHtml.length > 0) {
499
+ return { entryId: request.entryId, cached: false, fresh: true, fromFeed: true, html: request.feedHtml };
500
+ }
501
+ const failure = doc.annotations?.[request.entryId];
502
+ return {
503
+ entryId: request.entryId,
504
+ cached: false,
505
+ fresh: true,
506
+ fromFeed: false,
507
+ ...(failure?.error === undefined ? {} : { error: failure.error }),
508
+ };
509
+ }
510
+ /**
511
+ * Fetch one entry's article and hand the raw HTML to the browser.
512
+ *
513
+ * The HOST does not extract: extraction needs a DOM, and this runtime has no
514
+ * XML/HTML parser at all (the same reason feed parsing lives in the browser
515
+ * half). So this verb is the network half only — it returns what came back,
516
+ * and `storeEntryBody` persists what the browser made of it.
517
+ *
518
+ * Fetching here is deliberately NOT automatic on refresh: doing it for every
519
+ * summary-only entry would make this a crawler of other people's sites, which
520
+ * the design refuses. One entry, one fetch, on the reader's request.
521
+ *
522
+ * @param request - the entry and its article URL.
523
+ * @returns the raw payload, or why there is none.
524
+ */
525
+ async fetchEntryBody(request) {
526
+ const url = normalizeUrl(request.url);
527
+ if (url === undefined) {
528
+ await this.recordFetchFailure(request.entryId, 'invalid-url');
529
+ return { entryId: request.entryId, error: 'invalid-url' };
530
+ }
531
+ await this.markFetching(request.entryId);
532
+ try {
533
+ const fetched = await this.fetchFollowing(url);
534
+ // The raw payload is written to disk BEFORE the caller gets it, so a
535
+ // browser that goes away mid-request does not cost the reader the
536
+ // download: the next visit finds the stored payload and extracts it.
537
+ const rawFile = await this.storeRaw(request.entryId, fetched.raw, fetched.url, fetched.truncated);
538
+ return {
539
+ entryId: request.entryId,
540
+ url: fetched.url,
541
+ raw: fetched.raw,
542
+ ...(rawFile === undefined ? {} : { rawFile }),
543
+ ...(fetched.truncated ? { truncated: true } : {}),
544
+ };
545
+ }
546
+ catch (error) {
547
+ const failure = classifyFetchFailure(error, new Date().toISOString());
548
+ await this.recordFetchFailure(request.entryId, failure.message, failure.code);
549
+ return { entryId: request.entryId, error: failure.message };
550
+ }
551
+ }
552
+ /**
553
+ * Which entries have a body, a stored raw payload, or a recorded failure.
554
+ *
555
+ * The wall needs this per ENTRY (entries come from the browser's parse of a
556
+ * feed), so it cannot ride the source summaries. `raw` means "fetched, not yet
557
+ * extracted" — the browser half turns those into bodies on its next load.
558
+ *
559
+ * @param request - the entry ids the caller is showing.
560
+ * @returns one state per requested id.
561
+ */
562
+ async entryFetchStates(request) {
563
+ const doc = await this.currentDoc();
564
+ const states = {};
565
+ const now = Date.now();
566
+ for (const entryId of request.entryIds) {
567
+ const annotation = doc.annotations?.[entryId];
568
+ if (annotation?.body !== undefined && isFresh(annotation.body.expiresAt)) {
569
+ states[entryId] = { state: 'ready', ...(annotation.body.fetchedAt === undefined ? {} : { at: annotation.body.fetchedAt }) };
570
+ continue;
571
+ }
572
+ const fetch = annotation?.fetch;
573
+ if (fetch?.state === 'raw') {
574
+ states[entryId] = { state: 'raw', at: fetch.at };
575
+ continue;
576
+ }
577
+ if (fetch?.state === 'fetching' && isRecent(fetch.at, now, FETCH_STALE_MS)) {
578
+ states[entryId] = { state: 'fetching', at: fetch.at };
579
+ continue;
580
+ }
581
+ if (annotation?.error !== undefined && annotation.failedAt !== undefined) {
582
+ states[entryId] = {
583
+ state: 'failed',
584
+ at: annotation.failedAt,
585
+ message: annotation.error,
586
+ ...(annotation.failureCode === undefined ? {} : { code: annotation.failureCode }),
587
+ };
588
+ continue;
589
+ }
590
+ states[entryId] = { state: 'none' };
591
+ }
592
+ return { states };
593
+ }
594
+ /**
595
+ * Hand back a stored raw payload so the browser can extract it.
596
+ *
597
+ * @param request - the entry whose payload is wanted.
598
+ * @returns the payload, or why there is none.
599
+ */
600
+ async getRawBody(request) {
601
+ const doc = await this.currentDoc();
602
+ const annotation = doc.annotations?.[request.entryId];
603
+ const file = annotation?.fetch?.state === 'raw' ? annotation.fetch.rawFile : undefined;
604
+ if (file === undefined)
605
+ return { entryId: request.entryId, error: 'no stored payload' };
606
+ const raw = this.store.readBody(file);
607
+ if (raw === undefined)
608
+ return { entryId: request.entryId, error: 'stored payload is gone' };
609
+ return {
610
+ entryId: request.entryId,
611
+ raw,
612
+ ...(annotation?.fetch?.url === undefined ? {} : { url: annotation.fetch.url }),
613
+ ...(annotation?.fetch?.truncated === true ? { truncated: true } : {}),
614
+ };
615
+ }
616
+ /** Record that a fetch is in flight, so a wall reopened later can say so. */
617
+ async markFetching(entryId) {
618
+ await this.commit(current => ({
619
+ ...current,
620
+ annotations: { ...current.annotations, [entryId]: { ...current.annotations?.[entryId], fetch: { state: 'fetching', at: new Date().toISOString() } } },
621
+ }));
622
+ }
623
+ /**
624
+ * Store one raw payload for later extraction.
625
+ *
626
+ * @param entryId - the entry it belongs to.
627
+ * @param raw - the payload.
628
+ * @returns the file name, or `undefined` when the deployment has no fs.
629
+ */
630
+ async storeRaw(entryId, raw, url, truncated) {
631
+ if (!this.store.available)
632
+ return undefined;
633
+ const file = this.store.writeBody(`${entryId}#raw`, raw);
634
+ await this.commit(current => ({
635
+ ...current,
636
+ annotations: {
637
+ ...current.annotations,
638
+ [entryId]: {
639
+ ...current.annotations?.[entryId],
640
+ fetch: {
641
+ state: 'raw',
642
+ at: new Date().toISOString(),
643
+ rawFile: file,
644
+ chars: raw.length,
645
+ // The URL and the cap flag travel with the payload: the extraction
646
+ // pass needs both, and it runs in a browser that never saw the fetch.
647
+ url,
648
+ ...(truncated ? { truncated: true } : {}),
649
+ },
650
+ },
651
+ },
652
+ }));
653
+ return file;
654
+ }
655
+ /**
656
+ * Cache the markup the browser extracted for one entry.
657
+ *
658
+ * The deadline is computed here, not on the client, so the policy has exactly
659
+ * one owner (this service's document) — a client that guessed the TTL would
660
+ * be a second implementation of it.
661
+ *
662
+ * @param request - the entry, the markup, and the URL it came from.
663
+ * @returns the stored view.
664
+ */
665
+ async storeEntryBody(request) {
666
+ const html = request.html.trim();
667
+ if (html.length === 0) {
668
+ await this.recordFetchFailure(request.entryId, 'empty extraction');
669
+ return { entryId: request.entryId, cached: false, fresh: false, fromFeed: false, error: 'empty extraction' };
670
+ }
671
+ const doc = await this.currentDoc();
672
+ const ttlHours = doc.cache?.ttlHours ?? DEFAULT_CACHE_POLICY.ttlHours;
673
+ const now = new Date();
674
+ // A large body goes to its own file and the document keeps the file name and
675
+ // its size: the cache policy (ttl, maxEntries) then governs it exactly like
676
+ // an inline body, and `state.json` stays small.
677
+ const sidecar = html.length > INLINE_BODY_MAX_CHARS ? this.store.writeBody(request.entryId, html) : undefined;
678
+ const body = {
679
+ ...(sidecar === undefined ? { html } : { file: sidecar, chars: html.length }),
680
+ fetchedAt: now.toISOString(),
681
+ // 0 = keep until the budget evicts it; a far-future date keeps the
682
+ // freshness check a single comparison instead of a special case.
683
+ expiresAt: ttlHours === 0
684
+ ? new Date(now.getTime() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString()
685
+ : new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(),
686
+ url: request.url,
687
+ ...(request.truncated === true ? { truncated: true } : {}),
688
+ // An explicit zero is stored (it says "counted, none" — the remount
689
+ // would otherwise fall back to the entry's stale load-time count and
690
+ // resurrect the script-figure notice); only an absent count stays absent.
691
+ ...(request.scriptFigures === undefined
692
+ ? {}
693
+ : { scriptFigures: request.scriptFigures }),
694
+ ...(request.rendered === true ? { rendered: true } : {}),
695
+ };
696
+ await this.commit(current => {
697
+ const existing = current.annotations?.[request.entryId];
698
+ const tagIds = existing?.tagIds;
699
+ // The entry's translation map rides the body's identity: a stored body
700
+ // whose hash no longer matches makes the map dead weight, and it goes in
701
+ // the same commit (unchanged sentences still hit the GLOBAL memory). A
702
+ // re-store of the SAME body keeps it.
703
+ const translation = existing?.translation !== undefined
704
+ && request.bodyHash !== undefined
705
+ && existing.translation.bodyHash === request.bodyHash
706
+ ? existing.translation
707
+ : undefined;
708
+ // The article's own title/excerpt ride the same write: supplied beats
709
+ // stored, stored beats absent — a store that carries no meta keeps the
710
+ // captured values.
711
+ const title = metaText(request.title, 300) ?? existing?.title;
712
+ const excerpt = metaText(request.excerpt, 1000) ?? existing?.excerpt;
713
+ // The stored raw payload has been consumed: the fetch record goes with it,
714
+ // so the wall now reads `ready` from the body alone.
715
+ return {
716
+ ...current,
717
+ annotations: {
718
+ ...current.annotations,
719
+ [request.entryId]: {
720
+ body,
721
+ ...(tagIds === undefined ? {} : { tagIds }),
722
+ ...(translation === undefined ? {} : { translation }),
723
+ ...(title === undefined ? {} : { title }),
724
+ ...(excerpt === undefined ? {} : { excerpt }),
725
+ },
726
+ },
727
+ };
728
+ });
729
+ return {
730
+ entryId: request.entryId,
731
+ cached: true,
732
+ fresh: true,
733
+ fromFeed: false,
734
+ html,
735
+ fetchedAt: body.fetchedAt,
736
+ ...(body.truncated === true ? { truncated: true } : {}),
737
+ ...(body.scriptFigures === undefined ? {} : { scriptFigures: body.scriptFigures }),
738
+ ...(body.rendered === true ? { rendered: true } : {}),
739
+ };
740
+ }
741
+ /**
742
+ * Which entries still need their full text, and which may be retried.
743
+ *
744
+ * The host is the only side that can answer this: it holds the cache, the
745
+ * recorded failures and the policy. A failure is retried only after
746
+ * `BACKFILL_RETRY_MS` — an entry whose publisher refuses us must not be
747
+ * hammered once per refresh, which would make this a crawler with a grudge.
748
+ *
749
+ * @param request - the entries the caller is looking at (id, url, label) and
750
+ * whether the caller already has full text for them (the feed's own payload).
751
+ * @returns the candidates, capped so one refresh cannot start a stampede.
752
+ */
753
+ async listBackfillCandidates(request) {
754
+ const doc = await this.currentDoc();
755
+ const limit = Math.max(1, Math.min(request.limit ?? BACKFILL_MAX_PER_RUN, BACKFILL_MAX_PER_RUN));
756
+ const candidates = [];
757
+ const now = Date.now();
758
+ for (const entry of request.entries) {
759
+ if (entry.url === '')
760
+ continue;
761
+ if (entry.hasBody)
762
+ continue;
763
+ // A reason that will answer the same way forever — a bot wall, a login
764
+ // wall, a PDF, a 404 — is not retried on a timer: that is a crawler with
765
+ // a grudge, not a reader. The manual refresh in the subscription page
766
+ // stays available, and it clears the recorded failure on success. The
767
+ // match is restricted to `link` sources: a saved link's single entry
768
+ // carries the source's own URL, and a same-URL feed entry must not
769
+ // inherit somebody else's verdict.
770
+ const source = doc.sources.find(item => item.kind === 'link' && sameTarget(item.url, entry.url));
771
+ if (source?.failure !== undefined && !isRetryablePreviewFailure(source.failure.code))
772
+ continue;
773
+ const annotation = doc.annotations?.[entry.entryId];
774
+ if (annotation?.body !== undefined && isFresh(annotation.body.expiresAt))
775
+ continue;
776
+ if (annotation?.failedAt !== undefined) {
777
+ const failedAt = new Date(annotation.failedAt).getTime();
778
+ if (Number.isFinite(failedAt) && now - failedAt < BACKFILL_RETRY_MS)
779
+ continue;
780
+ }
781
+ candidates.push({
782
+ entryId: entry.entryId,
783
+ url: entry.url,
784
+ label: entry.label,
785
+ ...(annotation?.error === undefined ? {} : { lastError: annotation.error }),
786
+ });
787
+ if (candidates.length >= limit)
788
+ break;
789
+ }
790
+ return { candidates };
791
+ }
792
+ /* -------------------------------------------------- translation memory */
793
+ /**
794
+ * One entry's exact-fit translation record, with its segment map resolved.
795
+ *
796
+ * A record from an older storage schema, or one whose sidecar file is gone,
797
+ * reads as absent — the caller falls back to the global memory, and the stale
798
+ * record is carried out by the next write or budget pass (lazy, never wiped).
799
+ *
800
+ * @param request - the entry.
801
+ * @returns the record, or nothing usable.
802
+ */
803
+ async getEntryTranslation(request) {
804
+ const doc = await this.currentDoc();
805
+ const record = doc.annotations?.[request.entryId]?.translation;
806
+ if (record === undefined || record.version !== TRANSLATION_STORAGE_VERSION)
807
+ return {};
808
+ const segments = record.segments !== undefined
809
+ ? { ...record.segments }
810
+ : record.file === undefined
811
+ ? undefined
812
+ : readSegmentsFile(this.store.readBody(record.file));
813
+ if (segments === undefined)
814
+ return {};
815
+ return {
816
+ translation: {
817
+ pair: record.pair,
818
+ bodyHash: record.bodyHash,
819
+ segments,
820
+ },
821
+ };
822
+ }
823
+ /**
824
+ * The translations for exactly the sentences asked about, of one pair.
825
+ *
826
+ * The client hashes the body's sentences itself, so the slice is precise and
827
+ * the whole table never crosses the wire. Reads do not rewrite the file:
828
+ * the LRU stamp moves when the sentence next serves (the client's batched
829
+ * report of what it reused), not when a list of hashes arrives.
830
+ *
831
+ * @param request - the pair label and the sentence hashes wanted.
832
+ * @returns hash → translation for the hits.
833
+ */
834
+ async getSentenceTranslations(request) {
835
+ const memory = await this.translationMemory();
836
+ const translations = {};
837
+ for (const hash of request.hashes.slice(0, TRANSLATION_SLICE_LIMIT)) {
838
+ if (typeof hash !== 'string' || hash.length === 0)
839
+ continue;
840
+ const hit = memory[`${request.pair}:${hash}`];
841
+ if (hit !== undefined)
842
+ translations[hash] = hit.target;
843
+ }
844
+ return { translations };
845
+ }
846
+ /**
847
+ * Persist what one translation run produced, in one batch.
848
+ *
849
+ * `entries` are the sentences the model just translated: they upsert into the
850
+ * global memory AND the entry's exact-fit map. `recalled` are the sentences
851
+ * the run was served from memory: their lastUsedAt bumps (that is what makes
852
+ * the budget's LRU a use-clock rather than a write-clock), and they join the
853
+ * entry map too, so the map stays the complete record of this body. When
854
+ * `entryId` + `bodyHash` are present the entry map is REWRITTEN to the run's
855
+ * full sentence set — the run saw the whole body, so a merge would only
856
+ * preserve sentences the current body no longer has.
857
+ *
858
+ * @param request - the pair, the learned and recalled sentences, and the
859
+ * entry identity when this run was an article's.
860
+ * @returns how many sentences were newly learned.
861
+ */
862
+ async rememberSentences(request) {
863
+ const pair = request.pair.trim();
864
+ if (pair.length === 0)
865
+ return { stored: 0 };
866
+ const learned = normalizeLearns(request.entries);
867
+ const recalled = normalizeLearns(request.recalled ?? []);
868
+ const now = new Date().toISOString();
869
+ const doc = await this.currentDoc();
870
+ const budget = doc.cache?.translationBudgetChars ?? DEFAULT_TRANSLATION_BUDGET_CHARS;
871
+ // The global table: upsert the learned, bump the recalled.
872
+ const memory = { ...await this.translationMemory() };
873
+ let memoryChanged = false;
874
+ for (const learn of [...learned, ...recalled]) {
875
+ memory[`${pair}:${learn.hash}`] = { source: learn.source, target: learn.target, lastUsedAt: now };
876
+ memoryChanged = true;
877
+ }
878
+ // The entry map, when this run was an article's: the complete sentence set,
879
+ // sidecarred when it outgrows the inline limit — exactly like bodies.
880
+ let entryWrite;
881
+ if (request.entryId !== undefined && request.bodyHash !== undefined) {
882
+ const segments = {};
883
+ for (const learn of [...learned, ...recalled])
884
+ segments[learn.hash] = learn.target;
885
+ const entryId = request.entryId;
886
+ let translation = {
887
+ version: TRANSLATION_STORAGE_VERSION,
888
+ pair,
889
+ bodyHash: request.bodyHash,
890
+ translatedAt: doc.annotations?.[entryId]?.translation?.translatedAt ?? now,
891
+ lastUsedAt: now,
892
+ };
893
+ const serialized = JSON.stringify(segments);
894
+ if (serialized.length > INLINE_BODY_MAX_CHARS) {
895
+ translation = { ...translation, file: this.store.writeBody(`${entryId}#translation`, serialized), chars: serialized.length };
896
+ }
897
+ else {
898
+ translation = { ...translation, segments, chars: serialized.length };
899
+ }
900
+ entryWrite = { entryId, translation };
901
+ }
902
+ // The budget eviction runs against the document just read: its outcome (the
903
+ // final table + the evicted entry ids) is what the commit applies, so the
904
+ // memory file on disk always matches the manifest that names it.
905
+ const bounded = boundTranslations(memory, {
906
+ ...(doc.annotations ?? {}),
907
+ ...(entryWrite === undefined ? {} : { [entryWrite.entryId]: { ...doc.annotations?.[entryWrite.entryId], translation: entryWrite.translation } }),
908
+ }, budget);
909
+ if (bounded.evictedMemoryKeys.length > 0)
910
+ memoryChanged = true;
911
+ if (memoryChanged) {
912
+ // File before document — the same order `storeRaw` uses — so a crash
913
+ // leaves an unreferenced file for the next prune, never a dangling name.
914
+ const table = { version: TRANSLATION_STORAGE_VERSION, entries: bounded.memory };
915
+ this.store.writeNamedBody(TRANSLATION_MEMORY_FILE, JSON.stringify(table));
916
+ }
917
+ await this.commit(current => {
918
+ let annotations;
919
+ if (entryWrite !== undefined || bounded.evictedEntryIds.length > 0) {
920
+ annotations = { ...current.annotations };
921
+ if (entryWrite !== undefined) {
922
+ annotations[entryWrite.entryId] = { ...annotations[entryWrite.entryId], translation: entryWrite.translation };
923
+ }
924
+ for (const id of bounded.evictedEntryIds) {
925
+ const annotation = annotations[id];
926
+ if (annotation?.translation === undefined)
927
+ continue;
928
+ const { translation: _evicted, ...rest } = annotation;
929
+ annotations[id] = rest;
930
+ }
931
+ }
932
+ return {
933
+ ...current,
934
+ ...(annotations === undefined ? {} : { annotations }),
935
+ ...(memoryChanged
936
+ ? {
937
+ translationMemory: {
938
+ version: TRANSLATION_STORAGE_VERSION,
939
+ file: TRANSLATION_MEMORY_FILE,
940
+ entries: Object.keys(bounded.memory).length,
941
+ chars: memoryTableChars(bounded.memory),
942
+ updatedAt: now,
943
+ },
944
+ }
945
+ : {}),
946
+ };
947
+ });
948
+ return { stored: learned.length };
949
+ }
950
+ /**
951
+ * How much the two caches currently hold, aggregated on the host.
952
+ *
953
+ * The tables themselves never cross the wire for a settings readout: counts
954
+ * and characters only. The characters are the store's own accounting unit —
955
+ * the same numbers the budgets bound.
956
+ *
957
+ * @returns per-tier usage.
958
+ */
959
+ async getStorageStats() {
960
+ const doc = await this.currentDoc();
961
+ let bodyEntries = 0;
962
+ let bodyChars = 0;
963
+ let translationEntries = 0;
964
+ let translationChars = 0;
965
+ for (const annotation of Object.values(doc.annotations ?? {})) {
966
+ const body = annotation.body;
967
+ if (body !== undefined) {
968
+ bodyEntries += 1;
969
+ bodyChars += body.chars ?? body.html?.length ?? 0;
970
+ }
971
+ const translation = annotation.translation;
972
+ if (translation !== undefined) {
973
+ translationEntries += 1;
974
+ translationChars += translation.chars ?? Object.entries(translation.segments ?? {})
975
+ .reduce((sum, [hash, target]) => sum + hash.length + target.length + 2, 0);
976
+ }
977
+ }
978
+ return {
979
+ bodies: { entries: bodyEntries, chars: bodyChars },
980
+ translations: {
981
+ entries: translationEntries,
982
+ chars: translationChars,
983
+ memoryEntries: doc.translationMemory?.entries ?? 0,
984
+ memoryChars: doc.translationMemory?.chars ?? 0,
985
+ },
986
+ };
987
+ }
988
+ /**
989
+ * Forget every translation: the global sentence memory AND every entry map.
990
+ *
991
+ * The manage page's one gesture for it, after its own confirm. Bodies, tags,
992
+ * the recent list and the sources are untouched — the reader is clearing a
993
+ * derived cache, not their library. The commit's own `pruneBodies` sweep
994
+ * takes the files once the document no longer names them.
995
+ *
996
+ * @returns how many entry maps went, and whether the memory table went.
997
+ */
998
+ async clearTranslations() {
999
+ const doc = await this.currentDoc();
1000
+ const clearedEntries = Object.values(doc.annotations ?? {}).filter(annotation => annotation.translation !== undefined).length;
1001
+ const clearedMemory = doc.translationMemory !== undefined;
1002
+ if (clearedEntries === 0 && !clearedMemory)
1003
+ return { clearedEntries: 0, clearedMemory: false };
1004
+ await this.commit(current => {
1005
+ const annotations = {};
1006
+ for (const [id, annotation] of Object.entries(current.annotations ?? {})) {
1007
+ if (annotation.translation === undefined) {
1008
+ annotations[id] = annotation;
1009
+ continue;
1010
+ }
1011
+ const { translation: _cleared, ...rest } = annotation;
1012
+ annotations[id] = rest;
1013
+ }
1014
+ const { translationMemory: _dropped, ...rest } = current;
1015
+ return { ...rest, annotations };
1016
+ });
1017
+ return { clearedEntries, clearedMemory };
1018
+ }
1019
+ /**
1020
+ * The global sentence memory's table, from its sidecar file.
1021
+ *
1022
+ * Read per call, not cached: the file is the truth, and the service's own
1023
+ * commits are the only writes — a cache keyed on the manifest would save a
1024
+ * few MB of JSON parsing per translation run at the price of a coherence
1025
+ * story. An absent, unreadable, or stale-version file reads as empty (the
1026
+ * lazy half of the schema version).
1027
+ */
1028
+ async translationMemory() {
1029
+ const doc = await this.currentDoc();
1030
+ const manifest = doc.translationMemory;
1031
+ if (manifest === undefined)
1032
+ return {};
1033
+ const raw = this.store.readBody(manifest.file);
1034
+ if (raw === undefined)
1035
+ return {};
1036
+ try {
1037
+ const table = JSON.parse(raw);
1038
+ if (table.version !== TRANSLATION_STORAGE_VERSION || typeof table.entries !== 'object' || table.entries === null)
1039
+ return {};
1040
+ return { ...table.entries };
1041
+ }
1042
+ catch {
1043
+ return {};
1044
+ }
1045
+ }
1046
+ /** The tag vocabulary plus which entry ids carry each tag. */
1047
+ async listTags() {
1048
+ const doc = await this.currentDoc();
1049
+ const tags = Object.values(doc.tags ?? {}).sort((a, b) => a.name.localeCompare(b.name));
1050
+ const counts = {};
1051
+ for (const annotation of Object.values(doc.annotations ?? {})) {
1052
+ for (const id of annotation.tagIds ?? [])
1053
+ counts[id] = (counts[id] ?? 0) + 1;
1054
+ }
1055
+ return { tags, counts };
1056
+ }
1057
+ /**
1058
+ * Create a tag, or return the existing one with the same name.
1059
+ *
1060
+ * Names are matched case-insensitively on purpose: "AI" and "ai" becoming two
1061
+ * tags is what makes a tag system useless for filtering, and the reader
1062
+ * cannot be expected to remember which spelling they used.
1063
+ *
1064
+ * @param request - the name to create or find.
1065
+ * @returns the tag id, or a refusal.
1066
+ */
1067
+ async createTag(request) {
1068
+ const name = request.name.trim().slice(0, 40);
1069
+ if (name.length === 0)
1070
+ return 'empty';
1071
+ const doc = await this.currentDoc();
1072
+ const existing = Object.values(doc.tags ?? {}).find(tag => tag.name.toLowerCase() === name.toLowerCase());
1073
+ if (existing !== undefined)
1074
+ return existing;
1075
+ const tag = { id: `tag-${hash(name.toLowerCase())}`, name, createdAt: new Date().toISOString() };
1076
+ await this.commit(current => ({ ...current, tags: { ...current.tags, [tag.id]: tag } }));
1077
+ return tag;
1078
+ }
1079
+ /** Rename a tag (its id, and therefore every entry that carries it, is untouched). */
1080
+ async renameTag(request) {
1081
+ const name = request.name.trim().slice(0, 40);
1082
+ if (name.length === 0)
1083
+ return 'empty';
1084
+ const doc = await this.currentDoc();
1085
+ if (doc.tags?.[request.id] === undefined)
1086
+ return 'not-found';
1087
+ await this.commit(current => ({
1088
+ ...current,
1089
+ tags: { ...current.tags, [request.id]: { ...current.tags?.[request.id], id: request.id, name } },
1090
+ }));
1091
+ return 'ok';
1092
+ }
1093
+ /** Drop a tag from the vocabulary and from every entry that carried it. */
1094
+ async deleteTag(request) {
1095
+ const doc = await this.currentDoc();
1096
+ if (doc.tags?.[request.id] === undefined)
1097
+ return 'not-found';
1098
+ await this.commit(current => {
1099
+ const annotations = {};
1100
+ for (const [entryId, entry] of Object.entries(current.annotations ?? {})) {
1101
+ const tagIds = (entry.tagIds ?? []).filter(id => id !== request.id);
1102
+ annotations[entryId] = tagIds.length > 0
1103
+ ? { ...entry, tagIds }
1104
+ : withoutTags(entry);
1105
+ }
1106
+ const { [request.id]: _removed, ...tags } = current.tags ?? {};
1107
+ return { ...current, tags, annotations: cleanupAnnotations(annotations) };
1108
+ });
1109
+ return 'ok';
1110
+ }
1111
+ /** Add or remove one tag on one entry. */
1112
+ async tagEntry(request) {
1113
+ const doc = await this.currentDoc();
1114
+ if (doc.tags?.[request.tagId] === undefined)
1115
+ return 'not-found';
1116
+ await this.commit(current => {
1117
+ const entry = current.annotations?.[request.entryId] ?? {};
1118
+ const ids = new Set(entry.tagIds ?? []);
1119
+ if (request.on)
1120
+ ids.add(request.tagId);
1121
+ else
1122
+ ids.delete(request.tagId);
1123
+ const tagIds = [...ids];
1124
+ return {
1125
+ ...current,
1126
+ annotations: {
1127
+ ...current.annotations,
1128
+ [request.entryId]: tagIds.length > 0 ? { ...entry, tagIds } : withoutTags(entry),
1129
+ },
1130
+ };
1131
+ });
1132
+ return 'ok';
1133
+ }
1134
+ /** The tags on one entry. */
1135
+ async entryTags(request) {
1136
+ const doc = await this.currentDoc();
1137
+ const ids = new Set(doc.annotations?.[request.entryId]?.tagIds ?? []);
1138
+ return { tags: Object.values(doc.tags ?? {}).filter(tag => ids.has(tag.id)) };
1139
+ }
1140
+ /** How long a fetched body is served, and how large the translation store may grow. */
1141
+ async getCachePolicy() {
1142
+ const doc = await this.currentDoc();
1143
+ return {
1144
+ ttlHours: doc.cache?.ttlHours ?? DEFAULT_CACHE_POLICY.ttlHours,
1145
+ maxEntries: doc.cache?.maxEntries ?? DEFAULT_CACHE_POLICY.maxEntries,
1146
+ translationBudgetChars: doc.cache?.translationBudgetChars ?? DEFAULT_TRANSLATION_BUDGET_CHARS,
1147
+ };
1148
+ }
1149
+ /** Change the cache policy (`ttlHours: 0` = keep until the budget evicts). */
1150
+ async setCachePolicy(request) {
1151
+ if (!Number.isFinite(request.ttlHours) || request.ttlHours < 0 || request.ttlHours > 24 * 90)
1152
+ return 'invalid';
1153
+ if (request.translationBudgetChars !== undefined
1154
+ && (!Number.isFinite(request.translationBudgetChars) || request.translationBudgetChars <= 0 || request.translationBudgetChars > 1024 * 1024 * 1024)) {
1155
+ return 'invalid';
1156
+ }
1157
+ const doc = await this.currentDoc();
1158
+ const maxEntries = request.maxEntries ?? doc.cache?.maxEntries ?? DEFAULT_CACHE_POLICY.maxEntries;
1159
+ const translationBudgetChars = request.translationBudgetChars ?? doc.cache?.translationBudgetChars ?? DEFAULT_TRANSLATION_BUDGET_CHARS;
1160
+ await this.commit(current => ({ ...current, cache: { ttlHours: request.ttlHours, maxEntries, translationBudgetChars } }));
1161
+ return 'ok';
1162
+ }
1163
+ /** Drop tags no entry references any more. */
1164
+ async pruneTags() {
1165
+ const doc = await this.currentDoc();
1166
+ const used = new Set(Object.values(doc.annotations ?? {}).flatMap(entry => entry.tagIds ?? []));
1167
+ const kept = {};
1168
+ let removed = 0;
1169
+ for (const [id, tag] of Object.entries(doc.tags ?? {})) {
1170
+ if (used.has(id))
1171
+ kept[id] = tag;
1172
+ else
1173
+ removed += 1;
1174
+ }
1175
+ if (removed > 0)
1176
+ await this.commit(current => ({ ...current, tags: kept }));
1177
+ return { removed };
1178
+ }
1179
+ /**
1180
+ * Record that the reader opened one entry.
1181
+ *
1182
+ * One row per ENTRY (not per source): the question the recent page answers is
1183
+ * about the articles that were read, and an RSS entry has no source-level
1184
+ * page to point at. Reopening an entry moves it to the top rather than
1185
+ * duplicating it, and the list is capped — see `MAX_RECENT_ENTRIES`.
1186
+ *
1187
+ * The write is deliberately not batched: opening is a human-paced gesture,
1188
+ * and a queue that had not flushed yet would lose the item on a restart.
1189
+ *
1190
+ * @param request - the entry, its source, and what to call it in the list.
1191
+ * @returns how many entries the list holds afterwards.
1192
+ */
1193
+ async recordRead(request) {
1194
+ const entryId = request.entryId.trim();
1195
+ const sourceId = request.sourceId.trim();
1196
+ if (entryId.length === 0 || sourceId.length === 0) {
1197
+ return { entries: (await this.currentDoc()).recent?.length ?? 0 };
1198
+ }
1199
+ const title = request.title.trim().slice(0, 300);
1200
+ const url = request.url === undefined || request.url.length === 0 ? undefined : request.url;
1201
+ const readAt = new Date().toISOString();
1202
+ await this.commit(current => {
1203
+ const kept = (current.recent ?? []).filter(item => item.entryId !== entryId);
1204
+ return {
1205
+ ...current,
1206
+ recent: [{ entryId, sourceId, title, ...(url === undefined ? {} : { url }), readAt }, ...kept]
1207
+ .slice(0, MAX_RECENT_ENTRIES),
1208
+ };
1209
+ });
1210
+ return { entries: (await this.currentDoc()).recent?.length ?? 0 };
1211
+ }
1212
+ /**
1213
+ * The entries the reader opened, newest first.
1214
+ *
1215
+ * @returns the recent list as the page renders it.
1216
+ */
1217
+ async listRecent() {
1218
+ const doc = await this.currentDoc();
1219
+ return { entries: [...(doc.recent ?? [])] };
1220
+ }
1221
+ /**
1222
+ * Forget every recent entry.
1223
+ *
1224
+ * The reader's own list is theirs to erase: a "recently read" page that can
1225
+ * only grow is one nobody wants to open in company.
1226
+ *
1227
+ * @returns how many entries were dropped.
1228
+ */
1229
+ async clearRecent() {
1230
+ const doc = await this.currentDoc();
1231
+ const removed = doc.recent?.length ?? 0;
1232
+ if (removed > 0)
1233
+ await this.commit(current => ({ ...current, recent: [] }));
1234
+ return { removed };
1235
+ }
1236
+ /**
1237
+ * Record why a fetch produced nothing, so the retry is a decision.
1238
+ *
1239
+ * The failure also carries its classified CODE when there is one: the wall's
1240
+ * card button explains a red state with the same sentence the detail view
1241
+ * uses, instead of the seam's raw words.
1242
+ *
1243
+ * @param entryId - the entry.
1244
+ * @param error - the message to keep, verbatim.
1245
+ * @param code - the classified reason, when one was derived.
1246
+ */
1247
+ async recordFetchFailure(entryId, error, code) {
1248
+ await this.commit(current => {
1249
+ const { fetch: _cleared, ...rest } = current.annotations?.[entryId] ?? {};
1250
+ return {
1251
+ ...current,
1252
+ annotations: {
1253
+ ...current.annotations,
1254
+ [entryId]: {
1255
+ ...rest,
1256
+ error,
1257
+ ...(code === undefined ? {} : { failureCode: code }),
1258
+ failedAt: new Date().toISOString(),
1259
+ },
1260
+ },
1261
+ };
1262
+ });
1263
+ }
1264
+ /* --------------------------------------------------------------- internals */
1265
+ /**
1266
+ * Ask the link-resolver table what a pasted URL names.
1267
+ *
1268
+ * Runs on the ADD path only, and never breaks it: the table's own failure
1269
+ * (a resolver bug, an answer shape nobody planned for) reads as "no answer"
1270
+ * and the ordinary fetch path takes over. Resolvers that need the network
1271
+ * ride the same sanctioned seam as every fetch here; with no seam mounted
1272
+ * their fetch throws, they answer `null`, and the ordinary path reports the
1273
+ * missing capability — while a resolver that needs no network (OpenReview's
1274
+ * known-unreadable verdict) still answers.
1275
+ */
1276
+ async resolvePaper(url) {
1277
+ const web = this.web();
1278
+ const fetch = async (target) => {
1279
+ if (web === undefined)
1280
+ throw new UnsupportedContent('no web capability is mounted');
1281
+ const result = await web.fetch({ url: target });
1282
+ return { statusCode: result.statusCode, body: result.body.content };
1283
+ };
1284
+ try {
1285
+ return await resolveLink(url, fetch);
1286
+ }
1287
+ catch {
1288
+ return null;
1289
+ }
1290
+ }
1291
+ /** The probed web seam (no host import, no boot-time dependency). */
1292
+ web() {
1293
+ return this.ctx.get('web');
1294
+ }
1295
+ /** The probed side-chat service. */
1296
+ sideChat() {
1297
+ return this.ctx.get('sideChat');
1298
+ }
1299
+ /**
1300
+ * The document of record: from disk when one is mounted, else the in-memory
1301
+ * copy the service has been accumulating.
1302
+ */
1303
+ async currentDoc() {
1304
+ if (!this.store.available)
1305
+ return this.memoryDoc ?? emptyStateDoc();
1306
+ try {
1307
+ const { doc } = await this.store.read();
1308
+ this.memoryDoc = doc;
1309
+ return doc;
1310
+ }
1311
+ catch (error) {
1312
+ if (!(error instanceof ReaderStoreError))
1313
+ throw error;
1314
+ // A corrupt file must not take the plugin down; the service reports what
1315
+ // it can and the corrupt document stays on disk for its owner to fix.
1316
+ this.ctx.logger.warn(`reader: state document unusable — ${error.message}`);
1317
+ return this.memoryDoc ?? emptyStateDoc();
1318
+ }
1319
+ }
1320
+ /**
1321
+ * Apply a mutation to the document of record.
1322
+ *
1323
+ * Without a filesystem the mutation lands in memory so the session still
1324
+ * works; with one it goes through the store's version guard.
1325
+ *
1326
+ * @param mutate - pure function from the current document to the next.
1327
+ */
1328
+ async commit(mutate) {
1329
+ if (!this.store.available) {
1330
+ this.memoryDoc = mutate(this.memoryDoc ?? emptyStateDoc());
1331
+ return;
1332
+ }
1333
+ this.memoryDoc = await this.store.update(mutate);
1334
+ }
1335
+ /**
1336
+ * Fetch a URL, following the cross-origin hops that can be reconstructed.
1337
+ *
1338
+ * The seam refuses a cross-origin hop and names only the target ORIGIN, so
1339
+ * there is exactly one hop this method can follow without inventing a URL:
1340
+ * a same-host scheme/port change, which keeps the path and query
1341
+ * (`http://host/feed` → `https://host/feed`). A hop to a DIFFERENT host is
1342
+ * re-thrown untouched — the caller classifies it, and the reader is told the
1343
+ * address moves to another site rather than being served some other page
1344
+ * under the URL they pasted.
1345
+ *
1346
+ * @param url - the initial URL.
1347
+ * @returns the final URL and its decoded payload.
1348
+ */
1349
+ async fetchFollowing(url) {
1350
+ const web = this.web();
1351
+ if (web === undefined)
1352
+ throw new UnsupportedContent('no web capability is mounted');
1353
+ let current = url;
1354
+ for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop++) {
1355
+ try {
1356
+ const result = await web.fetch({ url: current });
1357
+ if (result.statusCode < 200 || result.statusCode >= 300) {
1358
+ throw new UnsupportedContent(result.statusCode === 403 || result.statusCode === 401
1359
+ ? `HTTP ${result.statusCode}: the site refuses non-browser requests, so its page cannot be read here — use the original page`
1360
+ : `HTTP ${result.statusCode}`);
1361
+ }
1362
+ return { url: result.url, raw: result.body.content, truncated: result.truncated };
1363
+ }
1364
+ catch (error) {
1365
+ const target = crossOriginRetarget(error, current);
1366
+ if (target === undefined)
1367
+ throw error;
1368
+ current = target;
1369
+ }
1370
+ }
1371
+ throw new UnsupportedContent(`more than ${MAX_REDIRECT_HOPS} cross-origin redirects`);
1372
+ }
1373
+ /** Arm (or re-arm) the daily refresh timer against the current document. */
1374
+ async armSchedule() {
1375
+ if (this.disposed)
1376
+ return;
1377
+ if (this.timer !== undefined) {
1378
+ clearTimeout(this.timer);
1379
+ this.timer = undefined;
1380
+ }
1381
+ const doc = await this.currentDoc();
1382
+ if (!doc.refresh.enabled)
1383
+ return;
1384
+ const now = new Date();
1385
+ const lastRun = doc.lastRefreshAt === undefined ? undefined : new Date(doc.lastRefreshAt);
1386
+ const runThenRearm = () => {
1387
+ void this.refresh().then(() => this.armSchedule(), () => this.armSchedule());
1388
+ };
1389
+ if (isCatchUpDue(lastRun, now, doc.refresh.timeOfDay, doc.refresh.enabled)) {
1390
+ runThenRearm();
1391
+ return;
1392
+ }
1393
+ this.timer = setTimeout(runThenRearm, delayUntilNext(now, doc.refresh.timeOfDay));
1394
+ // A day-long timer must not hold the process open on shutdown.
1395
+ this.timer.unref?.();
1396
+ }
1397
+ }
1398
+ /** Parse and normalize a pasted URL; reject anything that is not http(s). */
1399
+ export function normalizeUrl(input) {
1400
+ try {
1401
+ const url = new URL(input.trim());
1402
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
1403
+ return undefined;
1404
+ if (url.username.length > 0 || url.password.length > 0)
1405
+ return undefined;
1406
+ return url.toString();
1407
+ }
1408
+ catch {
1409
+ return undefined;
1410
+ }
1411
+ }
1412
+ /** Whether two URLs point at the same target, ignoring a trailing slash. */
1413
+ function sameTarget(a, b) {
1414
+ const strip = (value) => value.replace(/\/+$/, '');
1415
+ return strip(a) === strip(b);
1416
+ }
1417
+ /** A captured title/excerpt, trimmed and capped; empty reads as absent. */
1418
+ function metaText(value, cap) {
1419
+ if (value === undefined)
1420
+ return undefined;
1421
+ const trimmed = value.trim();
1422
+ return trimmed.length === 0 ? undefined : trimmed.slice(0, cap);
1423
+ }
1424
+ /** A short stable hash, used only to build ids. */
1425
+ function hash(input) {
1426
+ let value = 0x811c9dc5;
1427
+ for (let index = 0; index < input.length; index++) {
1428
+ value ^= input.charCodeAt(index);
1429
+ value = Math.imul(value, 0x01000193);
1430
+ }
1431
+ return (value >>> 0).toString(36);
1432
+ }
1433
+ /**
1434
+ * The next URL a cross-origin refusal can be followed to — or `undefined`.
1435
+ *
1436
+ * The host seams throw a `WEB_REDIRECT_BLOCKED` error whose CODE is a property
1437
+ * and whose message names only the target origin. The original implementation
1438
+ * tested the message for the code text, which it never contains, so the whole
1439
+ * follow path was dead code and every cross-origin redirect surfaced as a raw
1440
+ * seam error. This reads the code where it actually lives.
1441
+ *
1442
+ * @param error - the value the seam threw.
1443
+ * @param current - the URL whose request was refused.
1444
+ * @returns the same path and query on the new origin, when the HOST is
1445
+ * unchanged (a scheme or port upgrade); otherwise `undefined`.
1446
+ */
1447
+ export function crossOriginRetarget(error, current) {
1448
+ if (errorCode(error) !== 'WEB_REDIRECT_BLOCKED')
1449
+ return undefined;
1450
+ const origin = /cross-origin redirect to (\S+)/i.exec(errorMessage(error))?.[1];
1451
+ if (origin === undefined)
1452
+ return undefined;
1453
+ let target;
1454
+ let from;
1455
+ try {
1456
+ target = new URL(origin);
1457
+ from = new URL(current);
1458
+ }
1459
+ catch {
1460
+ return undefined;
1461
+ }
1462
+ if (target.hostname !== from.hostname)
1463
+ return undefined;
1464
+ return `${target.origin}${from.pathname}${from.search}`;
1465
+ }
1466
+ /**
1467
+ * The machine-readable code a harness error carries.
1468
+ *
1469
+ * @param error - any thrown value.
1470
+ * @returns the code when it has one, else `undefined`.
1471
+ */
1472
+ function errorCode(error) {
1473
+ const code = error?.code;
1474
+ return typeof code === 'string' ? code : undefined;
1475
+ }
1476
+ /** Marker words a bot wall leaves on an otherwise empty page. */
1477
+ const BOT_WALL_PATTERN = /just a moment|checking your browser|cf-chl|cf_chl|attention required|unusual traffic|captcha|verifying you are human|enable javascript and cookies|access denied|are you a robot|验证码|人机验证|访问验证|安全验证/;
1478
+ /** Marker words an authentication interstitial leaves behind. */
1479
+ const LOGIN_WALL_PATTERN = /cookie required|requires? cookies|sign in|sign-in|log in|login|please log|single sign|sso|qurl=|ezproxy|shibboleth|访问权限|请登录|登录后/;
1480
+ /** Hosts whose NAME alone says the hop is an authentication wall. */
1481
+ const LOGIN_HOST_PATTERN = /(^|[.-])(login|sso|auth|signin|idp|cas)([.-]|$)/i;
1482
+ /**
1483
+ * The smallest amount of visible text that still counts as a readable page.
1484
+ *
1485
+ * Deliberately small: this only runs on a freshly fetched page, and its job is
1486
+ * to catch interstitials — a bot challenge, a cookie notice, an empty shell —
1487
+ * not to judge whether an article is long enough to be worth reading.
1488
+ */
1489
+ export const MIN_PREVIEW_TEXT_CHARS = 200;
1490
+ /**
1491
+ * Whether a fetched page can be previewed at all, and why not when it cannot.
1492
+ *
1493
+ * The host has no DOM, so this is a deliberately crude reading of the payload:
1494
+ * strip script/style/tags, count what is left, and only when that is
1495
+ * vanishingly small look for the words a wall leaves behind. A page with real
1496
+ * text is never second-guessed.
1497
+ *
1498
+ * @param raw - the decoded payload.
1499
+ * @param finalUrl - where the fetch actually landed.
1500
+ * @param requestedUrl - the URL the reader pasted.
1501
+ * @returns the reason when there is nothing to preview, else `undefined`.
1502
+ */
1503
+ export function inspectPreview(raw, finalUrl, requestedUrl) {
1504
+ const text = visibleTextLength(raw);
1505
+ if (text >= MIN_PREVIEW_TEXT_CHARS)
1506
+ return undefined;
1507
+ const haystack = `${finalUrl}\n${raw.slice(0, 8000)}`.toLowerCase();
1508
+ const host = hostOf(finalUrl);
1509
+ if (BOT_WALL_PATTERN.test(haystack)) {
1510
+ return { code: 'blocked', message: `the site answered a bot challenge instead of the page (${finalUrl})` };
1511
+ }
1512
+ if (LOGIN_WALL_PATTERN.test(haystack) || LOGIN_HOST_PATTERN.test(host)) {
1513
+ return { code: 'login', message: `the page is an authentication interstitial (${finalUrl})` };
1514
+ }
1515
+ if (hostOf(requestedUrl) !== host) {
1516
+ return { code: 'redirected', message: `the address redirected to ${finalUrl}, which yielded no readable page` };
1517
+ }
1518
+ return { code: 'empty', message: `the page yielded ${text} characters of text (${finalUrl})` };
1519
+ }
1520
+ /**
1521
+ * Classify a thrown fetch failure into the reason the reader sees.
1522
+ *
1523
+ * Everything here is per-case diagnosable: the seam's own message is kept
1524
+ * verbatim, and only the CODE decides which sentence the browser shows and
1525
+ * whether an automatic retry is allowed.
1526
+ *
1527
+ * @param error - the value the fetch threw.
1528
+ * @param at - the failure instant (ISO-8601).
1529
+ * @returns the recorded failure.
1530
+ */
1531
+ export function classifyFetchFailure(error, at) {
1532
+ const message = errorMessage(error);
1533
+ const code = errorCode(error);
1534
+ if (code === 'WEB_UNSUPPORTED_CONTENT_TYPE' || /unsupported content type/i.test(message)) {
1535
+ return { code: 'unsupported-type', message, at };
1536
+ }
1537
+ if (code === 'WEB_REDIRECT_BLOCKED' || /cross-origin redirect/i.test(message)) {
1538
+ // A hop to a host whose name says "login" is a login wall, not merely a
1539
+ // move: the reader's next action differs (sign in elsewhere vs paste the
1540
+ // destination URL).
1541
+ const origin = /cross-origin redirect to (\S+)/i.exec(message)?.[1];
1542
+ const loginish = origin !== undefined && LOGIN_HOST_PATTERN.test(hostOf(origin));
1543
+ return { code: loginish ? 'login' : 'redirected', message, at };
1544
+ }
1545
+ const status = /\bHTTP (\d{3})\b/.exec(message)?.[1];
1546
+ if (status === '401' || status === '403')
1547
+ return { code: 'blocked', message, at };
1548
+ if (status !== undefined)
1549
+ return { code: 'http', message, at };
1550
+ return { code: 'unreachable', message, at };
1551
+ }
1552
+ /** The host of a URL, or an empty string when it cannot be parsed. */
1553
+ function hostOf(url) {
1554
+ try {
1555
+ return new URL(url).hostname.toLowerCase();
1556
+ }
1557
+ catch {
1558
+ return '';
1559
+ }
1560
+ }
1561
+ /**
1562
+ * How much visible text a payload carries.
1563
+ *
1564
+ * Script, style and markup are stripped; entities collapse to a single space.
1565
+ * This is a measurement, not a parser: its only job is to separate "a page"
1566
+ * from "an interstitial that is technically HTML".
1567
+ *
1568
+ * @param raw - the decoded payload.
1569
+ * @returns the visible character count.
1570
+ */
1571
+ export function visibleTextLength(raw) {
1572
+ return raw
1573
+ .replace(/<script[\s\S]*?<\/script\s*>/gi, ' ')
1574
+ .replace(/<style[\s\S]*?<\/style\s*>/gi, ' ')
1575
+ .replace(/<[^>]*>/g, ' ')
1576
+ .replace(/&[a-z#0-9]+;/gi, ' ')
1577
+ .replace(/\s+/g, ' ')
1578
+ .trim()
1579
+ .length;
1580
+ }
1581
+ /** The same source with any recorded preview failure removed. */
1582
+ function withoutFailure(source) {
1583
+ const { failure: _removed, ...rest } = source;
1584
+ return rest;
1585
+ }
1586
+ /** Project one source into the browser-facing summary. */
1587
+ function summarize(source) {
1588
+ return {
1589
+ id: source.id,
1590
+ kind: source.kind,
1591
+ url: source.url,
1592
+ label: source.label ?? defaultSourceLabel(source.url),
1593
+ enabled: source.enabled,
1594
+ addedAt: source.addedAt,
1595
+ hasBody: source.raw !== undefined,
1596
+ ...(source.fetchedAt !== undefined ? { fetchedAt: source.fetchedAt } : {}),
1597
+ ...(source.status !== undefined ? { status: source.status } : {}),
1598
+ ...(source.error !== undefined ? { error: source.error } : {}),
1599
+ ...(source.truncated === true ? { truncated: true } : {}),
1600
+ ...(source.failure === undefined
1601
+ ? {}
1602
+ : { failure: { code: source.failure.code, message: source.failure.message } }),
1603
+ ...(source.resolvedFrom === undefined ? {} : { resolvedFrom: source.resolvedFrom }),
1604
+ };
1605
+ }
1606
+ /**
1607
+ * The same annotation with no `tagIds` key at all.
1608
+ *
1609
+ * Under `exactOptionalPropertyTypes` an explicit `undefined` is not the same as
1610
+ * an absent optional field, and this document is serialized straight to disk —
1611
+ * so an empty tag list must be an ABSENT key, not a null one.
1612
+ *
1613
+ * @param entry - the annotation to strip.
1614
+ * @returns the annotation without its tag list.
1615
+ */
1616
+ function withoutTags(entry) {
1617
+ const { tagIds: _removed, ...rest } = entry;
1618
+ return rest;
1619
+ }
1620
+ /**
1621
+ * Drop annotations that carry nothing, so the document does not accumulate
1622
+ * empty objects as tags are removed.
1623
+ *
1624
+ * @param annotations - the table to clean.
1625
+ * @returns the table with empty entries removed.
1626
+ */
1627
+ function cleanupAnnotations(annotations) {
1628
+ const out = {};
1629
+ for (const [entryId, entry] of Object.entries(annotations)) {
1630
+ // A fetch record alone is load-bearing (the payload is on disk waiting to
1631
+ // be extracted), and a translation is the costly-to-rebuild kind of record
1632
+ // this document exists to keep — neither may be swept as "empty". Neither
1633
+ // may a captured title/excerpt: it is the card's memory of what the paper
1634
+ // was, and it must outlive the body it came from.
1635
+ if (entry.body === undefined && (entry.tagIds ?? []).length === 0 && entry.error === undefined
1636
+ && entry.fetch === undefined && entry.translation === undefined
1637
+ && entry.title === undefined && entry.excerpt === undefined)
1638
+ continue;
1639
+ out[entryId] = (entry.tagIds ?? []).length > 0 ? entry : withoutTags(entry);
1640
+ }
1641
+ return out;
1642
+ }
1643
+ /**
1644
+ * Parse a translation sidecar's segment map, tolerating a damaged file.
1645
+ *
1646
+ * @param raw - the file's content, or `undefined` when it is gone.
1647
+ * @returns the map, or `undefined` when the file holds none.
1648
+ */
1649
+ function readSegmentsFile(raw) {
1650
+ if (raw === undefined)
1651
+ return undefined;
1652
+ try {
1653
+ const parsed = JSON.parse(raw);
1654
+ if (typeof parsed !== 'object' || parsed === null)
1655
+ return undefined;
1656
+ return Object.fromEntries(Object.entries(parsed).filter(([, target]) => typeof target === 'string'));
1657
+ }
1658
+ catch {
1659
+ return undefined;
1660
+ }
1661
+ }
1662
+ /**
1663
+ * Coerce one batch of learned sentences: bounded in count and in size, each
1664
+ * carrying its hash and both texts.
1665
+ *
1666
+ * @param entries - the client's batch.
1667
+ * @returns the entries worth storing.
1668
+ */
1669
+ function normalizeLearns(entries) {
1670
+ const out = [];
1671
+ for (const entry of entries.slice(0, TRANSLATION_WRITE_LIMIT)) {
1672
+ if (typeof entry.hash !== 'string' || entry.hash.length === 0 || entry.hash.length > 64)
1673
+ continue;
1674
+ if (typeof entry.source !== 'string' || typeof entry.target !== 'string')
1675
+ continue;
1676
+ if (entry.source.length === 0 || entry.target.length === 0)
1677
+ continue;
1678
+ out.push({
1679
+ hash: entry.hash,
1680
+ source: entry.source.slice(0, TRANSLATION_TEXT_LIMIT),
1681
+ target: entry.target.slice(0, TRANSLATION_TEXT_LIMIT),
1682
+ });
1683
+ }
1684
+ return out;
1685
+ }
1686
+ /** How old a `fetching` record may be before it is treated as abandoned. */
1687
+ const FETCH_STALE_MS = 10 * 60 * 1000;
1688
+ /** Whether an ISO instant is recent enough to still mean "in flight". */
1689
+ function isRecent(iso, now, windowMs) {
1690
+ const at = new Date(iso).getTime();
1691
+ return Number.isFinite(at) && now - at <= windowMs;
1692
+ }
1693
+ /** Whether a cache deadline is still in the future. */
1694
+ function isFresh(expiresAt) {
1695
+ const deadline = new Date(expiresAt).getTime();
1696
+ return Number.isFinite(deadline) && deadline > Date.now();
1697
+ }