@khorsheed/dsh-reader 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.en.md +26 -121
- package/README.i18n.yaml +3 -3
- package/README.md +26 -121
- package/cordis.patch.yml +7 -0
- package/lib/client.js +29105 -6692
- package/lib/index.js +3500 -321
- package/lib/typert.host.js +2145 -259
- package/lib/typert.remote-client.d.ts +97 -3
- package/lib/typert.remote-client.js +1590 -250
- package/lib/types/captions.d.ts +99 -0
- package/lib/types/captions.js +236 -0
- package/lib/types/client/MediaPlayer.d.ts +55 -0
- package/lib/types/client/MediaPlayer.js +106 -0
- package/lib/types/client/NotesView.d.ts +57 -0
- package/lib/types/client/NotesView.js +124 -0
- package/lib/types/client/ReaderPane.d.ts +20 -3
- package/lib/types/client/ReaderPane.js +860 -164
- package/lib/types/client/SpeakerLegend.d.ts +18 -0
- package/lib/types/client/SpeakerLegend.js +26 -0
- package/lib/types/client/canvas-sync.d.ts +84 -0
- package/lib/types/client/canvas-sync.js +59 -0
- package/lib/types/client/contract.d.ts +130 -3
- package/lib/types/client/extract-article.d.ts +38 -1
- package/lib/types/client/extract-article.js +561 -33
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/icons.js +1 -1
- package/lib/types/client/index.js +81 -2
- package/lib/types/client/locales.d.ts +279 -11
- package/lib/types/client/locales.js +278 -10
- package/lib/types/client/notes.d.ts +142 -0
- package/lib/types/client/notes.js +334 -0
- package/lib/types/client/parse-rss.js +40 -0
- package/lib/types/client/preset-visibility.d.ts +93 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/selectors.d.ts +5 -0
- package/lib/types/client/selectors.js +5 -0
- package/lib/types/client/session.d.ts +21 -1
- package/lib/types/client/session.js +12 -0
- package/lib/types/client/store.js +4 -1
- package/lib/types/client/translate.d.ts +26 -1
- package/lib/types/client/translate.js +29 -12
- package/lib/types/client/useLocalTranscript.d.ts +43 -0
- package/lib/types/client/useLocalTranscript.js +129 -0
- package/lib/types/client/useMedia.d.ts +68 -0
- package/lib/types/client/useMedia.js +299 -0
- package/lib/types/client/useMediaStates.d.ts +9 -0
- package/lib/types/client/useMediaStates.js +43 -0
- package/lib/types/client/useNotes.d.ts +110 -0
- package/lib/types/client/useNotes.js +423 -0
- package/lib/types/client/usePolish.d.ts +47 -0
- package/lib/types/client/usePolish.js +162 -0
- package/lib/types/document-fetch.d.ts +106 -0
- package/lib/types/document-fetch.js +100 -0
- package/lib/types/index.d.ts +15 -0
- package/lib/types/index.js +3 -0
- package/lib/types/local-transcribe.d.ts +171 -0
- package/lib/types/local-transcribe.js +473 -0
- package/lib/types/media-host.d.ts +91 -0
- package/lib/types/media-host.js +230 -0
- package/lib/types/media.d.ts +100 -0
- package/lib/types/media.js +207 -0
- package/lib/types/notes-types.d.ts +158 -0
- package/lib/types/notes-types.js +8 -0
- package/lib/types/notes.d.ts +213 -0
- package/lib/types/notes.js +718 -0
- package/lib/types/polish.d.ts +41 -0
- package/lib/types/polish.js +136 -0
- package/lib/types/remote.d.ts +118 -0
- package/lib/types/remote.js +151 -1
- package/lib/types/service.d.ts +81 -6
- package/lib/types/service.js +286 -65
- package/lib/types/skill.d.ts +20 -0
- package/lib/types/skill.js +78 -0
- package/lib/types/store.js +30 -8
- package/lib/types/tool.d.ts +23 -0
- package/lib/types/tool.js +40 -0
- package/lib/types/tools.d.ts +69 -0
- package/lib/types/tools.js +400 -0
- package/lib/types/types.d.ts +90 -2
- package/lib/types/types.js +66 -0
- package/lib/types/xiaoyuzhou.d.ts +43 -0
- package/lib/types/xiaoyuzhou.js +134 -0
- package/lib/types/ytdlp.d.ts +111 -0
- package/lib/types/ytdlp.js +269 -0
- package/package.json +33 -20
- package/lib/invariant.js +0 -38
- package/lib/types/invariant.d.ts +0 -29
- 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
|
-
|
|
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
|
|
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
|
|
492
|
-
if (id === 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
|
|
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
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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}`;
|
|
@@ -1169,6 +1240,104 @@ function arxivHtmlUrl(url) {
|
|
|
1169
1240
|
if (link === void 0 || link.kind === "html") return void 0;
|
|
1170
1241
|
return `https://arxiv.org/html/${link.id}`;
|
|
1171
1242
|
}
|
|
1243
|
+
//#endregion
|
|
1244
|
+
//#region lib/types/document-fetch.js
|
|
1245
|
+
/**
|
|
1246
|
+
* The document fetcher: the reader's own instance of the official HTTP fetch
|
|
1247
|
+
* provider, so feeds and article pages arrive as the origin served them.
|
|
1248
|
+
*
|
|
1249
|
+
* Why not `ctx.web.fetch` alone: the seam runs whichever provider the host
|
|
1250
|
+
* selects, and from 0.2.1-rc.2 the web-search settings switch for the DeepSeek
|
|
1251
|
+
* Search/Fetch API moves `web.fetchProvider` too. That provider answers
|
|
1252
|
+
* Markdown (`body.kind: 'text'`), a constant 200, and the request URL as the
|
|
1253
|
+
* final URL — so `classifyPayload` reads every feed as a page, the extractor
|
|
1254
|
+
* gets no HTML, and the cross-origin redirect handling never fires. The
|
|
1255
|
+
* request carries only a URL; no caller can ask for the HTTP provider (seam
|
|
1256
|
+
* registry S22).
|
|
1257
|
+
*
|
|
1258
|
+
* So when the mounted `web` is the official service (it carries
|
|
1259
|
+
* `registerFetchProvider`), the reader fetches through its own
|
|
1260
|
+
* `HttpFetchProvider` — the same class the default `http` row registers, with
|
|
1261
|
+
* that row's default limits, so the policy (public addresses only, connection
|
|
1262
|
+
* pinned, same-origin redirects, charset decoding) and the errors
|
|
1263
|
+
* (`WEB_REDIRECT_BLOCKED` naming the target origin) are exactly today's.
|
|
1264
|
+
* `ctx.web` stays the permission: without it nothing is fetched. A host
|
|
1265
|
+
* without the provider package (or any other `web` shape, such as a test's
|
|
1266
|
+
* plain `{ fetch }`) is fetched through as before.
|
|
1267
|
+
*
|
|
1268
|
+
* Retire when the seam lets one request name its provider (S22).
|
|
1269
|
+
*
|
|
1270
|
+
* @module @khorsheed/dsh-reader/document-fetch
|
|
1271
|
+
*/
|
|
1272
|
+
/**
|
|
1273
|
+
* The reader's own fetch limits: the `http` row's defaults
|
|
1274
|
+
* (`@deepseek-ai/dsh-web-fetch-http` Config, 0.1.5 through 0.2.1-rc.3) except
|
|
1275
|
+
* `maxBodyChars`. The row's 100 000 is sized for a page handed to a model; the
|
|
1276
|
+
* reader extracts the article itself from the raw HTML, and a modern page
|
|
1277
|
+
* carries several hundred KB of inline script before its last paragraph
|
|
1278
|
+
* (measured: claude.com's articles, 620 KB, cut mid-sentence at 100 000). 2 000 000
|
|
1279
|
+
* is what deployments ran the row at while the reader still fetched through
|
|
1280
|
+
* it, and the per-source storage budget (`MAX_BODY_CHARS_PER_SOURCE`) is
|
|
1281
|
+
* sized to match. A deployment changes these with the reader's own
|
|
1282
|
+
* `fetchLimits`: the row's config does not reach this instance.
|
|
1283
|
+
*/
|
|
1284
|
+
const DOCUMENT_FETCH_DEFAULTS = {
|
|
1285
|
+
maxResponseBytes: 5e6,
|
|
1286
|
+
maxBodyChars: 2e6,
|
|
1287
|
+
timeoutMs: 3e4,
|
|
1288
|
+
maxRedirects: 5
|
|
1289
|
+
};
|
|
1290
|
+
/** Whether a mounted `web` is the official service rather than some other shape. */
|
|
1291
|
+
function isOfficialWebService(web) {
|
|
1292
|
+
return typeof web?.registerFetchProvider === "function";
|
|
1293
|
+
}
|
|
1294
|
+
const loadOfficialProvider = () => import("@deepseek-ai/dsh-web-fetch-http").then((module) => module, () => void 0);
|
|
1295
|
+
/**
|
|
1296
|
+
* Pick the fetcher for raw documents.
|
|
1297
|
+
*
|
|
1298
|
+
* Built once per service: the first fetch on an official web service loads
|
|
1299
|
+
* the provider package and keeps the instance; a failed load is remembered
|
|
1300
|
+
* too, and every later fetch goes through the seam.
|
|
1301
|
+
*/
|
|
1302
|
+
var DocumentFetch = class {
|
|
1303
|
+
web;
|
|
1304
|
+
limits;
|
|
1305
|
+
load;
|
|
1306
|
+
provider;
|
|
1307
|
+
/**
|
|
1308
|
+
* @param web - the probed web seam, read on every call (it may come and go).
|
|
1309
|
+
* @param limits - the reader's configured fetch limits, over the defaults.
|
|
1310
|
+
* @param load - the provider package loader (tests inject one).
|
|
1311
|
+
*/
|
|
1312
|
+
constructor(web, limits = {}, load = loadOfficialProvider) {
|
|
1313
|
+
this.web = web;
|
|
1314
|
+
this.limits = limits;
|
|
1315
|
+
this.load = load;
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* The fetcher for this call, or undefined when no web capability is mounted.
|
|
1319
|
+
* @returns the reader's own provider on an official web service, else the seam itself.
|
|
1320
|
+
*/
|
|
1321
|
+
async current() {
|
|
1322
|
+
const web = this.web();
|
|
1323
|
+
if (web === void 0 || web === null) return void 0;
|
|
1324
|
+
const seam = web;
|
|
1325
|
+
if (!isOfficialWebService(web)) return seam;
|
|
1326
|
+
this.provider ??= this.load().then((module) => {
|
|
1327
|
+
if (module === void 0 || typeof module.HttpFetchProvider !== "function") return void 0;
|
|
1328
|
+
try {
|
|
1329
|
+
return new module.HttpFetchProvider({
|
|
1330
|
+
...DOCUMENT_FETCH_DEFAULTS,
|
|
1331
|
+
...this.limits,
|
|
1332
|
+
userAgent: module.DEFAULT_USER_AGENT ?? "deepseek-harness/0.0.1 (+https://github.com/deepseek-ai)"
|
|
1333
|
+
});
|
|
1334
|
+
} catch {
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
return await this.provider ?? seam;
|
|
1339
|
+
}
|
|
1340
|
+
};
|
|
1172
1341
|
/** An honest agent line — bot walls are reported, never impersonated around. */
|
|
1173
1342
|
const IMAGE_FETCH_USER_AGENT = "dsh-reader image rescue (+https://github.com/Khorsheed/dsh-plugins)";
|
|
1174
1343
|
/**
|
|
@@ -1837,103 +2006,1632 @@ async function resolveLink(url, fetch) {
|
|
|
1837
2006
|
for (const resolver of LINK_RESOLVERS) if (resolver.match(parsed)) return resolver.resolve(parsed, fetch);
|
|
1838
2007
|
return null;
|
|
1839
2008
|
}
|
|
1840
|
-
|
|
1841
|
-
|
|
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 {};
|
|
2009
|
+
//#endregion
|
|
2010
|
+
//#region lib/types/captions.js
|
|
1852
2011
|
/**
|
|
1853
|
-
*
|
|
1854
|
-
*
|
|
1855
|
-
*
|
|
1856
|
-
*
|
|
2012
|
+
* Captions → the transcript a video or audio entry reads as.
|
|
2013
|
+
*
|
|
2014
|
+
* Every platform's caption file is parsed into the same flat cue list (start,
|
|
2015
|
+
* end, text), merged into paragraphs a person can read (sentence ends, pauses,
|
|
2016
|
+
* a length cap), and rendered as article markup where each paragraph carries
|
|
2017
|
+
* its start time. The markup is the entry's body: notes, highlights,
|
|
2018
|
+
* translation and the model's ¶ numbering all work on it unchanged, and the
|
|
2019
|
+
* player reads `data-t` to seek.
|
|
2020
|
+
*
|
|
2021
|
+
* Pure: no fs, no network — the formats are measured on real files and pinned
|
|
2022
|
+
* by the specs.
|
|
2023
|
+
*
|
|
2024
|
+
* @module @khorsheed/dsh-reader/captions
|
|
1857
2025
|
*/
|
|
1858
|
-
|
|
1859
|
-
|
|
2026
|
+
/** Collapse whitespace and strip the markup some caption files carry. */
|
|
2027
|
+
function clean(text) {
|
|
2028
|
+
return text.replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'").replace(/"/g, "\"").replace(/\s+/g, " ").trim();
|
|
2029
|
+
}
|
|
2030
|
+
/** Seconds from `hh:mm:ss.mmm` / `mm:ss.mmm` / `hh:mm:ss,mmm`. */
|
|
2031
|
+
function clock(value) {
|
|
2032
|
+
const parts = value.trim().replace(",", ".").split(":").map(Number);
|
|
2033
|
+
if (parts.some((part) => !Number.isFinite(part))) return NaN;
|
|
2034
|
+
return parts.reduce((total, part) => total * 60 + part, 0);
|
|
1860
2035
|
}
|
|
1861
2036
|
/**
|
|
1862
|
-
*
|
|
1863
|
-
*
|
|
1864
|
-
*
|
|
1865
|
-
|
|
1866
|
-
|
|
2037
|
+
* YouTube's `json3` captions: `events[]` with `tStartMs`, `dDurationMs` and
|
|
2038
|
+
* `segs[].utf8`. Auto captions also carry `aAppend` newline events and
|
|
2039
|
+
* word-level `tOffsetMs`; only whole events become cues.
|
|
2040
|
+
*/
|
|
2041
|
+
function parseJson3(raw) {
|
|
2042
|
+
const data = JSON.parse(raw);
|
|
2043
|
+
const cues = [];
|
|
2044
|
+
for (const event of data.events ?? []) {
|
|
2045
|
+
if (event.segs === void 0 || event.aAppend === 1) continue;
|
|
2046
|
+
const text = clean(event.segs.map((seg) => seg.utf8 ?? "").join(""));
|
|
2047
|
+
if (text === "") continue;
|
|
2048
|
+
const startMs = event.tStartMs ?? 0;
|
|
2049
|
+
cues.push({
|
|
2050
|
+
start: startMs / 1e3,
|
|
2051
|
+
end: (startMs + (event.dDurationMs ?? 0)) / 1e3,
|
|
2052
|
+
text
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
return cues;
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* WebVTT. YouTube's auto captions in VTT are ROLLING: each cue repeats the
|
|
2059
|
+
* previous line and adds the next, with inline word timings. The rolling
|
|
2060
|
+
* duplicates are dropped by keeping only the text a cue adds over the one
|
|
2061
|
+
* before it.
|
|
2062
|
+
*/
|
|
2063
|
+
function parseVtt(raw) {
|
|
2064
|
+
const blocks = raw.replace(/\r/g, "").split(/\n\n+/);
|
|
2065
|
+
const cues = [];
|
|
2066
|
+
let previous = "";
|
|
2067
|
+
for (const block of blocks) {
|
|
2068
|
+
const lines = block.split("\n");
|
|
2069
|
+
const timing = lines.findIndex((line) => line.includes("-->"));
|
|
2070
|
+
if (timing < 0) continue;
|
|
2071
|
+
const [from, to] = lines[timing].split("-->");
|
|
2072
|
+
const start = clock(from ?? "");
|
|
2073
|
+
const end = clock((to ?? "").trim().split(/\s+/)[0] ?? "");
|
|
2074
|
+
if (!Number.isFinite(start)) continue;
|
|
2075
|
+
const textLines = lines.slice(timing + 1).map((line) => clean(line)).filter((line) => line !== "");
|
|
2076
|
+
let text = textLines.join(" ");
|
|
2077
|
+
if (text === "") continue;
|
|
2078
|
+
if (previous !== "" && text.startsWith(previous)) text = text.slice(previous.length).trim();
|
|
2079
|
+
else if (textLines.length > 1 && textLines[0] === previous) text = textLines.slice(1).join(" ");
|
|
2080
|
+
previous = textLines[textLines.length - 1] ?? previous;
|
|
2081
|
+
if (text === "" || cues.length > 0 && cues[cues.length - 1].text === text) continue;
|
|
2082
|
+
cues.push({
|
|
2083
|
+
start,
|
|
2084
|
+
end: Number.isFinite(end) ? end : start,
|
|
2085
|
+
text
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
return cues;
|
|
2089
|
+
}
|
|
2090
|
+
/** SubRip (`.srt`). */
|
|
2091
|
+
function parseSrt(raw) {
|
|
2092
|
+
const cues = [];
|
|
2093
|
+
for (const block of raw.replace(/\r/g, "").split(/\n\n+/)) {
|
|
2094
|
+
const lines = block.split("\n");
|
|
2095
|
+
const timing = lines.findIndex((line) => line.includes("-->"));
|
|
2096
|
+
if (timing < 0) continue;
|
|
2097
|
+
const [from, to] = lines[timing].split("-->");
|
|
2098
|
+
const start = clock(from ?? "");
|
|
2099
|
+
const end = clock(to ?? "");
|
|
2100
|
+
const text = clean(lines.slice(timing + 1).join(" "));
|
|
2101
|
+
if (!Number.isFinite(start) || text === "") continue;
|
|
2102
|
+
cues.push({
|
|
2103
|
+
start,
|
|
2104
|
+
end: Number.isFinite(end) ? end : start,
|
|
2105
|
+
text
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
return cues;
|
|
2109
|
+
}
|
|
2110
|
+
/** Bilibili's subtitle JSON: `body[]` with `from`, `to`, `content` (seconds). */
|
|
2111
|
+
function parseBiliJson(raw) {
|
|
2112
|
+
return (JSON.parse(raw).body ?? []).flatMap((item) => {
|
|
2113
|
+
const text = clean(item.content ?? "");
|
|
2114
|
+
if (text === "" || typeof item.from !== "number") return [];
|
|
2115
|
+
return [{
|
|
2116
|
+
start: item.from,
|
|
2117
|
+
end: typeof item.to === "number" ? item.to : item.from,
|
|
2118
|
+
text
|
|
2119
|
+
}];
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Parse a caption file by its extension (the one yt-dlp wrote).
|
|
1867
2124
|
*
|
|
1868
|
-
* @param raw - the
|
|
1869
|
-
* @
|
|
2125
|
+
* @param raw - the file's text.
|
|
2126
|
+
* @param ext - `json3`, `vtt`, `srt` or `json`.
|
|
1870
2127
|
*/
|
|
1871
|
-
function
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
2128
|
+
function parseCaptions(raw, ext) {
|
|
2129
|
+
switch (ext.toLowerCase()) {
|
|
2130
|
+
case "json3": return parseJson3(raw);
|
|
2131
|
+
case "vtt": return parseVtt(raw);
|
|
2132
|
+
case "srt": return parseSrt(raw);
|
|
2133
|
+
case "json": return parseBiliJson(raw);
|
|
2134
|
+
default: return [];
|
|
2135
|
+
}
|
|
1878
2136
|
}
|
|
1879
|
-
/**
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
2137
|
+
/** True when a line ends a sentence (Latin or CJK punctuation). */
|
|
2138
|
+
function endsSentence(text) {
|
|
2139
|
+
return /[.!?。!?…]["'”’))]?$/.test(text);
|
|
2140
|
+
}
|
|
2141
|
+
/** True for text that is mostly CJK (joined without spaces). */
|
|
2142
|
+
function isCjk(text) {
|
|
2143
|
+
return /[㐀-鿿]/.test(text);
|
|
2144
|
+
}
|
|
2145
|
+
/** The merge rules (exported for the specs). */
|
|
2146
|
+
const MERGE = {
|
|
2147
|
+
/** A paragraph closes at a sentence end once it is at least this long (seconds). */
|
|
2148
|
+
minSeconds: 15,
|
|
2149
|
+
/** …and always closes past this length, sentence end or not. */
|
|
2150
|
+
maxSeconds: 60,
|
|
2151
|
+
/** A pause this long between cues closes the paragraph too. */
|
|
2152
|
+
pauseSeconds: 2.5,
|
|
1889
2153
|
/**
|
|
1890
|
-
*
|
|
1891
|
-
*
|
|
2154
|
+
* Unpunctuated captions (YouTube's automatic ones carry no punctuation at
|
|
2155
|
+
* all — measured 2026-10-20): close at the first pause of this length once
|
|
2156
|
+
* the paragraph has run {@link MERGE.bareMinSeconds}, and always by
|
|
2157
|
+
* {@link MERGE.bareMaxSeconds}.
|
|
1892
2158
|
*/
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
2159
|
+
barePauseSeconds: .7,
|
|
2160
|
+
bareMinSeconds: 15,
|
|
2161
|
+
bareMaxSeconds: 35,
|
|
2162
|
+
/** Below this share of sentence-ending cues, captions count as unpunctuated. */
|
|
2163
|
+
punctuatedShare: .1
|
|
2164
|
+
};
|
|
2165
|
+
/**
|
|
2166
|
+
* Merge caption cues into readable paragraphs.
|
|
2167
|
+
*
|
|
2168
|
+
* Punctuated captions close a paragraph at a sentence end once it has run
|
|
2169
|
+
* {@link MERGE.minSeconds}, at a pause of {@link MERGE.pauseSeconds}, or past
|
|
2170
|
+
* {@link MERGE.maxSeconds}. Unpunctuated ones have no sentence ends to use, so
|
|
2171
|
+
* they close at a short pause past {@link MERGE.bareMinSeconds} — short enough
|
|
2172
|
+
* to point at, long enough to read as prose rather than subtitles.
|
|
2173
|
+
*
|
|
2174
|
+
* @param cues - the caption cues in time order.
|
|
2175
|
+
* @returns the paragraphs.
|
|
2176
|
+
*/
|
|
2177
|
+
function mergeCues(cues) {
|
|
2178
|
+
const sorted = [...cues].sort((left, right) => left.start - right.start);
|
|
2179
|
+
const punctuated = sorted.length > 0 && sorted.filter((cue) => endsSentence(cue.text)).length / sorted.length >= MERGE.punctuatedShare;
|
|
2180
|
+
const segments = [];
|
|
2181
|
+
let current;
|
|
2182
|
+
const flush = () => {
|
|
2183
|
+
if (current === void 0 || current.lines.length === 0) return;
|
|
2184
|
+
const joiner = isCjk(current.lines.map((line) => line.text).join("")) ? "" : " ";
|
|
2185
|
+
segments.push({
|
|
2186
|
+
start: current.start,
|
|
2187
|
+
end: current.end,
|
|
2188
|
+
text: current.lines.map((line) => line.text).join(joiner).replace(/\s+/g, " ").trim(),
|
|
2189
|
+
lines: current.lines
|
|
2190
|
+
});
|
|
2191
|
+
current = void 0;
|
|
2192
|
+
};
|
|
2193
|
+
for (let index = 0; index < sorted.length; index++) {
|
|
2194
|
+
const cue = sorted[index];
|
|
2195
|
+
if (current !== void 0 && cue.start - current.end >= MERGE.pauseSeconds) flush();
|
|
2196
|
+
if (current === void 0) current = {
|
|
2197
|
+
start: cue.start,
|
|
2198
|
+
end: cue.end,
|
|
2199
|
+
lines: []
|
|
2200
|
+
};
|
|
2201
|
+
current.lines.push({
|
|
2202
|
+
start: cue.start,
|
|
2203
|
+
text: cue.text
|
|
2204
|
+
});
|
|
2205
|
+
current.end = Math.max(current.end, cue.end);
|
|
2206
|
+
const length = current.end - current.start;
|
|
2207
|
+
if (punctuated) {
|
|
2208
|
+
if (length >= MERGE.minSeconds && endsSentence(cue.text) || length >= MERGE.maxSeconds) flush();
|
|
2209
|
+
} else {
|
|
2210
|
+
const next = sorted[index + 1];
|
|
2211
|
+
const gap = next === void 0 ? Infinity : next.start - cue.end;
|
|
2212
|
+
if (length >= MERGE.bareMinSeconds && gap >= MERGE.barePauseSeconds || length >= MERGE.bareMaxSeconds) flush();
|
|
2213
|
+
}
|
|
1901
2214
|
}
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2215
|
+
flush();
|
|
2216
|
+
return segments;
|
|
2217
|
+
}
|
|
2218
|
+
/** Escape text for HTML content. */
|
|
2219
|
+
function escapeHtml$1(text) {
|
|
2220
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2221
|
+
}
|
|
2222
|
+
/**
|
|
2223
|
+
* The transcript as article markup: one `<p>` per paragraph, its start time in
|
|
2224
|
+
* `data-t` (seconds, one decimal) — the attribute the player seeks by.
|
|
2225
|
+
*
|
|
2226
|
+
* @param segments - the merged paragraphs.
|
|
2227
|
+
*/
|
|
2228
|
+
function renderTranscript(segments) {
|
|
2229
|
+
return segments.map((segment) => `<p data-t="${segment.start.toFixed(1)}">${escapeHtml$1(segment.text)}</p>`).join("\n");
|
|
2230
|
+
}
|
|
2231
|
+
//#endregion
|
|
2232
|
+
//#region lib/types/local-transcribe.js
|
|
2233
|
+
/**
|
|
2234
|
+
* Local transcription of a video or audio entry without captions, on the
|
|
2235
|
+
* optional `transcribe` engine (@khorsheed/dsh-transcribe).
|
|
2236
|
+
*
|
|
2237
|
+
* Opening such an entry starts a job (2026-10-20 decision: automatic on open,
|
|
2238
|
+
* never on feed refresh): the audio is fetched into a temporary directory —
|
|
2239
|
+
* yt-dlp for a video, curl for an audio URL — handed to the engine, and the
|
|
2240
|
+
* finished stretches render as transcript paragraphs while it runs. A job
|
|
2241
|
+
* still QUEUED when the reader leaves is withdrawn; a RUNNING one finishes and
|
|
2242
|
+
* is stored either way (`cancel` with `force`, the 字幕 switch and the 取消
|
|
2243
|
+
* button, stops it). The engine's first use downloads its models: the job
|
|
2244
|
+
* waits for one confirmation (`needs-models`) and then runs.
|
|
2245
|
+
*
|
|
2246
|
+
* The engine is restated here structurally (no code edge between the
|
|
2247
|
+
* packages) and probed on every start.
|
|
2248
|
+
*
|
|
2249
|
+
* @module @khorsheed/dsh-reader/local-transcribe
|
|
2250
|
+
*/
|
|
2251
|
+
/** Sentence ends in either script. */
|
|
2252
|
+
const SENTENCE_END = /[。!?!?…]$|[^.\d]\.$/;
|
|
2253
|
+
/** A run-on sentence (SenseVoice punctuates English sparsely) closes at a comma past this long (s)… */
|
|
2254
|
+
const LONG_AT_COMMA_SEC = 20;
|
|
2255
|
+
/** …at a pause between words past this long… */
|
|
2256
|
+
const LONG_AT_PAUSE_SEC = 30;
|
|
2257
|
+
/** …and in any case by this. */
|
|
2258
|
+
const LONGEST_CUE_SEC = 45;
|
|
2259
|
+
/**
|
|
2260
|
+
* Engine stretches as caption-like cues: one per sentence (the engine's
|
|
2261
|
+
* stretches are cut at pauses, not sentence ends), so the transcript merges
|
|
2262
|
+
* into the same paragraphs platform captions do. A sentence that runs on
|
|
2263
|
+
* without a period is closed at a comma or a pause once it is long, or a
|
|
2264
|
+
* single cue would make a minute-and-a-half paragraph (measured 2026-10-20).
|
|
2265
|
+
*
|
|
2266
|
+
* @param segments - the engine's segments, in order.
|
|
2267
|
+
*/
|
|
2268
|
+
function cuesFromSegments(segments, speakers) {
|
|
2269
|
+
const cues = [];
|
|
2270
|
+
let text = "";
|
|
2271
|
+
let start;
|
|
2272
|
+
let end = 0;
|
|
2273
|
+
let speaker;
|
|
2274
|
+
const flush = () => {
|
|
2275
|
+
const trimmed = text.replace(/\s+/g, " ").trim();
|
|
2276
|
+
if (start !== void 0 && trimmed !== "") cues.push({
|
|
2277
|
+
start,
|
|
2278
|
+
end,
|
|
2279
|
+
text: trimmed,
|
|
2280
|
+
...speaker === void 0 ? {} : { speaker }
|
|
2281
|
+
});
|
|
2282
|
+
text = "";
|
|
2283
|
+
start = void 0;
|
|
2284
|
+
};
|
|
2285
|
+
const tokens = segments.flatMap((segment) => segment.tokens);
|
|
2286
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
2287
|
+
const token = tokens[index];
|
|
2288
|
+
const voice = speakers?.[index];
|
|
2289
|
+
if (voice !== void 0 && voice !== speaker) {
|
|
2290
|
+
flush();
|
|
2291
|
+
speaker = voice;
|
|
2292
|
+
}
|
|
2293
|
+
start ??= token.start;
|
|
2294
|
+
text += token.text;
|
|
2295
|
+
end = token.end;
|
|
2296
|
+
const length = end - start;
|
|
2297
|
+
const next = tokens[index + 1];
|
|
2298
|
+
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();
|
|
2299
|
+
}
|
|
2300
|
+
flush();
|
|
2301
|
+
return cues;
|
|
2302
|
+
}
|
|
2303
|
+
/** A run of one voice shorter than this is the diarizer flickering, not a turn (s). */
|
|
2304
|
+
const MIN_TURN_SEC = 2.5;
|
|
2305
|
+
/**
|
|
2306
|
+
* Each token's speaker, as an index in order of first appearance (A, B, …):
|
|
2307
|
+
* the turn covering the token's middle, else the nearest one; then runs of a
|
|
2308
|
+
* voice shorter than {@link MIN_TURN_SEC} are folded into the voice before
|
|
2309
|
+
* them — a diarizer's boundary wobble would otherwise split sentences.
|
|
2310
|
+
*
|
|
2311
|
+
* @param segments - the transcript's segments.
|
|
2312
|
+
* @param turns - the engine's turns.
|
|
2313
|
+
*/
|
|
2314
|
+
function speakerPerToken(segments, turns) {
|
|
2315
|
+
const sorted = [...turns].sort((a, b) => a.start - b.start);
|
|
2316
|
+
const order = /* @__PURE__ */ new Map();
|
|
2317
|
+
const tokens = segments.flatMap((segment) => segment.tokens);
|
|
2318
|
+
const out = [...tokens.map((token) => {
|
|
2319
|
+
const at = (token.start + token.end) / 2;
|
|
2320
|
+
let best;
|
|
2321
|
+
let distance = Infinity;
|
|
2322
|
+
for (const turn of sorted) {
|
|
2323
|
+
const d = at < turn.start ? turn.start - at : at > turn.end ? at - turn.end : 0;
|
|
2324
|
+
if (d < distance) {
|
|
2325
|
+
distance = d;
|
|
2326
|
+
best = turn;
|
|
2327
|
+
}
|
|
2328
|
+
if (turn.start > at + distance) break;
|
|
2329
|
+
}
|
|
2330
|
+
return best?.cluster ?? -1;
|
|
2331
|
+
})];
|
|
2332
|
+
let runStart = 0;
|
|
2333
|
+
for (let index = 1; index <= out.length; index++) {
|
|
2334
|
+
if (index < out.length && out[index] === out[runStart]) continue;
|
|
2335
|
+
const length = (tokens[index - 1]?.end ?? 0) - (tokens[runStart]?.start ?? 0);
|
|
2336
|
+
if (runStart > 0 && length < MIN_TURN_SEC) for (let k = runStart; k < index; k++) out[k] = out[runStart - 1];
|
|
2337
|
+
runStart = index;
|
|
2338
|
+
}
|
|
2339
|
+
for (let index = 1; index < out.length; index++) if (/^\s*[\p{P}]+\s*$/u.test(tokens[index].text)) out[index] = out[index - 1];
|
|
2340
|
+
return out.map((cluster) => {
|
|
2341
|
+
if (!order.has(cluster)) order.set(cluster, order.size);
|
|
2342
|
+
return order.get(cluster);
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
/** Escape text for HTML content. */
|
|
2346
|
+
function escapeText(text) {
|
|
2347
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* The transcript with its speakers: paragraphs never cross a change of voice,
|
|
2351
|
+
* and each carries `data-spk` (the speaker's index) beside its `data-t`.
|
|
2352
|
+
*/
|
|
2353
|
+
function renderSpeakerTranscript(segments, turns) {
|
|
2354
|
+
const cues = cuesFromSegments(segments, speakerPerToken(segments, turns));
|
|
2355
|
+
const out = [];
|
|
2356
|
+
let run = [];
|
|
2357
|
+
const flush = () => {
|
|
2358
|
+
const speaker = run[0]?.speaker;
|
|
2359
|
+
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>`);
|
|
2360
|
+
run = [];
|
|
2361
|
+
};
|
|
2362
|
+
for (const cue of cues) {
|
|
2363
|
+
if (run.length > 0 && cue.speaker !== run[0].speaker) flush();
|
|
2364
|
+
run.push(cue);
|
|
2365
|
+
}
|
|
2366
|
+
flush();
|
|
2367
|
+
return out.join("\n");
|
|
2368
|
+
}
|
|
2369
|
+
/** The marker a locally transcribed paragraph carries (the 来源 line reads it). */
|
|
2370
|
+
const LOCAL_MARK = "data-src=\"local\"";
|
|
2371
|
+
/** The engine's segments as transcript markup, each paragraph marked local. */
|
|
2372
|
+
function renderLocalTranscript(segments) {
|
|
2373
|
+
return renderTranscript(mergeCues(cuesFromSegments(segments))).replace(/<p data-t=/g, `<p ${LOCAL_MARK} data-t=`);
|
|
2374
|
+
}
|
|
2375
|
+
/**
|
|
2376
|
+
* The language to ask for: always detection (SenseVoice). Measured
|
|
2377
|
+
* 2026-10-20: a platform says nothing reliable about the spoken language — a
|
|
2378
|
+
* Bilibili upload of an English interview came out of the Chinese model as
|
|
2379
|
+
* lower-case English with Chinese punctuation — and on Chinese speech
|
|
2380
|
+
* SenseVoice reads about as well as Paraformer (a 90 s sample: each missed
|
|
2381
|
+
* two or three characters the other got right).
|
|
2382
|
+
*/
|
|
2383
|
+
function languageFor(_media) {
|
|
2384
|
+
return "auto";
|
|
2385
|
+
}
|
|
2386
|
+
/** Fetch a URL to a file with curl (it honors the deployment's proxy environment). */
|
|
2387
|
+
function curlTo(url, file, signal) {
|
|
2388
|
+
return new Promise((resolve, reject) => {
|
|
2389
|
+
execFile("curl", [
|
|
2390
|
+
"-fsSL",
|
|
2391
|
+
"--retry",
|
|
2392
|
+
"2",
|
|
2393
|
+
"-o",
|
|
2394
|
+
file,
|
|
2395
|
+
url
|
|
2396
|
+
], {
|
|
2397
|
+
signal,
|
|
2398
|
+
timeout: 18e5
|
|
2399
|
+
}, (error) => {
|
|
2400
|
+
if (error === null) resolve();
|
|
2401
|
+
else reject(/* @__PURE__ */ new Error(`下载音频失败:${error.message.split("\n")[0] ?? ""}`));
|
|
2402
|
+
});
|
|
2403
|
+
});
|
|
2404
|
+
}
|
|
2405
|
+
/** A model download larger than this asks first; a small addition (the 2 MB VAD) just happens. */
|
|
2406
|
+
const CONFIRM_BYTES = 2e7;
|
|
2407
|
+
/** Share of the progress bar the audio download takes. */
|
|
2408
|
+
const FETCH_SHARE = .1;
|
|
2409
|
+
/** How often a running job writes its finished segments down (ms). */
|
|
2410
|
+
const SAVE_EVERY_MS = 15e3;
|
|
2411
|
+
/** How long a restart waits for the engine to be provided before giving up on resuming (ms). */
|
|
2412
|
+
const RESUME_WAIT_MS = 6e5;
|
|
2413
|
+
function readJson(path) {
|
|
2414
|
+
try {
|
|
2415
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
2416
|
+
} catch {
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
function writeJson(path, value) {
|
|
2421
|
+
try {
|
|
2422
|
+
writeFileSync(`${path}.tmp`, JSON.stringify(value));
|
|
2423
|
+
renameSync(`${path}.tmp`, path);
|
|
2424
|
+
} catch {}
|
|
2425
|
+
}
|
|
2426
|
+
/** The reader's local transcription jobs, by entry. */
|
|
2427
|
+
var LocalTranscriber = class {
|
|
2428
|
+
deps;
|
|
2429
|
+
jobs = /* @__PURE__ */ new Map();
|
|
2430
|
+
/** Jobs a previous process left mid-way, waiting for the engine. */
|
|
2431
|
+
resumable = [];
|
|
2432
|
+
resumeTimer;
|
|
2433
|
+
disposing = false;
|
|
2434
|
+
constructor(deps) {
|
|
2435
|
+
this.deps = deps;
|
|
2436
|
+
try {
|
|
2437
|
+
const cutoff = Date.now() - 864e5;
|
|
2438
|
+
for (const name of readdirSync(deps.tmpRoot)) {
|
|
2439
|
+
if (!name.startsWith("audio-")) continue;
|
|
2440
|
+
const dir = join(deps.tmpRoot, name);
|
|
2441
|
+
if (statSync(dir).mtimeMs < cutoff) {
|
|
2442
|
+
rmSync(dir, {
|
|
2443
|
+
recursive: true,
|
|
2444
|
+
force: true
|
|
2445
|
+
});
|
|
2446
|
+
continue;
|
|
2447
|
+
}
|
|
2448
|
+
const record = readJson(join(dir, "job.json"));
|
|
2449
|
+
if (record?.entryId !== void 0 && record.media !== void 0) this.resumable.push({
|
|
2450
|
+
dir,
|
|
2451
|
+
record
|
|
2452
|
+
});
|
|
2453
|
+
else rmSync(dir, {
|
|
2454
|
+
recursive: true,
|
|
2455
|
+
force: true
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
} catch {}
|
|
2459
|
+
if (this.resumable.length > 0) {
|
|
2460
|
+
const since = Date.now();
|
|
2461
|
+
this.resumeTimer = setInterval(() => {
|
|
2462
|
+
const engine = this.deps.engine();
|
|
2463
|
+
if (engine === void 0 && Date.now() - since < RESUME_WAIT_MS) return;
|
|
2464
|
+
clearInterval(this.resumeTimer);
|
|
2465
|
+
this.resumeTimer = void 0;
|
|
2466
|
+
for (const { dir, record } of this.resumable.splice(0)) {
|
|
2467
|
+
if (engine === void 0 || this.jobs.has(record.entryId)) {
|
|
2468
|
+
rmSync(dir, {
|
|
2469
|
+
recursive: true,
|
|
2470
|
+
force: true
|
|
2471
|
+
});
|
|
2472
|
+
continue;
|
|
2473
|
+
}
|
|
2474
|
+
const job = {
|
|
2475
|
+
state: "queued",
|
|
2476
|
+
progress: FETCH_SHARE,
|
|
2477
|
+
segments: [],
|
|
2478
|
+
rendered: 0,
|
|
2479
|
+
abort: new AbortController()
|
|
2480
|
+
};
|
|
2481
|
+
this.jobs.set(record.entryId, job);
|
|
2482
|
+
this.execute(record, engine, "auto", job, dir);
|
|
2483
|
+
}
|
|
2484
|
+
}, this.deps.resumePollMs ?? 2e3);
|
|
2485
|
+
this.resumeTimer.unref?.();
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
/** An entry's job as the browser sees it. */
|
|
2489
|
+
status(entryId) {
|
|
2490
|
+
const job = this.jobs.get(entryId);
|
|
2491
|
+
if (job === void 0) return {
|
|
2492
|
+
entryId,
|
|
2493
|
+
state: "idle"
|
|
2494
|
+
};
|
|
2495
|
+
if (job.rendered !== job.segments.length && job.state !== "done") {
|
|
2496
|
+
job.html = renderLocalTranscript(job.segments);
|
|
2497
|
+
job.rendered = job.segments.length;
|
|
2498
|
+
}
|
|
1905
2499
|
return {
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
...
|
|
2500
|
+
entryId,
|
|
2501
|
+
state: job.state,
|
|
2502
|
+
progress: job.progress,
|
|
2503
|
+
...job.ahead === void 0 ? {} : { ahead: job.ahead },
|
|
2504
|
+
...job.html === void 0 ? {} : { html: job.html },
|
|
2505
|
+
...job.language === void 0 ? {} : { language: job.language },
|
|
2506
|
+
...job.error === void 0 ? {} : { error: job.error },
|
|
2507
|
+
...job.speakers === void 0 ? {} : { speakers: job.speakers }
|
|
1911
2508
|
};
|
|
1912
2509
|
}
|
|
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
2510
|
/**
|
|
1918
|
-
*
|
|
2511
|
+
* Start (or rejoin) an entry's transcription.
|
|
1919
2512
|
*
|
|
1920
|
-
*
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
2513
|
+
* @param request - the entry, its media, and whether the model download was confirmed.
|
|
2514
|
+
*/
|
|
2515
|
+
async start(request) {
|
|
2516
|
+
const current = this.jobs.get(request.entryId);
|
|
2517
|
+
if (current !== void 0 && current.state !== "failed" && current.state !== "done") return this.status(request.entryId);
|
|
2518
|
+
const engine = this.deps.engine();
|
|
2519
|
+
if (engine === void 0) return {
|
|
2520
|
+
entryId: request.entryId,
|
|
2521
|
+
state: "unavailable"
|
|
2522
|
+
};
|
|
2523
|
+
const language = languageFor(request.media);
|
|
2524
|
+
const bytes = await engine.missingBytes(engine.modelsFor({
|
|
2525
|
+
language,
|
|
2526
|
+
diarize: false
|
|
2527
|
+
}));
|
|
2528
|
+
if (bytes > CONFIRM_BYTES && request.confirmModels !== true) return {
|
|
2529
|
+
entryId: request.entryId,
|
|
2530
|
+
state: "needs-models",
|
|
2531
|
+
bytes
|
|
2532
|
+
};
|
|
2533
|
+
const job = {
|
|
2534
|
+
state: "fetching",
|
|
2535
|
+
progress: 0,
|
|
2536
|
+
segments: [],
|
|
2537
|
+
rendered: 0,
|
|
2538
|
+
abort: new AbortController()
|
|
2539
|
+
};
|
|
2540
|
+
this.jobs.set(request.entryId, job);
|
|
2541
|
+
const record = {
|
|
2542
|
+
entryId: request.entryId,
|
|
2543
|
+
url: request.url,
|
|
2544
|
+
media: request.media,
|
|
2545
|
+
...request.title === void 0 ? {} : { title: request.title },
|
|
2546
|
+
phase: "transcribe"
|
|
2547
|
+
};
|
|
2548
|
+
this.execute(record, engine, language, job);
|
|
2549
|
+
return this.status(request.entryId);
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* Leave or stop an entry's job: a queued (or still fetching) job is
|
|
2553
|
+
* withdrawn either way; a running one only with `force`.
|
|
2554
|
+
*/
|
|
2555
|
+
cancel(entryId, force) {
|
|
2556
|
+
const job = this.jobs.get(entryId);
|
|
2557
|
+
if (job === void 0) return {
|
|
2558
|
+
entryId,
|
|
2559
|
+
state: "idle"
|
|
2560
|
+
};
|
|
2561
|
+
if (force || job.state === "queued" || job.state === "fetching") {
|
|
2562
|
+
job.abort.abort();
|
|
2563
|
+
this.jobs.delete(entryId);
|
|
2564
|
+
return {
|
|
2565
|
+
entryId,
|
|
2566
|
+
state: "idle"
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
return this.status(entryId);
|
|
2570
|
+
}
|
|
2571
|
+
/** Whether the engine is installed right now. */
|
|
2572
|
+
engineAvailable() {
|
|
2573
|
+
return this.deps.engine() !== void 0;
|
|
2574
|
+
}
|
|
2575
|
+
/** Stop everything (plugin disposal) — keeping each job's record, so the next process resumes it. */
|
|
2576
|
+
dispose() {
|
|
2577
|
+
this.disposing = true;
|
|
2578
|
+
if (this.resumeTimer !== void 0) clearInterval(this.resumeTimer);
|
|
2579
|
+
for (const job of this.jobs.values()) job.abort.abort();
|
|
2580
|
+
this.jobs.clear();
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* Run (or resume) one job: fetch the audio unless the record has it,
|
|
2584
|
+
* transcribe from where the saved segments end, store, then the speakers.
|
|
1926
2585
|
*
|
|
1927
|
-
* @param
|
|
1928
|
-
* @returns the decision, or a domain refusal.
|
|
2586
|
+
* @param resumeDir - a previous process's directory for this job.
|
|
1929
2587
|
*/
|
|
1930
|
-
async
|
|
1931
|
-
const
|
|
1932
|
-
|
|
1933
|
-
const
|
|
1934
|
-
|
|
1935
|
-
const
|
|
1936
|
-
|
|
2588
|
+
async execute(record, engine, language, job, resumeDir) {
|
|
2589
|
+
const signal = job.abort.signal;
|
|
2590
|
+
mkdirSync(this.deps.tmpRoot, { recursive: true });
|
|
2591
|
+
const dir = resumeDir ?? mkdtempSync(join(this.deps.tmpRoot, "audio-"));
|
|
2592
|
+
const recordPath = join(dir, "job.json");
|
|
2593
|
+
const segmentsPath = join(dir, "segments.json");
|
|
2594
|
+
writeJson(recordPath, record);
|
|
2595
|
+
let lastSave = 0;
|
|
2596
|
+
const saveSegments = (force) => {
|
|
2597
|
+
if (!force && Date.now() - lastSave < SAVE_EVERY_MS) return;
|
|
2598
|
+
lastSave = Date.now();
|
|
2599
|
+
writeJson(segmentsPath, job.segments);
|
|
2600
|
+
};
|
|
2601
|
+
try {
|
|
2602
|
+
let file = record.file !== void 0 && existsSync(record.file) ? record.file : void 0;
|
|
2603
|
+
if (file === void 0) {
|
|
2604
|
+
job.segments = [];
|
|
2605
|
+
if (record.media.kind === "video") file = await this.deps.videoAudio(record.url, dir, {
|
|
2606
|
+
signal,
|
|
2607
|
+
onProgress: (fraction) => {
|
|
2608
|
+
job.progress = FETCH_SHARE * fraction;
|
|
2609
|
+
}
|
|
2610
|
+
});
|
|
2611
|
+
else {
|
|
2612
|
+
file = join(dir, "audio");
|
|
2613
|
+
await curlTo(record.media.ref, file, signal);
|
|
2614
|
+
}
|
|
2615
|
+
record.file = file;
|
|
2616
|
+
record.phase = "transcribe";
|
|
2617
|
+
writeJson(recordPath, record);
|
|
2618
|
+
} else job.segments = readJson(segmentsPath) ?? [];
|
|
2619
|
+
job.progress = FETCH_SHARE;
|
|
2620
|
+
let segments;
|
|
2621
|
+
let detected = record.language;
|
|
2622
|
+
if (record.phase === "transcribe") {
|
|
2623
|
+
job.state = "queued";
|
|
2624
|
+
const earlier = [...job.segments];
|
|
2625
|
+
const fromSec = earlier[earlier.length - 1]?.end ?? 0;
|
|
2626
|
+
const result = await engine.run({
|
|
2627
|
+
input: { files: [file] },
|
|
2628
|
+
...fromSec > 0 ? { fromSec } : {},
|
|
2629
|
+
language,
|
|
2630
|
+
diarize: false,
|
|
2631
|
+
owner: "@khorsheed/dsh-reader",
|
|
2632
|
+
...record.title === void 0 ? {} : { label: record.title }
|
|
2633
|
+
}, {
|
|
2634
|
+
signal,
|
|
2635
|
+
onProgress: (progress) => {
|
|
2636
|
+
if (progress.stage === "queued") {
|
|
2637
|
+
job.state = "queued";
|
|
2638
|
+
if (progress.ahead === void 0) delete job.ahead;
|
|
2639
|
+
else job.ahead = progress.ahead;
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
job.state = "transcribing";
|
|
2643
|
+
delete job.ahead;
|
|
2644
|
+
job.progress = FETCH_SHARE + .9 * progress.progress;
|
|
2645
|
+
},
|
|
2646
|
+
onSegments: (more) => {
|
|
2647
|
+
job.segments.push(...more);
|
|
2648
|
+
saveSegments(false);
|
|
2649
|
+
}
|
|
2650
|
+
});
|
|
2651
|
+
segments = [...earlier, ...result.segments];
|
|
2652
|
+
detected = result.language ?? detected;
|
|
2653
|
+
} else segments = job.segments;
|
|
2654
|
+
const html = renderLocalTranscript(segments);
|
|
2655
|
+
if (html === "") throw new Error("没有识别出语音");
|
|
2656
|
+
if (detected !== void 0) job.language = detected;
|
|
2657
|
+
job.segments = segments;
|
|
2658
|
+
saveSegments(true);
|
|
2659
|
+
if (record.phase === "transcribe") {
|
|
2660
|
+
await this.deps.store(record.entryId, record.url, html, detected);
|
|
2661
|
+
record.phase = "speakers";
|
|
2662
|
+
if (detected !== void 0) record.language = detected;
|
|
2663
|
+
writeJson(recordPath, record);
|
|
2664
|
+
}
|
|
2665
|
+
job.html = html;
|
|
2666
|
+
job.rendered = segments.length;
|
|
2667
|
+
job.progress = 1;
|
|
2668
|
+
if (engine.speakers !== void 0) {
|
|
2669
|
+
job.state = "speakers";
|
|
2670
|
+
try {
|
|
2671
|
+
const voices = await engine.speakers({
|
|
2672
|
+
input: { files: [file] },
|
|
2673
|
+
speech: segments.map((segment) => ({
|
|
2674
|
+
start: segment.start,
|
|
2675
|
+
end: segment.end
|
|
2676
|
+
})),
|
|
2677
|
+
owner: "@khorsheed/dsh-reader",
|
|
2678
|
+
...record.title === void 0 ? {} : { label: record.title }
|
|
2679
|
+
}, { signal });
|
|
2680
|
+
const count = voices.single ? 1 : new Set(voices.turns.map((turn) => turn.cluster)).size;
|
|
2681
|
+
if (count >= 2) {
|
|
2682
|
+
const split = renderSpeakerTranscript(segments, voices.turns);
|
|
2683
|
+
await this.deps.store(record.entryId, record.url, split, detected, count);
|
|
2684
|
+
job.html = split;
|
|
2685
|
+
} else await this.deps.store(record.entryId, record.url, html, detected, 1);
|
|
2686
|
+
job.speakers = count;
|
|
2687
|
+
} catch (error) {
|
|
2688
|
+
if (signal.aborted) return;
|
|
2689
|
+
job.speakersError = error instanceof Error ? error.message : String(error);
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
job.state = "done";
|
|
2693
|
+
} catch (error) {
|
|
2694
|
+
if (signal.aborted) return;
|
|
2695
|
+
job.state = "failed";
|
|
2696
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
2697
|
+
} finally {
|
|
2698
|
+
if (!this.disposing) rmSync(dir, {
|
|
2699
|
+
recursive: true,
|
|
2700
|
+
force: true
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
};
|
|
2705
|
+
//#endregion
|
|
2706
|
+
//#region lib/types/media.js
|
|
2707
|
+
/**
|
|
2708
|
+
* Video and audio entries: what kind of media a link or a feed item is, and
|
|
2709
|
+
* how the browser plays it.
|
|
2710
|
+
*
|
|
2711
|
+
* The kind is decided per ITEM, never per source (the 2026-10-20 decision): a
|
|
2712
|
+
* feed may carry articles, audio and video side by side. The signals, best
|
|
2713
|
+
* first, are a feed enclosure's MIME type, a link the resolvers recognize
|
|
2714
|
+
* (YouTube, Bilibili, Xiaoyuzhou), and — on the host, where pages are fetched
|
|
2715
|
+
* — a page whose main content is a player. A page with real prose stays an
|
|
2716
|
+
* article even when it embeds a video.
|
|
2717
|
+
*
|
|
2718
|
+
* Pure and shared by both halves.
|
|
2719
|
+
*
|
|
2720
|
+
* @module @khorsheed/dsh-reader/media
|
|
2721
|
+
*/
|
|
2722
|
+
/**
|
|
2723
|
+
* Recognize a media link from its URL alone.
|
|
2724
|
+
*
|
|
2725
|
+
* @param input - any URL.
|
|
2726
|
+
* @returns the link, or `undefined` for anything else.
|
|
2727
|
+
*/
|
|
2728
|
+
function mediaLink(input) {
|
|
2729
|
+
let url;
|
|
2730
|
+
try {
|
|
2731
|
+
url = new URL(input);
|
|
2732
|
+
} catch {
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
const host = url.hostname.replace(/^(www|m)\./, "");
|
|
2736
|
+
if (host === "youtube.com" || host === "music.youtube.com") {
|
|
2737
|
+
const v = url.searchParams.get("v");
|
|
2738
|
+
if (url.pathname === "/watch" && v !== null && /^[\w-]{6,}$/.test(v)) return {
|
|
2739
|
+
provider: "youtube",
|
|
2740
|
+
kind: "video",
|
|
2741
|
+
id: v
|
|
2742
|
+
};
|
|
2743
|
+
const short = /^\/(?:shorts|live|embed)\/([\w-]{6,})/.exec(url.pathname);
|
|
2744
|
+
if (short !== null) return {
|
|
2745
|
+
provider: "youtube",
|
|
2746
|
+
kind: "video",
|
|
2747
|
+
id: short[1]
|
|
2748
|
+
};
|
|
2749
|
+
const channel = /^\/(@[\w.-]+|channel\/UC[\w-]{10,}|c\/[\w.-]+|user\/[\w.-]+)\/?$/.exec(url.pathname.replace(/\/(videos|featured|streams)\/?$/, "/"));
|
|
2750
|
+
if (channel !== null) return {
|
|
2751
|
+
provider: "youtube",
|
|
2752
|
+
kind: "channel",
|
|
2753
|
+
id: channel[1]
|
|
2754
|
+
};
|
|
2755
|
+
return;
|
|
2756
|
+
}
|
|
2757
|
+
if (host === "youtu.be") {
|
|
2758
|
+
const id = url.pathname.slice(1);
|
|
2759
|
+
return /^[\w-]{6,}$/.test(id) ? {
|
|
2760
|
+
provider: "youtube",
|
|
2761
|
+
kind: "video",
|
|
2762
|
+
id
|
|
2763
|
+
} : void 0;
|
|
2764
|
+
}
|
|
2765
|
+
if (host === "bilibili.com" || host === "b23.tv") {
|
|
2766
|
+
const bv = /\/video\/(BV[0-9A-Za-z]{10})/.exec(url.pathname);
|
|
2767
|
+
if (bv !== null) return {
|
|
2768
|
+
provider: "bilibili",
|
|
2769
|
+
kind: "video",
|
|
2770
|
+
id: bv[1]
|
|
2771
|
+
};
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
if (host === "xiaoyuzhoufm.com") {
|
|
2775
|
+
const episode = /^\/episode\/([0-9a-f]{24})/.exec(url.pathname);
|
|
2776
|
+
if (episode !== null) return {
|
|
2777
|
+
provider: "xiaoyuzhou",
|
|
2778
|
+
kind: "audio",
|
|
2779
|
+
id: episode[1]
|
|
2780
|
+
};
|
|
2781
|
+
const podcast = /^\/podcast\/([0-9a-f]{24})/.exec(url.pathname);
|
|
2782
|
+
if (podcast !== null) return {
|
|
2783
|
+
provider: "xiaoyuzhou",
|
|
2784
|
+
kind: "podcast",
|
|
2785
|
+
id: podcast[1]
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
/** Hosts whose links are only redirects to a media page (Bilibili's share links). */
|
|
2790
|
+
const SHORT_LINK_HOSTS = /* @__PURE__ */ new Set(["b23.tv", "bili2233.cn"]);
|
|
2791
|
+
/** True for a share short link that must be resolved before it can be recognized. */
|
|
2792
|
+
function isShortMediaLink(input) {
|
|
2793
|
+
try {
|
|
2794
|
+
return SHORT_LINK_HOSTS.has(new URL(input).hostname.replace(/^www\./, ""));
|
|
2795
|
+
} catch {
|
|
2796
|
+
return false;
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
/**
|
|
2800
|
+
* The clean address of a recognized video link: the platform's canonical page,
|
|
2801
|
+
* without the share-tracking parameters a resolved short link drags along.
|
|
2802
|
+
*
|
|
2803
|
+
* @param input - a video URL.
|
|
2804
|
+
*/
|
|
2805
|
+
function canonicalMediaUrl(input) {
|
|
2806
|
+
const link = mediaLink(input);
|
|
2807
|
+
if (link?.provider === "bilibili" && link.kind === "video") {
|
|
2808
|
+
const page = new URL(input).searchParams.get("p");
|
|
2809
|
+
return `https://www.bilibili.com/video/${link.id}/${page !== null && page !== "1" ? `?p=${encodeURIComponent(page)}` : ""}`;
|
|
2810
|
+
}
|
|
2811
|
+
if (link?.provider === "youtube" && link.kind === "video") return `https://www.youtube.com/watch?v=${link.id}`;
|
|
2812
|
+
}
|
|
2813
|
+
/** The `<meta name>` a media link's synthetic page carries its description in. */
|
|
2814
|
+
const MEDIA_META_NAME = "dsh-reader:media";
|
|
2815
|
+
//#endregion
|
|
2816
|
+
//#region lib/types/ytdlp.js
|
|
2817
|
+
/**
|
|
2818
|
+
* yt-dlp on the host: video metadata and platform captions, never the video.
|
|
2819
|
+
*
|
|
2820
|
+
* Measured 2026-10-20: a 2023 yt-dlp (and the 2025 one pip can still install
|
|
2821
|
+
* on a Python 3.9 environment) can no longer read YouTube — captions now need
|
|
2822
|
+
* the current extractor and a JavaScript runtime. So the reader keeps its own
|
|
2823
|
+
* copy of the official STANDALONE release (bundled Python) under its state
|
|
2824
|
+
* root, fetched on first use and self-updated (`-U`) when it is two weeks old;
|
|
2825
|
+
* a path configured by the deployment wins over it. Node — the host's own
|
|
2826
|
+
* runtime — is handed to yt-dlp as its JavaScript runtime.
|
|
2827
|
+
*
|
|
2828
|
+
* Metadata (`--dump-single-json`) and captions (`--write-subs`) are requested
|
|
2829
|
+
* with `--skip-download`; the only download is an AUDIO track, for local
|
|
2830
|
+
* transcription of a video without captions, deleted once transcribed.
|
|
2831
|
+
*
|
|
2832
|
+
* @module @khorsheed/dsh-reader/ytdlp
|
|
2833
|
+
*/
|
|
2834
|
+
/** The release asset for this platform, or `undefined` where none is published. */
|
|
2835
|
+
function releaseAsset(platform = process.platform, arch = process.arch) {
|
|
2836
|
+
if (platform === "darwin") return "yt-dlp_macos";
|
|
2837
|
+
if (platform === "linux") return arch === "arm64" ? "yt-dlp_linux_aarch64" : "yt-dlp_linux";
|
|
2838
|
+
if (platform === "win32") return "yt-dlp.exe";
|
|
2839
|
+
}
|
|
2840
|
+
/** How old the managed binary may get before a self-update (ms). */
|
|
2841
|
+
const UPDATE_AFTER_MS = 12096e5;
|
|
2842
|
+
/** Tracks that are not speech captions. */
|
|
2843
|
+
const NOT_CAPTIONS = /* @__PURE__ */ new Set([
|
|
2844
|
+
"live_chat",
|
|
2845
|
+
"danmaku",
|
|
2846
|
+
"rechat"
|
|
2847
|
+
]);
|
|
2848
|
+
/** Caption formats, best first (json3 is YouTube's cleanest; json is Bilibili's). */
|
|
2849
|
+
const FORMATS = [
|
|
2850
|
+
"json3",
|
|
2851
|
+
"vtt",
|
|
2852
|
+
"srt",
|
|
2853
|
+
"json"
|
|
2854
|
+
];
|
|
2855
|
+
/** The best format a track offers, among the ones the parser reads. */
|
|
2856
|
+
function bestFormat(formats) {
|
|
2857
|
+
const offered = new Set((formats ?? []).map((format) => format.ext));
|
|
2858
|
+
return FORMATS.find((format) => offered.has(format));
|
|
2859
|
+
}
|
|
2860
|
+
/**
|
|
2861
|
+
* Choose the caption track: the uploader's own captions in the video's
|
|
2862
|
+
* language first, then any uploaded track, then the platform's automatic
|
|
2863
|
+
* captions in the ORIGINAL language (YouTube marks it `<lang>-orig`; the other
|
|
2864
|
+
* auto tracks are machine translations, which are also what trips its rate
|
|
2865
|
+
* limit). Manual and automatic tracks are equally "captions" to the reader
|
|
2866
|
+
* (2026-10-20); the choice only prefers the better one.
|
|
2867
|
+
*
|
|
2868
|
+
* @param info - the video's info JSON.
|
|
2869
|
+
*/
|
|
2870
|
+
function chooseCaptionTrack(info) {
|
|
2871
|
+
const language = (info.language ?? "").toLowerCase();
|
|
2872
|
+
const manual = Object.entries(info.subtitles ?? {}).filter(([lang]) => !NOT_CAPTIONS.has(lang));
|
|
2873
|
+
const pick = (entries, auto, preferred) => {
|
|
2874
|
+
for (const want of preferred) {
|
|
2875
|
+
const hit = entries.find(([lang]) => lang.toLowerCase() === want) ?? entries.find(([lang]) => want !== "" && lang.toLowerCase().startsWith(want));
|
|
2876
|
+
const ext = hit === void 0 ? void 0 : bestFormat(hit[1]);
|
|
2877
|
+
if (hit !== void 0 && ext !== void 0) return {
|
|
2878
|
+
lang: hit[0],
|
|
2879
|
+
auto,
|
|
2880
|
+
ext
|
|
2881
|
+
};
|
|
2882
|
+
}
|
|
2883
|
+
for (const [lang, formats] of entries) {
|
|
2884
|
+
const ext = bestFormat(formats);
|
|
2885
|
+
if (ext !== void 0) return {
|
|
2886
|
+
lang,
|
|
2887
|
+
auto,
|
|
2888
|
+
ext
|
|
2889
|
+
};
|
|
2890
|
+
}
|
|
2891
|
+
};
|
|
2892
|
+
const fromManual = pick(manual, false, [
|
|
2893
|
+
language,
|
|
2894
|
+
"zh",
|
|
2895
|
+
"en",
|
|
2896
|
+
"ai-zh"
|
|
2897
|
+
].filter((item) => item !== ""));
|
|
2898
|
+
if (fromManual !== void 0) return fromManual;
|
|
2899
|
+
const auto = Object.entries(info.automatic_captions ?? {}).filter(([lang]) => !NOT_CAPTIONS.has(lang));
|
|
2900
|
+
const fromOriginal = pick(auto.filter(([lang]) => lang.endsWith("-orig")), true, language === "" ? [] : [`${language}-orig`]);
|
|
2901
|
+
if (fromOriginal !== void 0) return fromOriginal;
|
|
2902
|
+
return language === "" ? void 0 : pick(auto.filter(([lang]) => lang.toLowerCase() === language), true, [language]);
|
|
2903
|
+
}
|
|
2904
|
+
/** Run a process, resolving its stdout. */
|
|
2905
|
+
function run(file, args, timeoutMs) {
|
|
2906
|
+
return new Promise((resolve, reject) => {
|
|
2907
|
+
execFile(file, [...args], {
|
|
2908
|
+
timeout: timeoutMs,
|
|
2909
|
+
maxBuffer: 67108864,
|
|
2910
|
+
env: process.env
|
|
2911
|
+
}, (error, stdout, stderr) => {
|
|
2912
|
+
if (error !== null) {
|
|
2913
|
+
const tail = String(stderr).trim().split("\n").filter((line) => /ERROR|error:/i.test(line)).slice(-1)[0];
|
|
2914
|
+
reject(new Error(tail ?? error.message));
|
|
2915
|
+
return;
|
|
2916
|
+
}
|
|
2917
|
+
resolve(String(stdout));
|
|
2918
|
+
});
|
|
2919
|
+
});
|
|
2920
|
+
}
|
|
2921
|
+
/**
|
|
2922
|
+
* Run a process to completion, honoring an abort signal and reporting stdout
|
|
2923
|
+
* lines as they come.
|
|
2924
|
+
*/
|
|
2925
|
+
function runStreaming(file, args, options) {
|
|
2926
|
+
return new Promise((resolve, reject) => {
|
|
2927
|
+
const child = spawn(file, [...args], {
|
|
2928
|
+
stdio: [
|
|
2929
|
+
"ignore",
|
|
2930
|
+
"pipe",
|
|
2931
|
+
"pipe"
|
|
2932
|
+
],
|
|
2933
|
+
env: process.env,
|
|
2934
|
+
...options.signal === void 0 ? {} : { signal: options.signal }
|
|
2935
|
+
});
|
|
2936
|
+
const timer = setTimeout(() => {
|
|
2937
|
+
child.kill("SIGTERM");
|
|
2938
|
+
}, options.timeoutMs);
|
|
2939
|
+
let buffered = "";
|
|
2940
|
+
let stderr = "";
|
|
2941
|
+
child.stdout.on("data", (chunk) => {
|
|
2942
|
+
buffered += chunk.toString("utf8");
|
|
2943
|
+
const lines = buffered.split(/\r?\n|\r/);
|
|
2944
|
+
buffered = lines.pop() ?? "";
|
|
2945
|
+
for (const line of lines) options.onLine?.(line);
|
|
2946
|
+
});
|
|
2947
|
+
child.stderr.on("data", (chunk) => {
|
|
2948
|
+
stderr = (stderr + chunk.toString("utf8")).slice(-4e3);
|
|
2949
|
+
});
|
|
2950
|
+
child.on("error", (error) => {
|
|
2951
|
+
clearTimeout(timer);
|
|
2952
|
+
reject(error);
|
|
2953
|
+
});
|
|
2954
|
+
child.on("close", (code) => {
|
|
2955
|
+
clearTimeout(timer);
|
|
2956
|
+
if (code === 0) resolve();
|
|
2957
|
+
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)}`));
|
|
2958
|
+
});
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
/** The yt-dlp manager. */
|
|
2962
|
+
var YtDlp = class {
|
|
2963
|
+
configured;
|
|
2964
|
+
binDir;
|
|
2965
|
+
tmpRoot;
|
|
2966
|
+
ensuring;
|
|
2967
|
+
/**
|
|
2968
|
+
* @param stateRoot - the reader's state root (the managed binary lives under `bin/`).
|
|
2969
|
+
* @param configured - a deployment-configured binary path, which wins.
|
|
2970
|
+
*/
|
|
2971
|
+
constructor(stateRoot, configured) {
|
|
2972
|
+
this.configured = configured;
|
|
2973
|
+
this.binDir = join(stateRoot, "bin");
|
|
2974
|
+
this.tmpRoot = join(stateRoot, "tmp");
|
|
2975
|
+
}
|
|
2976
|
+
/** The binary to run: the configured one, else the managed one (fetched / updated as needed). */
|
|
2977
|
+
binary() {
|
|
2978
|
+
if (this.configured !== void 0 && this.configured !== "") return Promise.resolve(this.configured);
|
|
2979
|
+
this.ensuring ??= this.ensureManaged().finally(() => {
|
|
2980
|
+
this.ensuring = void 0;
|
|
2981
|
+
});
|
|
2982
|
+
return this.ensuring;
|
|
2983
|
+
}
|
|
2984
|
+
/** Fetch the standalone release on first use; self-update it when stale. */
|
|
2985
|
+
async ensureManaged() {
|
|
2986
|
+
const asset = releaseAsset();
|
|
2987
|
+
if (asset === void 0) throw new Error(`yt-dlp: no standalone release for ${process.platform}/${process.arch}`);
|
|
2988
|
+
const target = join(this.binDir, asset);
|
|
2989
|
+
if (!existsSync(target)) {
|
|
2990
|
+
mkdirSync(this.binDir, { recursive: true });
|
|
2991
|
+
const partial = `${target}.download`;
|
|
2992
|
+
await run("curl", [
|
|
2993
|
+
"-fsSL",
|
|
2994
|
+
"--retry",
|
|
2995
|
+
"2",
|
|
2996
|
+
"-o",
|
|
2997
|
+
partial,
|
|
2998
|
+
`https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
|
|
2999
|
+
], 3e5);
|
|
3000
|
+
chmodSync(partial, 493);
|
|
3001
|
+
renameSync(partial, target);
|
|
3002
|
+
return target;
|
|
3003
|
+
}
|
|
3004
|
+
if (Date.now() - statSync(target).mtimeMs > UPDATE_AFTER_MS) await run(target, ["-U"], 12e4).catch(() => void 0);
|
|
3005
|
+
return target;
|
|
3006
|
+
}
|
|
3007
|
+
/** The version of the binary in use. */
|
|
3008
|
+
async version() {
|
|
3009
|
+
return (await run(await this.binary(), ["--version"], 3e4)).trim();
|
|
3010
|
+
}
|
|
3011
|
+
/** The common flags: no download, no formats needed, Node as the JS runtime. */
|
|
3012
|
+
base() {
|
|
3013
|
+
return [
|
|
3014
|
+
"--skip-download",
|
|
3015
|
+
"--ignore-no-formats-error",
|
|
3016
|
+
"--no-playlist",
|
|
3017
|
+
"--no-warnings",
|
|
3018
|
+
"--js-runtimes",
|
|
3019
|
+
`node:${process.execPath}`
|
|
3020
|
+
];
|
|
3021
|
+
}
|
|
3022
|
+
/**
|
|
3023
|
+
* A video's metadata.
|
|
3024
|
+
*
|
|
3025
|
+
* @param url - the video page URL.
|
|
3026
|
+
*/
|
|
3027
|
+
async info(url) {
|
|
3028
|
+
const line = (await run(await this.binary(), [
|
|
3029
|
+
...this.base(),
|
|
3030
|
+
"--dump-single-json",
|
|
3031
|
+
url
|
|
3032
|
+
], 12e4)).trim().split("\n").filter((item) => item.startsWith("{")).pop();
|
|
3033
|
+
if (line === void 0) throw new Error("yt-dlp: no metadata");
|
|
3034
|
+
return JSON.parse(line);
|
|
3035
|
+
}
|
|
3036
|
+
/**
|
|
3037
|
+
* A channel's newest videos (a flat listing: no video is opened).
|
|
3038
|
+
*
|
|
3039
|
+
* YouTube's own channel feed (`feeds/videos.xml`) answers 404 (measured
|
|
3040
|
+
* 2026-10-20), so the listing is yt-dlp's; `approximate_date` gives each
|
|
3041
|
+
* entry a day-precision date.
|
|
3042
|
+
*
|
|
3043
|
+
* @param url - the channel URL.
|
|
3044
|
+
* @param limit - how many of the newest videos.
|
|
3045
|
+
*/
|
|
3046
|
+
async channel(url, limit = 15) {
|
|
3047
|
+
const listing = /\/(videos|streams|shorts)\/?$/.test(url) ? url : `${url.replace(/\/+$/, "")}/videos`;
|
|
3048
|
+
const line = (await run(await this.binary(), [
|
|
3049
|
+
"--flat-playlist",
|
|
3050
|
+
"--dump-single-json",
|
|
3051
|
+
"--playlist-end",
|
|
3052
|
+
String(limit),
|
|
3053
|
+
"--no-warnings",
|
|
3054
|
+
"--extractor-args",
|
|
3055
|
+
"youtubetab:approximate_date",
|
|
3056
|
+
"--js-runtimes",
|
|
3057
|
+
`node:${process.execPath}`,
|
|
3058
|
+
listing
|
|
3059
|
+
], 18e4)).trim().split("\n").filter((item) => item.startsWith("{")).pop();
|
|
3060
|
+
if (line === void 0) throw new Error("yt-dlp: no channel listing");
|
|
3061
|
+
const data = JSON.parse(line);
|
|
3062
|
+
const title = data.channel ?? data.uploader ?? data.title?.replace(/ - Videos$/, "");
|
|
3063
|
+
return {
|
|
3064
|
+
...title === void 0 ? {} : { title },
|
|
3065
|
+
entries: (data.entries ?? []).map((entry) => {
|
|
3066
|
+
const { thumbnails, ...rest } = entry;
|
|
3067
|
+
const thumbnail = entry.thumbnail ?? thumbnails?.[thumbnails.length - 1]?.url;
|
|
3068
|
+
return thumbnail === void 0 ? rest : {
|
|
3069
|
+
...rest,
|
|
3070
|
+
thumbnail
|
|
3071
|
+
};
|
|
3072
|
+
})
|
|
3073
|
+
};
|
|
3074
|
+
}
|
|
3075
|
+
/**
|
|
3076
|
+
* A video's audio track, downloaded for local transcription (the smallest
|
|
3077
|
+
* audio-only format; the platform's combined stream when there is none).
|
|
3078
|
+
*
|
|
3079
|
+
* @param url - the video page URL.
|
|
3080
|
+
* @param dir - an empty directory the file lands in.
|
|
3081
|
+
* @param options - abort, and download progress 0–1.
|
|
3082
|
+
* @returns the downloaded file.
|
|
3083
|
+
*/
|
|
3084
|
+
async audio(url, dir, options = {}) {
|
|
3085
|
+
await runStreaming(await this.binary(), [
|
|
3086
|
+
"-f",
|
|
3087
|
+
"wa[acodec!=none]/ba/b",
|
|
3088
|
+
"--no-playlist",
|
|
3089
|
+
"--no-warnings",
|
|
3090
|
+
"--newline",
|
|
3091
|
+
"--no-part",
|
|
3092
|
+
"--js-runtimes",
|
|
3093
|
+
`node:${process.execPath}`,
|
|
3094
|
+
"-o",
|
|
3095
|
+
join(dir, "audio.%(ext)s"),
|
|
3096
|
+
url
|
|
3097
|
+
], {
|
|
3098
|
+
timeoutMs: 18e5,
|
|
3099
|
+
...options.signal === void 0 ? {} : { signal: options.signal },
|
|
3100
|
+
onLine: (line) => {
|
|
3101
|
+
const percent = /^\[download\]\s+([\d.]+)%/.exec(line)?.[1];
|
|
3102
|
+
if (percent !== void 0) options.onProgress?.(Number(percent) / 100);
|
|
3103
|
+
}
|
|
3104
|
+
});
|
|
3105
|
+
const file = readdirSync(dir).find((name) => name.startsWith("audio."));
|
|
3106
|
+
if (file === void 0) throw new Error("yt-dlp: no audio downloaded");
|
|
3107
|
+
return join(dir, file);
|
|
3108
|
+
}
|
|
3109
|
+
/**
|
|
3110
|
+
* One caption track's cues.
|
|
3111
|
+
*
|
|
3112
|
+
* @param url - the video page URL.
|
|
3113
|
+
* @param track - the chosen track.
|
|
3114
|
+
*/
|
|
3115
|
+
async captions(url, track) {
|
|
3116
|
+
mkdirSync(this.tmpRoot, { recursive: true });
|
|
3117
|
+
const dir = mkdtempSync(join(this.tmpRoot, "subs-"));
|
|
3118
|
+
try {
|
|
3119
|
+
await run(await this.binary(), [
|
|
3120
|
+
...this.base(),
|
|
3121
|
+
track.auto ? "--write-auto-subs" : "--write-subs",
|
|
3122
|
+
"--sub-langs",
|
|
3123
|
+
track.lang,
|
|
3124
|
+
"--sub-format",
|
|
3125
|
+
track.ext,
|
|
3126
|
+
"-o",
|
|
3127
|
+
join(dir, "captions.%(ext)s"),
|
|
3128
|
+
url
|
|
3129
|
+
], 18e4);
|
|
3130
|
+
const file = readdirSync(dir).find((name) => name.startsWith("captions.") && !name.endsWith(".part"));
|
|
3131
|
+
if (file === void 0) return [];
|
|
3132
|
+
return parseCaptions(readFileSync(join(dir, file), "utf8"), file.split(".").pop() ?? "");
|
|
3133
|
+
} finally {
|
|
3134
|
+
rmSync(dir, {
|
|
3135
|
+
recursive: true,
|
|
3136
|
+
force: true
|
|
3137
|
+
});
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
};
|
|
3141
|
+
//#endregion
|
|
3142
|
+
//#region lib/types/media-host.js
|
|
3143
|
+
/**
|
|
3144
|
+
* The host half of video and audio entries: the pages a media link becomes,
|
|
3145
|
+
* the feeds a channel or podcast becomes, and the transcript a video reads as.
|
|
3146
|
+
*
|
|
3147
|
+
* - A single video (YouTube, Bilibili) or Xiaoyuzhou episode is stored as a
|
|
3148
|
+
* `link` source whose payload is a SYNTHETIC page: title, description,
|
|
3149
|
+
* thumbnail, and a `<meta name="dsh-reader:media">` carrying the media
|
|
3150
|
+
* description the browser half reads (`readMediaMeta`). Every other part of
|
|
3151
|
+
* the reader then treats it as a saved link.
|
|
3152
|
+
* - A YouTube channel resolves to YouTube's own channel feed; a Xiaoyuzhou
|
|
3153
|
+
* podcast page becomes an RSS document (`xiaoyuzhou.ts`).
|
|
3154
|
+
* - A video's transcript comes from the platform's captions via yt-dlp,
|
|
3155
|
+
* merged into timed paragraphs (`captions.ts`). Whether a video has
|
|
3156
|
+
* captions is remembered in `media.json`, so a caption-less video does not
|
|
3157
|
+
* cost a yt-dlp run on every open.
|
|
3158
|
+
*
|
|
3159
|
+
* @module @khorsheed/dsh-reader/media-host
|
|
3160
|
+
*/
|
|
3161
|
+
/** Escape text for HTML content and attributes. */
|
|
3162
|
+
function escapeHtml(text) {
|
|
3163
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
3164
|
+
}
|
|
3165
|
+
/** A plain-text description as paragraphs. */
|
|
3166
|
+
function paragraphs(text) {
|
|
3167
|
+
return text.split(/\n{2,}/).map((block) => block.trim()).filter((block) => block !== "").map((block) => `<p>${escapeHtml(block).replace(/\n/g, "<br>")}</p>`).join("\n");
|
|
3168
|
+
}
|
|
3169
|
+
/**
|
|
3170
|
+
* The synthetic page a media link is stored as.
|
|
3171
|
+
*
|
|
3172
|
+
* @param page - what the page says.
|
|
3173
|
+
*/
|
|
3174
|
+
function syntheticPage(page) {
|
|
3175
|
+
const body = page.descriptionHtml ?? (page.descriptionText === void 0 ? "" : paragraphs(page.descriptionText));
|
|
3176
|
+
const plain = (page.descriptionText ?? page.descriptionHtml?.replace(/<[^>]+>/g, " ") ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
3177
|
+
return [
|
|
3178
|
+
"<!doctype html><html><head><meta charset=\"utf-8\">",
|
|
3179
|
+
`<title>${escapeHtml(page.title)}</title>`,
|
|
3180
|
+
`<meta property="og:title" content="${escapeHtml(page.title)}">`,
|
|
3181
|
+
plain === "" ? "" : `<meta name="description" content="${escapeHtml(plain)}">`,
|
|
3182
|
+
page.media.thumbnail === void 0 ? "" : `<meta property="og:image" content="${escapeHtml(page.media.thumbnail)}">`,
|
|
3183
|
+
page.author === void 0 ? "" : `<meta name="author" content="${escapeHtml(page.author)}">`,
|
|
3184
|
+
page.publishedAt === void 0 ? "" : `<meta property="article:published_time" content="${escapeHtml(page.publishedAt)}">`,
|
|
3185
|
+
`<meta name="${MEDIA_META_NAME}" content="${escapeHtml(JSON.stringify(page.media))}">`,
|
|
3186
|
+
"</head><body><article>",
|
|
3187
|
+
`<h1>${escapeHtml(page.title)}</h1>`,
|
|
3188
|
+
body,
|
|
3189
|
+
"</article></body></html>"
|
|
3190
|
+
].filter((line) => line !== "").join("\n");
|
|
3191
|
+
}
|
|
3192
|
+
/** A yt-dlp `upload_date` (`YYYYMMDD`) or timestamp as ISO-8601. */
|
|
3193
|
+
function publishedOf(info) {
|
|
3194
|
+
if (typeof info.timestamp === "number") return (/* @__PURE__ */ new Date(info.timestamp * 1e3)).toISOString();
|
|
3195
|
+
const day = /^(\d{4})(\d{2})(\d{2})$/.exec(info.upload_date ?? "");
|
|
3196
|
+
return day === null ? void 0 : `${day[1]}-${day[2]}-${day[3]}T00:00:00.000Z`;
|
|
3197
|
+
}
|
|
3198
|
+
/**
|
|
3199
|
+
* A video's synthetic page from its yt-dlp metadata.
|
|
3200
|
+
*
|
|
3201
|
+
* @param info - the metadata.
|
|
3202
|
+
* @param provider - which platform.
|
|
3203
|
+
* @param id - the platform's video id.
|
|
3204
|
+
*/
|
|
3205
|
+
function videoPage(info, provider, id) {
|
|
3206
|
+
const published = publishedOf(info);
|
|
3207
|
+
const author = info.uploader ?? info.channel;
|
|
3208
|
+
return syntheticPage({
|
|
3209
|
+
title: info.title ?? id,
|
|
3210
|
+
media: {
|
|
3211
|
+
kind: "video",
|
|
3212
|
+
provider,
|
|
3213
|
+
ref: id,
|
|
3214
|
+
...typeof info.duration === "number" ? { durationSec: Math.round(info.duration) } : {},
|
|
3215
|
+
...info.thumbnail === void 0 ? {} : { thumbnail: info.thumbnail }
|
|
3216
|
+
},
|
|
3217
|
+
...info.description === void 0 ? {} : { descriptionText: info.description },
|
|
3218
|
+
...author === void 0 ? {} : { author },
|
|
3219
|
+
...published === void 0 ? {} : { publishedAt: published }
|
|
3220
|
+
});
|
|
3221
|
+
}
|
|
3222
|
+
/**
|
|
3223
|
+
* A Xiaoyuzhou episode's synthetic page.
|
|
3224
|
+
*
|
|
3225
|
+
* @param episode - the parsed episode.
|
|
3226
|
+
*/
|
|
3227
|
+
function episodePage(episode) {
|
|
3228
|
+
return syntheticPage({
|
|
3229
|
+
title: episode.title,
|
|
3230
|
+
media: {
|
|
3231
|
+
kind: "audio",
|
|
3232
|
+
provider: "xiaoyuzhou",
|
|
3233
|
+
ref: episode.audio,
|
|
3234
|
+
...episode.durationSec === void 0 ? {} : { durationSec: episode.durationSec },
|
|
3235
|
+
...episode.image === void 0 ? {} : { thumbnail: episode.image }
|
|
3236
|
+
},
|
|
3237
|
+
...episode.notesHtml === void 0 ? {} : { descriptionHtml: episode.notesHtml },
|
|
3238
|
+
...episode.podcastTitle === void 0 ? {} : { author: episode.podcastTitle },
|
|
3239
|
+
...episode.publishedAt === void 0 ? {} : { publishedAt: episode.publishedAt }
|
|
3240
|
+
});
|
|
3241
|
+
}
|
|
3242
|
+
/** Escape text for XML. */
|
|
3243
|
+
function xml$1(text) {
|
|
3244
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
3245
|
+
}
|
|
3246
|
+
/**
|
|
3247
|
+
* A YouTube channel's newest videos as an RSS document (YouTube's own channel
|
|
3248
|
+
* feed answers 404 — measured 2026-10-20). Each item links the watch page, so
|
|
3249
|
+
* the browser half reads it as a video; the thumbnail rides `media:thumbnail`.
|
|
3250
|
+
*
|
|
3251
|
+
* @param channel - the yt-dlp flat listing.
|
|
3252
|
+
* @param pageUrl - the channel URL.
|
|
3253
|
+
*/
|
|
3254
|
+
function channelRss(channel, pageUrl) {
|
|
3255
|
+
const items = channel.entries.flatMap((entry) => {
|
|
3256
|
+
if (entry.id === void 0 || entry.title === void 0) return [];
|
|
3257
|
+
const published = publishedOf(entry);
|
|
3258
|
+
return [[
|
|
3259
|
+
"<item>",
|
|
3260
|
+
`<title>${xml$1(entry.title)}</title>`,
|
|
3261
|
+
`<link>https://www.youtube.com/watch?v=${xml$1(entry.id)}</link>`,
|
|
3262
|
+
`<guid isPermaLink="false">yt:video:${xml$1(entry.id)}</guid>`,
|
|
3263
|
+
published === void 0 ? "" : `<pubDate>${new Date(published).toUTCString()}</pubDate>`,
|
|
3264
|
+
entry.thumbnail === void 0 ? "" : `<media:thumbnail url="${xml$1(entry.thumbnail)}"/>`,
|
|
3265
|
+
"</item>"
|
|
3266
|
+
].filter((line) => line !== "").join("")];
|
|
3267
|
+
});
|
|
3268
|
+
return [
|
|
3269
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
3270
|
+
"<rss version=\"2.0\" xmlns:media=\"http://search.yahoo.com/mrss/\"><channel>",
|
|
3271
|
+
`<title>${xml$1(channel.title ?? pageUrl)}</title>`,
|
|
3272
|
+
`<link>${xml$1(pageUrl)}</link>`,
|
|
3273
|
+
...items,
|
|
3274
|
+
"</channel></rss>"
|
|
3275
|
+
].join("\n");
|
|
3276
|
+
}
|
|
3277
|
+
/** A synthetic page's display name for the source: its author (the channel / podcast), else its title. */
|
|
3278
|
+
function syntheticLabel(raw) {
|
|
3279
|
+
const author = /<meta name="author" content="([^"]*)"/.exec(raw)?.[1];
|
|
3280
|
+
const title = /<title>([^<]*)<\/title>/.exec(raw)?.[1];
|
|
3281
|
+
const value = author ?? title;
|
|
3282
|
+
return value === void 0 ? void 0 : value.replace(/"/g, "\"").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
3283
|
+
}
|
|
3284
|
+
/**
|
|
3285
|
+
* Where a share short link leads (b23.tv → bilibili.com/video/BV…). curl
|
|
3286
|
+
* follows the redirect chain and honors the deployment's proxy environment.
|
|
3287
|
+
*
|
|
3288
|
+
* @param url - the short link.
|
|
3289
|
+
*/
|
|
3290
|
+
function resolveShortLink(url) {
|
|
3291
|
+
return new Promise((resolve, reject) => {
|
|
3292
|
+
execFile("curl", [
|
|
3293
|
+
"-sL",
|
|
3294
|
+
"-m",
|
|
3295
|
+
"20",
|
|
3296
|
+
"-A",
|
|
3297
|
+
"Mozilla/5.0",
|
|
3298
|
+
"-o",
|
|
3299
|
+
"/dev/null",
|
|
3300
|
+
"-w",
|
|
3301
|
+
"%{url_effective}",
|
|
3302
|
+
url
|
|
3303
|
+
], {
|
|
3304
|
+
timeout: 3e4,
|
|
3305
|
+
env: process.env
|
|
3306
|
+
}, (error, stdout) => {
|
|
3307
|
+
if (error !== null) {
|
|
3308
|
+
reject(error);
|
|
3309
|
+
return;
|
|
3310
|
+
}
|
|
3311
|
+
resolve(String(stdout).trim());
|
|
3312
|
+
});
|
|
3313
|
+
});
|
|
3314
|
+
}
|
|
3315
|
+
/** The host's media helper: yt-dlp plus the caption record. */
|
|
3316
|
+
var ReaderMediaHost = class {
|
|
3317
|
+
stateRoot;
|
|
3318
|
+
ytdlp;
|
|
3319
|
+
file;
|
|
3320
|
+
records;
|
|
3321
|
+
/**
|
|
3322
|
+
* @param stateRoot - the reader's state root.
|
|
3323
|
+
* @param ytdlpPath - a deployment-configured yt-dlp, which wins over the managed one.
|
|
3324
|
+
*/
|
|
3325
|
+
constructor(stateRoot, ytdlpPath) {
|
|
3326
|
+
this.stateRoot = stateRoot;
|
|
3327
|
+
this.ytdlp = new YtDlp(stateRoot, ytdlpPath);
|
|
3328
|
+
this.file = join(stateRoot, "media.json");
|
|
3329
|
+
}
|
|
3330
|
+
/** The caption records, read once. */
|
|
3331
|
+
load() {
|
|
3332
|
+
if (this.records !== void 0) return this.records;
|
|
3333
|
+
try {
|
|
3334
|
+
this.records = JSON.parse(readFileSync(this.file, "utf8"));
|
|
3335
|
+
} catch {
|
|
3336
|
+
this.records = {};
|
|
3337
|
+
}
|
|
3338
|
+
return this.records;
|
|
3339
|
+
}
|
|
3340
|
+
/** What is known about an entry's captions. */
|
|
3341
|
+
captions(entryId) {
|
|
3342
|
+
return this.load()[entryId];
|
|
3343
|
+
}
|
|
3344
|
+
/** Remember what an entry's captions came to. */
|
|
3345
|
+
remember(entryId, record) {
|
|
3346
|
+
const records = {
|
|
3347
|
+
...this.load(),
|
|
3348
|
+
[entryId]: record
|
|
3349
|
+
};
|
|
3350
|
+
this.records = records;
|
|
3351
|
+
try {
|
|
3352
|
+
mkdirSync(this.stateRoot, { recursive: true });
|
|
3353
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
3354
|
+
writeFileSync(temporary, `${JSON.stringify(records, null, 2)}\n`);
|
|
3355
|
+
renameSync(temporary, this.file);
|
|
3356
|
+
} catch {
|
|
3357
|
+
rmSync(`${this.file}.${process.pid}.tmp`, { force: true });
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
/**
|
|
3361
|
+
* A video's transcript from its platform captions.
|
|
3362
|
+
*
|
|
3363
|
+
* @param url - the video page.
|
|
3364
|
+
* @returns the transcript markup and its language, or `none` when the video has no captions.
|
|
3365
|
+
*/
|
|
3366
|
+
async transcript(url) {
|
|
3367
|
+
const track = chooseCaptionTrack(await this.ytdlp.info(url));
|
|
3368
|
+
if (track === void 0) return "none";
|
|
3369
|
+
const segments = mergeCues(await this.ytdlp.captions(url, track));
|
|
3370
|
+
if (segments.length === 0) return "none";
|
|
3371
|
+
return {
|
|
3372
|
+
html: renderTranscript(segments),
|
|
3373
|
+
language: track.lang.replace(/-orig$/, "")
|
|
3374
|
+
};
|
|
3375
|
+
}
|
|
3376
|
+
};
|
|
3377
|
+
//#endregion
|
|
3378
|
+
//#region lib/types/xiaoyuzhou.js
|
|
3379
|
+
/**
|
|
3380
|
+
* Xiaoyuzhou (小宇宙): episode and podcast pages → what the reader stores.
|
|
3381
|
+
*
|
|
3382
|
+
* Xiaoyuzhou publishes no RSS. Its pages do carry their data as Next.js
|
|
3383
|
+
* `__NEXT_DATA__` JSON (measured 2026-10-20): an episode page has
|
|
3384
|
+
* `props.pageProps.episode` with the audio enclosure, duration, shownotes and
|
|
3385
|
+
* podcast; a podcast page has `props.pageProps.podcast.episodes[]`. A podcast
|
|
3386
|
+
* page is turned into an RSS document on the host, so subscribing to it is an
|
|
3387
|
+
* ordinary feed subscription for every other part of the reader.
|
|
3388
|
+
*
|
|
3389
|
+
* Pure: the host fetches the page and hands over its text.
|
|
3390
|
+
*
|
|
3391
|
+
* @module @khorsheed/dsh-reader/xiaoyuzhou
|
|
3392
|
+
*/
|
|
3393
|
+
/** The page's Next.js data, or `undefined` when the page has none. */
|
|
3394
|
+
function nextData(html) {
|
|
3395
|
+
const match = /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/.exec(html);
|
|
3396
|
+
if (match === null) return void 0;
|
|
3397
|
+
try {
|
|
3398
|
+
return JSON.parse(match[1]);
|
|
3399
|
+
} catch {
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
/** A nested record field, tolerantly. */
|
|
3404
|
+
function field(value, ...path) {
|
|
3405
|
+
let current = value;
|
|
3406
|
+
for (const key of path) {
|
|
3407
|
+
if (typeof current !== "object" || current === null) return void 0;
|
|
3408
|
+
current = current[key];
|
|
3409
|
+
}
|
|
3410
|
+
return current;
|
|
3411
|
+
}
|
|
3412
|
+
/** A string field, or `undefined`. */
|
|
3413
|
+
function str(value) {
|
|
3414
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
3415
|
+
}
|
|
3416
|
+
/** One episode record → the reader's shape. */
|
|
3417
|
+
function readEpisode(record, podcastTitle) {
|
|
3418
|
+
const id = str(field(record, "eid"));
|
|
3419
|
+
const title = str(field(record, "title"));
|
|
3420
|
+
const audio = str(field(record, "enclosure", "url")) ?? str(field(record, "media", "source", "url"));
|
|
3421
|
+
if (id === void 0 || title === void 0 || audio === void 0) return void 0;
|
|
3422
|
+
const duration = Number(field(record, "duration"));
|
|
3423
|
+
const notes = str(field(record, "shownotes")) ?? str(field(record, "description"));
|
|
3424
|
+
const image = str(field(record, "image", "picUrl")) ?? str(field(record, "podcast", "image", "picUrl"));
|
|
3425
|
+
const podcast = podcastTitle ?? str(field(record, "podcast", "title"));
|
|
3426
|
+
const publishedAt = str(field(record, "pubDate"));
|
|
3427
|
+
return {
|
|
3428
|
+
id,
|
|
3429
|
+
title,
|
|
3430
|
+
audio,
|
|
3431
|
+
...Number.isFinite(duration) && duration > 0 ? { durationSec: duration } : {},
|
|
3432
|
+
...publishedAt === void 0 ? {} : { publishedAt },
|
|
3433
|
+
...notes === void 0 ? {} : { notesHtml: notes },
|
|
3434
|
+
...image === void 0 ? {} : { image },
|
|
3435
|
+
...podcast === void 0 ? {} : { podcastTitle: podcast }
|
|
3436
|
+
};
|
|
3437
|
+
}
|
|
3438
|
+
/**
|
|
3439
|
+
* Read an episode page.
|
|
3440
|
+
*
|
|
3441
|
+
* @param html - the page's HTML.
|
|
3442
|
+
* @returns the episode, or `undefined` when the page carries none.
|
|
3443
|
+
*/
|
|
3444
|
+
function readEpisodePage(html) {
|
|
3445
|
+
return readEpisode(field(nextData(html), "props", "pageProps", "episode"));
|
|
3446
|
+
}
|
|
3447
|
+
/** Escape text for XML content and attributes. */
|
|
3448
|
+
function xml(text) {
|
|
3449
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
3450
|
+
}
|
|
3451
|
+
/** CDATA, safe against a literal `]]>` in the content. */
|
|
3452
|
+
function cdata(text) {
|
|
3453
|
+
return `<![CDATA[${text.replace(/]]>/g, "]]]]><![CDATA[>")}]]>`;
|
|
3454
|
+
}
|
|
3455
|
+
/** The MIME type an audio URL most likely has. */
|
|
3456
|
+
function audioType(url) {
|
|
3457
|
+
const path = url.toLowerCase().split("?")[0] ?? "";
|
|
3458
|
+
if (path.endsWith(".m4a") || path.endsWith(".mp4")) return "audio/mp4";
|
|
3459
|
+
return "audio/mpeg";
|
|
3460
|
+
}
|
|
3461
|
+
/**
|
|
3462
|
+
* Turn a podcast page into an RSS document the reader subscribes to like any
|
|
3463
|
+
* other feed.
|
|
3464
|
+
*
|
|
3465
|
+
* @param html - the podcast page's HTML.
|
|
3466
|
+
* @param pageUrl - the page's URL (the channel link).
|
|
3467
|
+
* @returns the RSS text, or `undefined` when the page carries no podcast.
|
|
3468
|
+
*/
|
|
3469
|
+
function podcastPageToRss(html, pageUrl) {
|
|
3470
|
+
const podcast = field(nextData(html), "props", "pageProps", "podcast");
|
|
3471
|
+
const title = str(field(podcast, "title"));
|
|
3472
|
+
if (title === void 0) return void 0;
|
|
3473
|
+
const items = (Array.isArray(field(podcast, "episodes")) ? field(podcast, "episodes") : []).flatMap((record) => {
|
|
3474
|
+
const episode = readEpisode(record, title);
|
|
3475
|
+
if (episode === void 0) return [];
|
|
3476
|
+
const date = episode.publishedAt === void 0 ? void 0 : new Date(episode.publishedAt);
|
|
3477
|
+
return [[
|
|
3478
|
+
"<item>",
|
|
3479
|
+
`<title>${xml(episode.title)}</title>`,
|
|
3480
|
+
`<link>https://www.xiaoyuzhoufm.com/episode/${episode.id}</link>`,
|
|
3481
|
+
`<guid isPermaLink="false">xiaoyuzhou:${episode.id}</guid>`,
|
|
3482
|
+
date === void 0 || Number.isNaN(date.getTime()) ? "" : `<pubDate>${date.toUTCString()}</pubDate>`,
|
|
3483
|
+
episode.notesHtml === void 0 ? "" : `<description>${cdata(episode.notesHtml)}</description>`,
|
|
3484
|
+
`<enclosure url="${xml(episode.audio)}" type="${audioType(episode.audio)}" length="0"/>`,
|
|
3485
|
+
episode.durationSec === void 0 ? "" : `<itunes:duration>${String(episode.durationSec)}</itunes:duration>`,
|
|
3486
|
+
episode.image === void 0 ? "" : `<itunes:image href="${xml(episode.image)}"/>`,
|
|
3487
|
+
"</item>"
|
|
3488
|
+
].filter((line) => line !== "").join("")];
|
|
3489
|
+
});
|
|
3490
|
+
const description = str(field(podcast, "description"));
|
|
3491
|
+
const image = str(field(podcast, "image", "picUrl"));
|
|
3492
|
+
return [
|
|
3493
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
3494
|
+
"<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\">",
|
|
3495
|
+
"<channel>",
|
|
3496
|
+
`<title>${xml(title)}</title>`,
|
|
3497
|
+
`<link>${xml(pageUrl)}</link>`,
|
|
3498
|
+
description === void 0 ? "" : `<description>${xml(description)}</description>`,
|
|
3499
|
+
image === void 0 ? "" : `<image><url>${xml(image)}</url><title>${xml(title)}</title><link>${xml(pageUrl)}</link></image>`,
|
|
3500
|
+
...items,
|
|
3501
|
+
"</channel>",
|
|
3502
|
+
"</rss>"
|
|
3503
|
+
].filter((line) => line !== "").join("\n");
|
|
3504
|
+
}
|
|
3505
|
+
/** Cross-origin redirect hops the service will follow before giving up. */
|
|
3506
|
+
const MAX_REDIRECT_HOPS = 3;
|
|
3507
|
+
/** Upper bound on summaries handed to the browser in one call. */
|
|
3508
|
+
const SUMMARY_LIMIT = 500;
|
|
3509
|
+
/** How many sentence hashes one memory-slice read answers (the table never crosses). */
|
|
3510
|
+
const TRANSLATION_SLICE_LIMIT = 2e3;
|
|
3511
|
+
/** How many sentences one write batch may carry (a long article is ~500). */
|
|
3512
|
+
const TRANSLATION_WRITE_LIMIT = 2e3;
|
|
3513
|
+
/** One sentence's source or translation is never longer than a few paragraphs. */
|
|
3514
|
+
const TRANSLATION_TEXT_LIMIT = 4e3;
|
|
3515
|
+
/** Thrown when a payload cannot become a source. */
|
|
3516
|
+
var UnsupportedContent = class extends Error {};
|
|
3517
|
+
/**
|
|
3518
|
+
* Whether a failed fetch is the HTML endpoint's "this paper has no HTML
|
|
3519
|
+
* version" — a bare 404 from the content fetch, and nothing else. A refusal
|
|
3520
|
+
* (403/401) or a transport error is a fact about the REQUEST, not about which
|
|
3521
|
+
* face of the paper exists, and must not silently reroute to another page.
|
|
3522
|
+
*/
|
|
3523
|
+
function isNoHtmlVersion(error) {
|
|
3524
|
+
return error instanceof UnsupportedContent && error.message === "HTTP 404";
|
|
3525
|
+
}
|
|
3526
|
+
/**
|
|
3527
|
+
* Classify a fetched payload as a feed or an article page.
|
|
3528
|
+
*
|
|
3529
|
+
* The decision comes from the CONTENT, not from asking the user: nobody pasting
|
|
3530
|
+
* a URL can be expected to know whether it is a feed, and guessing wrong used
|
|
3531
|
+
* to mean silently creating an empty subscription.
|
|
3532
|
+
*
|
|
3533
|
+
* @param raw - the decoded payload.
|
|
3534
|
+
* @returns which kind of source this payload can become.
|
|
3535
|
+
*/
|
|
3536
|
+
function classifyPayload(raw) {
|
|
3537
|
+
const head = raw.slice(0, 512).trimStart().toLowerCase();
|
|
3538
|
+
if (head.startsWith("<?xml")) return "feed";
|
|
3539
|
+
if (/^<rss[\s>]/.test(head)) return "feed";
|
|
3540
|
+
if (/^<feed[\s>]/.test(head)) return "feed";
|
|
3541
|
+
if (/^<rdf:rdf[\s>]/.test(head)) return "feed";
|
|
3542
|
+
return "page";
|
|
3543
|
+
}
|
|
3544
|
+
/** The reader service. */
|
|
3545
|
+
var ReaderService = class {
|
|
3546
|
+
store;
|
|
3547
|
+
/** Video and audio entries: synthetic pages, channel feeds, transcripts. */
|
|
3548
|
+
media;
|
|
3549
|
+
/** Local transcription of entries without captions (the optional `transcribe` engine). */
|
|
3550
|
+
local;
|
|
3551
|
+
ctx;
|
|
3552
|
+
timer;
|
|
3553
|
+
disposed = false;
|
|
3554
|
+
/** The document of record when no filesystem is mounted. */
|
|
3555
|
+
memoryDoc;
|
|
3556
|
+
/** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
|
|
3557
|
+
imageCache = new ImageFetchCache();
|
|
3558
|
+
/** Feeds and pages, as the origin served them (see `document-fetch.ts`). */
|
|
3559
|
+
documents;
|
|
3560
|
+
/**
|
|
3561
|
+
* @param ctx - owning context.
|
|
3562
|
+
* @param config - optional state-root override.
|
|
3563
|
+
*/
|
|
3564
|
+
constructor(ctx, config = {}) {
|
|
3565
|
+
this.ctx = ctx;
|
|
3566
|
+
this.documents = new DocumentFetch(() => this.ctx.get("web"), config.fetchLimits);
|
|
3567
|
+
this.store = new ReaderStore(config);
|
|
3568
|
+
const stateRoot = resolveReaderStateRoot(config.stateRoot);
|
|
3569
|
+
this.media = new ReaderMediaHost(stateRoot, config.ytdlpPath);
|
|
3570
|
+
this.local = new LocalTranscriber({
|
|
3571
|
+
engine: () => ctx.get("transcribe"),
|
|
3572
|
+
tmpRoot: join(stateRoot, "tmp"),
|
|
3573
|
+
videoAudio: (url, dir, options) => this.media.ytdlp.audio(url, dir, options),
|
|
3574
|
+
store: async (entryId, url, html, language, speakers) => {
|
|
3575
|
+
await this.storeEntryBody({
|
|
3576
|
+
entryId,
|
|
3577
|
+
url,
|
|
3578
|
+
html,
|
|
3579
|
+
keep: true
|
|
3580
|
+
});
|
|
3581
|
+
const before = this.media.captions(entryId);
|
|
3582
|
+
const names = before?.names !== void 0 && (speakers === void 0 || speakers === before.speakers) ? before.names : void 0;
|
|
3583
|
+
const count = speakers ?? before?.speakers;
|
|
3584
|
+
this.media.remember(entryId, {
|
|
3585
|
+
state: "local",
|
|
3586
|
+
...language === void 0 ? {} : { language },
|
|
3587
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3588
|
+
...count === void 0 ? {} : { speakers: count },
|
|
3589
|
+
...names === void 0 ? {} : { names }
|
|
3590
|
+
});
|
|
3591
|
+
}
|
|
3592
|
+
});
|
|
3593
|
+
ctx.effect(() => () => {
|
|
3594
|
+
this.disposed = true;
|
|
3595
|
+
this.local.dispose();
|
|
3596
|
+
if (this.timer !== void 0) clearTimeout(this.timer);
|
|
3597
|
+
}, "reader: daily refresh");
|
|
3598
|
+
this.armSchedule();
|
|
3599
|
+
}
|
|
3600
|
+
/** Capabilities the browser may rely on. */
|
|
3601
|
+
async capabilities() {
|
|
3602
|
+
const doc = await this.currentDoc();
|
|
3603
|
+
return {
|
|
3604
|
+
protocolVersion: 1,
|
|
3605
|
+
hasFs: this.store.available,
|
|
3606
|
+
...doc.lastRefreshAt === void 0 ? {} : { lastRefreshAt: doc.lastRefreshAt },
|
|
3607
|
+
hasSideChat: this.sideChat() !== void 0,
|
|
3608
|
+
...doc.refresh.enabled ? { nextRefreshAt: new Date(Date.now() + delayUntilNext(/* @__PURE__ */ new Date(), doc.refresh.timeOfDay)).toISOString() } : {}
|
|
3609
|
+
};
|
|
3610
|
+
}
|
|
3611
|
+
/** The source list, without payloads. */
|
|
3612
|
+
async listSources() {
|
|
3613
|
+
return { sources: (await this.currentDoc()).sources.slice(0, SUMMARY_LIMIT).map(summarize$1) };
|
|
3614
|
+
}
|
|
3615
|
+
/**
|
|
3616
|
+
* Add a source, deciding what it is from what comes back.
|
|
3617
|
+
*
|
|
3618
|
+
* The one rule this method exists to enforce: **a URL the reader pasted is
|
|
3619
|
+
* never thrown away.** A fetch that fails, a bot challenge, a login wall and
|
|
3620
|
+
* a PDF all end the same way — a `link` source that exists, carries the
|
|
3621
|
+
* reason it has no previewable body, and can be opened, tagged, refreshed
|
|
3622
|
+
* manually or deleted. Only a URL that cannot be a source at all
|
|
3623
|
+
* (`invalid-url`) or one already present (`duplicate`) is refused.
|
|
3624
|
+
*
|
|
3625
|
+
* @param request - the pasted URL and an optional label.
|
|
3626
|
+
* @returns the decision, or a domain refusal.
|
|
3627
|
+
*/
|
|
3628
|
+
async addSource(request) {
|
|
3629
|
+
const url = normalizeUrl(request.url);
|
|
3630
|
+
if (url === void 0) return "invalid-url";
|
|
3631
|
+
const doc = await this.currentDoc();
|
|
3632
|
+
if (doc.sources.some((source) => sameTarget(source.url, url))) return "duplicate";
|
|
3633
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3634
|
+
const resolution = await this.resolvePaper(url);
|
|
1937
3635
|
if (resolution?.kind === "link-only") return await this.saveLinkOnly({
|
|
1938
3636
|
url,
|
|
1939
3637
|
...request.label === void 0 ? {} : { label: request.label }
|
|
@@ -1949,7 +3647,7 @@ var ReaderService = class {
|
|
|
1949
3647
|
if (upgrade !== void 0 && doc.sources.some((source) => sameTarget(source.url, upgrade))) return "duplicate";
|
|
1950
3648
|
let fetched;
|
|
1951
3649
|
try {
|
|
1952
|
-
fetched = await this.
|
|
3650
|
+
fetched = await this.fetchSource(upgrade ?? url);
|
|
1953
3651
|
} catch (error) {
|
|
1954
3652
|
if (upgrade !== void 0 && isNoHtmlVersion(error)) try {
|
|
1955
3653
|
fetched = await this.fetchFollowing(fallbackUrl);
|
|
@@ -1967,10 +3665,10 @@ var ReaderService = class {
|
|
|
1967
3665
|
}, classifyFetchFailure(error, now), now);
|
|
1968
3666
|
}
|
|
1969
3667
|
const feed = classifyPayload(fetched.raw) === "feed";
|
|
1970
|
-
if (!feed) {
|
|
3668
|
+
if (!feed && fetched.media !== true) {
|
|
1971
3669
|
const problem = inspectPreview(fetched.raw, fetched.url, upgrade ?? url);
|
|
1972
3670
|
if (problem !== void 0) return await this.saveLinkOnly({
|
|
1973
|
-
url
|
|
3671
|
+
url,
|
|
1974
3672
|
...request.label === void 0 ? {} : { label: request.label },
|
|
1975
3673
|
...resolvedFrom === void 0 ? {} : { resolvedFrom }
|
|
1976
3674
|
}, {
|
|
@@ -1979,7 +3677,7 @@ var ReaderService = class {
|
|
|
1979
3677
|
at: now
|
|
1980
3678
|
}, now);
|
|
1981
3679
|
}
|
|
1982
|
-
const label = request.label ?? (resolution?.kind === "arxiv" ? resolution.title : void 0) ?? defaultSourceLabel(fetched.url);
|
|
3680
|
+
const label = request.label ?? (resolution?.kind === "arxiv" ? resolution.title : void 0) ?? (fetched.media === true ? syntheticLabel(fetched.raw) : void 0) ?? defaultSourceLabel(fetched.url);
|
|
1983
3681
|
const source = {
|
|
1984
3682
|
id: `${feed ? "rss" : "link"}-${hash(fetched.url)}`,
|
|
1985
3683
|
kind: feed ? "rss" : "link",
|
|
@@ -1993,10 +3691,7 @@ var ReaderService = class {
|
|
|
1993
3691
|
...resolvedFrom === void 0 ? {} : { resolvedFrom },
|
|
1994
3692
|
raw: fetched.raw
|
|
1995
3693
|
};
|
|
1996
|
-
await this.commit((current) => (
|
|
1997
|
-
...current,
|
|
1998
|
-
sources: [source, ...current.sources]
|
|
1999
|
-
}));
|
|
3694
|
+
await this.commit((current) => withNewSource(current, source));
|
|
2000
3695
|
return {
|
|
2001
3696
|
outcome: feed ? "subscribed" : "saved-link",
|
|
2002
3697
|
kind: source.kind,
|
|
@@ -2034,10 +3729,7 @@ var ReaderService = class {
|
|
|
2034
3729
|
failure,
|
|
2035
3730
|
...request.resolvedFrom === void 0 ? {} : { resolvedFrom: request.resolvedFrom }
|
|
2036
3731
|
};
|
|
2037
|
-
await this.commit((current) => (
|
|
2038
|
-
...current,
|
|
2039
|
-
sources: [source, ...current.sources]
|
|
2040
|
-
}));
|
|
3732
|
+
await this.commit((current) => withNewSource(current, source));
|
|
2041
3733
|
await this.recordFetchFailure(linkEntryId(source.id), failure.message);
|
|
2042
3734
|
return {
|
|
2043
3735
|
outcome: "saved-link",
|
|
@@ -2132,66 +3824,79 @@ var ReaderService = class {
|
|
|
2132
3824
|
const results = [];
|
|
2133
3825
|
const patches = /* @__PURE__ */ new Map();
|
|
2134
3826
|
const base = new Map(doc.sources.map((source) => [source.id, source]));
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
3827
|
+
/**
|
|
3828
|
+
* Failures to record AFTER the pool: the workers below run concurrently,
|
|
3829
|
+
* but every document write (a failure record is its own commit) stays
|
|
3830
|
+
* single-filed here — the store's commit is a read-modify-write, and two
|
|
3831
|
+
* in flight at once is how a lost update happens.
|
|
3832
|
+
*/
|
|
3833
|
+
const failures = [];
|
|
3834
|
+
const queue = [...wanted];
|
|
3835
|
+
const workers = Array.from({ length: Math.min(3, queue.length) }, async () => {
|
|
3836
|
+
for (;;) {
|
|
3837
|
+
const source = queue.shift();
|
|
3838
|
+
if (source === void 0) return;
|
|
3839
|
+
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3840
|
+
try {
|
|
3841
|
+
const fetched = await this.fetchSource(source.url);
|
|
3842
|
+
const problem = source.kind === "link" && fetched.media !== true ? inspectPreview(fetched.raw, fetched.url, source.url) : void 0;
|
|
3843
|
+
if (problem !== void 0) {
|
|
3844
|
+
const failure = {
|
|
3845
|
+
code: problem.code,
|
|
3846
|
+
message: problem.message,
|
|
3847
|
+
at: fetchedAt
|
|
3848
|
+
};
|
|
3849
|
+
patches.set(source.id, {
|
|
3850
|
+
...withoutFailure(source),
|
|
3851
|
+
fetchedAt,
|
|
3852
|
+
status: "error",
|
|
3853
|
+
error: failure.message,
|
|
3854
|
+
failure
|
|
3855
|
+
});
|
|
3856
|
+
failures.push({
|
|
3857
|
+
entryId: linkEntryId(source.id),
|
|
3858
|
+
message: failure.message
|
|
3859
|
+
});
|
|
3860
|
+
results.push({
|
|
3861
|
+
id: source.id,
|
|
3862
|
+
status: "unavailable",
|
|
3863
|
+
message: failure.message
|
|
3864
|
+
});
|
|
3865
|
+
continue;
|
|
3866
|
+
}
|
|
2146
3867
|
patches.set(source.id, {
|
|
2147
|
-
...withoutFailure(
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
3868
|
+
...withoutFailure(source),
|
|
3869
|
+
url: fetched.url,
|
|
3870
|
+
raw: fetched.raw,
|
|
3871
|
+
fetchedAt,
|
|
3872
|
+
status: "ok",
|
|
3873
|
+
truncated: fetched.truncated
|
|
3874
|
+
});
|
|
3875
|
+
results.push({
|
|
3876
|
+
id: source.id,
|
|
3877
|
+
status: fetched.truncated ? "truncated" : "ok",
|
|
3878
|
+
...fetched.truncated ? { message: "payload hit the fetch seam's size cap" } : {}
|
|
3879
|
+
});
|
|
3880
|
+
} catch (error) {
|
|
3881
|
+
const failure = classifyFetchFailure(error, fetchedAt);
|
|
3882
|
+
const previous = base.get(source.id);
|
|
3883
|
+
patches.set(source.id, {
|
|
3884
|
+
...previous ?? source,
|
|
2152
3885
|
fetchedAt,
|
|
2153
3886
|
status: "error",
|
|
2154
3887
|
error: failure.message,
|
|
2155
3888
|
failure
|
|
2156
3889
|
});
|
|
2157
|
-
await this.recordFetchFailure(linkEntryId(source.id), failure.message);
|
|
2158
3890
|
results.push({
|
|
2159
3891
|
id: source.id,
|
|
2160
|
-
status: "unavailable",
|
|
3892
|
+
status: error instanceof UnsupportedContent ? "unavailable" : "fetch-failed",
|
|
2161
3893
|
message: failure.message
|
|
2162
3894
|
});
|
|
2163
|
-
continue;
|
|
2164
3895
|
}
|
|
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
3896
|
}
|
|
2194
|
-
}
|
|
3897
|
+
});
|
|
3898
|
+
await Promise.all(workers);
|
|
3899
|
+
for (const failure of failures) await this.recordFetchFailure(failure.entryId, failure.message);
|
|
2195
3900
|
if (patches.size > 0) await this.commit((current) => ({
|
|
2196
3901
|
...current,
|
|
2197
3902
|
lastRefreshAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -2329,6 +4034,14 @@ var ReaderService = class {
|
|
|
2329
4034
|
await this.markFetching(request.entryId);
|
|
2330
4035
|
try {
|
|
2331
4036
|
const fetched = await this.fetchFollowing(url);
|
|
4037
|
+
const wall = inspectPreview(fetched.raw, fetched.url, url);
|
|
4038
|
+
if (wall?.code === "blocked") {
|
|
4039
|
+
await this.recordFetchFailure(request.entryId, wall.message, wall.code);
|
|
4040
|
+
return {
|
|
4041
|
+
entryId: request.entryId,
|
|
4042
|
+
error: wall.message
|
|
4043
|
+
};
|
|
4044
|
+
}
|
|
2332
4045
|
const rawFile = await this.storeRaw(request.entryId, fetched.raw, fetched.url, fetched.truncated);
|
|
2333
4046
|
return {
|
|
2334
4047
|
entryId: request.entryId,
|
|
@@ -2546,7 +4259,7 @@ var ReaderService = class {
|
|
|
2546
4259
|
chars: html.length
|
|
2547
4260
|
},
|
|
2548
4261
|
fetchedAt: now.toISOString(),
|
|
2549
|
-
expiresAt: ttlHours === 0 ? new Date(now.getTime() + 31536e8).toISOString() : new Date(now.getTime() + ttlHours * 60 * 60 * 1e3).toISOString(),
|
|
4262
|
+
expiresAt: ttlHours === 0 || request.keep === true ? new Date(now.getTime() + 31536e8).toISOString() : new Date(now.getTime() + ttlHours * 60 * 60 * 1e3).toISOString(),
|
|
2550
4263
|
url: request.url,
|
|
2551
4264
|
...request.truncated === true ? { truncated: true } : {},
|
|
2552
4265
|
...request.scriptFigures === void 0 ? {} : { scriptFigures: request.scriptFigures },
|
|
@@ -2604,6 +4317,7 @@ var ReaderService = class {
|
|
|
2604
4317
|
for (const entry of request.entries) {
|
|
2605
4318
|
if (entry.url === "") continue;
|
|
2606
4319
|
if (entry.hasBody) continue;
|
|
4320
|
+
if (mediaLink(entry.url) !== void 0) continue;
|
|
2607
4321
|
const source = doc.sources.find((item) => item.kind === "link" && sameTarget(item.url, entry.url));
|
|
2608
4322
|
if (source?.failure !== void 0 && !isRetryablePreviewFailure(source.failure.code)) continue;
|
|
2609
4323
|
const annotation = doc.annotations?.[entry.entryId];
|
|
@@ -3119,7 +4833,7 @@ var ReaderService = class {
|
|
|
3119
4833
|
* known-unreadable verdict) still answers.
|
|
3120
4834
|
*/
|
|
3121
4835
|
async resolvePaper(url) {
|
|
3122
|
-
const web = this.
|
|
4836
|
+
const web = await this.documents.current();
|
|
3123
4837
|
const fetch = async (target) => {
|
|
3124
4838
|
if (web === void 0) throw new UnsupportedContent("no web capability is mounted");
|
|
3125
4839
|
const result = await web.fetch({ url: target });
|
|
@@ -3134,10 +4848,6 @@ var ReaderService = class {
|
|
|
3134
4848
|
return null;
|
|
3135
4849
|
}
|
|
3136
4850
|
}
|
|
3137
|
-
/** The probed web seam (no host import, no boot-time dependency). */
|
|
3138
|
-
web() {
|
|
3139
|
-
return this.ctx.get("web");
|
|
3140
|
-
}
|
|
3141
4851
|
/** The probed side-chat service. */
|
|
3142
4852
|
sideChat() {
|
|
3143
4853
|
return this.ctx.get("sideChat");
|
|
@@ -3188,7 +4898,7 @@ var ReaderService = class {
|
|
|
3188
4898
|
* @returns the final URL and its decoded payload.
|
|
3189
4899
|
*/
|
|
3190
4900
|
async fetchFollowing(url) {
|
|
3191
|
-
const web = this.
|
|
4901
|
+
const web = await this.documents.current();
|
|
3192
4902
|
if (web === void 0) throw new UnsupportedContent("no web capability is mounted");
|
|
3193
4903
|
let current = url;
|
|
3194
4904
|
for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop++) try {
|
|
@@ -3204,7 +4914,169 @@ var ReaderService = class {
|
|
|
3204
4914
|
if (target === void 0) throw error;
|
|
3205
4915
|
current = target;
|
|
3206
4916
|
}
|
|
3207
|
-
throw new UnsupportedContent(`more than ${MAX_REDIRECT_HOPS} cross-origin redirects`);
|
|
4917
|
+
throw new UnsupportedContent(`more than ${MAX_REDIRECT_HOPS} cross-origin redirects`);
|
|
4918
|
+
}
|
|
4919
|
+
/**
|
|
4920
|
+
* Fetch a source's payload, media-aware (media-host.ts).
|
|
4921
|
+
*
|
|
4922
|
+
* A YouTube channel is fetched as YouTube's own channel feed; a Xiaoyuzhou
|
|
4923
|
+
* podcast page as the RSS document it is turned into; a single video or
|
|
4924
|
+
* Xiaoyuzhou episode as a synthetic page carrying its media description.
|
|
4925
|
+
* Everything else is the ordinary {@link fetchFollowing}.
|
|
4926
|
+
*
|
|
4927
|
+
* @param url - the source URL.
|
|
4928
|
+
*/
|
|
4929
|
+
async fetchSource(url) {
|
|
4930
|
+
if (isShortMediaLink(url)) {
|
|
4931
|
+
const target = await resolveShortLink(url);
|
|
4932
|
+
if (target === "" || isShortMediaLink(target)) throw new UnsupportedContent("the short link leads nowhere");
|
|
4933
|
+
return this.fetchSource(canonicalMediaUrl(target) ?? target);
|
|
4934
|
+
}
|
|
4935
|
+
const link = mediaLink(url);
|
|
4936
|
+
if (link === void 0) return this.fetchFollowing(url);
|
|
4937
|
+
if (link.provider === "youtube" && link.kind === "channel") return {
|
|
4938
|
+
url,
|
|
4939
|
+
raw: channelRss(await this.media.ytdlp.channel(url), url),
|
|
4940
|
+
truncated: false
|
|
4941
|
+
};
|
|
4942
|
+
if (link.provider === "xiaoyuzhou") {
|
|
4943
|
+
const page = await this.fetchFollowing(url);
|
|
4944
|
+
if (link.kind === "podcast") {
|
|
4945
|
+
const rss = podcastPageToRss(page.raw, url);
|
|
4946
|
+
if (rss === void 0) throw new UnsupportedContent("the podcast page carries no episodes");
|
|
4947
|
+
return {
|
|
4948
|
+
url,
|
|
4949
|
+
raw: rss,
|
|
4950
|
+
truncated: false
|
|
4951
|
+
};
|
|
4952
|
+
}
|
|
4953
|
+
const episode = readEpisodePage(page.raw);
|
|
4954
|
+
if (episode === void 0) throw new UnsupportedContent("the episode page carries no audio");
|
|
4955
|
+
return {
|
|
4956
|
+
url,
|
|
4957
|
+
raw: episodePage(episode),
|
|
4958
|
+
truncated: false,
|
|
4959
|
+
media: true
|
|
4960
|
+
};
|
|
4961
|
+
}
|
|
4962
|
+
if (link.kind === "video" && (link.provider === "youtube" || link.provider === "bilibili")) {
|
|
4963
|
+
const clean = canonicalMediaUrl(url) ?? url;
|
|
4964
|
+
return {
|
|
4965
|
+
url: clean,
|
|
4966
|
+
raw: videoPage(await this.media.ytdlp.info(clean), link.provider, link.id),
|
|
4967
|
+
truncated: false,
|
|
4968
|
+
media: true
|
|
4969
|
+
};
|
|
4970
|
+
}
|
|
4971
|
+
return this.fetchFollowing(url);
|
|
4972
|
+
}
|
|
4973
|
+
/**
|
|
4974
|
+
* A video entry's transcript: the stored one, else the platform captions
|
|
4975
|
+
* fetched now and kept (no TTL — it costs a yt-dlp run). A video known to
|
|
4976
|
+
* have no captions answers so without running yt-dlp again.
|
|
4977
|
+
*
|
|
4978
|
+
* @param request - the entry and its video page URL.
|
|
4979
|
+
* @returns the transcript markup, `none`, or why it failed.
|
|
4980
|
+
*/
|
|
4981
|
+
async mediaTranscript(request) {
|
|
4982
|
+
const known = this.media.captions(request.entryId);
|
|
4983
|
+
if (known?.state === "none" || known?.state === "local") return {
|
|
4984
|
+
entryId: request.entryId,
|
|
4985
|
+
captions: "none"
|
|
4986
|
+
};
|
|
4987
|
+
const link = mediaLink(request.url);
|
|
4988
|
+
if (link?.kind !== "video" || link.provider !== "youtube" && link.provider !== "bilibili") return {
|
|
4989
|
+
entryId: request.entryId,
|
|
4990
|
+
captions: "none"
|
|
4991
|
+
};
|
|
4992
|
+
try {
|
|
4993
|
+
const transcript = await this.media.transcript(request.url);
|
|
4994
|
+
if (transcript === "none") {
|
|
4995
|
+
this.media.remember(request.entryId, {
|
|
4996
|
+
state: "none",
|
|
4997
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4998
|
+
});
|
|
4999
|
+
return {
|
|
5000
|
+
entryId: request.entryId,
|
|
5001
|
+
captions: "none"
|
|
5002
|
+
};
|
|
5003
|
+
}
|
|
5004
|
+
const stored = await this.storeEntryBody({
|
|
5005
|
+
entryId: request.entryId,
|
|
5006
|
+
url: request.url,
|
|
5007
|
+
html: transcript.html,
|
|
5008
|
+
keep: true
|
|
5009
|
+
});
|
|
5010
|
+
this.media.remember(request.entryId, {
|
|
5011
|
+
state: "platform",
|
|
5012
|
+
language: transcript.language,
|
|
5013
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5014
|
+
});
|
|
5015
|
+
return {
|
|
5016
|
+
entryId: request.entryId,
|
|
5017
|
+
captions: "platform",
|
|
5018
|
+
language: transcript.language,
|
|
5019
|
+
...stored.html === void 0 ? {} : { html: stored.html }
|
|
5020
|
+
};
|
|
5021
|
+
} catch (error) {
|
|
5022
|
+
return {
|
|
5023
|
+
entryId: request.entryId,
|
|
5024
|
+
captions: "failed",
|
|
5025
|
+
error: error instanceof Error ? error.message : String(error)
|
|
5026
|
+
};
|
|
5027
|
+
}
|
|
5028
|
+
}
|
|
5029
|
+
/** The reader's names for an entry's speakers, by index. */
|
|
5030
|
+
speakerNames(request) {
|
|
5031
|
+
return { ...this.media.captions(request.entryId)?.names ?? {} };
|
|
5032
|
+
}
|
|
5033
|
+
/**
|
|
5034
|
+
* Name one speaker of an entry's transcript (an empty name restores the default).
|
|
5035
|
+
*
|
|
5036
|
+
* @param request - the entry, the speaker's index, the name.
|
|
5037
|
+
*/
|
|
5038
|
+
nameSpeaker(request) {
|
|
5039
|
+
const record = this.media.captions(request.entryId);
|
|
5040
|
+
if (record === void 0) return {};
|
|
5041
|
+
const names = { ...record.names ?? {} };
|
|
5042
|
+
const name = request.name.trim().slice(0, 40);
|
|
5043
|
+
if (name === "") delete names[String(request.speaker)];
|
|
5044
|
+
else names[String(request.speaker)] = name;
|
|
5045
|
+
this.media.remember(request.entryId, {
|
|
5046
|
+
...record,
|
|
5047
|
+
names
|
|
5048
|
+
});
|
|
5049
|
+
return names;
|
|
5050
|
+
}
|
|
5051
|
+
/**
|
|
5052
|
+
* The wall's media cards: a live local job first, else what the captions
|
|
5053
|
+
* record says (platform captions, a stored local transcript, none).
|
|
5054
|
+
*
|
|
5055
|
+
* @param request - the card ids on screen.
|
|
5056
|
+
*/
|
|
5057
|
+
mediaStates(request) {
|
|
5058
|
+
const states = {};
|
|
5059
|
+
for (const entryId of request.entryIds) {
|
|
5060
|
+
const job = this.local.status(entryId);
|
|
5061
|
+
if (job.state === "fetching" || job.state === "queued" || job.state === "transcribing") {
|
|
5062
|
+
states[entryId] = {
|
|
5063
|
+
state: job.state,
|
|
5064
|
+
...job.progress === void 0 ? {} : { progress: job.progress },
|
|
5065
|
+
...job.ahead === void 0 ? {} : { ahead: job.ahead }
|
|
5066
|
+
};
|
|
5067
|
+
continue;
|
|
5068
|
+
}
|
|
5069
|
+
if (job.state === "failed") {
|
|
5070
|
+
states[entryId] = { state: "failed" };
|
|
5071
|
+
continue;
|
|
5072
|
+
}
|
|
5073
|
+
const record = this.media.captions(entryId);
|
|
5074
|
+
if (record !== void 0) states[entryId] = { state: record.state };
|
|
5075
|
+
}
|
|
5076
|
+
return {
|
|
5077
|
+
engine: this.local.engineAvailable(),
|
|
5078
|
+
states
|
|
5079
|
+
};
|
|
3208
5080
|
}
|
|
3209
5081
|
/** Arm (or re-arm) the daily refresh timer against the current document. */
|
|
3210
5082
|
async armSchedule() {
|
|
@@ -3240,6 +5112,27 @@ function normalizeUrl(input) {
|
|
|
3240
5112
|
}
|
|
3241
5113
|
}
|
|
3242
5114
|
/** Whether two URLs point at the same target, ignoring a trailing slash. */
|
|
5115
|
+
/**
|
|
5116
|
+
* The document with `source` added, unless an add that raced this one already
|
|
5117
|
+
* filed it.
|
|
5118
|
+
*
|
|
5119
|
+
* `addSource` checks for a duplicate BEFORE its fetch, which takes long enough
|
|
5120
|
+
* (hundreds of ms to seconds) for a second click on 「添加」 to pass the same
|
|
5121
|
+
* check; both then committed, and the reader saw the card twice under one id
|
|
5122
|
+
* (measured on 3080, 2026-10-04: two `link-1v6imqy` rows 0.7 s apart). The
|
|
5123
|
+
* re-check inside the commit is what makes the add idempotent.
|
|
5124
|
+
*
|
|
5125
|
+
* @param doc - the current document.
|
|
5126
|
+
* @param source - the source to file.
|
|
5127
|
+
* @returns the document, with the source prepended when it is new.
|
|
5128
|
+
*/
|
|
5129
|
+
function withNewSource(doc, source) {
|
|
5130
|
+
if (doc.sources.some((existing) => existing.id === source.id || sameTarget(existing.url, source.url))) return doc;
|
|
5131
|
+
return {
|
|
5132
|
+
...doc,
|
|
5133
|
+
sources: [source, ...doc.sources]
|
|
5134
|
+
};
|
|
5135
|
+
}
|
|
3243
5136
|
function sameTarget(a, b) {
|
|
3244
5137
|
const strip = (value) => value.replace(/\/+$/, "");
|
|
3245
5138
|
return strip(a) === strip(b);
|
|
@@ -3299,7 +5192,7 @@ function errorCode(error) {
|
|
|
3299
5192
|
return typeof code === "string" ? code : void 0;
|
|
3300
5193
|
}
|
|
3301
5194
|
/** 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
|
|
5195
|
+
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
5196
|
/** Marker words an authentication interstitial leaves behind. */
|
|
3304
5197
|
const LOGIN_WALL_PATTERN = /cookie required|requires? cookies|sign in|sign-in|log in|login|please log|single sign|sso|qurl=|ezproxy|shibboleth|访问权限|请登录|登录后/;
|
|
3305
5198
|
/** Hosts whose NAME alone says the hop is an authentication wall. */
|
|
@@ -3326,188 +5219,1121 @@ function inspectPreview(raw, finalUrl, requestedUrl) {
|
|
|
3326
5219
|
code: "blocked",
|
|
3327
5220
|
message: `the site answered a bot challenge instead of the page (${finalUrl})`
|
|
3328
5221
|
};
|
|
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})`
|
|
5222
|
+
if (LOGIN_WALL_PATTERN.test(haystack) || LOGIN_HOST_PATTERN.test(host)) return {
|
|
5223
|
+
code: "login",
|
|
5224
|
+
message: `the page is an authentication interstitial (${finalUrl})`
|
|
5225
|
+
};
|
|
5226
|
+
if (hostOf(requestedUrl) !== host) return {
|
|
5227
|
+
code: "redirected",
|
|
5228
|
+
message: `the address redirected to ${finalUrl}, which yielded no readable page`
|
|
5229
|
+
};
|
|
5230
|
+
return {
|
|
5231
|
+
code: "empty",
|
|
5232
|
+
message: `the page yielded ${text} characters of text (${finalUrl})`
|
|
5233
|
+
};
|
|
5234
|
+
}
|
|
5235
|
+
/**
|
|
5236
|
+
* Classify a thrown fetch failure into the reason the reader sees.
|
|
5237
|
+
*
|
|
5238
|
+
* Everything here is per-case diagnosable: the seam's own message is kept
|
|
5239
|
+
* verbatim, and only the CODE decides which sentence the browser shows and
|
|
5240
|
+
* whether an automatic retry is allowed.
|
|
5241
|
+
*
|
|
5242
|
+
* @param error - the value the fetch threw.
|
|
5243
|
+
* @param at - the failure instant (ISO-8601).
|
|
5244
|
+
* @returns the recorded failure.
|
|
5245
|
+
*/
|
|
5246
|
+
function classifyFetchFailure(error, at) {
|
|
5247
|
+
const message = errorMessage(error);
|
|
5248
|
+
const code = errorCode(error);
|
|
5249
|
+
if (code === "WEB_UNSUPPORTED_CONTENT_TYPE" || /unsupported content type/i.test(message)) return {
|
|
5250
|
+
code: "unsupported-type",
|
|
5251
|
+
message,
|
|
5252
|
+
at
|
|
5253
|
+
};
|
|
5254
|
+
if (code === "WEB_REDIRECT_BLOCKED" || /cross-origin redirect/i.test(message)) {
|
|
5255
|
+
if (isCookieHandshake(message)) return {
|
|
5256
|
+
code: "blocked",
|
|
5257
|
+
message,
|
|
5258
|
+
at
|
|
5259
|
+
};
|
|
5260
|
+
const origin = /cross-origin redirect to (\S+)/i.exec(message)?.[1];
|
|
5261
|
+
return {
|
|
5262
|
+
code: origin !== void 0 && LOGIN_HOST_PATTERN.test(hostOf(origin)) ? "login" : "redirected",
|
|
5263
|
+
message,
|
|
5264
|
+
at
|
|
5265
|
+
};
|
|
5266
|
+
}
|
|
5267
|
+
const status = /\bHTTP (\d{3})\b/.exec(message)?.[1];
|
|
5268
|
+
if (status === "401" || status === "403") return {
|
|
5269
|
+
code: "blocked",
|
|
5270
|
+
message,
|
|
5271
|
+
at
|
|
5272
|
+
};
|
|
5273
|
+
if (status !== void 0) return {
|
|
5274
|
+
code: "http",
|
|
5275
|
+
message,
|
|
5276
|
+
at
|
|
5277
|
+
};
|
|
5278
|
+
return {
|
|
5279
|
+
code: "unreachable",
|
|
5280
|
+
message,
|
|
5281
|
+
at
|
|
5282
|
+
};
|
|
5283
|
+
}
|
|
5284
|
+
/** The host of a URL, or an empty string when it cannot be parsed. */
|
|
5285
|
+
function hostOf(url) {
|
|
5286
|
+
try {
|
|
5287
|
+
return new URL(url).hostname.toLowerCase();
|
|
5288
|
+
} catch {
|
|
5289
|
+
return "";
|
|
5290
|
+
}
|
|
5291
|
+
}
|
|
5292
|
+
/**
|
|
5293
|
+
* How much visible text a payload carries.
|
|
5294
|
+
*
|
|
5295
|
+
* Script, style and markup are stripped; entities collapse to a single space.
|
|
5296
|
+
* This is a measurement, not a parser: its only job is to separate "a page"
|
|
5297
|
+
* from "an interstitial that is technically HTML".
|
|
5298
|
+
*
|
|
5299
|
+
* @param raw - the decoded payload.
|
|
5300
|
+
* @returns the visible character count.
|
|
5301
|
+
*/
|
|
5302
|
+
function visibleTextLength(raw) {
|
|
5303
|
+
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;
|
|
5304
|
+
}
|
|
5305
|
+
/** The same source with any recorded preview failure removed. */
|
|
5306
|
+
function withoutFailure(source) {
|
|
5307
|
+
const { failure: _removed, ...rest } = source;
|
|
5308
|
+
return rest;
|
|
5309
|
+
}
|
|
5310
|
+
/** Project one source into the browser-facing summary. */
|
|
5311
|
+
function summarize$1(source) {
|
|
5312
|
+
return {
|
|
5313
|
+
id: source.id,
|
|
5314
|
+
kind: source.kind,
|
|
5315
|
+
url: source.url,
|
|
5316
|
+
label: source.label ?? defaultSourceLabel(source.url),
|
|
5317
|
+
enabled: source.enabled,
|
|
5318
|
+
addedAt: source.addedAt,
|
|
5319
|
+
hasBody: source.raw !== void 0,
|
|
5320
|
+
...source.fetchedAt !== void 0 ? { fetchedAt: source.fetchedAt } : {},
|
|
5321
|
+
...source.status !== void 0 ? { status: source.status } : {},
|
|
5322
|
+
...source.error !== void 0 ? { error: source.error } : {},
|
|
5323
|
+
...source.truncated === true ? { truncated: true } : {},
|
|
5324
|
+
...source.failure === void 0 ? {} : { failure: {
|
|
5325
|
+
code: source.failure.code,
|
|
5326
|
+
message: source.failure.message
|
|
5327
|
+
} },
|
|
5328
|
+
...source.resolvedFrom === void 0 ? {} : { resolvedFrom: source.resolvedFrom }
|
|
5329
|
+
};
|
|
5330
|
+
}
|
|
5331
|
+
/**
|
|
5332
|
+
* The same annotation with no `tagIds` key at all.
|
|
5333
|
+
*
|
|
5334
|
+
* Under `exactOptionalPropertyTypes` an explicit `undefined` is not the same as
|
|
5335
|
+
* an absent optional field, and this document is serialized straight to disk —
|
|
5336
|
+
* so an empty tag list must be an ABSENT key, not a null one.
|
|
5337
|
+
*
|
|
5338
|
+
* @param entry - the annotation to strip.
|
|
5339
|
+
* @returns the annotation without its tag list.
|
|
5340
|
+
*/
|
|
5341
|
+
function withoutTags(entry) {
|
|
5342
|
+
const { tagIds: _removed, ...rest } = entry;
|
|
5343
|
+
return rest;
|
|
5344
|
+
}
|
|
5345
|
+
/**
|
|
5346
|
+
* Drop annotations that carry nothing, so the document does not accumulate
|
|
5347
|
+
* empty objects as tags are removed.
|
|
5348
|
+
*
|
|
5349
|
+
* @param annotations - the table to clean.
|
|
5350
|
+
* @returns the table with empty entries removed.
|
|
5351
|
+
*/
|
|
5352
|
+
function cleanupAnnotations(annotations) {
|
|
5353
|
+
const out = {};
|
|
5354
|
+
for (const [entryId, entry] of Object.entries(annotations)) {
|
|
5355
|
+
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;
|
|
5356
|
+
out[entryId] = (entry.tagIds ?? []).length > 0 ? entry : withoutTags(entry);
|
|
5357
|
+
}
|
|
5358
|
+
return out;
|
|
5359
|
+
}
|
|
5360
|
+
/**
|
|
5361
|
+
* Parse a translation sidecar's segment map, tolerating a damaged file.
|
|
5362
|
+
*
|
|
5363
|
+
* @param raw - the file's content, or `undefined` when it is gone.
|
|
5364
|
+
* @returns the map, or `undefined` when the file holds none.
|
|
5365
|
+
*/
|
|
5366
|
+
function readSegmentsFile(raw) {
|
|
5367
|
+
if (raw === void 0) return void 0;
|
|
5368
|
+
try {
|
|
5369
|
+
const parsed = JSON.parse(raw);
|
|
5370
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
5371
|
+
return Object.fromEntries(Object.entries(parsed).filter(([, target]) => typeof target === "string"));
|
|
5372
|
+
} catch {
|
|
5373
|
+
return;
|
|
5374
|
+
}
|
|
5375
|
+
}
|
|
5376
|
+
/**
|
|
5377
|
+
* Coerce one batch of learned sentences: bounded in count and in size, each
|
|
5378
|
+
* carrying its hash and both texts.
|
|
5379
|
+
*
|
|
5380
|
+
* @param entries - the client's batch.
|
|
5381
|
+
* @returns the entries worth storing.
|
|
5382
|
+
*/
|
|
5383
|
+
function normalizeLearns(entries) {
|
|
5384
|
+
const out = [];
|
|
5385
|
+
for (const entry of entries.slice(0, TRANSLATION_WRITE_LIMIT)) {
|
|
5386
|
+
if (typeof entry.hash !== "string" || entry.hash.length === 0 || entry.hash.length > 64) continue;
|
|
5387
|
+
if (typeof entry.source !== "string" || typeof entry.target !== "string") continue;
|
|
5388
|
+
if (entry.source.length === 0 || entry.target.length === 0) continue;
|
|
5389
|
+
out.push({
|
|
5390
|
+
hash: entry.hash,
|
|
5391
|
+
source: entry.source.slice(0, TRANSLATION_TEXT_LIMIT),
|
|
5392
|
+
target: entry.target.slice(0, TRANSLATION_TEXT_LIMIT)
|
|
5393
|
+
});
|
|
5394
|
+
}
|
|
5395
|
+
return out;
|
|
5396
|
+
}
|
|
5397
|
+
/** How old a `fetching` record may be before it is treated as abandoned. */
|
|
5398
|
+
const FETCH_STALE_MS = 6e5;
|
|
5399
|
+
/** Whether an ISO instant is recent enough to still mean "in flight". */
|
|
5400
|
+
function isRecent(iso, now, windowMs) {
|
|
5401
|
+
const at = new Date(iso).getTime();
|
|
5402
|
+
return Number.isFinite(at) && now - at <= windowMs;
|
|
5403
|
+
}
|
|
5404
|
+
/** Whether a cache deadline is still in the future. */
|
|
5405
|
+
function isFresh(expiresAt) {
|
|
5406
|
+
const deadline = new Date(expiresAt).getTime();
|
|
5407
|
+
return Number.isFinite(deadline) && deadline > Date.now();
|
|
5408
|
+
}
|
|
5409
|
+
//#endregion
|
|
5410
|
+
//#region lib/types/polish.js
|
|
5411
|
+
/**
|
|
5412
|
+
* The 润色 layer: an agent's paragraph-by-paragraph translation of an
|
|
5413
|
+
* article, written back through `reader_translate` and shown in place of the
|
|
5414
|
+
* browser's sentence-by-sentence machine translation.
|
|
5415
|
+
*
|
|
5416
|
+
* Keyed to the article's text as the model read it (the snapshot): a
|
|
5417
|
+
* re-transcription changes the text and so drops the layer; renaming a
|
|
5418
|
+
* speaker only changes the "[mm:ss] 名字:" prefixes and keeps it.
|
|
5419
|
+
*
|
|
5420
|
+
* One JSON file per article under `<state>/polish/`, memory-only when the
|
|
5421
|
+
* state root cannot be written.
|
|
5422
|
+
*
|
|
5423
|
+
* @module @khorsheed/dsh-reader/polish
|
|
5424
|
+
*/
|
|
5425
|
+
/** The time / speaker prefix of a transcript paragraph in the snapshot. */
|
|
5426
|
+
const PREFIX = /^\[\d+(?::\d+){1,2}\]\s*(?:[^:\n]{1,40}:)?/;
|
|
5427
|
+
/**
|
|
5428
|
+
* What a layer belongs to: the paragraphs' text without their time and
|
|
5429
|
+
* speaker prefixes, hashed.
|
|
5430
|
+
*/
|
|
5431
|
+
function sourceKey(paragraphs) {
|
|
5432
|
+
const hash = createHash("sha1");
|
|
5433
|
+
hash.update(String(paragraphs.length));
|
|
5434
|
+
for (const paragraph of paragraphs) hash.update(`\n${paragraph.replace(PREFIX, "")}`);
|
|
5435
|
+
return hash.digest("hex").slice(0, 24);
|
|
5436
|
+
}
|
|
5437
|
+
/** The per-article 润色 layers. */
|
|
5438
|
+
var ReaderPolishStore = class {
|
|
5439
|
+
dir;
|
|
5440
|
+
memory = /* @__PURE__ */ new Map();
|
|
5441
|
+
constructor(stateRoot) {
|
|
5442
|
+
this.dir = join(stateRoot, "polish");
|
|
5443
|
+
}
|
|
5444
|
+
fileOf(entryId) {
|
|
5445
|
+
return join(this.dir, `${createHash("sha1").update(entryId).digest("hex").slice(0, 32)}.json`);
|
|
5446
|
+
}
|
|
5447
|
+
load(entryId) {
|
|
5448
|
+
const cached = this.memory.get(entryId);
|
|
5449
|
+
if (cached !== void 0) return cached;
|
|
5450
|
+
try {
|
|
5451
|
+
const parsed = JSON.parse(readFileSync(this.fileOf(entryId), "utf8"));
|
|
5452
|
+
if (parsed.entryId !== entryId) return void 0;
|
|
5453
|
+
this.memory.set(entryId, parsed);
|
|
5454
|
+
return parsed;
|
|
5455
|
+
} catch {
|
|
5456
|
+
return;
|
|
5457
|
+
}
|
|
5458
|
+
}
|
|
5459
|
+
save(file) {
|
|
5460
|
+
this.memory.set(file.entryId, file);
|
|
5461
|
+
try {
|
|
5462
|
+
mkdirSync(this.dir, { recursive: true });
|
|
5463
|
+
const target = this.fileOf(file.entryId);
|
|
5464
|
+
writeFileSync(`${target}.tmp`, JSON.stringify(file));
|
|
5465
|
+
renameSync(`${target}.tmp`, target);
|
|
5466
|
+
} catch {}
|
|
5467
|
+
}
|
|
5468
|
+
/**
|
|
5469
|
+
* An article's layer, when it belongs to the text now captured.
|
|
5470
|
+
*
|
|
5471
|
+
* @param entryId - the article.
|
|
5472
|
+
* @param key - the current text's {@link sourceKey}.
|
|
5473
|
+
*/
|
|
5474
|
+
view(entryId, key) {
|
|
5475
|
+
const file = this.load(entryId);
|
|
5476
|
+
if (file === void 0 || key === void 0 || file.sourceKey !== key) return void 0;
|
|
5477
|
+
const done = Object.keys(file.paragraphs).length;
|
|
5478
|
+
return {
|
|
5479
|
+
total: file.total,
|
|
5480
|
+
done,
|
|
5481
|
+
paragraphs: { ...file.paragraphs },
|
|
5482
|
+
off: [...file.off],
|
|
5483
|
+
updatedAt: file.updatedAt,
|
|
5484
|
+
complete: done >= file.total
|
|
5485
|
+
};
|
|
5486
|
+
}
|
|
5487
|
+
/**
|
|
5488
|
+
* Merge the model's translations into the layer (a different text starts a
|
|
5489
|
+
* fresh one).
|
|
5490
|
+
*
|
|
5491
|
+
* @returns the layer after the write.
|
|
5492
|
+
*/
|
|
5493
|
+
write(entryId, key, total, updates) {
|
|
5494
|
+
const before = this.load(entryId);
|
|
5495
|
+
const fresh = before === void 0 || before.sourceKey !== key;
|
|
5496
|
+
const paragraphs = fresh ? {} : { ...before.paragraphs };
|
|
5497
|
+
for (const [index, value] of updates) if (index >= 1 && index <= total) paragraphs[String(index)] = value;
|
|
5498
|
+
const file = {
|
|
5499
|
+
entryId,
|
|
5500
|
+
sourceKey: key,
|
|
5501
|
+
total,
|
|
5502
|
+
paragraphs,
|
|
5503
|
+
off: fresh ? [] : before.off,
|
|
5504
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5505
|
+
};
|
|
5506
|
+
this.save(file);
|
|
5507
|
+
return this.view(entryId, key);
|
|
5508
|
+
}
|
|
5509
|
+
/** Turn one paragraph back to (or away from) the machine translation. */
|
|
5510
|
+
setOff(entryId, key, paragraph, off) {
|
|
5511
|
+
const file = this.load(entryId);
|
|
5512
|
+
if (file === void 0 || key === void 0 || file.sourceKey !== key) return void 0;
|
|
5513
|
+
const rest = file.off.filter((item) => item !== paragraph);
|
|
5514
|
+
this.save({
|
|
5515
|
+
...file,
|
|
5516
|
+
off: off ? [...rest, paragraph] : rest
|
|
5517
|
+
});
|
|
5518
|
+
return this.view(entryId, key);
|
|
5519
|
+
}
|
|
5520
|
+
/** Forget an article's layer. */
|
|
5521
|
+
drop(entryId) {
|
|
5522
|
+
this.memory.delete(entryId);
|
|
5523
|
+
rmSync(this.fileOf(entryId), { force: true });
|
|
5524
|
+
}
|
|
5525
|
+
};
|
|
5526
|
+
//#endregion
|
|
5527
|
+
//#region lib/types/notes.js
|
|
5528
|
+
/**
|
|
5529
|
+
* The reader's notes: excerpts the reader kept from an article, the comment on
|
|
5530
|
+
* each, which conversations discussed the article, and the article text the
|
|
5531
|
+
* model reads when it does.
|
|
5532
|
+
*
|
|
5533
|
+
* Notes are what the reader OWNS, so they live on the host next to the rest of
|
|
5534
|
+
* the reader's state (the state-boundaries note, rule 1) — but in their own
|
|
5535
|
+
* file, `notes.json`, not inside `state.json`'s per-entry annotations. An
|
|
5536
|
+
* annotation is shaped around the body cache: `boundAnnotations` evicts by
|
|
5537
|
+
* body, `normalizeAnnotations` drops entries that carry nothing it knows. A
|
|
5538
|
+
* note must outlive every one of those (the excerpt carries its own text), and
|
|
5539
|
+
* keeping it out of that machinery is what makes "the body was evicted" and
|
|
5540
|
+
* "the note is gone" two unrelated events.
|
|
5541
|
+
*
|
|
5542
|
+
* The article text is a second, separate concern: the host never parses an
|
|
5543
|
+
* article (the browser half does), and a feed entry's text never reaches the
|
|
5544
|
+
* host at all. So the browser hands over a SNAPSHOT — the article's original
|
|
5545
|
+
* prose, one string per top-level block, numbered the way the pane numbers its
|
|
5546
|
+
* blocks — and the model reads that. Snapshots are capped and pruned; notes
|
|
5547
|
+
* are never pruned.
|
|
5548
|
+
*
|
|
5549
|
+
* Persistence mirrors `store.ts`: native fs at the deployment's state root,
|
|
5550
|
+
* atomic temp-file-and-rename writes, and an in-memory document when the root
|
|
5551
|
+
* is not writable. Writes are serialized through one promise chain, so two
|
|
5552
|
+
* gestures landing together never read the same version and lose one.
|
|
5553
|
+
*
|
|
5554
|
+
* @module @khorsheed/dsh-reader/notes
|
|
5555
|
+
*/
|
|
5556
|
+
/** The notes document's file name inside the state root. */
|
|
5557
|
+
const READER_NOTES_FILE_NAME = "notes.json";
|
|
5558
|
+
/** Where article snapshots live, inside the state root. */
|
|
5559
|
+
const READER_SNAPSHOTS_DIR = "snapshots";
|
|
5560
|
+
/** Most notes one article keeps (a cap against a runaway client, not a quota anyone reaches). */
|
|
5561
|
+
const MAX_NOTES_PER_ENTRY = 500;
|
|
5562
|
+
/** Most conversations one article remembers. */
|
|
5563
|
+
const MAX_SESSIONS_PER_ENTRY = 50;
|
|
5564
|
+
/** Snapshots kept for articles with no notes and no discussions (the most recent ones). */
|
|
5565
|
+
const MAX_LOOSE_SNAPSHOTS = 30;
|
|
5566
|
+
/** The cap on one snapshot's total characters (a long paper fits; a book does not). */
|
|
5567
|
+
const MAX_SNAPSHOT_CHARS = 4e5;
|
|
5568
|
+
/** Most lines under one note. */
|
|
5569
|
+
const MAX_REPLIES_PER_NOTE = 50;
|
|
5570
|
+
/** Most discussion notes one article keeps (the oldest go first). */
|
|
5571
|
+
const MAX_DISCUSSIONS_PER_ENTRY = 100;
|
|
5572
|
+
/** Most points in one discussion note. */
|
|
5573
|
+
const MAX_POINTS = 30;
|
|
5574
|
+
/** The cap on one reply or one discussion point. */
|
|
5575
|
+
const MAX_POINT_CHARS = 4e3;
|
|
5576
|
+
/** The cap on one excerpt. */
|
|
5577
|
+
const MAX_QUOTE_CHARS = 2e3;
|
|
5578
|
+
/** The cap on one comment. */
|
|
5579
|
+
const MAX_COMMENT_CHARS = 4e3;
|
|
5580
|
+
/** The empty document. */
|
|
5581
|
+
function emptyNotesDoc() {
|
|
5582
|
+
return {
|
|
5583
|
+
version: 1,
|
|
5584
|
+
entries: {},
|
|
5585
|
+
snapshots: {}
|
|
5586
|
+
};
|
|
5587
|
+
}
|
|
5588
|
+
/** A string field, trimmed and capped, or `undefined` when empty. */
|
|
5589
|
+
function text(value, cap) {
|
|
5590
|
+
if (typeof value !== "string") return void 0;
|
|
5591
|
+
const trimmed = value.trim();
|
|
5592
|
+
return trimmed.length === 0 ? void 0 : trimmed.slice(0, cap);
|
|
5593
|
+
}
|
|
5594
|
+
/** A timestamp field, or the epoch when it is missing. */
|
|
5595
|
+
function stamp(value) {
|
|
5596
|
+
return typeof value === "string" && value.length > 0 ? value : (/* @__PURE__ */ new Date(0)).toISOString();
|
|
5597
|
+
}
|
|
5598
|
+
/** One note, when it carries an id and an excerpt. */
|
|
5599
|
+
function normalizeNote(value) {
|
|
5600
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
5601
|
+
const record = value;
|
|
5602
|
+
const id = text(record.id, 20);
|
|
5603
|
+
const anchor = record.anchor;
|
|
5604
|
+
const quote = text(anchor?.quote, MAX_QUOTE_CHARS);
|
|
5605
|
+
if (id === void 0 || quote === void 0) return void 0;
|
|
5606
|
+
const block = typeof anchor?.block === "number" && Number.isInteger(anchor.block) && anchor.block >= 0 ? anchor.block : 0;
|
|
5607
|
+
const start = typeof anchor?.start === "number" && Number.isInteger(anchor.start) && anchor.start >= 0 ? anchor.start : void 0;
|
|
5608
|
+
const translated = text(anchor?.translated, MAX_QUOTE_CHARS);
|
|
5609
|
+
const time = typeof anchor?.time === "number" && Number.isFinite(anchor.time) && anchor.time >= 0 ? anchor.time : void 0;
|
|
5610
|
+
const comment = text(record.comment, MAX_COMMENT_CHARS);
|
|
5611
|
+
const replies = Array.isArray(record.replies) ? record.replies.map(normalizeReply).filter((reply) => reply !== void 0).slice(-50) : [];
|
|
5612
|
+
return {
|
|
5613
|
+
id,
|
|
5614
|
+
anchor: {
|
|
5615
|
+
block,
|
|
5616
|
+
...start === void 0 ? {} : { start },
|
|
5617
|
+
quote,
|
|
5618
|
+
...translated === void 0 ? {} : { translated },
|
|
5619
|
+
...time === void 0 ? {} : { time }
|
|
5620
|
+
},
|
|
5621
|
+
...comment === void 0 ? {} : { comment },
|
|
5622
|
+
...replies.length === 0 ? {} : { replies },
|
|
5623
|
+
...normalizeSynced(record.syncedTo).length === 0 ? {} : { syncedTo: normalizeSynced(record.syncedTo) },
|
|
5624
|
+
createdAt: stamp(record.createdAt),
|
|
5625
|
+
updatedAt: stamp(record.updatedAt ?? record.createdAt)
|
|
5626
|
+
};
|
|
5627
|
+
}
|
|
5628
|
+
/** The canvas cards an item was synced to (bounded, malformed rows dropped). */
|
|
5629
|
+
function normalizeSynced(value) {
|
|
5630
|
+
if (!Array.isArray(value)) return [];
|
|
5631
|
+
return value.flatMap((item) => {
|
|
5632
|
+
if (typeof item !== "object" || item === null) return [];
|
|
5633
|
+
const record = item;
|
|
5634
|
+
const canvasId = text(record.canvasId, 200);
|
|
5635
|
+
const cardId = text(record.cardId, 200);
|
|
5636
|
+
if (canvasId === void 0 || cardId === void 0) return [];
|
|
5637
|
+
return [{
|
|
5638
|
+
canvasId,
|
|
5639
|
+
cardId,
|
|
5640
|
+
canvasTitle: text(record.canvasTitle, 200) ?? canvasId,
|
|
5641
|
+
at: stamp(record.at)
|
|
5642
|
+
}];
|
|
5643
|
+
}).slice(-20);
|
|
5644
|
+
}
|
|
5645
|
+
/** One line under a note, when it has text. */
|
|
5646
|
+
function normalizeReply(value) {
|
|
5647
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
5648
|
+
const record = value;
|
|
5649
|
+
const body = text(record.text, MAX_POINT_CHARS);
|
|
5650
|
+
if (body === void 0) return void 0;
|
|
5651
|
+
const sessionId = text(record.sessionId, 200);
|
|
5652
|
+
return {
|
|
5653
|
+
by: record.by === "agent" ? "agent" : "user",
|
|
5654
|
+
text: body,
|
|
5655
|
+
...sessionId === void 0 ? {} : { sessionId },
|
|
5656
|
+
...record.editedFrom === "agent" ? { editedFrom: "agent" } : {},
|
|
5657
|
+
at: stamp(record.at)
|
|
3332
5658
|
};
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
5659
|
+
}
|
|
5660
|
+
/** Bound a discussion's points: trimmed, capped, ¶ kept only when it is a positive integer. */
|
|
5661
|
+
function boundPoints(points) {
|
|
5662
|
+
const out = [];
|
|
5663
|
+
for (const item of points) {
|
|
5664
|
+
if (typeof item !== "object" || item === null) continue;
|
|
5665
|
+
const record = item;
|
|
5666
|
+
const body = text(record.text, MAX_POINT_CHARS);
|
|
5667
|
+
if (body === void 0) continue;
|
|
5668
|
+
const para = typeof record.para === "number" && Number.isInteger(record.para) && record.para > 0 ? record.para : void 0;
|
|
5669
|
+
out.push({
|
|
5670
|
+
text: body,
|
|
5671
|
+
...para === void 0 ? {} : { para }
|
|
5672
|
+
});
|
|
5673
|
+
if (out.length >= 30) break;
|
|
5674
|
+
}
|
|
5675
|
+
return out;
|
|
5676
|
+
}
|
|
5677
|
+
/** One discussion note, when it has an id and at least one point. */
|
|
5678
|
+
function normalizeDiscussion(value) {
|
|
5679
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
5680
|
+
const record = value;
|
|
5681
|
+
const id = text(record.id, 20);
|
|
5682
|
+
const points = Array.isArray(record.points) ? boundPoints(record.points) : [];
|
|
5683
|
+
if (id === void 0 || points.length === 0) return void 0;
|
|
5684
|
+
const sessionId = text(record.sessionId, 200);
|
|
5685
|
+
const aside = text(record.aside, 300);
|
|
5686
|
+
return {
|
|
5687
|
+
id,
|
|
5688
|
+
kind: record.kind === "check" ? "check" : "discussion",
|
|
5689
|
+
...sessionId === void 0 ? {} : { sessionId },
|
|
5690
|
+
title: text(record.title, 200) ?? "",
|
|
5691
|
+
points,
|
|
5692
|
+
...aside === void 0 ? {} : { aside },
|
|
5693
|
+
...normalizeSynced(record.syncedTo).length === 0 ? {} : { syncedTo: normalizeSynced(record.syncedTo) },
|
|
5694
|
+
createdAt: stamp(record.createdAt)
|
|
3336
5695
|
};
|
|
5696
|
+
}
|
|
5697
|
+
/** One article's notes, when it has an id and a title. */
|
|
5698
|
+
function normalizeEntry(entryId, value) {
|
|
5699
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
5700
|
+
const record = value;
|
|
5701
|
+
const title = text(record.title, 300) ?? entryId;
|
|
5702
|
+
const notes = Array.isArray(record.notes) ? record.notes.map(normalizeNote).filter((note) => note !== void 0).slice(0, 500) : [];
|
|
5703
|
+
const sessions = Array.isArray(record.sessions) ? record.sessions.flatMap((item) => {
|
|
5704
|
+
if (typeof item !== "object" || item === null) return [];
|
|
5705
|
+
const row = item;
|
|
5706
|
+
const sessionId = text(row.sessionId, 200);
|
|
5707
|
+
if (sessionId === void 0) return [];
|
|
5708
|
+
const sessionTitle = text(row.title, 200);
|
|
5709
|
+
return [{
|
|
5710
|
+
sessionId,
|
|
5711
|
+
...sessionTitle === void 0 ? {} : { title: sessionTitle },
|
|
5712
|
+
lastAt: stamp(row.lastAt)
|
|
5713
|
+
}];
|
|
5714
|
+
}).slice(0, 50) : [];
|
|
5715
|
+
const discussions = Array.isArray(record.discussions) ? record.discussions.map(normalizeDiscussion).filter((item) => item !== void 0).slice(0, 100) : [];
|
|
5716
|
+
if (notes.length === 0 && sessions.length === 0 && discussions.length === 0) return void 0;
|
|
5717
|
+
const highest = notes.reduce((max, note) => Math.max(max, Number(/^n(\d+)$/.exec(note.id)?.[1] ?? 0)), 0);
|
|
5718
|
+
const stored = typeof record.nextNote === "number" && Number.isInteger(record.nextNote) ? record.nextNote : 1;
|
|
5719
|
+
const highestDiscussion = discussions.reduce((max, item) => Math.max(max, Number(/^d(\d+)$/.exec(item.id)?.[1] ?? 0)), 0);
|
|
5720
|
+
const storedDiscussion = typeof record.nextDiscussion === "number" && Number.isInteger(record.nextDiscussion) ? record.nextDiscussion : 1;
|
|
5721
|
+
const url = text(record.url, 2e3);
|
|
5722
|
+
const source = text(record.source, 200);
|
|
3337
5723
|
return {
|
|
3338
|
-
|
|
3339
|
-
|
|
5724
|
+
entryId,
|
|
5725
|
+
title,
|
|
5726
|
+
...url === void 0 ? {} : { url },
|
|
5727
|
+
...source === void 0 ? {} : { source },
|
|
5728
|
+
notes,
|
|
5729
|
+
sessions,
|
|
5730
|
+
...discussions.length === 0 ? {} : { discussions },
|
|
5731
|
+
nextNote: Math.max(stored, highest + 1),
|
|
5732
|
+
nextDiscussion: Math.max(storedDiscussion, highestDiscussion + 1),
|
|
5733
|
+
updatedAt: stamp(record.updatedAt)
|
|
3340
5734
|
};
|
|
3341
5735
|
}
|
|
3342
5736
|
/**
|
|
3343
|
-
*
|
|
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.
|
|
5737
|
+
* Coerce a parsed value into a notes document, dropping what it cannot read.
|
|
3348
5738
|
*
|
|
3349
|
-
* @param
|
|
3350
|
-
* @
|
|
3351
|
-
* @returns the recorded failure.
|
|
5739
|
+
* @param value - the parsed file.
|
|
5740
|
+
* @returns the document.
|
|
3352
5741
|
*/
|
|
3353
|
-
function
|
|
3354
|
-
|
|
3355
|
-
const
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
5742
|
+
function normalizeNotesDoc(value) {
|
|
5743
|
+
if (typeof value !== "object" || value === null) return emptyNotesDoc();
|
|
5744
|
+
const record = value;
|
|
5745
|
+
const entries = {};
|
|
5746
|
+
if (typeof record.entries === "object" && record.entries !== null) for (const [entryId, entry] of Object.entries(record.entries)) {
|
|
5747
|
+
if (entryId.length === 0) continue;
|
|
5748
|
+
const normalized = normalizeEntry(entryId, entry);
|
|
5749
|
+
if (normalized !== void 0) entries[entryId] = normalized;
|
|
5750
|
+
}
|
|
5751
|
+
const snapshots = {};
|
|
5752
|
+
if (typeof record.snapshots === "object" && record.snapshots !== null) for (const [entryId, row] of Object.entries(record.snapshots)) {
|
|
5753
|
+
if (typeof row !== "object" || row === null) continue;
|
|
5754
|
+
const item = row;
|
|
5755
|
+
const file = typeof item.file === "string" && /^[0-9a-f]{32}\.json$/.test(item.file) ? item.file : void 0;
|
|
5756
|
+
if (file === void 0) continue;
|
|
5757
|
+
snapshots[entryId] = {
|
|
5758
|
+
file,
|
|
5759
|
+
chars: typeof item.chars === "number" && Number.isFinite(item.chars) ? item.chars : 0,
|
|
5760
|
+
capturedAt: stamp(item.capturedAt)
|
|
3367
5761
|
};
|
|
3368
5762
|
}
|
|
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
5763
|
return {
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
5764
|
+
version: 1,
|
|
5765
|
+
entries,
|
|
5766
|
+
snapshots
|
|
3384
5767
|
};
|
|
3385
5768
|
}
|
|
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
5769
|
/**
|
|
3395
|
-
*
|
|
3396
|
-
*
|
|
3397
|
-
*
|
|
3398
|
-
* This is a measurement, not a parser: its only job is to separate "a page"
|
|
3399
|
-
* from "an interstitial that is technically HTML".
|
|
5770
|
+
* Bound a snapshot's paragraphs: whitespace collapsed, empty blocks kept as
|
|
5771
|
+
* empty strings (so ¶ numbers stay aligned with the pane's blocks), and the
|
|
5772
|
+
* whole capped — cut at a paragraph boundary, with the cut reported.
|
|
3400
5773
|
*
|
|
3401
|
-
* @param
|
|
3402
|
-
* @returns the
|
|
5774
|
+
* @param paragraphs - the blocks' original text.
|
|
5775
|
+
* @returns the bounded paragraphs and whether anything was dropped.
|
|
3403
5776
|
*/
|
|
3404
|
-
function
|
|
3405
|
-
|
|
5777
|
+
function boundParagraphs(paragraphs) {
|
|
5778
|
+
const out = [];
|
|
5779
|
+
let total = 0;
|
|
5780
|
+
for (const raw of paragraphs) {
|
|
5781
|
+
const block = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
|
|
5782
|
+
if (total + block.length > 4e5) return {
|
|
5783
|
+
paragraphs: out,
|
|
5784
|
+
truncated: true
|
|
5785
|
+
};
|
|
5786
|
+
out.push(block);
|
|
5787
|
+
total += block.length;
|
|
5788
|
+
}
|
|
5789
|
+
return {
|
|
5790
|
+
paragraphs: out,
|
|
5791
|
+
truncated: false
|
|
5792
|
+
};
|
|
3406
5793
|
}
|
|
3407
|
-
/** The
|
|
3408
|
-
function
|
|
3409
|
-
|
|
3410
|
-
return rest;
|
|
5794
|
+
/** The bare file name one entry's snapshot lives under. */
|
|
5795
|
+
function snapshotFileName(entryId) {
|
|
5796
|
+
return `${createHash("sha1").update(entryId).digest("hex").slice(0, 32)}.json`;
|
|
3411
5797
|
}
|
|
3412
|
-
/**
|
|
3413
|
-
function
|
|
5798
|
+
/** A native fs failure's message, with its code. */
|
|
5799
|
+
function errorMessage$1(error) {
|
|
5800
|
+
const code = error?.code;
|
|
5801
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5802
|
+
return code === void 0 ? message : `${code}: ${message}`;
|
|
5803
|
+
}
|
|
5804
|
+
/** Write a file atomically (temp file, then rename). */
|
|
5805
|
+
function writeAtomic(target, content) {
|
|
5806
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
5807
|
+
try {
|
|
5808
|
+
writeFileSync(temporary, content);
|
|
5809
|
+
renameSync(temporary, target);
|
|
5810
|
+
} catch (error) {
|
|
5811
|
+
rmSync(temporary, { force: true });
|
|
5812
|
+
throw new Error(`reader: ${target} could not be written — ${errorMessage$1(error)}`);
|
|
5813
|
+
}
|
|
5814
|
+
}
|
|
5815
|
+
/** The entry row a write lands on: the existing one refreshed with the latest identity, or a new one. */
|
|
5816
|
+
function entryFor(doc, identity, now) {
|
|
5817
|
+
const existing = doc.entries[identity.entryId];
|
|
5818
|
+
const title = identity.title.trim().slice(0, 300) || existing?.title || identity.entryId;
|
|
5819
|
+
const url = identity.url ?? existing?.url;
|
|
5820
|
+
const source = identity.source ?? existing?.source;
|
|
3414
5821
|
return {
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
url:
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
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 }
|
|
5822
|
+
entryId: identity.entryId,
|
|
5823
|
+
title,
|
|
5824
|
+
...url === void 0 || url === "" ? {} : { url },
|
|
5825
|
+
...source === void 0 || source === "" ? {} : { source },
|
|
5826
|
+
notes: existing?.notes ?? [],
|
|
5827
|
+
sessions: existing?.sessions ?? [],
|
|
5828
|
+
...existing?.discussions === void 0 ? {} : { discussions: existing.discussions },
|
|
5829
|
+
nextNote: existing?.nextNote ?? 1,
|
|
5830
|
+
nextDiscussion: existing?.nextDiscussion ?? 1,
|
|
5831
|
+
updatedAt: now
|
|
3431
5832
|
};
|
|
3432
5833
|
}
|
|
3433
|
-
/**
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
5834
|
+
/** The document with one entry replaced (or removed, when it no longer carries anything). */
|
|
5835
|
+
function withEntry(doc, entry) {
|
|
5836
|
+
const { [entry.entryId]: _previous, ...rest } = doc.entries;
|
|
5837
|
+
return entry.notes.length === 0 && entry.sessions.length === 0 && (entry.discussions ?? []).length === 0 ? {
|
|
5838
|
+
...doc,
|
|
5839
|
+
entries: rest
|
|
5840
|
+
} : {
|
|
5841
|
+
...doc,
|
|
5842
|
+
entries: {
|
|
5843
|
+
...rest,
|
|
5844
|
+
[entry.entryId]: entry
|
|
5845
|
+
}
|
|
5846
|
+
};
|
|
3446
5847
|
}
|
|
3447
|
-
/**
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
*/
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
5848
|
+
/** The reader's notes store. */
|
|
5849
|
+
var ReaderNotesStore = class {
|
|
5850
|
+
root;
|
|
5851
|
+
file;
|
|
5852
|
+
snapshotsDir;
|
|
5853
|
+
unwritable;
|
|
5854
|
+
/** The document of record when the root is not writable. */
|
|
5855
|
+
memoryDoc;
|
|
5856
|
+
/** Snapshots kept in memory when the root is not writable. */
|
|
5857
|
+
memorySnapshots = /* @__PURE__ */ new Map();
|
|
5858
|
+
/** The write queue: every mutation runs after the previous one settled. */
|
|
5859
|
+
queue = Promise.resolve();
|
|
5860
|
+
/** The agent's 润色 translations, per article (keyed to the snapshot's text). */
|
|
5861
|
+
polish;
|
|
5862
|
+
/**
|
|
5863
|
+
* @param config - the same state-root override the reader's main store takes.
|
|
5864
|
+
*/
|
|
5865
|
+
constructor(config = {}) {
|
|
5866
|
+
this.root = resolveReaderStateRoot(config.stateRoot);
|
|
5867
|
+
this.file = join(this.root, READER_NOTES_FILE_NAME);
|
|
5868
|
+
this.snapshotsDir = join(this.root, READER_SNAPSHOTS_DIR);
|
|
5869
|
+
this.polish = new ReaderPolishStore(this.root);
|
|
5870
|
+
}
|
|
5871
|
+
/** An article's 润色 layer, when it belongs to the text captured now. */
|
|
5872
|
+
polishView(entryId) {
|
|
5873
|
+
const snapshot = this.readSnapshot(entryId);
|
|
5874
|
+
return this.polish.view(entryId, snapshot === void 0 ? void 0 : sourceKey(snapshot.paragraphs));
|
|
5875
|
+
}
|
|
5876
|
+
/** Turn one paragraph back to the machine translation, or to the 润色 again. */
|
|
5877
|
+
setPolishOff(entryId, paragraph, off) {
|
|
5878
|
+
const snapshot = this.readSnapshot(entryId);
|
|
5879
|
+
return this.polish.setOff(entryId, snapshot === void 0 ? void 0 : sourceKey(snapshot.paragraphs), paragraph, off);
|
|
5880
|
+
}
|
|
5881
|
+
/** Whether the notes persist (false means memory-only, for this process). */
|
|
5882
|
+
get available() {
|
|
5883
|
+
if (this.unwritable !== void 0) return false;
|
|
5884
|
+
try {
|
|
5885
|
+
mkdirSync(this.root, { recursive: true });
|
|
5886
|
+
return true;
|
|
5887
|
+
} catch (error) {
|
|
5888
|
+
this.unwritable = `${this.root}: ${errorMessage$1(error)}`;
|
|
5889
|
+
return false;
|
|
5890
|
+
}
|
|
5891
|
+
}
|
|
5892
|
+
/** Read the document; a missing or unreadable file reads as empty. */
|
|
5893
|
+
read() {
|
|
5894
|
+
if (!this.available) return this.memoryDoc ?? emptyNotesDoc();
|
|
5895
|
+
try {
|
|
5896
|
+
return normalizeNotesDoc(JSON.parse(readFileSync(this.file, "utf8")));
|
|
5897
|
+
} catch (error) {
|
|
5898
|
+
if (error.code === "ENOENT") return emptyNotesDoc();
|
|
5899
|
+
throw new Error(`reader: ${this.file} does not parse — fix or remove it by hand`);
|
|
5900
|
+
}
|
|
5901
|
+
}
|
|
5902
|
+
/** Run one read-modify-write after every earlier one. */
|
|
5903
|
+
update(mutate) {
|
|
5904
|
+
const run = this.queue.then(() => {
|
|
5905
|
+
const { doc, result } = mutate(this.read());
|
|
5906
|
+
if (this.available) writeAtomic(this.file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
5907
|
+
else this.memoryDoc = doc;
|
|
5908
|
+
return result;
|
|
5909
|
+
});
|
|
5910
|
+
this.queue = run.catch(() => void 0);
|
|
5911
|
+
return run;
|
|
5912
|
+
}
|
|
5913
|
+
/** One article's notes and discussions, oldest note first. */
|
|
5914
|
+
entry(entryId) {
|
|
5915
|
+
return this.read().entries[entryId];
|
|
5916
|
+
}
|
|
5917
|
+
/** Every article that carries notes or discussions. */
|
|
5918
|
+
entries() {
|
|
5919
|
+
return Object.values(this.read().entries);
|
|
5920
|
+
}
|
|
5921
|
+
/** How many notes each article carries (the wall's badge). */
|
|
5922
|
+
counts() {
|
|
5923
|
+
const counts = {};
|
|
5924
|
+
for (const entry of Object.values(this.read().entries)) if (entry.notes.length > 0) counts[entry.entryId] = entry.notes.length;
|
|
5925
|
+
return counts;
|
|
5926
|
+
}
|
|
5927
|
+
/**
|
|
5928
|
+
* Keep one excerpt.
|
|
5929
|
+
*
|
|
5930
|
+
* @param input - the article, the excerpt and where it sits, and the comment.
|
|
5931
|
+
* @returns the new note, or why none was kept.
|
|
5932
|
+
*/
|
|
5933
|
+
addNote(input) {
|
|
5934
|
+
const quote = input.quote.replace(/\s+/g, " ").trim().slice(0, MAX_QUOTE_CHARS);
|
|
5935
|
+
if (quote.length === 0 || input.entryId.length === 0) return Promise.resolve("empty");
|
|
5936
|
+
return this.update((doc) => {
|
|
5937
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5938
|
+
const entry = entryFor(doc, input, now);
|
|
5939
|
+
if (entry.notes.length >= 500) return {
|
|
5940
|
+
doc,
|
|
5941
|
+
result: "full"
|
|
5942
|
+
};
|
|
5943
|
+
const translated = input.translated?.replace(/\s+/g, " ").trim().slice(0, MAX_QUOTE_CHARS);
|
|
5944
|
+
const comment = input.comment?.trim().slice(0, MAX_COMMENT_CHARS);
|
|
5945
|
+
const note = {
|
|
5946
|
+
id: `n${String(entry.nextNote)}`,
|
|
5947
|
+
anchor: {
|
|
5948
|
+
block: Number.isInteger(input.block) && input.block >= 0 ? input.block : 0,
|
|
5949
|
+
...input.start !== void 0 && Number.isInteger(input.start) && input.start >= 0 ? { start: input.start } : {},
|
|
5950
|
+
quote,
|
|
5951
|
+
...translated === void 0 || translated === "" ? {} : { translated },
|
|
5952
|
+
...input.time !== void 0 && Number.isFinite(input.time) && input.time >= 0 ? { time: input.time } : {}
|
|
5953
|
+
},
|
|
5954
|
+
...comment === void 0 || comment === "" ? {} : { comment },
|
|
5955
|
+
createdAt: now,
|
|
5956
|
+
updatedAt: now
|
|
5957
|
+
};
|
|
5958
|
+
const notes = [...entry.notes, note].sort((left, right) => left.anchor.block - right.anchor.block || (left.anchor.start ?? 0) - (right.anchor.start ?? 0));
|
|
5959
|
+
return {
|
|
5960
|
+
doc: withEntry(doc, {
|
|
5961
|
+
...entry,
|
|
5962
|
+
notes,
|
|
5963
|
+
nextNote: entry.nextNote + 1
|
|
5964
|
+
}),
|
|
5965
|
+
result: note
|
|
5966
|
+
};
|
|
5967
|
+
});
|
|
5968
|
+
}
|
|
5969
|
+
/** Change (or clear) one note's comment. */
|
|
5970
|
+
updateNote(request) {
|
|
5971
|
+
return this.update((doc) => {
|
|
5972
|
+
const entry = doc.entries[request.entryId];
|
|
5973
|
+
const note = entry?.notes.find((item) => item.id === request.noteId);
|
|
5974
|
+
if (entry === void 0 || note === void 0) return {
|
|
5975
|
+
doc,
|
|
5976
|
+
result: "not-found"
|
|
5977
|
+
};
|
|
5978
|
+
const comment = request.comment.trim().slice(0, MAX_COMMENT_CHARS);
|
|
5979
|
+
const { comment: _old, ...rest } = note;
|
|
5980
|
+
const next = {
|
|
5981
|
+
...rest,
|
|
5982
|
+
...comment === "" ? {} : { comment },
|
|
5983
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5984
|
+
};
|
|
5985
|
+
const notes = entry.notes.map((item) => item.id === note.id ? next : item);
|
|
5986
|
+
return {
|
|
5987
|
+
doc: withEntry(doc, {
|
|
5988
|
+
...entry,
|
|
5989
|
+
notes,
|
|
5990
|
+
updatedAt: next.updatedAt
|
|
5991
|
+
}),
|
|
5992
|
+
result: "ok"
|
|
5993
|
+
};
|
|
5994
|
+
});
|
|
5995
|
+
}
|
|
5996
|
+
/** Delete one note (its id is not handed out again). */
|
|
5997
|
+
deleteNote(request) {
|
|
5998
|
+
return this.update((doc) => {
|
|
5999
|
+
const entry = doc.entries[request.entryId];
|
|
6000
|
+
if (entry === void 0 || !entry.notes.some((item) => item.id === request.noteId)) return {
|
|
6001
|
+
doc,
|
|
6002
|
+
result: "not-found"
|
|
6003
|
+
};
|
|
6004
|
+
const notes = entry.notes.filter((item) => item.id !== request.noteId);
|
|
6005
|
+
return {
|
|
6006
|
+
doc: withEntry(doc, {
|
|
6007
|
+
...entry,
|
|
6008
|
+
notes,
|
|
6009
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6010
|
+
}),
|
|
6011
|
+
result: "ok"
|
|
6012
|
+
};
|
|
6013
|
+
});
|
|
6014
|
+
}
|
|
6015
|
+
/**
|
|
6016
|
+
* Add one line under a note: a conclusion (by the model, when the reader asked
|
|
6017
|
+
* for it) or the reader's own addition.
|
|
6018
|
+
*
|
|
6019
|
+
* @param request - the article, the note, the line and who wrote it.
|
|
6020
|
+
*/
|
|
6021
|
+
addReply(request) {
|
|
6022
|
+
const body = request.text.trim().slice(0, MAX_POINT_CHARS);
|
|
6023
|
+
if (body.length === 0) return Promise.resolve("empty");
|
|
6024
|
+
return this.update((doc) => {
|
|
6025
|
+
const entry = doc.entries[request.entryId];
|
|
6026
|
+
const note = entry?.notes.find((item) => item.id === request.noteId);
|
|
6027
|
+
if (entry === void 0 || note === void 0) return {
|
|
6028
|
+
doc,
|
|
6029
|
+
result: "not-found"
|
|
6030
|
+
};
|
|
6031
|
+
const replies = note.replies ?? [];
|
|
6032
|
+
if (replies.length >= 50) return {
|
|
6033
|
+
doc,
|
|
6034
|
+
result: "full"
|
|
6035
|
+
};
|
|
6036
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6037
|
+
const reply = {
|
|
6038
|
+
by: request.by,
|
|
6039
|
+
text: body,
|
|
6040
|
+
...request.sessionId === void 0 || request.sessionId === "" ? {} : { sessionId: request.sessionId },
|
|
6041
|
+
at: now
|
|
6042
|
+
};
|
|
6043
|
+
const notes = entry.notes.map((item) => item.id === note.id ? {
|
|
6044
|
+
...item,
|
|
6045
|
+
replies: [...replies, reply],
|
|
6046
|
+
updatedAt: now
|
|
6047
|
+
} : item);
|
|
6048
|
+
return {
|
|
6049
|
+
doc: withEntry(doc, {
|
|
6050
|
+
...entry,
|
|
6051
|
+
notes,
|
|
6052
|
+
updatedAt: now
|
|
6053
|
+
}),
|
|
6054
|
+
result: "ok"
|
|
6055
|
+
};
|
|
6056
|
+
});
|
|
6057
|
+
}
|
|
6058
|
+
/**
|
|
6059
|
+
* Rewrite (or, with empty text, remove) one line under a note. A line the
|
|
6060
|
+
* model wrote becomes the reader's, marked as rewritten from the model's.
|
|
6061
|
+
*/
|
|
6062
|
+
editReply(request) {
|
|
6063
|
+
return this.update((doc) => {
|
|
6064
|
+
const entry = doc.entries[request.entryId];
|
|
6065
|
+
const note = entry?.notes.find((item) => item.id === request.noteId);
|
|
6066
|
+
const replies = note?.replies ?? [];
|
|
6067
|
+
const old = replies[request.index];
|
|
6068
|
+
if (entry === void 0 || note === void 0 || old === void 0) return {
|
|
6069
|
+
doc,
|
|
6070
|
+
result: "not-found"
|
|
6071
|
+
};
|
|
6072
|
+
const body = request.text.trim().slice(0, MAX_POINT_CHARS);
|
|
6073
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6074
|
+
const next = body === "" ? replies.filter((_, index) => index !== request.index) : replies.map((item, index) => index !== request.index ? item : {
|
|
6075
|
+
...item,
|
|
6076
|
+
by: "user",
|
|
6077
|
+
text: body,
|
|
6078
|
+
...item.by === "agent" || item.editedFrom === "agent" ? { editedFrom: "agent" } : {},
|
|
6079
|
+
at: now
|
|
6080
|
+
});
|
|
6081
|
+
const { replies: _old, ...rest } = note;
|
|
6082
|
+
const notes = entry.notes.map((item) => item.id === note.id ? {
|
|
6083
|
+
...rest,
|
|
6084
|
+
...next.length === 0 ? {} : { replies: next },
|
|
6085
|
+
updatedAt: now
|
|
6086
|
+
} : item);
|
|
6087
|
+
return {
|
|
6088
|
+
doc: withEntry(doc, {
|
|
6089
|
+
...entry,
|
|
6090
|
+
notes,
|
|
6091
|
+
updatedAt: now
|
|
6092
|
+
}),
|
|
6093
|
+
result: "ok"
|
|
6094
|
+
};
|
|
6095
|
+
});
|
|
6096
|
+
}
|
|
6097
|
+
/**
|
|
6098
|
+
* Keep one discussion note (newest first). The conversation it came from is
|
|
6099
|
+
* remembered on the article too.
|
|
6100
|
+
*
|
|
6101
|
+
* @param input - the article, the kind, the points.
|
|
6102
|
+
* @returns the new discussion note, or why none was kept.
|
|
6103
|
+
*/
|
|
6104
|
+
addDiscussion(input) {
|
|
6105
|
+
const points = boundPoints(input.points);
|
|
6106
|
+
if (points.length === 0 || input.entryId.length === 0) return Promise.resolve("empty");
|
|
6107
|
+
return this.update((doc) => {
|
|
6108
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6109
|
+
const entry = entryFor(doc, input, now);
|
|
6110
|
+
const number = entry.nextDiscussion ?? 1;
|
|
6111
|
+
const aside = input.aside?.trim().slice(0, 300);
|
|
6112
|
+
const sessionId = input.sessionId?.trim();
|
|
6113
|
+
const discussion = {
|
|
6114
|
+
id: `d${String(number)}`,
|
|
6115
|
+
kind: input.kind === "check" ? "check" : "discussion",
|
|
6116
|
+
...sessionId === void 0 || sessionId === "" ? {} : { sessionId },
|
|
6117
|
+
title: input.heading?.trim().slice(0, 200) ?? "",
|
|
6118
|
+
points,
|
|
6119
|
+
...aside === void 0 || aside === "" ? {} : { aside },
|
|
6120
|
+
createdAt: now
|
|
6121
|
+
};
|
|
6122
|
+
const sessions = sessionId === void 0 || sessionId === "" ? entry.sessions : [entry.sessions.find((item) => item.sessionId === sessionId) === void 0 ? {
|
|
6123
|
+
sessionId,
|
|
6124
|
+
lastAt: now
|
|
6125
|
+
} : {
|
|
6126
|
+
...entry.sessions.find((item) => item.sessionId === sessionId),
|
|
6127
|
+
lastAt: now
|
|
6128
|
+
}, ...entry.sessions.filter((item) => item.sessionId !== sessionId)].slice(0, 50);
|
|
6129
|
+
const discussions = [discussion, ...entry.discussions ?? []].slice(0, 100);
|
|
6130
|
+
return {
|
|
6131
|
+
doc: withEntry(doc, {
|
|
6132
|
+
...entry,
|
|
6133
|
+
sessions,
|
|
6134
|
+
discussions,
|
|
6135
|
+
nextDiscussion: number + 1
|
|
6136
|
+
}),
|
|
6137
|
+
result: discussion
|
|
6138
|
+
};
|
|
6139
|
+
});
|
|
6140
|
+
}
|
|
6141
|
+
/**
|
|
6142
|
+
* Remember that a note (or a discussion note) became a canvas card, so the
|
|
6143
|
+
* notes face can say where it went and not offer the same sync twice blindly.
|
|
6144
|
+
*/
|
|
6145
|
+
markSynced(request) {
|
|
6146
|
+
return this.update((doc) => {
|
|
6147
|
+
const entry = doc.entries[request.entryId];
|
|
6148
|
+
if (entry === void 0) return {
|
|
6149
|
+
doc,
|
|
6150
|
+
result: "not-found"
|
|
6151
|
+
};
|
|
6152
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6153
|
+
const card = {
|
|
6154
|
+
canvasId: request.canvasId,
|
|
6155
|
+
cardId: request.cardId,
|
|
6156
|
+
canvasTitle: request.canvasTitle.slice(0, 200),
|
|
6157
|
+
at: now
|
|
6158
|
+
};
|
|
6159
|
+
if (request.noteId !== void 0) {
|
|
6160
|
+
if (!entry.notes.some((item) => item.id === request.noteId)) return {
|
|
6161
|
+
doc,
|
|
6162
|
+
result: "not-found"
|
|
6163
|
+
};
|
|
6164
|
+
const notes = entry.notes.map((item) => item.id === request.noteId ? {
|
|
6165
|
+
...item,
|
|
6166
|
+
syncedTo: [...item.syncedTo ?? [], card].slice(-20)
|
|
6167
|
+
} : item);
|
|
6168
|
+
return {
|
|
6169
|
+
doc: withEntry(doc, {
|
|
6170
|
+
...entry,
|
|
6171
|
+
notes
|
|
6172
|
+
}),
|
|
6173
|
+
result: "ok"
|
|
6174
|
+
};
|
|
6175
|
+
}
|
|
6176
|
+
const discussions = entry.discussions ?? [];
|
|
6177
|
+
if (request.discussionId === void 0 || !discussions.some((item) => item.id === request.discussionId)) return {
|
|
6178
|
+
doc,
|
|
6179
|
+
result: "not-found"
|
|
6180
|
+
};
|
|
6181
|
+
const next = discussions.map((item) => item.id === request.discussionId ? {
|
|
6182
|
+
...item,
|
|
6183
|
+
syncedTo: [...item.syncedTo ?? [], card].slice(-20)
|
|
6184
|
+
} : item);
|
|
6185
|
+
return {
|
|
6186
|
+
doc: withEntry(doc, {
|
|
6187
|
+
...entry,
|
|
6188
|
+
discussions: next
|
|
6189
|
+
}),
|
|
6190
|
+
result: "ok"
|
|
6191
|
+
};
|
|
6192
|
+
});
|
|
6193
|
+
}
|
|
6194
|
+
/** Delete one discussion note. */
|
|
6195
|
+
deleteDiscussion(request) {
|
|
6196
|
+
return this.update((doc) => {
|
|
6197
|
+
const entry = doc.entries[request.entryId];
|
|
6198
|
+
if (entry === void 0 || !(entry.discussions ?? []).some((item) => item.id === request.discussionId)) return {
|
|
6199
|
+
doc,
|
|
6200
|
+
result: "not-found"
|
|
6201
|
+
};
|
|
6202
|
+
const discussions = (entry.discussions ?? []).filter((item) => item.id !== request.discussionId);
|
|
6203
|
+
return {
|
|
6204
|
+
doc: withEntry(doc, {
|
|
6205
|
+
...entry,
|
|
6206
|
+
discussions,
|
|
6207
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6208
|
+
}),
|
|
6209
|
+
result: "ok"
|
|
6210
|
+
};
|
|
6211
|
+
});
|
|
3459
6212
|
}
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
return
|
|
3474
|
-
} catch {
|
|
3475
|
-
return;
|
|
6213
|
+
/**
|
|
6214
|
+
* The article this conversation discussed most recently, when there is one —
|
|
6215
|
+
* where a model's write lands when it names no article.
|
|
6216
|
+
*/
|
|
6217
|
+
sessionEntry(sessionId) {
|
|
6218
|
+
let best;
|
|
6219
|
+
for (const entry of Object.values(this.read().entries)) {
|
|
6220
|
+
const row = entry.sessions.find((item) => item.sessionId === sessionId);
|
|
6221
|
+
if (row !== void 0 && (best === void 0 || row.lastAt > best.at)) best = {
|
|
6222
|
+
entryId: entry.entryId,
|
|
6223
|
+
at: row.lastAt
|
|
6224
|
+
};
|
|
6225
|
+
}
|
|
6226
|
+
return best?.entryId;
|
|
3476
6227
|
}
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
*
|
|
3480
|
-
*
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
6228
|
+
/**
|
|
6229
|
+
* Remember that a conversation discussed this article (most recent first).
|
|
6230
|
+
*
|
|
6231
|
+
* @param request - the article and the conversation.
|
|
6232
|
+
*/
|
|
6233
|
+
recordSession(request) {
|
|
6234
|
+
const sessionId = request.sessionId.trim();
|
|
6235
|
+
if (sessionId.length === 0 || request.entryId.length === 0) return Promise.resolve("empty");
|
|
6236
|
+
return this.update((doc) => {
|
|
6237
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6238
|
+
const entry = entryFor(doc, request, now);
|
|
6239
|
+
const previous = entry.sessions.find((item) => item.sessionId === sessionId);
|
|
6240
|
+
const title = request.sessionTitle?.trim().slice(0, 200) || previous?.title;
|
|
6241
|
+
const sessions = [{
|
|
6242
|
+
sessionId,
|
|
6243
|
+
...title === void 0 || title === "" ? {} : { title },
|
|
6244
|
+
lastAt: now
|
|
6245
|
+
}, ...entry.sessions.filter((item) => item.sessionId !== sessionId)].slice(0, 50);
|
|
6246
|
+
return {
|
|
6247
|
+
doc: withEntry(doc, {
|
|
6248
|
+
...entry,
|
|
6249
|
+
sessions
|
|
6250
|
+
}),
|
|
6251
|
+
result: "ok"
|
|
6252
|
+
};
|
|
3495
6253
|
});
|
|
3496
6254
|
}
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
6255
|
+
/**
|
|
6256
|
+
* Keep the article's text for the model, replacing the previous snapshot.
|
|
6257
|
+
*
|
|
6258
|
+
* Loose snapshots (articles with no notes and no discussions) are pruned to
|
|
6259
|
+
* the {@link MAX_LOOSE_SNAPSHOTS} most recent; the rest are kept as long as
|
|
6260
|
+
* their article carries anything.
|
|
6261
|
+
*
|
|
6262
|
+
* @param request - the article and its blocks' original text.
|
|
6263
|
+
*/
|
|
6264
|
+
snapshot(request) {
|
|
6265
|
+
const { paragraphs, truncated } = boundParagraphs(request.paragraphs);
|
|
6266
|
+
if (request.entryId.length === 0 || paragraphs.every((item) => item.length === 0)) return Promise.resolve("empty");
|
|
6267
|
+
const snapshot = {
|
|
6268
|
+
entryId: request.entryId,
|
|
6269
|
+
title: request.title.trim().slice(0, 300) || request.entryId,
|
|
6270
|
+
...request.url === void 0 || request.url === "" ? {} : { url: request.url },
|
|
6271
|
+
...request.source === void 0 || request.source === "" ? {} : { source: request.source },
|
|
6272
|
+
paragraphs,
|
|
6273
|
+
...truncated ? { truncated: true } : {},
|
|
6274
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6275
|
+
};
|
|
6276
|
+
return this.update((doc) => {
|
|
6277
|
+
if (!this.available) {
|
|
6278
|
+
this.memorySnapshots.set(snapshot.entryId, snapshot);
|
|
6279
|
+
return {
|
|
6280
|
+
doc,
|
|
6281
|
+
result: "ok"
|
|
6282
|
+
};
|
|
6283
|
+
}
|
|
6284
|
+
mkdirSync(this.snapshotsDir, { recursive: true });
|
|
6285
|
+
const file = snapshotFileName(snapshot.entryId);
|
|
6286
|
+
writeAtomic(join(this.snapshotsDir, file), `${JSON.stringify(snapshot)}\n`);
|
|
6287
|
+
const chars = paragraphs.reduce((sum, item) => sum + item.length, 0);
|
|
6288
|
+
const snapshots = {
|
|
6289
|
+
...doc.snapshots,
|
|
6290
|
+
[snapshot.entryId]: {
|
|
6291
|
+
file,
|
|
6292
|
+
chars,
|
|
6293
|
+
capturedAt: snapshot.capturedAt
|
|
6294
|
+
}
|
|
6295
|
+
};
|
|
6296
|
+
const loose = Object.entries(snapshots).filter(([entryId]) => doc.entries[entryId] === void 0).sort((left, right) => right[1].capturedAt.localeCompare(left[1].capturedAt));
|
|
6297
|
+
for (const [entryId, row] of loose.slice(30)) {
|
|
6298
|
+
delete snapshots[entryId];
|
|
6299
|
+
rmSync(join(this.snapshotsDir, row.file), { force: true });
|
|
6300
|
+
}
|
|
6301
|
+
return {
|
|
6302
|
+
doc: {
|
|
6303
|
+
...doc,
|
|
6304
|
+
snapshots
|
|
6305
|
+
},
|
|
6306
|
+
result: "ok"
|
|
6307
|
+
};
|
|
6308
|
+
});
|
|
6309
|
+
}
|
|
6310
|
+
/** The article's text as last captured, when there is one. */
|
|
6311
|
+
readSnapshot(entryId) {
|
|
6312
|
+
if (!this.available) return this.memorySnapshots.get(entryId);
|
|
6313
|
+
const row = this.read().snapshots[entryId];
|
|
6314
|
+
if (row === void 0) return void 0;
|
|
6315
|
+
try {
|
|
6316
|
+
const parsed = JSON.parse(readFileSync(join(this.snapshotsDir, row.file), "utf8"));
|
|
6317
|
+
if (!Array.isArray(parsed.paragraphs)) return void 0;
|
|
6318
|
+
return {
|
|
6319
|
+
entryId,
|
|
6320
|
+
title: text(parsed.title, 300) ?? entryId,
|
|
6321
|
+
...typeof parsed.url === "string" && parsed.url !== "" ? { url: parsed.url } : {},
|
|
6322
|
+
...typeof parsed.source === "string" && parsed.source !== "" ? { source: parsed.source } : {},
|
|
6323
|
+
paragraphs: parsed.paragraphs.map((item) => typeof item === "string" ? item : ""),
|
|
6324
|
+
...parsed.truncated === true ? { truncated: true } : {},
|
|
6325
|
+
capturedAt: stamp(parsed.capturedAt)
|
|
6326
|
+
};
|
|
6327
|
+
} catch {
|
|
6328
|
+
return;
|
|
6329
|
+
}
|
|
6330
|
+
}
|
|
6331
|
+
/** Every article with a snapshot, newest capture first. */
|
|
6332
|
+
snapshotIds() {
|
|
6333
|
+
if (!this.available) return [...this.memorySnapshots.keys()].reverse();
|
|
6334
|
+
return Object.entries(this.read().snapshots).sort((left, right) => right[1].capturedAt.localeCompare(left[1].capturedAt)).map(([entryId]) => entryId);
|
|
6335
|
+
}
|
|
6336
|
+
};
|
|
3511
6337
|
//#endregion
|
|
3512
6338
|
//#region lib/types/remote.js
|
|
3513
6339
|
var __runInitializers = function(thisArg, initializers, value) {
|
|
@@ -3586,6 +6412,26 @@ let ReaderRemoteService = (() => {
|
|
|
3586
6412
|
let _listRecent_decorators;
|
|
3587
6413
|
let _clearRecent_decorators;
|
|
3588
6414
|
let _quoteToSideChat_decorators;
|
|
6415
|
+
let _entryNotes_decorators;
|
|
6416
|
+
let _noteCounts_decorators;
|
|
6417
|
+
let _addNote_decorators;
|
|
6418
|
+
let _updateNote_decorators;
|
|
6419
|
+
let _deleteNote_decorators;
|
|
6420
|
+
let _recordDiscussion_decorators;
|
|
6421
|
+
let _addReply_decorators;
|
|
6422
|
+
let _editReply_decorators;
|
|
6423
|
+
let _markSynced_decorators;
|
|
6424
|
+
let _deleteDiscussion_decorators;
|
|
6425
|
+
let _mediaTranscript_decorators;
|
|
6426
|
+
let _startLocalTranscript_decorators;
|
|
6427
|
+
let _localTranscript_decorators;
|
|
6428
|
+
let _speakerNames_decorators;
|
|
6429
|
+
let _nameSpeaker_decorators;
|
|
6430
|
+
let _mediaStates_decorators;
|
|
6431
|
+
let _cancelLocalTranscript_decorators;
|
|
6432
|
+
let _polish_decorators;
|
|
6433
|
+
let _setPolishOff_decorators;
|
|
6434
|
+
let _snapshotEntry_decorators;
|
|
3589
6435
|
return class ReaderRemoteService extends _classSuper {
|
|
3590
6436
|
static {
|
|
3591
6437
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
@@ -3621,6 +6467,26 @@ let ReaderRemoteService = (() => {
|
|
|
3621
6467
|
_listRecent_decorators = [Remote("listRecent")];
|
|
3622
6468
|
_clearRecent_decorators = [Remote("clearRecent")];
|
|
3623
6469
|
_quoteToSideChat_decorators = [Remote("quoteToSideChat")];
|
|
6470
|
+
_entryNotes_decorators = [Remote("entryNotes")];
|
|
6471
|
+
_noteCounts_decorators = [Remote("noteCounts")];
|
|
6472
|
+
_addNote_decorators = [Remote("addNote")];
|
|
6473
|
+
_updateNote_decorators = [Remote("updateNote")];
|
|
6474
|
+
_deleteNote_decorators = [Remote("deleteNote")];
|
|
6475
|
+
_recordDiscussion_decorators = [Remote("recordDiscussion")];
|
|
6476
|
+
_addReply_decorators = [Remote("addReply")];
|
|
6477
|
+
_editReply_decorators = [Remote("editReply")];
|
|
6478
|
+
_markSynced_decorators = [Remote("markSynced")];
|
|
6479
|
+
_deleteDiscussion_decorators = [Remote("deleteDiscussion")];
|
|
6480
|
+
_mediaTranscript_decorators = [Remote("mediaTranscript")];
|
|
6481
|
+
_startLocalTranscript_decorators = [Remote("startLocalTranscript")];
|
|
6482
|
+
_localTranscript_decorators = [Remote("localTranscript")];
|
|
6483
|
+
_speakerNames_decorators = [Remote("speakerNames")];
|
|
6484
|
+
_nameSpeaker_decorators = [Remote("nameSpeaker")];
|
|
6485
|
+
_mediaStates_decorators = [Remote("mediaStates")];
|
|
6486
|
+
_cancelLocalTranscript_decorators = [Remote("cancelLocalTranscript")];
|
|
6487
|
+
_polish_decorators = [Remote("polish")];
|
|
6488
|
+
_setPolishOff_decorators = [Remote("setPolishOff")];
|
|
6489
|
+
_snapshotEntry_decorators = [Remote("snapshotEntry")];
|
|
3624
6490
|
__esDecorate(this, null, _capabilities_decorators, {
|
|
3625
6491
|
kind: "method",
|
|
3626
6492
|
name: "capabilities",
|
|
@@ -3973,6 +6839,226 @@ let ReaderRemoteService = (() => {
|
|
|
3973
6839
|
},
|
|
3974
6840
|
metadata: _metadata
|
|
3975
6841
|
}, null, _instanceExtraInitializers);
|
|
6842
|
+
__esDecorate(this, null, _entryNotes_decorators, {
|
|
6843
|
+
kind: "method",
|
|
6844
|
+
name: "entryNotes",
|
|
6845
|
+
static: false,
|
|
6846
|
+
private: false,
|
|
6847
|
+
access: {
|
|
6848
|
+
has: (obj) => "entryNotes" in obj,
|
|
6849
|
+
get: (obj) => obj.entryNotes
|
|
6850
|
+
},
|
|
6851
|
+
metadata: _metadata
|
|
6852
|
+
}, null, _instanceExtraInitializers);
|
|
6853
|
+
__esDecorate(this, null, _noteCounts_decorators, {
|
|
6854
|
+
kind: "method",
|
|
6855
|
+
name: "noteCounts",
|
|
6856
|
+
static: false,
|
|
6857
|
+
private: false,
|
|
6858
|
+
access: {
|
|
6859
|
+
has: (obj) => "noteCounts" in obj,
|
|
6860
|
+
get: (obj) => obj.noteCounts
|
|
6861
|
+
},
|
|
6862
|
+
metadata: _metadata
|
|
6863
|
+
}, null, _instanceExtraInitializers);
|
|
6864
|
+
__esDecorate(this, null, _addNote_decorators, {
|
|
6865
|
+
kind: "method",
|
|
6866
|
+
name: "addNote",
|
|
6867
|
+
static: false,
|
|
6868
|
+
private: false,
|
|
6869
|
+
access: {
|
|
6870
|
+
has: (obj) => "addNote" in obj,
|
|
6871
|
+
get: (obj) => obj.addNote
|
|
6872
|
+
},
|
|
6873
|
+
metadata: _metadata
|
|
6874
|
+
}, null, _instanceExtraInitializers);
|
|
6875
|
+
__esDecorate(this, null, _updateNote_decorators, {
|
|
6876
|
+
kind: "method",
|
|
6877
|
+
name: "updateNote",
|
|
6878
|
+
static: false,
|
|
6879
|
+
private: false,
|
|
6880
|
+
access: {
|
|
6881
|
+
has: (obj) => "updateNote" in obj,
|
|
6882
|
+
get: (obj) => obj.updateNote
|
|
6883
|
+
},
|
|
6884
|
+
metadata: _metadata
|
|
6885
|
+
}, null, _instanceExtraInitializers);
|
|
6886
|
+
__esDecorate(this, null, _deleteNote_decorators, {
|
|
6887
|
+
kind: "method",
|
|
6888
|
+
name: "deleteNote",
|
|
6889
|
+
static: false,
|
|
6890
|
+
private: false,
|
|
6891
|
+
access: {
|
|
6892
|
+
has: (obj) => "deleteNote" in obj,
|
|
6893
|
+
get: (obj) => obj.deleteNote
|
|
6894
|
+
},
|
|
6895
|
+
metadata: _metadata
|
|
6896
|
+
}, null, _instanceExtraInitializers);
|
|
6897
|
+
__esDecorate(this, null, _recordDiscussion_decorators, {
|
|
6898
|
+
kind: "method",
|
|
6899
|
+
name: "recordDiscussion",
|
|
6900
|
+
static: false,
|
|
6901
|
+
private: false,
|
|
6902
|
+
access: {
|
|
6903
|
+
has: (obj) => "recordDiscussion" in obj,
|
|
6904
|
+
get: (obj) => obj.recordDiscussion
|
|
6905
|
+
},
|
|
6906
|
+
metadata: _metadata
|
|
6907
|
+
}, null, _instanceExtraInitializers);
|
|
6908
|
+
__esDecorate(this, null, _addReply_decorators, {
|
|
6909
|
+
kind: "method",
|
|
6910
|
+
name: "addReply",
|
|
6911
|
+
static: false,
|
|
6912
|
+
private: false,
|
|
6913
|
+
access: {
|
|
6914
|
+
has: (obj) => "addReply" in obj,
|
|
6915
|
+
get: (obj) => obj.addReply
|
|
6916
|
+
},
|
|
6917
|
+
metadata: _metadata
|
|
6918
|
+
}, null, _instanceExtraInitializers);
|
|
6919
|
+
__esDecorate(this, null, _editReply_decorators, {
|
|
6920
|
+
kind: "method",
|
|
6921
|
+
name: "editReply",
|
|
6922
|
+
static: false,
|
|
6923
|
+
private: false,
|
|
6924
|
+
access: {
|
|
6925
|
+
has: (obj) => "editReply" in obj,
|
|
6926
|
+
get: (obj) => obj.editReply
|
|
6927
|
+
},
|
|
6928
|
+
metadata: _metadata
|
|
6929
|
+
}, null, _instanceExtraInitializers);
|
|
6930
|
+
__esDecorate(this, null, _markSynced_decorators, {
|
|
6931
|
+
kind: "method",
|
|
6932
|
+
name: "markSynced",
|
|
6933
|
+
static: false,
|
|
6934
|
+
private: false,
|
|
6935
|
+
access: {
|
|
6936
|
+
has: (obj) => "markSynced" in obj,
|
|
6937
|
+
get: (obj) => obj.markSynced
|
|
6938
|
+
},
|
|
6939
|
+
metadata: _metadata
|
|
6940
|
+
}, null, _instanceExtraInitializers);
|
|
6941
|
+
__esDecorate(this, null, _deleteDiscussion_decorators, {
|
|
6942
|
+
kind: "method",
|
|
6943
|
+
name: "deleteDiscussion",
|
|
6944
|
+
static: false,
|
|
6945
|
+
private: false,
|
|
6946
|
+
access: {
|
|
6947
|
+
has: (obj) => "deleteDiscussion" in obj,
|
|
6948
|
+
get: (obj) => obj.deleteDiscussion
|
|
6949
|
+
},
|
|
6950
|
+
metadata: _metadata
|
|
6951
|
+
}, null, _instanceExtraInitializers);
|
|
6952
|
+
__esDecorate(this, null, _mediaTranscript_decorators, {
|
|
6953
|
+
kind: "method",
|
|
6954
|
+
name: "mediaTranscript",
|
|
6955
|
+
static: false,
|
|
6956
|
+
private: false,
|
|
6957
|
+
access: {
|
|
6958
|
+
has: (obj) => "mediaTranscript" in obj,
|
|
6959
|
+
get: (obj) => obj.mediaTranscript
|
|
6960
|
+
},
|
|
6961
|
+
metadata: _metadata
|
|
6962
|
+
}, null, _instanceExtraInitializers);
|
|
6963
|
+
__esDecorate(this, null, _startLocalTranscript_decorators, {
|
|
6964
|
+
kind: "method",
|
|
6965
|
+
name: "startLocalTranscript",
|
|
6966
|
+
static: false,
|
|
6967
|
+
private: false,
|
|
6968
|
+
access: {
|
|
6969
|
+
has: (obj) => "startLocalTranscript" in obj,
|
|
6970
|
+
get: (obj) => obj.startLocalTranscript
|
|
6971
|
+
},
|
|
6972
|
+
metadata: _metadata
|
|
6973
|
+
}, null, _instanceExtraInitializers);
|
|
6974
|
+
__esDecorate(this, null, _localTranscript_decorators, {
|
|
6975
|
+
kind: "method",
|
|
6976
|
+
name: "localTranscript",
|
|
6977
|
+
static: false,
|
|
6978
|
+
private: false,
|
|
6979
|
+
access: {
|
|
6980
|
+
has: (obj) => "localTranscript" in obj,
|
|
6981
|
+
get: (obj) => obj.localTranscript
|
|
6982
|
+
},
|
|
6983
|
+
metadata: _metadata
|
|
6984
|
+
}, null, _instanceExtraInitializers);
|
|
6985
|
+
__esDecorate(this, null, _speakerNames_decorators, {
|
|
6986
|
+
kind: "method",
|
|
6987
|
+
name: "speakerNames",
|
|
6988
|
+
static: false,
|
|
6989
|
+
private: false,
|
|
6990
|
+
access: {
|
|
6991
|
+
has: (obj) => "speakerNames" in obj,
|
|
6992
|
+
get: (obj) => obj.speakerNames
|
|
6993
|
+
},
|
|
6994
|
+
metadata: _metadata
|
|
6995
|
+
}, null, _instanceExtraInitializers);
|
|
6996
|
+
__esDecorate(this, null, _nameSpeaker_decorators, {
|
|
6997
|
+
kind: "method",
|
|
6998
|
+
name: "nameSpeaker",
|
|
6999
|
+
static: false,
|
|
7000
|
+
private: false,
|
|
7001
|
+
access: {
|
|
7002
|
+
has: (obj) => "nameSpeaker" in obj,
|
|
7003
|
+
get: (obj) => obj.nameSpeaker
|
|
7004
|
+
},
|
|
7005
|
+
metadata: _metadata
|
|
7006
|
+
}, null, _instanceExtraInitializers);
|
|
7007
|
+
__esDecorate(this, null, _mediaStates_decorators, {
|
|
7008
|
+
kind: "method",
|
|
7009
|
+
name: "mediaStates",
|
|
7010
|
+
static: false,
|
|
7011
|
+
private: false,
|
|
7012
|
+
access: {
|
|
7013
|
+
has: (obj) => "mediaStates" in obj,
|
|
7014
|
+
get: (obj) => obj.mediaStates
|
|
7015
|
+
},
|
|
7016
|
+
metadata: _metadata
|
|
7017
|
+
}, null, _instanceExtraInitializers);
|
|
7018
|
+
__esDecorate(this, null, _cancelLocalTranscript_decorators, {
|
|
7019
|
+
kind: "method",
|
|
7020
|
+
name: "cancelLocalTranscript",
|
|
7021
|
+
static: false,
|
|
7022
|
+
private: false,
|
|
7023
|
+
access: {
|
|
7024
|
+
has: (obj) => "cancelLocalTranscript" in obj,
|
|
7025
|
+
get: (obj) => obj.cancelLocalTranscript
|
|
7026
|
+
},
|
|
7027
|
+
metadata: _metadata
|
|
7028
|
+
}, null, _instanceExtraInitializers);
|
|
7029
|
+
__esDecorate(this, null, _polish_decorators, {
|
|
7030
|
+
kind: "method",
|
|
7031
|
+
name: "polish",
|
|
7032
|
+
static: false,
|
|
7033
|
+
private: false,
|
|
7034
|
+
access: {
|
|
7035
|
+
has: (obj) => "polish" in obj,
|
|
7036
|
+
get: (obj) => obj.polish
|
|
7037
|
+
},
|
|
7038
|
+
metadata: _metadata
|
|
7039
|
+
}, null, _instanceExtraInitializers);
|
|
7040
|
+
__esDecorate(this, null, _setPolishOff_decorators, {
|
|
7041
|
+
kind: "method",
|
|
7042
|
+
name: "setPolishOff",
|
|
7043
|
+
static: false,
|
|
7044
|
+
private: false,
|
|
7045
|
+
access: {
|
|
7046
|
+
has: (obj) => "setPolishOff" in obj,
|
|
7047
|
+
get: (obj) => obj.setPolishOff
|
|
7048
|
+
},
|
|
7049
|
+
metadata: _metadata
|
|
7050
|
+
}, null, _instanceExtraInitializers);
|
|
7051
|
+
__esDecorate(this, null, _snapshotEntry_decorators, {
|
|
7052
|
+
kind: "method",
|
|
7053
|
+
name: "snapshotEntry",
|
|
7054
|
+
static: false,
|
|
7055
|
+
private: false,
|
|
7056
|
+
access: {
|
|
7057
|
+
has: (obj) => "snapshotEntry" in obj,
|
|
7058
|
+
get: (obj) => obj.snapshotEntry
|
|
7059
|
+
},
|
|
7060
|
+
metadata: _metadata
|
|
7061
|
+
}, null, _instanceExtraInitializers);
|
|
3976
7062
|
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
3977
7063
|
enumerable: true,
|
|
3978
7064
|
configurable: true,
|
|
@@ -3980,7 +7066,7 @@ let ReaderRemoteService = (() => {
|
|
|
3980
7066
|
value: _metadata
|
|
3981
7067
|
});
|
|
3982
7068
|
}
|
|
3983
|
-
static inject = ["reader"];
|
|
7069
|
+
static inject = ["reader", "readerNotes"];
|
|
3984
7070
|
constructor(ctx, _config = {}) {
|
|
3985
7071
|
super(ctx, "readerRemote", { namespace: "reader" });
|
|
3986
7072
|
__runInitializers(this, _instanceExtraInitializers);
|
|
@@ -3989,6 +7075,10 @@ let ReaderRemoteService = (() => {
|
|
|
3989
7075
|
get core() {
|
|
3990
7076
|
return this.ctx.reader;
|
|
3991
7077
|
}
|
|
7078
|
+
/** The notes store this face delegates to. */
|
|
7079
|
+
get notes() {
|
|
7080
|
+
return this.ctx.readerNotes;
|
|
7081
|
+
}
|
|
3992
7082
|
/** Capability handshake: what the browser may rely on in this composition. */
|
|
3993
7083
|
capabilities() {
|
|
3994
7084
|
return this.core.capabilities();
|
|
@@ -4120,6 +7210,94 @@ let ReaderRemoteService = (() => {
|
|
|
4120
7210
|
quoteToSideChat(request) {
|
|
4121
7211
|
return this.core.quoteToSideChat(request);
|
|
4122
7212
|
}
|
|
7213
|
+
/** One article's notes and the conversations that discussed it. */
|
|
7214
|
+
async entryNotes(request) {
|
|
7215
|
+
const entry = this.notes.entry(request.entryId);
|
|
7216
|
+
return entry === void 0 ? {} : { entry };
|
|
7217
|
+
}
|
|
7218
|
+
/** How many notes each article carries. */
|
|
7219
|
+
async noteCounts() {
|
|
7220
|
+
return { counts: this.notes.counts() };
|
|
7221
|
+
}
|
|
7222
|
+
/** Keep one excerpt (and its comment). */
|
|
7223
|
+
addNote(request) {
|
|
7224
|
+
return this.notes.addNote(request);
|
|
7225
|
+
}
|
|
7226
|
+
/** Change or clear one note's comment. */
|
|
7227
|
+
updateNote(request) {
|
|
7228
|
+
return this.notes.updateNote(request);
|
|
7229
|
+
}
|
|
7230
|
+
/** Delete one note. */
|
|
7231
|
+
deleteNote(request) {
|
|
7232
|
+
return this.notes.deleteNote(request);
|
|
7233
|
+
}
|
|
7234
|
+
/** Remember that a conversation discussed this article. */
|
|
7235
|
+
recordDiscussion(request) {
|
|
7236
|
+
return this.notes.recordSession(request);
|
|
7237
|
+
}
|
|
7238
|
+
/** Add the reader's own line under a note. */
|
|
7239
|
+
addReply(request) {
|
|
7240
|
+
return this.notes.addReply({
|
|
7241
|
+
...request,
|
|
7242
|
+
by: "user"
|
|
7243
|
+
});
|
|
7244
|
+
}
|
|
7245
|
+
/** Rewrite (or, with empty text, remove) one line under a note. */
|
|
7246
|
+
editReply(request) {
|
|
7247
|
+
return this.notes.editReply(request);
|
|
7248
|
+
}
|
|
7249
|
+
/** Remember that a note or a discussion note became a canvas card. */
|
|
7250
|
+
markSynced(request) {
|
|
7251
|
+
return this.notes.markSynced(request);
|
|
7252
|
+
}
|
|
7253
|
+
/** Delete one discussion note. */
|
|
7254
|
+
deleteDiscussion(request) {
|
|
7255
|
+
return this.notes.deleteDiscussion(request);
|
|
7256
|
+
}
|
|
7257
|
+
/** A video entry's transcript from its platform captions (stored; `none` when it has none). */
|
|
7258
|
+
mediaTranscript(request) {
|
|
7259
|
+
return this.core.mediaTranscript(request);
|
|
7260
|
+
}
|
|
7261
|
+
/**
|
|
7262
|
+
* Start (or rejoin) local transcription of an entry without captions.
|
|
7263
|
+
* Answers `unavailable` without the transcription plugin and `needs-models`
|
|
7264
|
+
* until the first model download is confirmed.
|
|
7265
|
+
*/
|
|
7266
|
+
startLocalTranscript(request) {
|
|
7267
|
+
return this.core.local.start(request);
|
|
7268
|
+
}
|
|
7269
|
+
/** Where an entry's local transcription stands, with the transcript so far. */
|
|
7270
|
+
localTranscript(request) {
|
|
7271
|
+
return Promise.resolve(this.core.local.status(request.entryId));
|
|
7272
|
+
}
|
|
7273
|
+
/** The reader's names for a transcript's speakers, by index. */
|
|
7274
|
+
speakerNames(request) {
|
|
7275
|
+
return Promise.resolve(this.core.speakerNames(request));
|
|
7276
|
+
}
|
|
7277
|
+
/** Name one speaker of a transcript (empty: back to 说话人 A…); answers all the names. */
|
|
7278
|
+
nameSpeaker(request) {
|
|
7279
|
+
return Promise.resolve(this.core.nameSpeaker(request));
|
|
7280
|
+
}
|
|
7281
|
+
/** The wall's video / audio cards: what each one's transcript is (captions, local, transcribing…). */
|
|
7282
|
+
mediaStates(request) {
|
|
7283
|
+
return Promise.resolve(this.core.mediaStates(request));
|
|
7284
|
+
}
|
|
7285
|
+
/** Leave (withdraws a queued job) or, with `force`, stop an entry's local transcription. */
|
|
7286
|
+
cancelLocalTranscript(request) {
|
|
7287
|
+
return Promise.resolve(this.core.local.cancel(request.entryId, request.force));
|
|
7288
|
+
}
|
|
7289
|
+
/** The article's 润色 layer (the agent's translation), when there is one for its current text. */
|
|
7290
|
+
polish(request) {
|
|
7291
|
+
return Promise.resolve(this.notes.polishView(request.entryId) ?? null);
|
|
7292
|
+
}
|
|
7293
|
+
/** Show one paragraph's machine translation instead of the 润色 (or back). */
|
|
7294
|
+
setPolishOff(request) {
|
|
7295
|
+
return Promise.resolve(this.notes.setPolishOff(request.entryId, request.paragraph, request.off) ?? null);
|
|
7296
|
+
}
|
|
7297
|
+
/** Hand the host the article's original text, block by block, for the model to read. */
|
|
7298
|
+
snapshotEntry(request) {
|
|
7299
|
+
return this.notes.snapshot(request);
|
|
7300
|
+
}
|
|
4123
7301
|
};
|
|
4124
7302
|
})();
|
|
4125
7303
|
//#endregion
|
|
@@ -4140,7 +7318,8 @@ const name = "reader";
|
|
|
4140
7318
|
function apply(ctx, config = {}) {
|
|
4141
7319
|
const service = new ReaderService(ctx, config);
|
|
4142
7320
|
ctx.provide("reader", service);
|
|
7321
|
+
ctx.provide("readerNotes", new ReaderNotesStore(config));
|
|
4143
7322
|
ctx.plugin(ReaderRemoteService, {});
|
|
4144
7323
|
}
|
|
4145
7324
|
//#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 };
|
|
7325
|
+
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 };
|