@khorsheed/dsh-reader 0.2.1 → 0.3.1

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 (89) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.en.md +26 -121
  3. package/README.i18n.yaml +3 -3
  4. package/README.md +26 -121
  5. package/cordis.patch.yml +7 -0
  6. package/lib/client.js +30580 -6892
  7. package/lib/index.js +4189 -621
  8. package/lib/typert.host.js +2265 -319
  9. package/lib/typert.remote-client.d.ts +99 -3
  10. package/lib/typert.remote-client.js +1618 -240
  11. package/lib/types/captions.d.ts +99 -0
  12. package/lib/types/captions.js +236 -0
  13. package/lib/types/client/MediaPlayer.d.ts +55 -0
  14. package/lib/types/client/MediaPlayer.js +106 -0
  15. package/lib/types/client/NotesView.d.ts +57 -0
  16. package/lib/types/client/NotesView.js +124 -0
  17. package/lib/types/client/ReaderPane.d.ts +20 -3
  18. package/lib/types/client/ReaderPane.js +905 -164
  19. package/lib/types/client/SpeakerLegend.d.ts +18 -0
  20. package/lib/types/client/SpeakerLegend.js +26 -0
  21. package/lib/types/client/canvas-sync.d.ts +84 -0
  22. package/lib/types/client/canvas-sync.js +59 -0
  23. package/lib/types/client/contract.d.ts +138 -3
  24. package/lib/types/client/extract-article.d.ts +38 -1
  25. package/lib/types/client/extract-article.js +501 -28
  26. package/lib/types/client/icons.d.ts +1 -1
  27. package/lib/types/client/icons.js +1 -1
  28. package/lib/types/client/index.js +82 -2
  29. package/lib/types/client/locales.d.ts +279 -11
  30. package/lib/types/client/locales.js +278 -10
  31. package/lib/types/client/notes.d.ts +142 -0
  32. package/lib/types/client/notes.js +334 -0
  33. package/lib/types/client/parse-rss.js +40 -0
  34. package/lib/types/client/preset-visibility.d.ts +93 -0
  35. package/lib/types/client/preset-visibility.js +115 -0
  36. package/lib/types/client/selectors.d.ts +5 -0
  37. package/lib/types/client/selectors.js +5 -0
  38. package/lib/types/client/session.d.ts +21 -1
  39. package/lib/types/client/session.js +12 -0
  40. package/lib/types/client/store.js +4 -1
  41. package/lib/types/client/translate.d.ts +26 -1
  42. package/lib/types/client/translate.js +29 -12
  43. package/lib/types/client/useLocalTranscript.d.ts +43 -0
  44. package/lib/types/client/useLocalTranscript.js +129 -0
  45. package/lib/types/client/useMedia.d.ts +68 -0
  46. package/lib/types/client/useMedia.js +299 -0
  47. package/lib/types/client/useMediaStates.d.ts +9 -0
  48. package/lib/types/client/useMediaStates.js +43 -0
  49. package/lib/types/client/useNotes.d.ts +110 -0
  50. package/lib/types/client/useNotes.js +423 -0
  51. package/lib/types/client/usePolish.d.ts +47 -0
  52. package/lib/types/client/usePolish.js +162 -0
  53. package/lib/types/image-fetch.d.ts +81 -0
  54. package/lib/types/image-fetch.js +385 -0
  55. package/lib/types/index.d.ts +6 -0
  56. package/lib/types/index.js +3 -0
  57. package/lib/types/local-transcribe.d.ts +171 -0
  58. package/lib/types/local-transcribe.js +473 -0
  59. package/lib/types/media-host.d.ts +91 -0
  60. package/lib/types/media-host.js +230 -0
  61. package/lib/types/media.d.ts +100 -0
  62. package/lib/types/media.js +207 -0
  63. package/lib/types/notes-types.d.ts +158 -0
  64. package/lib/types/notes-types.js +8 -0
  65. package/lib/types/notes.d.ts +213 -0
  66. package/lib/types/notes.js +718 -0
  67. package/lib/types/polish.d.ts +41 -0
  68. package/lib/types/polish.js +136 -0
  69. package/lib/types/remote.d.ts +127 -1
  70. package/lib/types/remote.js +162 -1
  71. package/lib/types/service.d.ts +100 -1
  72. package/lib/types/service.js +320 -59
  73. package/lib/types/skill.d.ts +20 -0
  74. package/lib/types/skill.js +78 -0
  75. package/lib/types/store.js +30 -8
  76. package/lib/types/tool.d.ts +23 -0
  77. package/lib/types/tool.js +40 -0
  78. package/lib/types/tools.d.ts +69 -0
  79. package/lib/types/tools.js +400 -0
  80. package/lib/types/types.d.ts +106 -0
  81. package/lib/types/types.js +66 -0
  82. package/lib/types/xiaoyuzhou.d.ts +43 -0
  83. package/lib/types/xiaoyuzhou.js +134 -0
  84. package/lib/types/ytdlp.d.ts +111 -0
  85. package/lib/types/ytdlp.js +269 -0
  86. package/package.json +29 -20
  87. package/lib/invariant.js +0 -38
  88. package/lib/types/invariant.d.ts +0 -29
  89. package/lib/types/invariant.js +0 -38
