@khorsheed/dsh-reader 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +90 -0
- package/README.i18n.yaml +6 -0
- package/README.md +90 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +15119 -0
- package/lib/index.js +3660 -0
- package/lib/invariant.js +38 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1738 -0
- package/lib/typert.remote-client.d.ts +82 -0
- package/lib/typert.remote-client.js +1115 -0
- package/lib/types/arxiv.d.ts +47 -0
- package/lib/types/arxiv.js +76 -0
- package/lib/types/client/ReaderPane.d.ts +71 -0
- package/lib/types/client/ReaderPane.js +3135 -0
- package/lib/types/client/contract.d.ts +254 -0
- package/lib/types/client/contract.js +1 -0
- package/lib/types/client/definition.d.ts +27 -0
- package/lib/types/client/definition.js +32 -0
- package/lib/types/client/extract-article.d.ts +102 -0
- package/lib/types/client/extract-article.js +1024 -0
- package/lib/types/client/icons.d.ts +27 -0
- package/lib/types/client/icons.js +11 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/index.js +222 -0
- package/lib/types/client/locales.d.ts +391 -0
- package/lib/types/client/locales.js +382 -0
- package/lib/types/client/parse-rss.d.ts +69 -0
- package/lib/types/client/parse-rss.js +656 -0
- package/lib/types/client/quote.d.ts +89 -0
- package/lib/types/client/quote.js +119 -0
- package/lib/types/client/selectors.d.ts +167 -0
- package/lib/types/client/selectors.js +413 -0
- package/lib/types/client/session.d.ts +171 -0
- package/lib/types/client/session.js +223 -0
- package/lib/types/client/store.d.ts +181 -0
- package/lib/types/client/store.js +226 -0
- package/lib/types/client/translate.d.ts +379 -0
- package/lib/types/client/translate.js +873 -0
- package/lib/types/index.d.ts +48 -0
- package/lib/types/index.js +25 -0
- package/lib/types/invariant.d.ts +29 -0
- package/lib/types/invariant.js +38 -0
- package/lib/types/link-resolvers.d.ts +107 -0
- package/lib/types/link-resolvers.js +299 -0
- package/lib/types/remote.d.ts +224 -0
- package/lib/types/remote.js +275 -0
- package/lib/types/schedule.d.ts +78 -0
- package/lib/types/schedule.js +114 -0
- package/lib/types/service.d.ts +633 -0
- package/lib/types/service.js +1697 -0
- package/lib/types/store.d.ts +224 -0
- package/lib/types/store.js +916 -0
- package/lib/types/types.d.ts +710 -0
- package/lib/types/types.js +182 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +128 -0
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(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))).replace(/&/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 };
|