@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
package/lib/index.js ADDED
@@ -0,0 +1,3660 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
5
+ //#region lib/types/types.js
6
+ /**
7
+ * Shared vocabulary of `@khorsheed/dsh-reader`: the persisted state document,
8
+ * the Remote wire payloads, and the pure helpers both faces share.
9
+ *
10
+ * Two responsibilities are deliberately split across the process boundary:
11
+ *
12
+ * - the HOST half owns the network (the sanctioned `ctx.web` fetch seam) and
13
+ * the disk (one version-guarded JSON document over `ctx.fs`). It stores the
14
+ * LAST RAW PAYLOAD per source — feed XML or article HTML — plus fetch
15
+ * metadata, and never parses either.
16
+ * - the BROWSER half owns parsing. The host runtime has no XML/DOM parser at
17
+ * all (Node's `globalThis.DOMParser` is absent), so the single parser lives
18
+ * in the browser where `DOMParser` is native, and its output never needs to
19
+ * be persisted: entries and extracted article bodies are session state.
20
+ *
21
+ * @module @khorsheed/dsh-reader/types
22
+ */
23
+ /**
24
+ * Whether an automatic retry is worth the request.
25
+ *
26
+ * Only a transport failure is: a bot wall, a login wall, a PDF and a 404 all
27
+ * answer the same way forever, so retrying them on a timer would be a crawler
28
+ * with a grudge rather than a reader. The manual refresh stays available.
29
+ *
30
+ * @param code - the recorded failure.
31
+ * @returns true when the entry may be retried automatically.
32
+ */
33
+ function isRetryablePreviewFailure(code) {
34
+ return code === "unreachable";
35
+ }
36
+ /** The default cache policy: a day, and a few hundred articles. */
37
+ const DEFAULT_CACHE_POLICY = {
38
+ ttlHours: 24,
39
+ maxEntries: 500
40
+ };
41
+ /**
42
+ * The schema version of the persisted translation tiers (the global sentence
43
+ * memory's table, and each entry's segment map). A browser Translator model
44
+ * upgrade quietly changes what the same sentence translates to, and a
45
+ * segmentation change here changes what a "sentence" is — bumping this turns
46
+ * every old record into a MISS. Records are evicted LAZILY (the next write or
47
+ * budget pass drops them), never wiped eagerly.
48
+ */
49
+ const TRANSLATION_STORAGE_VERSION = 1;
50
+ /**
51
+ * The default total budget for both translation tiers, in characters — 64 MB
52
+ * worth of text. Counted in characters, like every other budget in this
53
+ * document (a CJK sentence costs more bytes than that reads; the approximation
54
+ * is the store's existing convention).
55
+ */
56
+ const DEFAULT_TRANSLATION_BUDGET_CHARS = 67108864;
57
+ /** How many sentences the global memory keeps before its own LRU end is evicted. */
58
+ const MAX_TRANSLATION_MEMORY_ENTRIES = 5e4;
59
+ /** Default state root segment under `$DSH_HOME/state`. */
60
+ const STATE_ROOT_SEGMENT = "dsh-reader";
61
+ /** Default daily refresh time. */
62
+ const DEFAULT_REFRESH_TIME = "10:00";
63
+ /**
64
+ * Per-source raw-payload budget in characters.
65
+ *
66
+ * Sized to the HOST's egress cap rather than below it: a deployment can raise
67
+ * `web-fetch-http`'s `maxBodyChars` (the acceptance instance runs it at
68
+ * 2,000,000), and a per-source budget smaller than what the seam is willing to
69
+ * deliver would silently re-truncate every large feed here — the reader would
70
+ * pay for the fetch and still get half a document, with the note blaming the
71
+ * fetch. The fetch cap is the outer bound; this one must not be the inner one.
72
+ */
73
+ const MAX_BODY_CHARS_PER_SOURCE = 2097152;
74
+ /** Total raw-payload budget in characters across all sources (newest kept). */
75
+ const MAX_TOTAL_BODY_CHARS = 12582912;
76
+ /**
77
+ * How large an extracted body may be before it lives in its own file.
78
+ *
79
+ * `state.json` is read and rewritten as a whole on every mutation, so a research
80
+ * paper whose figures are inlined as base64 (transformer-circuits.pub's emotions
81
+ * paper is a 41.8 MB HTML document and extracts to tens of megabytes) would make
82
+ * every reader operation pay a multi-megabyte JSON round trip. Above this
83
+ * threshold the body is written to one file under `bodies/` and the document
84
+ * keeps only its metadata — so the cache policy (`ttlHours`, `maxEntries`) still
85
+ * governs it exactly like an inline body, and eviction deletes the file.
86
+ */
87
+ const INLINE_BODY_MAX_CHARS = 262144;
88
+ /**
89
+ * Build a display label for a source from its URL: the registrable-ish host
90
+ * plus the last meaningful path segment, which is what distinguishes several
91
+ * feeds from one publisher.
92
+ *
93
+ * @param url - the source URL (any string; a malformed one degrades to its own head).
94
+ * @returns a short human label.
95
+ */
96
+ function defaultSourceLabel(url) {
97
+ try {
98
+ const parsed = new URL(url);
99
+ const host = parsed.hostname.replace(/^www\./, "");
100
+ const tail = (parsed.pathname.split("/").filter((segment) => segment.length > 0).at(-1) ?? "").replace(/\.(xml|rss|atom|json)$/i, "");
101
+ return tail.length > 0 ? `${host}/${tail}` : host;
102
+ } catch {
103
+ return url.slice(0, 60);
104
+ }
105
+ }
106
+ /**
107
+ * Build a stable entry id. Feeds are inconsistent about identifiers, so the
108
+ * order is: explicit guid/id, then the entry link, then a hash over title and
109
+ * link together (a feed with neither must still deduplicate across refreshes).
110
+ *
111
+ * @param entry - the identifying fields an entry may carry.
112
+ * @returns an id stable across refreshes of the same feed content.
113
+ */
114
+ function stableEntryId(entry) {
115
+ const explicit = entry.guid?.trim();
116
+ if (explicit !== void 0 && explicit.length > 0) return `g:${explicit}`;
117
+ const link = entry.link?.trim();
118
+ if (link !== void 0 && link.length > 0) return `l:${link}`;
119
+ return `h:${hash32(`${entry.title}\u0000${entry.link ?? ""}`).toString(36)}`;
120
+ }
121
+ /**
122
+ * The entry id a saved link's single entry carries.
123
+ *
124
+ * A `link` source is exactly one entry, so the host must be able to name that
125
+ * entry when it records a per-entry annotation (the fetch failure the detail
126
+ * view reads). Both halves use this one function: a magic string duplicated
127
+ * across the process boundary is a bug waiting for a rename.
128
+ *
129
+ * @param sourceId - the link source's id.
130
+ * @returns the entry id the browser will build for it.
131
+ */
132
+ function linkEntryId(sourceId) {
133
+ return `link:${sourceId}`;
134
+ }
135
+ /**
136
+ * The query that selects every entry of one source KIND.
137
+ *
138
+ * The same local-predicate mechanism as a source or tag filter, so a kind
139
+ * narrowing is visible in the search box and can be cleared there. The prefix
140
+ * cannot collide with a real source id: ids are always `<kind>-<hash>`.
141
+ *
142
+ * @param kind - the source kind to select.
143
+ * @returns the query string.
144
+ */
145
+ function kindQuery(kind) {
146
+ return `#${kind}`;
147
+ }
148
+ /** The source kinds a kind query can name, for the predicate that reads one. */
149
+ const READER_SOURCE_KINDS = ["rss", "link"];
150
+ /**
151
+ * Shorten a summary for a card, collapsing whitespace so multi-line feed
152
+ * descriptions do not blow up the layout.
153
+ *
154
+ * @param text - the raw summary or content text.
155
+ * @param maxLength - the maximum returned length, ellipsis included.
156
+ * @returns the collapsed, shortened text, or `undefined` when there is none.
157
+ */
158
+ function summarize(text, maxLength = 280) {
159
+ if (text === void 0) return void 0;
160
+ const collapsed = text.replace(/\s+/g, " ").trim();
161
+ if (collapsed.length === 0) return void 0;
162
+ if (collapsed.length <= maxLength) return collapsed;
163
+ return `${collapsed.slice(0, maxLength - 1)}…`;
164
+ }
165
+ /** Coerce an unknown thrown value into a message string. */
166
+ function errorMessage(error) {
167
+ return error instanceof Error ? error.message : String(error);
168
+ }
169
+ /** 32-bit FNV-1a hash, used only to build stable ids (never for security). */
170
+ function hash32(input) {
171
+ let hash = 2166136261;
172
+ for (let index = 0; index < input.length; index++) {
173
+ hash ^= input.charCodeAt(index);
174
+ hash = Math.imul(hash, 16777619);
175
+ }
176
+ return hash >>> 0;
177
+ }
178
+ //#endregion
179
+ //#region lib/types/store.js
180
+ /**
181
+ * The host-side state store: one JSON document under the deployment's state
182
+ * root, holding the source list and, per source, the raw payload of its last
183
+ * fetch plus that fetch's metadata.
184
+ *
185
+ * **Why plain `node:fs` and not `ctx.fs`.** `ctx.fs` is the SANDBOXED
186
+ * filesystem: every mutation is fenced by the calling session's policy, so a
187
+ * workspace-write session refuses a write to `$DSH_HOME/state` with
188
+ * `FS_SANDBOX_DENIED` — which is exactly what happened on the acceptance
189
+ * instance, where a subscription failed with
190
+ * `cannot write "…/state/dsh-reader/state.json": file access denied under
191
+ * workspace-write mode`. That fence is correct and must stay; the mistake is
192
+ * aiming a session-fenced capability at host-owned durable state. This
193
+ * package's state belongs to the DEPLOYMENT, not to whichever session happened
194
+ * to click "fetch", so it is written the way this repo's other host-state
195
+ * packages write theirs (`packages/lab/src/state.ts`): `node:fs`, atomic
196
+ * via a temporary file plus rename.
197
+ *
198
+ * The rest of the contract is unchanged and is why this file exists:
199
+ *
200
+ * - a missing document reads as empty, and a **corrupt** one refuses rather
201
+ * than being overwritten, so a hand-edited file stays visible until its
202
+ * owner fixes it instead of being silently destroyed by the next refresh;
203
+ * - an unwritable root degrades to memory-only instead of taking the plugin
204
+ * down, because a composition must never fail for lack of a writable disk;
205
+ * - nothing here parses a feed. The document stores raw payloads, and the
206
+ * browser half — the only side with a DOM parser — turns them into entries.
207
+ *
208
+ * @module @khorsheed/dsh-reader/store
209
+ */
210
+ /** The failure codes the document may carry, for normalization. */
211
+ const PREVIEW_FAILURE_CODES = /* @__PURE__ */ new Set([
212
+ "blocked",
213
+ "login",
214
+ "unsupported-type",
215
+ "redirected",
216
+ "empty",
217
+ "unreachable",
218
+ "http",
219
+ "unreadable"
220
+ ]);
221
+ /** How many tags one document may define (a vocabulary, not a folksonomy dump). */
222
+ const MAX_TAGS = 200;
223
+ /** How many entries may carry fetched bodies, before the newest-first eviction. */
224
+ const MAX_CACHED_BODIES = 500;
225
+ /** The state file name inside the plugin's state root. */
226
+ const READER_STATE_FILE_NAME = "state.json";
227
+ /** A store failure that the service maps onto a wire outcome. */
228
+ var ReaderStoreError = class extends Error {
229
+ kind;
230
+ constructor(message, kind) {
231
+ super(message);
232
+ this.kind = kind;
233
+ this.name = "ReaderStoreError";
234
+ }
235
+ };
236
+ /**
237
+ * Resolve the state root: an explicit config value wins, then
238
+ * `$DSH_HOME/state/dsh-reader`, then `<cwd>/.dsh-reader`.
239
+ *
240
+ * @param configured - the plugin-provided override, if any.
241
+ * @returns the absolute state root directory.
242
+ */
243
+ function resolveReaderStateRoot(configured) {
244
+ if (configured !== void 0 && configured !== "") return configured;
245
+ const home = process.env.DSH_HOME;
246
+ if (home !== void 0 && home !== "") return join(home, "state", STATE_ROOT_SEGMENT);
247
+ return join(process.cwd(), `.${STATE_ROOT_SEGMENT}`);
248
+ }
249
+ /** The empty document. */
250
+ function emptyStateDoc() {
251
+ return {
252
+ version: 1,
253
+ sources: [],
254
+ refresh: {
255
+ enabled: true,
256
+ timeOfDay: "10:00"
257
+ }
258
+ };
259
+ }
260
+ /** Serialize the document (trailing newline, the repo's state-file shape). */
261
+ function serializeStateDoc(doc) {
262
+ return `${JSON.stringify(doc, null, 2)}\n`;
263
+ }
264
+ /**
265
+ * Coerce a parsed value into a document, dropping anything unrecognizable
266
+ * rather than trusting the file: this is user-editable state.
267
+ *
268
+ * @param value - the parsed JSON.
269
+ * @returns a valid document.
270
+ */
271
+ function normalizeStateDoc(value) {
272
+ const base = emptyStateDoc();
273
+ if (typeof value !== "object" || value === null) return base;
274
+ const record = value;
275
+ const sources = Array.isArray(record.sources) ? record.sources : [];
276
+ const refresh = typeof record.refresh === "object" && record.refresh !== null ? record.refresh : {};
277
+ return {
278
+ version: 1,
279
+ sources: sources.flatMap((item) => {
280
+ const source = normalizeSource(item);
281
+ return source === void 0 ? [] : [source];
282
+ }),
283
+ refresh: {
284
+ enabled: refresh.enabled !== false,
285
+ timeOfDay: typeof refresh.timeOfDay === "string" && /^\d{1,2}:\d{2}$/.test(refresh.timeOfDay) ? refresh.timeOfDay : base.refresh.timeOfDay
286
+ },
287
+ ...typeof record.lastRefreshAt === "string" ? { lastRefreshAt: record.lastRefreshAt } : {},
288
+ ...(() => {
289
+ const cache = normalizeCachePolicy(record.cache);
290
+ return cache === void 0 ? {} : { cache };
291
+ })(),
292
+ ...(() => {
293
+ const tags = normalizeTags(record.tags);
294
+ return Object.keys(tags).length > 0 ? { tags } : {};
295
+ })(),
296
+ ...(() => {
297
+ const annotations = normalizeAnnotations(record.annotations);
298
+ return Object.keys(annotations).length > 0 ? { annotations } : {};
299
+ })(),
300
+ ...(() => {
301
+ const translationMemory = normalizeTranslationMemory(record.translationMemory);
302
+ return translationMemory === void 0 ? {} : { translationMemory };
303
+ })(),
304
+ ...(() => {
305
+ const recent = normalizeRecent(record.recent);
306
+ return recent.length > 0 ? { recent } : {};
307
+ })()
308
+ };
309
+ }
310
+ /**
311
+ * The recent list, dropping anything without the two ids and a timestamp.
312
+ *
313
+ * The order is the file's, which is newest-first by construction (the service
314
+ * unshifts); a hand-edited file that disagrees still renders in its own order,
315
+ * which is the honest reading of what is on disk.
316
+ */
317
+ function normalizeRecent(value) {
318
+ if (!Array.isArray(value)) return [];
319
+ const out = [];
320
+ const seen = /* @__PURE__ */ new Set();
321
+ for (const item of value) {
322
+ if (typeof item !== "object" || item === null) continue;
323
+ const record = item;
324
+ const entryId = typeof record.entryId === "string" ? record.entryId.trim() : "";
325
+ const sourceId = typeof record.sourceId === "string" ? record.sourceId.trim() : "";
326
+ const title = typeof record.title === "string" ? record.title.trim() : "";
327
+ const readAt = typeof record.readAt === "string" ? record.readAt : "";
328
+ if (entryId.length === 0 || sourceId.length === 0 || readAt.length === 0) continue;
329
+ if (seen.has(entryId)) continue;
330
+ seen.add(entryId);
331
+ out.push({
332
+ entryId,
333
+ sourceId,
334
+ title: title.slice(0, 300),
335
+ ...typeof record.url === "string" && record.url.length > 0 ? { url: record.url } : {},
336
+ readAt
337
+ });
338
+ if (out.length >= 100) break;
339
+ }
340
+ return out;
341
+ }
342
+ /** The cache policy, coerced to something usable (absent = the default). */
343
+ function normalizeCachePolicy(value) {
344
+ if (typeof value !== "object" || value === null) return void 0;
345
+ const record = value;
346
+ const ttlHours = typeof record.ttlHours === "number" && Number.isFinite(record.ttlHours) && record.ttlHours >= 0 ? Math.min(record.ttlHours, 2160) : DEFAULT_CACHE_POLICY.ttlHours;
347
+ const maxEntries = typeof record.maxEntries === "number" && Number.isFinite(record.maxEntries) && record.maxEntries > 0 ? Math.min(Math.floor(record.maxEntries), 5e3) : DEFAULT_CACHE_POLICY.maxEntries;
348
+ const translationBudgetChars = typeof record.translationBudgetChars === "number" && Number.isFinite(record.translationBudgetChars) && record.translationBudgetChars > 0 ? Math.min(Math.floor(record.translationBudgetChars), 1073741824) : void 0;
349
+ return {
350
+ ttlHours,
351
+ maxEntries,
352
+ ...translationBudgetChars === void 0 ? {} : { translationBudgetChars }
353
+ };
354
+ }
355
+ /** A `bodies/` file name is one path segment, or it is not a name we wrote. */
356
+ function normalizeFileName(value) {
357
+ return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) ? value : void 0;
358
+ }
359
+ /** The global memory's manifest; kept as recorded (a version bump reads as a miss, lazily). */
360
+ function normalizeTranslationMemory(value) {
361
+ if (typeof value !== "object" || value === null) return void 0;
362
+ const record = value;
363
+ const file = normalizeFileName(record.file);
364
+ if (typeof record.version !== "number" || file === void 0) return void 0;
365
+ return {
366
+ version: record.version,
367
+ file,
368
+ entries: typeof record.entries === "number" && Number.isFinite(record.entries) ? Math.max(0, Math.floor(record.entries)) : 0,
369
+ chars: typeof record.chars === "number" && Number.isFinite(record.chars) ? Math.max(0, Math.floor(record.chars)) : 0,
370
+ updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : (/* @__PURE__ */ new Date(0)).toISOString()
371
+ };
372
+ }
373
+ /**
374
+ * One entry's translation segment map, when it carries the fields that make it
375
+ * one. The schema version is preserved as recorded: a mismatch is a MISS when
376
+ * the record is served, and the record is evicted by the next write or budget
377
+ * pass — never by the read path.
378
+ */
379
+ function normalizeTranslation(value) {
380
+ if (typeof value !== "object" || value === null) return void 0;
381
+ const record = value;
382
+ if (typeof record.version !== "number") return void 0;
383
+ const pair = typeof record.pair === "string" ? record.pair : "";
384
+ const bodyHash = typeof record.bodyHash === "string" ? record.bodyHash : "";
385
+ if (pair.length === 0 || bodyHash.length === 0) return void 0;
386
+ const segments = typeof record.segments === "object" && record.segments !== null ? Object.fromEntries(Object.entries(record.segments).filter(([, target]) => typeof target === "string")) : void 0;
387
+ const file = normalizeFileName(record.file);
388
+ if (segments === void 0 && file === void 0) return void 0;
389
+ return {
390
+ version: record.version,
391
+ pair,
392
+ bodyHash,
393
+ ...segments !== void 0 && Object.keys(segments).length > 0 ? { segments } : {},
394
+ ...file === void 0 ? {} : { file },
395
+ ...typeof record.chars === "number" && Number.isFinite(record.chars) ? { chars: record.chars } : {},
396
+ translatedAt: typeof record.translatedAt === "string" ? record.translatedAt : (/* @__PURE__ */ new Date(0)).toISOString(),
397
+ lastUsedAt: typeof record.lastUsedAt === "string" ? record.lastUsedAt : (/* @__PURE__ */ new Date(0)).toISOString()
398
+ };
399
+ }
400
+ /** The tag vocabulary, dropping anything without an id and a name. */
401
+ function normalizeTags(value) {
402
+ if (typeof value !== "object" || value === null) return {};
403
+ const out = {};
404
+ for (const [id, entry] of Object.entries(value)) {
405
+ if (typeof entry !== "object" || entry === null) continue;
406
+ const record = entry;
407
+ const name = typeof record.name === "string" ? record.name.trim() : "";
408
+ if (id.length === 0 || name.length === 0) continue;
409
+ out[id] = {
410
+ id,
411
+ name: name.slice(0, 40),
412
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : (/* @__PURE__ */ new Date(0)).toISOString()
413
+ };
414
+ if (Object.keys(out).length >= 200) break;
415
+ }
416
+ return out;
417
+ }
418
+ /** The per-entry annotations, dropping entries that carry nothing. */
419
+ function normalizeAnnotations(value) {
420
+ if (typeof value !== "object" || value === null) return {};
421
+ const out = {};
422
+ for (const [entryId, entry] of Object.entries(value)) {
423
+ if (entryId.length === 0 || typeof entry !== "object" || entry === null) continue;
424
+ const record = entry;
425
+ const body = normalizeBody(record.body);
426
+ const tagIds = Array.isArray(record.tagIds) ? [...new Set(record.tagIds.filter((id) => typeof id === "string" && id.length > 0))] : [];
427
+ const fetch = normalizeFetch(record.fetch);
428
+ const translation = normalizeTranslation(record.translation);
429
+ const failureCode = PREVIEW_FAILURE_CODES.has(String(record.failureCode)) ? record.failureCode : void 0;
430
+ const annotation = {
431
+ ...body === void 0 ? {} : { body },
432
+ ...fetch === void 0 ? {} : { fetch },
433
+ ...translation === void 0 ? {} : { translation },
434
+ ...tagIds.length > 0 ? { tagIds } : {},
435
+ ...typeof record.error === "string" ? { error: record.error.slice(0, 500) } : {},
436
+ ...failureCode === void 0 ? {} : { failureCode },
437
+ ...typeof record.failedAt === "string" ? { failedAt: record.failedAt } : {},
438
+ ...typeof record.title === "string" && record.title.trim().length > 0 ? { title: record.title.trim().slice(0, 300) } : {},
439
+ ...typeof record.excerpt === "string" && record.excerpt.trim().length > 0 ? { excerpt: record.excerpt.trim().slice(0, 1e3) } : {}
440
+ };
441
+ if (annotation.body === void 0 && annotation.fetch === void 0 && annotation.tagIds === void 0 && annotation.error === void 0 && annotation.translation === void 0 && annotation.title === void 0 && annotation.excerpt === void 0) continue;
442
+ out[entryId] = annotation;
443
+ }
444
+ return out;
445
+ }
446
+ /**
447
+ * One in-flight or stored-raw record, when it carries the fields that make it one.
448
+ *
449
+ * @param value - the persisted field.
450
+ * @returns the normalized record, or `undefined`.
451
+ */
452
+ function normalizeFetch(value) {
453
+ if (typeof value !== "object" || value === null) return void 0;
454
+ const record = value;
455
+ const state = record.state === "fetching" || record.state === "raw" ? record.state : void 0;
456
+ if (state === void 0) return void 0;
457
+ const rawFile = typeof record.rawFile === "string" && record.rawFile.length > 0 ? record.rawFile : void 0;
458
+ return {
459
+ state,
460
+ at: typeof record.at === "string" ? record.at : (/* @__PURE__ */ new Date(0)).toISOString(),
461
+ ...rawFile === void 0 ? {} : { rawFile },
462
+ ...typeof record.chars === "number" && Number.isFinite(record.chars) ? { chars: record.chars } : {},
463
+ ...typeof record.url === "string" ? { url: record.url } : {},
464
+ ...record.truncated === true ? { truncated: true } : {}
465
+ };
466
+ }
467
+ /** One cached body, when it carries the fields that make it one. */
468
+ function normalizeBody(value) {
469
+ if (typeof value !== "object" || value === null) return void 0;
470
+ const record = value;
471
+ const inline = typeof record.html === "string" && record.html.length > 0;
472
+ const file = typeof record.file === "string" && record.file.length > 0 ? record.file : void 0;
473
+ if (!inline && file === void 0) return void 0;
474
+ return {
475
+ ...inline ? { html: record.html } : {},
476
+ ...file === void 0 ? {} : { file },
477
+ ...typeof record.chars === "number" && Number.isFinite(record.chars) ? { chars: record.chars } : {},
478
+ fetchedAt: typeof record.fetchedAt === "string" ? record.fetchedAt : (/* @__PURE__ */ new Date(0)).toISOString(),
479
+ expiresAt: typeof record.expiresAt === "string" ? record.expiresAt : (/* @__PURE__ */ new Date(0)).toISOString(),
480
+ url: typeof record.url === "string" ? record.url : "",
481
+ ...record.truncated === true ? { truncated: true } : {},
482
+ ...typeof record.scriptFigures === "number" && Number.isFinite(record.scriptFigures) ? { scriptFigures: record.scriptFigures } : {},
483
+ ...record.rendered === true ? { rendered: true } : {}
484
+ };
485
+ }
486
+ /** One source, or `undefined` when it lacks the fields that make it one. */
487
+ function normalizeSource(value) {
488
+ if (typeof value !== "object" || value === null) return void 0;
489
+ const record = value;
490
+ const id = typeof record.id === "string" ? record.id : void 0;
491
+ const url = typeof record.url === "string" ? record.url : void 0;
492
+ if (id === void 0 || url === void 0) return void 0;
493
+ const kind = record.kind === "link" ? "link" : "rss";
494
+ const status = record.status === "ok" || record.status === "fetching" || record.status === "error" ? record.status : void 0;
495
+ const failure = normalizePreviewFailure(record.failure);
496
+ return {
497
+ id,
498
+ kind,
499
+ url,
500
+ label: typeof record.label === "string" && record.label.length > 0 ? record.label : url,
501
+ enabled: record.enabled !== false,
502
+ addedAt: typeof record.addedAt === "string" ? record.addedAt : (/* @__PURE__ */ new Date(0)).toISOString(),
503
+ ...typeof record.fetchedAt === "string" ? { fetchedAt: record.fetchedAt } : {},
504
+ ...status !== void 0 ? { status } : {},
505
+ ...typeof record.error === "string" ? { error: record.error } : {},
506
+ ...record.truncated === true ? { truncated: true } : {},
507
+ ...failure === void 0 ? {} : { failure },
508
+ ...typeof record.resolvedFrom === "string" ? { resolvedFrom: record.resolvedFrom } : {},
509
+ ...typeof record.raw === "string" ? { raw: record.raw } : {},
510
+ ...typeof record.timeOfDay === "string" ? { timeOfDay: record.timeOfDay } : {}
511
+ };
512
+ }
513
+ /**
514
+ * One recorded preview failure, or `undefined` when the record is not one.
515
+ *
516
+ * An unknown code is dropped rather than trusted: the union is what the
517
+ * browser switches on, and a code this build does not know would render as a
518
+ * missing sentence instead of a diagnosis.
519
+ *
520
+ * @param value - the persisted field.
521
+ * @returns the normalized failure, or `undefined`.
522
+ */
523
+ function normalizePreviewFailure(value) {
524
+ if (typeof value !== "object" || value === null) return void 0;
525
+ const record = value;
526
+ const code = record.code;
527
+ if (typeof code !== "string" || !PREVIEW_FAILURE_CODES.has(code)) return void 0;
528
+ return {
529
+ code,
530
+ message: typeof record.message === "string" ? record.message.slice(0, 500) : "",
531
+ at: typeof record.at === "string" ? record.at : (/* @__PURE__ */ new Date(0)).toISOString()
532
+ };
533
+ }
534
+ /**
535
+ * Bound the document's payload bytes without dropping a subscription.
536
+ *
537
+ * Newest-first: the freshest payload is the one the reader is most likely to
538
+ * open, so it survives and the oldest bodies are the ones released. Keeping
539
+ * every source row regardless is deliberate — a source whose body was evicted
540
+ * still refreshes, and dropping subscriptions to save space would be a silent
541
+ * data loss the reader never asked for.
542
+ *
543
+ * @param doc - the document to bound.
544
+ * @returns the bounded document, and whether anything was released.
545
+ */
546
+ function boundPayloads(doc) {
547
+ let changed = false;
548
+ const byNewest = [...doc.sources].sort((a, b) => (b.fetchedAt ?? "").localeCompare(a.fetchedAt ?? ""));
549
+ const trimmed = /* @__PURE__ */ new Map();
550
+ let total = 0;
551
+ for (const source of byNewest) {
552
+ const raw = source.raw;
553
+ if (raw === void 0) continue;
554
+ if (raw.length > 2097152) {
555
+ trimmed.set(source.id, {
556
+ ...source,
557
+ raw: raw.slice(0, MAX_BODY_CHARS_PER_SOURCE),
558
+ truncated: true
559
+ });
560
+ total += MAX_BODY_CHARS_PER_SOURCE;
561
+ changed = true;
562
+ continue;
563
+ }
564
+ if (total + raw.length > 12582912) {
565
+ const { raw: _dropped, ...withoutRaw } = source;
566
+ trimmed.set(source.id, withoutRaw);
567
+ changed = true;
568
+ continue;
569
+ }
570
+ total += raw.length;
571
+ trimmed.set(source.id, source);
572
+ }
573
+ if (!changed) return {
574
+ doc,
575
+ changed
576
+ };
577
+ return {
578
+ doc: {
579
+ ...doc,
580
+ sources: doc.sources.map((s) => trimmed.get(s.id) ?? s)
581
+ },
582
+ changed
583
+ };
584
+ }
585
+ /** Sub-directory holding bodies too large to inline in the document. */
586
+ const READER_BODIES_DIR = "bodies";
587
+ /**
588
+ * The global translation memory's file name inside `bodies/`.
589
+ *
590
+ * One fixed name rather than a per-key hash: the table is a single artifact,
591
+ * rewritten whole by the (already debounced) write path, and referenced from
592
+ * the document's `translationMemory` manifest so `pruneBodies` keeps it.
593
+ */
594
+ const TRANSLATION_MEMORY_FILE = "translation-memory.json";
595
+ /** Native-fs state store: the deployment's document, not the session's. */
596
+ var ReaderStore = class {
597
+ stateRoot;
598
+ file;
599
+ bodiesDir;
600
+ /** Set once the root has proven unwritable; the service then goes memory-only. */
601
+ unwritable;
602
+ /**
603
+ * @param config - optional state-root override.
604
+ */
605
+ constructor(config = {}) {
606
+ this.stateRoot = resolveReaderStateRoot(config.stateRoot);
607
+ this.file = join(this.stateRoot, READER_STATE_FILE_NAME);
608
+ this.bodiesDir = join(this.stateRoot, READER_BODIES_DIR);
609
+ }
610
+ /**
611
+ * Write one body to its own file, atomically, and return the file's name.
612
+ *
613
+ * The name is a digest of the entry id: entry ids are URLs, and a document
614
+ * carrying an absolute path (or a path derived from remote text) is a
615
+ * directory-traversal waiting to happen.
616
+ *
617
+ * @param entryId - the entry the body belongs to.
618
+ * @param html - the normalized body markup.
619
+ * @returns the file name to store in the document.
620
+ */
621
+ writeBody(entryId, html) {
622
+ const name = `${bodyFileName(entryId)}.html`;
623
+ mkdirSync(this.bodiesDir, { recursive: true });
624
+ const target = join(this.bodiesDir, name);
625
+ const temporary = `${target}.${process.pid}.tmp`;
626
+ try {
627
+ writeFileSync(temporary, html);
628
+ renameSync(temporary, target);
629
+ } catch (error) {
630
+ rmSync(temporary, { force: true });
631
+ throw new ReaderStoreError(`reader: ${target} could not be written — ${errorMessage$1(error)}`, "io");
632
+ }
633
+ return name;
634
+ }
635
+ /**
636
+ * Write one NAMED artifact into `bodies/`, atomically.
637
+ *
638
+ * Unlike {@link writeBody} the name is the caller's, not a hash of an entry
639
+ * id: the global translation memory is one fixed file, not one per entry. The
640
+ * name is validated to a bare file name — this directory must never grow a
641
+ * path.
642
+ *
643
+ * @param name - the bare file name to write.
644
+ * @param content - the file's content.
645
+ * @returns the name written.
646
+ */
647
+ writeNamedBody(name, content) {
648
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) throw new ReaderStoreError(`reader: refusing to write ${name} — not a bare file name`, "io");
649
+ mkdirSync(this.bodiesDir, { recursive: true });
650
+ const target = join(this.bodiesDir, name);
651
+ const temporary = `${target}.${process.pid}.tmp`;
652
+ try {
653
+ writeFileSync(temporary, content);
654
+ renameSync(temporary, target);
655
+ } catch (error) {
656
+ rmSync(temporary, { force: true });
657
+ throw new ReaderStoreError(`reader: ${target} could not be written — ${errorMessage$1(error)}`, "io");
658
+ }
659
+ return name;
660
+ }
661
+ /**
662
+ * Read one body file.
663
+ *
664
+ * @param name - the file name recorded in the document.
665
+ * @returns the markup, or `undefined` when the file is gone or unreadable.
666
+ */
667
+ readBody(name) {
668
+ try {
669
+ return readFileSync(join(this.bodiesDir, name), "utf8");
670
+ } catch {
671
+ return;
672
+ }
673
+ }
674
+ /**
675
+ * Delete body files the document no longer references.
676
+ *
677
+ * Eviction (`maxEntries`), tag edits that drop an annotation, and a hand-edited
678
+ * document all release bodies by REMOVING them from the JSON — which would
679
+ * otherwise leave their files on disk forever. Best-effort by design: a failed
680
+ * sweep must never fail the commit that triggered it.
681
+ *
682
+ * @param doc - the document that was just written.
683
+ * @returns how many files were removed.
684
+ */
685
+ pruneBodies(doc) {
686
+ const referenced = /* @__PURE__ */ new Set();
687
+ for (const annotation of Object.values(doc.annotations ?? {})) {
688
+ if (annotation.body?.file !== void 0) referenced.add(annotation.body.file);
689
+ if (annotation.fetch?.rawFile !== void 0) referenced.add(annotation.fetch.rawFile);
690
+ if (annotation.translation?.file !== void 0) referenced.add(annotation.translation.file);
691
+ }
692
+ if (doc.translationMemory !== void 0) referenced.add(doc.translationMemory.file);
693
+ let names;
694
+ try {
695
+ names = readdirSync(this.bodiesDir);
696
+ } catch {
697
+ return 0;
698
+ }
699
+ let removed = 0;
700
+ for (const name of names) {
701
+ if (referenced.has(name)) continue;
702
+ try {
703
+ rmSync(join(this.bodiesDir, name), { force: true });
704
+ removed += 1;
705
+ } catch {}
706
+ }
707
+ return removed;
708
+ }
709
+ /** The absolute path of the state file (diagnostics and tests). */
710
+ get path() {
711
+ return this.file;
712
+ }
713
+ /** Why persistence is unavailable, when it is. */
714
+ get unavailableReason() {
715
+ return this.unwritable;
716
+ }
717
+ /**
718
+ * Whether persistence is available.
719
+ *
720
+ * Probed by creating the state root, not by asking a capability: a
721
+ * read-only deployment (a container with no writable home) must degrade to
722
+ * memory-only, and that is only knowable by trying.
723
+ */
724
+ get available() {
725
+ if (this.unwritable !== void 0) return false;
726
+ try {
727
+ mkdirSync(this.stateRoot, { recursive: true });
728
+ return true;
729
+ } catch (error) {
730
+ this.unwritable = `${this.stateRoot}: ${errorMessage$1(error)}`;
731
+ return false;
732
+ }
733
+ }
734
+ /**
735
+ * Read the document tolerantly.
736
+ *
737
+ * @returns the document and its freshness token; a missing file reads as empty.
738
+ * @throws ReaderStoreError when the file exists but does not parse.
739
+ */
740
+ async read() {
741
+ if (!this.available) return {
742
+ doc: emptyStateDoc(),
743
+ version: null
744
+ };
745
+ let text;
746
+ try {
747
+ text = readFileSync(this.file, "utf8");
748
+ } catch (error) {
749
+ if (isMissing(error)) return {
750
+ doc: emptyStateDoc(),
751
+ version: null
752
+ };
753
+ throw new ReaderStoreError(`reader: ${this.file} could not be read — ${errorMessage$1(error)}`, "io");
754
+ }
755
+ try {
756
+ return {
757
+ doc: normalizeStateDoc(JSON.parse(text)),
758
+ version: String(text.length) + ":" + String(statSync(this.file).mtimeMs)
759
+ };
760
+ } catch (error) {
761
+ if (error instanceof ReaderStoreError) throw error;
762
+ throw new ReaderStoreError(`reader: ${this.file} does not parse — fix or remove it by hand`, "corrupt");
763
+ }
764
+ }
765
+ /**
766
+ * Write the document atomically under the token from the last read.
767
+ *
768
+ * @param doc - the document to publish.
769
+ * @param version - the token from the last read (`null` for the first write).
770
+ * @returns the new freshness token.
771
+ */
772
+ async write(doc, version) {
773
+ if (!this.available) throw new ReaderStoreError("reader: no writable state root", "io");
774
+ if (version !== null) {
775
+ if ((await this.read()).version !== version) throw new ReaderStoreError(`reader: ${this.file} changed underneath this write`, "io");
776
+ }
777
+ const temporary = `${this.file}.${process.pid}.tmp`;
778
+ try {
779
+ writeFileSync(temporary, serializeStateDoc(doc));
780
+ renameSync(temporary, this.file);
781
+ } catch (error) {
782
+ rmSync(temporary, { force: true });
783
+ throw new ReaderStoreError(`reader: ${this.file} could not be written — ${errorMessage$1(error)}`, "io");
784
+ }
785
+ const text = serializeStateDoc(doc);
786
+ return String(text.length) + ":" + String(statSync(this.file).mtimeMs);
787
+ }
788
+ /**
789
+ * Read-modify-write with the one concurrent-edit retry.
790
+ *
791
+ * @param mutate - pure function from the current document to the next one.
792
+ * @returns the document that ended up on disk.
793
+ */
794
+ async update(mutate) {
795
+ for (let attempt = 0; attempt < 2; attempt++) {
796
+ const { doc, version } = await this.read();
797
+ const next = boundAnnotations(boundPayloads(mutate(doc)).doc).doc;
798
+ try {
799
+ await this.write(next, version);
800
+ this.pruneBodies(next);
801
+ return next;
802
+ } catch (error) {
803
+ if (attempt === 1 || !(error instanceof ReaderStoreError) || error.kind !== "io") throw error;
804
+ }
805
+ }
806
+ /* v8 ignore next -- the loop either returns or throws */
807
+ throw new ReaderStoreError("reader: could not commit after a concurrent-edit retry", "io");
808
+ }
809
+ };
810
+ /** Whether a native fs failure is "there is no file yet". */
811
+ /** The bare file name one entry's body lives under. */
812
+ function bodyFileName(entryId) {
813
+ return createHash("sha1").update(entryId).digest("hex").slice(0, 32);
814
+ }
815
+ function isMissing(error) {
816
+ return error?.code === "ENOENT";
817
+ }
818
+ /** A native fs failure's message, with its code, for the wire. */
819
+ function errorMessage$1(error) {
820
+ const code = error?.code;
821
+ const message = error instanceof Error ? error.message : String(error);
822
+ return code === void 0 ? message : `${code}: ${message}`;
823
+ }
824
+ /**
825
+ * Bound the cached article bodies without touching what the reader authored.
826
+ *
827
+ * Newest-first: the article just fetched is the one being read, so the budget
828
+ * releases the older copies. Tags are NOT part of this budget — a tag is a
829
+ * sentence the reader wrote, and losing it to make room for a cached page would
830
+ * be the worst trade in this document.
831
+ *
832
+ * @param doc - the document to bound.
833
+ * @returns the bounded document, and whether anything was released.
834
+ */
835
+ function boundAnnotations(doc) {
836
+ const annotations = doc.annotations;
837
+ if (annotations === void 0) return {
838
+ doc,
839
+ changed: false
840
+ };
841
+ const withBody = Object.entries(annotations).filter(([, entry]) => entry.body !== void 0);
842
+ const limit = Math.max(1, doc.cache?.maxEntries ?? 500);
843
+ if (withBody.length <= limit) return {
844
+ doc,
845
+ changed: false
846
+ };
847
+ const keep = new Set([...withBody].sort((a, b) => (b[1].body?.fetchedAt ?? "").localeCompare(a[1].body?.fetchedAt ?? "")).slice(0, limit).map(([entryId]) => entryId));
848
+ const next = {};
849
+ for (const [entryId, entry] of Object.entries(annotations)) {
850
+ if (entry.body === void 0 || keep.has(entryId)) {
851
+ next[entryId] = entry;
852
+ continue;
853
+ }
854
+ const { body: _released, translation: _linked, ...rest } = entry;
855
+ if (rest.tagIds === void 0 && rest.error === void 0 && rest.fetch === void 0 && rest.title === void 0 && rest.excerpt === void 0) continue;
856
+ next[entryId] = rest;
857
+ }
858
+ return {
859
+ doc: {
860
+ ...doc,
861
+ annotations: next
862
+ },
863
+ changed: true
864
+ };
865
+ }
866
+ /**
867
+ * Drop tags nothing references any more.
868
+ *
869
+ * @param doc - the document to prune.
870
+ * @returns the pruned document, and how many tags went.
871
+ */
872
+ function pruneOrphanTags(doc) {
873
+ const annotations = doc.annotations ?? {};
874
+ const used = new Set(Object.values(annotations).flatMap((entry) => entry.tagIds ?? []));
875
+ const tags = doc.tags ?? {};
876
+ const kept = {};
877
+ let removed = 0;
878
+ for (const [id, tag] of Object.entries(tags)) if (used.has(id)) kept[id] = tag;
879
+ else removed += 1;
880
+ if (removed === 0) return {
881
+ doc,
882
+ removed
883
+ };
884
+ return {
885
+ doc: {
886
+ ...doc,
887
+ tags: kept
888
+ },
889
+ removed
890
+ };
891
+ }
892
+ /** One memory entry's approximate size in characters (key + both texts + stamp). */
893
+ function memoryEntryChars(key, entry) {
894
+ return key.length + entry.source.length + entry.target.length + 24;
895
+ }
896
+ /** One entry translation's size in characters, as recorded or estimated. */
897
+ function entryTranslationChars(translation) {
898
+ if (translation.chars !== void 0) return translation.chars;
899
+ return Object.entries(translation.segments ?? {}).reduce((sum, [hash, target]) => sum + hash.length + target.length + 2, 0);
900
+ }
901
+ /**
902
+ * Bound the two translation tiers to their shared budget, LRU by last use.
903
+ *
904
+ * The eviction list is ONE ordering across the global memory and every entry's
905
+ * segment map: a sentence remembered and a per-entry map are the same kind of
906
+ * thing (a translation that was paid for), so the oldest lastUsedAt goes first
907
+ * regardless of which tier holds it. Two extra rules ride the same walk,
908
+ * because it is the only place both tiers are walked: schema-version mismatches
909
+ * are evicted on sight (the lazy half of "version bump = miss"), and the memory
910
+ * table answers to its own count cap even when the budget has room.
911
+ *
912
+ * Pure over plain records: the service wires the files, this decides.
913
+ *
914
+ * @param memory - the global sentence memory (table content).
915
+ * @param annotations - the document's annotations (entry translations included).
916
+ * @param budgetChars - the shared budget.
917
+ * @returns the bounded records, plus WHICH keys each tier lost (the caller
918
+ * applies the entry evictions to the freshest document inside its commit).
919
+ */
920
+ function boundTranslations(memory, annotations, budgetChars) {
921
+ let nextMemory = memory;
922
+ const evictedMemoryKeys = [];
923
+ const keysByAge = Object.keys(memory).sort((a, b) => memory[a].lastUsedAt.localeCompare(memory[b].lastUsedAt));
924
+ if (keysByAge.length > 5e4) {
925
+ nextMemory = { ...memory };
926
+ for (const key of keysByAge.slice(0, keysByAge.length - MAX_TRANSLATION_MEMORY_ENTRIES)) {
927
+ delete nextMemory[key];
928
+ evictedMemoryKeys.push(key);
929
+ }
930
+ }
931
+ const ladder = [];
932
+ let total = 0;
933
+ for (const [key, entry] of Object.entries(nextMemory)) {
934
+ const chars = memoryEntryChars(key, entry);
935
+ total += chars;
936
+ ladder.push({
937
+ at: entry.lastUsedAt,
938
+ chars,
939
+ kind: "memory",
940
+ key,
941
+ stale: false
942
+ });
943
+ }
944
+ for (const [entryId, annotation] of Object.entries(annotations)) {
945
+ const translation = annotation.translation;
946
+ if (translation === void 0) continue;
947
+ const stale = translation.version !== 1;
948
+ const chars = entryTranslationChars(translation);
949
+ if (!stale) total += chars;
950
+ ladder.push({
951
+ at: stale ? (/* @__PURE__ */ new Date(0)).toISOString() : translation.lastUsedAt,
952
+ chars,
953
+ kind: "entry",
954
+ key: entryId,
955
+ stale
956
+ });
957
+ }
958
+ ladder.sort((a, b) => a.at.localeCompare(b.at));
959
+ const evictedEntryIds = [];
960
+ let nextAnnotations;
961
+ for (const item of ladder) {
962
+ if (!item.stale && total <= budgetChars) break;
963
+ if (item.kind === "memory") {
964
+ if (nextMemory === memory) nextMemory = { ...memory };
965
+ if (nextMemory[item.key] !== void 0) {
966
+ delete nextMemory[item.key];
967
+ total -= item.chars;
968
+ evictedMemoryKeys.push(item.key);
969
+ }
970
+ } else {
971
+ const annotation = (nextAnnotations ?? annotations)[item.key];
972
+ if (annotation?.translation !== void 0) {
973
+ if (nextAnnotations === void 0) nextAnnotations = { ...annotations };
974
+ const { translation: _evicted, ...rest } = annotation;
975
+ nextAnnotations[item.key] = rest;
976
+ if (!item.stale) total -= item.chars;
977
+ evictedEntryIds.push(item.key);
978
+ }
979
+ }
980
+ }
981
+ return {
982
+ memory: nextMemory,
983
+ annotations: nextAnnotations ?? annotations,
984
+ evictedMemoryKeys,
985
+ evictedEntryIds
986
+ };
987
+ }
988
+ /** The table's approximate size in characters, for the document's manifest. */
989
+ function memoryTableChars(entries) {
990
+ return Object.entries(entries).reduce((sum, [key, entry]) => sum + memoryEntryChars(key, entry), 0);
991
+ }
992
+ //#endregion
993
+ //#region lib/types/schedule.js
994
+ /**
995
+ * The daily-refresh clock: pure date math, deliberately separated from the
996
+ * service that arms it.
997
+ *
998
+ * There is no host scheduler to hang a "refresh every morning" job on — the
999
+ * host's `ctx.timer` is fiber-scoped `timeout`/`interval`/`throttle`/`debounce`
1000
+ * only, and `dsh-schedule` is agent-session reminders rather than a service a
1001
+ * plugin may schedule against. So the plugin owns a `setTimeout` and the only
1002
+ * interesting part is deciding *when* — which is what lives here, as functions
1003
+ * that take a `now` and are therefore testable without a clock.
1004
+ *
1005
+ * @module @khorsheed/dsh-reader/schedule
1006
+ */
1007
+ /** Milliseconds in one day. */
1008
+ const DAY_MS = 864e5;
1009
+ /**
1010
+ * Parse an `HH:MM` string into hours and minutes.
1011
+ *
1012
+ * A malformed value falls back to the caller's default rather than throwing: a
1013
+ * hand-edited state file must not be able to break the refresh loop.
1014
+ *
1015
+ * @param value - the configured time of day.
1016
+ * @param fallback - the value to use when `value` is unusable.
1017
+ * @returns the parsed hour and minute.
1018
+ */
1019
+ function parseTimeOfDay(value, fallback) {
1020
+ return parseOrFallback(value) ?? parseOrFallback(fallback) ?? {
1021
+ hour: 10,
1022
+ minute: 0
1023
+ };
1024
+ }
1025
+ /** Parse one candidate, or `undefined` when it is not a valid `HH:MM`. */
1026
+ function parseOrFallback(value) {
1027
+ if (value === void 0) return void 0;
1028
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
1029
+ if (match === null) return void 0;
1030
+ const hour = Number(match[1]);
1031
+ const minute = Number(match[2]);
1032
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return void 0;
1033
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return void 0;
1034
+ return {
1035
+ hour,
1036
+ minute
1037
+ };
1038
+ }
1039
+ /**
1040
+ * The next wall-clock occurrence of `timeOfDay` strictly after `now`.
1041
+ *
1042
+ * Computed through the local-time constructor rather than by adding a fixed
1043
+ * day length, so a DST shift moves the refresh to the same wall-clock time
1044
+ * instead of drifting an hour.
1045
+ *
1046
+ * @param now - the reference instant.
1047
+ * @param timeOfDay - the configured local time.
1048
+ * @returns the next occurrence.
1049
+ */
1050
+ function nextOccurrence(now, timeOfDay) {
1051
+ const { hour, minute } = parseTimeOfDay(timeOfDay, "10:00");
1052
+ const candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0);
1053
+ if (candidate.getTime() > now.getTime()) return candidate;
1054
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, hour, minute, 0, 0);
1055
+ }
1056
+ /**
1057
+ * The most recent occurrence of `timeOfDay` at or before `now` — the deadline
1058
+ * a catch-up refresh compares against.
1059
+ *
1060
+ * @param now - the reference instant.
1061
+ * @param timeOfDay - the configured local time.
1062
+ * @returns the most recent occurrence.
1063
+ */
1064
+ function previousOccurrence(now, timeOfDay) {
1065
+ const { hour, minute } = parseTimeOfDay(timeOfDay, "10:00");
1066
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0);
1067
+ if (today.getTime() <= now.getTime()) return today;
1068
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, hour, minute, 0, 0);
1069
+ }
1070
+ /** Delay to the next occurrence, clamped to Node's timer ceiling. */
1071
+ const MAX_TIMEOUT_MS = 2147483647;
1072
+ /**
1073
+ * Whether a boot should refresh immediately because the last run missed its
1074
+ * slot — the thing that makes "daily refresh" true on a machine that was
1075
+ * asleep or whose process restarted.
1076
+ *
1077
+ * @param lastRun - the last completed refresh, if any.
1078
+ * @param now - the current instant.
1079
+ * @param timeOfDay - the configured local time.
1080
+ * @param enabled - whether the schedule is on at all.
1081
+ * @returns true when a catch-up run is due.
1082
+ */
1083
+ function isCatchUpDue(lastRun, now, timeOfDay, enabled) {
1084
+ if (!enabled) return false;
1085
+ if (lastRun === void 0) return false;
1086
+ return previousOccurrence(now, timeOfDay).getTime() > lastRun.getTime();
1087
+ }
1088
+ /**
1089
+ * The timer delay for the next occurrence, capped so a scheduler survives
1090
+ * dates beyond the 32-bit millisecond limit.
1091
+ *
1092
+ * @param now - the reference instant.
1093
+ * @param timeOfDay - the configured local time.
1094
+ * @returns the delay in milliseconds.
1095
+ */
1096
+ function delayUntilNext(now, timeOfDay) {
1097
+ const delta = nextOccurrence(now, timeOfDay).getTime() - now.getTime();
1098
+ return Math.min(Math.max(0, delta), MAX_TIMEOUT_MS);
1099
+ }
1100
+ //#endregion
1101
+ //#region lib/types/arxiv.js
1102
+ /**
1103
+ * arXiv link recognition, and the upgrade to the official HTML version.
1104
+ *
1105
+ * Many papers carry a LaTeXML-built HTML rendering next to the abstract and the
1106
+ * PDF (`arxiv.org/html/<id>`): real tables, semantic sections and MathML
1107
+ * formulas, where the PDF is a byte stream and the abstract page is a summary.
1108
+ * A pasted `/abs` or `/pdf` link therefore upgrades to the HTML version BEFORE
1109
+ * the first fetch — the reader who pasted the paper meant the paper.
1110
+ *
1111
+ * Pure and network-free, so both the add flow and the link resolvers build on
1112
+ * it; the fetch policy (try the HTML version, fall back on 404) lives in the
1113
+ * service.
1114
+ *
1115
+ * @module @khorsheed/dsh-reader/arxiv
1116
+ */
1117
+ /**
1118
+ * The id shapes arXiv assigns: new-style `YYMM.NNNNN(vN)` (month-keyed, four or
1119
+ * five sequence digits — six from 2027 on), or the archived subject-class form
1120
+ * `hep-th/9901001(vN)` / `math.GT/0309136(vN)`.
1121
+ */
1122
+ const ARXIV_ID = /^(?:\d{4}\.\d{4,6}|[a-z-]+(?:\.[A-Z]{2})?\/\d{7})(?:v\d+)?$/;
1123
+ /** Whether a bare string is a syntactically valid arXiv id (the resolvers validate API answers with it). */
1124
+ function isArxivId(value) {
1125
+ return ARXIV_ID.test(value);
1126
+ }
1127
+ /**
1128
+ * Parse an arxiv.org link to one paper.
1129
+ *
1130
+ * Only the three single-paper paths count: `/abs`, `/pdf`, `/html`. Listing,
1131
+ * search and author pages name no paper, so they read as ordinary links.
1132
+ *
1133
+ * @param url - the URL as pasted (already normalized by the caller).
1134
+ * @returns the parsed link, or `undefined`.
1135
+ */
1136
+ function arxivLink(url) {
1137
+ let parsed;
1138
+ try {
1139
+ parsed = new URL(url);
1140
+ } catch {
1141
+ return;
1142
+ }
1143
+ const host = parsed.hostname.toLowerCase();
1144
+ if (host !== "arxiv.org" && host !== "www.arxiv.org") return void 0;
1145
+ const match = /^\/(abs|pdf|html)\/([^?#]+)/.exec(parsed.pathname);
1146
+ const kind = match?.[1];
1147
+ let id = match?.[2];
1148
+ if (kind === void 0 || id === void 0) return void 0;
1149
+ if (kind === "pdf" && /\.pdf$/i.test(id)) id = id.slice(0, -4);
1150
+ if (!ARXIV_ID.test(id)) return void 0;
1151
+ return {
1152
+ kind,
1153
+ id
1154
+ };
1155
+ }
1156
+ /**
1157
+ * The official HTML version of an `/abs` or `/pdf` link.
1158
+ *
1159
+ * `undefined` when the link IS already the HTML page (no rewrite, so the add
1160
+ * flow can never loop) or is not an arXiv paper link at all. The HTML version
1161
+ * is not guaranteed to exist — old papers lack it — so the caller must treat
1162
+ * a 404 as "no upgrade", not as a failure of the link.
1163
+ *
1164
+ * @param url - the pasted arXiv URL.
1165
+ * @returns the HTML version's URL, or `undefined`.
1166
+ */
1167
+ function arxivHtmlUrl(url) {
1168
+ const link = arxivLink(url);
1169
+ if (link === void 0 || link.kind === "html") return void 0;
1170
+ return `https://arxiv.org/html/${link.id}`;
1171
+ }
1172
+ //#endregion
1173
+ //#region lib/types/link-resolvers.js
1174
+ /**
1175
+ * Paper-link resolvers: turn a link that NAMES a paper into the paper itself.
1176
+ *
1177
+ * A DOI or an OpenReview URL is a reference, not a readable page: doi.org
1178
+ * answers a cross-origin redirect the host seam refuses by design, and
1179
+ * openreview.net cannot be read server-side at all (measured 2026-09-20: the
1180
+ * API sits behind a Cloudflare challenge, the forum is a JS SPA whose static
1181
+ * title is always "Forum | OpenReview"). For such links the add flow asks this
1182
+ * module BEFORE fetching anything.
1183
+ *
1184
+ * Table-driven: one resolver per source, each answering an arXiv candidate, an
1185
+ * honest link-only verdict, or `null` ("not mine — take the ordinary path").
1186
+ * New sources (dblp, Semantic Scholar) are new rows, not new plumbing. The
1187
+ * network face is injected, so every branch is testable without a socket.
1188
+ *
1189
+ * The one rule the module exists to enforce: **a resolution is proven or
1190
+ * absent, never guessed.** A candidate that fails the gate degrades to the
1191
+ * ordinary path — a wrong paper filed under the reader's link is worse than a
1192
+ * card that says "open it in a browser".
1193
+ *
1194
+ * @module @khorsheed/dsh-reader/link-resolvers
1195
+ */
1196
+ /** The DOI resolver covers the doi.org alias hosts; publisher URLs are out of scope. */
1197
+ const DOI_HOSTS = /* @__PURE__ */ new Set([
1198
+ "doi.org",
1199
+ "www.doi.org",
1200
+ "dx.doi.org"
1201
+ ]);
1202
+ /** A DOI is `10.<registrant>/<suffix>`; the registrant is 4–9 digits. */
1203
+ const DOI_PATH = /^\/(10\.\d{4,9}\/\S+)/;
1204
+ /** Crossref's filtered list route — see the resolver's doc comment on why not the work route. */
1205
+ const CROSSREF_SELECT = "DOI,title,author";
1206
+ /** The arXiv API phrase search silently answers nothing beyond six terms (measured). */
1207
+ const TITLE_QUERY_WORDS = 6;
1208
+ /** How many candidates one title search is read for. */
1209
+ const ARXIV_SEARCH_RESULTS = 5;
1210
+ const TITLE_SIMILARITY_STRICT = .95;
1211
+ /**
1212
+ * Lowercase, accent-folded, punctuation-to-space — the form two titles are
1213
+ * compared in. CJK characters are letters for this purpose (a Japanese paper
1214
+ * title must not collapse to nothing).
1215
+ */
1216
+ function normalizeTitle(title) {
1217
+ return title.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
1218
+ }
1219
+ /** Levenshtein distance, two rows, on ALREADY-normalized titles. */
1220
+ function editDistance(a, b) {
1221
+ if (a === b) return 0;
1222
+ if (a.length === 0) return b.length;
1223
+ if (b.length === 0) return a.length;
1224
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
1225
+ for (let i = 1; i <= a.length; i++) {
1226
+ const current = [i];
1227
+ for (let j = 1; j <= b.length; j++) current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
1228
+ previous = current;
1229
+ }
1230
+ return previous[b.length];
1231
+ }
1232
+ /**
1233
+ * How alike two titles are: 1 for identical after normalization, 0 for nothing
1234
+ * in common. Either side empty is a 0 — there is nothing to compare.
1235
+ */
1236
+ function titleSimilarity(a, b) {
1237
+ const left = normalizeTitle(a);
1238
+ const right = normalizeTitle(b);
1239
+ if (left.length === 0 || right.length === 0) return 0;
1240
+ if (left === right) return 1;
1241
+ return 1 - editDistance(left, right) / Math.max(left.length, right.length);
1242
+ }
1243
+ /**
1244
+ * The family-name set of an author list, accent-folded and lowercased.
1245
+ *
1246
+ * Full names ("Nicholas G. Polson", the arXiv spelling) reduce to their last
1247
+ * token; structured records (Crossref's `family`) arrive already reduced.
1248
+ */
1249
+ function familyNames(authors) {
1250
+ const names = /* @__PURE__ */ new Set();
1251
+ for (const author of authors) {
1252
+ const last = normalizeTitle(author).split(" ").filter((part) => part.length > 0).at(-1);
1253
+ if (last !== void 0) names.add(last);
1254
+ }
1255
+ return names;
1256
+ }
1257
+ /**
1258
+ * Whether one arXiv hit may stand in for the paper a DOI names.
1259
+ *
1260
+ * The author factor is what makes "no match → degrade" honest: short generic
1261
+ * titles collide across DIFFERENT papers (measured: Crossref's "Deep learning"
1262
+ * — LeCun, Bengio, Hinton — is a perfect title match for arXiv's "Deep
1263
+ * Learning" — Polson, Sokolov), so title similarity alone guesses.
1264
+ *
1265
+ * @param candidate - the arXiv hit.
1266
+ * @param wanted - the Crossref record's title and authors.
1267
+ * @returns true when the candidate is the same paper beyond a title accident.
1268
+ */
1269
+ function gateArxivCandidate(candidate, wanted) {
1270
+ const similarity = titleSimilarity(candidate.title, wanted.title);
1271
+ const candidateNames = familyNames(candidate.authors);
1272
+ const wantedNames = familyNames(wanted.authors);
1273
+ if (candidateNames.size === 0 || wantedNames.size === 0) return similarity >= TITLE_SIMILARITY_STRICT;
1274
+ const overlap = [...candidateNames].some((name) => wantedNames.has(name));
1275
+ return similarity >= .85 && overlap;
1276
+ }
1277
+ /** The title phrase the arXiv API is asked for: the title's first words, quote-safe. */
1278
+ function titleQuery(title) {
1279
+ return title.replace(/\s+/g, " ").trim().split(" ").slice(0, TITLE_QUERY_WORDS).join(" ").replace(/"+/g, " ").replace(/\s+/g, " ").trim();
1280
+ }
1281
+ /**
1282
+ * The entries of an arXiv API Atom answer. A deliberately narrow read — the
1283
+ * host runtime has no XML parser, and this document's shape is fixed: each
1284
+ * `<entry>` carries its `<id>` (the abs URL) and `<title>` first.
1285
+ */
1286
+ function parseArxivSearch(xml) {
1287
+ const hits = [];
1288
+ for (const block of xml.split("<entry>").slice(1)) {
1289
+ const id = /<id>https?:\/\/arxiv\.org\/abs\/([^<]+)<\/id>/.exec(block)?.[1]?.trim();
1290
+ const rawTitle = /<title>([\s\S]*?)<\/title>/.exec(block)?.[1];
1291
+ if (id === void 0 || rawTitle === void 0 || !isArxivId(id)) continue;
1292
+ hits.push({
1293
+ id,
1294
+ title: unescapeXml(rawTitle).replace(/\s+/g, " ").trim(),
1295
+ authors: [...block.matchAll(/<name>([^<]+)<\/name>/g)].map((match) => unescapeXml(match[1]).trim())
1296
+ });
1297
+ }
1298
+ return hits;
1299
+ }
1300
+ /** The few entities an API title can carry. */
1301
+ function unescapeXml(value) {
1302
+ return value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))).replace(/&amp;/g, "&");
1303
+ }
1304
+ /**
1305
+ * Ask the arXiv API for papers titled like `title`.
1306
+ *
1307
+ * The phrase search accepts at most {@link TITLE_QUERY_WORDS} terms (measured:
1308
+ * a 7-word phrase answers an empty feed where its 6-word prefix finds the
1309
+ * paper), so the query is the title's prefix, and precision is the gate's job,
1310
+ * not the query's.
1311
+ */
1312
+ async function searchArxiv(title, fetch) {
1313
+ const phrase = titleQuery(title);
1314
+ if (phrase.length === 0) return [];
1315
+ const url = `https://export.arxiv.org/api/query?${new URLSearchParams({
1316
+ search_query: `ti:"${phrase}"`,
1317
+ max_results: String(ARXIV_SEARCH_RESULTS)
1318
+ }).toString()}`;
1319
+ let body;
1320
+ try {
1321
+ const response = await fetch(url);
1322
+ if (response.statusCode !== 200) return [];
1323
+ body = response.body;
1324
+ } catch {
1325
+ return [];
1326
+ }
1327
+ return parseArxivSearch(body);
1328
+ }
1329
+ /**
1330
+ * The title and authors Crossref has for one DOI, or `null`.
1331
+ *
1332
+ * The LIST route with a `doi:` filter is used, not `/works/<doi>`: the
1333
+ * single-work route rejects `select` (measured: `parameter-not-allowed`), and
1334
+ * an unfiltered work record can carry hundreds of references — past the fetch
1335
+ * seam's body cap, which truncates JSON into something unparseable. The
1336
+ * filtered route answers a few hundred bytes. A DOI Crossref does not know
1337
+ * (404, empty items) is not an error here: it is the ordinary path's business.
1338
+ */
1339
+ async function crossrefWork(doi, fetch) {
1340
+ const url = `https://api.crossref.org/works?${new URLSearchParams({
1341
+ filter: `doi:${doi}`,
1342
+ select: CROSSREF_SELECT
1343
+ }).toString()}`;
1344
+ let parsed;
1345
+ try {
1346
+ const response = await fetch(url);
1347
+ if (response.statusCode !== 200) return null;
1348
+ parsed = JSON.parse(response.body);
1349
+ } catch {
1350
+ return null;
1351
+ }
1352
+ const items = parsed?.message?.items;
1353
+ const work = Array.isArray(items) ? items[0] : void 0;
1354
+ if (typeof work !== "object" || work === null) return null;
1355
+ const record = work;
1356
+ const title = Array.isArray(record.title) ? record.title.find((item) => typeof item === "string" && item.length > 0) : void 0;
1357
+ if (typeof title !== "string") return null;
1358
+ return {
1359
+ title,
1360
+ authors: Array.isArray(record.author) ? record.author.flatMap((author) => {
1361
+ const family = author?.family;
1362
+ return typeof family === "string" && family.length > 0 ? [family] : [];
1363
+ }) : []
1364
+ };
1365
+ }
1366
+ /** The resolver table, in match order. */
1367
+ const LINK_RESOLVERS = [{
1368
+ name: "doi",
1369
+ match: (url) => DOI_HOSTS.has(url.hostname.toLowerCase()) && DOI_PATH.test(url.pathname),
1370
+ resolve: async (url, fetch) => {
1371
+ const doi = decodeURIComponent(DOI_PATH.exec(url.pathname)?.[1] ?? "");
1372
+ if (doi.length === 0) return null;
1373
+ const work = await crossrefWork(doi, fetch);
1374
+ if (work === null) return null;
1375
+ const hits = await searchArxiv(work.title, fetch);
1376
+ let best;
1377
+ for (const hit of hits) {
1378
+ const similarity = titleSimilarity(hit.title, work.title);
1379
+ if (best === void 0 || similarity > best.similarity) best = {
1380
+ hit,
1381
+ similarity
1382
+ };
1383
+ }
1384
+ if (best === void 0 || !gateArxivCandidate(best.hit, work)) return null;
1385
+ return {
1386
+ kind: "arxiv",
1387
+ id: best.hit.id,
1388
+ title: best.hit.title
1389
+ };
1390
+ }
1391
+ }, {
1392
+ name: "openreview",
1393
+ match: (url) => {
1394
+ const host = url.hostname.toLowerCase();
1395
+ if (host !== "openreview.net" && host !== "www.openreview.net") return false;
1396
+ return (url.pathname === "/forum" || url.pathname === "/pdf") && (url.searchParams.get("id") ?? "").length > 0;
1397
+ },
1398
+ resolve: () => Promise.resolve({
1399
+ kind: "link-only",
1400
+ code: "unreadable",
1401
+ message: "OpenReview pages are script-rendered behind an anti-bot wall, so the server side cannot read them — if the paper has an arXiv version, paste that link instead"
1402
+ })
1403
+ }];
1404
+ /**
1405
+ * Resolve a pasted link through the table.
1406
+ *
1407
+ * `null` — not an answer, but the ABSENCE of one — sends the URL down the
1408
+ * ordinary fetch-and-classify path. A resolver's own failure is caught by the
1409
+ * caller (the service): a DOI lookup that throws must not break an add.
1410
+ *
1411
+ * @param url - the normalized pasted URL.
1412
+ * @param fetch - the injected GET face.
1413
+ * @returns the resolution, or `null`.
1414
+ */
1415
+ async function resolveLink(url, fetch) {
1416
+ let parsed;
1417
+ try {
1418
+ parsed = new URL(url);
1419
+ } catch {
1420
+ return null;
1421
+ }
1422
+ for (const resolver of LINK_RESOLVERS) if (resolver.match(parsed)) return resolver.resolve(parsed, fetch);
1423
+ return null;
1424
+ }
1425
+ /** Cross-origin redirect hops the service will follow before giving up. */
1426
+ const MAX_REDIRECT_HOPS = 3;
1427
+ /** Upper bound on summaries handed to the browser in one call. */
1428
+ const SUMMARY_LIMIT = 500;
1429
+ /** How many sentence hashes one memory-slice read answers (the table never crosses). */
1430
+ const TRANSLATION_SLICE_LIMIT = 2e3;
1431
+ /** How many sentences one write batch may carry (a long article is ~500). */
1432
+ const TRANSLATION_WRITE_LIMIT = 2e3;
1433
+ /** One sentence's source or translation is never longer than a few paragraphs. */
1434
+ const TRANSLATION_TEXT_LIMIT = 4e3;
1435
+ /** Thrown when a payload cannot become a source. */
1436
+ var UnsupportedContent = class extends Error {};
1437
+ /**
1438
+ * Whether a failed fetch is the HTML endpoint's "this paper has no HTML
1439
+ * version" — a bare 404 from the content fetch, and nothing else. A refusal
1440
+ * (403/401) or a transport error is a fact about the REQUEST, not about which
1441
+ * face of the paper exists, and must not silently reroute to another page.
1442
+ */
1443
+ function isNoHtmlVersion(error) {
1444
+ return error instanceof UnsupportedContent && error.message === "HTTP 404";
1445
+ }
1446
+ /**
1447
+ * Classify a fetched payload as a feed or an article page.
1448
+ *
1449
+ * The decision comes from the CONTENT, not from asking the user: nobody pasting
1450
+ * a URL can be expected to know whether it is a feed, and guessing wrong used
1451
+ * to mean silently creating an empty subscription.
1452
+ *
1453
+ * @param raw - the decoded payload.
1454
+ * @returns which kind of source this payload can become.
1455
+ */
1456
+ function classifyPayload(raw) {
1457
+ const head = raw.slice(0, 512).trimStart().toLowerCase();
1458
+ if (head.startsWith("<?xml")) return "feed";
1459
+ if (/^<rss[\s>]/.test(head)) return "feed";
1460
+ if (/^<feed[\s>]/.test(head)) return "feed";
1461
+ if (/^<rdf:rdf[\s>]/.test(head)) return "feed";
1462
+ return "page";
1463
+ }
1464
+ /** The reader service. */
1465
+ var ReaderService = class {
1466
+ store;
1467
+ ctx;
1468
+ timer;
1469
+ disposed = false;
1470
+ /** The document of record when no filesystem is mounted. */
1471
+ memoryDoc;
1472
+ /**
1473
+ * @param ctx - owning context.
1474
+ * @param config - optional state-root override.
1475
+ */
1476
+ constructor(ctx, config = {}) {
1477
+ this.ctx = ctx;
1478
+ this.store = new ReaderStore(config);
1479
+ ctx.effect(() => () => {
1480
+ this.disposed = true;
1481
+ if (this.timer !== void 0) clearTimeout(this.timer);
1482
+ }, "reader: daily refresh");
1483
+ this.armSchedule();
1484
+ }
1485
+ /** Capabilities the browser may rely on. */
1486
+ async capabilities() {
1487
+ const doc = await this.currentDoc();
1488
+ return {
1489
+ protocolVersion: 1,
1490
+ hasFs: this.store.available,
1491
+ ...doc.lastRefreshAt === void 0 ? {} : { lastRefreshAt: doc.lastRefreshAt },
1492
+ hasSideChat: this.sideChat() !== void 0,
1493
+ ...doc.refresh.enabled ? { nextRefreshAt: new Date(Date.now() + delayUntilNext(/* @__PURE__ */ new Date(), doc.refresh.timeOfDay)).toISOString() } : {}
1494
+ };
1495
+ }
1496
+ /** The source list, without payloads. */
1497
+ async listSources() {
1498
+ return { sources: (await this.currentDoc()).sources.slice(0, SUMMARY_LIMIT).map(summarize$1) };
1499
+ }
1500
+ /**
1501
+ * Add a source, deciding what it is from what comes back.
1502
+ *
1503
+ * The one rule this method exists to enforce: **a URL the reader pasted is
1504
+ * never thrown away.** A fetch that fails, a bot challenge, a login wall and
1505
+ * a PDF all end the same way — a `link` source that exists, carries the
1506
+ * reason it has no previewable body, and can be opened, tagged, refreshed
1507
+ * manually or deleted. Only a URL that cannot be a source at all
1508
+ * (`invalid-url`) or one already present (`duplicate`) is refused.
1509
+ *
1510
+ * @param request - the pasted URL and an optional label.
1511
+ * @returns the decision, or a domain refusal.
1512
+ */
1513
+ async addSource(request) {
1514
+ const url = normalizeUrl(request.url);
1515
+ if (url === void 0) return "invalid-url";
1516
+ const doc = await this.currentDoc();
1517
+ if (doc.sources.some((source) => sameTarget(source.url, url))) return "duplicate";
1518
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1519
+ const resolution = await this.resolvePaper(url);
1520
+ if (resolution?.kind === "link-only") return await this.saveLinkOnly({
1521
+ url,
1522
+ ...request.label === void 0 ? {} : { label: request.label }
1523
+ }, {
1524
+ code: resolution.code,
1525
+ message: resolution.message,
1526
+ at: now
1527
+ }, now);
1528
+ const resolvedId = resolution?.kind === "arxiv" ? resolution.id : void 0;
1529
+ const upgrade = resolvedId === void 0 ? arxivHtmlUrl(url) : `https://arxiv.org/html/${resolvedId}`;
1530
+ const fallbackUrl = resolvedId === void 0 ? url : `https://arxiv.org/abs/${resolvedId}`;
1531
+ const resolvedFrom = resolvedId === void 0 ? void 0 : url;
1532
+ if (upgrade !== void 0 && doc.sources.some((source) => sameTarget(source.url, upgrade))) return "duplicate";
1533
+ let fetched;
1534
+ try {
1535
+ fetched = await this.fetchFollowing(upgrade ?? url);
1536
+ } catch (error) {
1537
+ if (upgrade !== void 0 && isNoHtmlVersion(error)) try {
1538
+ fetched = await this.fetchFollowing(fallbackUrl);
1539
+ } catch (fallbackError) {
1540
+ return await this.saveLinkOnly({
1541
+ url,
1542
+ ...request.label === void 0 ? {} : { label: request.label },
1543
+ ...resolvedFrom === void 0 ? {} : { resolvedFrom }
1544
+ }, classifyFetchFailure(fallbackError, now), now);
1545
+ }
1546
+ else return await this.saveLinkOnly({
1547
+ url,
1548
+ ...request.label === void 0 ? {} : { label: request.label },
1549
+ ...resolvedFrom === void 0 ? {} : { resolvedFrom }
1550
+ }, classifyFetchFailure(error, now), now);
1551
+ }
1552
+ const feed = classifyPayload(fetched.raw) === "feed";
1553
+ if (!feed) {
1554
+ const problem = inspectPreview(fetched.raw, fetched.url, upgrade ?? url);
1555
+ if (problem !== void 0) return await this.saveLinkOnly({
1556
+ url: fetched.url,
1557
+ ...request.label === void 0 ? {} : { label: request.label },
1558
+ ...resolvedFrom === void 0 ? {} : { resolvedFrom }
1559
+ }, {
1560
+ code: problem.code,
1561
+ message: problem.message,
1562
+ at: now
1563
+ }, now);
1564
+ }
1565
+ const label = request.label ?? (resolution?.kind === "arxiv" ? resolution.title : void 0) ?? defaultSourceLabel(fetched.url);
1566
+ const source = {
1567
+ id: `${feed ? "rss" : "link"}-${hash(fetched.url)}`,
1568
+ kind: feed ? "rss" : "link",
1569
+ url: fetched.url,
1570
+ label,
1571
+ enabled: true,
1572
+ addedAt: now,
1573
+ fetchedAt: now,
1574
+ status: "ok",
1575
+ ...fetched.truncated ? { truncated: true } : {},
1576
+ ...resolvedFrom === void 0 ? {} : { resolvedFrom },
1577
+ raw: fetched.raw
1578
+ };
1579
+ await this.commit((current) => ({
1580
+ ...current,
1581
+ sources: [source, ...current.sources]
1582
+ }));
1583
+ return {
1584
+ outcome: feed ? "subscribed" : "saved-link",
1585
+ kind: source.kind,
1586
+ id: source.id,
1587
+ label
1588
+ };
1589
+ }
1590
+ /**
1591
+ * Persist a link the reader owns even though its body cannot be previewed.
1592
+ *
1593
+ * Two records, because two consumers ask different questions: the SOURCE
1594
+ * carries the failure so the wall can mark the card and the backfill can
1595
+ * leave a permanent wall alone, while the ENTRY annotation carries it so
1596
+ * `getEntryBody` — the one call the detail view makes — explains itself
1597
+ * instead of answering with a blank article.
1598
+ *
1599
+ * @param request - the final URL, an optional label, and the pasted URL when
1600
+ * this link was resolved to a canonical version.
1601
+ * @param failure - the classified reason, with its timestamp.
1602
+ * @param now - the commit instant.
1603
+ * @returns the add outcome the browser reports.
1604
+ */
1605
+ async saveLinkOnly(request, failure, now) {
1606
+ const label = request.label ?? defaultSourceLabel(request.url);
1607
+ const source = {
1608
+ id: `link-${hash(request.url)}`,
1609
+ kind: "link",
1610
+ url: request.url,
1611
+ label,
1612
+ enabled: true,
1613
+ addedAt: now,
1614
+ fetchedAt: now,
1615
+ status: "error",
1616
+ error: failure.message,
1617
+ failure,
1618
+ ...request.resolvedFrom === void 0 ? {} : { resolvedFrom: request.resolvedFrom }
1619
+ };
1620
+ await this.commit((current) => ({
1621
+ ...current,
1622
+ sources: [source, ...current.sources]
1623
+ }));
1624
+ await this.recordFetchFailure(linkEntryId(source.id), failure.message);
1625
+ return {
1626
+ outcome: "saved-link",
1627
+ kind: "link",
1628
+ id: source.id,
1629
+ label,
1630
+ failure: {
1631
+ code: failure.code,
1632
+ message: failure.message
1633
+ }
1634
+ };
1635
+ }
1636
+ /**
1637
+ * Update a source's mutable fields, or the global refresh time.
1638
+ *
1639
+ * @param request - the id plus the fields to change.
1640
+ * @returns the outcome, or a domain refusal.
1641
+ */
1642
+ async updateSource(request) {
1643
+ if (!(await this.currentDoc()).sources.some((source) => source.id === request.id)) return "not-found";
1644
+ if (request.timeOfDay !== void 0 && !/^\d{1,2}:\d{2}$/.test(request.timeOfDay)) return "invalid-time";
1645
+ const url = request.url === void 0 || request.url === "" ? void 0 : normalizeUrl(request.url);
1646
+ if (request.url !== void 0 && request.url !== "" && url === void 0) return "invalid-url";
1647
+ await this.commit((current) => ({
1648
+ ...current,
1649
+ sources: current.sources.map((source) => source.id === request.id ? {
1650
+ ...source,
1651
+ ...request.enabled !== void 0 ? { enabled: request.enabled } : {},
1652
+ ...request.label !== void 0 && request.label !== "" ? { label: request.label } : {},
1653
+ ...url !== void 0 ? {
1654
+ url,
1655
+ status: "ok"
1656
+ } : {}
1657
+ } : source),
1658
+ ...request.timeOfDay !== void 0 ? { refresh: {
1659
+ ...current.refresh,
1660
+ timeOfDay: request.timeOfDay
1661
+ } } : {}
1662
+ }));
1663
+ if (request.timeOfDay !== void 0) await this.armSchedule();
1664
+ return "ok";
1665
+ }
1666
+ /**
1667
+ * Remove a source.
1668
+ *
1669
+ * Its entries' TRANSLATIONS go with it: the entries themselves are unreachable
1670
+ * once the source is gone (the recent page disables them), so their exact-fit
1671
+ * maps are dead weight. The host never parses feeds, so the caller passes the
1672
+ * entry ids it has parsed; a link source's single entry id is derivable here
1673
+ * regardless. The cached BODIES stay — the body budget owns their lifetime,
1674
+ * unchanged.
1675
+ *
1676
+ * @param request - the source id, plus the entries the caller knows belong to it.
1677
+ * @returns the outcome, or a domain refusal.
1678
+ */
1679
+ async removeSource(request) {
1680
+ if (!(await this.currentDoc()).sources.some((source) => source.id === request.id)) return "not-found";
1681
+ const entryIds = new Set(request.entryIds ?? []);
1682
+ entryIds.add(linkEntryId(request.id));
1683
+ await this.commit((current) => {
1684
+ let annotations = current.annotations;
1685
+ const touched = {};
1686
+ let changed = false;
1687
+ for (const id of entryIds) {
1688
+ const annotation = annotations?.[id];
1689
+ if (annotation?.translation === void 0) continue;
1690
+ const { translation: _gone, ...rest } = annotation;
1691
+ touched[id] = rest;
1692
+ changed = true;
1693
+ }
1694
+ if (changed) annotations = {
1695
+ ...annotations,
1696
+ ...touched
1697
+ };
1698
+ return {
1699
+ ...current,
1700
+ sources: current.sources.filter((source) => source.id !== request.id),
1701
+ ...changed ? { annotations } : {}
1702
+ };
1703
+ });
1704
+ return "ok";
1705
+ }
1706
+ /**
1707
+ * Refresh the enabled sources, or the named ones.
1708
+ *
1709
+ * @param request - optional id list.
1710
+ * @returns one result per attempted source.
1711
+ */
1712
+ async refresh(request = {}) {
1713
+ const doc = await this.currentDoc();
1714
+ const wanted = request.ids === void 0 ? doc.sources.filter((source) => source.enabled) : doc.sources.filter((source) => request.ids?.includes(source.id) === true);
1715
+ const results = [];
1716
+ const patches = /* @__PURE__ */ new Map();
1717
+ const base = new Map(doc.sources.map((source) => [source.id, source]));
1718
+ for (const source of wanted) {
1719
+ const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
1720
+ try {
1721
+ const fetched = await this.fetchFollowing(source.url);
1722
+ const problem = source.kind === "link" ? inspectPreview(fetched.raw, fetched.url, source.url) : void 0;
1723
+ if (problem !== void 0) {
1724
+ const failure = {
1725
+ code: problem.code,
1726
+ message: problem.message,
1727
+ at: fetchedAt
1728
+ };
1729
+ patches.set(source.id, {
1730
+ ...withoutFailure({
1731
+ ...source,
1732
+ url: fetched.url,
1733
+ ...source.raw === void 0 ? {} : { raw: source.raw }
1734
+ }),
1735
+ fetchedAt,
1736
+ status: "error",
1737
+ error: failure.message,
1738
+ failure
1739
+ });
1740
+ await this.recordFetchFailure(linkEntryId(source.id), failure.message);
1741
+ results.push({
1742
+ id: source.id,
1743
+ status: "unavailable",
1744
+ message: failure.message
1745
+ });
1746
+ continue;
1747
+ }
1748
+ patches.set(source.id, {
1749
+ ...withoutFailure(source),
1750
+ url: fetched.url,
1751
+ raw: fetched.raw,
1752
+ fetchedAt,
1753
+ status: "ok",
1754
+ truncated: fetched.truncated
1755
+ });
1756
+ results.push({
1757
+ id: source.id,
1758
+ status: fetched.truncated ? "truncated" : "ok",
1759
+ ...fetched.truncated ? { message: "payload hit the fetch seam's size cap" } : {}
1760
+ });
1761
+ } catch (error) {
1762
+ const failure = classifyFetchFailure(error, fetchedAt);
1763
+ const previous = base.get(source.id);
1764
+ patches.set(source.id, {
1765
+ ...previous ?? source,
1766
+ fetchedAt,
1767
+ status: "error",
1768
+ error: failure.message,
1769
+ failure
1770
+ });
1771
+ results.push({
1772
+ id: source.id,
1773
+ status: error instanceof UnsupportedContent ? "unavailable" : "fetch-failed",
1774
+ message: failure.message
1775
+ });
1776
+ }
1777
+ }
1778
+ if (patches.size > 0) await this.commit((current) => ({
1779
+ ...current,
1780
+ lastRefreshAt: (/* @__PURE__ */ new Date()).toISOString(),
1781
+ sources: current.sources.map((source) => patches.get(source.id) ?? source)
1782
+ }));
1783
+ return { results };
1784
+ }
1785
+ /**
1786
+ * Hand back raw payloads for the browser to parse.
1787
+ *
1788
+ * @param request - the source ids whose payloads are wanted.
1789
+ * @returns one body per requested id; an error instead of a payload when
1790
+ * nothing usable is stored.
1791
+ */
1792
+ async getBodies(request) {
1793
+ const doc = await this.currentDoc();
1794
+ const byId = new Map(doc.sources.map((source) => [source.id, source]));
1795
+ return { bodies: request.ids.map((id) => {
1796
+ const source = byId.get(id);
1797
+ if (source === void 0) return {
1798
+ id,
1799
+ error: "not-found"
1800
+ };
1801
+ const meta = source.kind === "link" ? doc.annotations?.[linkEntryId(source.id)] : void 0;
1802
+ const carried = {
1803
+ ...meta?.title === void 0 ? {} : { title: meta.title },
1804
+ ...meta?.excerpt === void 0 ? {} : { excerpt: meta.excerpt }
1805
+ };
1806
+ if (source.raw === void 0) return {
1807
+ id,
1808
+ error: source.error ?? "no payload stored yet",
1809
+ ...carried
1810
+ };
1811
+ return {
1812
+ id,
1813
+ raw: source.raw,
1814
+ ...source.truncated === true ? { truncated: true } : {},
1815
+ ...carried
1816
+ };
1817
+ }) };
1818
+ }
1819
+ /**
1820
+ * Pass a ref block to the side-chat service when one is composed.
1821
+ *
1822
+ * @param request - the target context, its label, and the ref text.
1823
+ * @returns `'ok'`, or `'unavailable'` when no side chat is mounted.
1824
+ */
1825
+ async quoteToSideChat(request) {
1826
+ const sideChat = this.sideChat();
1827
+ if (sideChat === void 0) return "unavailable";
1828
+ try {
1829
+ await sideChat.openWith({
1830
+ contextKey: request.contextKey,
1831
+ label: request.label,
1832
+ refs: [{
1833
+ label: request.label,
1834
+ text: request.text
1835
+ }]
1836
+ });
1837
+ return "ok";
1838
+ } catch {
1839
+ return "unavailable";
1840
+ }
1841
+ }
1842
+ /**
1843
+ * The body the detail view should render for one entry, if any.
1844
+ *
1845
+ * The order is the whole contract: a fresh cached fetch wins (it is full text
1846
+ * the reader already paid for), then the feed's own payload, and only when
1847
+ * neither exists does the view offer to fetch. A STALE cache entry reports
1848
+ * `fresh: false` and no html — the detail view then offers the fetch again
1849
+ * rather than quietly serving yesterday's copy.
1850
+ *
1851
+ * @param request - the entry and the payload the feed gave for it.
1852
+ * @returns what there is to show, and whether a fetch is worth offering.
1853
+ */
1854
+ async getEntryBody(request) {
1855
+ const doc = await this.currentDoc();
1856
+ const cached = doc.annotations?.[request.entryId]?.body;
1857
+ if (cached !== void 0) {
1858
+ const fresh = isFresh(cached.expiresAt);
1859
+ const html = cached.file === void 0 ? cached.html : fresh && cached.url === request.url ? this.store.readBody(cached.file) : void 0;
1860
+ return {
1861
+ entryId: request.entryId,
1862
+ cached: true,
1863
+ fresh,
1864
+ fromFeed: false,
1865
+ fetchedAt: cached.fetchedAt,
1866
+ ...cached.truncated === true ? { truncated: true } : {},
1867
+ ...cached.scriptFigures === void 0 ? {} : { scriptFigures: cached.scriptFigures },
1868
+ ...cached.rendered === true ? { rendered: true } : {},
1869
+ ...html === void 0 || !fresh || cached.url !== request.url ? {} : { html }
1870
+ };
1871
+ }
1872
+ if (request.feedHtml !== void 0 && request.feedHtml.length > 0) return {
1873
+ entryId: request.entryId,
1874
+ cached: false,
1875
+ fresh: true,
1876
+ fromFeed: true,
1877
+ html: request.feedHtml
1878
+ };
1879
+ const failure = doc.annotations?.[request.entryId];
1880
+ return {
1881
+ entryId: request.entryId,
1882
+ cached: false,
1883
+ fresh: true,
1884
+ fromFeed: false,
1885
+ ...failure?.error === void 0 ? {} : { error: failure.error }
1886
+ };
1887
+ }
1888
+ /**
1889
+ * Fetch one entry's article and hand the raw HTML to the browser.
1890
+ *
1891
+ * The HOST does not extract: extraction needs a DOM, and this runtime has no
1892
+ * XML/HTML parser at all (the same reason feed parsing lives in the browser
1893
+ * half). So this verb is the network half only — it returns what came back,
1894
+ * and `storeEntryBody` persists what the browser made of it.
1895
+ *
1896
+ * Fetching here is deliberately NOT automatic on refresh: doing it for every
1897
+ * summary-only entry would make this a crawler of other people's sites, which
1898
+ * the design refuses. One entry, one fetch, on the reader's request.
1899
+ *
1900
+ * @param request - the entry and its article URL.
1901
+ * @returns the raw payload, or why there is none.
1902
+ */
1903
+ async fetchEntryBody(request) {
1904
+ const url = normalizeUrl(request.url);
1905
+ if (url === void 0) {
1906
+ await this.recordFetchFailure(request.entryId, "invalid-url");
1907
+ return {
1908
+ entryId: request.entryId,
1909
+ error: "invalid-url"
1910
+ };
1911
+ }
1912
+ await this.markFetching(request.entryId);
1913
+ try {
1914
+ const fetched = await this.fetchFollowing(url);
1915
+ const rawFile = await this.storeRaw(request.entryId, fetched.raw, fetched.url, fetched.truncated);
1916
+ return {
1917
+ entryId: request.entryId,
1918
+ url: fetched.url,
1919
+ raw: fetched.raw,
1920
+ ...rawFile === void 0 ? {} : { rawFile },
1921
+ ...fetched.truncated ? { truncated: true } : {}
1922
+ };
1923
+ } catch (error) {
1924
+ const failure = classifyFetchFailure(error, (/* @__PURE__ */ new Date()).toISOString());
1925
+ await this.recordFetchFailure(request.entryId, failure.message, failure.code);
1926
+ return {
1927
+ entryId: request.entryId,
1928
+ error: failure.message
1929
+ };
1930
+ }
1931
+ }
1932
+ /**
1933
+ * Which entries have a body, a stored raw payload, or a recorded failure.
1934
+ *
1935
+ * The wall needs this per ENTRY (entries come from the browser's parse of a
1936
+ * feed), so it cannot ride the source summaries. `raw` means "fetched, not yet
1937
+ * extracted" — the browser half turns those into bodies on its next load.
1938
+ *
1939
+ * @param request - the entry ids the caller is showing.
1940
+ * @returns one state per requested id.
1941
+ */
1942
+ async entryFetchStates(request) {
1943
+ const doc = await this.currentDoc();
1944
+ const states = {};
1945
+ const now = Date.now();
1946
+ for (const entryId of request.entryIds) {
1947
+ const annotation = doc.annotations?.[entryId];
1948
+ if (annotation?.body !== void 0 && isFresh(annotation.body.expiresAt)) {
1949
+ states[entryId] = {
1950
+ state: "ready",
1951
+ ...annotation.body.fetchedAt === void 0 ? {} : { at: annotation.body.fetchedAt }
1952
+ };
1953
+ continue;
1954
+ }
1955
+ const fetch = annotation?.fetch;
1956
+ if (fetch?.state === "raw") {
1957
+ states[entryId] = {
1958
+ state: "raw",
1959
+ at: fetch.at
1960
+ };
1961
+ continue;
1962
+ }
1963
+ if (fetch?.state === "fetching" && isRecent(fetch.at, now, FETCH_STALE_MS)) {
1964
+ states[entryId] = {
1965
+ state: "fetching",
1966
+ at: fetch.at
1967
+ };
1968
+ continue;
1969
+ }
1970
+ if (annotation?.error !== void 0 && annotation.failedAt !== void 0) {
1971
+ states[entryId] = {
1972
+ state: "failed",
1973
+ at: annotation.failedAt,
1974
+ message: annotation.error,
1975
+ ...annotation.failureCode === void 0 ? {} : { code: annotation.failureCode }
1976
+ };
1977
+ continue;
1978
+ }
1979
+ states[entryId] = { state: "none" };
1980
+ }
1981
+ return { states };
1982
+ }
1983
+ /**
1984
+ * Hand back a stored raw payload so the browser can extract it.
1985
+ *
1986
+ * @param request - the entry whose payload is wanted.
1987
+ * @returns the payload, or why there is none.
1988
+ */
1989
+ async getRawBody(request) {
1990
+ const annotation = (await this.currentDoc()).annotations?.[request.entryId];
1991
+ const file = annotation?.fetch?.state === "raw" ? annotation.fetch.rawFile : void 0;
1992
+ if (file === void 0) return {
1993
+ entryId: request.entryId,
1994
+ error: "no stored payload"
1995
+ };
1996
+ const raw = this.store.readBody(file);
1997
+ if (raw === void 0) return {
1998
+ entryId: request.entryId,
1999
+ error: "stored payload is gone"
2000
+ };
2001
+ return {
2002
+ entryId: request.entryId,
2003
+ raw,
2004
+ ...annotation?.fetch?.url === void 0 ? {} : { url: annotation.fetch.url },
2005
+ ...annotation?.fetch?.truncated === true ? { truncated: true } : {}
2006
+ };
2007
+ }
2008
+ /** Record that a fetch is in flight, so a wall reopened later can say so. */
2009
+ async markFetching(entryId) {
2010
+ await this.commit((current) => ({
2011
+ ...current,
2012
+ annotations: {
2013
+ ...current.annotations,
2014
+ [entryId]: {
2015
+ ...current.annotations?.[entryId],
2016
+ fetch: {
2017
+ state: "fetching",
2018
+ at: (/* @__PURE__ */ new Date()).toISOString()
2019
+ }
2020
+ }
2021
+ }
2022
+ }));
2023
+ }
2024
+ /**
2025
+ * Store one raw payload for later extraction.
2026
+ *
2027
+ * @param entryId - the entry it belongs to.
2028
+ * @param raw - the payload.
2029
+ * @returns the file name, or `undefined` when the deployment has no fs.
2030
+ */
2031
+ async storeRaw(entryId, raw, url, truncated) {
2032
+ if (!this.store.available) return void 0;
2033
+ const file = this.store.writeBody(`${entryId}#raw`, raw);
2034
+ await this.commit((current) => ({
2035
+ ...current,
2036
+ annotations: {
2037
+ ...current.annotations,
2038
+ [entryId]: {
2039
+ ...current.annotations?.[entryId],
2040
+ fetch: {
2041
+ state: "raw",
2042
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2043
+ rawFile: file,
2044
+ chars: raw.length,
2045
+ url,
2046
+ ...truncated ? { truncated: true } : {}
2047
+ }
2048
+ }
2049
+ }
2050
+ }));
2051
+ return file;
2052
+ }
2053
+ /**
2054
+ * Cache the markup the browser extracted for one entry.
2055
+ *
2056
+ * The deadline is computed here, not on the client, so the policy has exactly
2057
+ * one owner (this service's document) — a client that guessed the TTL would
2058
+ * be a second implementation of it.
2059
+ *
2060
+ * @param request - the entry, the markup, and the URL it came from.
2061
+ * @returns the stored view.
2062
+ */
2063
+ async storeEntryBody(request) {
2064
+ const html = request.html.trim();
2065
+ if (html.length === 0) {
2066
+ await this.recordFetchFailure(request.entryId, "empty extraction");
2067
+ return {
2068
+ entryId: request.entryId,
2069
+ cached: false,
2070
+ fresh: false,
2071
+ fromFeed: false,
2072
+ error: "empty extraction"
2073
+ };
2074
+ }
2075
+ const ttlHours = (await this.currentDoc()).cache?.ttlHours ?? DEFAULT_CACHE_POLICY.ttlHours;
2076
+ const now = /* @__PURE__ */ new Date();
2077
+ const sidecar = html.length > 262144 ? this.store.writeBody(request.entryId, html) : void 0;
2078
+ const body = {
2079
+ ...sidecar === void 0 ? { html } : {
2080
+ file: sidecar,
2081
+ chars: html.length
2082
+ },
2083
+ fetchedAt: now.toISOString(),
2084
+ expiresAt: ttlHours === 0 ? new Date(now.getTime() + 31536e8).toISOString() : new Date(now.getTime() + ttlHours * 60 * 60 * 1e3).toISOString(),
2085
+ url: request.url,
2086
+ ...request.truncated === true ? { truncated: true } : {},
2087
+ ...request.scriptFigures === void 0 ? {} : { scriptFigures: request.scriptFigures },
2088
+ ...request.rendered === true ? { rendered: true } : {}
2089
+ };
2090
+ await this.commit((current) => {
2091
+ const existing = current.annotations?.[request.entryId];
2092
+ const tagIds = existing?.tagIds;
2093
+ const translation = existing?.translation !== void 0 && request.bodyHash !== void 0 && existing.translation.bodyHash === request.bodyHash ? existing.translation : void 0;
2094
+ const title = metaText(request.title, 300) ?? existing?.title;
2095
+ const excerpt = metaText(request.excerpt, 1e3) ?? existing?.excerpt;
2096
+ return {
2097
+ ...current,
2098
+ annotations: {
2099
+ ...current.annotations,
2100
+ [request.entryId]: {
2101
+ body,
2102
+ ...tagIds === void 0 ? {} : { tagIds },
2103
+ ...translation === void 0 ? {} : { translation },
2104
+ ...title === void 0 ? {} : { title },
2105
+ ...excerpt === void 0 ? {} : { excerpt }
2106
+ }
2107
+ }
2108
+ };
2109
+ });
2110
+ return {
2111
+ entryId: request.entryId,
2112
+ cached: true,
2113
+ fresh: true,
2114
+ fromFeed: false,
2115
+ html,
2116
+ fetchedAt: body.fetchedAt,
2117
+ ...body.truncated === true ? { truncated: true } : {},
2118
+ ...body.scriptFigures === void 0 ? {} : { scriptFigures: body.scriptFigures },
2119
+ ...body.rendered === true ? { rendered: true } : {}
2120
+ };
2121
+ }
2122
+ /**
2123
+ * Which entries still need their full text, and which may be retried.
2124
+ *
2125
+ * The host is the only side that can answer this: it holds the cache, the
2126
+ * recorded failures and the policy. A failure is retried only after
2127
+ * `BACKFILL_RETRY_MS` — an entry whose publisher refuses us must not be
2128
+ * hammered once per refresh, which would make this a crawler with a grudge.
2129
+ *
2130
+ * @param request - the entries the caller is looking at (id, url, label) and
2131
+ * whether the caller already has full text for them (the feed's own payload).
2132
+ * @returns the candidates, capped so one refresh cannot start a stampede.
2133
+ */
2134
+ async listBackfillCandidates(request) {
2135
+ const doc = await this.currentDoc();
2136
+ const limit = Math.max(1, Math.min(request.limit ?? 8, 8));
2137
+ const candidates = [];
2138
+ const now = Date.now();
2139
+ for (const entry of request.entries) {
2140
+ if (entry.url === "") continue;
2141
+ if (entry.hasBody) continue;
2142
+ const source = doc.sources.find((item) => item.kind === "link" && sameTarget(item.url, entry.url));
2143
+ if (source?.failure !== void 0 && !isRetryablePreviewFailure(source.failure.code)) continue;
2144
+ const annotation = doc.annotations?.[entry.entryId];
2145
+ if (annotation?.body !== void 0 && isFresh(annotation.body.expiresAt)) continue;
2146
+ if (annotation?.failedAt !== void 0) {
2147
+ const failedAt = new Date(annotation.failedAt).getTime();
2148
+ if (Number.isFinite(failedAt) && now - failedAt < 216e5) continue;
2149
+ }
2150
+ candidates.push({
2151
+ entryId: entry.entryId,
2152
+ url: entry.url,
2153
+ label: entry.label,
2154
+ ...annotation?.error === void 0 ? {} : { lastError: annotation.error }
2155
+ });
2156
+ if (candidates.length >= limit) break;
2157
+ }
2158
+ return { candidates };
2159
+ }
2160
+ /**
2161
+ * One entry's exact-fit translation record, with its segment map resolved.
2162
+ *
2163
+ * A record from an older storage schema, or one whose sidecar file is gone,
2164
+ * reads as absent — the caller falls back to the global memory, and the stale
2165
+ * record is carried out by the next write or budget pass (lazy, never wiped).
2166
+ *
2167
+ * @param request - the entry.
2168
+ * @returns the record, or nothing usable.
2169
+ */
2170
+ async getEntryTranslation(request) {
2171
+ const record = (await this.currentDoc()).annotations?.[request.entryId]?.translation;
2172
+ if (record === void 0 || record.version !== 1) return {};
2173
+ const segments = record.segments !== void 0 ? { ...record.segments } : record.file === void 0 ? void 0 : readSegmentsFile(this.store.readBody(record.file));
2174
+ if (segments === void 0) return {};
2175
+ return { translation: {
2176
+ pair: record.pair,
2177
+ bodyHash: record.bodyHash,
2178
+ segments
2179
+ } };
2180
+ }
2181
+ /**
2182
+ * The translations for exactly the sentences asked about, of one pair.
2183
+ *
2184
+ * The client hashes the body's sentences itself, so the slice is precise and
2185
+ * the whole table never crosses the wire. Reads do not rewrite the file:
2186
+ * the LRU stamp moves when the sentence next serves (the client's batched
2187
+ * report of what it reused), not when a list of hashes arrives.
2188
+ *
2189
+ * @param request - the pair label and the sentence hashes wanted.
2190
+ * @returns hash → translation for the hits.
2191
+ */
2192
+ async getSentenceTranslations(request) {
2193
+ const memory = await this.translationMemory();
2194
+ const translations = {};
2195
+ for (const hash of request.hashes.slice(0, TRANSLATION_SLICE_LIMIT)) {
2196
+ if (typeof hash !== "string" || hash.length === 0) continue;
2197
+ const hit = memory[`${request.pair}:${hash}`];
2198
+ if (hit !== void 0) translations[hash] = hit.target;
2199
+ }
2200
+ return { translations };
2201
+ }
2202
+ /**
2203
+ * Persist what one translation run produced, in one batch.
2204
+ *
2205
+ * `entries` are the sentences the model just translated: they upsert into the
2206
+ * global memory AND the entry's exact-fit map. `recalled` are the sentences
2207
+ * the run was served from memory: their lastUsedAt bumps (that is what makes
2208
+ * the budget's LRU a use-clock rather than a write-clock), and they join the
2209
+ * entry map too, so the map stays the complete record of this body. When
2210
+ * `entryId` + `bodyHash` are present the entry map is REWRITTEN to the run's
2211
+ * full sentence set — the run saw the whole body, so a merge would only
2212
+ * preserve sentences the current body no longer has.
2213
+ *
2214
+ * @param request - the pair, the learned and recalled sentences, and the
2215
+ * entry identity when this run was an article's.
2216
+ * @returns how many sentences were newly learned.
2217
+ */
2218
+ async rememberSentences(request) {
2219
+ const pair = request.pair.trim();
2220
+ if (pair.length === 0) return { stored: 0 };
2221
+ const learned = normalizeLearns(request.entries);
2222
+ const recalled = normalizeLearns(request.recalled ?? []);
2223
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2224
+ const doc = await this.currentDoc();
2225
+ const budget = doc.cache?.translationBudgetChars ?? 67108864;
2226
+ const memory = { ...await this.translationMemory() };
2227
+ let memoryChanged = false;
2228
+ for (const learn of [...learned, ...recalled]) {
2229
+ memory[`${pair}:${learn.hash}`] = {
2230
+ source: learn.source,
2231
+ target: learn.target,
2232
+ lastUsedAt: now
2233
+ };
2234
+ memoryChanged = true;
2235
+ }
2236
+ let entryWrite;
2237
+ if (request.entryId !== void 0 && request.bodyHash !== void 0) {
2238
+ const segments = {};
2239
+ for (const learn of [...learned, ...recalled]) segments[learn.hash] = learn.target;
2240
+ const entryId = request.entryId;
2241
+ let translation = {
2242
+ version: 1,
2243
+ pair,
2244
+ bodyHash: request.bodyHash,
2245
+ translatedAt: doc.annotations?.[entryId]?.translation?.translatedAt ?? now,
2246
+ lastUsedAt: now
2247
+ };
2248
+ const serialized = JSON.stringify(segments);
2249
+ if (serialized.length > 262144) translation = {
2250
+ ...translation,
2251
+ file: this.store.writeBody(`${entryId}#translation`, serialized),
2252
+ chars: serialized.length
2253
+ };
2254
+ else translation = {
2255
+ ...translation,
2256
+ segments,
2257
+ chars: serialized.length
2258
+ };
2259
+ entryWrite = {
2260
+ entryId,
2261
+ translation
2262
+ };
2263
+ }
2264
+ const bounded = boundTranslations(memory, {
2265
+ ...doc.annotations ?? {},
2266
+ ...entryWrite === void 0 ? {} : { [entryWrite.entryId]: {
2267
+ ...doc.annotations?.[entryWrite.entryId],
2268
+ translation: entryWrite.translation
2269
+ } }
2270
+ }, budget);
2271
+ if (bounded.evictedMemoryKeys.length > 0) memoryChanged = true;
2272
+ if (memoryChanged) {
2273
+ const table = {
2274
+ version: 1,
2275
+ entries: bounded.memory
2276
+ };
2277
+ this.store.writeNamedBody(TRANSLATION_MEMORY_FILE, JSON.stringify(table));
2278
+ }
2279
+ await this.commit((current) => {
2280
+ let annotations;
2281
+ if (entryWrite !== void 0 || bounded.evictedEntryIds.length > 0) {
2282
+ annotations = { ...current.annotations };
2283
+ if (entryWrite !== void 0) annotations[entryWrite.entryId] = {
2284
+ ...annotations[entryWrite.entryId],
2285
+ translation: entryWrite.translation
2286
+ };
2287
+ for (const id of bounded.evictedEntryIds) {
2288
+ const annotation = annotations[id];
2289
+ if (annotation?.translation === void 0) continue;
2290
+ const { translation: _evicted, ...rest } = annotation;
2291
+ annotations[id] = rest;
2292
+ }
2293
+ }
2294
+ return {
2295
+ ...current,
2296
+ ...annotations === void 0 ? {} : { annotations },
2297
+ ...memoryChanged ? { translationMemory: {
2298
+ version: 1,
2299
+ file: TRANSLATION_MEMORY_FILE,
2300
+ entries: Object.keys(bounded.memory).length,
2301
+ chars: memoryTableChars(bounded.memory),
2302
+ updatedAt: now
2303
+ } } : {}
2304
+ };
2305
+ });
2306
+ return { stored: learned.length };
2307
+ }
2308
+ /**
2309
+ * How much the two caches currently hold, aggregated on the host.
2310
+ *
2311
+ * The tables themselves never cross the wire for a settings readout: counts
2312
+ * and characters only. The characters are the store's own accounting unit —
2313
+ * the same numbers the budgets bound.
2314
+ *
2315
+ * @returns per-tier usage.
2316
+ */
2317
+ async getStorageStats() {
2318
+ const doc = await this.currentDoc();
2319
+ let bodyEntries = 0;
2320
+ let bodyChars = 0;
2321
+ let translationEntries = 0;
2322
+ let translationChars = 0;
2323
+ for (const annotation of Object.values(doc.annotations ?? {})) {
2324
+ const body = annotation.body;
2325
+ if (body !== void 0) {
2326
+ bodyEntries += 1;
2327
+ bodyChars += body.chars ?? body.html?.length ?? 0;
2328
+ }
2329
+ const translation = annotation.translation;
2330
+ if (translation !== void 0) {
2331
+ translationEntries += 1;
2332
+ translationChars += translation.chars ?? Object.entries(translation.segments ?? {}).reduce((sum, [hash, target]) => sum + hash.length + target.length + 2, 0);
2333
+ }
2334
+ }
2335
+ return {
2336
+ bodies: {
2337
+ entries: bodyEntries,
2338
+ chars: bodyChars
2339
+ },
2340
+ translations: {
2341
+ entries: translationEntries,
2342
+ chars: translationChars,
2343
+ memoryEntries: doc.translationMemory?.entries ?? 0,
2344
+ memoryChars: doc.translationMemory?.chars ?? 0
2345
+ }
2346
+ };
2347
+ }
2348
+ /**
2349
+ * Forget every translation: the global sentence memory AND every entry map.
2350
+ *
2351
+ * The manage page's one gesture for it, after its own confirm. Bodies, tags,
2352
+ * the recent list and the sources are untouched — the reader is clearing a
2353
+ * derived cache, not their library. The commit's own `pruneBodies` sweep
2354
+ * takes the files once the document no longer names them.
2355
+ *
2356
+ * @returns how many entry maps went, and whether the memory table went.
2357
+ */
2358
+ async clearTranslations() {
2359
+ const doc = await this.currentDoc();
2360
+ const clearedEntries = Object.values(doc.annotations ?? {}).filter((annotation) => annotation.translation !== void 0).length;
2361
+ const clearedMemory = doc.translationMemory !== void 0;
2362
+ if (clearedEntries === 0 && !clearedMemory) return {
2363
+ clearedEntries: 0,
2364
+ clearedMemory: false
2365
+ };
2366
+ await this.commit((current) => {
2367
+ const annotations = {};
2368
+ for (const [id, annotation] of Object.entries(current.annotations ?? {})) {
2369
+ if (annotation.translation === void 0) {
2370
+ annotations[id] = annotation;
2371
+ continue;
2372
+ }
2373
+ const { translation: _cleared, ...rest } = annotation;
2374
+ annotations[id] = rest;
2375
+ }
2376
+ const { translationMemory: _dropped, ...rest } = current;
2377
+ return {
2378
+ ...rest,
2379
+ annotations
2380
+ };
2381
+ });
2382
+ return {
2383
+ clearedEntries,
2384
+ clearedMemory
2385
+ };
2386
+ }
2387
+ /**
2388
+ * The global sentence memory's table, from its sidecar file.
2389
+ *
2390
+ * Read per call, not cached: the file is the truth, and the service's own
2391
+ * commits are the only writes — a cache keyed on the manifest would save a
2392
+ * few MB of JSON parsing per translation run at the price of a coherence
2393
+ * story. An absent, unreadable, or stale-version file reads as empty (the
2394
+ * lazy half of the schema version).
2395
+ */
2396
+ async translationMemory() {
2397
+ const manifest = (await this.currentDoc()).translationMemory;
2398
+ if (manifest === void 0) return {};
2399
+ const raw = this.store.readBody(manifest.file);
2400
+ if (raw === void 0) return {};
2401
+ try {
2402
+ const table = JSON.parse(raw);
2403
+ if (table.version !== 1 || typeof table.entries !== "object" || table.entries === null) return {};
2404
+ return { ...table.entries };
2405
+ } catch {
2406
+ return {};
2407
+ }
2408
+ }
2409
+ /** The tag vocabulary plus which entry ids carry each tag. */
2410
+ async listTags() {
2411
+ const doc = await this.currentDoc();
2412
+ const tags = Object.values(doc.tags ?? {}).sort((a, b) => a.name.localeCompare(b.name));
2413
+ const counts = {};
2414
+ for (const annotation of Object.values(doc.annotations ?? {})) for (const id of annotation.tagIds ?? []) counts[id] = (counts[id] ?? 0) + 1;
2415
+ return {
2416
+ tags,
2417
+ counts
2418
+ };
2419
+ }
2420
+ /**
2421
+ * Create a tag, or return the existing one with the same name.
2422
+ *
2423
+ * Names are matched case-insensitively on purpose: "AI" and "ai" becoming two
2424
+ * tags is what makes a tag system useless for filtering, and the reader
2425
+ * cannot be expected to remember which spelling they used.
2426
+ *
2427
+ * @param request - the name to create or find.
2428
+ * @returns the tag id, or a refusal.
2429
+ */
2430
+ async createTag(request) {
2431
+ const name = request.name.trim().slice(0, 40);
2432
+ if (name.length === 0) return "empty";
2433
+ const doc = await this.currentDoc();
2434
+ const existing = Object.values(doc.tags ?? {}).find((tag) => tag.name.toLowerCase() === name.toLowerCase());
2435
+ if (existing !== void 0) return existing;
2436
+ const tag = {
2437
+ id: `tag-${hash(name.toLowerCase())}`,
2438
+ name,
2439
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2440
+ };
2441
+ await this.commit((current) => ({
2442
+ ...current,
2443
+ tags: {
2444
+ ...current.tags,
2445
+ [tag.id]: tag
2446
+ }
2447
+ }));
2448
+ return tag;
2449
+ }
2450
+ /** Rename a tag (its id, and therefore every entry that carries it, is untouched). */
2451
+ async renameTag(request) {
2452
+ const name = request.name.trim().slice(0, 40);
2453
+ if (name.length === 0) return "empty";
2454
+ if ((await this.currentDoc()).tags?.[request.id] === void 0) return "not-found";
2455
+ await this.commit((current) => ({
2456
+ ...current,
2457
+ tags: {
2458
+ ...current.tags,
2459
+ [request.id]: {
2460
+ ...current.tags?.[request.id],
2461
+ id: request.id,
2462
+ name
2463
+ }
2464
+ }
2465
+ }));
2466
+ return "ok";
2467
+ }
2468
+ /** Drop a tag from the vocabulary and from every entry that carried it. */
2469
+ async deleteTag(request) {
2470
+ if ((await this.currentDoc()).tags?.[request.id] === void 0) return "not-found";
2471
+ await this.commit((current) => {
2472
+ const annotations = {};
2473
+ for (const [entryId, entry] of Object.entries(current.annotations ?? {})) {
2474
+ const tagIds = (entry.tagIds ?? []).filter((id) => id !== request.id);
2475
+ annotations[entryId] = tagIds.length > 0 ? {
2476
+ ...entry,
2477
+ tagIds
2478
+ } : withoutTags(entry);
2479
+ }
2480
+ const { [request.id]: _removed, ...tags } = current.tags ?? {};
2481
+ return {
2482
+ ...current,
2483
+ tags,
2484
+ annotations: cleanupAnnotations(annotations)
2485
+ };
2486
+ });
2487
+ return "ok";
2488
+ }
2489
+ /** Add or remove one tag on one entry. */
2490
+ async tagEntry(request) {
2491
+ if ((await this.currentDoc()).tags?.[request.tagId] === void 0) return "not-found";
2492
+ await this.commit((current) => {
2493
+ const entry = current.annotations?.[request.entryId] ?? {};
2494
+ const ids = new Set(entry.tagIds ?? []);
2495
+ if (request.on) ids.add(request.tagId);
2496
+ else ids.delete(request.tagId);
2497
+ const tagIds = [...ids];
2498
+ return {
2499
+ ...current,
2500
+ annotations: {
2501
+ ...current.annotations,
2502
+ [request.entryId]: tagIds.length > 0 ? {
2503
+ ...entry,
2504
+ tagIds
2505
+ } : withoutTags(entry)
2506
+ }
2507
+ };
2508
+ });
2509
+ return "ok";
2510
+ }
2511
+ /** The tags on one entry. */
2512
+ async entryTags(request) {
2513
+ const doc = await this.currentDoc();
2514
+ const ids = new Set(doc.annotations?.[request.entryId]?.tagIds ?? []);
2515
+ return { tags: Object.values(doc.tags ?? {}).filter((tag) => ids.has(tag.id)) };
2516
+ }
2517
+ /** How long a fetched body is served, and how large the translation store may grow. */
2518
+ async getCachePolicy() {
2519
+ const doc = await this.currentDoc();
2520
+ return {
2521
+ ttlHours: doc.cache?.ttlHours ?? DEFAULT_CACHE_POLICY.ttlHours,
2522
+ maxEntries: doc.cache?.maxEntries ?? DEFAULT_CACHE_POLICY.maxEntries,
2523
+ translationBudgetChars: doc.cache?.translationBudgetChars ?? 67108864
2524
+ };
2525
+ }
2526
+ /** Change the cache policy (`ttlHours: 0` = keep until the budget evicts). */
2527
+ async setCachePolicy(request) {
2528
+ if (!Number.isFinite(request.ttlHours) || request.ttlHours < 0 || request.ttlHours > 2160) return "invalid";
2529
+ if (request.translationBudgetChars !== void 0 && (!Number.isFinite(request.translationBudgetChars) || request.translationBudgetChars <= 0 || request.translationBudgetChars > 1073741824)) return "invalid";
2530
+ const doc = await this.currentDoc();
2531
+ const maxEntries = request.maxEntries ?? doc.cache?.maxEntries ?? DEFAULT_CACHE_POLICY.maxEntries;
2532
+ const translationBudgetChars = request.translationBudgetChars ?? doc.cache?.translationBudgetChars ?? 67108864;
2533
+ await this.commit((current) => ({
2534
+ ...current,
2535
+ cache: {
2536
+ ttlHours: request.ttlHours,
2537
+ maxEntries,
2538
+ translationBudgetChars
2539
+ }
2540
+ }));
2541
+ return "ok";
2542
+ }
2543
+ /** Drop tags no entry references any more. */
2544
+ async pruneTags() {
2545
+ const doc = await this.currentDoc();
2546
+ const used = new Set(Object.values(doc.annotations ?? {}).flatMap((entry) => entry.tagIds ?? []));
2547
+ const kept = {};
2548
+ let removed = 0;
2549
+ for (const [id, tag] of Object.entries(doc.tags ?? {})) if (used.has(id)) kept[id] = tag;
2550
+ else removed += 1;
2551
+ if (removed > 0) await this.commit((current) => ({
2552
+ ...current,
2553
+ tags: kept
2554
+ }));
2555
+ return { removed };
2556
+ }
2557
+ /**
2558
+ * Record that the reader opened one entry.
2559
+ *
2560
+ * One row per ENTRY (not per source): the question the recent page answers is
2561
+ * about the articles that were read, and an RSS entry has no source-level
2562
+ * page to point at. Reopening an entry moves it to the top rather than
2563
+ * duplicating it, and the list is capped — see `MAX_RECENT_ENTRIES`.
2564
+ *
2565
+ * The write is deliberately not batched: opening is a human-paced gesture,
2566
+ * and a queue that had not flushed yet would lose the item on a restart.
2567
+ *
2568
+ * @param request - the entry, its source, and what to call it in the list.
2569
+ * @returns how many entries the list holds afterwards.
2570
+ */
2571
+ async recordRead(request) {
2572
+ const entryId = request.entryId.trim();
2573
+ const sourceId = request.sourceId.trim();
2574
+ if (entryId.length === 0 || sourceId.length === 0) return { entries: (await this.currentDoc()).recent?.length ?? 0 };
2575
+ const title = request.title.trim().slice(0, 300);
2576
+ const url = request.url === void 0 || request.url.length === 0 ? void 0 : request.url;
2577
+ const readAt = (/* @__PURE__ */ new Date()).toISOString();
2578
+ await this.commit((current) => {
2579
+ const kept = (current.recent ?? []).filter((item) => item.entryId !== entryId);
2580
+ return {
2581
+ ...current,
2582
+ recent: [{
2583
+ entryId,
2584
+ sourceId,
2585
+ title,
2586
+ ...url === void 0 ? {} : { url },
2587
+ readAt
2588
+ }, ...kept].slice(0, 100)
2589
+ };
2590
+ });
2591
+ return { entries: (await this.currentDoc()).recent?.length ?? 0 };
2592
+ }
2593
+ /**
2594
+ * The entries the reader opened, newest first.
2595
+ *
2596
+ * @returns the recent list as the page renders it.
2597
+ */
2598
+ async listRecent() {
2599
+ return { entries: [...(await this.currentDoc()).recent ?? []] };
2600
+ }
2601
+ /**
2602
+ * Forget every recent entry.
2603
+ *
2604
+ * The reader's own list is theirs to erase: a "recently read" page that can
2605
+ * only grow is one nobody wants to open in company.
2606
+ *
2607
+ * @returns how many entries were dropped.
2608
+ */
2609
+ async clearRecent() {
2610
+ const removed = (await this.currentDoc()).recent?.length ?? 0;
2611
+ if (removed > 0) await this.commit((current) => ({
2612
+ ...current,
2613
+ recent: []
2614
+ }));
2615
+ return { removed };
2616
+ }
2617
+ /**
2618
+ * Record why a fetch produced nothing, so the retry is a decision.
2619
+ *
2620
+ * The failure also carries its classified CODE when there is one: the wall's
2621
+ * card button explains a red state with the same sentence the detail view
2622
+ * uses, instead of the seam's raw words.
2623
+ *
2624
+ * @param entryId - the entry.
2625
+ * @param error - the message to keep, verbatim.
2626
+ * @param code - the classified reason, when one was derived.
2627
+ */
2628
+ async recordFetchFailure(entryId, error, code) {
2629
+ await this.commit((current) => {
2630
+ const { fetch: _cleared, ...rest } = current.annotations?.[entryId] ?? {};
2631
+ return {
2632
+ ...current,
2633
+ annotations: {
2634
+ ...current.annotations,
2635
+ [entryId]: {
2636
+ ...rest,
2637
+ error,
2638
+ ...code === void 0 ? {} : { failureCode: code },
2639
+ failedAt: (/* @__PURE__ */ new Date()).toISOString()
2640
+ }
2641
+ }
2642
+ };
2643
+ });
2644
+ }
2645
+ /**
2646
+ * Ask the link-resolver table what a pasted URL names.
2647
+ *
2648
+ * Runs on the ADD path only, and never breaks it: the table's own failure
2649
+ * (a resolver bug, an answer shape nobody planned for) reads as "no answer"
2650
+ * and the ordinary fetch path takes over. Resolvers that need the network
2651
+ * ride the same sanctioned seam as every fetch here; with no seam mounted
2652
+ * their fetch throws, they answer `null`, and the ordinary path reports the
2653
+ * missing capability — while a resolver that needs no network (OpenReview's
2654
+ * known-unreadable verdict) still answers.
2655
+ */
2656
+ async resolvePaper(url) {
2657
+ const web = this.web();
2658
+ const fetch = async (target) => {
2659
+ if (web === void 0) throw new UnsupportedContent("no web capability is mounted");
2660
+ const result = await web.fetch({ url: target });
2661
+ return {
2662
+ statusCode: result.statusCode,
2663
+ body: result.body.content
2664
+ };
2665
+ };
2666
+ try {
2667
+ return await resolveLink(url, fetch);
2668
+ } catch {
2669
+ return null;
2670
+ }
2671
+ }
2672
+ /** The probed web seam (no host import, no boot-time dependency). */
2673
+ web() {
2674
+ return this.ctx.get("web");
2675
+ }
2676
+ /** The probed side-chat service. */
2677
+ sideChat() {
2678
+ return this.ctx.get("sideChat");
2679
+ }
2680
+ /**
2681
+ * The document of record: from disk when one is mounted, else the in-memory
2682
+ * copy the service has been accumulating.
2683
+ */
2684
+ async currentDoc() {
2685
+ if (!this.store.available) return this.memoryDoc ?? emptyStateDoc();
2686
+ try {
2687
+ const { doc } = await this.store.read();
2688
+ this.memoryDoc = doc;
2689
+ return doc;
2690
+ } catch (error) {
2691
+ if (!(error instanceof ReaderStoreError)) throw error;
2692
+ this.ctx.logger.warn(`reader: state document unusable — ${error.message}`);
2693
+ return this.memoryDoc ?? emptyStateDoc();
2694
+ }
2695
+ }
2696
+ /**
2697
+ * Apply a mutation to the document of record.
2698
+ *
2699
+ * Without a filesystem the mutation lands in memory so the session still
2700
+ * works; with one it goes through the store's version guard.
2701
+ *
2702
+ * @param mutate - pure function from the current document to the next.
2703
+ */
2704
+ async commit(mutate) {
2705
+ if (!this.store.available) {
2706
+ this.memoryDoc = mutate(this.memoryDoc ?? emptyStateDoc());
2707
+ return;
2708
+ }
2709
+ this.memoryDoc = await this.store.update(mutate);
2710
+ }
2711
+ /**
2712
+ * Fetch a URL, following the cross-origin hops that can be reconstructed.
2713
+ *
2714
+ * The seam refuses a cross-origin hop and names only the target ORIGIN, so
2715
+ * there is exactly one hop this method can follow without inventing a URL:
2716
+ * a same-host scheme/port change, which keeps the path and query
2717
+ * (`http://host/feed` → `https://host/feed`). A hop to a DIFFERENT host is
2718
+ * re-thrown untouched — the caller classifies it, and the reader is told the
2719
+ * address moves to another site rather than being served some other page
2720
+ * under the URL they pasted.
2721
+ *
2722
+ * @param url - the initial URL.
2723
+ * @returns the final URL and its decoded payload.
2724
+ */
2725
+ async fetchFollowing(url) {
2726
+ const web = this.web();
2727
+ if (web === void 0) throw new UnsupportedContent("no web capability is mounted");
2728
+ let current = url;
2729
+ for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop++) try {
2730
+ const result = await web.fetch({ url: current });
2731
+ if (result.statusCode < 200 || result.statusCode >= 300) throw new UnsupportedContent(result.statusCode === 403 || result.statusCode === 401 ? `HTTP ${result.statusCode}: the site refuses non-browser requests, so its page cannot be read here — use the original page` : `HTTP ${result.statusCode}`);
2732
+ return {
2733
+ url: result.url,
2734
+ raw: result.body.content,
2735
+ truncated: result.truncated
2736
+ };
2737
+ } catch (error) {
2738
+ const target = crossOriginRetarget(error, current);
2739
+ if (target === void 0) throw error;
2740
+ current = target;
2741
+ }
2742
+ throw new UnsupportedContent(`more than ${MAX_REDIRECT_HOPS} cross-origin redirects`);
2743
+ }
2744
+ /** Arm (or re-arm) the daily refresh timer against the current document. */
2745
+ async armSchedule() {
2746
+ if (this.disposed) return;
2747
+ if (this.timer !== void 0) {
2748
+ clearTimeout(this.timer);
2749
+ this.timer = void 0;
2750
+ }
2751
+ const doc = await this.currentDoc();
2752
+ if (!doc.refresh.enabled) return;
2753
+ const now = /* @__PURE__ */ new Date();
2754
+ const lastRun = doc.lastRefreshAt === void 0 ? void 0 : new Date(doc.lastRefreshAt);
2755
+ const runThenRearm = () => {
2756
+ this.refresh().then(() => this.armSchedule(), () => this.armSchedule());
2757
+ };
2758
+ if (isCatchUpDue(lastRun, now, doc.refresh.timeOfDay, doc.refresh.enabled)) {
2759
+ runThenRearm();
2760
+ return;
2761
+ }
2762
+ this.timer = setTimeout(runThenRearm, delayUntilNext(now, doc.refresh.timeOfDay));
2763
+ this.timer.unref?.();
2764
+ }
2765
+ };
2766
+ /** Parse and normalize a pasted URL; reject anything that is not http(s). */
2767
+ function normalizeUrl(input) {
2768
+ try {
2769
+ const url = new URL(input.trim());
2770
+ if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
2771
+ if (url.username.length > 0 || url.password.length > 0) return void 0;
2772
+ return url.toString();
2773
+ } catch {
2774
+ return;
2775
+ }
2776
+ }
2777
+ /** Whether two URLs point at the same target, ignoring a trailing slash. */
2778
+ function sameTarget(a, b) {
2779
+ const strip = (value) => value.replace(/\/+$/, "");
2780
+ return strip(a) === strip(b);
2781
+ }
2782
+ /** A captured title/excerpt, trimmed and capped; empty reads as absent. */
2783
+ function metaText(value, cap) {
2784
+ if (value === void 0) return void 0;
2785
+ const trimmed = value.trim();
2786
+ return trimmed.length === 0 ? void 0 : trimmed.slice(0, cap);
2787
+ }
2788
+ /** A short stable hash, used only to build ids. */
2789
+ function hash(input) {
2790
+ let value = 2166136261;
2791
+ for (let index = 0; index < input.length; index++) {
2792
+ value ^= input.charCodeAt(index);
2793
+ value = Math.imul(value, 16777619);
2794
+ }
2795
+ return (value >>> 0).toString(36);
2796
+ }
2797
+ /**
2798
+ * The next URL a cross-origin refusal can be followed to — or `undefined`.
2799
+ *
2800
+ * The host seams throw a `WEB_REDIRECT_BLOCKED` error whose CODE is a property
2801
+ * and whose message names only the target origin. The original implementation
2802
+ * tested the message for the code text, which it never contains, so the whole
2803
+ * follow path was dead code and every cross-origin redirect surfaced as a raw
2804
+ * seam error. This reads the code where it actually lives.
2805
+ *
2806
+ * @param error - the value the seam threw.
2807
+ * @param current - the URL whose request was refused.
2808
+ * @returns the same path and query on the new origin, when the HOST is
2809
+ * unchanged (a scheme or port upgrade); otherwise `undefined`.
2810
+ */
2811
+ function crossOriginRetarget(error, current) {
2812
+ if (errorCode(error) !== "WEB_REDIRECT_BLOCKED") return void 0;
2813
+ const origin = /cross-origin redirect to (\S+)/i.exec(errorMessage(error))?.[1];
2814
+ if (origin === void 0) return void 0;
2815
+ let target;
2816
+ let from;
2817
+ try {
2818
+ target = new URL(origin);
2819
+ from = new URL(current);
2820
+ } catch {
2821
+ return;
2822
+ }
2823
+ if (target.hostname !== from.hostname) return void 0;
2824
+ return `${target.origin}${from.pathname}${from.search}`;
2825
+ }
2826
+ /**
2827
+ * The machine-readable code a harness error carries.
2828
+ *
2829
+ * @param error - any thrown value.
2830
+ * @returns the code when it has one, else `undefined`.
2831
+ */
2832
+ function errorCode(error) {
2833
+ const code = error?.code;
2834
+ return typeof code === "string" ? code : void 0;
2835
+ }
2836
+ /** Marker words a bot wall leaves on an otherwise empty page. */
2837
+ 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|验证码|人机验证|访问验证|安全验证/;
2838
+ /** Marker words an authentication interstitial leaves behind. */
2839
+ const LOGIN_WALL_PATTERN = /cookie required|requires? cookies|sign in|sign-in|log in|login|please log|single sign|sso|qurl=|ezproxy|shibboleth|访问权限|请登录|登录后/;
2840
+ /** Hosts whose NAME alone says the hop is an authentication wall. */
2841
+ const LOGIN_HOST_PATTERN = /(^|[.-])(login|sso|auth|signin|idp|cas)([.-]|$)/i;
2842
+ /**
2843
+ * Whether a fetched page can be previewed at all, and why not when it cannot.
2844
+ *
2845
+ * The host has no DOM, so this is a deliberately crude reading of the payload:
2846
+ * strip script/style/tags, count what is left, and only when that is
2847
+ * vanishingly small look for the words a wall leaves behind. A page with real
2848
+ * text is never second-guessed.
2849
+ *
2850
+ * @param raw - the decoded payload.
2851
+ * @param finalUrl - where the fetch actually landed.
2852
+ * @param requestedUrl - the URL the reader pasted.
2853
+ * @returns the reason when there is nothing to preview, else `undefined`.
2854
+ */
2855
+ function inspectPreview(raw, finalUrl, requestedUrl) {
2856
+ const text = visibleTextLength(raw);
2857
+ if (text >= 200) return void 0;
2858
+ const haystack = `${finalUrl}\n${raw.slice(0, 8e3)}`.toLowerCase();
2859
+ const host = hostOf(finalUrl);
2860
+ if (BOT_WALL_PATTERN.test(haystack)) return {
2861
+ code: "blocked",
2862
+ message: `the site answered a bot challenge instead of the page (${finalUrl})`
2863
+ };
2864
+ if (LOGIN_WALL_PATTERN.test(haystack) || LOGIN_HOST_PATTERN.test(host)) return {
2865
+ code: "login",
2866
+ message: `the page is an authentication interstitial (${finalUrl})`
2867
+ };
2868
+ if (hostOf(requestedUrl) !== host) return {
2869
+ code: "redirected",
2870
+ message: `the address redirected to ${finalUrl}, which yielded no readable page`
2871
+ };
2872
+ return {
2873
+ code: "empty",
2874
+ message: `the page yielded ${text} characters of text (${finalUrl})`
2875
+ };
2876
+ }
2877
+ /**
2878
+ * Classify a thrown fetch failure into the reason the reader sees.
2879
+ *
2880
+ * Everything here is per-case diagnosable: the seam's own message is kept
2881
+ * verbatim, and only the CODE decides which sentence the browser shows and
2882
+ * whether an automatic retry is allowed.
2883
+ *
2884
+ * @param error - the value the fetch threw.
2885
+ * @param at - the failure instant (ISO-8601).
2886
+ * @returns the recorded failure.
2887
+ */
2888
+ function classifyFetchFailure(error, at) {
2889
+ const message = errorMessage(error);
2890
+ const code = errorCode(error);
2891
+ if (code === "WEB_UNSUPPORTED_CONTENT_TYPE" || /unsupported content type/i.test(message)) return {
2892
+ code: "unsupported-type",
2893
+ message,
2894
+ at
2895
+ };
2896
+ if (code === "WEB_REDIRECT_BLOCKED" || /cross-origin redirect/i.test(message)) {
2897
+ const origin = /cross-origin redirect to (\S+)/i.exec(message)?.[1];
2898
+ return {
2899
+ code: origin !== void 0 && LOGIN_HOST_PATTERN.test(hostOf(origin)) ? "login" : "redirected",
2900
+ message,
2901
+ at
2902
+ };
2903
+ }
2904
+ const status = /\bHTTP (\d{3})\b/.exec(message)?.[1];
2905
+ if (status === "401" || status === "403") return {
2906
+ code: "blocked",
2907
+ message,
2908
+ at
2909
+ };
2910
+ if (status !== void 0) return {
2911
+ code: "http",
2912
+ message,
2913
+ at
2914
+ };
2915
+ return {
2916
+ code: "unreachable",
2917
+ message,
2918
+ at
2919
+ };
2920
+ }
2921
+ /** The host of a URL, or an empty string when it cannot be parsed. */
2922
+ function hostOf(url) {
2923
+ try {
2924
+ return new URL(url).hostname.toLowerCase();
2925
+ } catch {
2926
+ return "";
2927
+ }
2928
+ }
2929
+ /**
2930
+ * How much visible text a payload carries.
2931
+ *
2932
+ * Script, style and markup are stripped; entities collapse to a single space.
2933
+ * This is a measurement, not a parser: its only job is to separate "a page"
2934
+ * from "an interstitial that is technically HTML".
2935
+ *
2936
+ * @param raw - the decoded payload.
2937
+ * @returns the visible character count.
2938
+ */
2939
+ function visibleTextLength(raw) {
2940
+ return raw.replace(/<script[\s\S]*?<\/script\s*>/gi, " ").replace(/<style[\s\S]*?<\/style\s*>/gi, " ").replace(/<[^>]*>/g, " ").replace(/&[a-z#0-9]+;/gi, " ").replace(/\s+/g, " ").trim().length;
2941
+ }
2942
+ /** The same source with any recorded preview failure removed. */
2943
+ function withoutFailure(source) {
2944
+ const { failure: _removed, ...rest } = source;
2945
+ return rest;
2946
+ }
2947
+ /** Project one source into the browser-facing summary. */
2948
+ function summarize$1(source) {
2949
+ return {
2950
+ id: source.id,
2951
+ kind: source.kind,
2952
+ url: source.url,
2953
+ label: source.label ?? defaultSourceLabel(source.url),
2954
+ enabled: source.enabled,
2955
+ addedAt: source.addedAt,
2956
+ hasBody: source.raw !== void 0,
2957
+ ...source.fetchedAt !== void 0 ? { fetchedAt: source.fetchedAt } : {},
2958
+ ...source.status !== void 0 ? { status: source.status } : {},
2959
+ ...source.error !== void 0 ? { error: source.error } : {},
2960
+ ...source.truncated === true ? { truncated: true } : {},
2961
+ ...source.failure === void 0 ? {} : { failure: {
2962
+ code: source.failure.code,
2963
+ message: source.failure.message
2964
+ } },
2965
+ ...source.resolvedFrom === void 0 ? {} : { resolvedFrom: source.resolvedFrom }
2966
+ };
2967
+ }
2968
+ /**
2969
+ * The same annotation with no `tagIds` key at all.
2970
+ *
2971
+ * Under `exactOptionalPropertyTypes` an explicit `undefined` is not the same as
2972
+ * an absent optional field, and this document is serialized straight to disk —
2973
+ * so an empty tag list must be an ABSENT key, not a null one.
2974
+ *
2975
+ * @param entry - the annotation to strip.
2976
+ * @returns the annotation without its tag list.
2977
+ */
2978
+ function withoutTags(entry) {
2979
+ const { tagIds: _removed, ...rest } = entry;
2980
+ return rest;
2981
+ }
2982
+ /**
2983
+ * Drop annotations that carry nothing, so the document does not accumulate
2984
+ * empty objects as tags are removed.
2985
+ *
2986
+ * @param annotations - the table to clean.
2987
+ * @returns the table with empty entries removed.
2988
+ */
2989
+ function cleanupAnnotations(annotations) {
2990
+ const out = {};
2991
+ for (const [entryId, entry] of Object.entries(annotations)) {
2992
+ if (entry.body === void 0 && (entry.tagIds ?? []).length === 0 && entry.error === void 0 && entry.fetch === void 0 && entry.translation === void 0 && entry.title === void 0 && entry.excerpt === void 0) continue;
2993
+ out[entryId] = (entry.tagIds ?? []).length > 0 ? entry : withoutTags(entry);
2994
+ }
2995
+ return out;
2996
+ }
2997
+ /**
2998
+ * Parse a translation sidecar's segment map, tolerating a damaged file.
2999
+ *
3000
+ * @param raw - the file's content, or `undefined` when it is gone.
3001
+ * @returns the map, or `undefined` when the file holds none.
3002
+ */
3003
+ function readSegmentsFile(raw) {
3004
+ if (raw === void 0) return void 0;
3005
+ try {
3006
+ const parsed = JSON.parse(raw);
3007
+ if (typeof parsed !== "object" || parsed === null) return void 0;
3008
+ return Object.fromEntries(Object.entries(parsed).filter(([, target]) => typeof target === "string"));
3009
+ } catch {
3010
+ return;
3011
+ }
3012
+ }
3013
+ /**
3014
+ * Coerce one batch of learned sentences: bounded in count and in size, each
3015
+ * carrying its hash and both texts.
3016
+ *
3017
+ * @param entries - the client's batch.
3018
+ * @returns the entries worth storing.
3019
+ */
3020
+ function normalizeLearns(entries) {
3021
+ const out = [];
3022
+ for (const entry of entries.slice(0, TRANSLATION_WRITE_LIMIT)) {
3023
+ if (typeof entry.hash !== "string" || entry.hash.length === 0 || entry.hash.length > 64) continue;
3024
+ if (typeof entry.source !== "string" || typeof entry.target !== "string") continue;
3025
+ if (entry.source.length === 0 || entry.target.length === 0) continue;
3026
+ out.push({
3027
+ hash: entry.hash,
3028
+ source: entry.source.slice(0, TRANSLATION_TEXT_LIMIT),
3029
+ target: entry.target.slice(0, TRANSLATION_TEXT_LIMIT)
3030
+ });
3031
+ }
3032
+ return out;
3033
+ }
3034
+ /** How old a `fetching` record may be before it is treated as abandoned. */
3035
+ const FETCH_STALE_MS = 6e5;
3036
+ /** Whether an ISO instant is recent enough to still mean "in flight". */
3037
+ function isRecent(iso, now, windowMs) {
3038
+ const at = new Date(iso).getTime();
3039
+ return Number.isFinite(at) && now - at <= windowMs;
3040
+ }
3041
+ /** Whether a cache deadline is still in the future. */
3042
+ function isFresh(expiresAt) {
3043
+ const deadline = new Date(expiresAt).getTime();
3044
+ return Number.isFinite(deadline) && deadline > Date.now();
3045
+ }
3046
+ //#endregion
3047
+ //#region lib/types/remote.js
3048
+ var __runInitializers = function(thisArg, initializers, value) {
3049
+ var useValue = arguments.length > 2;
3050
+ for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
3051
+ return useValue ? value : void 0;
3052
+ };
3053
+ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
3054
+ function accept(f) {
3055
+ if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
3056
+ return f;
3057
+ }
3058
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
3059
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
3060
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
3061
+ var _, done = false;
3062
+ for (var i = decorators.length - 1; i >= 0; i--) {
3063
+ var context = {};
3064
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
3065
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
3066
+ context.addInitializer = function(f) {
3067
+ if (done) throw new TypeError("Cannot add initializers after decoration has completed");
3068
+ extraInitializers.push(accept(f || null));
3069
+ };
3070
+ var result = (0, decorators[i])(kind === "accessor" ? {
3071
+ get: descriptor.get,
3072
+ set: descriptor.set
3073
+ } : descriptor[key], context);
3074
+ if (kind === "accessor") {
3075
+ if (result === void 0) continue;
3076
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
3077
+ if (_ = accept(result.get)) descriptor.get = _;
3078
+ if (_ = accept(result.set)) descriptor.set = _;
3079
+ if (_ = accept(result.init)) initializers.unshift(_);
3080
+ } else if (_ = accept(result)) {
3081
+ if (kind === "field") initializers.unshift(_);
3082
+ else descriptor[key] = _;
3083
+ }
3084
+ }
3085
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
3086
+ done = true;
3087
+ };
3088
+ /** The wire namespace is `reader`: the browser calls `remote.reader.*`. */
3089
+ let ReaderRemoteService = (() => {
3090
+ let _classSuper = TypertRemoteService;
3091
+ let _instanceExtraInitializers = [];
3092
+ let _capabilities_decorators;
3093
+ let _listSources_decorators;
3094
+ let _addSource_decorators;
3095
+ let _updateSource_decorators;
3096
+ let _removeSource_decorators;
3097
+ let _refresh_decorators;
3098
+ let _getBodies_decorators;
3099
+ let _getEntryBody_decorators;
3100
+ let _fetchEntryBody_decorators;
3101
+ let _storeEntryBody_decorators;
3102
+ let _entryFetchStates_decorators;
3103
+ let _getRawBody_decorators;
3104
+ let _listBackfillCandidates_decorators;
3105
+ let _getEntryTranslation_decorators;
3106
+ let _getSentenceTranslations_decorators;
3107
+ let _rememberSentences_decorators;
3108
+ let _getStorageStats_decorators;
3109
+ let _clearTranslations_decorators;
3110
+ let _listTags_decorators;
3111
+ let _createTag_decorators;
3112
+ let _renameTag_decorators;
3113
+ let _deleteTag_decorators;
3114
+ let _tagEntry_decorators;
3115
+ let _entryTags_decorators;
3116
+ let _getCachePolicy_decorators;
3117
+ let _setCachePolicy_decorators;
3118
+ let _pruneTags_decorators;
3119
+ let _recordRead_decorators;
3120
+ let _listRecent_decorators;
3121
+ let _clearRecent_decorators;
3122
+ let _quoteToSideChat_decorators;
3123
+ return class ReaderRemoteService extends _classSuper {
3124
+ static {
3125
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
3126
+ _capabilities_decorators = [Remote("capabilities")];
3127
+ _listSources_decorators = [Remote("listSources")];
3128
+ _addSource_decorators = [Remote("addSource")];
3129
+ _updateSource_decorators = [Remote("updateSource")];
3130
+ _removeSource_decorators = [Remote("removeSource")];
3131
+ _refresh_decorators = [Remote("refresh")];
3132
+ _getBodies_decorators = [Remote("getBodies")];
3133
+ _getEntryBody_decorators = [Remote("getEntryBody")];
3134
+ _fetchEntryBody_decorators = [Remote("fetchEntryBody")];
3135
+ _storeEntryBody_decorators = [Remote("storeEntryBody")];
3136
+ _entryFetchStates_decorators = [Remote("entryFetchStates")];
3137
+ _getRawBody_decorators = [Remote("getRawBody")];
3138
+ _listBackfillCandidates_decorators = [Remote("listBackfillCandidates")];
3139
+ _getEntryTranslation_decorators = [Remote("getEntryTranslation")];
3140
+ _getSentenceTranslations_decorators = [Remote("getSentenceTranslations")];
3141
+ _rememberSentences_decorators = [Remote("rememberSentences")];
3142
+ _getStorageStats_decorators = [Remote("getStorageStats")];
3143
+ _clearTranslations_decorators = [Remote("clearTranslations")];
3144
+ _listTags_decorators = [Remote("listTags")];
3145
+ _createTag_decorators = [Remote("createTag")];
3146
+ _renameTag_decorators = [Remote("renameTag")];
3147
+ _deleteTag_decorators = [Remote("deleteTag")];
3148
+ _tagEntry_decorators = [Remote("tagEntry")];
3149
+ _entryTags_decorators = [Remote("entryTags")];
3150
+ _getCachePolicy_decorators = [Remote("getCachePolicy")];
3151
+ _setCachePolicy_decorators = [Remote("setCachePolicy")];
3152
+ _pruneTags_decorators = [Remote("pruneTags")];
3153
+ _recordRead_decorators = [Remote("recordRead")];
3154
+ _listRecent_decorators = [Remote("listRecent")];
3155
+ _clearRecent_decorators = [Remote("clearRecent")];
3156
+ _quoteToSideChat_decorators = [Remote("quoteToSideChat")];
3157
+ __esDecorate(this, null, _capabilities_decorators, {
3158
+ kind: "method",
3159
+ name: "capabilities",
3160
+ static: false,
3161
+ private: false,
3162
+ access: {
3163
+ has: (obj) => "capabilities" in obj,
3164
+ get: (obj) => obj.capabilities
3165
+ },
3166
+ metadata: _metadata
3167
+ }, null, _instanceExtraInitializers);
3168
+ __esDecorate(this, null, _listSources_decorators, {
3169
+ kind: "method",
3170
+ name: "listSources",
3171
+ static: false,
3172
+ private: false,
3173
+ access: {
3174
+ has: (obj) => "listSources" in obj,
3175
+ get: (obj) => obj.listSources
3176
+ },
3177
+ metadata: _metadata
3178
+ }, null, _instanceExtraInitializers);
3179
+ __esDecorate(this, null, _addSource_decorators, {
3180
+ kind: "method",
3181
+ name: "addSource",
3182
+ static: false,
3183
+ private: false,
3184
+ access: {
3185
+ has: (obj) => "addSource" in obj,
3186
+ get: (obj) => obj.addSource
3187
+ },
3188
+ metadata: _metadata
3189
+ }, null, _instanceExtraInitializers);
3190
+ __esDecorate(this, null, _updateSource_decorators, {
3191
+ kind: "method",
3192
+ name: "updateSource",
3193
+ static: false,
3194
+ private: false,
3195
+ access: {
3196
+ has: (obj) => "updateSource" in obj,
3197
+ get: (obj) => obj.updateSource
3198
+ },
3199
+ metadata: _metadata
3200
+ }, null, _instanceExtraInitializers);
3201
+ __esDecorate(this, null, _removeSource_decorators, {
3202
+ kind: "method",
3203
+ name: "removeSource",
3204
+ static: false,
3205
+ private: false,
3206
+ access: {
3207
+ has: (obj) => "removeSource" in obj,
3208
+ get: (obj) => obj.removeSource
3209
+ },
3210
+ metadata: _metadata
3211
+ }, null, _instanceExtraInitializers);
3212
+ __esDecorate(this, null, _refresh_decorators, {
3213
+ kind: "method",
3214
+ name: "refresh",
3215
+ static: false,
3216
+ private: false,
3217
+ access: {
3218
+ has: (obj) => "refresh" in obj,
3219
+ get: (obj) => obj.refresh
3220
+ },
3221
+ metadata: _metadata
3222
+ }, null, _instanceExtraInitializers);
3223
+ __esDecorate(this, null, _getBodies_decorators, {
3224
+ kind: "method",
3225
+ name: "getBodies",
3226
+ static: false,
3227
+ private: false,
3228
+ access: {
3229
+ has: (obj) => "getBodies" in obj,
3230
+ get: (obj) => obj.getBodies
3231
+ },
3232
+ metadata: _metadata
3233
+ }, null, _instanceExtraInitializers);
3234
+ __esDecorate(this, null, _getEntryBody_decorators, {
3235
+ kind: "method",
3236
+ name: "getEntryBody",
3237
+ static: false,
3238
+ private: false,
3239
+ access: {
3240
+ has: (obj) => "getEntryBody" in obj,
3241
+ get: (obj) => obj.getEntryBody
3242
+ },
3243
+ metadata: _metadata
3244
+ }, null, _instanceExtraInitializers);
3245
+ __esDecorate(this, null, _fetchEntryBody_decorators, {
3246
+ kind: "method",
3247
+ name: "fetchEntryBody",
3248
+ static: false,
3249
+ private: false,
3250
+ access: {
3251
+ has: (obj) => "fetchEntryBody" in obj,
3252
+ get: (obj) => obj.fetchEntryBody
3253
+ },
3254
+ metadata: _metadata
3255
+ }, null, _instanceExtraInitializers);
3256
+ __esDecorate(this, null, _storeEntryBody_decorators, {
3257
+ kind: "method",
3258
+ name: "storeEntryBody",
3259
+ static: false,
3260
+ private: false,
3261
+ access: {
3262
+ has: (obj) => "storeEntryBody" in obj,
3263
+ get: (obj) => obj.storeEntryBody
3264
+ },
3265
+ metadata: _metadata
3266
+ }, null, _instanceExtraInitializers);
3267
+ __esDecorate(this, null, _entryFetchStates_decorators, {
3268
+ kind: "method",
3269
+ name: "entryFetchStates",
3270
+ static: false,
3271
+ private: false,
3272
+ access: {
3273
+ has: (obj) => "entryFetchStates" in obj,
3274
+ get: (obj) => obj.entryFetchStates
3275
+ },
3276
+ metadata: _metadata
3277
+ }, null, _instanceExtraInitializers);
3278
+ __esDecorate(this, null, _getRawBody_decorators, {
3279
+ kind: "method",
3280
+ name: "getRawBody",
3281
+ static: false,
3282
+ private: false,
3283
+ access: {
3284
+ has: (obj) => "getRawBody" in obj,
3285
+ get: (obj) => obj.getRawBody
3286
+ },
3287
+ metadata: _metadata
3288
+ }, null, _instanceExtraInitializers);
3289
+ __esDecorate(this, null, _listBackfillCandidates_decorators, {
3290
+ kind: "method",
3291
+ name: "listBackfillCandidates",
3292
+ static: false,
3293
+ private: false,
3294
+ access: {
3295
+ has: (obj) => "listBackfillCandidates" in obj,
3296
+ get: (obj) => obj.listBackfillCandidates
3297
+ },
3298
+ metadata: _metadata
3299
+ }, null, _instanceExtraInitializers);
3300
+ __esDecorate(this, null, _getEntryTranslation_decorators, {
3301
+ kind: "method",
3302
+ name: "getEntryTranslation",
3303
+ static: false,
3304
+ private: false,
3305
+ access: {
3306
+ has: (obj) => "getEntryTranslation" in obj,
3307
+ get: (obj) => obj.getEntryTranslation
3308
+ },
3309
+ metadata: _metadata
3310
+ }, null, _instanceExtraInitializers);
3311
+ __esDecorate(this, null, _getSentenceTranslations_decorators, {
3312
+ kind: "method",
3313
+ name: "getSentenceTranslations",
3314
+ static: false,
3315
+ private: false,
3316
+ access: {
3317
+ has: (obj) => "getSentenceTranslations" in obj,
3318
+ get: (obj) => obj.getSentenceTranslations
3319
+ },
3320
+ metadata: _metadata
3321
+ }, null, _instanceExtraInitializers);
3322
+ __esDecorate(this, null, _rememberSentences_decorators, {
3323
+ kind: "method",
3324
+ name: "rememberSentences",
3325
+ static: false,
3326
+ private: false,
3327
+ access: {
3328
+ has: (obj) => "rememberSentences" in obj,
3329
+ get: (obj) => obj.rememberSentences
3330
+ },
3331
+ metadata: _metadata
3332
+ }, null, _instanceExtraInitializers);
3333
+ __esDecorate(this, null, _getStorageStats_decorators, {
3334
+ kind: "method",
3335
+ name: "getStorageStats",
3336
+ static: false,
3337
+ private: false,
3338
+ access: {
3339
+ has: (obj) => "getStorageStats" in obj,
3340
+ get: (obj) => obj.getStorageStats
3341
+ },
3342
+ metadata: _metadata
3343
+ }, null, _instanceExtraInitializers);
3344
+ __esDecorate(this, null, _clearTranslations_decorators, {
3345
+ kind: "method",
3346
+ name: "clearTranslations",
3347
+ static: false,
3348
+ private: false,
3349
+ access: {
3350
+ has: (obj) => "clearTranslations" in obj,
3351
+ get: (obj) => obj.clearTranslations
3352
+ },
3353
+ metadata: _metadata
3354
+ }, null, _instanceExtraInitializers);
3355
+ __esDecorate(this, null, _listTags_decorators, {
3356
+ kind: "method",
3357
+ name: "listTags",
3358
+ static: false,
3359
+ private: false,
3360
+ access: {
3361
+ has: (obj) => "listTags" in obj,
3362
+ get: (obj) => obj.listTags
3363
+ },
3364
+ metadata: _metadata
3365
+ }, null, _instanceExtraInitializers);
3366
+ __esDecorate(this, null, _createTag_decorators, {
3367
+ kind: "method",
3368
+ name: "createTag",
3369
+ static: false,
3370
+ private: false,
3371
+ access: {
3372
+ has: (obj) => "createTag" in obj,
3373
+ get: (obj) => obj.createTag
3374
+ },
3375
+ metadata: _metadata
3376
+ }, null, _instanceExtraInitializers);
3377
+ __esDecorate(this, null, _renameTag_decorators, {
3378
+ kind: "method",
3379
+ name: "renameTag",
3380
+ static: false,
3381
+ private: false,
3382
+ access: {
3383
+ has: (obj) => "renameTag" in obj,
3384
+ get: (obj) => obj.renameTag
3385
+ },
3386
+ metadata: _metadata
3387
+ }, null, _instanceExtraInitializers);
3388
+ __esDecorate(this, null, _deleteTag_decorators, {
3389
+ kind: "method",
3390
+ name: "deleteTag",
3391
+ static: false,
3392
+ private: false,
3393
+ access: {
3394
+ has: (obj) => "deleteTag" in obj,
3395
+ get: (obj) => obj.deleteTag
3396
+ },
3397
+ metadata: _metadata
3398
+ }, null, _instanceExtraInitializers);
3399
+ __esDecorate(this, null, _tagEntry_decorators, {
3400
+ kind: "method",
3401
+ name: "tagEntry",
3402
+ static: false,
3403
+ private: false,
3404
+ access: {
3405
+ has: (obj) => "tagEntry" in obj,
3406
+ get: (obj) => obj.tagEntry
3407
+ },
3408
+ metadata: _metadata
3409
+ }, null, _instanceExtraInitializers);
3410
+ __esDecorate(this, null, _entryTags_decorators, {
3411
+ kind: "method",
3412
+ name: "entryTags",
3413
+ static: false,
3414
+ private: false,
3415
+ access: {
3416
+ has: (obj) => "entryTags" in obj,
3417
+ get: (obj) => obj.entryTags
3418
+ },
3419
+ metadata: _metadata
3420
+ }, null, _instanceExtraInitializers);
3421
+ __esDecorate(this, null, _getCachePolicy_decorators, {
3422
+ kind: "method",
3423
+ name: "getCachePolicy",
3424
+ static: false,
3425
+ private: false,
3426
+ access: {
3427
+ has: (obj) => "getCachePolicy" in obj,
3428
+ get: (obj) => obj.getCachePolicy
3429
+ },
3430
+ metadata: _metadata
3431
+ }, null, _instanceExtraInitializers);
3432
+ __esDecorate(this, null, _setCachePolicy_decorators, {
3433
+ kind: "method",
3434
+ name: "setCachePolicy",
3435
+ static: false,
3436
+ private: false,
3437
+ access: {
3438
+ has: (obj) => "setCachePolicy" in obj,
3439
+ get: (obj) => obj.setCachePolicy
3440
+ },
3441
+ metadata: _metadata
3442
+ }, null, _instanceExtraInitializers);
3443
+ __esDecorate(this, null, _pruneTags_decorators, {
3444
+ kind: "method",
3445
+ name: "pruneTags",
3446
+ static: false,
3447
+ private: false,
3448
+ access: {
3449
+ has: (obj) => "pruneTags" in obj,
3450
+ get: (obj) => obj.pruneTags
3451
+ },
3452
+ metadata: _metadata
3453
+ }, null, _instanceExtraInitializers);
3454
+ __esDecorate(this, null, _recordRead_decorators, {
3455
+ kind: "method",
3456
+ name: "recordRead",
3457
+ static: false,
3458
+ private: false,
3459
+ access: {
3460
+ has: (obj) => "recordRead" in obj,
3461
+ get: (obj) => obj.recordRead
3462
+ },
3463
+ metadata: _metadata
3464
+ }, null, _instanceExtraInitializers);
3465
+ __esDecorate(this, null, _listRecent_decorators, {
3466
+ kind: "method",
3467
+ name: "listRecent",
3468
+ static: false,
3469
+ private: false,
3470
+ access: {
3471
+ has: (obj) => "listRecent" in obj,
3472
+ get: (obj) => obj.listRecent
3473
+ },
3474
+ metadata: _metadata
3475
+ }, null, _instanceExtraInitializers);
3476
+ __esDecorate(this, null, _clearRecent_decorators, {
3477
+ kind: "method",
3478
+ name: "clearRecent",
3479
+ static: false,
3480
+ private: false,
3481
+ access: {
3482
+ has: (obj) => "clearRecent" in obj,
3483
+ get: (obj) => obj.clearRecent
3484
+ },
3485
+ metadata: _metadata
3486
+ }, null, _instanceExtraInitializers);
3487
+ __esDecorate(this, null, _quoteToSideChat_decorators, {
3488
+ kind: "method",
3489
+ name: "quoteToSideChat",
3490
+ static: false,
3491
+ private: false,
3492
+ access: {
3493
+ has: (obj) => "quoteToSideChat" in obj,
3494
+ get: (obj) => obj.quoteToSideChat
3495
+ },
3496
+ metadata: _metadata
3497
+ }, null, _instanceExtraInitializers);
3498
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, {
3499
+ enumerable: true,
3500
+ configurable: true,
3501
+ writable: true,
3502
+ value: _metadata
3503
+ });
3504
+ }
3505
+ static inject = ["reader"];
3506
+ constructor(ctx, _config = {}) {
3507
+ super(ctx, "readerRemote", { namespace: "reader" });
3508
+ __runInitializers(this, _instanceExtraInitializers);
3509
+ }
3510
+ /** The service core this face delegates to. */
3511
+ get core() {
3512
+ return this.ctx.reader;
3513
+ }
3514
+ /** Capability handshake: what the browser may rely on in this composition. */
3515
+ capabilities() {
3516
+ return this.core.capabilities();
3517
+ }
3518
+ /** The configured sources, without payloads. */
3519
+ async listSources() {
3520
+ return this.core.listSources();
3521
+ }
3522
+ /** Add a source; the host decides whether the URL is a feed or an article. */
3523
+ addSource(request) {
3524
+ return this.core.addSource(request);
3525
+ }
3526
+ /** Change a source's label/enabled flag, or the global refresh time. */
3527
+ updateSource(request) {
3528
+ return this.core.updateSource(request);
3529
+ }
3530
+ /** Drop a source; its entries' translations go with it (their bodies stay for the budget). */
3531
+ removeSource(request) {
3532
+ return this.core.removeSource(request);
3533
+ }
3534
+ /** Fetch the named sources (or every enabled one) now. */
3535
+ async refresh(request) {
3536
+ return this.core.refresh(request);
3537
+ }
3538
+ /** Hand back raw payloads for the browser to parse. */
3539
+ async getBodies(request) {
3540
+ return this.core.getBodies(request);
3541
+ }
3542
+ /** What the detail view should render for one entry, and whether a fetch is worth offering. */
3543
+ getEntryBody(request) {
3544
+ return this.core.getEntryBody(request);
3545
+ }
3546
+ /** Fetch one entry's article (the network half; the browser extracts). */
3547
+ fetchEntryBody(request) {
3548
+ return this.core.fetchEntryBody(request);
3549
+ }
3550
+ /** Cache the markup the browser extracted for one entry. */
3551
+ storeEntryBody(request) {
3552
+ return this.core.storeEntryBody(request);
3553
+ }
3554
+ /** What the plugin holds per entry: a body, a stored raw payload, or a failure. */
3555
+ entryFetchStates(request) {
3556
+ return this.core.entryFetchStates(request);
3557
+ }
3558
+ /** A stored raw payload, so the browser can extract it later. */
3559
+ getRawBody(request) {
3560
+ return this.core.getRawBody(request);
3561
+ }
3562
+ /** Which entries still need their full text (the automatic backfill's work list). */
3563
+ listBackfillCandidates(request) {
3564
+ return this.core.listBackfillCandidates(request);
3565
+ }
3566
+ /** One entry's exact-fit translation record (segment map resolved), when usable. */
3567
+ getEntryTranslation(request) {
3568
+ return this.core.getEntryTranslation(request);
3569
+ }
3570
+ /** Translations for exactly the asked sentence hashes of one pair — never the whole table. */
3571
+ getSentenceTranslations(request) {
3572
+ return this.core.getSentenceTranslations(request);
3573
+ }
3574
+ /** Persist what one translation run learned (and refresh what it reused), in one batch. */
3575
+ rememberSentences(request) {
3576
+ return this.core.rememberSentences(request);
3577
+ }
3578
+ /** Per-tier cache usage, aggregated on the host (the tables never cross). */
3579
+ getStorageStats() {
3580
+ return this.core.getStorageStats();
3581
+ }
3582
+ /** Forget every translation: the global memory and every entry map. */
3583
+ clearTranslations() {
3584
+ return this.core.clearTranslations();
3585
+ }
3586
+ /** The tag vocabulary, with how many entries carry each tag. */
3587
+ listTags() {
3588
+ return this.core.listTags();
3589
+ }
3590
+ /** Create a tag, or return the existing one with the same name. */
3591
+ createTag(request) {
3592
+ return this.core.createTag(request);
3593
+ }
3594
+ /** Rename a tag without touching the entries that carry it. */
3595
+ renameTag(request) {
3596
+ return this.core.renameTag(request);
3597
+ }
3598
+ /** Delete a tag and remove it from every entry. */
3599
+ deleteTag(request) {
3600
+ return this.core.deleteTag(request);
3601
+ }
3602
+ /** Add or remove one tag on one entry. */
3603
+ tagEntry(request) {
3604
+ return this.core.tagEntry(request);
3605
+ }
3606
+ /** The tags on one entry. */
3607
+ entryTags(request) {
3608
+ return this.core.entryTags(request);
3609
+ }
3610
+ /** Read or set how long a fetched body is served (and the translation budget). */
3611
+ getCachePolicy() {
3612
+ return this.core.getCachePolicy();
3613
+ }
3614
+ setCachePolicy(request) {
3615
+ return this.core.setCachePolicy(request);
3616
+ }
3617
+ /** Drop tags nothing references any more. */
3618
+ pruneTags() {
3619
+ return this.core.pruneTags();
3620
+ }
3621
+ /** Record that the reader opened one entry (the recent page's write half). */
3622
+ recordRead(request) {
3623
+ return this.core.recordRead(request);
3624
+ }
3625
+ /** The entries the reader opened, newest first. */
3626
+ listRecent() {
3627
+ return this.core.listRecent();
3628
+ }
3629
+ /** Forget every recent entry. */
3630
+ clearRecent() {
3631
+ return this.core.clearRecent();
3632
+ }
3633
+ /** Forward a ref block to the side-chat service when one is composed. */
3634
+ quoteToSideChat(request) {
3635
+ return this.core.quoteToSideChat(request);
3636
+ }
3637
+ };
3638
+ })();
3639
+ //#endregion
3640
+ //#region lib/types/index.js
3641
+ /** Cordis plugin name used by loader diagnostics. */
3642
+ const name = "reader";
3643
+ /**
3644
+ * Plugin body: provide the service core, then mount the Remote data face.
3645
+ *
3646
+ * No `static inject`: every optional capability (`fs`, `web`, `sideChat`) is
3647
+ * probed at the point of use, so the row boots in any composition and degrades
3648
+ * — memory-only state without `fs`, `unsupported-content` without `web`, a
3649
+ * hidden side-chat gesture without `sideChat`.
3650
+ *
3651
+ * @param ctx - owning Cordis context.
3652
+ * @param config - optional plugin config.
3653
+ */
3654
+ function apply(ctx, config = {}) {
3655
+ const service = new ReaderService(ctx, config);
3656
+ ctx.provide("reader", service);
3657
+ ctx.plugin(ReaderRemoteService, {});
3658
+ }
3659
+ //#endregion
3660
+ export { DAY_MS, DEFAULT_CACHE_POLICY, DEFAULT_REFRESH_TIME, DEFAULT_TRANSLATION_BUDGET_CHARS, INLINE_BODY_MAX_CHARS, MAX_BODY_CHARS_PER_SOURCE, MAX_CACHED_BODIES, MAX_TAGS, MAX_TIMEOUT_MS, MAX_TOTAL_BODY_CHARS, MAX_TRANSLATION_MEMORY_ENTRIES, READER_SOURCE_KINDS, ReaderRemoteService, ReaderService, ReaderStore, ReaderStoreError, STATE_ROOT_SEGMENT, TRANSLATION_STORAGE_VERSION, apply, boundAnnotations, boundPayloads, classifyPayload, defaultSourceLabel, delayUntilNext, emptyStateDoc, errorMessage, isCatchUpDue, isRetryablePreviewFailure, kindQuery, linkEntryId, name, nextOccurrence, normalizeStateDoc, normalizeUrl, parseTimeOfDay, previousOccurrence, pruneOrphanTags, resolveReaderStateRoot, serializeStateDoc, stableEntryId, summarize };