@@ -0,0 +1,334 @@
1
+ import { formatClock } from "../captions.js";
2
+ /** The highlight registry names (the pane's stylesheet paints them). */
3
+ export const NOTE_HIGHLIGHT = 'reader-note';
4
+ export const NOTE_FLASH_HIGHLIGHT = 'reader-note-flash';
5
+ /** Collapse whitespace the way stored excerpts are collapsed. */
6
+ export function normalizeText(value) {
7
+ return value.replace(/\s+/g, ' ').trim();
8
+ }
9
+ /** True for a node `translate.ts` inserted (a reveal of original sentences). */
10
+ function isReveal(element) {
11
+ return element.getAttribute('data-reader-reveal') === '1';
12
+ }
13
+ /** The article's blocks: top-level children, reveals excluded. */
14
+ export function blockElements(article) {
15
+ return [...article.children].filter(child => !isReveal(child));
16
+ }
17
+ /**
18
+ * The block a node sits in, as an index into {@link blockElements}.
19
+ *
20
+ * A node inside a sibling-placed reveal belongs to the block the reveal
21
+ * follows; one inside an inside-placed reveal is already inside its block.
22
+ */
23
+ export function blockIndexOf(article, node) {
24
+ let current = node;
25
+ while (current !== null && current.parentNode !== article)
26
+ current = current.parentNode;
27
+ if (current === null || !(current instanceof Element))
28
+ return 0;
29
+ let block = current;
30
+ while (block !== null && isReveal(block))
31
+ block = block.previousElementSibling;
32
+ if (block === null)
33
+ return 0;
34
+ const index = blockElements(article).indexOf(block);
35
+ return index < 0 ? 0 : index;
36
+ }
37
+ /** A transcript paragraph's start (`data-t`), when the block is one. */
38
+ function timeOf(element) {
39
+ const value = element?.getAttribute('data-t');
40
+ if (value === null || value === undefined)
41
+ return undefined;
42
+ const seconds = Number(value);
43
+ return Number.isFinite(seconds) ? seconds : undefined;
44
+ }
45
+ /** The segments a range touches whose span shows a TRANSLATION right now. */
46
+ function translatedSegmentsIn(built, range) {
47
+ if (built.view === 'orig')
48
+ return [];
49
+ const touched = [];
50
+ for (const block of built.blocks) {
51
+ for (const segment of block.segments) {
52
+ if (segment.translated === null)
53
+ continue;
54
+ if (segment.span.closest('[data-reader-reveal="1"]') !== null)
55
+ continue;
56
+ if (range.intersectsNode(segment.span))
57
+ touched.push(segment);
58
+ }
59
+ }
60
+ return touched;
61
+ }
62
+ /**
63
+ * Read the current selection as an excerpt of this article.
64
+ *
65
+ * Selected in the translated view, the excerpt is the ORIGINAL of every
66
+ * sentence the selection touched, and the selected translation rides along —
67
+ * the stored quote is always original text (decision D8). Selected in the
68
+ * original (or in a reveal's original lines), the selection is the quote.
69
+ *
70
+ * @param article - the rendered article root.
71
+ * @param built - the translation segmentation, when there is one.
72
+ * @param selection - the selection to read (defaults to the window's).
73
+ * @returns the excerpt, or `null` when nothing in this article is selected.
74
+ */
75
+ export function captureSelection(article, built, selection = typeof window === 'undefined' ? null : window.getSelection()) {
76
+ if (selection === null || selection.rangeCount === 0 || selection.isCollapsed)
77
+ return null;
78
+ const range = selection.getRangeAt(0);
79
+ if (!article.contains(range.commonAncestorContainer))
80
+ return null;
81
+ const shown = normalizeText(selection.toString());
82
+ if (shown.length === 0)
83
+ return null;
84
+ const block = blockIndexOf(article, range.startContainer);
85
+ const element = blockElements(article)[block];
86
+ const time = timeOf(element);
87
+ const timed = time === undefined ? {} : { time };
88
+ if (built !== null) {
89
+ const segments = translatedSegmentsIn(built, range);
90
+ if (segments.length > 0) {
91
+ return { block, quote: normalizeText(segments.map(segment => segment.original).join(' ')), translated: shown, ...timed };
92
+ }
93
+ }
94
+ let start;
95
+ if (element !== undefined && element.contains(range.startContainer)) {
96
+ const before = element.ownerDocument.createRange();
97
+ before.setStart(element, 0);
98
+ before.setEnd(range.startContainer, range.startOffset);
99
+ start = normalizeText(before.toString()).length;
100
+ }
101
+ return { block, ...(start === undefined ? {} : { start }), quote: shown, ...timed };
102
+ }
103
+ /**
104
+ * The article's original prose, one string per block, for the host snapshot.
105
+ *
106
+ * Parsed from the stored markup rather than read off the screen: the screen may
107
+ * be showing a translation, and the model must read what the author wrote.
108
+ *
109
+ * @param html - the article markup the pane renders.
110
+ * @returns the blocks' text (a bare-text body is one block).
111
+ */
112
+ export function paragraphsOf(html, speakerLabel) {
113
+ const doc = new DOMParser().parseFromString(`<div>${html}</div>`, 'text/html');
114
+ const root = doc.body.firstElementChild;
115
+ if (root === null)
116
+ return [];
117
+ const children = [...root.children];
118
+ if (children.length === 0)
119
+ return [normalizeText(root.textContent ?? '')];
120
+ // A transcript paragraph says when it is, and who spoke it: the model reads
121
+ // ¶3 [24:05] 嘉宾:… — so a discussion never puts a view in the wrong mouth.
122
+ return children.map(child => {
123
+ const time = timeOf(child);
124
+ const spk = child.getAttribute('data-spk');
125
+ const text = normalizeText(child.textContent ?? '');
126
+ const who = spk === null || speakerLabel === undefined ? '' : `${speakerLabel(Number(spk))}:`;
127
+ return time === undefined || text === '' ? text : `[${formatClock(time)}] ${who}${text}`;
128
+ });
129
+ }
130
+ /**
131
+ * Find a normalized string inside an element's text, as a DOM Range.
132
+ *
133
+ * The text is flattened with whitespace collapsed (the stored quote's form),
134
+ * keeping a map from each flattened character back to its node and offset.
135
+ *
136
+ * @param root - where to search.
137
+ * @param needle - the normalized text to find.
138
+ * @param skip - elements whose text does not count.
139
+ * @returns the range, or `null`.
140
+ */
141
+ export function findText(root, needle, skip = () => false) {
142
+ if (needle.length === 0)
143
+ return null;
144
+ const doc = root.ownerDocument;
145
+ const walker = doc.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
146
+ let flat = '';
147
+ const points = [];
148
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
149
+ const parent = node.parentElement;
150
+ if (parent !== null && parent !== root && skipped(parent, root, skip))
151
+ continue;
152
+ const value = node.nodeValue ?? '';
153
+ for (let offset = 0; offset < value.length; offset++) {
154
+ const ch = value[offset];
155
+ if (/\s/.test(ch)) {
156
+ if (flat.length === 0 || flat.endsWith(' '))
157
+ continue;
158
+ flat += ' ';
159
+ }
160
+ else {
161
+ flat += ch;
162
+ }
163
+ points.push({ node: node, offset });
164
+ }
165
+ }
166
+ const at = flat.indexOf(needle);
167
+ if (at < 0)
168
+ return null;
169
+ const first = points[at];
170
+ const last = points[at + needle.length - 1];
171
+ if (first === undefined || last === undefined)
172
+ return null;
173
+ const range = doc.createRange();
174
+ range.setStart(first.node, first.offset);
175
+ range.setEnd(last.node, last.offset + 1);
176
+ return range;
177
+ }
178
+ /** True when an element (or an ancestor below root) is skipped. */
179
+ function skipped(element, root, skip) {
180
+ for (let current = element; current !== null && current !== root; current = current.parentElement) {
181
+ if (skip(current))
182
+ return true;
183
+ }
184
+ return false;
185
+ }
186
+ /** True when two texts share content: one holds the other, or one runs into the other. */
187
+ export function overlaps(left, right, edge = 6) {
188
+ const a = normalizeText(left);
189
+ const b = normalizeText(right);
190
+ if (a.length === 0 || b.length === 0)
191
+ return false;
192
+ if (a.includes(b) || b.includes(a))
193
+ return true;
194
+ const max = Math.min(a.length, b.length);
195
+ for (let size = max; size >= edge; size--) {
196
+ if (a.endsWith(b.slice(0, size)) || b.endsWith(a.slice(0, size)))
197
+ return true;
198
+ }
199
+ return false;
200
+ }
201
+ /** The built block that renders one top-level block element. */
202
+ function builtBlockFor(built, element) {
203
+ return built.blocks.filter(block => block.element === element || element.contains(block.element));
204
+ }
205
+ /** Ranges for one note inside one block element, in the view on screen. */
206
+ function rangesInBlock(element, built, anchor) {
207
+ const translated = built !== null && built.view !== 'orig'
208
+ && built.blocks.some(block => block.segments.some(segment => segment.translated !== null));
209
+ if (!translated || built === null) {
210
+ const range = findText(element, anchor.quote, isReveal);
211
+ return range === null ? [] : [range];
212
+ }
213
+ const ranges = [];
214
+ for (const block of builtBlockFor(built, element)) {
215
+ // The translated sentences: highlighted whole (the sentence is the unit
216
+ // the translation is aligned on).
217
+ for (const segment of block.segments) {
218
+ if (segment.translated === null || !overlaps(segment.original, anchor.quote))
219
+ continue;
220
+ const range = element.ownerDocument.createRange();
221
+ range.selectNodeContents(segment.span);
222
+ ranges.push(range);
223
+ }
224
+ // The originals shown beside them (side-by-side view, or an opened sentence).
225
+ if (block.reveal !== null) {
226
+ const range = findText(block.reveal, anchor.quote);
227
+ if (range !== null)
228
+ ranges.push(range);
229
+ }
230
+ }
231
+ return ranges;
232
+ }
233
+ /**
234
+ * Where each note is on screen right now.
235
+ *
236
+ * The anchored block is tried first; when the excerpt is not there (the
237
+ * article was refetched and moved), every block is searched. A note found
238
+ * nowhere maps to an empty list — the notes view says so.
239
+ *
240
+ * @param article - the rendered article root.
241
+ * @param built - the translation segmentation, when there is one.
242
+ * @param notes - the article's notes.
243
+ */
244
+ export function noteRanges(article, built, notes) {
245
+ const blocks = blockElements(article);
246
+ const found = new Map();
247
+ for (const note of notes) {
248
+ const hinted = blocks[note.anchor.block];
249
+ let ranges = hinted === undefined ? [] : rangesInBlock(hinted, built, note.anchor);
250
+ if (ranges.length === 0) {
251
+ for (const block of blocks) {
252
+ if (block === hinted)
253
+ continue;
254
+ ranges = rangesInBlock(block, built, note.anchor);
255
+ if (ranges.length > 0)
256
+ break;
257
+ }
258
+ }
259
+ found.set(note.id, ranges);
260
+ }
261
+ return found;
262
+ }
263
+ /** The highlight registry, when the browser has one. */
264
+ function registry() {
265
+ const highlights = globalThis.CSS?.highlights;
266
+ const Highlight = globalThis.Highlight;
267
+ if (highlights === undefined || typeof Highlight !== 'function')
268
+ return undefined;
269
+ return highlights;
270
+ }
271
+ /**
272
+ * Paint (or clear) one named highlight.
273
+ *
274
+ * @param name - the registry name.
275
+ * @param ranges - the ranges to paint; empty clears it.
276
+ */
277
+ export function paintHighlight(name, ranges) {
278
+ const highlights = registry();
279
+ if (highlights === undefined)
280
+ return;
281
+ if (ranges.length === 0) {
282
+ highlights.delete(name);
283
+ return;
284
+ }
285
+ const Highlight = globalThis.Highlight;
286
+ highlights.set(name, new Highlight(...ranges));
287
+ }
288
+ /**
289
+ * The discussion draft: the article, then each note as a quote with its comment.
290
+ *
291
+ * Notes carry their ¶ and id so the model can match them to `reader_read`'s
292
+ * output; the closing hint names the article id for the same reason.
293
+ *
294
+ * @param source - the article.
295
+ * @param notes - the notes to bring, in article order.
296
+ * @param words - the localized fragments.
297
+ */
298
+ export function formatNotesDraft(source, notes, words) {
299
+ const lines = [words.heading(notes.length), ''];
300
+ for (const note of notes) {
301
+ lines.push(`> ${note.id} ¶${String(note.anchor.block + 1)}${note.anchor.time === undefined ? '' : ` [${formatClock(note.anchor.time)}]`}「${note.anchor.quote}」`);
302
+ if (note.anchor.translated !== undefined)
303
+ lines.push(`> (${note.anchor.translated})`);
304
+ if (note.comment !== undefined) {
305
+ for (const [index, line] of note.comment.split('\n').entries()) {
306
+ lines.push(index === 0 ? `> ${words.comment}${line}` : `> ${line}`);
307
+ }
308
+ }
309
+ lines.push('');
310
+ }
311
+ const attribution = [`— 《${source.title}》`, source.source].filter(part => part.length > 0).join(' · ');
312
+ lines.push(attribution);
313
+ if (source.link !== undefined)
314
+ lines.push(source.link);
315
+ lines.push(words.hint(source.entryId));
316
+ return lines.join('\n');
317
+ }
318
+ const TARGETS = new Set();
319
+ /** Register a pane as a note target; returns the disposer. */
320
+ export function registerNoteTarget(target) {
321
+ TARGETS.add(target);
322
+ return () => { TARGETS.delete(target); };
323
+ }
324
+ /** The pane whose article holds the current selection, when one does. */
325
+ export function noteTargetForSelection(selection = typeof window === 'undefined' ? null : window.getSelection()) {
326
+ if (selection === null || selection.rangeCount === 0 || selection.isCollapsed)
327
+ return undefined;
328
+ const node = selection.getRangeAt(0).commonAncestorContainer;
329
+ for (const target of TARGETS) {
330
+ if (target.owns(node))
331
+ return target;
332
+ }
333
+ return undefined;
334
+ }
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { stableEntryId, summarize, } from "../types.js";
17
17
  import { normalizeRichText } from "./extract-article.js";
