@khorsheed/dsh-reader 0.3.0 → 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 (87) hide show
  1. package/CHANGELOG.md +22 -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 +29054 -6698
  7. package/lib/index.js +3416 -334
  8. package/lib/typert.host.js +2145 -259
  9. package/lib/typert.remote-client.d.ts +97 -3
  10. package/lib/typert.remote-client.js +1590 -250
  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 +860 -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 +130 -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 +81 -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/index.d.ts +6 -0
  54. package/lib/types/index.js +3 -0
  55. package/lib/types/local-transcribe.d.ts +171 -0
  56. package/lib/types/local-transcribe.js +473 -0
  57. package/lib/types/media-host.d.ts +91 -0
  58. package/lib/types/media-host.js +230 -0
  59. package/lib/types/media.d.ts +100 -0
  60. package/lib/types/media.js +207 -0
  61. package/lib/types/notes-types.d.ts +158 -0
  62. package/lib/types/notes-types.js +8 -0
  63. package/lib/types/notes.d.ts +213 -0
  64. package/lib/types/notes.js +718 -0
  65. package/lib/types/polish.d.ts +41 -0
  66. package/lib/types/polish.js +136 -0
  67. package/lib/types/remote.d.ts +118 -0
  68. package/lib/types/remote.js +151 -1
  69. package/lib/types/service.d.ts +71 -1
  70. package/lib/types/service.js +280 -59
  71. package/lib/types/skill.d.ts +20 -0
  72. package/lib/types/skill.js +78 -0
  73. package/lib/types/store.js +30 -8
  74. package/lib/types/tool.d.ts +23 -0
  75. package/lib/types/tool.js +40 -0
  76. package/lib/types/tools.d.ts +69 -0
  77. package/lib/types/tools.js +400 -0
  78. package/lib/types/types.d.ts +87 -0
  79. package/lib/types/types.js +66 -0
  80. package/lib/types/xiaoyuzhou.d.ts +43 -0
  81. package/lib/types/xiaoyuzhou.js +134 -0
  82. package/lib/types/ytdlp.d.ts +111 -0
  83. package/lib/types/ytdlp.js +269 -0
  84. package/package.json +29 -20
  85. package/lib/invariant.js +0 -38
  86. package/lib/types/invariant.d.ts +0 -29
  87. package/lib/types/invariant.js +0 -38
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { execFile, spawn } from "node:child_process";
4
5
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
5
6
  //#region lib/types/types.js
6
7
  /**
@@ -86,6 +87,67 @@ const MAX_TOTAL_BODY_CHARS = 12582912;
86
87
  */
87
88
  const INLINE_BODY_MAX_CHARS = 262144;
