@khorsheed/dsh-reader 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.en.md +90 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +90 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +15119 -0
  6. package/lib/index.js +3660 -0
  7. package/lib/invariant.js +38 -0
  8. package/lib/typert.host.d.ts +3 -0
  9. package/lib/typert.host.js +1738 -0
  10. package/lib/typert.remote-client.d.ts +82 -0
  11. package/lib/typert.remote-client.js +1115 -0
  12. package/lib/types/arxiv.d.ts +47 -0
  13. package/lib/types/arxiv.js +76 -0
  14. package/lib/types/client/ReaderPane.d.ts +71 -0
  15. package/lib/types/client/ReaderPane.js +3135 -0
  16. package/lib/types/client/contract.d.ts +254 -0
  17. package/lib/types/client/contract.js +1 -0
  18. package/lib/types/client/definition.d.ts +27 -0
  19. package/lib/types/client/definition.js +32 -0
  20. package/lib/types/client/extract-article.d.ts +102 -0
  21. package/lib/types/client/extract-article.js +1024 -0
  22. package/lib/types/client/icons.d.ts +27 -0
  23. package/lib/types/client/icons.js +11 -0
  24. package/lib/types/client/index.d.ts +34 -0
  25. package/lib/types/client/index.js +222 -0
  26. package/lib/types/client/locales.d.ts +391 -0
  27. package/lib/types/client/locales.js +382 -0
  28. package/lib/types/client/parse-rss.d.ts +69 -0
  29. package/lib/types/client/parse-rss.js +656 -0
  30. package/lib/types/client/quote.d.ts +89 -0
  31. package/lib/types/client/quote.js +119 -0
  32. package/lib/types/client/selectors.d.ts +167 -0
  33. package/lib/types/client/selectors.js +413 -0
  34. package/lib/types/client/session.d.ts +171 -0
  35. package/lib/types/client/session.js +223 -0
  36. package/lib/types/client/store.d.ts +181 -0
  37. package/lib/types/client/store.js +226 -0
  38. package/lib/types/client/translate.d.ts +379 -0
  39. package/lib/types/client/translate.js +873 -0
  40. package/lib/types/index.d.ts +48 -0
  41. package/lib/types/index.js +25 -0
  42. package/lib/types/invariant.d.ts +29 -0
  43. package/lib/types/invariant.js +38 -0
  44. package/lib/types/link-resolvers.d.ts +107 -0
  45. package/lib/types/link-resolvers.js +299 -0
  46. package/lib/types/remote.d.ts +224 -0
  47. package/lib/types/remote.js +275 -0
  48. package/lib/types/schedule.d.ts +78 -0
  49. package/lib/types/schedule.js +114 -0
  50. package/lib/types/service.d.ts +633 -0
  51. package/lib/types/service.js +1697 -0
  52. package/lib/types/store.d.ts +224 -0
  53. package/lib/types/store.js +916 -0
  54. package/lib/types/types.d.ts +710 -0
  55. package/lib/types/types.js +182 -0
  56. package/locale/en.json +6 -0
  57. package/locale/zh.json +6 -0
  58. package/package.json +128 -0