18
+ import { entryMedia } from "../media.js";
18
19
  /** Element local-names that may carry an entry's full body, best first. */
19
20
  const CONTENT_TAGS = ['encoded', 'content'];
20
21
  /** Element local-names that may carry a short description, best first. */
@@ -146,6 +147,7 @@ function readEntry(element, sourceId) {
146
147
  });
147
148
  const tags = readTags(element);
148
149
  const author = readAuthor(element);
150
+ const media = entryMedia({ ...(link === undefined ? {} : { link }), ...present('enclosure', readEnclosure(element)) });
149
151
  return {
150
152
  id,
151
153
  sourceId,
@@ -157,8 +159,46 @@ function readEntry(element, sourceId) {
157
159
  ...present('summary', summarize(summary, 320)),
158
160
  ...present('contentHtml', body),
159
161
  ...(summaryOnly ? { summaryOnly: true } : {}),
162
+ ...(media === undefined ? {} : { media }),
160
163
  };
161
164
  }
165
+ /**
166
+ * A podcast / media enclosure: `<enclosure url type>`, with the iTunes
167
+ * duration and artwork, or YouTube's `<media:group>` thumbnail.
168
+ *
169
+ * @param element - the `<item>` / `<entry>`.
170
+ */
171
+ function readEnclosure(element) {
172
+ // RSS `<enclosure url type>`, or Atom's `<link rel="enclosure" href type>`.
173
+ const enclosure = child(element, 'enclosure')
174
+ ?? [...element.children].find(node => node.localName === 'link' && node.getAttribute('rel') === 'enclosure') ?? null;
175
+ const url = enclosure?.getAttribute('url') ?? enclosure?.getAttribute('href') ?? undefined;
176
+ const image = [...element.getElementsByTagName('*')]
177
+ .map(node => node.localName === 'image' ? node.getAttribute('href') : node.localName === 'thumbnail' ? node.getAttribute('url') : null)
178
+ .find((value) => typeof value === 'string' && value !== '');
179
+ if (url === undefined || url === '') {
180
+ // No enclosure (a YouTube channel feed): the thumbnail alone still dresses
181
+ // the card when the link is a recognized video.
182
+ return image === undefined ? undefined : { url: '', image };
183
+ }
184
+ const type = enclosure?.getAttribute('type') ?? undefined;
185
+ return {
186
+ url,
187
+ ...(type === undefined || type === '' ? {} : { type }),
188
+ ...present('durationSec', parseDuration(elementText(child(element, 'duration')))),
189
+ ...(image === undefined ? {} : { image }),
190
+ };
191
+ }
192
+ /** `3725`, `62:05` or `1:02:05` → seconds. */
193
+ function parseDuration(value) {
194
+ if (value === undefined)
195
+ return undefined;
196
+ const parts = value.trim().split(':').map(Number);
197
+ if (parts.length === 0 || parts.some(part => !Number.isFinite(part)))
198
+ return undefined;
199
+ const seconds = parts.reduce((total, part) => total * 60 + part, 0);
200
+ return seconds > 0 ? Math.round(seconds) : undefined;
201
+ }
162
202
  /**
163
203
  * The best available body for the detail view.
164
204
  *
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The preset-composition visibility of the reader (灵感空间) right-Sidebar tab
3
+ * type — a copy of the canvas's (docs/plugin-visibility.md: keep per-package
4
+ * inline copies). The tab is the entry to a cross-session wall, but since the
5
+ * reader carries session-granted tools (`reader_read` / `reader_note`) its
6
+ * entry semantics are "can THIS session reach the reader tools" — the canvas
7
+ * dual check, over the `./tool` row:
8
+ *
9
+ * 1. **Profile-root mount** (the package's own patch, a standalone install):
10
+ * an ENABLED `@khorsheed/dsh-reader/tool` row in `entries` → every session.
11
+ * 2. **Preset mount** (the learning / writing mode packs, 2026-10-19): shown
12
+ * exactly when the CURRENT session's preset composition names that row.
13
+ *
14
+ * Every unreadable path fails OPEN (visible): no pluginInventory namespace, a
15
+ * pending/failed RPC, a missing or `broken` preset group, a session with no
16
+ * preset, and the no-session home state. Hidden means NOT registered.
17
+ * @module @khorsheed/dsh-reader/client/preset-visibility
18
+ */
19
+ import type { Context } from '@deepseek-ai/cordis';
20
+ import type { SessionId } from '@deepseek-ai/dsh-session/types';
21
+ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol';
22
+ /** The criterion's row: the reader tools composition entry. */
23
+ export declare const READER_TOOL_ROW_MODULE = "@khorsheed/dsh-reader/tool";
24
+ /**
25
+ * Minimal structural mirror of the official pluginInventory snapshot — the
26
+ * entries slice (root grants) plus the agentPresets slice (preset grants),
27
+ * duck-typed and host-version tolerant.
28
+ */
29
+ export interface ReaderPluginInventorySnapshot {
30
+ readonly entries?: readonly {
31
+ readonly moduleName: string;
32
+ readonly enabled?: boolean;
33
+ }[];
34
+ readonly agentPresets?: readonly {
35
+ readonly id: string;
36
+ readonly broken?: string;
37
+ readonly rows: readonly {
38
+ readonly moduleName: string;
39
+ readonly enabled?: boolean;
40
+ }[];
41
+ }[];
42
+ }
43
+ /**
44
+ * The tab's visibility controller: one inventory fetch per page (composition
45
+ * data changes only with preset files, which a reload re-reads anyway),
46
+ * re-evaluated by the toggle on every session switch.
47
+ */
48
+ export declare class ReaderTabVisibility {
49
+ private readonly ctx;
50
+ /** The inventory answer; null while pending/failed and on namespace-less hosts. */
51
+ private composition;
52
+ private readonly listeners;
53
+ /**
54
+ * @param ctx - client root context.
55
+ * @param pluginInventory - the OFFICIAL namespace, probed through ctx.get
56
+ * (never injected — declaring it would pend the whole client on a host
57
+ * without it, and the criterion must fail open there).
58
+ */
59
+ constructor(ctx: Context, pluginInventory: {
60
+ list: () => Promise<RemoteResult<ReaderPluginInventorySnapshot>>;
61
+ } | undefined);
62
+ show(sessionId: SessionId | undefined): boolean;
63
+ /**
64
+ * Whether the session reaches any of `modules` by either grant path. Every
65
+ * unreadable path fails open, as above.
66
+ */
67
+ grantsAny(sessionId: SessionId | undefined, modules: readonly string[]): boolean;
68
+ /** Subscribe to criterion-input changes (session switches, the inventory answer). */
69
+ subscribe(listener: () => void): () => void;
70
+ private notify;
71
+ }
72
+ /**
73
+ * The tab type's registration toggle (the room template): registers exactly
74
+ * while the criterion passes for the current session and disposes otherwise.
75
+ * Unlike the keyed-slot consumers, the sidebarRightTabs registry is a direct
76
+ * inject — no slot-declaration wait, so the toggle is ready immediately.
77
+ */
78
+ export declare class RegistrationToggle {
79
+ private readonly register;
80
+ private readonly show;
81
+ private dispose;
82
+ private ready;
83
+ /**
84
+ * @param register - mounts the entry and returns its disposer.
85
+ * @param show - the current decision (re-read on every sync).
86
+ */
87
+ constructor(register: () => () => void, show: () => boolean);
88
+ /** The registry is reachable — decisions may register now. */
89
+ setReady(ready: boolean): void;
90
+ /** Re-evaluate: register while shown and ready, dispose otherwise. */
91
+ sync(): void;
92
+ }
93
+ //# sourceMappingURL=preset-visibility.d.ts.map
@@ -0,0 +1,115 @@
1
+ /** The criterion's row: the reader tools composition entry. */
2
+ export const READER_TOOL_ROW_MODULE = '@khorsheed/dsh-reader/tool';
3
+ /** The current session's agent preset, the dual read per host line. */
4
+ function presetOf(row) {
5
+ const value = row
6
+ ?.projectionValues?.agentPreset
7
+ ?? row?.agentPreset;
8
+ return typeof value === 'string' ? value : undefined;
9
+ }
10
+ /**
11
+ * The tab's visibility controller: one inventory fetch per page (composition
12
+ * data changes only with preset files, which a reload re-reads anyway),
13
+ * re-evaluated by the toggle on every session switch.
14
+ */
15
+ export class ReaderTabVisibility {
16
+ ctx;
17
+ /** The inventory answer; null while pending/failed and on namespace-less hosts. */
18
+ composition = null;
19
+ listeners = new Set();
20
+ /**
21
+ * @param ctx - client root context.
22
+ * @param pluginInventory - the OFFICIAL namespace, probed through ctx.get
23
+ * (never injected — declaring it would pend the whole client on a host
24
+ * without it, and the criterion must fail open there).
25
+ */
26
+ constructor(ctx, pluginInventory) {
27
+ this.ctx = ctx;
28
+ if (pluginInventory !== undefined) {
29
+ void pluginInventory.list().then(result => {
30
+ if (!result.ok)
31
+ return;
32
+ this.composition = result.value ?? null;
33
+ this.notify();
34
+ }).catch(() => { });
35
+ }
36
+ // Session switches re-decide the tab type's registration (the guide
37
+ // enumerates registrations, not components).
38
+ ctx.effect(() => ctx.sessions.list.subscribe(() => { this.notify(); }), 'reader: tab visibility session follower');
39
+ }
40
+ show(sessionId) {
41
+ return this.grantsAny(sessionId, [READER_TOOL_ROW_MODULE]);
42
+ }
43
+ /**
44
+ * Whether the session reaches any of `modules` by either grant path. Every
45
+ * unreadable path fails open, as above.
46
+ */
47
+ grantsAny(sessionId, modules) {
48
+ if (this.composition === null)
49
+ return true;
50
+ // Path 1: a root-mounted tool row grants the tools to every session —
51
+ // a deployment-level constant, checked before any per-session question.
52
+ if (this.composition.entries?.some(row => modules.includes(row.moduleName) && row.enabled === true) === true)
53
+ return true;
54
+ if (sessionId === undefined)
55
+ return true;
56
+ const preset = presetOf(this.ctx.sessions.list.getSnapshot().byId[sessionId]);
57
+ if (preset === undefined)
58
+ return true;
59
+ const group = this.composition.agentPresets?.find(candidate => candidate.id === preset);
60
+ if (group === undefined || group.broken !== undefined)
61
+ return true;
62
+ // Path 2: the preset-mounted row grants this session.
63
+ return group.rows.some(row => modules.includes(row.moduleName) && row.enabled !== false);
64
+ }
65
+ /** Subscribe to criterion-input changes (session switches, the inventory answer). */
66
+ subscribe(listener) {
67
+ this.listeners.add(listener);
68
+ return () => { this.listeners.delete(listener); };
69
+ }
70
+ notify() {
71
+ for (const listener of this.listeners)
72
+ listener();
73
+ }
74
+ }
75
+ /**
76
+ * The tab type's registration toggle (the room template): registers exactly
77
+ * while the criterion passes for the current session and disposes otherwise.
78
+ * Unlike the keyed-slot consumers, the sidebarRightTabs registry is a direct
79
+ * inject — no slot-declaration wait, so the toggle is ready immediately.
80
+ */
81
+ export class RegistrationToggle {
82
+ register;
83
+ show;
84
+ dispose;
85
+ ready = false;
86
+ /**
87
+ * @param register - mounts the entry and returns its disposer.
88
+ * @param show - the current decision (re-read on every sync).
89
+ */
90
+ constructor(register, show) {
91
+ this.register = register;
92
+ this.show = show;
93
+ }
94
+ /** The registry is reachable — decisions may register now. */
95
+ setReady(ready) {
96
+ this.ready = ready;
97
+ if (!ready && this.dispose !== undefined) {
98
+ this.dispose();
99
+ this.dispose = undefined;
100
+ }
101
+ this.sync();
102
+ }
103
+ /** Re-evaluate: register while shown and ready, dispose otherwise. */
104
+ sync() {
105
+ if (!this.ready)
106
+ return;
107
+ if (this.show()) {
108
+ this.dispose ??= this.register();
109
+ }
110
+ else if (this.dispose !== undefined) {
111
+ this.dispose();
112
+ this.dispose = undefined;
113
+ }
114
+ }
115
+ }
@@ -11,6 +11,11 @@
11
11
  import type { ReaderEntry } from './parse-rss.ts';