88
89
  /**
90
+ * Hosts that bounce every first visit through a cookie handshake, not a login.
91
+ *
92
+ * Springer Nature answers a cookie-less request with a 303 to
93
+ * `idp.<brand>/authorize?response_type=cookie`, which mints a one-time code and
94
+ * sends the browser back to the article (measured 2026-10-04 on
95
+ * link.springer.com and www.nature.com). The host fetch does not follow the
96
+ * cross-origin hop and keeps no cookies, so the plain fetch can never finish
97
+ * it; a headless browser does (the rendered fetch read the full EPJ Data
98
+ * Science article). The `idp` name alone would read as a login wall.
99
+ */
100
+ const COOKIE_HANDSHAKE_HOSTS = /* @__PURE__ */ new Set(["idp.springer.com", "idp.nature.com"]);
101
+ /**
102
+ * Whether a fetch failure is a cross-origin hop into a known cookie handshake.
103
+ *
104
+ * @param message - the seam's recorded failure message.
105
+ * @returns true when the failure is the handshake, which a browser completes.
106
+ */
107
+ function isCookieHandshake(message) {
108
+ const origin = /cross-origin redirect to (\S+)/i.exec(message)?.[1];
109
+ if (origin === void 0) return false;
110
+ try {
111
+ return COOKIE_HANDSHAKE_HOSTS.has(new URL(origin).hostname.toLowerCase());
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+ /**
117
+ * The article a challenge page stands in front of, when its URL names it.
118
+ *
119
+ * A bot wall usually redirects to its own page and carries the original in a
120
+ * query parameter (mp.weixin.qq.com → `/mp/wappoc_appmsgcaptcha?…&target_url=`).
121
+ * Builds before 2026-10-04 saved that landing URL as the link itself, which
122
+ * then titled the card and was what every retry — the rendered fetch included
123
+ * — asked for; the state normalizer walks such a link back with this.
124
+ *
125
+ * @param url - a stored link.
126
+ * @returns the http(s) target, or `undefined` when the URL is not a challenge landing.
127
+ */
128
+ function challengeTarget(url) {
129
+ let parsed;
130
+ try {
131
+ parsed = new URL(url);
132
+ } catch {
133
+ return;
134
+ }
135
+ if (!/captcha|challenge/i.test(parsed.pathname)) return void 0;
136
+ for (const key of [
137
+ "target_url",
138
+ "targetUrl",
139
+ "return_url",
140
+ "redirect_url"
141
+ ]) {
142
+ const value = parsed.searchParams.get(key);
143
+ if (value === null) continue;
144
+ try {
145
+ const target = new URL(value);
146
+ if (target.protocol === "https:" || target.protocol === "http:") return target.toString();
147
+ } catch {}
148
+ }
149
+ }
150
+ /**
89
151
  * Build a display label for a source from its URL: the registrable-ish host
90
152
  * plus the last meaningful path segment, which is what distinguishes several
91
153
  * feeds from one publisher.
@@ -274,11 +336,14 @@ function normalizeStateDoc(value) {
274
336
  const record = value;
275
337
  const sources = Array.isArray(record.sources) ? record.sources : [];
276
338
  const refresh = typeof record.refresh === "object" && record.refresh !== null ? record.refresh : {};
339
+ const seenIds = /* @__PURE__ */ new Set();
277
340
  return {
278
341
  version: 1,
279
342
  sources: sources.flatMap((item) => {
280
343
  const source = normalizeSource(item);
281
- return source === void 0 ? [] : [source];
344
+ if (source === void 0 || seenIds.has(source.id)) return [];
345
+ seenIds.add(source.id);
346
+ return [source];
282
347
  }),
283
348
  refresh: {
284
349
  enabled: refresh.enabled !== false,
@@ -426,7 +491,8 @@ function normalizeAnnotations(value) {
426
491
  const tagIds = Array.isArray(record.tagIds) ? [...new Set(record.tagIds.filter((id) => typeof id === "string" && id.length > 0))] : [];
427
492
  const fetch = normalizeFetch(record.fetch);
428
493
  const translation = normalizeTranslation(record.translation);
429
- const failureCode = PREVIEW_FAILURE_CODES.has(String(record.failureCode)) ? record.failureCode : void 0;
494
+ const storedFailureCode = PREVIEW_FAILURE_CODES.has(String(record.failureCode)) ? record.failureCode : void 0;
495
+ const failureCode = storedFailureCode === "login" && typeof record.error === "string" && isCookieHandshake(record.error) ? "blocked" : storedFailureCode;
430
496
  const annotation = {
431
497
  ...body === void 0 ? {} : { body },
432
498
  ...fetch === void 0 ? {} : { fetch },
@@ -488,16 +554,20 @@ function normalizeSource(value) {
488
554
  if (typeof value !== "object" || value === null) return void 0;
489
555
  const record = value;
490
556
  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;
557
+ const storedUrl = typeof record.url === "string" ? record.url : void 0;
558
+ if (id === void 0 || storedUrl === void 0) return void 0;
493
559
  const kind = record.kind === "link" ? "link" : "rss";
560
+ const target = kind === "link" ? challengeTarget(storedUrl) : void 0;
561
+ const url = target ?? storedUrl;
562
+ const storedLabel = typeof record.label === "string" && record.label.length > 0 ? record.label : void 0;
563
+ const label = target !== void 0 && (storedLabel === void 0 || storedLabel === defaultSourceLabel(storedUrl)) ? defaultSourceLabel(target) : storedLabel ?? url;
494
564
  const status = record.status === "ok" || record.status === "fetching" || record.status === "error" ? record.status : void 0;
495
565
  const failure = normalizePreviewFailure(record.failure);
496
566
  return {
497
567
  id,
498
568
  kind,
499
569
  url,
500
- label: typeof record.label === "string" && record.label.length > 0 ? record.label : url,
570
+ label,
501
571
  enabled: record.enabled !== false,
502
572
  addedAt: typeof record.addedAt === "string" ? record.addedAt : (/* @__PURE__ */ new Date(0)).toISOString(),
503
573
  ...typeof record.fetchedAt === "string" ? { fetchedAt: record.fetchedAt } : {},
@@ -525,9 +595,10 @@ function normalizePreviewFailure(value) {
525
595
  const record = value;
526
596
  const code = record.code;
527
597
  if (typeof code !== "string" || !PREVIEW_FAILURE_CODES.has(code)) return void 0;
598
+ const message = typeof record.message === "string" ? record.message.slice(0, 500) : "";
528
599
  return {
529
- code,
530
- message: typeof record.message === "string" ? record.message.slice(0, 500) : "",
600
+ code: code === "login" && isCookieHandshake(message) ? "blocked" : code,
601
+ message,
531
602
  at: typeof record.at === "string" ? record.at : (/* @__PURE__ */ new Date(0)).toISOString()
532
603
  };
533
604
  }
@@ -628,7 +699,7 @@ var ReaderStore = class {
628
699
  renameSync(temporary, target);
629
700
  } catch (error) {
630
701
  rmSync(temporary, { force: true });
631
- throw new ReaderStoreError(`reader: ${target} could not be written — ${errorMessage$1(error)}`, "io");
702
+ throw new ReaderStoreError(`reader: ${target} could not be written — ${errorMessage$2(error)}`, "io");
632
703
  }
633
704
  return name;
634
705
  }
@@ -654,7 +725,7 @@ var ReaderStore = class {
654
725
  renameSync(temporary, target);
655
726
  } catch (error) {
656
727
  rmSync(temporary, { force: true });
657
- throw new ReaderStoreError(`reader: ${target} could not be written — ${errorMessage$1(error)}`, "io");
728
+ throw new ReaderStoreError(`reader: ${target} could not be written — ${errorMessage$2(error)}`, "io");
658
729
  }
659
730
  return name;
660
731
  }
@@ -727,7 +798,7 @@ var ReaderStore = class {
727
798
  mkdirSync(this.stateRoot, { recursive: true });
728
799
  return true;
729
800
  } catch (error) {
730
- this.unwritable = `${this.stateRoot}: ${errorMessage$1(error)}`;
801
+ this.unwritable = `${this.stateRoot}: ${errorMessage$2(error)}`;
731
802
  return false;
732
803
  }
733
804
  }
@@ -750,7 +821,7 @@ var ReaderStore = class {
750
821
  doc: emptyStateDoc(),
751
822
  version: null
752
823
  };
753
- throw new ReaderStoreError(`reader: ${this.file} could not be read — ${errorMessage$1(error)}`, "io");
824
+ throw new ReaderStoreError(`reader: ${this.file} could not be read — ${errorMessage$2(error)}`, "io");
754
825
  }
755
826
  try {
756
827
  return {
@@ -780,7 +851,7 @@ var ReaderStore = class {
780
851
  renameSync(temporary, this.file);
781
852
  } catch (error) {
782
853
  rmSync(temporary, { force: true });
783
- throw new ReaderStoreError(`reader: ${this.file} could not be written — ${errorMessage$1(error)}`, "io");
854
+ throw new ReaderStoreError(`reader: ${this.file} could not be written — ${errorMessage$2(error)}`, "io");
784
855
  }
785
856
  const text = serializeStateDoc(doc);
786
857
  return String(text.length) + ":" + String(statSync(this.file).mtimeMs);
@@ -816,7 +887,7 @@ function isMissing(error) {
816
887
  return error?.code === "ENOENT";
817
888
  }
818
889
  /** A native fs failure's message, with its code, for the wire. */
819
- function errorMessage$1(error) {
890
+ function errorMessage$2(error) {
820
891
  const code = error?.code;
821
892
  const message = error instanceof Error ? error.message : String(error);
822
893
  return code === void 0 ? message : `${code}: ${message}`;
@@ -1837,125 +1908,1651 @@ async function resolveLink(url, fetch) {
1837
1908
  for (const resolver of LINK_RESOLVERS) if (resolver.match(parsed)) return resolver.resolve(parsed, fetch);
1838
1909
  return null;
1839
1910
  }
1840
- /** Cross-origin redirect hops the service will follow before giving up. */
1841
- const MAX_REDIRECT_HOPS = 3;
1842
- /** Upper bound on summaries handed to the browser in one call. */
1843
- const SUMMARY_LIMIT = 500;
1844
- /** How many sentence hashes one memory-slice read answers (the table never crosses). */
1845
- const TRANSLATION_SLICE_LIMIT = 2e3;
1846
- /** How many sentences one write batch may carry (a long article is ~500). */
1847
- const TRANSLATION_WRITE_LIMIT = 2e3;
1848
- /** One sentence's source or translation is never longer than a few paragraphs. */
1849
- const TRANSLATION_TEXT_LIMIT = 4e3;
1850
- /** Thrown when a payload cannot become a source. */
1851
- var UnsupportedContent = class extends Error {};
1911
+ //#endregion
1912
+ //#region lib/types/captions.js
1852
1913
  /**
1853
- * Whether a failed fetch is the HTML endpoint's "this paper has no HTML
1854
- * version" — a bare 404 from the content fetch, and nothing else. A refusal
1855
- * (403/401) or a transport error is a fact about the REQUEST, not about which
1856
- * face of the paper exists, and must not silently reroute to another page.
1914
+ * Captions → the transcript a video or audio entry reads as.
1915
+ *
1916
+ * Every platform's caption file is parsed into the same flat cue list (start,
1917
+ * end, text), merged into paragraphs a person can read (sentence ends, pauses,
1918
+ * a length cap), and rendered as article markup where each paragraph carries
1919
+ * its start time. The markup is the entry's body: notes, highlights,
1920
+ * translation and the model's ¶ numbering all work on it unchanged, and the
1921
+ * player reads `data-t` to seek.
1922
+ *
1923
+ * Pure: no fs, no network — the formats are measured on real files and pinned
1924
+ * by the specs.
1925
+ *
1926
+ * @module @khorsheed/dsh-reader/captions
1857
1927
  */
1858
- function isNoHtmlVersion(error) {
1859
- return error instanceof UnsupportedContent && error.message === "HTTP 404";
1928
+ /** Collapse whitespace and strip the markup some caption files carry. */
1929
+ function clean(text) {
1930
+ return text.replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&#39;/g, "'").replace(/&quot;/g, "\"").replace(/\s+/g, " ").trim();
1931
+ }
1932
+ /** Seconds from `hh:mm:ss.mmm` / `mm:ss.mmm` / `hh:mm:ss,mmm`. */
1933
+ function clock(value) {
1934
+ const parts = value.trim().replace(",", ".").split(":").map(Number);
1935
+ if (parts.some((part) => !Number.isFinite(part))) return NaN;
1936
+ return parts.reduce((total, part) => total * 60 + part, 0);
1860
1937
  }
1861
1938
  /**
1862
- * Classify a fetched payload as a feed or an article page.
1863
- *
1864
- * The decision comes from the CONTENT, not from asking the user: nobody pasting
1865
- * a URL can be expected to know whether it is a feed, and guessing wrong used
1866
- * to mean silently creating an empty subscription.
1939
+ * YouTube's `json3` captions: `events[]` with `tStartMs`, `dDurationMs` and
1940
+ * `segs[].utf8`. Auto captions also carry `aAppend` newline events and
1941
+ * word-level `tOffsetMs`; only whole events become cues.
1942
+ */
1943
+ function parseJson3(raw) {
1944
+ const data = JSON.parse(raw);
1945
+ const cues = [];
1946
+ for (const event of data.events ?? []) {
1947
+ if (event.segs === void 0 || event.aAppend === 1) continue;
1948
+ const text = clean(event.segs.map((seg) => seg.utf8 ?? "").join(""));
1949
+ if (text === "") continue;
1950
+ const startMs = event.tStartMs ?? 0;
1951
+ cues.push({
1952
+ start: startMs / 1e3,
1953
+ end: (startMs + (event.dDurationMs ?? 0)) / 1e3,
1954
+ text
1955
+ });
1956
+ }
1957
+ return cues;
1958
+ }
1959
+ /**
1960
+ * WebVTT. YouTube's auto captions in VTT are ROLLING: each cue repeats the
1961
+ * previous line and adds the next, with inline word timings. The rolling
1962
+ * duplicates are dropped by keeping only the text a cue adds over the one
1963
+ * before it.
1964
+ */
1965
+ function parseVtt(raw) {
1966
+ const blocks = raw.replace(/\r/g, "").split(/\n\n+/);
1967
+ const cues = [];
1968
+ let previous = "";
1969
+ for (const block of blocks) {
1970
+ const lines = block.split("\n");
1971
+ const timing = lines.findIndex((line) => line.includes("-->"));
1972
+ if (timing < 0) continue;
1973
+ const [from, to] = lines[timing].split("-->");
1974
+ const start = clock(from ?? "");
1975
+ const end = clock((to ?? "").trim().split(/\s+/)[0] ?? "");
1976
+ if (!Number.isFinite(start)) continue;
1977
+ const textLines = lines.slice(timing + 1).map((line) => clean(line)).filter((line) => line !== "");
1978
+ let text = textLines.join(" ");
1979
+ if (text === "") continue;
1980
+ if (previous !== "" && text.startsWith(previous)) text = text.slice(previous.length).trim();
1981
+ else if (textLines.length > 1 && textLines[0] === previous) text = textLines.slice(1).join(" ");
1982
+ previous = textLines[textLines.length - 1] ?? previous;
1983
+ if (text === "" || cues.length > 0 && cues[cues.length - 1].text === text) continue;
1984
+ cues.push({
1985
+ start,
1986
+ end: Number.isFinite(end) ? end : start,
1987
+ text
1988
+ });
1989
+ }
1990
+ return cues;
1991
+ }
1992
+ /** SubRip (`.srt`). */
1993
+ function parseSrt(raw) {
1994
+ const cues = [];
1995
+ for (const block of raw.replace(/\r/g, "").split(/\n\n+/)) {
1996
+ const lines = block.split("\n");
1997
+ const timing = lines.findIndex((line) => line.includes("-->"));
1998
+ if (timing < 0) continue;
1999
+ const [from, to] = lines[timing].split("-->");
2000
+ const start = clock(from ?? "");
2001
+ const end = clock(to ?? "");
2002
+ const text = clean(lines.slice(timing + 1).join(" "));
2003
+ if (!Number.isFinite(start) || text === "") continue;
2004
+ cues.push({
2005
+ start,
2006
+ end: Number.isFinite(end) ? end : start,
2007
+ text
2008
+ });
2009
+ }
2010
+ return cues;
2011
+ }
2012
+ /** Bilibili's subtitle JSON: `body[]` with `from`, `to`, `content` (seconds). */
2013
+ function parseBiliJson(raw) {
2014
+ return (JSON.parse(raw).body ?? []).flatMap((item) => {
2015
+ const text = clean(item.content ?? "");
2016
+ if (text === "" || typeof item.from !== "number") return [];
2017
+ return [{
2018
+ start: item.from,
2019
+ end: typeof item.to === "number" ? item.to : item.from,
2020
+ text
2021
+ }];
2022
+ });
2023
+ }
2024
+ /**
2025
+ * Parse a caption file by its extension (the one yt-dlp wrote).
1867
2026
  *
1868
- * @param raw - the decoded payload.
1869
- * @returns which kind of source this payload can become.
2027
+ * @param raw - the file's text.
2028
+ * @param ext - `json3`, `vtt`, `srt` or `json`.
1870
2029
  */
1871
- function classifyPayload(raw) {
1872
- const head = raw.slice(0, 512).trimStart().toLowerCase();
1873
- if (head.startsWith("<?xml")) return "feed";
1874
- if (/^<rss[\s>]/.test(head)) return "feed";
1875
- if (/^<feed[\s>]/.test(head)) return "feed";
1876
- if (/^<rdf:rdf[\s>]/.test(head)) return "feed";
1877
- return "page";
2030
+ function parseCaptions(raw, ext) {
2031
+ switch (ext.toLowerCase()) {
2032
+ case "json3": return parseJson3(raw);
2033
+ case "vtt": return parseVtt(raw);
2034
+ case "srt": return parseSrt(raw);
2035
+ case "json": return parseBiliJson(raw);
2036
+ default: return [];
2037
+ }
1878
2038
  }
1879
- /** The reader service. */
1880
- var ReaderService = class {
1881
- store;
1882
- ctx;
1883
- timer;
1884
- disposed = false;
1885
- /** The document of record when no filesystem is mounted. */
1886
- memoryDoc;
1887
- /** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
1888
- imageCache = new ImageFetchCache();
2039
+ /** True when a line ends a sentence (Latin or CJK punctuation). */
2040
+ function endsSentence(text) {
2041
+ return /[.!?。!?…]["'”’))]?$/.test(text);
2042
+ }
2043
+ /** True for text that is mostly CJK (joined without spaces). */
2044
+ function isCjk(text) {
2045
+ return /[㐀-鿿]/.test(text);
2046
+ }
2047
+ /** The merge rules (exported for the specs). */
2048
+ const MERGE = {
2049
+ /** A paragraph closes at a sentence end once it is at least this long (seconds). */
2050
+ minSeconds: 15,
2051
+ /** …and always closes past this length, sentence end or not. */
2052
+ maxSeconds: 60,
2053
+ /** A pause this long between cues closes the paragraph too. */
2054
+ pauseSeconds: 2.5,
1889
2055
  /**
1890
- * @param ctx - owning context.
1891
- * @param config - optional state-root override.
2056
+ * Unpunctuated captions (YouTube's automatic ones carry no punctuation at
2057
+ * all — measured 2026-10-20): close at the first pause of this length once
2058
+ * the paragraph has run {@link MERGE.bareMinSeconds}, and always by
2059
+ * {@link MERGE.bareMaxSeconds}.
1892
2060
  */
1893
- constructor(ctx, config = {}) {
1894
- this.ctx = ctx;
1895
- this.store = new ReaderStore(config);
1896
- ctx.effect(() => () => {
1897
- this.disposed = true;
1898
- if (this.timer !== void 0) clearTimeout(this.timer);
1899
- }, "reader: daily refresh");
1900
- this.armSchedule();
2061
+ barePauseSeconds: .7,
2062
+ bareMinSeconds: 15,
2063
+ bareMaxSeconds: 35,
2064
+ /** Below this share of sentence-ending cues, captions count as unpunctuated. */
2065
+ punctuatedShare: .1
2066
+ };
2067
+ /**
2068
+ * Merge caption cues into readable paragraphs.
2069
+ *
2070
+ * Punctuated captions close a paragraph at a sentence end once it has run
2071
+ * {@link MERGE.minSeconds}, at a pause of {@link MERGE.pauseSeconds}, or past
2072
+ * {@link MERGE.maxSeconds}. Unpunctuated ones have no sentence ends to use, so
2073
+ * they close at a short pause past {@link MERGE.bareMinSeconds} — short enough
2074
+ * to point at, long enough to read as prose rather than subtitles.
2075
+ *
2076
+ * @param cues - the caption cues in time order.
2077
+ * @returns the paragraphs.
2078
+ */
2079
+ function mergeCues(cues) {
2080
+ const sorted = [...cues].sort((left, right) => left.start - right.start);
2081
+ const punctuated = sorted.length > 0 && sorted.filter((cue) => endsSentence(cue.text)).length / sorted.length >= MERGE.punctuatedShare;
2082
+ const segments = [];
2083
+ let current;
2084
+ const flush = () => {
2085
+ if (current === void 0 || current.lines.length === 0) return;
2086
+ const joiner = isCjk(current.lines.map((line) => line.text).join("")) ? "" : " ";
2087
+ segments.push({
2088
+ start: current.start,
2089
+ end: current.end,
2090
+ text: current.lines.map((line) => line.text).join(joiner).replace(/\s+/g, " ").trim(),
2091
+ lines: current.lines
2092
+ });
2093
+ current = void 0;
2094
+ };
2095
+ for (let index = 0; index < sorted.length; index++) {
2096
+ const cue = sorted[index];
2097
+ if (current !== void 0 && cue.start - current.end >= MERGE.pauseSeconds) flush();
2098
+ if (current === void 0) current = {
2099
+ start: cue.start,
2100
+ end: cue.end,
2101
+ lines: []
2102
+ };
2103
+ current.lines.push({
2104
+ start: cue.start,
2105
+ text: cue.text
2106
+ });
2107
+ current.end = Math.max(current.end, cue.end);
2108
+ const length = current.end - current.start;
2109
+ if (punctuated) {
2110
+ if (length >= MERGE.minSeconds && endsSentence(cue.text) || length >= MERGE.maxSeconds) flush();
2111
+ } else {
2112
+ const next = sorted[index + 1];
2113
+ const gap = next === void 0 ? Infinity : next.start - cue.end;
2114
+ if (length >= MERGE.bareMinSeconds && gap >= MERGE.barePauseSeconds || length >= MERGE.bareMaxSeconds) flush();
2115
+ }
1901
2116
  }
1902
- /** Capabilities the browser may rely on. */
1903
- async capabilities() {
1904
- const doc = await this.currentDoc();
2117
+ flush();
2118
+ return segments;
2119
+ }
2120
+ /** Escape text for HTML content. */
2121
+ function escapeHtml$1(text) {
2122
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2123
+ }
2124
+ /**
2125
+ * The transcript as article markup: one `<p>` per paragraph, its start time in
2126
+ * `data-t` (seconds, one decimal) — the attribute the player seeks by.
2127
+ *
2128
+ * @param segments - the merged paragraphs.
2129
+ */
2130
+ function renderTranscript(segments) {
2131
+ return segments.map((segment) => `<p data-t="${segment.start.toFixed(1)}">${escapeHtml$1(segment.text)}</p>`).join("\n");
2132
+ }
2133
+ //#endregion
2134
+ //#region lib/types/local-transcribe.js
2135
+ /**
2136
+ * Local transcription of a video or audio entry without captions, on the
2137
+ * optional `transcribe` engine (@khorsheed/dsh-transcribe).
2138
+ *
2139
+ * Opening such an entry starts a job (2026-10-20 decision: automatic on open,
2140
+ * never on feed refresh): the audio is fetched into a temporary directory —
2141
+ * yt-dlp for a video, curl for an audio URL — handed to the engine, and the
2142
+ * finished stretches render as transcript paragraphs while it runs. A job
2143
+ * still QUEUED when the reader leaves is withdrawn; a RUNNING one finishes and
2144
+ * is stored either way (`cancel` with `force`, the 字幕 switch and the 取消
2145
+ * button, stops it). The engine's first use downloads its models: the job
2146
+ * waits for one confirmation (`needs-models`) and then runs.
2147
+ *
2148
+ * The engine is restated here structurally (no code edge between the
2149
+ * packages) and probed on every start.
2150
+ *
2151
+ * @module @khorsheed/dsh-reader/local-transcribe
2152
+ */
2153
+ /** Sentence ends in either script. */
2154
+ const SENTENCE_END = /[。!?!?…]$|[^.\d]\.$/;
2155
+ /** A run-on sentence (SenseVoice punctuates English sparsely) closes at a comma past this long (s)… */
2156
+ const LONG_AT_COMMA_SEC = 20;
2157
+ /** …at a pause between words past this long… */
2158
+ const LONG_AT_PAUSE_SEC = 30;
2159
+ /** …and in any case by this. */
2160
+ const LONGEST_CUE_SEC = 45;
2161
+ /**
2162
+ * Engine stretches as caption-like cues: one per sentence (the engine's
2163
+ * stretches are cut at pauses, not sentence ends), so the transcript merges
2164
+ * into the same paragraphs platform captions do. A sentence that runs on
2165
+ * without a period is closed at a comma or a pause once it is long, or a
2166
+ * single cue would make a minute-and-a-half paragraph (measured 2026-10-20).
2167
+ *
2168
+ * @param segments - the engine's segments, in order.
2169
+ */
2170
+ function cuesFromSegments(segments, speakers) {
2171
+ const cues = [];
2172
+ let text = "";
2173
+ let start;
2174
+ let end = 0;
2175
+ let speaker;
2176
+ const flush = () => {
2177
+ const trimmed = text.replace(/\s+/g, " ").trim();
2178
+ if (start !== void 0 && trimmed !== "") cues.push({
2179
+ start,
2180
+ end,
2181
+ text: trimmed,
2182
+ ...speaker === void 0 ? {} : { speaker }
2183
+ });
2184
+ text = "";
2185
+ start = void 0;
2186
+ };
2187
+ const tokens = segments.flatMap((segment) => segment.tokens);
2188
+ for (let index = 0; index < tokens.length; index++) {
2189
+ const token = tokens[index];
2190
+ const voice = speakers?.[index];
2191
+ if (voice !== void 0 && voice !== speaker) {
2192
+ flush();
2193
+ speaker = voice;
2194
+ }
2195
+ start ??= token.start;
2196
+ text += token.text;
2197
+ end = token.end;
2198
+ const length = end - start;
2199
+ const next = tokens[index + 1];
2200
+ if (SENTENCE_END.test(text.trimEnd()) || length >= LONG_AT_COMMA_SEC && /[,,、;;]$/.test(token.text.trim()) || length >= LONG_AT_PAUSE_SEC && next !== void 0 && next.start - token.end >= .4 || length >= LONGEST_CUE_SEC) flush();
2201
+ }
2202
+ flush();
2203
+ return cues;
2204
+ }
2205
+ /** A run of one voice shorter than this is the diarizer flickering, not a turn (s). */
2206
+ const MIN_TURN_SEC = 2.5;
2207
+ /**
2208
+ * Each token's speaker, as an index in order of first appearance (A, B, …):
2209
+ * the turn covering the token's middle, else the nearest one; then runs of a
2210
+ * voice shorter than {@link MIN_TURN_SEC} are folded into the voice before
2211
+ * them — a diarizer's boundary wobble would otherwise split sentences.
2212
+ *
2213
+ * @param segments - the transcript's segments.
2214
+ * @param turns - the engine's turns.
2215
+ */
2216
+ function speakerPerToken(segments, turns) {
2217
+ const sorted = [...turns].sort((a, b) => a.start - b.start);
2218
+ const order = /* @__PURE__ */ new Map();
2219
+ const tokens = segments.flatMap((segment) => segment.tokens);
2220
+ const out = [...tokens.map((token) => {
2221
+ const at = (token.start + token.end) / 2;
2222
+ let best;
2223
+ let distance = Infinity;
2224
+ for (const turn of sorted) {
2225
+ const d = at < turn.start ? turn.start - at : at > turn.end ? at - turn.end : 0;
2226
+ if (d < distance) {
2227
+ distance = d;
2228
+ best = turn;
2229
+ }
2230
+ if (turn.start > at + distance) break;
2231
+ }
2232
+ return best?.cluster ?? -1;
2233
+ })];
2234
+ let runStart = 0;
2235
+ for (let index = 1; index <= out.length; index++) {
2236
+ if (index < out.length && out[index] === out[runStart]) continue;
2237
+ const length = (tokens[index - 1]?.end ?? 0) - (tokens[runStart]?.start ?? 0);
2238
+ if (runStart > 0 && length < MIN_TURN_SEC) for (let k = runStart; k < index; k++) out[k] = out[runStart - 1];
2239
+ runStart = index;
2240
+ }
2241
+ for (let index = 1; index < out.length; index++) if (/^\s*[\p{P}]+\s*$/u.test(tokens[index].text)) out[index] = out[index - 1];
2242
+ return out.map((cluster) => {
2243
+ if (!order.has(cluster)) order.set(cluster, order.size);
2244
+ return order.get(cluster);
2245
+ });
2246
+ }
2247
+ /** Escape text for HTML content. */
2248
+ function escapeText(text) {
2249
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2250
+ }
2251
+ /**
2252
+ * The transcript with its speakers: paragraphs never cross a change of voice,
2253
+ * and each carries `data-spk` (the speaker's index) beside its `data-t`.
2254
+ */
2255
+ function renderSpeakerTranscript(segments, turns) {
2256
+ const cues = cuesFromSegments(segments, speakerPerToken(segments, turns));
2257
+ const out = [];
2258
+ let run = [];
2259
+ const flush = () => {
2260
+ const speaker = run[0]?.speaker;
2261
+ for (const paragraph of mergeCues(run)) out.push(`<p ${LOCAL_MARK} data-spk="${String(speaker ?? 0)}" data-t="${paragraph.start.toFixed(1)}">${escapeText(paragraph.text)}</p>`);
2262
+ run = [];
2263
+ };
2264
+ for (const cue of cues) {
2265
+ if (run.length > 0 && cue.speaker !== run[0].speaker) flush();
2266
+ run.push(cue);
2267
+ }
2268
+ flush();
2269
+ return out.join("\n");
2270
+ }
2271
+ /** The marker a locally transcribed paragraph carries (the 来源 line reads it). */
2272
+ const LOCAL_MARK = "data-src=\"local\"";
2273
+ /** The engine's segments as transcript markup, each paragraph marked local. */
2274
+ function renderLocalTranscript(segments) {
2275
+ return renderTranscript(mergeCues(cuesFromSegments(segments))).replace(/<p data-t=/g, `<p ${LOCAL_MARK} data-t=`);
2276
+ }
2277
+ /**
2278
+ * The language to ask for: always detection (SenseVoice). Measured
2279
+ * 2026-10-20: a platform says nothing reliable about the spoken language — a
2280
+ * Bilibili upload of an English interview came out of the Chinese model as
2281
+ * lower-case English with Chinese punctuation — and on Chinese speech
2282
+ * SenseVoice reads about as well as Paraformer (a 90 s sample: each missed
2283
+ * two or three characters the other got right).
2284
+ */
2285
+ function languageFor(_media) {
2286
+ return "auto";
2287
+ }
2288
+ /** Fetch a URL to a file with curl (it honors the deployment's proxy environment). */
2289
+ function curlTo(url, file, signal) {
2290
+ return new Promise((resolve, reject) => {
2291
+ execFile("curl", [
2292
+ "-fsSL",
2293
+ "--retry",
2294
+ "2",
2295
+ "-o",
2296
+ file,
2297
+ url
2298
+ ], {
2299
+ signal,
2300
+ timeout: 18e5
2301
+ }, (error) => {
2302
+ if (error === null) resolve();
2303
+ else reject(/* @__PURE__ */ new Error(`下载音频失败:${error.message.split("\n")[0] ?? ""}`));
2304
+ });
2305
+ });
2306
+ }
2307
+ /** A model download larger than this asks first; a small addition (the 2 MB VAD) just happens. */
2308
+ const CONFIRM_BYTES = 2e7;
2309
+ /** Share of the progress bar the audio download takes. */
2310
+ const FETCH_SHARE = .1;
2311
+ /** How often a running job writes its finished segments down (ms). */
2312
+ const SAVE_EVERY_MS = 15e3;
2313
+ /** How long a restart waits for the engine to be provided before giving up on resuming (ms). */
2314
+ const RESUME_WAIT_MS = 6e5;
2315
+ function readJson(path) {
2316
+ try {
2317
+ return JSON.parse(readFileSync(path, "utf8"));
2318
+ } catch {
2319
+ return;
2320
+ }
2321
+ }
2322
+ function writeJson(path, value) {
2323
+ try {
2324
+ writeFileSync(`${path}.tmp`, JSON.stringify(value));
2325
+ renameSync(`${path}.tmp`, path);
2326
+ } catch {}
2327
+ }
2328
+ /** The reader's local transcription jobs, by entry. */
2329
+ var LocalTranscriber = class {
2330
+ deps;
2331
+ jobs = /* @__PURE__ */ new Map();
2332
+ /** Jobs a previous process left mid-way, waiting for the engine. */
2333
+ resumable = [];
2334
+ resumeTimer;
2335
+ disposing = false;
2336
+ constructor(deps) {
2337
+ this.deps = deps;
2338
+ try {
2339
+ const cutoff = Date.now() - 864e5;
2340
+ for (const name of readdirSync(deps.tmpRoot)) {
2341
+ if (!name.startsWith("audio-")) continue;
2342
+ const dir = join(deps.tmpRoot, name);
2343
+ if (statSync(dir).mtimeMs < cutoff) {
2344
+ rmSync(dir, {
2345
+ recursive: true,
2346
+ force: true
2347
+ });
2348
+ continue;
2349
+ }
2350
+ const record = readJson(join(dir, "job.json"));
2351
+ if (record?.entryId !== void 0 && record.media !== void 0) this.resumable.push({
2352
+ dir,
2353
+ record
2354
+ });
2355
+ else rmSync(dir, {
2356
+ recursive: true,
2357
+ force: true
2358
+ });
2359
+ }
2360
+ } catch {}
2361
+ if (this.resumable.length > 0) {
2362
+ const since = Date.now();
2363
+ this.resumeTimer = setInterval(() => {
2364
+ const engine = this.deps.engine();
2365
+ if (engine === void 0 && Date.now() - since < RESUME_WAIT_MS) return;
2366
+ clearInterval(this.resumeTimer);
2367
+ this.resumeTimer = void 0;
2368
+ for (const { dir, record } of this.resumable.splice(0)) {
2369
+ if (engine === void 0 || this.jobs.has(record.entryId)) {
2370
+ rmSync(dir, {
2371
+ recursive: true,
2372
+ force: true
2373
+ });
2374
+ continue;
2375
+ }
2376
+ const job = {
2377
+ state: "queued",
2378
+ progress: FETCH_SHARE,
2379
+ segments: [],
2380
+ rendered: 0,
2381
+ abort: new AbortController()
2382
+ };
2383
+ this.jobs.set(record.entryId, job);
2384
+ this.execute(record, engine, "auto", job, dir);
2385
+ }
2386
+ }, this.deps.resumePollMs ?? 2e3);
2387
+ this.resumeTimer.unref?.();
2388
+ }
2389
+ }
2390
+ /** An entry's job as the browser sees it. */
2391
+ status(entryId) {
2392
+ const job = this.jobs.get(entryId);
2393
+ if (job === void 0) return {
2394
+ entryId,
2395
+ state: "idle"
2396
+ };
2397
+ if (job.rendered !== job.segments.length && job.state !== "done") {
2398
+ job.html = renderLocalTranscript(job.segments);
2399
+ job.rendered = job.segments.length;
2400
+ }
1905
2401
  return {
1906
- protocolVersion: 1,
1907
- hasFs: this.store.available,
1908
- ...doc.lastRefreshAt === void 0 ? {} : { lastRefreshAt: doc.lastRefreshAt },
1909
- hasSideChat: this.sideChat() !== void 0,
1910
- ...doc.refresh.enabled ? { nextRefreshAt: new Date(Date.now() + delayUntilNext(/* @__PURE__ */ new Date(), doc.refresh.timeOfDay)).toISOString() } : {}
2402
+ entryId,
2403
+ state: job.state,
2404
+ progress: job.progress,
2405
+ ...job.ahead === void 0 ? {} : { ahead: job.ahead },
2406
+ ...job.html === void 0 ? {} : { html: job.html },
2407
+ ...job.language === void 0 ? {} : { language: job.language },
2408
+ ...job.error === void 0 ? {} : { error: job.error },
2409
+ ...job.speakers === void 0 ? {} : { speakers: job.speakers }
1911
2410
  };
1912
2411
  }
1913
- /** The source list, without payloads. */
1914
- async listSources() {
1915
- return { sources: (await this.currentDoc()).sources.slice(0, SUMMARY_LIMIT).map(summarize$1) };
1916
- }
1917
2412
  /**
1918
- * Add a source, deciding what it is from what comes back.
2413
+ * Start (or rejoin) an entry's transcription.
1919
2414
  *
1920
- * The one rule this method exists to enforce: **a URL the reader pasted is
1921
- * never thrown away.** A fetch that fails, a bot challenge, a login wall and
1922
- * a PDF all end the same way — a `link` source that exists, carries the
1923
- * reason it has no previewable body, and can be opened, tagged, refreshed
1924
- * manually or deleted. Only a URL that cannot be a source at all
1925
- * (`invalid-url`) or one already present (`duplicate`) is refused.
2415
+ * @param request - the entry, its media, and whether the model download was confirmed.
2416
+ */
2417
+ async start(request) {
2418
+ const current = this.jobs.get(request.entryId);
2419
+ if (current !== void 0 && current.state !== "failed" && current.state !== "done") return this.status(request.entryId);
2420
+ const engine = this.deps.engine();
2421
+ if (engine === void 0) return {
2422
+ entryId: request.entryId,
2423
+ state: "unavailable"
2424
+ };
2425
+ const language = languageFor(request.media);
2426
+ const bytes = await engine.missingBytes(engine.modelsFor({
2427
+ language,
2428
+ diarize: false
2429
+ }));
2430
+ if (bytes > CONFIRM_BYTES && request.confirmModels !== true) return {
2431
+ entryId: request.entryId,
2432
+ state: "needs-models",
2433
+ bytes
2434
+ };
2435
+ const job = {
2436
+ state: "fetching",
2437
+ progress: 0,
2438
+ segments: [],
2439
+ rendered: 0,
2440
+ abort: new AbortController()
2441
+ };
2442
+ this.jobs.set(request.entryId, job);
2443
+ const record = {
2444
+ entryId: request.entryId,
2445
+ url: request.url,
2446
+ media: request.media,
2447
+ ...request.title === void 0 ? {} : { title: request.title },
2448
+ phase: "transcribe"
2449
+ };
2450
+ this.execute(record, engine, language, job);
2451
+ return this.status(request.entryId);
2452
+ }
2453
+ /**
2454
+ * Leave or stop an entry's job: a queued (or still fetching) job is
2455
+ * withdrawn either way; a running one only with `force`.
2456
+ */
2457
+ cancel(entryId, force) {
2458
+ const job = this.jobs.get(entryId);
2459
+ if (job === void 0) return {
2460
+ entryId,
2461
+ state: "idle"
2462
+ };
2463
+ if (force || job.state === "queued" || job.state === "fetching") {
2464
+ job.abort.abort();
2465
+ this.jobs.delete(entryId);
2466
+ return {
2467
+ entryId,
2468
+ state: "idle"
2469
+ };
2470
+ }
2471
+ return this.status(entryId);
2472
+ }
2473
+ /** Whether the engine is installed right now. */
2474
+ engineAvailable() {
2475
+ return this.deps.engine() !== void 0;
2476
+ }
2477
+ /** Stop everything (plugin disposal) — keeping each job's record, so the next process resumes it. */
2478
+ dispose() {
2479
+ this.disposing = true;
2480
+ if (this.resumeTimer !== void 0) clearInterval(this.resumeTimer);
2481
+ for (const job of this.jobs.values()) job.abort.abort();
2482
+ this.jobs.clear();
2483
+ }
2484
+ /**
2485
+ * Run (or resume) one job: fetch the audio unless the record has it,
2486
+ * transcribe from where the saved segments end, store, then the speakers.
1926
2487
  *
1927
- * @param request - the pasted URL and an optional label.
1928
- * @returns the decision, or a domain refusal.
2488
+ * @param resumeDir - a previous process's directory for this job.
1929
2489
  */
1930
- async addSource(request) {
1931
- const url = normalizeUrl(request.url);
1932
- if (url === void 0) return "invalid-url";
1933
- const doc = await this.currentDoc();
1934
- if (doc.sources.some((source) => sameTarget(source.url, url))) return "duplicate";
1935
- const now = (/* @__PURE__ */ new Date()).toISOString();
1936
- const resolution = await this.resolvePaper(url);
1937
- if (resolution?.kind === "link-only") return await this.saveLinkOnly({
1938
- url,
1939
- ...request.label === void 0 ? {} : { label: request.label }
1940
- }, {
1941
- code: resolution.code,
1942
- message: resolution.message,
1943
- at: now
1944
- }, now);
1945
- const resolvedId = resolution?.kind === "arxiv" ? resolution.id : void 0;
1946
- const upgrade = resolvedId === void 0 ? arxivHtmlUrl(url) : `https://arxiv.org/html/${resolvedId}`;
1947
- const fallbackUrl = resolvedId === void 0 ? url : `https://arxiv.org/abs/${resolvedId}`;
1948
- const resolvedFrom = resolvedId === void 0 ? void 0 : url;
1949
- if (upgrade !== void 0 && doc.sources.some((source) => sameTarget(source.url, upgrade))) return "duplicate";
1950
- let fetched;
2490
+ async execute(record, engine, language, job, resumeDir) {
2491
+ const signal = job.abort.signal;
2492
+ mkdirSync(this.deps.tmpRoot, { recursive: true });
2493
+ const dir = resumeDir ?? mkdtempSync(join(this.deps.tmpRoot, "audio-"));
2494
+ const recordPath = join(dir, "job.json");
2495
+ const segmentsPath = join(dir, "segments.json");
2496
+ writeJson(recordPath, record);
2497
+ let lastSave = 0;
2498
+ const saveSegments = (force) => {
2499
+ if (!force && Date.now() - lastSave < SAVE_EVERY_MS) return;
2500
+ lastSave = Date.now();
2501
+ writeJson(segmentsPath, job.segments);
2502
+ };
1951
2503
  try {
1952
- fetched = await this.fetchFollowing(upgrade ?? url);
2504
+ let file = record.file !== void 0 && existsSync(record.file) ? record.file : void 0;
2505
+ if (file === void 0) {
2506
+ job.segments = [];
2507
+ if (record.media.kind === "video") file = await this.deps.videoAudio(record.url, dir, {
2508
+ signal,
2509
+ onProgress: (fraction) => {
2510
+ job.progress = FETCH_SHARE * fraction;
2511
+ }
2512
+ });
2513
+ else {
2514
+ file = join(dir, "audio");
2515
+ await curlTo(record.media.ref, file, signal);
2516
+ }
2517
+ record.file = file;
2518
+ record.phase = "transcribe";
2519
+ writeJson(recordPath, record);
2520
+ } else job.segments = readJson(segmentsPath) ?? [];
2521
+ job.progress = FETCH_SHARE;
2522
+ let segments;
2523
+ let detected = record.language;
2524
+ if (record.phase === "transcribe") {
2525
+ job.state = "queued";
2526
+ const earlier = [...job.segments];
2527
+ const fromSec = earlier[earlier.length - 1]?.end ?? 0;
2528
+ const result = await engine.run({
2529
+ input: { files: [file] },
2530
+ ...fromSec > 0 ? { fromSec } : {},
2531
+ language,
2532
+ diarize: false,
2533
+ owner: "@khorsheed/dsh-reader",
2534
+ ...record.title === void 0 ? {} : { label: record.title }
2535
+ }, {
2536
+ signal,
2537
+ onProgress: (progress) => {
2538
+ if (progress.stage === "queued") {
2539
+ job.state = "queued";
2540
+ if (progress.ahead === void 0) delete job.ahead;
2541
+ else job.ahead = progress.ahead;
2542
+ return;
2543
+ }
2544
+ job.state = "transcribing";
2545
+ delete job.ahead;
2546
+ job.progress = FETCH_SHARE + .9 * progress.progress;
2547
+ },
2548
+ onSegments: (more) => {
2549
+ job.segments.push(...more);
2550
+ saveSegments(false);
2551
+ }
2552
+ });
2553
+ segments = [...earlier, ...result.segments];
2554
+ detected = result.language ?? detected;
2555
+ } else segments = job.segments;
2556
+ const html = renderLocalTranscript(segments);
2557
+ if (html === "") throw new Error("没有识别出语音");
2558
+ if (detected !== void 0) job.language = detected;
2559
+ job.segments = segments;
2560
+ saveSegments(true);
2561
+ if (record.phase === "transcribe") {
2562
+ await this.deps.store(record.entryId, record.url, html, detected);
2563
+ record.phase = "speakers";
2564
+ if (detected !== void 0) record.language = detected;
2565
+ writeJson(recordPath, record);
2566
+ }
2567
+ job.html = html;
2568
+ job.rendered = segments.length;
2569
+ job.progress = 1;
2570
+ if (engine.speakers !== void 0) {
2571
+ job.state = "speakers";
2572
+ try {
2573
+ const voices = await engine.speakers({
2574
+ input: { files: [file] },
2575
+ speech: segments.map((segment) => ({
2576
+ start: segment.start,
2577
+ end: segment.end
2578
+ })),
2579
+ owner: "@khorsheed/dsh-reader",
2580
+ ...record.title === void 0 ? {} : { label: record.title }
2581
+ }, { signal });
2582
+ const count = voices.single ? 1 : new Set(voices.turns.map((turn) => turn.cluster)).size;
2583
+ if (count >= 2) {
2584
+ const split = renderSpeakerTranscript(segments, voices.turns);
2585
+ await this.deps.store(record.entryId, record.url, split, detected, count);
2586
+ job.html = split;
2587
+ } else await this.deps.store(record.entryId, record.url, html, detected, 1);
2588
+ job.speakers = count;
2589
+ } catch (error) {
2590
+ if (signal.aborted) return;
2591
+ job.speakersError = error instanceof Error ? error.message : String(error);
2592
+ }
2593
+ }
2594
+ job.state = "done";
1953
2595
  } catch (error) {
1954
- if (upgrade !== void 0 && isNoHtmlVersion(error)) try {
1955
- fetched = await this.fetchFollowing(fallbackUrl);
1956
- } catch (fallbackError) {
1957
- return await this.saveLinkOnly({
1958
- url,
2596
+ if (signal.aborted) return;
2597
+ job.state = "failed";
2598
+ job.error = error instanceof Error ? error.message : String(error);
2599
+ } finally {
2600
+ if (!this.disposing) rmSync(dir, {
2601
+ recursive: true,
2602
+ force: true
2603
+ });
2604
+ }
2605
+ }
2606
+ };
2607
+ //#endregion
2608
+ //#region lib/types/media.js
2609
+ /**
2610
+ * Video and audio entries: what kind of media a link or a feed item is, and
2611
+ * how the browser plays it.
2612
+ *
2613
+ * The kind is decided per ITEM, never per source (the 2026-10-20 decision): a
2614
+ * feed may carry articles, audio and video side by side. The signals, best
2615
+ * first, are a feed enclosure's MIME type, a link the resolvers recognize
2616
+ * (YouTube, Bilibili, Xiaoyuzhou), and — on the host, where pages are fetched
2617
+ * — a page whose main content is a player. A page with real prose stays an
2618
+ * article even when it embeds a video.
2619
+ *
2620
+ * Pure and shared by both halves.
2621
+ *
2622
+ * @module @khorsheed/dsh-reader/media
2623
+ */
2624
+ /**
2625
+ * Recognize a media link from its URL alone.
2626
+ *
2627
+ * @param input - any URL.
2628
+ * @returns the link, or `undefined` for anything else.
2629
+ */
2630
+ function mediaLink(input) {
2631
+ let url;
2632
+ try {
2633
+ url = new URL(input);
2634
+ } catch {
2635
+ return;
2636
+ }
2637
+ const host = url.hostname.replace(/^(www|m)\./, "");
2638
+ if (host === "youtube.com" || host === "music.youtube.com") {
2639
+ const v = url.searchParams.get("v");
2640
+ if (url.pathname === "/watch" && v !== null && /^[\w-]{6,}$/.test(v)) return {
2641
+ provider: "youtube",
2642
+ kind: "video",
2643
+ id: v
2644
+ };
2645
+ const short = /^\/(?:shorts|live|embed)\/([\w-]{6,})/.exec(url.pathname);
2646
+ if (short !== null) return {
2647
+ provider: "youtube",
2648
+ kind: "video",
2649
+ id: short[1]
2650
+ };
2651
+ const channel = /^\/(@[\w.-]+|channel\/UC[\w-]{10,}|c\/[\w.-]+|user\/[\w.-]+)\/?$/.exec(url.pathname.replace(/\/(videos|featured|streams)\/?$/, "/"));
2652
+ if (channel !== null) return {
2653
+ provider: "youtube",
2654
+ kind: "channel",
2655
+ id: channel[1]
2656
+ };
2657
+ return;
2658
+ }
2659
+ if (host === "youtu.be") {
2660
+ const id = url.pathname.slice(1);
2661
+ return /^[\w-]{6,}$/.test(id) ? {
2662
+ provider: "youtube",
2663
+ kind: "video",
2664
+ id
2665
+ } : void 0;
2666
+ }
2667
+ if (host === "bilibili.com" || host === "b23.tv") {
2668
+ const bv = /\/video\/(BV[0-9A-Za-z]{10})/.exec(url.pathname);
2669
+ if (bv !== null) return {
2670
+ provider: "bilibili",
2671
+ kind: "video",
2672
+ id: bv[1]
2673
+ };
2674
+ return;
2675
+ }
2676
+ if (host === "xiaoyuzhoufm.com") {
2677
+ const episode = /^\/episode\/([0-9a-f]{24})/.exec(url.pathname);
2678
+ if (episode !== null) return {
2679
+ provider: "xiaoyuzhou",
2680
+ kind: "audio",
2681
+ id: episode[1]
2682
+ };
2683
+ const podcast = /^\/podcast\/([0-9a-f]{24})/.exec(url.pathname);
2684
+ if (podcast !== null) return {
2685
+ provider: "xiaoyuzhou",
2686
+ kind: "podcast",
2687
+ id: podcast[1]
2688
+ };
2689
+ }
2690
+ }
2691
+ /** Hosts whose links are only redirects to a media page (Bilibili's share links). */
2692
+ const SHORT_LINK_HOSTS = /* @__PURE__ */ new Set(["b23.tv", "bili2233.cn"]);
2693
+ /** True for a share short link that must be resolved before it can be recognized. */
2694
+ function isShortMediaLink(input) {
2695
+ try {
2696
+ return SHORT_LINK_HOSTS.has(new URL(input).hostname.replace(/^www\./, ""));
2697
+ } catch {
2698
+ return false;
2699
+ }
2700
+ }
2701
+ /**
2702
+ * The clean address of a recognized video link: the platform's canonical page,
2703
+ * without the share-tracking parameters a resolved short link drags along.
2704
+ *
2705
+ * @param input - a video URL.
2706
+ */
2707
+ function canonicalMediaUrl(input) {
2708
+ const link = mediaLink(input);
2709
+ if (link?.provider === "bilibili" && link.kind === "video") {
2710
+ const page = new URL(input).searchParams.get("p");
2711
+ return `https://www.bilibili.com/video/${link.id}/${page !== null && page !== "1" ? `?p=${encodeURIComponent(page)}` : ""}`;
2712
+ }
2713
+ if (link?.provider === "youtube" && link.kind === "video") return `https://www.youtube.com/watch?v=${link.id}`;
2714
+ }
2715
+ /** The `<meta name>` a media link's synthetic page carries its description in. */
2716
+ const MEDIA_META_NAME = "dsh-reader:media";
2717
+ //#endregion
2718
+ //#region lib/types/ytdlp.js
2719
+ /**
2720
+ * yt-dlp on the host: video metadata and platform captions, never the video.
2721
+ *
2722
+ * Measured 2026-10-20: a 2023 yt-dlp (and the 2025 one pip can still install
2723
+ * on a Python 3.9 environment) can no longer read YouTube — captions now need
2724
+ * the current extractor and a JavaScript runtime. So the reader keeps its own
2725
+ * copy of the official STANDALONE release (bundled Python) under its state
2726
+ * root, fetched on first use and self-updated (`-U`) when it is two weeks old;
2727
+ * a path configured by the deployment wins over it. Node — the host's own
2728
+ * runtime — is handed to yt-dlp as its JavaScript runtime.
2729
+ *
2730
+ * Metadata (`--dump-single-json`) and captions (`--write-subs`) are requested
2731
+ * with `--skip-download`; the only download is an AUDIO track, for local
2732
+ * transcription of a video without captions, deleted once transcribed.
2733
+ *
2734
+ * @module @khorsheed/dsh-reader/ytdlp
2735
+ */
2736
+ /** The release asset for this platform, or `undefined` where none is published. */
2737
+ function releaseAsset(platform = process.platform, arch = process.arch) {
2738
+ if (platform === "darwin") return "yt-dlp_macos";
2739
+ if (platform === "linux") return arch === "arm64" ? "yt-dlp_linux_aarch64" : "yt-dlp_linux";
2740
+ if (platform === "win32") return "yt-dlp.exe";
2741
+ }
2742
+ /** How old the managed binary may get before a self-update (ms). */
2743
+ const UPDATE_AFTER_MS = 12096e5;
2744
+ /** Tracks that are not speech captions. */
2745
+ const NOT_CAPTIONS = /* @__PURE__ */ new Set([
2746
+ "live_chat",
2747
+ "danmaku",
2748
+ "rechat"
2749
+ ]);
2750
+ /** Caption formats, best first (json3 is YouTube's cleanest; json is Bilibili's). */
2751
+ const FORMATS = [
2752
+ "json3",
2753
+ "vtt",
2754
+ "srt",
2755
+ "json"
2756
+ ];
2757
+ /** The best format a track offers, among the ones the parser reads. */
2758
+ function bestFormat(formats) {
2759
+ const offered = new Set((formats ?? []).map((format) => format.ext));
2760
+ return FORMATS.find((format) => offered.has(format));
2761
+ }
2762
+ /**
2763
+ * Choose the caption track: the uploader's own captions in the video's
2764
+ * language first, then any uploaded track, then the platform's automatic
2765
+ * captions in the ORIGINAL language (YouTube marks it `<lang>-orig`; the other
2766
+ * auto tracks are machine translations, which are also what trips its rate
2767
+ * limit). Manual and automatic tracks are equally "captions" to the reader
2768
+ * (2026-10-20); the choice only prefers the better one.
2769
+ *
2770
+ * @param info - the video's info JSON.
2771
+ */
2772
+ function chooseCaptionTrack(info) {
2773
+ const language = (info.language ?? "").toLowerCase();
2774
+ const manual = Object.entries(info.subtitles ?? {}).filter(([lang]) => !NOT_CAPTIONS.has(lang));
2775
+ const pick = (entries, auto, preferred) => {
2776
+ for (const want of preferred) {
2777
+ const hit = entries.find(([lang]) => lang.toLowerCase() === want) ?? entries.find(([lang]) => want !== "" && lang.toLowerCase().startsWith(want));
2778
+ const ext = hit === void 0 ? void 0 : bestFormat(hit[1]);
2779
+ if (hit !== void 0 && ext !== void 0) return {
2780
+ lang: hit[0],
2781
+ auto,
2782
+ ext
2783
+ };
2784
+ }
2785
+ for (const [lang, formats] of entries) {
2786
+ const ext = bestFormat(formats);
2787
+ if (ext !== void 0) return {
2788
+ lang,
2789
+ auto,
2790
+ ext
2791
+ };
2792
+ }
2793
+ };
2794
+ const fromManual = pick(manual, false, [
2795
+ language,
2796
+ "zh",
2797
+ "en",
2798
+ "ai-zh"
2799
+ ].filter((item) => item !== ""));
2800
+ if (fromManual !== void 0) return fromManual;
2801
+ const auto = Object.entries(info.automatic_captions ?? {}).filter(([lang]) => !NOT_CAPTIONS.has(lang));
2802
+ const fromOriginal = pick(auto.filter(([lang]) => lang.endsWith("-orig")), true, language === "" ? [] : [`${language}-orig`]);
2803
+ if (fromOriginal !== void 0) return fromOriginal;
2804
+ return language === "" ? void 0 : pick(auto.filter(([lang]) => lang.toLowerCase() === language), true, [language]);
2805
+ }
2806
+ /** Run a process, resolving its stdout. */
2807
+ function run(file, args, timeoutMs) {
2808
+ return new Promise((resolve, reject) => {
2809
+ execFile(file, [...args], {
2810
+ timeout: timeoutMs,
2811
+ maxBuffer: 67108864,
2812
+ env: process.env
2813
+ }, (error, stdout, stderr) => {
2814
+ if (error !== null) {
2815
+ const tail = String(stderr).trim().split("\n").filter((line) => /ERROR|error:/i.test(line)).slice(-1)[0];
2816
+ reject(new Error(tail ?? error.message));
2817
+ return;
2818
+ }
2819
+ resolve(String(stdout));
2820
+ });
2821
+ });
2822
+ }
2823
+ /**
2824
+ * Run a process to completion, honoring an abort signal and reporting stdout
2825
+ * lines as they come.
2826
+ */
2827
+ function runStreaming(file, args, options) {
2828
+ return new Promise((resolve, reject) => {
2829
+ const child = spawn(file, [...args], {
2830
+ stdio: [
2831
+ "ignore",
2832
+ "pipe",
2833
+ "pipe"
2834
+ ],
2835
+ env: process.env,
2836
+ ...options.signal === void 0 ? {} : { signal: options.signal }
2837
+ });
2838
+ const timer = setTimeout(() => {
2839
+ child.kill("SIGTERM");
2840
+ }, options.timeoutMs);
2841
+ let buffered = "";
2842
+ let stderr = "";
2843
+ child.stdout.on("data", (chunk) => {
2844
+ buffered += chunk.toString("utf8");
2845
+ const lines = buffered.split(/\r?\n|\r/);
2846
+ buffered = lines.pop() ?? "";
2847
+ for (const line of lines) options.onLine?.(line);
2848
+ });
2849
+ child.stderr.on("data", (chunk) => {
2850
+ stderr = (stderr + chunk.toString("utf8")).slice(-4e3);
2851
+ });
2852
+ child.on("error", (error) => {
2853
+ clearTimeout(timer);
2854
+ reject(error);
2855
+ });
2856
+ child.on("close", (code) => {
2857
+ clearTimeout(timer);
2858
+ if (code === 0) resolve();
2859
+ else reject(new Error(stderr.trim().split("\n").filter((line) => /ERROR|error:/i.test(line)).slice(-1)[0] ?? `${file.split("/").pop() ?? file} exited with ${String(code)}`));
2860
+ });
2861
+ });
2862
+ }
2863
+ /** The yt-dlp manager. */
2864
+ var YtDlp = class {
2865
+ configured;
2866
+ binDir;
2867
+ tmpRoot;
2868
+ ensuring;
2869
+ /**
2870
+ * @param stateRoot - the reader's state root (the managed binary lives under `bin/`).
2871
+ * @param configured - a deployment-configured binary path, which wins.
2872
+ */
2873
+ constructor(stateRoot, configured) {
2874
+ this.configured = configured;
2875
+ this.binDir = join(stateRoot, "bin");
2876
+ this.tmpRoot = join(stateRoot, "tmp");
2877
+ }
2878
+ /** The binary to run: the configured one, else the managed one (fetched / updated as needed). */
2879
+ binary() {
2880
+ if (this.configured !== void 0 && this.configured !== "") return Promise.resolve(this.configured);
2881
+ this.ensuring ??= this.ensureManaged().finally(() => {
2882
+ this.ensuring = void 0;
2883
+ });
2884
+ return this.ensuring;
2885
+ }
2886
+ /** Fetch the standalone release on first use; self-update it when stale. */
2887
+ async ensureManaged() {
2888
+ const asset = releaseAsset();
2889
+ if (asset === void 0) throw new Error(`yt-dlp: no standalone release for ${process.platform}/${process.arch}`);
2890
+ const target = join(this.binDir, asset);
2891
+ if (!existsSync(target)) {
2892
+ mkdirSync(this.binDir, { recursive: true });
2893
+ const partial = `${target}.download`;
2894
+ await run("curl", [
2895
+ "-fsSL",
2896
+ "--retry",
2897
+ "2",
2898
+ "-o",
2899
+ partial,
2900
+ `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
2901
+ ], 3e5);
2902
+ chmodSync(partial, 493);
2903
+ renameSync(partial, target);
2904
+ return target;
2905
+ }
2906
+ if (Date.now() - statSync(target).mtimeMs > UPDATE_AFTER_MS) await run(target, ["-U"], 12e4).catch(() => void 0);
2907
+ return target;
2908
+ }
2909
+ /** The version of the binary in use. */
2910
+ async version() {
2911
+ return (await run(await this.binary(), ["--version"], 3e4)).trim();
2912
+ }
2913
+ /** The common flags: no download, no formats needed, Node as the JS runtime. */
2914
+ base() {
2915
+ return [
2916
+ "--skip-download",
2917
+ "--ignore-no-formats-error",
2918
+ "--no-playlist",
2919
+ "--no-warnings",
2920
+ "--js-runtimes",
2921
+ `node:${process.execPath}`
2922
+ ];
2923
+ }
2924
+ /**
2925
+ * A video's metadata.
2926
+ *
2927
+ * @param url - the video page URL.
2928
+ */
2929
+ async info(url) {
2930
+ const line = (await run(await this.binary(), [
2931
+ ...this.base(),
2932
+ "--dump-single-json",
2933
+ url
2934
+ ], 12e4)).trim().split("\n").filter((item) => item.startsWith("{")).pop();
2935
+ if (line === void 0) throw new Error("yt-dlp: no metadata");
2936
+ return JSON.parse(line);
2937
+ }
2938
+ /**
2939
+ * A channel's newest videos (a flat listing: no video is opened).
2940
+ *
2941
+ * YouTube's own channel feed (`feeds/videos.xml`) answers 404 (measured
2942
+ * 2026-10-20), so the listing is yt-dlp's; `approximate_date` gives each
2943
+ * entry a day-precision date.
2944
+ *
2945
+ * @param url - the channel URL.
2946
+ * @param limit - how many of the newest videos.
2947
+ */
2948
+ async channel(url, limit = 15) {
2949
+ const listing = /\/(videos|streams|shorts)\/?$/.test(url) ? url : `${url.replace(/\/+$/, "")}/videos`;
2950
+ const line = (await run(await this.binary(), [
2951
+ "--flat-playlist",
2952
+ "--dump-single-json",
2953
+ "--playlist-end",
2954
+ String(limit),
2955
+ "--no-warnings",
2956
+ "--extractor-args",
2957
+ "youtubetab:approximate_date",
2958
+ "--js-runtimes",
2959
+ `node:${process.execPath}`,
2960
+ listing
2961
+ ], 18e4)).trim().split("\n").filter((item) => item.startsWith("{")).pop();
2962
+ if (line === void 0) throw new Error("yt-dlp: no channel listing");
2963
+ const data = JSON.parse(line);
2964
+ const title = data.channel ?? data.uploader ?? data.title?.replace(/ - Videos$/, "");
2965
+ return {
2966
+ ...title === void 0 ? {} : { title },
2967
+ entries: (data.entries ?? []).map((entry) => {
2968
+ const { thumbnails, ...rest } = entry;
2969
+ const thumbnail = entry.thumbnail ?? thumbnails?.[thumbnails.length - 1]?.url;
2970
+ return thumbnail === void 0 ? rest : {
2971
+ ...rest,
2972
+ thumbnail
2973
+ };
2974
+ })
2975
+ };
2976
+ }
2977
+ /**
2978
+ * A video's audio track, downloaded for local transcription (the smallest
2979
+ * audio-only format; the platform's combined stream when there is none).
2980
+ *
2981
+ * @param url - the video page URL.
2982
+ * @param dir - an empty directory the file lands in.
2983
+ * @param options - abort, and download progress 0–1.
2984
+ * @returns the downloaded file.
2985
+ */
2986
+ async audio(url, dir, options = {}) {
2987
+ await runStreaming(await this.binary(), [
2988
+ "-f",
2989
+ "wa[acodec!=none]/ba/b",
2990
+ "--no-playlist",
2991
+ "--no-warnings",
2992
+ "--newline",
2993
+ "--no-part",
2994
+ "--js-runtimes",
2995
+ `node:${process.execPath}`,
2996
+ "-o",
2997
+ join(dir, "audio.%(ext)s"),
2998
+ url
2999
+ ], {
3000
+ timeoutMs: 18e5,
3001
+ ...options.signal === void 0 ? {} : { signal: options.signal },
3002
+ onLine: (line) => {
3003
+ const percent = /^\[download\]\s+([\d.]+)%/.exec(line)?.[1];
3004
+ if (percent !== void 0) options.onProgress?.(Number(percent) / 100);
3005
+ }
3006
+ });
3007
+ const file = readdirSync(dir).find((name) => name.startsWith("audio."));
3008
+ if (file === void 0) throw new Error("yt-dlp: no audio downloaded");
3009
+ return join(dir, file);
3010
+ }
3011
+ /**
3012
+ * One caption track's cues.
3013
+ *
3014
+ * @param url - the video page URL.
3015
+ * @param track - the chosen track.
3016
+ */
3017
+ async captions(url, track) {
3018
+ mkdirSync(this.tmpRoot, { recursive: true });
3019
+ const dir = mkdtempSync(join(this.tmpRoot, "subs-"));
3020
+ try {
3021
+ await run(await this.binary(), [
3022
+ ...this.base(),
3023
+ track.auto ? "--write-auto-subs" : "--write-subs",
3024
+ "--sub-langs",
3025
+ track.lang,
3026
+ "--sub-format",
3027
+ track.ext,
3028
+ "-o",
3029
+ join(dir, "captions.%(ext)s"),
3030
+ url
3031
+ ], 18e4);
3032
+ const file = readdirSync(dir).find((name) => name.startsWith("captions.") && !name.endsWith(".part"));
3033
+ if (file === void 0) return [];
3034
+ return parseCaptions(readFileSync(join(dir, file), "utf8"), file.split(".").pop() ?? "");
3035
+ } finally {
3036
+ rmSync(dir, {
3037
+ recursive: true,
3038
+ force: true
3039
+ });
3040
+ }
3041
+ }
3042
+ };
3043
+ //#endregion
3044
+ //#region lib/types/media-host.js
3045
+ /**
3046
+ * The host half of video and audio entries: the pages a media link becomes,
3047
+ * the feeds a channel or podcast becomes, and the transcript a video reads as.
3048
+ *
3049
+ * - A single video (YouTube, Bilibili) or Xiaoyuzhou episode is stored as a
3050
+ * `link` source whose payload is a SYNTHETIC page: title, description,
3051
+ * thumbnail, and a `<meta name="dsh-reader:media">` carrying the media
3052
+ * description the browser half reads (`readMediaMeta`). Every other part of
3053
+ * the reader then treats it as a saved link.
3054
+ * - A YouTube channel resolves to YouTube's own channel feed; a Xiaoyuzhou
3055
+ * podcast page becomes an RSS document (`xiaoyuzhou.ts`).
3056
+ * - A video's transcript comes from the platform's captions via yt-dlp,
3057
+ * merged into timed paragraphs (`captions.ts`). Whether a video has
3058
+ * captions is remembered in `media.json`, so a caption-less video does not
3059
+ * cost a yt-dlp run on every open.
3060
+ *
3061
+ * @module @khorsheed/dsh-reader/media-host
3062
+ */
3063
+ /** Escape text for HTML content and attributes. */
3064
+ function escapeHtml(text) {
3065
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3066
+ }
3067
+ /** A plain-text description as paragraphs. */
3068
+ function paragraphs(text) {
3069
+ return text.split(/\n{2,}/).map((block) => block.trim()).filter((block) => block !== "").map((block) => `<p>${escapeHtml(block).replace(/\n/g, "<br>")}</p>`).join("\n");
3070
+ }
3071
+ /**
3072
+ * The synthetic page a media link is stored as.
3073
+ *
3074
+ * @param page - what the page says.
3075
+ */
3076
+ function syntheticPage(page) {
3077
+ const body = page.descriptionHtml ?? (page.descriptionText === void 0 ? "" : paragraphs(page.descriptionText));
3078
+ const plain = (page.descriptionText ?? page.descriptionHtml?.replace(/<[^>]+>/g, " ") ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
3079
+ return [
3080
+ "<!doctype html><html><head><meta charset=\"utf-8\">",
3081
+ `<title>${escapeHtml(page.title)}</title>`,
3082
+ `<meta property="og:title" content="${escapeHtml(page.title)}">`,
3083
+ plain === "" ? "" : `<meta name="description" content="${escapeHtml(plain)}">`,
3084
+ page.media.thumbnail === void 0 ? "" : `<meta property="og:image" content="${escapeHtml(page.media.thumbnail)}">`,
3085
+ page.author === void 0 ? "" : `<meta name="author" content="${escapeHtml(page.author)}">`,
3086
+ page.publishedAt === void 0 ? "" : `<meta property="article:published_time" content="${escapeHtml(page.publishedAt)}">`,
3087
+ `<meta name="${MEDIA_META_NAME}" content="${escapeHtml(JSON.stringify(page.media))}">`,
3088
+ "</head><body><article>",
3089
+ `<h1>${escapeHtml(page.title)}</h1>`,
3090
+ body,
3091
+ "</article></body></html>"
3092
+ ].filter((line) => line !== "").join("\n");
3093
+ }
3094
+ /** A yt-dlp `upload_date` (`YYYYMMDD`) or timestamp as ISO-8601. */
3095
+ function publishedOf(info) {
3096
+ if (typeof info.timestamp === "number") return (/* @__PURE__ */ new Date(info.timestamp * 1e3)).toISOString();
3097
+ const day = /^(\d{4})(\d{2})(\d{2})$/.exec(info.upload_date ?? "");
3098
+ return day === null ? void 0 : `${day[1]}-${day[2]}-${day[3]}T00:00:00.000Z`;
3099
+ }
3100
+ /**
3101
+ * A video's synthetic page from its yt-dlp metadata.
3102
+ *
3103
+ * @param info - the metadata.
3104
+ * @param provider - which platform.
3105
+ * @param id - the platform's video id.
3106
+ */
3107
+ function videoPage(info, provider, id) {
3108
+ const published = publishedOf(info);
3109
+ const author = info.uploader ?? info.channel;
3110
+ return syntheticPage({
3111
+ title: info.title ?? id,
3112
+ media: {
3113
+ kind: "video",
3114
+ provider,
3115
+ ref: id,
3116
+ ...typeof info.duration === "number" ? { durationSec: Math.round(info.duration) } : {},
3117
+ ...info.thumbnail === void 0 ? {} : { thumbnail: info.thumbnail }
3118
+ },
3119
+ ...info.description === void 0 ? {} : { descriptionText: info.description },
3120
+ ...author === void 0 ? {} : { author },
3121
+ ...published === void 0 ? {} : { publishedAt: published }
3122
+ });
3123
+ }
3124
+ /**
3125
+ * A Xiaoyuzhou episode's synthetic page.
3126
+ *
3127
+ * @param episode - the parsed episode.
3128
+ */
3129
+ function episodePage(episode) {
3130
+ return syntheticPage({
3131
+ title: episode.title,
3132
+ media: {
3133
+ kind: "audio",
3134
+ provider: "xiaoyuzhou",
3135
+ ref: episode.audio,
3136
+ ...episode.durationSec === void 0 ? {} : { durationSec: episode.durationSec },
3137
+ ...episode.image === void 0 ? {} : { thumbnail: episode.image }
3138
+ },
3139
+ ...episode.notesHtml === void 0 ? {} : { descriptionHtml: episode.notesHtml },
3140
+ ...episode.podcastTitle === void 0 ? {} : { author: episode.podcastTitle },
3141
+ ...episode.publishedAt === void 0 ? {} : { publishedAt: episode.publishedAt }
3142
+ });
3143
+ }
3144
+ /** Escape text for XML. */
3145
+ function xml$1(text) {
3146
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3147
+ }
3148
+ /**
3149
+ * A YouTube channel's newest videos as an RSS document (YouTube's own channel
3150
+ * feed answers 404 — measured 2026-10-20). Each item links the watch page, so
3151
+ * the browser half reads it as a video; the thumbnail rides `media:thumbnail`.
3152
+ *
3153
+ * @param channel - the yt-dlp flat listing.
3154
+ * @param pageUrl - the channel URL.
3155
+ */
3156
+ function channelRss(channel, pageUrl) {
3157
+ const items = channel.entries.flatMap((entry) => {
3158
+ if (entry.id === void 0 || entry.title === void 0) return [];
3159
+ const published = publishedOf(entry);
3160
+ return [[
3161
+ "<item>",
3162
+ `<title>${xml$1(entry.title)}</title>`,
3163
+ `<link>https://www.youtube.com/watch?v=${xml$1(entry.id)}</link>`,
3164
+ `<guid isPermaLink="false">yt:video:${xml$1(entry.id)}</guid>`,
3165
+ published === void 0 ? "" : `<pubDate>${new Date(published).toUTCString()}</pubDate>`,
3166
+ entry.thumbnail === void 0 ? "" : `<media:thumbnail url="${xml$1(entry.thumbnail)}"/>`,
3167
+ "</item>"
3168
+ ].filter((line) => line !== "").join("")];
3169
+ });
3170
+ return [
3171
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
3172
+ "<rss version=\"2.0\" xmlns:media=\"http://search.yahoo.com/mrss/\"><channel>",
3173
+ `<title>${xml$1(channel.title ?? pageUrl)}</title>`,
3174
+ `<link>${xml$1(pageUrl)}</link>`,
3175
+ ...items,
3176
+ "</channel></rss>"
3177
+ ].join("\n");
3178
+ }
3179
+ /** A synthetic page's display name for the source: its author (the channel / podcast), else its title. */
3180
+ function syntheticLabel(raw) {
3181
+ const author = /<meta name="author" content="([^"]*)"/.exec(raw)?.[1];
3182
+ const title = /<title>([^<]*)<\/title>/.exec(raw)?.[1];
3183
+ const value = author ?? title;
3184
+ return value === void 0 ? void 0 : value.replace(/&quot;/g, "\"").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
3185
+ }
3186
+ /**
3187
+ * Where a share short link leads (b23.tv → bilibili.com/video/BV…). curl
3188
+ * follows the redirect chain and honors the deployment's proxy environment.
3189
+ *
3190
+ * @param url - the short link.
3191
+ */
3192
+ function resolveShortLink(url) {
3193
+ return new Promise((resolve, reject) => {
3194
+ execFile("curl", [
3195
+ "-sL",
3196
+ "-m",
3197
+ "20",
3198
+ "-A",
3199
+ "Mozilla/5.0",
3200
+ "-o",
3201
+ "/dev/null",
3202
+ "-w",
3203
+ "%{url_effective}",
3204
+ url
3205
+ ], {
3206
+ timeout: 3e4,
3207
+ env: process.env
3208
+ }, (error, stdout) => {
3209
+ if (error !== null) {
3210
+ reject(error);
3211
+ return;
3212
+ }
3213
+ resolve(String(stdout).trim());
3214
+ });
3215
+ });
3216
+ }
3217
+ /** The host's media helper: yt-dlp plus the caption record. */
3218
+ var ReaderMediaHost = class {
3219
+ stateRoot;
3220
+ ytdlp;
3221
+ file;
3222
+ records;
3223
+ /**
3224
+ * @param stateRoot - the reader's state root.
3225
+ * @param ytdlpPath - a deployment-configured yt-dlp, which wins over the managed one.
3226
+ */
3227
+ constructor(stateRoot, ytdlpPath) {
3228
+ this.stateRoot = stateRoot;
3229
+ this.ytdlp = new YtDlp(stateRoot, ytdlpPath);
3230
+ this.file = join(stateRoot, "media.json");
3231
+ }
3232
+ /** The caption records, read once. */
3233
+ load() {
3234
+ if (this.records !== void 0) return this.records;
3235
+ try {
3236
+ this.records = JSON.parse(readFileSync(this.file, "utf8"));
3237
+ } catch {
3238
+ this.records = {};
3239
+ }
3240
+ return this.records;
3241
+ }
3242
+ /** What is known about an entry's captions. */
3243
+ captions(entryId) {
3244
+ return this.load()[entryId];
3245
+ }
3246
+ /** Remember what an entry's captions came to. */
3247
+ remember(entryId, record) {
3248
+ const records = {
3249
+ ...this.load(),
3250
+ [entryId]: record
3251
+ };
3252
+ this.records = records;
3253
+ try {
3254
+ mkdirSync(this.stateRoot, { recursive: true });
3255
+ const temporary = `${this.file}.${process.pid}.tmp`;
3256
+ writeFileSync(temporary, `${JSON.stringify(records, null, 2)}\n`);
3257
+ renameSync(temporary, this.file);
3258
+ } catch {
3259
+ rmSync(`${this.file}.${process.pid}.tmp`, { force: true });
3260
+ }
3261
+ }
3262
+ /**
3263
+ * A video's transcript from its platform captions.
3264
+ *
3265
+ * @param url - the video page.
3266
+ * @returns the transcript markup and its language, or `none` when the video has no captions.
3267
+ */
3268
+ async transcript(url) {
3269
+ const track = chooseCaptionTrack(await this.ytdlp.info(url));
3270
+ if (track === void 0) return "none";
3271
+ const segments = mergeCues(await this.ytdlp.captions(url, track));
3272
+ if (segments.length === 0) return "none";
3273
+ return {
3274
+ html: renderTranscript(segments),
3275
+ language: track.lang.replace(/-orig$/, "")
3276
+ };
3277
+ }
3278
+ };
3279
+ //#endregion
3280
+ //#region lib/types/xiaoyuzhou.js
3281
+ /**
3282
+ * Xiaoyuzhou (小宇宙): episode and podcast pages → what the reader stores.
3283
+ *
3284
+ * Xiaoyuzhou publishes no RSS. Its pages do carry their data as Next.js
3285
+ * `__NEXT_DATA__` JSON (measured 2026-10-20): an episode page has
3286
+ * `props.pageProps.episode` with the audio enclosure, duration, shownotes and
3287
+ * podcast; a podcast page has `props.pageProps.podcast.episodes[]`. A podcast
3288
+ * page is turned into an RSS document on the host, so subscribing to it is an
3289
+ * ordinary feed subscription for every other part of the reader.
3290
+ *
3291
+ * Pure: the host fetches the page and hands over its text.
3292
+ *
3293
+ * @module @khorsheed/dsh-reader/xiaoyuzhou
3294
+ */
3295
+ /** The page's Next.js data, or `undefined` when the page has none. */
3296
+ function nextData(html) {
3297
+ const match = /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/.exec(html);
3298
+ if (match === null) return void 0;
3299
+ try {
3300
+ return JSON.parse(match[1]);
3301
+ } catch {
3302
+ return;
3303
+ }
3304
+ }
3305
+ /** A nested record field, tolerantly. */
3306
+ function field(value, ...path) {
3307
+ let current = value;
3308
+ for (const key of path) {
3309
+ if (typeof current !== "object" || current === null) return void 0;
3310
+ current = current[key];
3311
+ }
3312
+ return current;
3313
+ }
3314
+ /** A string field, or `undefined`. */
3315
+ function str(value) {
3316
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
3317
+ }
3318
+ /** One episode record → the reader's shape. */
3319
+ function readEpisode(record, podcastTitle) {
3320
+ const id = str(field(record, "eid"));
3321
+ const title = str(field(record, "title"));
3322
+ const audio = str(field(record, "enclosure", "url")) ?? str(field(record, "media", "source", "url"));
3323
+ if (id === void 0 || title === void 0 || audio === void 0) return void 0;
3324
+ const duration = Number(field(record, "duration"));
3325
+ const notes = str(field(record, "shownotes")) ?? str(field(record, "description"));
3326
+ const image = str(field(record, "image", "picUrl")) ?? str(field(record, "podcast", "image", "picUrl"));
3327
+ const podcast = podcastTitle ?? str(field(record, "podcast", "title"));
3328
+ const publishedAt = str(field(record, "pubDate"));
3329
+ return {
3330
+ id,
3331
+ title,
3332
+ audio,
3333
+ ...Number.isFinite(duration) && duration > 0 ? { durationSec: duration } : {},
3334
+ ...publishedAt === void 0 ? {} : { publishedAt },
3335
+ ...notes === void 0 ? {} : { notesHtml: notes },
3336
+ ...image === void 0 ? {} : { image },
3337
+ ...podcast === void 0 ? {} : { podcastTitle: podcast }
3338
+ };
3339
+ }
3340
+ /**
3341
+ * Read an episode page.
3342
+ *
3343
+ * @param html - the page's HTML.
3344
+ * @returns the episode, or `undefined` when the page carries none.
3345
+ */
3346
+ function readEpisodePage(html) {
3347
+ return readEpisode(field(nextData(html), "props", "pageProps", "episode"));
3348
+ }
3349
+ /** Escape text for XML content and attributes. */
3350
+ function xml(text) {
3351
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3352
+ }
3353
+ /** CDATA, safe against a literal `]]>` in the content. */
3354
+ function cdata(text) {
3355
+ return `<![CDATA[${text.replace(/]]>/g, "]]]]><![CDATA[>")}]]>`;
3356
+ }
3357
+ /** The MIME type an audio URL most likely has. */
3358
+ function audioType(url) {
3359
+ const path = url.toLowerCase().split("?")[0] ?? "";
3360
+ if (path.endsWith(".m4a") || path.endsWith(".mp4")) return "audio/mp4";
3361
+ return "audio/mpeg";
3362
+ }
3363
+ /**
3364
+ * Turn a podcast page into an RSS document the reader subscribes to like any
3365
+ * other feed.
3366
+ *
3367
+ * @param html - the podcast page's HTML.
3368
+ * @param pageUrl - the page's URL (the channel link).
3369
+ * @returns the RSS text, or `undefined` when the page carries no podcast.
3370
+ */
3371
+ function podcastPageToRss(html, pageUrl) {
3372
+ const podcast = field(nextData(html), "props", "pageProps", "podcast");
3373
+ const title = str(field(podcast, "title"));
3374
+ if (title === void 0) return void 0;
3375
+ const items = (Array.isArray(field(podcast, "episodes")) ? field(podcast, "episodes") : []).flatMap((record) => {
3376
+ const episode = readEpisode(record, title);
3377
+ if (episode === void 0) return [];
3378
+ const date = episode.publishedAt === void 0 ? void 0 : new Date(episode.publishedAt);
3379
+ return [[
3380
+ "<item>",
3381
+ `<title>${xml(episode.title)}</title>`,
3382
+ `<link>https://www.xiaoyuzhoufm.com/episode/${episode.id}</link>`,
3383
+ `<guid isPermaLink="false">xiaoyuzhou:${episode.id}</guid>`,
3384
+ date === void 0 || Number.isNaN(date.getTime()) ? "" : `<pubDate>${date.toUTCString()}</pubDate>`,
3385
+ episode.notesHtml === void 0 ? "" : `<description>${cdata(episode.notesHtml)}</description>`,
3386
+ `<enclosure url="${xml(episode.audio)}" type="${audioType(episode.audio)}" length="0"/>`,
3387
+ episode.durationSec === void 0 ? "" : `<itunes:duration>${String(episode.durationSec)}</itunes:duration>`,
3388
+ episode.image === void 0 ? "" : `<itunes:image href="${xml(episode.image)}"/>`,
3389
+ "</item>"
3390
+ ].filter((line) => line !== "").join("")];
3391
+ });
3392
+ const description = str(field(podcast, "description"));
3393
+ const image = str(field(podcast, "image", "picUrl"));
3394
+ return [
3395
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
3396
+ "<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\">",
3397
+ "<channel>",
3398
+ `<title>${xml(title)}</title>`,
3399
+ `<link>${xml(pageUrl)}</link>`,
3400
+ description === void 0 ? "" : `<description>${xml(description)}</description>`,
3401
+ image === void 0 ? "" : `<image><url>${xml(image)}</url><title>${xml(title)}</title><link>${xml(pageUrl)}</link></image>`,
3402
+ ...items,
3403
+ "</channel>",
3404
+ "</rss>"
3405
+ ].filter((line) => line !== "").join("\n");
3406
+ }
3407
+ /** Cross-origin redirect hops the service will follow before giving up. */
3408
+ const MAX_REDIRECT_HOPS = 3;
3409
+ /** Upper bound on summaries handed to the browser in one call. */
3410
+ const SUMMARY_LIMIT = 500;
3411
+ /** How many sentence hashes one memory-slice read answers (the table never crosses). */
3412
+ const TRANSLATION_SLICE_LIMIT = 2e3;
3413
+ /** How many sentences one write batch may carry (a long article is ~500). */
3414
+ const TRANSLATION_WRITE_LIMIT = 2e3;
3415
+ /** One sentence's source or translation is never longer than a few paragraphs. */
3416
+ const TRANSLATION_TEXT_LIMIT = 4e3;
3417
+ /** Thrown when a payload cannot become a source. */
3418
+ var UnsupportedContent = class extends Error {};
3419
+ /**
3420
+ * Whether a failed fetch is the HTML endpoint's "this paper has no HTML
3421
+ * version" — a bare 404 from the content fetch, and nothing else. A refusal
3422
+ * (403/401) or a transport error is a fact about the REQUEST, not about which
3423
+ * face of the paper exists, and must not silently reroute to another page.
3424
+ */
3425
+ function isNoHtmlVersion(error) {
3426
+ return error instanceof UnsupportedContent && error.message === "HTTP 404";
3427
+ }
3428
+ /**
3429
+ * Classify a fetched payload as a feed or an article page.
3430
+ *
3431
+ * The decision comes from the CONTENT, not from asking the user: nobody pasting
3432
+ * a URL can be expected to know whether it is a feed, and guessing wrong used
3433
+ * to mean silently creating an empty subscription.
3434
+ *
3435
+ * @param raw - the decoded payload.
3436
+ * @returns which kind of source this payload can become.
3437
+ */
3438
+ function classifyPayload(raw) {
3439
+ const head = raw.slice(0, 512).trimStart().toLowerCase();
3440
+ if (head.startsWith("<?xml")) return "feed";
3441
+ if (/^<rss[\s>]/.test(head)) return "feed";
3442
+ if (/^<feed[\s>]/.test(head)) return "feed";
3443
+ if (/^<rdf:rdf[\s>]/.test(head)) return "feed";
3444
+ return "page";
3445
+ }
3446
+ /** The reader service. */
3447
+ var ReaderService = class {
3448
+ store;
3449
+ /** Video and audio entries: synthetic pages, channel feeds, transcripts. */
3450
+ media;
3451
+ /** Local transcription of entries without captions (the optional `transcribe` engine). */
3452
+ local;
3453
+ ctx;
3454
+ timer;
3455
+ disposed = false;
3456
+ /** The document of record when no filesystem is mounted. */
3457
+ memoryDoc;
3458
+ /** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
3459
+ imageCache = new ImageFetchCache();
3460
+ /**
3461
+ * @param ctx - owning context.
3462
+ * @param config - optional state-root override.
3463
+ */
3464
+ constructor(ctx, config = {}) {
3465
+ this.ctx = ctx;
3466
+ this.store = new ReaderStore(config);
3467
+ const stateRoot = resolveReaderStateRoot(config.stateRoot);
3468
+ this.media = new ReaderMediaHost(stateRoot, config.ytdlpPath);
3469
+ this.local = new LocalTranscriber({
3470
+ engine: () => ctx.get("transcribe"),
3471
+ tmpRoot: join(stateRoot, "tmp"),
3472
+ videoAudio: (url, dir, options) => this.media.ytdlp.audio(url, dir, options),
3473
+ store: async (entryId, url, html, language, speakers) => {
3474
+ await this.storeEntryBody({
3475
+ entryId,
3476
+ url,
3477
+ html,
3478
+ keep: true
3479
+ });
3480
+ const before = this.media.captions(entryId);
3481
+ const names = before?.names !== void 0 && (speakers === void 0 || speakers === before.speakers) ? before.names : void 0;
3482
+ const count = speakers ?? before?.speakers;
3483
+ this.media.remember(entryId, {
3484
+ state: "local",
3485
+ ...language === void 0 ? {} : { language },
3486
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
3487
+ ...count === void 0 ? {} : { speakers: count },
3488
+ ...names === void 0 ? {} : { names }
3489
+ });
3490
+ }
3491
+ });
3492
+ ctx.effect(() => () => {
3493
+ this.disposed = true;
3494
+ this.local.dispose();
3495
+ if (this.timer !== void 0) clearTimeout(this.timer);
3496
+ }, "reader: daily refresh");
3497
+ this.armSchedule();
3498
+ }
3499
+ /** Capabilities the browser may rely on. */
3500
+ async capabilities() {
3501
+ const doc = await this.currentDoc();
3502
+ return {
3503
+ protocolVersion: 1,
3504
+ hasFs: this.store.available,
3505
+ ...doc.lastRefreshAt === void 0 ? {} : { lastRefreshAt: doc.lastRefreshAt },
3506
+ hasSideChat: this.sideChat() !== void 0,
3507
+ ...doc.refresh.enabled ? { nextRefreshAt: new Date(Date.now() + delayUntilNext(/* @__PURE__ */ new Date(), doc.refresh.timeOfDay)).toISOString() } : {}
3508
+ };
3509
+ }
3510
+ /** The source list, without payloads. */
3511
+ async listSources() {
3512
+ return { sources: (await this.currentDoc()).sources.slice(0, SUMMARY_LIMIT).map(summarize$1) };
3513
+ }
3514
+ /**
3515
+ * Add a source, deciding what it is from what comes back.
3516
+ *
3517
+ * The one rule this method exists to enforce: **a URL the reader pasted is
3518
+ * never thrown away.** A fetch that fails, a bot challenge, a login wall and
3519
+ * a PDF all end the same way — a `link` source that exists, carries the
3520
+ * reason it has no previewable body, and can be opened, tagged, refreshed
3521
+ * manually or deleted. Only a URL that cannot be a source at all
3522
+ * (`invalid-url`) or one already present (`duplicate`) is refused.
3523
+ *
3524
+ * @param request - the pasted URL and an optional label.
3525
+ * @returns the decision, or a domain refusal.
3526
+ */
3527
+ async addSource(request) {
3528
+ const url = normalizeUrl(request.url);
3529
+ if (url === void 0) return "invalid-url";
3530
+ const doc = await this.currentDoc();
3531
+ if (doc.sources.some((source) => sameTarget(source.url, url))) return "duplicate";
3532
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3533
+ const resolution = await this.resolvePaper(url);
3534
+ if (resolution?.kind === "link-only") return await this.saveLinkOnly({
3535
+ url,
3536
+ ...request.label === void 0 ? {} : { label: request.label }
3537
+ }, {
3538
+ code: resolution.code,
3539
+ message: resolution.message,
3540
+ at: now
3541
+ }, now);
3542
+ const resolvedId = resolution?.kind === "arxiv" ? resolution.id : void 0;
3543
+ const upgrade = resolvedId === void 0 ? arxivHtmlUrl(url) : `https://arxiv.org/html/${resolvedId}`;
3544
+ const fallbackUrl = resolvedId === void 0 ? url : `https://arxiv.org/abs/${resolvedId}`;
3545
+ const resolvedFrom = resolvedId === void 0 ? void 0 : url;
3546
+ if (upgrade !== void 0 && doc.sources.some((source) => sameTarget(source.url, upgrade))) return "duplicate";
3547
+ let fetched;
3548
+ try {
3549
+ fetched = await this.fetchSource(upgrade ?? url);
3550
+ } catch (error) {
3551
+ if (upgrade !== void 0 && isNoHtmlVersion(error)) try {
3552
+ fetched = await this.fetchFollowing(fallbackUrl);
3553
+ } catch (fallbackError) {
3554
+ return await this.saveLinkOnly({
3555
+ url,
1959
3556
  ...request.label === void 0 ? {} : { label: request.label },
1960
3557
  ...resolvedFrom === void 0 ? {} : { resolvedFrom }
1961
3558
  }, classifyFetchFailure(fallbackError, now), now);
@@ -1967,10 +3564,10 @@ var ReaderService = class {
1967
3564
  }, classifyFetchFailure(error, now), now);
1968
3565
  }
1969
3566
  const feed = classifyPayload(fetched.raw) === "feed";
1970
- if (!feed) {
3567
+ if (!feed && fetched.media !== true) {
1971
3568
  const problem = inspectPreview(fetched.raw, fetched.url, upgrade ?? url);
1972
3569
  if (problem !== void 0) return await this.saveLinkOnly({
1973
- url: fetched.url,
3570
+ url,
1974
3571
  ...request.label === void 0 ? {} : { label: request.label },
1975
3572
  ...resolvedFrom === void 0 ? {} : { resolvedFrom }
1976
3573
  }, {
@@ -1979,7 +3576,7 @@ var ReaderService = class {
1979
3576
  at: now
1980
3577
  }, now);
1981
3578
  }
1982
- const label = request.label ?? (resolution?.kind === "arxiv" ? resolution.title : void 0) ?? defaultSourceLabel(fetched.url);
3579
+ const label = request.label ?? (resolution?.kind === "arxiv" ? resolution.title : void 0) ?? (fetched.media === true ? syntheticLabel(fetched.raw) : void 0) ?? defaultSourceLabel(fetched.url);
1983
3580
  const source = {
1984
3581
  id: `${feed ? "rss" : "link"}-${hash(fetched.url)}`,
1985
3582
  kind: feed ? "rss" : "link",
@@ -1993,10 +3590,7 @@ var ReaderService = class {
1993
3590
  ...resolvedFrom === void 0 ? {} : { resolvedFrom },
1994
3591
  raw: fetched.raw
1995
3592
  };
1996
- await this.commit((current) => ({
1997
- ...current,
1998
- sources: [source, ...current.sources]
1999
- }));
3593
+ await this.commit((current) => withNewSource(current, source));
2000
3594
  return {
2001
3595
  outcome: feed ? "subscribed" : "saved-link",
2002
3596
  kind: source.kind,
@@ -2034,10 +3628,7 @@ var ReaderService = class {
2034
3628
  failure,
2035
3629
  ...request.resolvedFrom === void 0 ? {} : { resolvedFrom: request.resolvedFrom }
2036
3630
  };
2037
- await this.commit((current) => ({
2038
- ...current,
2039
- sources: [source, ...current.sources]
2040
- }));
3631
+ await this.commit((current) => withNewSource(current, source));
2041
3632
  await this.recordFetchFailure(linkEntryId(source.id), failure.message);
2042
3633
  return {
2043
3634
  outcome: "saved-link",
@@ -2132,66 +3723,79 @@ var ReaderService = class {
2132
3723
  const results = [];
2133
3724
  const patches = /* @__PURE__ */ new Map();
2134
3725
  const base = new Map(doc.sources.map((source) => [source.id, source]));
2135
- for (const source of wanted) {
2136
- const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
2137
- try {
2138
- const fetched = await this.fetchFollowing(source.url);
2139
- const problem = source.kind === "link" ? inspectPreview(fetched.raw, fetched.url, source.url) : void 0;
2140
- if (problem !== void 0) {
2141
- const failure = {
2142
- code: problem.code,
2143
- message: problem.message,
2144
- at: fetchedAt
2145
- };
3726
+ /**
3727
+ * Failures to record AFTER the pool: the workers below run concurrently,
3728
+ * but every document write (a failure record is its own commit) stays
3729
+ * single-filed here — the store's commit is a read-modify-write, and two
3730
+ * in flight at once is how a lost update happens.
3731
+ */
3732
+ const failures = [];
3733
+ const queue = [...wanted];
3734
+ const workers = Array.from({ length: Math.min(3, queue.length) }, async () => {
3735
+ for (;;) {
3736
+ const source = queue.shift();
3737
+ if (source === void 0) return;
3738
+ const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
3739
+ try {
3740
+ const fetched = await this.fetchSource(source.url);
3741
+ const problem = source.kind === "link" && fetched.media !== true ? inspectPreview(fetched.raw, fetched.url, source.url) : void 0;
3742
+ if (problem !== void 0) {
3743
+ const failure = {
3744
+ code: problem.code,
3745
+ message: problem.message,
3746
+ at: fetchedAt
3747
+ };
3748
+ patches.set(source.id, {
3749
+ ...withoutFailure(source),
3750
+ fetchedAt,
3751
+ status: "error",
3752
+ error: failure.message,
3753
+ failure
3754
+ });
3755
+ failures.push({
3756
+ entryId: linkEntryId(source.id),
3757
+ message: failure.message
3758
+ });
3759
+ results.push({
3760
+ id: source.id,
3761
+ status: "unavailable",
3762
+ message: failure.message
3763
+ });
3764
+ continue;
3765
+ }
2146
3766
  patches.set(source.id, {
2147
- ...withoutFailure({
2148
- ...source,
2149
- url: fetched.url,
2150
- ...source.raw === void 0 ? {} : { raw: source.raw }
2151
- }),
3767
+ ...withoutFailure(source),
3768
+ url: fetched.url,
3769
+ raw: fetched.raw,
3770
+ fetchedAt,
3771
+ status: "ok",
3772
+ truncated: fetched.truncated
3773
+ });
3774
+ results.push({
3775
+ id: source.id,
3776
+ status: fetched.truncated ? "truncated" : "ok",
3777
+ ...fetched.truncated ? { message: "payload hit the fetch seam's size cap" } : {}
3778
+ });
3779
+ } catch (error) {
3780
+ const failure = classifyFetchFailure(error, fetchedAt);
3781
+ const previous = base.get(source.id);
3782
+ patches.set(source.id, {
3783
+ ...previous ?? source,
2152
3784
  fetchedAt,
2153
3785
  status: "error",
2154
3786
  error: failure.message,
2155
3787
  failure
2156
3788
  });
2157
- await this.recordFetchFailure(linkEntryId(source.id), failure.message);
2158
3789
  results.push({
2159
3790
  id: source.id,
2160
- status: "unavailable",
3791
+ status: error instanceof UnsupportedContent ? "unavailable" : "fetch-failed",
2161
3792
  message: failure.message
2162
3793
  });
2163
- continue;
2164
3794
  }
2165
- patches.set(source.id, {
2166
- ...withoutFailure(source),
2167
- url: fetched.url,
2168
- raw: fetched.raw,
2169
- fetchedAt,
2170
- status: "ok",
2171
- truncated: fetched.truncated
2172
- });
2173
- results.push({
2174
- id: source.id,
2175
- status: fetched.truncated ? "truncated" : "ok",
2176
- ...fetched.truncated ? { message: "payload hit the fetch seam's size cap" } : {}
2177
- });
2178
- } catch (error) {
2179
- const failure = classifyFetchFailure(error, fetchedAt);
2180
- const previous = base.get(source.id);
2181
- patches.set(source.id, {
2182
- ...previous ?? source,
2183
- fetchedAt,
2184
- status: "error",
2185
- error: failure.message,
2186
- failure
2187
- });
2188
- results.push({
2189
- id: source.id,
2190
- status: error instanceof UnsupportedContent ? "unavailable" : "fetch-failed",
2191
- message: failure.message
2192
- });
2193
3795
  }
2194
- }
3796
+ });
3797
+ await Promise.all(workers);
3798
+ for (const failure of failures) await this.recordFetchFailure(failure.entryId, failure.message);
2195
3799
  if (patches.size > 0) await this.commit((current) => ({
2196
3800
  ...current,
2197
3801
  lastRefreshAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2329,6 +3933,14 @@ var ReaderService = class {
2329
3933
  await this.markFetching(request.entryId);
2330
3934
  try {
2331
3935
  const fetched = await this.fetchFollowing(url);
3936
+ const wall = inspectPreview(fetched.raw, fetched.url, url);
3937
+ if (wall?.code === "blocked") {
3938
+ await this.recordFetchFailure(request.entryId, wall.message, wall.code);
3939
+ return {
3940
+ entryId: request.entryId,
3941
+ error: wall.message
3942
+ };
3943
+ }
2332
3944
  const rawFile = await this.storeRaw(request.entryId, fetched.raw, fetched.url, fetched.truncated);
2333
3945
  return {
2334
3946
  entryId: request.entryId,
@@ -2546,7 +4158,7 @@ var ReaderService = class {
2546
4158
  chars: html.length
2547
4159
  },
2548
4160
  fetchedAt: now.toISOString(),
2549
- expiresAt: ttlHours === 0 ? new Date(now.getTime() + 31536e8).toISOString() : new Date(now.getTime() + ttlHours * 60 * 60 * 1e3).toISOString(),
4161
+ expiresAt: ttlHours === 0 || request.keep === true ? new Date(now.getTime() + 31536e8).toISOString() : new Date(now.getTime() + ttlHours * 60 * 60 * 1e3).toISOString(),
2550
4162
  url: request.url,
2551
4163
  ...request.truncated === true ? { truncated: true } : {},
2552
4164
  ...request.scriptFigures === void 0 ? {} : { scriptFigures: request.scriptFigures },
@@ -2604,6 +4216,7 @@ var ReaderService = class {
2604
4216
  for (const entry of request.entries) {
2605
4217
  if (entry.url === "") continue;
2606
4218
  if (entry.hasBody) continue;
4219
+ if (mediaLink(entry.url) !== void 0) continue;
2607
4220
  const source = doc.sources.find((item) => item.kind === "link" && sameTarget(item.url, entry.url));
2608
4221
  if (source?.failure !== void 0 && !isRetryablePreviewFailure(source.failure.code)) continue;
2609
4222
  const annotation = doc.annotations?.[entry.entryId];
@@ -3204,7 +4817,169 @@ var ReaderService = class {
3204
4817
  if (target === void 0) throw error;
3205
4818
  current = target;
3206
4819
  }
3207
- throw new UnsupportedContent(`more than ${MAX_REDIRECT_HOPS} cross-origin redirects`);
4820
+ throw new UnsupportedContent(`more than ${MAX_REDIRECT_HOPS} cross-origin redirects`);
4821
+ }
4822
+ /**
4823
+ * Fetch a source's payload, media-aware (media-host.ts).
4824
+ *
4825
+ * A YouTube channel is fetched as YouTube's own channel feed; a Xiaoyuzhou
4826
+ * podcast page as the RSS document it is turned into; a single video or
4827
+ * Xiaoyuzhou episode as a synthetic page carrying its media description.
4828
+ * Everything else is the ordinary {@link fetchFollowing}.
4829
+ *
4830
+ * @param url - the source URL.
4831
+ */
4832
+ async fetchSource(url) {
4833
+ if (isShortMediaLink(url)) {
4834
+ const target = await resolveShortLink(url);
4835
+ if (target === "" || isShortMediaLink(target)) throw new UnsupportedContent("the short link leads nowhere");
4836
+ return this.fetchSource(canonicalMediaUrl(target) ?? target);
4837
+ }
4838
+ const link = mediaLink(url);
4839
+ if (link === void 0) return this.fetchFollowing(url);
4840
+ if (link.provider === "youtube" && link.kind === "channel") return {
4841
+ url,
4842
+ raw: channelRss(await this.media.ytdlp.channel(url), url),
4843
+ truncated: false
4844
+ };
4845
+ if (link.provider === "xiaoyuzhou") {
4846
+ const page = await this.fetchFollowing(url);
4847
+ if (link.kind === "podcast") {
4848
+ const rss = podcastPageToRss(page.raw, url);
4849
+ if (rss === void 0) throw new UnsupportedContent("the podcast page carries no episodes");
4850
+ return {
4851
+ url,
4852
+ raw: rss,
4853
+ truncated: false
4854
+ };
4855
+ }
4856
+ const episode = readEpisodePage(page.raw);
4857
+ if (episode === void 0) throw new UnsupportedContent("the episode page carries no audio");
4858
+ return {
4859
+ url,
4860
+ raw: episodePage(episode),
4861
+ truncated: false,
4862
+ media: true
4863
+ };
4864
+ }
4865
+ if (link.kind === "video" && (link.provider === "youtube" || link.provider === "bilibili")) {
4866
+ const clean = canonicalMediaUrl(url) ?? url;
4867
+ return {
4868
+ url: clean,
4869
+ raw: videoPage(await this.media.ytdlp.info(clean), link.provider, link.id),
4870
+ truncated: false,
4871
+ media: true
4872
+ };
4873
+ }
4874
+ return this.fetchFollowing(url);
4875
+ }
4876
+ /**
4877
+ * A video entry's transcript: the stored one, else the platform captions
4878
+ * fetched now and kept (no TTL — it costs a yt-dlp run). A video known to
4879
+ * have no captions answers so without running yt-dlp again.
4880
+ *
4881
+ * @param request - the entry and its video page URL.
4882
+ * @returns the transcript markup, `none`, or why it failed.
4883
+ */
4884
+ async mediaTranscript(request) {
4885
+ const known = this.media.captions(request.entryId);
4886
+ if (known?.state === "none" || known?.state === "local") return {
4887
+ entryId: request.entryId,
4888
+ captions: "none"
4889
+ };
4890
+ const link = mediaLink(request.url);
4891
+ if (link?.kind !== "video" || link.provider !== "youtube" && link.provider !== "bilibili") return {
4892
+ entryId: request.entryId,
4893
+ captions: "none"
4894
+ };
4895
+ try {
4896
+ const transcript = await this.media.transcript(request.url);
4897
+ if (transcript === "none") {
4898
+ this.media.remember(request.entryId, {
4899
+ state: "none",
4900
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
4901
+ });
4902
+ return {
4903
+ entryId: request.entryId,
4904
+ captions: "none"
4905
+ };
4906
+ }
4907
+ const stored = await this.storeEntryBody({
4908
+ entryId: request.entryId,
4909
+ url: request.url,
4910
+ html: transcript.html,
4911
+ keep: true
4912
+ });
4913
+ this.media.remember(request.entryId, {
4914
+ state: "platform",
4915
+ language: transcript.language,
4916
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
4917
+ });
4918
+ return {
4919
+ entryId: request.entryId,
4920
+ captions: "platform",
4921
+ language: transcript.language,
4922
+ ...stored.html === void 0 ? {} : { html: stored.html }
4923
+ };
4924
+ } catch (error) {
4925
+ return {
4926
+ entryId: request.entryId,
4927
+ captions: "failed",
4928
+ error: error instanceof Error ? error.message : String(error)
4929
+ };
4930
+ }
4931
+ }
4932
+ /** The reader's names for an entry's speakers, by index. */
4933
+ speakerNames(request) {
4934
+ return { ...this.media.captions(request.entryId)?.names ?? {} };
4935
+ }
4936
+ /**
4937
+ * Name one speaker of an entry's transcript (an empty name restores the default).
4938
+ *
4939
+ * @param request - the entry, the speaker's index, the name.
4940
+ */
4941
+ nameSpeaker(request) {
4942
+ const record = this.media.captions(request.entryId);
4943
+ if (record === void 0) return {};
4944
+ const names = { ...record.names ?? {} };
4945
+ const name = request.name.trim().slice(0, 40);
4946
+ if (name === "") delete names[String(request.speaker)];
4947
+ else names[String(request.speaker)] = name;
4948
+ this.media.remember(request.entryId, {
4949
+ ...record,
4950
+ names
4951
+ });
4952
+ return names;
4953
+ }
4954
+ /**
4955
+ * The wall's media cards: a live local job first, else what the captions
4956
+ * record says (platform captions, a stored local transcript, none).
4957
+ *
4958
+ * @param request - the card ids on screen.
4959
+ */
4960
+ mediaStates(request) {
4961
+ const states = {};
4962
+ for (const entryId of request.entryIds) {
4963
+ const job = this.local.status(entryId);
4964
+ if (job.state === "fetching" || job.state === "queued" || job.state === "transcribing") {
4965
+ states[entryId] = {
4966
+ state: job.state,
4967
+ ...job.progress === void 0 ? {} : { progress: job.progress },
4968
+ ...job.ahead === void 0 ? {} : { ahead: job.ahead }
4969
+ };
4970
+ continue;
4971
+ }
4972
+ if (job.state === "failed") {
4973
+ states[entryId] = { state: "failed" };
4974
+ continue;
4975
+ }
4976
+ const record = this.media.captions(entryId);
4977
+ if (record !== void 0) states[entryId] = { state: record.state };
4978
+ }
4979
+ return {
4980
+ engine: this.local.engineAvailable(),
4981
+ states
4982
+ };
3208
4983
  }
3209
4984
  /** Arm (or re-arm) the daily refresh timer against the current document. */
3210
4985
  async armSchedule() {
@@ -3240,6 +5015,27 @@ function normalizeUrl(input) {
3240
5015
  }
3241
5016
  }
3242
5017
  /** Whether two URLs point at the same target, ignoring a trailing slash. */
5018
+ /**
5019
+ * The document with `source` added, unless an add that raced this one already
5020
+ * filed it.
5021
+ *
5022
+ * `addSource` checks for a duplicate BEFORE its fetch, which takes long enough
5023
+ * (hundreds of ms to seconds) for a second click on 「添加」 to pass the same
5024
+ * check; both then committed, and the reader saw the card twice under one id
5025
+ * (measured on 3080, 2026-10-04: two `link-1v6imqy` rows 0.7 s apart). The
5026
+ * re-check inside the commit is what makes the add idempotent.
5027
+ *
5028
+ * @param doc - the current document.
5029
+ * @param source - the source to file.
5030
+ * @returns the document, with the source prepended when it is new.
5031
+ */
5032
+ function withNewSource(doc, source) {
5033
+ if (doc.sources.some((existing) => existing.id === source.id || sameTarget(existing.url, source.url))) return doc;
5034
+ return {
5035
+ ...doc,
5036
+ sources: [source, ...doc.sources]
5037
+ };
5038
+ }
3243
5039
  function sameTarget(a, b) {
3244
5040
  const strip = (value) => value.replace(/\/+$/, "");
3245
5041
  return strip(a) === strip(b);
@@ -3299,7 +5095,7 @@ function errorCode(error) {
3299
5095
  return typeof code === "string" ? code : void 0;
3300
5096
  }
3301
5097
  /** Marker words a bot wall leaves on an otherwise empty page. */
3302
- 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|验证码|人机验证|访问验证|安全验证/;
5098
+ 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|验证码|人机验证|访问验证|安全验证|环境异常|完成验证后/;
3303
5099
  /** Marker words an authentication interstitial leaves behind. */
3304
5100
  const LOGIN_WALL_PATTERN = /cookie required|requires? cookies|sign in|sign-in|log in|login|please log|single sign|sso|qurl=|ezproxy|shibboleth|访问权限|请登录|登录后/;
3305
5101
  /** Hosts whose NAME alone says the hop is an authentication wall. */
@@ -3326,188 +5122,1121 @@ function inspectPreview(raw, finalUrl, requestedUrl) {
3326
5122
  code: "blocked",
3327
5123
  message: `the site answered a bot challenge instead of the page (${finalUrl})`
3328
5124
  };
3329
- if (LOGIN_WALL_PATTERN.test(haystack) || LOGIN_HOST_PATTERN.test(host)) return {
3330
- code: "login",
3331
- message: `the page is an authentication interstitial (${finalUrl})`
5125
+ if (LOGIN_WALL_PATTERN.test(haystack) || LOGIN_HOST_PATTERN.test(host)) return {
5126
+ code: "login",
5127
+ message: `the page is an authentication interstitial (${finalUrl})`
5128
+ };
5129
+ if (hostOf(requestedUrl) !== host) return {
5130
+ code: "redirected",
5131
+ message: `the address redirected to ${finalUrl}, which yielded no readable page`
5132
+ };
5133
+ return {
5134
+ code: "empty",
5135
+ message: `the page yielded ${text} characters of text (${finalUrl})`
5136
+ };
5137
+ }
5138
+ /**
5139
+ * Classify a thrown fetch failure into the reason the reader sees.
5140
+ *
5141
+ * Everything here is per-case diagnosable: the seam's own message is kept
5142
+ * verbatim, and only the CODE decides which sentence the browser shows and
5143
+ * whether an automatic retry is allowed.
5144
+ *
5145
+ * @param error - the value the fetch threw.
5146
+ * @param at - the failure instant (ISO-8601).
5147
+ * @returns the recorded failure.
5148
+ */
5149
+ function classifyFetchFailure(error, at) {
5150
+ const message = errorMessage(error);
5151
+ const code = errorCode(error);
5152
+ if (code === "WEB_UNSUPPORTED_CONTENT_TYPE" || /unsupported content type/i.test(message)) return {
5153
+ code: "unsupported-type",
5154
+ message,
5155
+ at
5156
+ };
5157
+ if (code === "WEB_REDIRECT_BLOCKED" || /cross-origin redirect/i.test(message)) {
5158
+ if (isCookieHandshake(message)) return {
5159
+ code: "blocked",
5160
+ message,
5161
+ at
5162
+ };
5163
+ const origin = /cross-origin redirect to (\S+)/i.exec(message)?.[1];
5164
+ return {
5165
+ code: origin !== void 0 && LOGIN_HOST_PATTERN.test(hostOf(origin)) ? "login" : "redirected",
5166
+ message,
5167
+ at
5168
+ };
5169
+ }
5170
+ const status = /\bHTTP (\d{3})\b/.exec(message)?.[1];
5171
+ if (status === "401" || status === "403") return {
5172
+ code: "blocked",
5173
+ message,
5174
+ at
5175
+ };
5176
+ if (status !== void 0) return {
5177
+ code: "http",
5178
+ message,
5179
+ at
5180
+ };
5181
+ return {
5182
+ code: "unreachable",
5183
+ message,
5184
+ at
5185
+ };
5186
+ }
5187
+ /** The host of a URL, or an empty string when it cannot be parsed. */
5188
+ function hostOf(url) {
5189
+ try {
5190
+ return new URL(url).hostname.toLowerCase();
5191
+ } catch {
5192
+ return "";
5193
+ }
5194
+ }
5195
+ /**
5196
+ * How much visible text a payload carries.
5197
+ *
5198
+ * Script, style and markup are stripped; entities collapse to a single space.
5199
+ * This is a measurement, not a parser: its only job is to separate "a page"
5200
+ * from "an interstitial that is technically HTML".
5201
+ *
5202
+ * @param raw - the decoded payload.
5203
+ * @returns the visible character count.
5204
+ */
5205
+ function visibleTextLength(raw) {
5206
+ 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;
5207
+ }
5208
+ /** The same source with any recorded preview failure removed. */
5209
+ function withoutFailure(source) {
5210
+ const { failure: _removed, ...rest } = source;
5211
+ return rest;
5212
+ }
5213
+ /** Project one source into the browser-facing summary. */
5214
+ function summarize$1(source) {
5215
+ return {
5216
+ id: source.id,
5217
+ kind: source.kind,
5218
+ url: source.url,
5219
+ label: source.label ?? defaultSourceLabel(source.url),
5220
+ enabled: source.enabled,
5221
+ addedAt: source.addedAt,
5222
+ hasBody: source.raw !== void 0,
5223
+ ...source.fetchedAt !== void 0 ? { fetchedAt: source.fetchedAt } : {},
5224
+ ...source.status !== void 0 ? { status: source.status } : {},
5225
+ ...source.error !== void 0 ? { error: source.error } : {},
5226
+ ...source.truncated === true ? { truncated: true } : {},
5227
+ ...source.failure === void 0 ? {} : { failure: {
5228
+ code: source.failure.code,
5229
+ message: source.failure.message
5230
+ } },
5231
+ ...source.resolvedFrom === void 0 ? {} : { resolvedFrom: source.resolvedFrom }
5232
+ };
5233
+ }
5234
+ /**
5235
+ * The same annotation with no `tagIds` key at all.
5236
+ *
5237
+ * Under `exactOptionalPropertyTypes` an explicit `undefined` is not the same as
5238
+ * an absent optional field, and this document is serialized straight to disk —
5239
+ * so an empty tag list must be an ABSENT key, not a null one.
5240
+ *
5241
+ * @param entry - the annotation to strip.
5242
+ * @returns the annotation without its tag list.
5243
+ */
5244
+ function withoutTags(entry) {
5245
+ const { tagIds: _removed, ...rest } = entry;
5246
+ return rest;
5247
+ }
5248
+ /**
5249
+ * Drop annotations that carry nothing, so the document does not accumulate
5250
+ * empty objects as tags are removed.
5251
+ *
5252
+ * @param annotations - the table to clean.
5253
+ * @returns the table with empty entries removed.
5254
+ */
5255
+ function cleanupAnnotations(annotations) {
5256
+ const out = {};
5257
+ for (const [entryId, entry] of Object.entries(annotations)) {
5258
+ 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;
5259
+ out[entryId] = (entry.tagIds ?? []).length > 0 ? entry : withoutTags(entry);
5260
+ }
5261
+ return out;
5262
+ }
5263
+ /**
5264
+ * Parse a translation sidecar's segment map, tolerating a damaged file.
5265
+ *
5266
+ * @param raw - the file's content, or `undefined` when it is gone.
5267
+ * @returns the map, or `undefined` when the file holds none.
5268
+ */
5269
+ function readSegmentsFile(raw) {
5270
+ if (raw === void 0) return void 0;
5271
+ try {
5272
+ const parsed = JSON.parse(raw);
5273
+ if (typeof parsed !== "object" || parsed === null) return void 0;
5274
+ return Object.fromEntries(Object.entries(parsed).filter(([, target]) => typeof target === "string"));
5275
+ } catch {
5276
+ return;
5277
+ }
5278
+ }
5279
+ /**
5280
+ * Coerce one batch of learned sentences: bounded in count and in size, each
5281
+ * carrying its hash and both texts.
5282
+ *
5283
+ * @param entries - the client's batch.
5284
+ * @returns the entries worth storing.
5285
+ */
5286
+ function normalizeLearns(entries) {
5287
+ const out = [];
5288
+ for (const entry of entries.slice(0, TRANSLATION_WRITE_LIMIT)) {
5289
+ if (typeof entry.hash !== "string" || entry.hash.length === 0 || entry.hash.length > 64) continue;
5290
+ if (typeof entry.source !== "string" || typeof entry.target !== "string") continue;
5291
+ if (entry.source.length === 0 || entry.target.length === 0) continue;
5292
+ out.push({
5293
+ hash: entry.hash,
5294
+ source: entry.source.slice(0, TRANSLATION_TEXT_LIMIT),
5295
+ target: entry.target.slice(0, TRANSLATION_TEXT_LIMIT)
5296
+ });
5297
+ }
5298
+ return out;
5299
+ }
5300
+ /** How old a `fetching` record may be before it is treated as abandoned. */
5301
+ const FETCH_STALE_MS = 6e5;
5302
+ /** Whether an ISO instant is recent enough to still mean "in flight". */
5303
+ function isRecent(iso, now, windowMs) {
5304
+ const at = new Date(iso).getTime();
5305
+ return Number.isFinite(at) && now - at <= windowMs;
5306
+ }
5307
+ /** Whether a cache deadline is still in the future. */
5308
+ function isFresh(expiresAt) {
5309
+ const deadline = new Date(expiresAt).getTime();
5310
+ return Number.isFinite(deadline) && deadline > Date.now();
5311
+ }
5312
+ //#endregion
5313
+ //#region lib/types/polish.js
5314
+ /**
5315
+ * The 润色 layer: an agent's paragraph-by-paragraph translation of an
5316
+ * article, written back through `reader_translate` and shown in place of the
5317
+ * browser's sentence-by-sentence machine translation.
5318
+ *
5319
+ * Keyed to the article's text as the model read it (the snapshot): a
5320
+ * re-transcription changes the text and so drops the layer; renaming a
5321
+ * speaker only changes the "[mm:ss] 名字:" prefixes and keeps it.
5322
+ *
5323
+ * One JSON file per article under `<state>/polish/`, memory-only when the
5324
+ * state root cannot be written.
5325
+ *
5326
+ * @module @khorsheed/dsh-reader/polish
5327
+ */
5328
+ /** The time / speaker prefix of a transcript paragraph in the snapshot. */
5329
+ const PREFIX = /^\[\d+(?::\d+){1,2}\]\s*(?:[^:\n]{1,40}:)?/;
5330
+ /**
5331
+ * What a layer belongs to: the paragraphs' text without their time and
5332
+ * speaker prefixes, hashed.
5333
+ */
5334
+ function sourceKey(paragraphs) {
5335
+ const hash = createHash("sha1");
5336
+ hash.update(String(paragraphs.length));
5337
+ for (const paragraph of paragraphs) hash.update(`\n${paragraph.replace(PREFIX, "")}`);
5338
+ return hash.digest("hex").slice(0, 24);
5339
+ }
5340
+ /** The per-article 润色 layers. */
5341
+ var ReaderPolishStore = class {
5342
+ dir;
5343
+ memory = /* @__PURE__ */ new Map();
5344
+ constructor(stateRoot) {
5345
+ this.dir = join(stateRoot, "polish");
5346
+ }
5347
+ fileOf(entryId) {
5348
+ return join(this.dir, `${createHash("sha1").update(entryId).digest("hex").slice(0, 32)}.json`);
5349
+ }
5350
+ load(entryId) {
5351
+ const cached = this.memory.get(entryId);
5352
+ if (cached !== void 0) return cached;
5353
+ try {
5354
+ const parsed = JSON.parse(readFileSync(this.fileOf(entryId), "utf8"));
5355
+ if (parsed.entryId !== entryId) return void 0;
5356
+ this.memory.set(entryId, parsed);
5357
+ return parsed;
5358
+ } catch {
5359
+ return;
5360
+ }
5361
+ }
5362
+ save(file) {
5363
+ this.memory.set(file.entryId, file);
5364
+ try {
5365
+ mkdirSync(this.dir, { recursive: true });
5366
+ const target = this.fileOf(file.entryId);
5367
+ writeFileSync(`${target}.tmp`, JSON.stringify(file));
5368
+ renameSync(`${target}.tmp`, target);
5369
+ } catch {}
5370
+ }
5371
+ /**
5372
+ * An article's layer, when it belongs to the text now captured.
5373
+ *
5374
+ * @param entryId - the article.
5375
+ * @param key - the current text's {@link sourceKey}.
5376
+ */
5377
+ view(entryId, key) {
5378
+ const file = this.load(entryId);
5379
+ if (file === void 0 || key === void 0 || file.sourceKey !== key) return void 0;
5380
+ const done = Object.keys(file.paragraphs).length;
5381
+ return {
5382
+ total: file.total,
5383
+ done,
5384
+ paragraphs: { ...file.paragraphs },
5385
+ off: [...file.off],
5386
+ updatedAt: file.updatedAt,
5387
+ complete: done >= file.total
5388
+ };
5389
+ }
5390
+ /**
5391
+ * Merge the model's translations into the layer (a different text starts a
5392
+ * fresh one).
5393
+ *
5394
+ * @returns the layer after the write.
5395
+ */
5396
+ write(entryId, key, total, updates) {
5397
+ const before = this.load(entryId);
5398
+ const fresh = before === void 0 || before.sourceKey !== key;
5399
+ const paragraphs = fresh ? {} : { ...before.paragraphs };
5400
+ for (const [index, value] of updates) if (index >= 1 && index <= total) paragraphs[String(index)] = value;
5401
+ const file = {
5402
+ entryId,
5403
+ sourceKey: key,
5404
+ total,
5405
+ paragraphs,
5406
+ off: fresh ? [] : before.off,
5407
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5408
+ };
5409
+ this.save(file);
5410
+ return this.view(entryId, key);
5411
+ }
5412
+ /** Turn one paragraph back to (or away from) the machine translation. */
5413
+ setOff(entryId, key, paragraph, off) {
5414
+ const file = this.load(entryId);
5415
+ if (file === void 0 || key === void 0 || file.sourceKey !== key) return void 0;
5416
+ const rest = file.off.filter((item) => item !== paragraph);
5417
+ this.save({
5418
+ ...file,
5419
+ off: off ? [...rest, paragraph] : rest
5420
+ });
5421
+ return this.view(entryId, key);
5422
+ }
5423
+ /** Forget an article's layer. */
5424
+ drop(entryId) {
5425
+ this.memory.delete(entryId);
5426
+ rmSync(this.fileOf(entryId), { force: true });
5427
+ }
5428
+ };
5429
+ //#endregion
5430
+ //#region lib/types/notes.js
5431
+ /**
5432
+ * The reader's notes: excerpts the reader kept from an article, the comment on
5433
+ * each, which conversations discussed the article, and the article text the
5434
+ * model reads when it does.
5435
+ *
5436
+ * Notes are what the reader OWNS, so they live on the host next to the rest of
5437
+ * the reader's state (the state-boundaries note, rule 1) — but in their own
5438
+ * file, `notes.json`, not inside `state.json`'s per-entry annotations. An
5439
+ * annotation is shaped around the body cache: `boundAnnotations` evicts by
5440
+ * body, `normalizeAnnotations` drops entries that carry nothing it knows. A
5441
+ * note must outlive every one of those (the excerpt carries its own text), and
5442
+ * keeping it out of that machinery is what makes "the body was evicted" and
5443
+ * "the note is gone" two unrelated events.
5444
+ *
5445
+ * The article text is a second, separate concern: the host never parses an
5446
+ * article (the browser half does), and a feed entry's text never reaches the
5447
+ * host at all. So the browser hands over a SNAPSHOT — the article's original
5448
+ * prose, one string per top-level block, numbered the way the pane numbers its
5449
+ * blocks — and the model reads that. Snapshots are capped and pruned; notes
5450
+ * are never pruned.
5451
+ *
5452
+ * Persistence mirrors `store.ts`: native fs at the deployment's state root,
5453
+ * atomic temp-file-and-rename writes, and an in-memory document when the root
5454
+ * is not writable. Writes are serialized through one promise chain, so two
5455
+ * gestures landing together never read the same version and lose one.
5456
+ *
5457
+ * @module @khorsheed/dsh-reader/notes
5458
+ */
5459
+ /** The notes document's file name inside the state root. */
5460
+ const READER_NOTES_FILE_NAME = "notes.json";
5461
+ /** Where article snapshots live, inside the state root. */
5462
+ const READER_SNAPSHOTS_DIR = "snapshots";
5463
+ /** Most notes one article keeps (a cap against a runaway client, not a quota anyone reaches). */
5464
+ const MAX_NOTES_PER_ENTRY = 500;
5465
+ /** Most conversations one article remembers. */
5466
+ const MAX_SESSIONS_PER_ENTRY = 50;
5467
+ /** Snapshots kept for articles with no notes and no discussions (the most recent ones). */
5468
+ const MAX_LOOSE_SNAPSHOTS = 30;
5469
+ /** The cap on one snapshot's total characters (a long paper fits; a book does not). */
5470
+ const MAX_SNAPSHOT_CHARS = 4e5;
5471
+ /** Most lines under one note. */
5472
+ const MAX_REPLIES_PER_NOTE = 50;
5473
+ /** Most discussion notes one article keeps (the oldest go first). */
5474
+ const MAX_DISCUSSIONS_PER_ENTRY = 100;
5475
+ /** Most points in one discussion note. */
5476
+ const MAX_POINTS = 30;
5477
+ /** The cap on one reply or one discussion point. */
5478
+ const MAX_POINT_CHARS = 4e3;
5479
+ /** The cap on one excerpt. */
5480
+ const MAX_QUOTE_CHARS = 2e3;
5481
+ /** The cap on one comment. */
5482
+ const MAX_COMMENT_CHARS = 4e3;
5483
+ /** The empty document. */
5484
+ function emptyNotesDoc() {
5485
+ return {
5486
+ version: 1,
5487
+ entries: {},
5488
+ snapshots: {}
5489
+ };
5490
+ }
5491
+ /** A string field, trimmed and capped, or `undefined` when empty. */
5492
+ function text(value, cap) {
5493
+ if (typeof value !== "string") return void 0;
5494
+ const trimmed = value.trim();
5495
+ return trimmed.length === 0 ? void 0 : trimmed.slice(0, cap);
5496
+ }
5497
+ /** A timestamp field, or the epoch when it is missing. */
5498
+ function stamp(value) {
5499
+ return typeof value === "string" && value.length > 0 ? value : (/* @__PURE__ */ new Date(0)).toISOString();
5500
+ }
5501
+ /** One note, when it carries an id and an excerpt. */
5502
+ function normalizeNote(value) {
5503
+ if (typeof value !== "object" || value === null) return void 0;
5504
+ const record = value;
5505
+ const id = text(record.id, 20);
5506
+ const anchor = record.anchor;
5507
+ const quote = text(anchor?.quote, MAX_QUOTE_CHARS);
5508
+ if (id === void 0 || quote === void 0) return void 0;
5509
+ const block = typeof anchor?.block === "number" && Number.isInteger(anchor.block) && anchor.block >= 0 ? anchor.block : 0;
5510
+ const start = typeof anchor?.start === "number" && Number.isInteger(anchor.start) && anchor.start >= 0 ? anchor.start : void 0;
5511
+ const translated = text(anchor?.translated, MAX_QUOTE_CHARS);
5512
+ const time = typeof anchor?.time === "number" && Number.isFinite(anchor.time) && anchor.time >= 0 ? anchor.time : void 0;
5513
+ const comment = text(record.comment, MAX_COMMENT_CHARS);
5514
+ const replies = Array.isArray(record.replies) ? record.replies.map(normalizeReply).filter((reply) => reply !== void 0).slice(-50) : [];
5515
+ return {
5516
+ id,
5517
+ anchor: {
5518
+ block,
5519
+ ...start === void 0 ? {} : { start },
5520
+ quote,
5521
+ ...translated === void 0 ? {} : { translated },
5522
+ ...time === void 0 ? {} : { time }
5523
+ },
5524
+ ...comment === void 0 ? {} : { comment },
5525
+ ...replies.length === 0 ? {} : { replies },
5526
+ ...normalizeSynced(record.syncedTo).length === 0 ? {} : { syncedTo: normalizeSynced(record.syncedTo) },
5527
+ createdAt: stamp(record.createdAt),
5528
+ updatedAt: stamp(record.updatedAt ?? record.createdAt)
5529
+ };
5530
+ }
5531
+ /** The canvas cards an item was synced to (bounded, malformed rows dropped). */
5532
+ function normalizeSynced(value) {
5533
+ if (!Array.isArray(value)) return [];
5534
+ return value.flatMap((item) => {
5535
+ if (typeof item !== "object" || item === null) return [];
5536
+ const record = item;
5537
+ const canvasId = text(record.canvasId, 200);
5538
+ const cardId = text(record.cardId, 200);
5539
+ if (canvasId === void 0 || cardId === void 0) return [];
5540
+ return [{
5541
+ canvasId,
5542
+ cardId,
5543
+ canvasTitle: text(record.canvasTitle, 200) ?? canvasId,
5544
+ at: stamp(record.at)
5545
+ }];
5546
+ }).slice(-20);
5547
+ }
5548
+ /** One line under a note, when it has text. */
5549
+ function normalizeReply(value) {
5550
+ if (typeof value !== "object" || value === null) return void 0;
5551
+ const record = value;
5552
+ const body = text(record.text, MAX_POINT_CHARS);
5553
+ if (body === void 0) return void 0;
5554
+ const sessionId = text(record.sessionId, 200);
5555
+ return {
5556
+ by: record.by === "agent" ? "agent" : "user",
5557
+ text: body,
5558
+ ...sessionId === void 0 ? {} : { sessionId },
5559
+ ...record.editedFrom === "agent" ? { editedFrom: "agent" } : {},
5560
+ at: stamp(record.at)
3332
5561
  };
3333
- if (hostOf(requestedUrl) !== host) return {
3334
- code: "redirected",
3335
- message: `the address redirected to ${finalUrl}, which yielded no readable page`
5562
+ }
5563
+ /** Bound a discussion's points: trimmed, capped, ¶ kept only when it is a positive integer. */
5564
+ function boundPoints(points) {
5565
+ const out = [];
5566
+ for (const item of points) {
5567
+ if (typeof item !== "object" || item === null) continue;
5568
+ const record = item;
5569
+ const body = text(record.text, MAX_POINT_CHARS);
5570
+ if (body === void 0) continue;
5571
+ const para = typeof record.para === "number" && Number.isInteger(record.para) && record.para > 0 ? record.para : void 0;
5572
+ out.push({
5573
+ text: body,
5574
+ ...para === void 0 ? {} : { para }
5575
+ });
5576
+ if (out.length >= 30) break;
5577
+ }
5578
+ return out;
5579
+ }
5580
+ /** One discussion note, when it has an id and at least one point. */
5581
+ function normalizeDiscussion(value) {
5582
+ if (typeof value !== "object" || value === null) return void 0;
5583
+ const record = value;
5584
+ const id = text(record.id, 20);
5585
+ const points = Array.isArray(record.points) ? boundPoints(record.points) : [];
5586
+ if (id === void 0 || points.length === 0) return void 0;
5587
+ const sessionId = text(record.sessionId, 200);
5588
+ const aside = text(record.aside, 300);
5589
+ return {
5590
+ id,
5591
+ kind: record.kind === "check" ? "check" : "discussion",
5592
+ ...sessionId === void 0 ? {} : { sessionId },
5593
+ title: text(record.title, 200) ?? "",
5594
+ points,
5595
+ ...aside === void 0 ? {} : { aside },
5596
+ ...normalizeSynced(record.syncedTo).length === 0 ? {} : { syncedTo: normalizeSynced(record.syncedTo) },
5597
+ createdAt: stamp(record.createdAt)
3336
5598
  };
5599
+ }
5600
+ /** One article's notes, when it has an id and a title. */
5601
+ function normalizeEntry(entryId, value) {
5602
+ if (typeof value !== "object" || value === null) return void 0;
5603
+ const record = value;
5604
+ const title = text(record.title, 300) ?? entryId;
5605
+ const notes = Array.isArray(record.notes) ? record.notes.map(normalizeNote).filter((note) => note !== void 0).slice(0, 500) : [];
5606
+ const sessions = Array.isArray(record.sessions) ? record.sessions.flatMap((item) => {
5607
+ if (typeof item !== "object" || item === null) return [];
5608
+ const row = item;
5609
+ const sessionId = text(row.sessionId, 200);
5610
+ if (sessionId === void 0) return [];
5611
+ const sessionTitle = text(row.title, 200);
5612
+ return [{
5613
+ sessionId,
5614
+ ...sessionTitle === void 0 ? {} : { title: sessionTitle },
5615
+ lastAt: stamp(row.lastAt)
5616
+ }];
5617
+ }).slice(0, 50) : [];
5618
+ const discussions = Array.isArray(record.discussions) ? record.discussions.map(normalizeDiscussion).filter((item) => item !== void 0).slice(0, 100) : [];
5619
+ if (notes.length === 0 && sessions.length === 0 && discussions.length === 0) return void 0;
5620
+ const highest = notes.reduce((max, note) => Math.max(max, Number(/^n(\d+)$/.exec(note.id)?.[1] ?? 0)), 0);
5621
+ const stored = typeof record.nextNote === "number" && Number.isInteger(record.nextNote) ? record.nextNote : 1;
5622
+ const highestDiscussion = discussions.reduce((max, item) => Math.max(max, Number(/^d(\d+)$/.exec(item.id)?.[1] ?? 0)), 0);
5623
+ const storedDiscussion = typeof record.nextDiscussion === "number" && Number.isInteger(record.nextDiscussion) ? record.nextDiscussion : 1;
5624
+ const url = text(record.url, 2e3);
5625
+ const source = text(record.source, 200);
3337
5626
  return {
3338
- code: "empty",
3339
- message: `the page yielded ${text} characters of text (${finalUrl})`
5627
+ entryId,
5628
+ title,
5629
+ ...url === void 0 ? {} : { url },
5630
+ ...source === void 0 ? {} : { source },
5631
+ notes,
5632
+ sessions,
5633
+ ...discussions.length === 0 ? {} : { discussions },
5634
+ nextNote: Math.max(stored, highest + 1),
5635
+ nextDiscussion: Math.max(storedDiscussion, highestDiscussion + 1),
5636
+ updatedAt: stamp(record.updatedAt)
3340
5637
  };
3341
5638
  }
3342
5639
  /**
3343
- * Classify a thrown fetch failure into the reason the reader sees.
3344
- *
3345
- * Everything here is per-case diagnosable: the seam's own message is kept
3346
- * verbatim, and only the CODE decides which sentence the browser shows and
3347
- * whether an automatic retry is allowed.
5640
+ * Coerce a parsed value into a notes document, dropping what it cannot read.
3348
5641
  *
3349
- * @param error - the value the fetch threw.
3350
- * @param at - the failure instant (ISO-8601).
3351
- * @returns the recorded failure.
5642
+ * @param value - the parsed file.
5643
+ * @returns the document.
3352
5644
  */
3353
- function classifyFetchFailure(error, at) {
3354
- const message = errorMessage(error);
3355
- const code = errorCode(error);
3356
- if (code === "WEB_UNSUPPORTED_CONTENT_TYPE" || /unsupported content type/i.test(message)) return {
3357
- code: "unsupported-type",
3358
- message,
3359
- at
3360
- };
3361
- if (code === "WEB_REDIRECT_BLOCKED" || /cross-origin redirect/i.test(message)) {
3362
- const origin = /cross-origin redirect to (\S+)/i.exec(message)?.[1];
3363
- return {
3364
- code: origin !== void 0 && LOGIN_HOST_PATTERN.test(hostOf(origin)) ? "login" : "redirected",
3365
- message,
3366
- at
5645
+ function normalizeNotesDoc(value) {
5646
+ if (typeof value !== "object" || value === null) return emptyNotesDoc();
5647
+ const record = value;
5648
+ const entries = {};
5649
+ if (typeof record.entries === "object" && record.entries !== null) for (const [entryId, entry] of Object.entries(record.entries)) {
5650
+ if (entryId.length === 0) continue;
5651
+ const normalized = normalizeEntry(entryId, entry);
5652
+ if (normalized !== void 0) entries[entryId] = normalized;
5653
+ }
5654
+ const snapshots = {};
5655
+ if (typeof record.snapshots === "object" && record.snapshots !== null) for (const [entryId, row] of Object.entries(record.snapshots)) {
5656
+ if (typeof row !== "object" || row === null) continue;
5657
+ const item = row;
5658
+ const file = typeof item.file === "string" && /^[0-9a-f]{32}\.json$/.test(item.file) ? item.file : void 0;
5659
+ if (file === void 0) continue;
5660
+ snapshots[entryId] = {
5661
+ file,
5662
+ chars: typeof item.chars === "number" && Number.isFinite(item.chars) ? item.chars : 0,
5663
+ capturedAt: stamp(item.capturedAt)
3367
5664
  };
3368
5665
  }
3369
- const status = /\bHTTP (\d{3})\b/.exec(message)?.[1];
3370
- if (status === "401" || status === "403") return {
3371
- code: "blocked",
3372
- message,
3373
- at
3374
- };
3375
- if (status !== void 0) return {
3376
- code: "http",
3377
- message,
3378
- at
3379
- };
3380
5666
  return {
3381
- code: "unreachable",
3382
- message,
3383
- at
5667
+ version: 1,
5668
+ entries,
5669
+ snapshots
3384
5670
  };
3385
5671
  }
3386
- /** The host of a URL, or an empty string when it cannot be parsed. */
3387
- function hostOf(url) {
3388
- try {
3389
- return new URL(url).hostname.toLowerCase();
3390
- } catch {
3391
- return "";
3392
- }
3393
- }
3394
5672
  /**
3395
- * How much visible text a payload carries.
3396
- *
3397
- * Script, style and markup are stripped; entities collapse to a single space.
3398
- * This is a measurement, not a parser: its only job is to separate "a page"
3399
- * from "an interstitial that is technically HTML".
5673
+ * Bound a snapshot's paragraphs: whitespace collapsed, empty blocks kept as
5674
+ * empty strings (so ¶ numbers stay aligned with the pane's blocks), and the
5675
+ * whole capped — cut at a paragraph boundary, with the cut reported.
3400
5676
  *
3401
- * @param raw - the decoded payload.
3402
- * @returns the visible character count.
5677
+ * @param paragraphs - the blocks' original text.
5678
+ * @returns the bounded paragraphs and whether anything was dropped.
3403
5679
  */
3404
- function visibleTextLength(raw) {
3405
- 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;
5680
+ function boundParagraphs(paragraphs) {
5681
+ const out = [];
5682
+ let total = 0;
5683
+ for (const raw of paragraphs) {
5684
+ const block = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
5685
+ if (total + block.length > 4e5) return {
5686
+ paragraphs: out,
5687
+ truncated: true
5688
+ };
5689
+ out.push(block);
5690
+ total += block.length;
5691
+ }
5692
+ return {
5693
+ paragraphs: out,
5694
+ truncated: false
5695
+ };
3406
5696
  }
3407
- /** The same source with any recorded preview failure removed. */
3408
- function withoutFailure(source) {
3409
- const { failure: _removed, ...rest } = source;
3410
- return rest;
5697
+ /** The bare file name one entry's snapshot lives under. */
5698
+ function snapshotFileName(entryId) {
5699
+ return `${createHash("sha1").update(entryId).digest("hex").slice(0, 32)}.json`;
3411
5700
  }
3412
- /** Project one source into the browser-facing summary. */
3413
- function summarize$1(source) {
5701
+ /** A native fs failure's message, with its code. */
5702
+ function errorMessage$1(error) {
5703
+ const code = error?.code;
5704
+ const message = error instanceof Error ? error.message : String(error);
5705
+ return code === void 0 ? message : `${code}: ${message}`;
5706
+ }
5707
+ /** Write a file atomically (temp file, then rename). */
5708
+ function writeAtomic(target, content) {
5709
+ const temporary = `${target}.${process.pid}.tmp`;
5710
+ try {
5711
+ writeFileSync(temporary, content);
5712
+ renameSync(temporary, target);
5713
+ } catch (error) {
5714
+ rmSync(temporary, { force: true });
5715
+ throw new Error(`reader: ${target} could not be written — ${errorMessage$1(error)}`);
5716
+ }
5717
+ }
5718
+ /** The entry row a write lands on: the existing one refreshed with the latest identity, or a new one. */
5719
+ function entryFor(doc, identity, now) {
5720
+ const existing = doc.entries[identity.entryId];
5721
+ const title = identity.title.trim().slice(0, 300) || existing?.title || identity.entryId;
5722
+ const url = identity.url ?? existing?.url;
5723
+ const source = identity.source ?? existing?.source;
3414
5724
  return {
3415
- id: source.id,
3416
- kind: source.kind,
3417
- url: source.url,
3418
- label: source.label ?? defaultSourceLabel(source.url),
3419
- enabled: source.enabled,
3420
- addedAt: source.addedAt,
3421
- hasBody: source.raw !== void 0,
3422
- ...source.fetchedAt !== void 0 ? { fetchedAt: source.fetchedAt } : {},
3423
- ...source.status !== void 0 ? { status: source.status } : {},
3424
- ...source.error !== void 0 ? { error: source.error } : {},
3425
- ...source.truncated === true ? { truncated: true } : {},
3426
- ...source.failure === void 0 ? {} : { failure: {
3427
- code: source.failure.code,
3428
- message: source.failure.message
3429
- } },
3430
- ...source.resolvedFrom === void 0 ? {} : { resolvedFrom: source.resolvedFrom }
5725
+ entryId: identity.entryId,
5726
+ title,
5727
+ ...url === void 0 || url === "" ? {} : { url },
5728
+ ...source === void 0 || source === "" ? {} : { source },
5729
+ notes: existing?.notes ?? [],
5730
+ sessions: existing?.sessions ?? [],
5731
+ ...existing?.discussions === void 0 ? {} : { discussions: existing.discussions },
5732
+ nextNote: existing?.nextNote ?? 1,
5733
+ nextDiscussion: existing?.nextDiscussion ?? 1,
5734
+ updatedAt: now
3431
5735
  };
3432
5736
  }
3433
- /**
3434
- * The same annotation with no `tagIds` key at all.
3435
- *
3436
- * Under `exactOptionalPropertyTypes` an explicit `undefined` is not the same as
3437
- * an absent optional field, and this document is serialized straight to disk —
3438
- * so an empty tag list must be an ABSENT key, not a null one.
3439
- *
3440
- * @param entry - the annotation to strip.
3441
- * @returns the annotation without its tag list.
3442
- */
3443
- function withoutTags(entry) {
3444
- const { tagIds: _removed, ...rest } = entry;
3445
- return rest;
5737
+ /** The document with one entry replaced (or removed, when it no longer carries anything). */
5738
+ function withEntry(doc, entry) {
5739
+ const { [entry.entryId]: _previous, ...rest } = doc.entries;
5740
+ return entry.notes.length === 0 && entry.sessions.length === 0 && (entry.discussions ?? []).length === 0 ? {
5741
+ ...doc,
5742
+ entries: rest
5743
+ } : {
5744
+ ...doc,
5745
+ entries: {
5746
+ ...rest,
5747
+ [entry.entryId]: entry
5748
+ }
5749
+ };
3446
5750
  }
3447
- /**
3448
- * Drop annotations that carry nothing, so the document does not accumulate
3449
- * empty objects as tags are removed.
3450
- *
3451
- * @param annotations - the table to clean.
3452
- * @returns the table with empty entries removed.
3453
- */
3454
- function cleanupAnnotations(annotations) {
3455
- const out = {};
3456
- for (const [entryId, entry] of Object.entries(annotations)) {
3457
- 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;
3458
- out[entryId] = (entry.tagIds ?? []).length > 0 ? entry : withoutTags(entry);
5751
+ /** The reader's notes store. */
5752
+ var ReaderNotesStore = class {
5753
+ root;
5754
+ file;
5755
+ snapshotsDir;
5756
+ unwritable;
5757
+ /** The document of record when the root is not writable. */
5758
+ memoryDoc;
5759
+ /** Snapshots kept in memory when the root is not writable. */
5760
+ memorySnapshots = /* @__PURE__ */ new Map();
5761
+ /** The write queue: every mutation runs after the previous one settled. */
5762
+ queue = Promise.resolve();
5763
+ /** The agent's 润色 translations, per article (keyed to the snapshot's text). */
5764
+ polish;
5765
+ /**
5766
+ * @param config - the same state-root override the reader's main store takes.
5767
+ */
5768
+ constructor(config = {}) {
5769
+ this.root = resolveReaderStateRoot(config.stateRoot);
5770
+ this.file = join(this.root, READER_NOTES_FILE_NAME);
5771
+ this.snapshotsDir = join(this.root, READER_SNAPSHOTS_DIR);
5772
+ this.polish = new ReaderPolishStore(this.root);
5773
+ }
5774
+ /** An article's 润色 layer, when it belongs to the text captured now. */
5775
+ polishView(entryId) {
5776
+ const snapshot = this.readSnapshot(entryId);
5777
+ return this.polish.view(entryId, snapshot === void 0 ? void 0 : sourceKey(snapshot.paragraphs));
5778
+ }
5779
+ /** Turn one paragraph back to the machine translation, or to the 润色 again. */
5780
+ setPolishOff(entryId, paragraph, off) {
5781
+ const snapshot = this.readSnapshot(entryId);
5782
+ return this.polish.setOff(entryId, snapshot === void 0 ? void 0 : sourceKey(snapshot.paragraphs), paragraph, off);
5783
+ }
5784
+ /** Whether the notes persist (false means memory-only, for this process). */
5785
+ get available() {
5786
+ if (this.unwritable !== void 0) return false;
5787
+ try {
5788
+ mkdirSync(this.root, { recursive: true });
5789
+ return true;
5790
+ } catch (error) {
5791
+ this.unwritable = `${this.root}: ${errorMessage$1(error)}`;
5792
+ return false;
5793
+ }
5794
+ }
5795
+ /** Read the document; a missing or unreadable file reads as empty. */
5796
+ read() {
5797
+ if (!this.available) return this.memoryDoc ?? emptyNotesDoc();
5798
+ try {
5799
+ return normalizeNotesDoc(JSON.parse(readFileSync(this.file, "utf8")));
5800
+ } catch (error) {
5801
+ if (error.code === "ENOENT") return emptyNotesDoc();
5802
+ throw new Error(`reader: ${this.file} does not parse — fix or remove it by hand`);
5803
+ }
5804
+ }
5805
+ /** Run one read-modify-write after every earlier one. */
5806
+ update(mutate) {
5807
+ const run = this.queue.then(() => {
5808
+ const { doc, result } = mutate(this.read());
5809
+ if (this.available) writeAtomic(this.file, `${JSON.stringify(doc, null, 2)}\n`);
5810
+ else this.memoryDoc = doc;
5811
+ return result;
5812
+ });
5813
+ this.queue = run.catch(() => void 0);
5814
+ return run;
5815
+ }
5816
+ /** One article's notes and discussions, oldest note first. */
5817
+ entry(entryId) {
5818
+ return this.read().entries[entryId];
5819
+ }
5820
+ /** Every article that carries notes or discussions. */
5821
+ entries() {
5822
+ return Object.values(this.read().entries);
5823
+ }
5824
+ /** How many notes each article carries (the wall's badge). */
5825
+ counts() {
5826
+ const counts = {};
5827
+ for (const entry of Object.values(this.read().entries)) if (entry.notes.length > 0) counts[entry.entryId] = entry.notes.length;
5828
+ return counts;
5829
+ }
5830
+ /**
5831
+ * Keep one excerpt.
5832
+ *
5833
+ * @param input - the article, the excerpt and where it sits, and the comment.
5834
+ * @returns the new note, or why none was kept.
5835
+ */
5836
+ addNote(input) {
5837
+ const quote = input.quote.replace(/\s+/g, " ").trim().slice(0, MAX_QUOTE_CHARS);
5838
+ if (quote.length === 0 || input.entryId.length === 0) return Promise.resolve("empty");
5839
+ return this.update((doc) => {
5840
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5841
+ const entry = entryFor(doc, input, now);
5842
+ if (entry.notes.length >= 500) return {
5843
+ doc,
5844
+ result: "full"
5845
+ };
5846
+ const translated = input.translated?.replace(/\s+/g, " ").trim().slice(0, MAX_QUOTE_CHARS);
5847
+ const comment = input.comment?.trim().slice(0, MAX_COMMENT_CHARS);
5848
+ const note = {
5849
+ id: `n${String(entry.nextNote)}`,
5850
+ anchor: {
5851
+ block: Number.isInteger(input.block) && input.block >= 0 ? input.block : 0,
5852
+ ...input.start !== void 0 && Number.isInteger(input.start) && input.start >= 0 ? { start: input.start } : {},
5853
+ quote,
5854
+ ...translated === void 0 || translated === "" ? {} : { translated },
5855
+ ...input.time !== void 0 && Number.isFinite(input.time) && input.time >= 0 ? { time: input.time } : {}
5856
+ },
5857
+ ...comment === void 0 || comment === "" ? {} : { comment },
5858
+ createdAt: now,
5859
+ updatedAt: now
5860
+ };
5861
+ const notes = [...entry.notes, note].sort((left, right) => left.anchor.block - right.anchor.block || (left.anchor.start ?? 0) - (right.anchor.start ?? 0));
5862
+ return {
5863
+ doc: withEntry(doc, {
5864
+ ...entry,
5865
+ notes,
5866
+ nextNote: entry.nextNote + 1
5867
+ }),
5868
+ result: note
5869
+ };
5870
+ });
5871
+ }
5872
+ /** Change (or clear) one note's comment. */
5873
+ updateNote(request) {
5874
+ return this.update((doc) => {
5875
+ const entry = doc.entries[request.entryId];
5876
+ const note = entry?.notes.find((item) => item.id === request.noteId);
5877
+ if (entry === void 0 || note === void 0) return {
5878
+ doc,
5879
+ result: "not-found"
5880
+ };
5881
+ const comment = request.comment.trim().slice(0, MAX_COMMENT_CHARS);
5882
+ const { comment: _old, ...rest } = note;
5883
+ const next = {
5884
+ ...rest,
5885
+ ...comment === "" ? {} : { comment },
5886
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5887
+ };
5888
+ const notes = entry.notes.map((item) => item.id === note.id ? next : item);
5889
+ return {
5890
+ doc: withEntry(doc, {
5891
+ ...entry,
5892
+ notes,
5893
+ updatedAt: next.updatedAt
5894
+ }),
5895
+ result: "ok"
5896
+ };
5897
+ });
5898
+ }
5899
+ /** Delete one note (its id is not handed out again). */
5900
+ deleteNote(request) {
5901
+ return this.update((doc) => {
5902
+ const entry = doc.entries[request.entryId];
5903
+ if (entry === void 0 || !entry.notes.some((item) => item.id === request.noteId)) return {
5904
+ doc,
5905
+ result: "not-found"
5906
+ };
5907
+ const notes = entry.notes.filter((item) => item.id !== request.noteId);
5908
+ return {
5909
+ doc: withEntry(doc, {
5910
+ ...entry,
5911
+ notes,
5912
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5913
+ }),
5914
+ result: "ok"
5915
+ };
5916
+ });
5917
+ }
5918
+ /**
5919
+ * Add one line under a note: a conclusion (by the model, when the reader asked
5920
+ * for it) or the reader's own addition.
5921
+ *
5922
+ * @param request - the article, the note, the line and who wrote it.
5923
+ */
5924
+ addReply(request) {
5925
+ const body = request.text.trim().slice(0, MAX_POINT_CHARS);
5926
+ if (body.length === 0) return Promise.resolve("empty");
5927
+ return this.update((doc) => {
5928
+ const entry = doc.entries[request.entryId];
5929
+ const note = entry?.notes.find((item) => item.id === request.noteId);
5930
+ if (entry === void 0 || note === void 0) return {
5931
+ doc,
5932
+ result: "not-found"
5933
+ };
5934
+ const replies = note.replies ?? [];
5935
+ if (replies.length >= 50) return {
5936
+ doc,
5937
+ result: "full"
5938
+ };
5939
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5940
+ const reply = {
5941
+ by: request.by,
5942
+ text: body,
5943
+ ...request.sessionId === void 0 || request.sessionId === "" ? {} : { sessionId: request.sessionId },
5944
+ at: now
5945
+ };
5946
+ const notes = entry.notes.map((item) => item.id === note.id ? {
5947
+ ...item,
5948
+ replies: [...replies, reply],
5949
+ updatedAt: now
5950
+ } : item);
5951
+ return {
5952
+ doc: withEntry(doc, {
5953
+ ...entry,
5954
+ notes,
5955
+ updatedAt: now
5956
+ }),
5957
+ result: "ok"
5958
+ };
5959
+ });
5960
+ }
5961
+ /**
5962
+ * Rewrite (or, with empty text, remove) one line under a note. A line the
5963
+ * model wrote becomes the reader's, marked as rewritten from the model's.
5964
+ */
5965
+ editReply(request) {
5966
+ return this.update((doc) => {
5967
+ const entry = doc.entries[request.entryId];
5968
+ const note = entry?.notes.find((item) => item.id === request.noteId);
5969
+ const replies = note?.replies ?? [];
5970
+ const old = replies[request.index];
5971
+ if (entry === void 0 || note === void 0 || old === void 0) return {
5972
+ doc,
5973
+ result: "not-found"
5974
+ };
5975
+ const body = request.text.trim().slice(0, MAX_POINT_CHARS);
5976
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5977
+ const next = body === "" ? replies.filter((_, index) => index !== request.index) : replies.map((item, index) => index !== request.index ? item : {
5978
+ ...item,
5979
+ by: "user",
5980
+ text: body,
5981
+ ...item.by === "agent" || item.editedFrom === "agent" ? { editedFrom: "agent" } : {},
5982
+ at: now
5983
+ });
5984
+ const { replies: _old, ...rest } = note;
5985
+ const notes = entry.notes.map((item) => item.id === note.id ? {
5986
+ ...rest,
5987
+ ...next.length === 0 ? {} : { replies: next },
5988
+ updatedAt: now
5989
+ } : item);
5990
+ return {
5991
+ doc: withEntry(doc, {
5992
+ ...entry,
5993
+ notes,
5994
+ updatedAt: now
5995
+ }),
5996
+ result: "ok"
5997
+ };
5998
+ });
5999
+ }
6000
+ /**
6001
+ * Keep one discussion note (newest first). The conversation it came from is
6002
+ * remembered on the article too.
6003
+ *
6004
+ * @param input - the article, the kind, the points.
6005
+ * @returns the new discussion note, or why none was kept.
6006
+ */
6007
+ addDiscussion(input) {
6008
+ const points = boundPoints(input.points);
6009
+ if (points.length === 0 || input.entryId.length === 0) return Promise.resolve("empty");
6010
+ return this.update((doc) => {
6011
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6012
+ const entry = entryFor(doc, input, now);
6013
+ const number = entry.nextDiscussion ?? 1;
6014
+ const aside = input.aside?.trim().slice(0, 300);
6015
+ const sessionId = input.sessionId?.trim();
6016
+ const discussion = {
6017
+ id: `d${String(number)}`,
6018
+ kind: input.kind === "check" ? "check" : "discussion",
6019
+ ...sessionId === void 0 || sessionId === "" ? {} : { sessionId },
6020
+ title: input.heading?.trim().slice(0, 200) ?? "",
6021
+ points,
6022
+ ...aside === void 0 || aside === "" ? {} : { aside },
6023
+ createdAt: now
6024
+ };
6025
+ const sessions = sessionId === void 0 || sessionId === "" ? entry.sessions : [entry.sessions.find((item) => item.sessionId === sessionId) === void 0 ? {
6026
+ sessionId,
6027
+ lastAt: now
6028
+ } : {
6029
+ ...entry.sessions.find((item) => item.sessionId === sessionId),
6030
+ lastAt: now
6031
+ }, ...entry.sessions.filter((item) => item.sessionId !== sessionId)].slice(0, 50);
6032
+ const discussions = [discussion, ...entry.discussions ?? []].slice(0, 100);
6033
+ return {
6034
+ doc: withEntry(doc, {
6035
+ ...entry,
6036
+ sessions,
6037
+ discussions,
6038
+ nextDiscussion: number + 1
6039
+ }),
6040
+ result: discussion
6041
+ };
6042
+ });
6043
+ }
6044
+ /**
6045
+ * Remember that a note (or a discussion note) became a canvas card, so the
6046
+ * notes face can say where it went and not offer the same sync twice blindly.
6047
+ */
6048
+ markSynced(request) {
6049
+ return this.update((doc) => {
6050
+ const entry = doc.entries[request.entryId];
6051
+ if (entry === void 0) return {
6052
+ doc,
6053
+ result: "not-found"
6054
+ };
6055
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6056
+ const card = {
6057
+ canvasId: request.canvasId,
6058
+ cardId: request.cardId,
6059
+ canvasTitle: request.canvasTitle.slice(0, 200),
6060
+ at: now
6061
+ };
6062
+ if (request.noteId !== void 0) {
6063
+ if (!entry.notes.some((item) => item.id === request.noteId)) return {
6064
+ doc,
6065
+ result: "not-found"
6066
+ };
6067
+ const notes = entry.notes.map((item) => item.id === request.noteId ? {
6068
+ ...item,
6069
+ syncedTo: [...item.syncedTo ?? [], card].slice(-20)
6070
+ } : item);
6071
+ return {
6072
+ doc: withEntry(doc, {
6073
+ ...entry,
6074
+ notes
6075
+ }),
6076
+ result: "ok"
6077
+ };
6078
+ }
6079
+ const discussions = entry.discussions ?? [];
6080
+ if (request.discussionId === void 0 || !discussions.some((item) => item.id === request.discussionId)) return {
6081
+ doc,
6082
+ result: "not-found"
6083
+ };
6084
+ const next = discussions.map((item) => item.id === request.discussionId ? {
6085
+ ...item,
6086
+ syncedTo: [...item.syncedTo ?? [], card].slice(-20)
6087
+ } : item);
6088
+ return {
6089
+ doc: withEntry(doc, {
6090
+ ...entry,
6091
+ discussions: next
6092
+ }),
6093
+ result: "ok"
6094
+ };
6095
+ });
6096
+ }
6097
+ /** Delete one discussion note. */
6098
+ deleteDiscussion(request) {
6099
+ return this.update((doc) => {
6100
+ const entry = doc.entries[request.entryId];
6101
+ if (entry === void 0 || !(entry.discussions ?? []).some((item) => item.id === request.discussionId)) return {
6102
+ doc,
6103
+ result: "not-found"
6104
+ };
6105
+ const discussions = (entry.discussions ?? []).filter((item) => item.id !== request.discussionId);
6106
+ return {
6107
+ doc: withEntry(doc, {
6108
+ ...entry,
6109
+ discussions,
6110
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6111
+ }),
6112
+ result: "ok"
6113
+ };
6114
+ });
3459
6115
  }
3460
- return out;
3461
- }
3462
- /**
3463
- * Parse a translation sidecar's segment map, tolerating a damaged file.
3464
- *
3465
- * @param raw - the file's content, or `undefined` when it is gone.
3466
- * @returns the map, or `undefined` when the file holds none.
3467
- */
3468
- function readSegmentsFile(raw) {
3469
- if (raw === void 0) return void 0;
3470
- try {
3471
- const parsed = JSON.parse(raw);
3472
- if (typeof parsed !== "object" || parsed === null) return void 0;
3473
- return Object.fromEntries(Object.entries(parsed).filter(([, target]) => typeof target === "string"));
3474
- } catch {
3475
- return;
6116
+ /**
6117
+ * The article this conversation discussed most recently, when there is one —
6118
+ * where a model's write lands when it names no article.
6119
+ */
6120
+ sessionEntry(sessionId) {
6121
+ let best;
6122
+ for (const entry of Object.values(this.read().entries)) {
6123
+ const row = entry.sessions.find((item) => item.sessionId === sessionId);
6124
+ if (row !== void 0 && (best === void 0 || row.lastAt > best.at)) best = {
6125
+ entryId: entry.entryId,
6126
+ at: row.lastAt
6127
+ };
6128
+ }
6129
+ return best?.entryId;
3476
6130
  }
3477
- }
3478
- /**
3479
- * Coerce one batch of learned sentences: bounded in count and in size, each
3480
- * carrying its hash and both texts.
3481
- *
3482
- * @param entries - the client's batch.
3483
- * @returns the entries worth storing.
3484
- */
3485
- function normalizeLearns(entries) {
3486
- const out = [];
3487
- for (const entry of entries.slice(0, TRANSLATION_WRITE_LIMIT)) {
3488
- if (typeof entry.hash !== "string" || entry.hash.length === 0 || entry.hash.length > 64) continue;
3489
- if (typeof entry.source !== "string" || typeof entry.target !== "string") continue;
3490
- if (entry.source.length === 0 || entry.target.length === 0) continue;
3491
- out.push({
3492
- hash: entry.hash,
3493
- source: entry.source.slice(0, TRANSLATION_TEXT_LIMIT),
3494
- target: entry.target.slice(0, TRANSLATION_TEXT_LIMIT)
6131
+ /**
6132
+ * Remember that a conversation discussed this article (most recent first).
6133
+ *
6134
+ * @param request - the article and the conversation.
6135
+ */
6136
+ recordSession(request) {
6137
+ const sessionId = request.sessionId.trim();
6138
+ if (sessionId.length === 0 || request.entryId.length === 0) return Promise.resolve("empty");
6139
+ return this.update((doc) => {
6140
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6141
+ const entry = entryFor(doc, request, now);
6142
+ const previous = entry.sessions.find((item) => item.sessionId === sessionId);
6143
+ const title = request.sessionTitle?.trim().slice(0, 200) || previous?.title;
6144
+ const sessions = [{
6145
+ sessionId,
6146
+ ...title === void 0 || title === "" ? {} : { title },
6147
+ lastAt: now
6148
+ }, ...entry.sessions.filter((item) => item.sessionId !== sessionId)].slice(0, 50);
6149
+ return {
6150
+ doc: withEntry(doc, {
6151
+ ...entry,
6152
+ sessions
6153
+ }),
6154
+ result: "ok"
6155
+ };
3495
6156
  });
3496
6157
  }
3497
- return out;
3498
- }
3499
- /** How old a `fetching` record may be before it is treated as abandoned. */
3500
- const FETCH_STALE_MS = 6e5;
3501
- /** Whether an ISO instant is recent enough to still mean "in flight". */
3502
- function isRecent(iso, now, windowMs) {
3503
- const at = new Date(iso).getTime();
3504
- return Number.isFinite(at) && now - at <= windowMs;
3505
- }
3506
- /** Whether a cache deadline is still in the future. */
3507
- function isFresh(expiresAt) {
3508
- const deadline = new Date(expiresAt).getTime();
3509
- return Number.isFinite(deadline) && deadline > Date.now();
3510
- }
6158
+ /**
6159
+ * Keep the article's text for the model, replacing the previous snapshot.
6160
+ *
6161
+ * Loose snapshots (articles with no notes and no discussions) are pruned to
6162
+ * the {@link MAX_LOOSE_SNAPSHOTS} most recent; the rest are kept as long as
6163
+ * their article carries anything.
6164
+ *
6165
+ * @param request - the article and its blocks' original text.
6166
+ */
6167
+ snapshot(request) {
6168
+ const { paragraphs, truncated } = boundParagraphs(request.paragraphs);
6169
+ if (request.entryId.length === 0 || paragraphs.every((item) => item.length === 0)) return Promise.resolve("empty");
6170
+ const snapshot = {
6171
+ entryId: request.entryId,
6172
+ title: request.title.trim().slice(0, 300) || request.entryId,
6173
+ ...request.url === void 0 || request.url === "" ? {} : { url: request.url },
6174
+ ...request.source === void 0 || request.source === "" ? {} : { source: request.source },
6175
+ paragraphs,
6176
+ ...truncated ? { truncated: true } : {},
6177
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
6178
+ };
6179
+ return this.update((doc) => {
6180
+ if (!this.available) {
6181
+ this.memorySnapshots.set(snapshot.entryId, snapshot);
6182
+ return {
6183
+ doc,
6184
+ result: "ok"
6185
+ };
6186
+ }
6187
+ mkdirSync(this.snapshotsDir, { recursive: true });
6188
+ const file = snapshotFileName(snapshot.entryId);
6189
+ writeAtomic(join(this.snapshotsDir, file), `${JSON.stringify(snapshot)}\n`);
6190
+ const chars = paragraphs.reduce((sum, item) => sum + item.length, 0);
6191
+ const snapshots = {
6192
+ ...doc.snapshots,
6193
+ [snapshot.entryId]: {
6194
+ file,
6195
+ chars,
6196
+ capturedAt: snapshot.capturedAt
6197
+ }
6198
+ };
6199
+ const loose = Object.entries(snapshots).filter(([entryId]) => doc.entries[entryId] === void 0).sort((left, right) => right[1].capturedAt.localeCompare(left[1].capturedAt));
6200
+ for (const [entryId, row] of loose.slice(30)) {
6201
+ delete snapshots[entryId];
6202
+ rmSync(join(this.snapshotsDir, row.file), { force: true });
6203
+ }
6204
+ return {
6205
+ doc: {
6206
+ ...doc,
6207
+ snapshots
6208
+ },
6209
+ result: "ok"
6210
+ };
6211
+ });
6212
+ }
6213
+ /** The article's text as last captured, when there is one. */
6214
+ readSnapshot(entryId) {
6215
+ if (!this.available) return this.memorySnapshots.get(entryId);
6216
+ const row = this.read().snapshots[entryId];
6217
+ if (row === void 0) return void 0;
6218
+ try {
6219
+ const parsed = JSON.parse(readFileSync(join(this.snapshotsDir, row.file), "utf8"));
6220
+ if (!Array.isArray(parsed.paragraphs)) return void 0;
6221
+ return {
6222
+ entryId,
6223
+ title: text(parsed.title, 300) ?? entryId,
6224
+ ...typeof parsed.url === "string" && parsed.url !== "" ? { url: parsed.url } : {},
6225
+ ...typeof parsed.source === "string" && parsed.source !== "" ? { source: parsed.source } : {},
6226
+ paragraphs: parsed.paragraphs.map((item) => typeof item === "string" ? item : ""),
6227
+ ...parsed.truncated === true ? { truncated: true } : {},
6228
+ capturedAt: stamp(parsed.capturedAt)
6229
+ };
6230
+ } catch {
6231
+ return;
6232
+ }
6233
+ }
6234
+ /** Every article with a snapshot, newest capture first. */
6235
+ snapshotIds() {
6236
+ if (!this.available) return [...this.memorySnapshots.keys()].reverse();
6237
+ return Object.entries(this.read().snapshots).sort((left, right) => right[1].capturedAt.localeCompare(left[1].capturedAt)).map(([entryId]) => entryId);
6238
+ }
6239
+ };
3511
6240
  //#endregion
3512
6241
  //#region lib/types/remote.js
3513
6242
  var __runInitializers = function(thisArg, initializers, value) {
@@ -3586,6 +6315,26 @@ let ReaderRemoteService = (() => {
3586
6315
  let _listRecent_decorators;
3587
6316
  let _clearRecent_decorators;
3588
6317
  let _quoteToSideChat_decorators;
6318
+ let _entryNotes_decorators;
6319
+ let _noteCounts_decorators;
6320
+ let _addNote_decorators;
6321
+ let _updateNote_decorators;
6322
+ let _deleteNote_decorators;
6323
+ let _recordDiscussion_decorators;
6324
+ let _addReply_decorators;
6325
+ let _editReply_decorators;
6326
+ let _markSynced_decorators;
6327
+ let _deleteDiscussion_decorators;
6328
+ let _mediaTranscript_decorators;
6329
+ let _startLocalTranscript_decorators;
6330
+ let _localTranscript_decorators;
6331
+ let _speakerNames_decorators;
6332
+ let _nameSpeaker_decorators;
6333
+ let _mediaStates_decorators;
6334
+ let _cancelLocalTranscript_decorators;
6335
+ let _polish_decorators;
6336
+ let _setPolishOff_decorators;
6337
+ let _snapshotEntry_decorators;
3589
6338
  return class ReaderRemoteService extends _classSuper {
3590
6339
  static {
3591
6340
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
@@ -3621,6 +6370,26 @@ let ReaderRemoteService = (() => {
3621
6370
  _listRecent_decorators = [Remote("listRecent")];
3622
6371
  _clearRecent_decorators = [Remote("clearRecent")];
3623
6372
  _quoteToSideChat_decorators = [Remote("quoteToSideChat")];
6373
+ _entryNotes_decorators = [Remote("entryNotes")];
6374
+ _noteCounts_decorators = [Remote("noteCounts")];
6375
+ _addNote_decorators = [Remote("addNote")];
6376
+ _updateNote_decorators = [Remote("updateNote")];
6377
+ _deleteNote_decorators = [Remote("deleteNote")];
6378
+ _recordDiscussion_decorators = [Remote("recordDiscussion")];
6379
+ _addReply_decorators = [Remote("addReply")];
6380
+ _editReply_decorators = [Remote("editReply")];
6381
+ _markSynced_decorators = [Remote("markSynced")];
6382
+ _deleteDiscussion_decorators = [Remote("deleteDiscussion")];
6383
+ _mediaTranscript_decorators = [Remote("mediaTranscript")];
6384
+ _startLocalTranscript_decorators = [Remote("startLocalTranscript")];
6385
+ _localTranscript_decorators = [Remote("localTranscript")];
6386
+ _speakerNames_decorators = [Remote("speakerNames")];
6387
+ _nameSpeaker_decorators = [Remote("nameSpeaker")];
6388
+ _mediaStates_decorators = [Remote("mediaStates")];
6389
+ _cancelLocalTranscript_decorators = [Remote("cancelLocalTranscript")];
6390
+ _polish_decorators = [Remote("polish")];
6391
+ _setPolishOff_decorators = [Remote("setPolishOff")];
6392
+ _snapshotEntry_decorators = [Remote("snapshotEntry")];
3624
6393
  __esDecorate(this, null, _capabilities_decorators, {
3625
6394
  kind: "method",
3626
6395
  name: "capabilities",
@@ -3973,6 +6742,226 @@ let ReaderRemoteService = (() => {
3973
6742
  },
3974
6743
  metadata: _metadata
3975
6744
  }, null, _instanceExtraInitializers);
6745
+ __esDecorate(this, null, _entryNotes_decorators, {
6746
+ kind: "method",
6747
+ name: "entryNotes",
6748
+ static: false,
6749
+ private: false,
6750
+ access: {
6751
+ has: (obj) => "entryNotes" in obj,
6752
+ get: (obj) => obj.entryNotes
6753
+ },
6754
+ metadata: _metadata
6755
+ }, null, _instanceExtraInitializers);
6756
+ __esDecorate(this, null, _noteCounts_decorators, {
6757
+ kind: "method",
6758
+ name: "noteCounts",
6759
+ static: false,
6760
+ private: false,
6761
+ access: {
6762
+ has: (obj) => "noteCounts" in obj,
6763
+ get: (obj) => obj.noteCounts
6764
+ },
6765
+ metadata: _metadata
6766
+ }, null, _instanceExtraInitializers);
6767
+ __esDecorate(this, null, _addNote_decorators, {
6768
+ kind: "method",
6769
+ name: "addNote",
6770
+ static: false,
6771
+ private: false,
6772
+ access: {
6773
+ has: (obj) => "addNote" in obj,
6774
+ get: (obj) => obj.addNote
6775
+ },
6776
+ metadata: _metadata
6777
+ }, null, _instanceExtraInitializers);
6778
+ __esDecorate(this, null, _updateNote_decorators, {
6779
+ kind: "method",
6780
+ name: "updateNote",
6781
+ static: false,
6782
+ private: false,
6783
+ access: {
6784
+ has: (obj) => "updateNote" in obj,
6785
+ get: (obj) => obj.updateNote
6786
+ },
6787
+ metadata: _metadata
6788
+ }, null, _instanceExtraInitializers);
6789
+ __esDecorate(this, null, _deleteNote_decorators, {
6790
+ kind: "method",
6791
+ name: "deleteNote",
6792
+ static: false,
6793
+ private: false,
6794
+ access: {
6795
+ has: (obj) => "deleteNote" in obj,
6796
+ get: (obj) => obj.deleteNote
6797
+ },
6798
+ metadata: _metadata
6799
+ }, null, _instanceExtraInitializers);
6800
+ __esDecorate(this, null, _recordDiscussion_decorators, {
6801
+ kind: "method",
6802
+ name: "recordDiscussion",
6803
+ static: false,
6804
+ private: false,
6805
+ access: {
6806
+ has: (obj) => "recordDiscussion" in obj,
6807
+ get: (obj) => obj.recordDiscussion
6808
+ },
6809
+ metadata: _metadata
6810
+ }, null, _instanceExtraInitializers);
6811
+ __esDecorate(this, null, _addReply_decorators, {
6812
+ kind: "method",
6813
+ name: "addReply",
6814
+ static: false,
6815
+ private: false,
6816
+ access: {
6817
+ has: (obj) => "addReply" in obj,
6818
+ get: (obj) => obj.addReply
6819
+ },
6820
+ metadata: _metadata
6821
+ }, null, _instanceExtraInitializers);
6822
+ __esDecorate(this, null, _editReply_decorators, {
6823
+ kind: "method",
6824
+ name: "editReply",
6825
+ static: false,
6826
+ private: false,
6827
+ access: {
6828
+ has: (obj) => "editReply" in obj,
6829
+ get: (obj) => obj.editReply
6830
+ },
6831
+ metadata: _metadata
6832
+ }, null, _instanceExtraInitializers);
6833
+ __esDecorate(this, null, _markSynced_decorators, {
6834
+ kind: "method",
6835
+ name: "markSynced",
6836
+ static: false,
6837
+ private: false,
6838
+ access: {
6839
+ has: (obj) => "markSynced" in obj,
6840
+ get: (obj) => obj.markSynced
6841
+ },
6842
+ metadata: _metadata
6843
+ }, null, _instanceExtraInitializers);
6844
+ __esDecorate(this, null, _deleteDiscussion_decorators, {
6845
+ kind: "method",
6846
+ name: "deleteDiscussion",
6847
+ static: false,
6848
+ private: false,
6849
+ access: {
6850
+ has: (obj) => "deleteDiscussion" in obj,
6851
+ get: (obj) => obj.deleteDiscussion
6852
+ },
6853
+ metadata: _metadata
6854
+ }, null, _instanceExtraInitializers);
6855
+ __esDecorate(this, null, _mediaTranscript_decorators, {
6856
+ kind: "method",
6857
+ name: "mediaTranscript",
6858
+ static: false,
6859
+ private: false,
6860
+ access: {
6861
+ has: (obj) => "mediaTranscript" in obj,
6862
+ get: (obj) => obj.mediaTranscript
6863
+ },
6864
+ metadata: _metadata
6865
+ }, null, _instanceExtraInitializers);
6866
+ __esDecorate(this, null, _startLocalTranscript_decorators, {
6867
+ kind: "method",
6868
+ name: "startLocalTranscript",
6869
+ static: false,
6870
+ private: false,
6871
+ access: {
6872
+ has: (obj) => "startLocalTranscript" in obj,
6873
+ get: (obj) => obj.startLocalTranscript
6874
+ },
6875
+ metadata: _metadata
6876
+ }, null, _instanceExtraInitializers);
6877
+ __esDecorate(this, null, _localTranscript_decorators, {
6878
+ kind: "method",
6879
+ name: "localTranscript",
6880
+ static: false,
6881
+ private: false,
6882
+ access: {
6883
+ has: (obj) => "localTranscript" in obj,
6884
+ get: (obj) => obj.localTranscript
6885
+ },
6886
+ metadata: _metadata
6887
+ }, null, _instanceExtraInitializers);
6888
+ __esDecorate(this, null, _speakerNames_decorators, {
6889
+ kind: "method",
6890
+ name: "speakerNames",
6891
+ static: false,
6892
+ private: false,
6893
+ access: {
6894
+ has: (obj) => "speakerNames" in obj,
6895
+ get: (obj) => obj.speakerNames
6896
+ },
6897
+ metadata: _metadata
6898
+ }, null, _instanceExtraInitializers);
6899
+ __esDecorate(this, null, _nameSpeaker_decorators, {
6900
+ kind: "method",
6901
+ name: "nameSpeaker",
6902
+ static: false,
6903
+ private: false,
6904
+ access: {
6905
+ has: (obj) => "nameSpeaker" in obj,
6906
+ get: (obj) => obj.nameSpeaker
6907
+ },
6908
+ metadata: _metadata
6909
+ }, null, _instanceExtraInitializers);
6910
+ __esDecorate(this, null, _mediaStates_decorators, {
6911
+ kind: "method",
6912
+ name: "mediaStates",
6913
+ static: false,
6914
+ private: false,
6915
+ access: {
6916
+ has: (obj) => "mediaStates" in obj,
6917
+ get: (obj) => obj.mediaStates
6918
+ },
6919
+ metadata: _metadata
6920
+ }, null, _instanceExtraInitializers);
6921
+ __esDecorate(this, null, _cancelLocalTranscript_decorators, {
6922
+ kind: "method",
6923
+ name: "cancelLocalTranscript",
6924
+ static: false,
6925
+ private: false,
6926
+ access: {
6927
+ has: (obj) => "cancelLocalTranscript" in obj,
6928
+ get: (obj) => obj.cancelLocalTranscript
6929
+ },
6930
+ metadata: _metadata
6931
+ }, null, _instanceExtraInitializers);
6932
+ __esDecorate(this, null, _polish_decorators, {
6933
+ kind: "method",
6934
+ name: "polish",
6935
+ static: false,
6936
+ private: false,
6937
+ access: {
6938
+ has: (obj) => "polish" in obj,
6939
+ get: (obj) => obj.polish
6940
+ },
6941
+ metadata: _metadata
6942
+ }, null, _instanceExtraInitializers);
6943
+ __esDecorate(this, null, _setPolishOff_decorators, {
6944
+ kind: "method",
6945
+ name: "setPolishOff",
6946
+ static: false,
6947
+ private: false,
6948
+ access: {
6949
+ has: (obj) => "setPolishOff" in obj,
6950
+ get: (obj) => obj.setPolishOff
6951
+ },
6952
+ metadata: _metadata
6953
+ }, null, _instanceExtraInitializers);
6954
+ __esDecorate(this, null, _snapshotEntry_decorators, {
6955
+ kind: "method",
6956
+ name: "snapshotEntry",
6957
+ static: false,
6958
+ private: false,
6959
+ access: {
6960
+ has: (obj) => "snapshotEntry" in obj,
6961
+ get: (obj) => obj.snapshotEntry
6962
+ },
6963
+ metadata: _metadata
6964
+ }, null, _instanceExtraInitializers);
3976
6965
  if (_metadata) Object.defineProperty(this, Symbol.metadata, {
3977
6966
  enumerable: true,
3978
6967
  configurable: true,
@@ -3980,7 +6969,7 @@ let ReaderRemoteService = (() => {
3980
6969
  value: _metadata
3981
6970
  });
3982
6971
  }
3983
- static inject = ["reader"];
6972
+ static inject = ["reader", "readerNotes"];
3984
6973
  constructor(ctx, _config = {}) {
3985
6974
  super(ctx, "readerRemote", { namespace: "reader" });
3986
6975
  __runInitializers(this, _instanceExtraInitializers);
@@ -3989,6 +6978,10 @@ let ReaderRemoteService = (() => {
3989
6978
  get core() {
3990
6979
  return this.ctx.reader;
3991
6980
  }
6981
+ /** The notes store this face delegates to. */
6982
+ get notes() {
6983
+ return this.ctx.readerNotes;
6984
+ }
3992
6985
  /** Capability handshake: what the browser may rely on in this composition. */
3993
6986
  capabilities() {
3994
6987
  return this.core.capabilities();
@@ -4120,6 +7113,94 @@ let ReaderRemoteService = (() => {
4120
7113
  quoteToSideChat(request) {
4121
7114
  return this.core.quoteToSideChat(request);
4122
7115
  }
7116
+ /** One article's notes and the conversations that discussed it. */
7117
+ async entryNotes(request) {
7118
+ const entry = this.notes.entry(request.entryId);
7119
+ return entry === void 0 ? {} : { entry };
7120
+ }
7121
+ /** How many notes each article carries. */
7122
+ async noteCounts() {
7123
+ return { counts: this.notes.counts() };
7124
+ }
7125
+ /** Keep one excerpt (and its comment). */
7126
+ addNote(request) {
7127
+ return this.notes.addNote(request);
7128
+ }
7129
+ /** Change or clear one note's comment. */
7130
+ updateNote(request) {
7131
+ return this.notes.updateNote(request);
7132
+ }
7133
+ /** Delete one note. */
7134
+ deleteNote(request) {
7135
+ return this.notes.deleteNote(request);
7136
+ }
7137
+ /** Remember that a conversation discussed this article. */
7138
+ recordDiscussion(request) {
7139
+ return this.notes.recordSession(request);
7140
+ }
7141
+ /** Add the reader's own line under a note. */
7142
+ addReply(request) {
7143
+ return this.notes.addReply({
7144
+ ...request,
7145
+ by: "user"
7146
+ });
7147
+ }
7148
+ /** Rewrite (or, with empty text, remove) one line under a note. */
7149
+ editReply(request) {
7150
+ return this.notes.editReply(request);
7151
+ }
7152
+ /** Remember that a note or a discussion note became a canvas card. */
7153
+ markSynced(request) {
7154
+ return this.notes.markSynced(request);
7155
+ }
7156
+ /** Delete one discussion note. */
7157
+ deleteDiscussion(request) {
7158
+ return this.notes.deleteDiscussion(request);
7159
+ }
7160
+ /** A video entry's transcript from its platform captions (stored; `none` when it has none). */
7161
+ mediaTranscript(request) {
7162
+ return this.core.mediaTranscript(request);
7163
+ }
7164
+ /**
7165
+ * Start (or rejoin) local transcription of an entry without captions.
7166
+ * Answers `unavailable` without the transcription plugin and `needs-models`
7167
+ * until the first model download is confirmed.
7168
+ */
7169
+ startLocalTranscript(request) {
7170
+ return this.core.local.start(request);
7171
+ }
7172
+ /** Where an entry's local transcription stands, with the transcript so far. */
7173
+ localTranscript(request) {
7174
+ return Promise.resolve(this.core.local.status(request.entryId));
7175
+ }
7176
+ /** The reader's names for a transcript's speakers, by index. */
7177
+ speakerNames(request) {
7178
+ return Promise.resolve(this.core.speakerNames(request));
7179
+ }
7180
+ /** Name one speaker of a transcript (empty: back to 说话人 A…); answers all the names. */
7181
+ nameSpeaker(request) {
7182
+ return Promise.resolve(this.core.nameSpeaker(request));
7183
+ }
7184
+ /** The wall's video / audio cards: what each one's transcript is (captions, local, transcribing…). */
7185
+ mediaStates(request) {
7186
+ return Promise.resolve(this.core.mediaStates(request));
7187
+ }
7188
+ /** Leave (withdraws a queued job) or, with `force`, stop an entry's local transcription. */
7189
+ cancelLocalTranscript(request) {
7190
+ return Promise.resolve(this.core.local.cancel(request.entryId, request.force));
7191
+ }
7192
+ /** The article's 润色 layer (the agent's translation), when there is one for its current text. */
7193
+ polish(request) {
7194
+ return Promise.resolve(this.notes.polishView(request.entryId) ?? null);
7195
+ }
7196
+ /** Show one paragraph's machine translation instead of the 润色 (or back). */
7197
+ setPolishOff(request) {
7198
+ return Promise.resolve(this.notes.setPolishOff(request.entryId, request.paragraph, request.off) ?? null);
7199
+ }
7200
+ /** Hand the host the article's original text, block by block, for the model to read. */
7201
+ snapshotEntry(request) {
7202
+ return this.notes.snapshot(request);
7203
+ }
4123
7204
  };
4124
7205
  })();
4125
7206
  //#endregion
@@ -4140,7 +7221,8 @@ const name = "reader";
4140
7221
  function apply(ctx, config = {}) {
4141
7222
  const service = new ReaderService(ctx, config);
4142
7223
  ctx.provide("reader", service);
7224
+ ctx.provide("readerNotes", new ReaderNotesStore(config));
4143
7225
  ctx.plugin(ReaderRemoteService, {});
4144
7226
  }
4145
7227
  //#endregion
4146
- 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 };
7228
+ export { COOKIE_HANDSHAKE_HOSTS, 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_COMMENT_CHARS, MAX_DISCUSSIONS_PER_ENTRY, MAX_LOOSE_SNAPSHOTS, MAX_NOTES_PER_ENTRY, MAX_POINTS, MAX_POINT_CHARS, MAX_QUOTE_CHARS, MAX_REPLIES_PER_NOTE, MAX_SESSIONS_PER_ENTRY, MAX_SNAPSHOT_CHARS, MAX_TAGS, MAX_TIMEOUT_MS, MAX_TOTAL_BODY_CHARS, MAX_TRANSLATION_MEMORY_ENTRIES, READER_NOTES_FILE_NAME, READER_SNAPSHOTS_DIR, READER_SOURCE_KINDS, ReaderNotesStore, ReaderRemoteService, ReaderService, ReaderStore, ReaderStoreError, STATE_ROOT_SEGMENT, TRANSLATION_STORAGE_VERSION, apply, boundAnnotations, boundParagraphs, boundPayloads, boundPoints, challengeTarget, classifyPayload, defaultSourceLabel, delayUntilNext, emptyNotesDoc, emptyStateDoc, errorMessage, isCatchUpDue, isCookieHandshake, isRetryablePreviewFailure, kindQuery, linkEntryId, name, nextOccurrence, normalizeNotesDoc, normalizeStateDoc, normalizeUrl, parseTimeOfDay, previousOccurrence, pruneOrphanTags, resolveReaderStateRoot, serializeStateDoc, stableEntryId, summarize };