@@ -0,0 +1,413 @@
1
+ import { kindQuery, READER_SOURCE_KINDS } from "../types.js";
2
+ /** How many entries the pane will show before it stops rendering cards. */
3
+ export const LIST_RENDER_LIMIT = 300;
4
+ /** Every parsed entry, flattened in source order. */
5
+ export function flattenEntries(parsed) {
6
+ const out = [];
7
+ for (const source of Object.values(parsed))
8
+ out.push(...source.entries);
9
+ return out;
10
+ }
11
+ /**
12
+ * What the plugin holds for one entry, as every surface reads it.
13
+ *
14
+ * The host's annotation is the only authority (a cached body, an in-flight or
15
+ * stored-raw fetch, a classified failure with its reason); the pane mirrors it
16
+ * per entry, and this is the ONE read path into that mirror — including the
17
+ * default, so no surface spells "no record" as anything other than `none`.
18
+ *
19
+ * @param states - the pane's mirror of the host's fetch states.
20
+ * @param entryId - the entry being shown.
21
+ * @returns the entry's fetch state.
22
+ */
23
+ export function fetchStateOf(states, entryId) {
24
+ return states[entryId] ?? { state: 'none' };
25
+ }
26
+ /**
27
+ * Apply the filter, the query and the sort.
28
+ *
29
+ * @param entries - the parsed entries.
30
+ * @param sources - presentation metadata per source id.
31
+ * @param options - the current filter/query/sort plus the session's read cursor.
32
+ * @returns the rows to render, newest-first unless the sort says otherwise.
33
+ */
34
+ export function selectRows(entries, sources, options) {
35
+ const query = options.query.trim().toLowerCase();
36
+ const rows = [];
37
+ for (const entry of entries) {
38
+ const source = sources.get(entry.sourceId);
39
+ if (source === undefined)
40
+ continue;
41
+ if (options.unreadOnly && options.read[entry.id] === true)
42
+ continue;
43
+ if (options.filter === 'today' && !isToday(entry.publishedAt, options.now))
44
+ continue;
45
+ if (query.length > 0 && !matches(entry, source, query, options.tags ?? {}))
46
+ continue;
47
+ rows.push({
48
+ entry,
49
+ sourceId: source.id,
50
+ sourceLabel: source.label,
51
+ sourceTile: source.tile,
52
+ sourceHue: source.hue,
53
+ sourceAddedAt: source.addedAt,
54
+ sourceKind: source.kind,
55
+ unread: options.read[entry.id] !== true,
56
+ });
57
+ }
58
+ sortRows(rows, options.sort);
59
+ // Relevance before the chosen order, but only for a TEXT search: a phrase
60
+ // that appears in a title is what the reader meant, and leaving a title match
61
+ // buried under newer summary matches is how a search "returns the wrong
62
+ // things first". `#sourceId` / `@tagId` selectors are not ranked — every row
63
+ // matches them the same way. Array#sort is stable, so the chosen order is
64
+ // what the rows inside one tier keep.
65
+ if (query.length > 0 && !query.startsWith('#') && !query.startsWith('@')) {
66
+ rows.sort((a, b) => relevance(a.entry.title, query) - relevance(b.entry.title, query));
67
+ }
68
+ return rows.slice(0, LIST_RENDER_LIMIT);
69
+ }
70
+ /**
71
+ * The row for one entry, when its source is still known.
72
+ *
73
+ * `selectRows` builds rows for the LIST; the detail view needs the same row for
74
+ * one entry it was told to reopen (a restored reading position), and rebuilding
75
+ * the row by hand there would be a second, drifting copy of this mapping.
76
+ *
77
+ * @param entry - the entry to present.
78
+ * @param sources - presentation metadata per source id.
79
+ * @param read - the session's read cursor.
80
+ * @returns the row, or `undefined` when the entry's source is gone.
81
+ */
82
+ export function rowFor(entry, sources, read) {
83
+ const source = sources.get(entry.sourceId);
84
+ if (source === undefined)
85
+ return undefined;
86
+ return {
87
+ entry,
88
+ sourceId: source.id,
89
+ sourceLabel: source.label,
90
+ sourceTile: source.tile,
91
+ sourceHue: source.hue,
92
+ sourceAddedAt: source.addedAt,
93
+ sourceKind: source.kind,
94
+ unread: read[entry.id] !== true,
95
+ };
96
+ }
97
+ /**
98
+ * Whether an entry belongs in the "today" view.
99
+ *
100
+ * An entry with NO usable date is kept, not dropped. The default filter is
101
+ * `today`, and a saved article link carries no `publishedAt` at all — dropping
102
+ * undated entries would make the primary add-a-link flow produce a link the
103
+ * reader can never see. A date the reader cannot compare is likewise not
104
+ * evidence that the entry is old.
105
+ *
106
+ * @param iso - the feed-declared timestamp, when there is one.
107
+ * @param now - the reference day.
108
+ * @returns true when the entry is on that day, or carries no comparable date.
109
+ */
110
+ export function isToday(iso, now) {
111
+ if (iso === undefined)
112
+ return true;
113
+ const parsed = new Date(iso);
114
+ if (Number.isNaN(parsed.getTime()))
115
+ return true;
116
+ return parsed.getFullYear() === now.getFullYear()
117
+ && parsed.getMonth() === now.getMonth()
118
+ && parsed.getDate() === now.getDate();
119
+ }
120
+ /**
121
+ * The query that selects exactly one source's entries.
122
+ *
123
+ * The search box and the source strip are the same mechanism — one local
124
+ * predicate (D14) — so a source filter is a query the reader can also see and
125
+ * edit. The two-character prefix keeps it out of the way of real search terms:
126
+ * no title search will accidentally match it, and the id is stable across
127
+ * renames.
128
+ *
129
+ * @param sourceId - the source to select.
130
+ * @returns the query string.
131
+ */
132
+ export function sourceQuery(sourceId) {
133
+ return `#${sourceId}`;
134
+ }
135
+ /**
136
+ * The query that selects everything carrying one tag.
137
+ *
138
+ * @param tagId - the tag to select.
139
+ * @returns the query string.
140
+ */
141
+ export function tagQuery(tagId) {
142
+ return `@${tagId}`;
143
+ }
144
+ /**
145
+ * How directly a title answers a text query: 0 exact, 1 contains, 2 elsewhere.
146
+ *
147
+ * @param title - the entry title.
148
+ * @param query - the lower-cased query.
149
+ * @returns the tier, lowest first.
150
+ */
151
+ function relevance(title, query) {
152
+ const lower = title.toLowerCase();
153
+ if (lower === query)
154
+ return 0;
155
+ return lower.includes(query) ? 1 : 2;
156
+ }
157
+ /** Whether an entry matches the query, across the fields a reader searches by. */
158
+ function matches(entry, source, query, tags) {
159
+ if (query.startsWith('#')) {
160
+ const selector = query.slice(1);
161
+ // A kind selector first: source ids are always `<kind>-<hash>`, so
162
+ // `#link` can never be one, and the two vocabularies stay disjoint.
163
+ const kind = READER_SOURCE_KINDS.find(candidate => kindQuery(candidate) === query);
164
+ if (kind !== undefined)
165
+ return source.kind === kind;
166
+ return selector === entry.sourceId;
167
+ }
168
+ if (query.startsWith('@'))
169
+ return (tags[entry.id] ?? []).includes(query.slice(1));
170
+ const haystack = [
171
+ entry.title,
172
+ entry.summary ?? '',
173
+ entry.author ?? '',
174
+ source.label,
175
+ ...(entry.tags ?? []),
176
+ ].join(' ').toLowerCase();
177
+ return haystack.includes(query);
178
+ }
179
+ /**
180
+ * When a row belongs on a time-ordered wall.
181
+ *
182
+ * An entry's own date wins; a saved link has none at all, so it falls back to
183
+ * when the SOURCE was added. An entry with neither (a hand-made link entry
184
+ * from a source whose document predates `addedAt`) sorts as `0`, i.e. last,
185
+ * which is the old behaviour and still deterministic.
186
+ *
187
+ * @param row - the row to date.
188
+ * @returns milliseconds, or `0` when the row carries no comparable date.
189
+ */
190
+ function timeOf(row) {
191
+ const iso = row.entry.publishedAt ?? row.sourceAddedAt;
192
+ const parsed = iso === undefined ? Number.NaN : new Date(iso).getTime();
193
+ return Number.isNaN(parsed) ? 0 : parsed;
194
+ }
195
+ /** Sort in place: newest first, oldest first, or grouped by source. */
196
+ function sortRows(rows, sort) {
197
+ if (sort === 'newest') {
198
+ rows.sort((a, b) => timeOf(b) - timeOf(a));
199
+ return;
200
+ }
201
+ if (sort === 'oldest') {
202
+ rows.sort((a, b) => timeOf(a) - timeOf(b));
203
+ return;
204
+ }
205
+ // Grouped: source name first, then newest inside each group. The comparison
206
+ // is total (ties broken by the stable entry id) so repeated renders of the
207
+ // same data never reshuffle.
208
+ rows.sort((a, b) => {
209
+ if (a.sourceLabel !== b.sourceLabel)
210
+ return a.sourceLabel < b.sourceLabel ? -1 : 1;
211
+ const delta = timeOf(b) - timeOf(a);
212
+ if (delta !== 0)
213
+ return delta;
214
+ return a.entry.id < b.entry.id ? -1 : a.entry.id > b.entry.id ? 1 : 0;
215
+ });
216
+ }
217
+ /** How many of these rows are still unread. */
218
+ export function countUnread(rows) {
219
+ return rows.reduce((count, row) => count + (row.unread ? 1 : 0), 0);
220
+ }
221
+ /* -------------------------------------------------------------- wall dedupe */
222
+ /**
223
+ * Wall dedupe: aggregated feeds republish the same article, and the wall
224
+ * should show it once (measured on the reader's own wall: "An Alien Mind"
225
+ * under both an aggregator and the source blog).
226
+ *
227
+ * Grouping is deliberately NEVER fuzzy — a miss costs one duplicate card, a
228
+ * false positive costs an article. Three tiers, strongest first; a match on
229
+ * ANY tier groups (they compose transitively — an id-match and a link-match
230
+ * bridge two groups into one):
231
+ *
232
+ * 1. **Same entry id** — the strongest signal there is: `stableEntryId` shares
233
+ * one id across copies BY DESIGN (a shared guid buys shared fetch/read/
234
+ * translation state), so a guid collision is the publisher saying "same
235
+ * item", and it folds even when the copies' links differ.
236
+ * 2. **Normalized link** (any entry that has one): scheme, `www.` and
237
+ * trailing slashes stripped, tracking parameters (`utm_*`, `fbclid`,
238
+ * `gclid`, `ref`, `spm`, …) removed, the rest of the query sorted.
239
+ * 3. **Folded title + same published day**: case, accents and punctuation
240
+ * folded; BOTH entries must carry a comparable date, so undated saved links
241
+ * never title-group.
242
+ */
243
+ /** Query keys that never change which article a link names. */
244
+ const TRACKING_PARAMS = new Set([
245
+ 'fbclid', 'gclid', 'spm', 'ref', 'ref_src', 'source', 'mc_cid', 'mc_eid', 'igshid',
246
+ ]);
247
+ /** The dedupe key for an entry's link, or `undefined` when it has none worth one. */
248
+ function linkKey(link) {
249
+ if (link === undefined)
250
+ return undefined;
251
+ try {
252
+ const url = new URL(link.trim());
253
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
254
+ return undefined;
255
+ const host = url.hostname.toLowerCase().replace(/^www\./, '');
256
+ const path = url.pathname.replace(/\/+$/, '');
257
+ const params = [...url.searchParams.entries()]
258
+ .filter(([key]) => !/^utm_/i.test(key) && !TRACKING_PARAMS.has(key.toLowerCase()))
259
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
260
+ const query = params.map(([key, value]) => `${key}=${value}`).join('&');
261
+ return `${host}${path}${query === '' ? '' : `?${query}`}`;
262
+ }
263
+ catch {
264
+ return undefined;
265
+ }
266
+ }
267
+ /** The dedupe key for an entry's title + day, or `undefined` when either is missing. */
268
+ function titleDayKey(entry) {
269
+ const title = entry.title
270
+ .normalize('NFKD')
271
+ .replace(/\p{M}/gu, '')
272
+ .toLowerCase()
273
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
274
+ .trim();
275
+ // A handful of characters is a topic, not a title: folding "Go 语言" onto
276
+ // itself across two feeds is a false positive, not a duplicate.
277
+ if (title.length < 6)
278
+ return undefined;
279
+ const parsed = entry.publishedAt === undefined ? Number.NaN : new Date(entry.publishedAt).getTime();
280
+ if (Number.isNaN(parsed))
281
+ return undefined;
282
+ return `${title}|${entry.publishedAt.slice(0, 10)}`;
283
+ }
284
+ /** Every tier key an entry qualifies for (the id tier always applies). */
285
+ function dedupeKeysOf(entry) {
286
+ const keys = [`i:${entry.id}`];
287
+ const link = linkKey(entry.link);
288
+ if (link !== undefined)
289
+ keys.push(`l:${link}`);
290
+ const title = titleDayKey(entry);
291
+ if (title !== undefined)
292
+ keys.push(`t:${title}`);
293
+ return keys;
294
+ }
295
+ /**
296
+ * Fold duplicate rows into their survivors.
297
+ *
298
+ * Survivor choice, in order: a member whose body the host already holds
299
+ * (a `ready` fetch state — the fetched copy opens instantly) beats a NEWER
300
+ * member, and a tie keeps the input order. Same-id copies share the id-keyed
301
+ * fetch-state record, so between them the choice is always the input order —
302
+ * they share every annotation anyway. The survivor sits at its own position in
303
+ * the row order. Read state merges across the group: the card reads as read
304
+ * when ANY copy was read (free via the shared id for same-id copies). Hidden
305
+ * rows are reported, never deleted — the card badges them.
306
+ *
307
+ * All bookkeeping is POSITIONAL (row indexes), never id-keyed for the fold
308
+ * itself: two copies of one article can share ONE entry id by design, and
309
+ * id-keyed folding keeps both — the survivor's id is the hidden copy's id.
310
+ * Only the badge lookup (`dupesBy`) is id-keyed, which is safe: the surviving
311
+ * card is the only rendered row carrying that id.
312
+ *
313
+ * @param rows - the wall's rows (selectRows' output: filtered and sorted).
314
+ * @param fetchStates - the pane's fetch-state mirror, for the ready signal.
315
+ * @returns the rows to render, and where the hidden ones went.
316
+ */
317
+ export function dedupeRows(rows, fetchStates = {}) {
318
+ // Multi-tier grouping with bridging: a row joins the group any of its tier
319
+ // keys names, and a row whose keys name two groups merges them (A≡B by id
320
+ // and B≡C by link fold all three).
321
+ const keyToGroup = new Map();
322
+ const groupRows = [];
323
+ const groupKeys = [];
324
+ rows.forEach((row, index) => {
325
+ const keys = dedupeKeysOf(row.entry);
326
+ const hits = [...new Set(keys.map(key => keyToGroup.get(key)).filter((group) => group !== undefined))];
327
+ if (hits.length === 0) {
328
+ const created = groupRows.length;
329
+ groupRows.push([index]);
330
+ groupKeys.push([...keys]);
331
+ for (const key of keys)
332
+ keyToGroup.set(key, created);
333
+ return;
334
+ }
335
+ const target = hits[0];
336
+ groupRows[target].push(index);
337
+ groupKeys[target].push(...keys);
338
+ for (const key of keys)
339
+ keyToGroup.set(key, target);
340
+ for (const absorbed of hits.slice(1)) {
341
+ groupRows[target].push(...groupRows[absorbed]);
342
+ for (const key of groupKeys[absorbed])
343
+ keyToGroup.set(key, target);
344
+ groupRows[absorbed] = [];
345
+ groupKeys[absorbed] = [];
346
+ }
347
+ });
348
+ /** The survivor of one group, by row index: a cached body first, then newest, then input order. */
349
+ const survivorIndexOf = (members) => {
350
+ let best = members[0];
351
+ for (const index of members.slice(1)) {
352
+ const member = rows[index];
353
+ const bestRow = rows[best];
354
+ const ready = fetchStates[member.entry.id]?.state === 'ready';
355
+ const bestReady = fetchStates[bestRow.entry.id]?.state === 'ready';
356
+ if (ready !== bestReady) {
357
+ if (ready)
358
+ best = index;
359
+ continue;
360
+ }
361
+ if (timeOf(member) > timeOf(bestRow))
362
+ best = index;
363
+ }
364
+ return best;
365
+ };
366
+ const dupesBy = new Map();
367
+ const folded = new Set(); // row indexes hidden behind their survivor
368
+ let hiddenCount = 0;
369
+ for (const members of groupRows) {
370
+ if (members.length <= 1)
371
+ continue;
372
+ const survivor = survivorIndexOf(members);
373
+ dupesBy.set(rows[survivor].entry.id, members.filter(index => index !== survivor).map(index => rows[index]));
374
+ for (const index of members) {
375
+ if (index === survivor)
376
+ continue;
377
+ folded.add(index);
378
+ hiddenCount += 1;
379
+ }
380
+ }
381
+ const out = rows
382
+ .filter((_, index) => !folded.has(index))
383
+ .map(row => {
384
+ const hidden = dupesBy.get(row.entry.id);
385
+ if (hidden === undefined)
386
+ return row;
387
+ // Read state merges across the group: one read copy reads the card.
388
+ const mergedUnread = ![row, ...hidden].some(member => !member.unread);
389
+ return mergedUnread === row.unread ? row : { ...row, unread: mergedUnread };
390
+ });
391
+ return { rows: out, dupesBy, hiddenCount };
392
+ }
393
+ /** A short, stable, source-derived monogram tile (design decision D11). */
394
+ export function tileForSource(label) {
395
+ const first = label.trim().slice(0, 1);
396
+ return first.length > 0 ? first.toUpperCase() : '·';
397
+ }
398
+ /** A stable hue derived from the source label, so a source keeps its colour. */
399
+ export function hueForSource(label) {
400
+ const palette = [
401
+ 'rgb(41,41,41)',
402
+ 'rgb(84,85,87)',
403
+ 'rgb(65,118,230)',
404
+ 'rgb(217,119,87)',
405
+ 'rgb(47,158,68)',
406
+ 'rgb(151,157,166)',
407
+ ];
408
+ let hash = 0;
409
+ for (let index = 0; index < label.length; index++) {
410
+ hash = (hash * 31 + label.charCodeAt(index)) >>> 0;
411
+ }
412
+ return palette[hash % palette.length];
413
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Where the reader was, kept for as long as the page is.
3
+ *
4
+ * The host unmounts the whole right sidebar when another main panel takes over
5
+ * (`RightbarRoot` renders only while `activePanelId === null`), and the pane's
6
+ * store is created per mount — so returning from the side chat used to lose the
7
+ * open article, the reading position, the wall's narrowing and the fact that a
8
+ * translation was on. This module is the fix: one snapshot in module memory.
9
+ *
10
+ * ONE bucket for the whole page, not one per dsh session. The pane is mounted
11
+ * per session, so keying this by `sessionId` meant "open the reader in another
12
+ * conversation" started from scratch — the same wall, the same article, the
13
+ * same translation, all to be set up again. The reader is one person reading one
14
+ * wall; the session id stays what it is genuinely for (the conversation draft,
15
+ * the side-chat context key), and nothing else here is session-bound.
16
+ *
17
+ * The PLACE is persisted, the CONTENT is not. `sessionStorage` carries the four
18
+ * fields that say where the reader was (the open entry, the view, the article
19
+ * anchors, the wall's offset), because "where I was reading" is not third-party
20
+ * content and the reader's report was exactly that a reload — or anything else
21
+ * that restarts the page — lost it. What stays out of every storage is the
22
+ * translated text and the translator sessions: those are a third party's words,
23
+ * and this package's rule is that they never leave the page. So after a reload
24
+ * the article and the position come back, and the globe does not (there is no
25
+ * session to restore it with, and rebuilding one needs a gesture).
26
+ *
27
+ * Everything else — the wall's narrowing, the read cursor, the wall's own
28
+ * translation switch and its card texts — is memory only: it is about this
29
+ * visit. Host state (cached bodies, sources, the 「最近阅读」 list) is read from
30
+ * the host, as always.
31
+ *
32
+ * @module @khorsheed/dsh-reader/client/session
33
+ */
34
+ import type { ReaderFilter, ReaderSort, ReaderView } from './store.ts';
35
+ import type { TranslationView, TranslatorSessionLike } from './translate.ts';
36
+ /** One session's worth of "where the reader was". */
37
+ export interface ReaderSessionSnapshot {
38
+ readonly view: ReaderView;
39
+ readonly openEntryId: string | null;
40
+ readonly openSourceId: string | null;
41
+ readonly filter: ReaderFilter;
42
+ readonly query: string;
43
+ readonly sort: ReaderSort;
44
+ readonly unreadOnly: boolean;
45
+ /** Whether the wall folds republished duplicates (default ON). */
46
+ readonly hideDupes: boolean;
47
+ readonly read: Record<string, true>;
48
+ /** Which surface was translated on the wall, and how it was shown. */
49
+ readonly wallOn: boolean;
50
+ readonly wallBoth: boolean;
51
+ /** Card translations already produced (small: title and summary per entry). */
52
+ readonly cardTranslations: Record<string, {
53
+ title?: string;
54
+ summary?: string;
55
+ }>;
56
+ /**
57
+ * Where the reader was inside each entry.
58
+ *
59
+ * An ANCHOR, not a pixel offset: the body's height is not final when it first
60
+ * renders (an article's images carry no dimensions, so they grow the document
61
+ * as they load), and a remembered pixel position then points at different
62
+ * content — which is how a reader ends up back at the top of a paper.
63
+ */
64
+ readonly scroll: Record<string, ReaderReadingAnchor>;
65
+ /** Where the WALL itself was scrolled to, in pixels. */
66
+ readonly wallScroll?: number;
67
+ /** The translation view a reader chose per entry (its presence means "globe was on"). */
68
+ readonly translationView: Record<string, TranslationView>;
69
+ /** The source language that view was built for, so the cached session is findable. */
70
+ readonly translationSource: Record<string, string>;
71
+ }
72
+ /**
73
+ * Where the reader was inside one entry.
74
+ *
75
+ * `block` + `offset` name a place in the DOCUMENT (the Nth top-level block, and
76
+ * how far into it the viewport top sat), so it survives everything above it
77
+ * changing height. `top` is the same place as a pixel offset: the fast path when
78
+ * the layout is already settled, and the only thing left when the body is not
79
+ * the one the anchor was taken from.
80
+ *
81
+ * `text` is the same place once more, as a CHARACTER offset into the block's
82
+ * text. A pixel offset into a block is only valid for the layout it was
83
+ * measured in: the translated view sets different words (Chinese runs shorter
84
+ * than English), so the same paragraph is a different height and `offset`
85
+ * points at the wrong sentence. `text` restores exactly (a Range rect) when the
86
+ * block still holds the same text, and as the same FRACTION of the text when it
87
+ * does not. `textLength` is how the restore tells those two apart. Both are
88
+ * optional: anchors written before them restore via the pixel offset, as
89
+ * before.
90
+ */
91
+ export interface ReaderReadingAnchor {
92
+ readonly block: number;
93
+ readonly offset: number;
94
+ readonly top: number;
95
+ readonly text?: number;
96
+ readonly textLength?: number;
97
+ }
98
+ type Patch = Partial<ReaderSessionSnapshot>;
99
+ /**
100
+ * What the page remembers, or `{}` when it remembers nothing yet.
101
+ *
102
+ * The persisted place sits UNDER the in-memory snapshot: within one page the
103
+ * memory is the truth (it has fields the place does not), and after a restart
104
+ * the place is all there is.
105
+ *
106
+ * @returns the snapshot (a copy of the recorded fields).
107
+ */
108
+ export declare function readSession(): Patch;
109
+ /**
110
+ * Record what changed.
111
+ *
112
+ * A field set to `undefined` is dropped rather than stored: "no record" and
113
+ * "recorded as nothing" would otherwise be two spellings of the same state.
114
+ *
115
+ * @param patch - the fields to replace.
116
+ */
117
+ export declare function patchSession(patch: Patch): void;
118
+ /** Drop the snapshot, here and in storage (the specs call this between cases). */
119
+ export declare function forgetSession(): void;
120
+ /**
121
+ * Remember where the reader was inside one entry.
122
+ *
123
+ * @param entryId - the entry whose body was on screen.
124
+ * @param anchor - the place in the document the viewport top sat at.
125
+ */
126
+ export declare function rememberReadingPosition(entryId: string, anchor: ReaderReadingAnchor): void;
127
+ /**
128
+ * Remember where the reader had scrolled the wall itself.
129
+ *
130
+ * The wall is the other long scroller in this pane, and losing its place is the
131
+ * same complaint as losing the place inside an article.
132
+ *
133
+ * @param top - the wall scroller's offset in pixels.
134
+ */
135
+ export declare function rememberWallScroll(top: number): void;
136
+ /**
137
+ * Remember that one entry is showing a translation, and in which view.
138
+ *
139
+ * @param entryId - the entry that was translated.
140
+ * @param view - the view that was on screen, or `'orig'` to forget it.
141
+ * @param source - the source language the translator was built for.
142
+ */
143
+ export declare function rememberTranslation(entryId: string, view: TranslationView, source: string): void;
144
+ /**
145
+ * Forget one entry's translation record (a re-fetch replaced its body).
146
+ *
147
+ * @param entryId - the entry whose body changed.
148
+ */
149
+ export declare function forgetTranslation(entryId: string): void;
150
+ /** The cache key for one language pair. */
151
+ export declare function translationKey(sources: readonly string[], targets: readonly string[]): string;
152
+ /**
153
+ * A built session for this pair, when one exists in this page.
154
+ *
155
+ * @param sources - source language candidates, in priority order.
156
+ * @param targets - target language candidates, in priority order.
157
+ * @returns the session, or `undefined`.
158
+ */
159
+ export declare function cachedTranslator(sources: readonly string[], targets: readonly string[]): TranslatorSessionLike | undefined;
160
+ /**
161
+ * Remember a session this page built.
162
+ *
163
+ * @param sources - source language candidates, in priority order.
164
+ * @param targets - target language candidates, in priority order.
165
+ * @param session - the built session.
166
+ */
167
+ export declare function rememberTranslator(sources: readonly string[], targets: readonly string[], session: TranslatorSessionLike): void;
168
+ /** Drop the translator sessions (the specs call this between cases). */
169
+ export declare function forgetTranslators(): void;
170
+ export {};
171
+ //# sourceMappingURL=session.d.ts.map