12
12
  import type { ReaderFilter, ReaderSort } from './store.ts';
13
13
  import { type ReaderEntryFetchState, type ReaderSourceKind } from '../types.ts';
14
+ /** The queries that select video / audio entries (source ids never collide: they are `<kind>-<hash>`). */
15
+ export declare const MEDIA_QUERIES: {
16
+ readonly video: "#video";
17
+ readonly audio: "#audio";
18
+ };
14
19
  /** How many entries the pane will show before it stops rendering cards. */
15
20
  export declare const LIST_RENDER_LIMIT = 300;
16
21
  /** One entry plus the source it came from, which is what a card needs. */
@@ -1,4 +1,6 @@
1
1
  import { kindQuery, READER_SOURCE_KINDS } from "../types.js";
2
+ /** The queries that select video / audio entries (source ids never collide: they are `<kind>-<hash>`). */
3
+ export const MEDIA_QUERIES = { video: '#video', audio: '#audio' };
2
4
  /** How many entries the pane will show before it stops rendering cards. */
3
5
  export const LIST_RENDER_LIMIT = 300;
4
6
  /** Every parsed entry, flattened in source order. */
@@ -156,6 +158,9 @@ function relevance(title, query) {
156
158
  }
157
159
  /** Whether an entry matches the query, across the fields a reader searches by. */
158
160
  function matches(entry, source, query, tags) {
161
+ // The media kinds (an entry's own kind, decided per item — media.ts).
162
+ if (query === MEDIA_QUERIES.video || query === MEDIA_QUERIES.audio)
163
+ return entry.media?.kind === query.slice(1);
159
164
  if (query.startsWith('#')) {
160
165
  const selector = query.slice(1);
161
166
  // A kind selector first: source ids are always `<kind>-<hash>`, so
@@ -31,7 +31,8 @@
31
31
  *
32
32
  * @module @khorsheed/dsh-reader/client/session
33
33
  */
34
- import type { ReaderFilter, ReaderSort, ReaderView } from './store.ts';
34
+ import type { ReaderSourceSummary } from '../types.ts';
35
+ import type { ReaderFilter, ReaderParsedSource, ReaderSort, ReaderView } from './store.ts';
35
36
  import type { TranslationView, TranslatorSessionLike } from './translate.ts';
36
37
  /** One session's worth of "where the reader was". */
37
38
  export interface ReaderSessionSnapshot {
@@ -115,6 +116,25 @@ export declare function readSession(): Patch;
115
116
  * @param patch - the fields to replace.
116
117
  */
117
118
  export declare function patchSession(patch: Patch): void;
119
+ /**
120
+ * The parsed wall, for as long as the page is: switching to another sidebar
121
+ * tab unmounts the pane, and re-parsing every feed on the way back made the
122
+ * whole wall flash 「加载中」 and rebuild (2026-10-20). A remount shows this at
123
+ * once and re-reads only the sources whose fetch changed (`stamp`). Memory
124
+ * only — feed content never goes to storage.
125
+ */
126
+ export interface WallCache {
127
+ readonly sources: readonly ReaderSourceSummary[];
128
+ readonly parsed: ReadonlyMap<string, {
129
+ readonly stamp: string;
130
+ readonly parsed: ReaderParsedSource;
131
+ readonly label?: string;
132
+ }>;
133
+ }
134
+ /** What makes a source's parsed entries stale: its fetch, its body, its kind. */
135
+ export declare function sourceStamp(source: ReaderSourceSummary): string;
136
+ export declare function readWallCache(): WallCache | undefined;
137
+ export declare function writeWallCache(cache: WallCache): void;
118
138
  /** Drop the snapshot, here and in storage (the specs call this between cases). */
119
139
  export declare function forgetSession(): void;
120
140
  /**