vomebook commited on
Commit
fbe729d
·
1 Parent(s): 4bfdbdc

Add Reader Assets online reading

Browse files
Dockerfile CHANGED
@@ -1,6 +1,6 @@
1
  FROM node:22-bookworm-slim AS static-assets
2
 
3
- ARG ESBUILD_VERSION=0.25.8
4
 
5
  WORKDIR /build
6
 
@@ -9,11 +9,17 @@ RUN npm install --global "esbuild@${ESBUILD_VERSION}" \
9
 
10
  COPY static/ /build/static/
11
  COPY scripts/build_static_assets.mjs /build/build_static_assets.mjs
 
12
 
13
  RUN mkdir -p /out \
14
  && cp -a static/. /out/ \
 
15
  && esbuild static/app.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/app.js \
 
 
 
16
  && esbuild static/style.css --minify --outfile=/out/style.css \
 
17
  && node /build/build_static_assets.mjs \
18
  && esbuild /out/sw.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/sw.min.js \
19
  && mv /out/sw.min.js /out/sw.js
 
1
  FROM node:22-bookworm-slim AS static-assets
2
 
3
+ ARG ESBUILD_VERSION=0.28.2
4
 
5
  WORKDIR /build
6
 
 
9
 
10
  COPY static/ /build/static/
11
  COPY scripts/build_static_assets.mjs /build/build_static_assets.mjs
12
+ COPY scripts/copy_reader_vendor.mjs /build/copy_reader_vendor.mjs
13
 
14
  RUN mkdir -p /out \
15
  && cp -a static/. /out/ \
16
+ && node /build/copy_reader_vendor.mjs /out/vendor \
17
  && esbuild static/app.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/app.js \
18
+ && esbuild static/reader-contract.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/reader-contract.js \
19
+ && esbuild static/reader-store.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/reader-store.js \
20
+ && esbuild static/reader.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/reader.js \
21
  && esbuild static/style.css --minify --outfile=/out/style.css \
22
+ && esbuild static/reader.css --minify --outfile=/out/reader.css \
23
  && node /build/build_static_assets.mjs \
24
  && esbuild /out/sw.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/sw.min.js \
25
  && mv /out/sw.min.js /out/sw.js
app.py CHANGED
@@ -7,6 +7,7 @@ import random
7
  import re
8
  import time
9
  import functools
 
10
  from pathlib import Path
11
  from typing import Optional
12
  from urllib.parse import unquote, quote, urljoin, urlparse
@@ -43,6 +44,10 @@ TXT_DIR = Path("txt")
43
  TXT_SPACE_RAW_BASE = "https://huggingface.co/spaces/VoiceOfML/Search/raw/main/txt"
44
  TXT_WARM_INTERVAL_SECONDS = 600
45
  TXT_WARM_IDLE_SECONDS = 600
 
 
 
 
46
  records: list[dict] = []
47
  folder_tree_data: dict[str, list[dict]] = {}
48
  folder_browser_data: dict[str, dict[str, dict]] = {}
@@ -61,6 +66,9 @@ repo_sorted_by_name = {}
61
  repo_sorted_by_size = {}
62
  txt_record_indices = []
63
  repo_txt_record_indices = {}
 
 
 
64
  initial_payload_global = None
65
  initial_payload_by_repo = {}
66
  API_CACHE_TTL_SECONDS = 120
@@ -75,6 +83,8 @@ ngram_postings: dict[int, dict[str, bytes]] = {}
75
  ngram_posting_buffers: dict[int, bytes] = {}
76
  injected_html_cache: dict[str | None, str] = {}
77
  last_user_request_at = time.monotonic()
 
 
78
 
79
  def cached_payload(key, builder):
80
  now = time.monotonic()
@@ -88,6 +98,67 @@ def cached_payload(key, builder):
88
  api_response_cache.pop(oldest_key, None)
89
  return value
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def decode_varint(data: bytes, offset: int) -> tuple[int, int]:
92
  value = 0
93
  shift = 0
@@ -523,7 +594,7 @@ def build_vocab_by_len():
523
 
524
 
525
  def build_indexes(build_words=True):
526
- global _data_generation, word_index, folder_index, extension_counts, repo_counts, repo_list, extension_list, repo_extension_counts, repo_records_map, sorted_by_name, sorted_by_size, repo_sorted_by_name, repo_sorted_by_size, txt_record_indices, repo_txt_record_indices
527
  _data_generation += 1
528
  word_index = {}
529
  folder_index = {}
@@ -535,6 +606,8 @@ def build_indexes(build_words=True):
535
  repo_sorted_by_size = {}
536
  txt_record_indices = []
537
  repo_txt_record_indices = {}
 
 
538
  for idx, rec in enumerate(records):
539
  repo = rec.get("Repo", "")
540
  repo_counts[repo] = repo_counts.get(repo, 0) + 1
@@ -554,6 +627,9 @@ def build_indexes(build_words=True):
554
  if rec.get("HasTxt"):
555
  txt_record_indices.append(idx)
556
  repo_txt_record_indices.setdefault(repo, []).append(idx)
 
 
 
557
  folders_lower = [f.lower() for f in (folders or [])]
558
  rec["_file_lower"] = file_name.lower()
559
  rec["_folders_lower"] = folders_lower
@@ -775,7 +851,7 @@ def build_global_bootstrap():
775
  "generation": _data_generation,
776
  "repos": repo_list,
777
  "extensions": [{"name": name, "count": extension_counts[name]} for name in sorted(extension_counts)],
778
- "random_txt": {"available": bool(txt_record_indices), "count": len(txt_record_indices)},
779
  "sidebar": sidebar,
780
  }
781
 
@@ -1123,6 +1199,7 @@ async def lifespan(app: FastAPI):
1123
  auto_decompress=False,
1124
  )
1125
  app.state.upstream_semaphore = asyncio.Semaphore(8)
 
1126
  app.state.txt_warm_task = asyncio.create_task(warm_txt_proxy_loop(app))
1127
  try:
1128
  yield
@@ -1196,6 +1273,10 @@ def api_search_repo(repo_name: str, body: SearchRequest):
1196
 
1197
  def api_repos():
1198
  return JSONResponse(cached_payload(("repos", _data_generation), lambda: repo_list), headers=METADATA_CACHE_HEADERS)
 
 
 
 
1199
  @app.get("/api/extensions")
1200
 
1201
  def api_extensions(repo: Optional[str] = Query(default=None)):
@@ -1262,6 +1343,21 @@ def api_random_txt_status(repo: Optional[str] = Query(default=None)):
1262
  count = len(txt_record_indices)
1263
  return JSONResponse({"available": count > 0, "count": count}, headers=METADATA_CACHE_HEADERS)
1264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1265
  @app.get("/api/bootstrap")
1266
  def api_bootstrap():
1267
  return JSONResponse(
 
7
  import re
8
  import time
9
  import functools
10
+ import io
11
  from pathlib import Path
12
  from typing import Optional
13
  from urllib.parse import unquote, quote, urljoin, urlparse
 
44
  TXT_SPACE_RAW_BASE = "https://huggingface.co/spaces/VoiceOfML/Search/raw/main/txt"
45
  TXT_WARM_INTERVAL_SECONDS = 600
46
  TXT_WARM_IDLE_SECONDS = 600
47
+ READER_ASSETS_URL = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/reader_assets.json.gz"
48
+ READER_ASSETS_CACHE_TTL_SECONDS = 300
49
+ READER_ASSETS_MAX_COMPRESSED_BYTES = 5 * 1024 * 1024
50
+ READER_ASSETS_MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024
51
  records: list[dict] = []
52
  folder_tree_data: dict[str, list[dict]] = {}
53
  folder_browser_data: dict[str, dict[str, dict]] = {}
 
66
  repo_sorted_by_size = {}
67
  txt_record_indices = []
68
  repo_txt_record_indices = {}
69
+ READER_EXTENSIONS = frozenset({"pdf", "epub", "txt", "md", "markdown", "jpg", "jpeg", "png", "gif", "bmp", "webp"})
70
+ reader_record_indices = []
71
+ repo_reader_record_indices = {}
72
  initial_payload_global = None
73
  initial_payload_by_repo = {}
74
  API_CACHE_TTL_SECONDS = 120
 
83
  ngram_posting_buffers: dict[int, bytes] = {}
84
  injected_html_cache: dict[str | None, str] = {}
85
  last_user_request_at = time.monotonic()
86
+ reader_assets_cache = {"v": 1, "f": {}}
87
+ reader_assets_cache_at = 0.0
88
 
89
  def cached_payload(key, builder):
90
  now = time.monotonic()
 
98
  api_response_cache.pop(oldest_key, None)
99
  return value
100
 
101
+
102
+ def decode_reader_assets(raw: bytes) -> dict:
103
+ if len(raw) > READER_ASSETS_MAX_COMPRESSED_BYTES:
104
+ raise ValueError("reader assets sidecar is too large")
105
+ with gzip.GzipFile(fileobj=io.BytesIO(raw)) as stream:
106
+ decoded = stream.read(READER_ASSETS_MAX_DECOMPRESSED_BYTES + 1)
107
+ if len(decoded) > READER_ASSETS_MAX_DECOMPRESSED_BYTES:
108
+ raise ValueError("reader assets sidecar expands beyond limit")
109
+ payload = json.loads(decoded)
110
+ files = payload.get("f") if isinstance(payload, dict) and payload.get("v") == 1 else None
111
+ if not isinstance(files, dict):
112
+ raise ValueError("invalid reader assets sidecar")
113
+ clean = {}
114
+ for key, entry in files.items():
115
+ if not isinstance(key, str) or not isinstance(entry, dict) or entry.get("s") not in (2, 4):
116
+ continue
117
+ if entry.get("s") == 2:
118
+ path = entry.get("p")
119
+ mode = entry.get("m")
120
+ if mode not in ("p", "e") or not isinstance(path, str) or not re.fullmatch(r"objects/[0-9a-f]{2}/[0-9a-f]{64}/(?:document\.pdf|book\.epub)", path):
121
+ continue
122
+ clean[key] = {"s": 2, "m": mode, "p": path}
123
+ else:
124
+ clean[key] = {"s": 4}
125
+ return {"v": 1, "f": clean}
126
+
127
+
128
+ async def get_reader_assets() -> dict:
129
+ global reader_assets_cache, reader_assets_cache_at
130
+ now = time.monotonic()
131
+ if now - reader_assets_cache_at < READER_ASSETS_CACHE_TTL_SECONDS:
132
+ return reader_assets_cache
133
+ async with app.state.reader_assets_lock:
134
+ now = time.monotonic()
135
+ if now - reader_assets_cache_at < READER_ASSETS_CACHE_TTL_SECONDS:
136
+ return reader_assets_cache
137
+ response = None
138
+ try:
139
+ response = await app.state.http_session.get(
140
+ READER_ASSETS_URL,
141
+ timeout=aiohttp.ClientTimeout(total=20, connect=8, sock_read=10),
142
+ )
143
+ if response.status != 200:
144
+ raise RuntimeError(f"reader assets HTTP {response.status}")
145
+ chunks = bytearray()
146
+ async for chunk in response.content.iter_chunked(64 * 1024):
147
+ chunks.extend(chunk)
148
+ if len(chunks) > READER_ASSETS_MAX_COMPRESSED_BYTES:
149
+ raise ValueError("reader assets response is too large")
150
+ raw = bytes(chunks)
151
+ reader_assets_cache = decode_reader_assets(raw)
152
+ reader_assets_cache_at = now
153
+ except Exception as exc:
154
+ print(f"reader assets refresh failed: {exc}")
155
+ if not reader_assets_cache_at:
156
+ reader_assets_cache_at = now
157
+ finally:
158
+ if response is not None:
159
+ response.release()
160
+ return reader_assets_cache
161
+
162
  def decode_varint(data: bytes, offset: int) -> tuple[int, int]:
163
  value = 0
164
  shift = 0
 
594
 
595
 
596
  def build_indexes(build_words=True):
597
+ global _data_generation, word_index, folder_index, extension_counts, repo_counts, repo_list, extension_list, repo_extension_counts, repo_records_map, sorted_by_name, sorted_by_size, repo_sorted_by_name, repo_sorted_by_size, txt_record_indices, repo_txt_record_indices, reader_record_indices, repo_reader_record_indices
598
  _data_generation += 1
599
  word_index = {}
600
  folder_index = {}
 
606
  repo_sorted_by_size = {}
607
  txt_record_indices = []
608
  repo_txt_record_indices = {}
609
+ reader_record_indices = []
610
+ repo_reader_record_indices = {}
611
  for idx, rec in enumerate(records):
612
  repo = rec.get("Repo", "")
613
  repo_counts[repo] = repo_counts.get(repo, 0) + 1
 
627
  if rec.get("HasTxt"):
628
  txt_record_indices.append(idx)
629
  repo_txt_record_indices.setdefault(repo, []).append(idx)
630
+ if ext in READER_EXTENSIONS:
631
+ reader_record_indices.append(idx)
632
+ repo_reader_record_indices.setdefault(repo, []).append(idx)
633
  folders_lower = [f.lower() for f in (folders or [])]
634
  rec["_file_lower"] = file_name.lower()
635
  rec["_folders_lower"] = folders_lower
 
851
  "generation": _data_generation,
852
  "repos": repo_list,
853
  "extensions": [{"name": name, "count": extension_counts[name]} for name in sorted(extension_counts)],
854
+ "random_txt": {"available": bool(reader_record_indices), "count": len(reader_record_indices)},
855
  "sidebar": sidebar,
856
  }
857
 
 
1199
  auto_decompress=False,
1200
  )
1201
  app.state.upstream_semaphore = asyncio.Semaphore(8)
1202
+ app.state.reader_assets_lock = asyncio.Lock()
1203
  app.state.txt_warm_task = asyncio.create_task(warm_txt_proxy_loop(app))
1204
  try:
1205
  yield
 
1273
 
1274
  def api_repos():
1275
  return JSONResponse(cached_payload(("repos", _data_generation), lambda: repo_list), headers=METADATA_CACHE_HEADERS)
1276
+ @app.get("/api/reader-assets")
1277
+
1278
+ async def api_reader_assets():
1279
+ return JSONResponse(await get_reader_assets(), headers={"Cache-Control": "public, max-age=300"})
1280
  @app.get("/api/extensions")
1281
 
1282
  def api_extensions(repo: Optional[str] = Query(default=None)):
 
1343
  count = len(txt_record_indices)
1344
  return JSONResponse({"available": count > 0, "count": count}, headers=METADATA_CACHE_HEADERS)
1345
 
1346
+ @app.get("/api/random-reader")
1347
+ def api_random_reader(repo: Optional[str] = Query(default=None)):
1348
+ pool = repo_reader_record_indices.get(f"VoiceOfML/{repo}", []) if repo else reader_record_indices
1349
+ if not pool:
1350
+ return JSONResponse({"error": "无可读文档"}, status_code=404)
1351
+ rec = dict(records[random.choice(pool)])
1352
+ rec["Link"] = rec.get("Link") or build_record_link(rec)
1353
+ rec["Path"] = rec.get("Path") or build_record_path_url(rec)
1354
+ return JSONResponse(rec)
1355
+
1356
+ @app.get("/api/random-reader/status")
1357
+ def api_random_reader_status(repo: Optional[str] = Query(default=None)):
1358
+ count = len(repo_reader_record_indices.get(f"VoiceOfML/{repo}", [])) if repo else len(reader_record_indices)
1359
+ return JSONResponse({"available": count > 0, "count": count}, headers=METADATA_CACHE_HEADERS)
1360
+
1361
  @app.get("/api/bootstrap")
1362
  def api_bootstrap():
1363
  return JSONResponse(
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- fastapi==0.115.0
2
- uvicorn==0.30.6
3
  aiohttp==3.14.3
4
  jieba==0.42.1
 
1
+ fastapi==0.141.1
2
+ uvicorn==0.52.4
3
  aiohttp==3.14.3
4
  jieba==0.42.1
scripts/build_static_assets.mjs CHANGED
@@ -5,7 +5,7 @@ import { extname, join } from "node:path";
5
  const outputDir = process.env.STATIC_OUTPUT_DIR || "/out";
6
  const replacements = new Map();
7
 
8
- for (const filename of ["app.js", "style.css"]) {
9
  const source = join(outputDir, filename);
10
  const content = readFileSync(source);
11
  const hash = createHash("sha256").update(content).digest("hex").slice(0, 12);
@@ -16,7 +16,7 @@ for (const filename of ["app.js", "style.css"]) {
16
  replacements.set(`/static/${filename}`, `/static/${versioned}`);
17
  }
18
 
19
- for (const filename of ["index.html", "sw.js"]) {
20
  const path = join(outputDir, filename);
21
  let content = readFileSync(path, "utf8");
22
  for (const [original, versioned] of replacements) {
 
5
  const outputDir = process.env.STATIC_OUTPUT_DIR || "/out";
6
  const replacements = new Map();
7
 
8
+ for (const filename of ["app.js", "style.css", "reader-contract.js", "reader-store.js", "reader.js", "reader.css"]) {
9
  const source = join(outputDir, filename);
10
  const content = readFileSync(source);
11
  const hash = createHash("sha256").update(content).digest("hex").slice(0, 12);
 
16
  replacements.set(`/static/${filename}`, `/static/${versioned}`);
17
  }
18
 
19
+ for (const filename of ["index.html", "reader.html", "sw.js"]) {
20
  const path = join(outputDir, filename);
21
  let content = readFileSync(path, "utf8");
22
  for (const [original, versioned] of replacements) {
scripts/copy_reader_vendor.mjs ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { get } from "node:https";
4
+ import { join } from "node:path";
5
+
6
+ const output = process.argv[2] || "static/vendor";
7
+ mkdirSync(output, { recursive: true });
8
+ function download(url, redirects = 0) {
9
+ return new Promise((resolve, reject) => {
10
+ const request = get(url, (response) => {
11
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location && redirects < 5) {
12
+ response.resume();
13
+ return download(new URL(response.headers.location, url), redirects + 1).then(resolve, reject);
14
+ }
15
+ if (response.statusCode !== 200) {
16
+ response.resume();
17
+ return reject(new Error(`${url}: HTTP ${response.statusCode}`));
18
+ }
19
+ const chunks = [];
20
+ response.on("data", (chunk) => chunks.push(chunk));
21
+ response.on("end", () => resolve(Buffer.concat(chunks)));
22
+ response.on("error", reject);
23
+ });
24
+ request.setTimeout(60000, () => request.destroy(new Error(`${url}: request timed out`)));
25
+ request.on("error", reject);
26
+ });
27
+ }
28
+
29
+ async function downloadWithRetry(url) {
30
+ let lastError;
31
+ for (let attempt = 0; attempt < 4; attempt++) {
32
+ try { return await download(url); }
33
+ catch (error) {
34
+ lastError = error;
35
+ if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, 1000 * (2 ** attempt)));
36
+ }
37
+ }
38
+ throw lastError;
39
+ }
40
+
41
+ async function main() {
42
+ for (const [url, target, expected] of [
43
+ ["https://cdn.jsdelivr.net/npm/pdfjs-dist@6.2.108/build/pdf.min.mjs", "pdf.min.mjs", "e0be3863c23c8af2305b16548febd58e7f8874a460253317d7771cddbc1c0f6d"],
44
+ ["https://cdn.jsdelivr.net/npm/pdfjs-dist@6.2.108/build/pdf.worker.min.mjs", "pdf.worker.min.mjs", "0613f41490dd6aaceed7a93fbbd38c85e6d6aa60474b6588c6e7709cfbe18cb3"],
45
+ ["https://cdn.jsdelivr.net/npm/epubjs@0.3.93/dist/epub.min.js", "epub.min.js", "06eae15745107b4aa508c95538275251f69bfb9f1175621fc458d9f42ed082d4"],
46
+ ["https://cdn.jsdelivr.net/npm/marked@18.0.10/lib/marked.umd.js", "marked.min.js", "eaccee2fb9fb3b2c09e873a5504da82507850d9e677bd720122ac49e2a03982a"],
47
+ ["https://cdn.jsdelivr.net/npm/dompurify@3.4.14/dist/purify.min.js", "purify.min.js", "c2f26ea4fc0d88141c9aa430eb515ac86fce59418ceebd85fa475b87a8d6c3e6"],
48
+ ]) {
49
+ const bytes = await downloadWithRetry(url);
50
+ const actual = createHash("sha256").update(bytes).digest("hex");
51
+ if (actual !== expected) throw new Error(`${url}: SHA-256 ${actual} does not match ${expected}`);
52
+ writeFileSync(join(output, target), bytes);
53
+ }
54
+ }
55
+
56
+ main().catch((error) => { console.error(error); process.exitCode = 1; });
static/app.js CHANGED
@@ -10,8 +10,6 @@ const ORDERED_EXTENSIONS = [
10
  "mp3", "wav",
11
  "iso", "dat", "exe",
12
  ];
13
- const TXT_BASE = "/txt";
14
-
15
  const FILE_ICON_MAP = {
16
  pdf: "pdf",
17
  txt: "text", mht: "text",
@@ -696,11 +694,55 @@ function encodeRecordPath(path) {
696
  return String(path || "").split("/").map(encodeURIComponent).join("/");
697
  }
698
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  function getRecordLink(rec) {
700
  if (rec.Link) return rec.Link;
701
  return `https://huggingface.co/datasets/${rec.Repo || ""}/resolve/main/${encodeRecordPath(buildRecordRelativePath(rec))}`;
702
  }
703
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
704
  function getRecordPath(rec) {
705
  if (rec.Path) return rec.Path;
706
  return `https://huggingface.co/datasets/${rec.Repo || ""}/blob/main/${encodeRecordPath(buildRecordRelativePath(rec))}`;
@@ -1112,7 +1154,7 @@ async function updateRandomTxtVisibility() {
1112
  const repo = STATE.mode === "repo" && STATE.repo ? STATE.repo : "";
1113
  try {
1114
  const data = repo
1115
- ? await fetchJsonWithTimeout(`/api/random-txt/status?repo=${encodeURIComponent(repo)}`, 4000)
1116
  : ((await API.getBootstrap()) || {}).random_txt;
1117
  if (!data) throw new Error("bootstrap unavailable");
1118
  if (id !== randomTxtStatusId) return;
@@ -1173,10 +1215,17 @@ function renderBrowserListItems(list, data, currentRepo, path) {
1173
  const sizeStr = formatSize(f.size);
1174
  const displayName = getBrowserFileName(f);
1175
  const fileLink = getBrowserFileLink(currentRepo, path, f);
1176
- const stem = displayName.includes(".") ? displayName.slice(0, displayName.lastIndexOf(".")) : displayName;
1177
- const txtPath = path ? `${path}/${stem}` : stem;
1178
- const readUrl = f.hasTxt ? `${TXT_BASE}/${encodeRecordPath(txtPath)}.txt` : "";
1179
- html += `<div class="browser-item" data-type="file" data-link="${escapeHTML(fileLink)}" data-filename="${escapeHTML(displayName)}"${readUrl ? ` data-read-url="${escapeHTML(readUrl)}"` : ''}>${ICONS[iconType] || ICONS.file}<span class="browser-name">${escapeHTML(displayName)}</span>${f.hasTxt ? '<span class="browser-action" data-read="1">阅读</span>' : ''}${sizeStr ? '<span class="browser-size">' + sizeStr + '</span>' : ''}</div>`;
 
 
 
 
 
 
 
1180
  }
1181
  list.innerHTML = html;
1182
  }
@@ -1187,6 +1236,8 @@ async function renderBrowser(path, routeId) {
1187
  syncStateToURL(true);
1188
  DOM.sidebarContent.innerHTML = "";
1189
  const currentRepo = STATE.repo;
 
 
1190
  const backBtn = document.createElement("div");
1191
  backBtn.className = "back-to-global";
1192
  backBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>返回全局搜索';
@@ -1351,7 +1402,7 @@ function buildResultHTML(rec, idx) {
1351
  <button class="result-action-btn" data-action="copy" data-link="${escapeHTML(getCopyableLink(recordLink))}">复制链接</button>
1352
  <button class="result-action-btn primary" data-action="download" data-filename="${escapeHTML(rec.File + (rec.Extension ? '.' + rec.Extension : ''))}" data-link="${escapeHTML(recordLink)}">下载</button>
1353
  <a href="${escapeHTML(getPreviewLink(recordPath))}" class="result-action-btn" target="_blank" rel="noopener noreferrer">仓库查看</a>
1354
- ${rec.HasTxt ? `<button class="result-action-btn" data-action="read" data-link="${escapeHTML(recordLink)}" data-repo="${repoShort}">在线阅读</button>` : ""}
1355
  </div>`;
1356
  }
1357
 
@@ -2444,12 +2495,11 @@ async function randomBook() {
2444
  }
2445
  }
2446
 
2447
- function openTxtRecord(rec, popup) {
2448
  if (!rec) return false;
2449
- const relPath = buildRecordRelativePath(rec);
2450
- const stem = relPath.includes(".") ? relPath.slice(0, relPath.lastIndexOf(".")) : relPath;
2451
- if (!stem) return false;
2452
- const url = `${TXT_BASE}/${encodeRecordPath(stem)}.txt`;
2453
  if (popup) popup.location.replace(url);
2454
  else openExternalWindow(url);
2455
  return true;
@@ -2459,11 +2509,11 @@ async function randomTxt() {
2459
  const popup = openPendingWindow();
2460
  try {
2461
  showToast("正在随机打开文章...");
2462
- const url = STATE.repo ? `/api/random-txt?repo=${encodeURIComponent(STATE.repo)}` : "/api/random-txt";
2463
  const resp = await fetch(url);
2464
  if (!resp.ok) throw new Error("HTTP " + resp.status);
2465
  const rec = await resp.json();
2466
- if (!openTxtRecord(rec, popup)) {
2467
  if (popup) popup.close();
2468
  showToast("暂无可读文章");
2469
  }
@@ -2706,6 +2756,7 @@ function applyMobileMode() {
2706
  STATE.leftSidebarOpen = true; STATE.rightSidebarOpen = false;
2707
  }
2708
  updateSidebarVisibility();
 
2709
  if (DOM.sidebarExpandBtn) DOM.sidebarExpandBtn.style.display = (STATE.mode === "repo" && !STATE.isMobile) ? "" : "none";
2710
  updateSelectionUI();
2711
  requestAnimationFrame(updateScrollTrack);
@@ -2820,18 +2871,8 @@ function setupResultDelegation() {
2820
  return;
2821
  }
2822
  if (action === "read") {
2823
- const link = actionBtn.dataset.link;
2824
- const repoShort = actionBtn.dataset.repo || STATE.repo;
2825
- const prefix = `https://huggingface.co/datasets/VoiceOfML/${repoShort}/resolve/main/`;
2826
- let relPath = "";
2827
- if (link.startsWith(prefix)) relPath = decodeURIComponent(link.slice(prefix.length));
2828
- let stem = relPath;
2829
- if (stem.includes(".")) {
2830
- const lastDot = stem.lastIndexOf(".");
2831
- const slashAfterDot = stem.indexOf("/", lastDot);
2832
- if (slashAfterDot === -1) stem = stem.substring(0, lastDot);
2833
- }
2834
- openExternalWindow(`${TXT_BASE}/${encodeRecordPath(stem)}.txt`);
2835
  return;
2836
  }
2837
  }
@@ -2879,14 +2920,20 @@ function setupResultDelegation() {
2879
  if (!browserItem) return;
2880
  if (e.target.closest(".browser-action")) {
2881
  e.stopPropagation();
2882
- const readUrl = browserItem.dataset.readUrl;
2883
- if (readUrl) openExternalWindow(readUrl);
2884
  return;
2885
  }
2886
  if (browserItem.dataset.type === "folder") {
2887
  renderBrowser(browserItem.dataset.path, ++routeRenderId);
2888
  return;
2889
  }
 
 
 
 
 
 
2890
  const fileLink = browserItem.dataset.link;
2891
  if (fileLink) downloadFile(browserItem.dataset.filename || "file", fileLink);
2892
  });
 
10
  "mp3", "wav",
11
  "iso", "dat", "exe",
12
  ];
 
 
13
  const FILE_ICON_MAP = {
14
  pdf: "pdf",
15
  txt: "text", mht: "text",
 
694
  return String(path || "").split("/").map(encodeURIComponent).join("/");
695
  }
696
 
697
+ let readerAssets = null;
698
+ let readerAssetsPending = null;
699
+
700
+ function loadReaderAssets() {
701
+ if (readerAssets) return Promise.resolve(readerAssets);
702
+ if (readerAssetsPending) return readerAssetsPending;
703
+ readerAssetsPending = fetch("/api/reader-assets")
704
+ .then(resp => resp.ok ? resp.json() : null)
705
+ .then(data => {
706
+ readerAssets = data && data.v === 1 && data.f && typeof data.f === "object" ? data.f : {};
707
+ return readerAssets;
708
+ })
709
+ .catch(() => {
710
+ readerAssets = {};
711
+ return readerAssets;
712
+ })
713
+ .finally(() => { readerAssetsPending = null; });
714
+ return readerAssetsPending;
715
+ }
716
+
717
+ function applyReaderAsset(record, repo, relativePath, originalLink) {
718
+ const asset = readerAssets && readerAssets[`${repo}\0${relativePath}`];
719
+ if (!asset || asset.s !== 2 || !["p", "e"].includes(asset.m) || !/^objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(document\.pdf|book\.epub)$/.test(asset.p || "")) return record;
720
+ return Object.assign({}, record, {
721
+ ReaderLink: `https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/${asset.p}`,
722
+ ReaderExtension: asset.m === "p" ? "pdf" : "epub",
723
+ DownloadLink: originalLink,
724
+ });
725
+ }
726
+
727
  function getRecordLink(rec) {
728
  if (rec.Link) return rec.Link;
729
  return `https://huggingface.co/datasets/${rec.Repo || ""}/resolve/main/${encodeRecordPath(buildRecordRelativePath(rec))}`;
730
  }
731
 
732
+ function getReaderLink(rec) {
733
+ const readerRecord = Object.assign({}, rec, { Link: getRecordLink(rec), ReturnUrl: location.href });
734
+ if (rec.HasTxt) {
735
+ const relPath = buildRecordRelativePath(rec);
736
+ const stem = relPath.includes(".") ? relPath.slice(0, relPath.lastIndexOf(".")) : relPath;
737
+ readerRecord.OcrUrl = `/txt/${encodeRecordPath(stem)}.txt`;
738
+ }
739
+ return VoiceOfMLReader.readerUrl(readerRecord, "/static/reader.html");
740
+ }
741
+
742
+ function isReadableRecord(rec) {
743
+ return VoiceOfMLReader.capability(rec && rec.Extension).article;
744
+ }
745
+
746
  function getRecordPath(rec) {
747
  if (rec.Path) return rec.Path;
748
  return `https://huggingface.co/datasets/${rec.Repo || ""}/blob/main/${encodeRecordPath(buildRecordRelativePath(rec))}`;
 
1154
  const repo = STATE.mode === "repo" && STATE.repo ? STATE.repo : "";
1155
  try {
1156
  const data = repo
1157
+ ? await fetchJsonWithTimeout(`/api/random-reader/status?repo=${encodeURIComponent(repo)}`, 4000)
1158
  : ((await API.getBootstrap()) || {}).random_txt;
1159
  if (!data) throw new Error("bootstrap unavailable");
1160
  if (id !== randomTxtStatusId) return;
 
1215
  const sizeStr = formatSize(f.size);
1216
  const displayName = getBrowserFileName(f);
1217
  const fileLink = getBrowserFileLink(currentRepo, path, f);
1218
+ const sourceRepo = currentRepo.startsWith("VoiceOfML/") ? currentRepo : `VoiceOfML/${currentRepo}`;
1219
+ const assetPath = path ? `${path}/${displayName}` : displayName;
1220
+ let browserRecord = { File: f.name, Extension: f.ext, Link: fileLink, ReturnUrl: location.href };
1221
+ if (f.hasTxt) {
1222
+ const relativePath = path ? `${path}/${f.name}` : f.name;
1223
+ const stem = f.ext ? relativePath.replace(new RegExp(`\\.${f.ext}$`, "i"), "") : relativePath;
1224
+ browserRecord.OcrUrl = `/txt/${encodeRecordPath(stem)}.txt`;
1225
+ }
1226
+ browserRecord = applyReaderAsset(browserRecord, sourceRepo, assetPath, fileLink);
1227
+ const readUrl = VoiceOfMLReader.readerUrl(browserRecord, "/static/reader.html");
1228
+ html += `<div class="browser-item" data-type="file" data-link="${escapeHTML(fileLink)}" data-filename="${escapeHTML(displayName)}"${readUrl ? ` data-read-url="${escapeHTML(readUrl)}"` : ''}>${ICONS[iconType] || ICONS.file}<span class="browser-name">${escapeHTML(displayName)}</span><span class="browser-action" data-download="1">下载</span>${sizeStr ? '<span class="browser-size">' + sizeStr + '</span>' : ''}</div>`;
1229
  }
1230
  list.innerHTML = html;
1231
  }
 
1236
  syncStateToURL(true);
1237
  DOM.sidebarContent.innerHTML = "";
1238
  const currentRepo = STATE.repo;
1239
+ await loadReaderAssets();
1240
+ if (routeId && routeId !== routeRenderId) return;
1241
  const backBtn = document.createElement("div");
1242
  backBtn.className = "back-to-global";
1243
  backBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>返回全局搜索';
 
1402
  <button class="result-action-btn" data-action="copy" data-link="${escapeHTML(getCopyableLink(recordLink))}">复制链接</button>
1403
  <button class="result-action-btn primary" data-action="download" data-filename="${escapeHTML(rec.File + (rec.Extension ? '.' + rec.Extension : ''))}" data-link="${escapeHTML(recordLink)}">下载</button>
1404
  <a href="${escapeHTML(getPreviewLink(recordPath))}" class="result-action-btn" target="_blank" rel="noopener noreferrer">仓库查看</a>
1405
+ ${isReadableRecord(rec) ? `<button class="result-action-btn" data-action="read" data-reader-url="${escapeHTML(getReaderLink(rec))}">在线阅读</button>` : ""}
1406
  </div>`;
1407
  }
1408
 
 
2495
  }
2496
  }
2497
 
2498
+ function openReaderRecord(rec, popup) {
2499
  if (!rec) return false;
2500
+ const url = getReaderLink(rec);
2501
+ if (!url) return false;
2502
+ if (STATE.isMobile) { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); }
 
2503
  if (popup) popup.location.replace(url);
2504
  else openExternalWindow(url);
2505
  return true;
 
2509
  const popup = openPendingWindow();
2510
  try {
2511
  showToast("正在随机打开文章...");
2512
+ const url = STATE.repo ? `/api/random-reader?repo=${encodeURIComponent(STATE.repo)}` : "/api/random-reader";
2513
  const resp = await fetch(url);
2514
  if (!resp.ok) throw new Error("HTTP " + resp.status);
2515
  const rec = await resp.json();
2516
+ if (!openReaderRecord(rec, popup)) {
2517
  if (popup) popup.close();
2518
  showToast("暂无可读文章");
2519
  }
 
2756
  STATE.leftSidebarOpen = true; STATE.rightSidebarOpen = false;
2757
  }
2758
  updateSidebarVisibility();
2759
+ document.documentElement.classList.remove("mobile-boot");
2760
  if (DOM.sidebarExpandBtn) DOM.sidebarExpandBtn.style.display = (STATE.mode === "repo" && !STATE.isMobile) ? "" : "none";
2761
  updateSelectionUI();
2762
  requestAnimationFrame(updateScrollTrack);
 
2871
  return;
2872
  }
2873
  if (action === "read") {
2874
+ if (STATE.isMobile) { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); }
2875
+ openExternalWindow(actionBtn.dataset.readerUrl);
 
 
 
 
 
 
 
 
 
 
2876
  return;
2877
  }
2878
  }
 
2920
  if (!browserItem) return;
2921
  if (e.target.closest(".browser-action")) {
2922
  e.stopPropagation();
2923
+ const fileLink = browserItem.dataset.link;
2924
+ if (fileLink) downloadFile(browserItem.dataset.filename || "file", fileLink);
2925
  return;
2926
  }
2927
  if (browserItem.dataset.type === "folder") {
2928
  renderBrowser(browserItem.dataset.path, ++routeRenderId);
2929
  return;
2930
  }
2931
+ const readUrl = browserItem.dataset.readUrl;
2932
+ if (readUrl) {
2933
+ if (STATE.isMobile) { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); }
2934
+ openExternalWindow(readUrl);
2935
+ return;
2936
+ }
2937
  const fileLink = browserItem.dataset.link;
2938
  if (fileLink) downloadFile(browserItem.dataset.filename || "file", fileLink);
2939
  });
static/index.html CHANGED
@@ -3,6 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 
6
  <title>VoiceOfML Search</title>
7
  <link rel="manifest" href="/manifest.json">
8
  <meta name="theme-color" content="#1a1c1e">
@@ -266,6 +267,7 @@ if ("serviceWorker" in navigator) {
266
  }
267
  </script>
268
 
 
269
  <script src="/static/app.js"></script>
270
  </body>
271
  </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <script>if (matchMedia("(max-width: 768px)").matches && localStorage.getItem("mobileMode") !== "desktop") document.documentElement.classList.add("mobile-boot");</script>
7
  <title>VoiceOfML Search</title>
8
  <link rel="manifest" href="/manifest.json">
9
  <meta name="theme-color" content="#1a1c1e">
 
267
  }
268
  </script>
269
 
270
+ <script src="/static/reader-contract.js"></script>
271
  <script src="/static/app.js"></script>
272
  </body>
273
  </html>
static/reader-contract.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function (root) {
2
+ "use strict";
3
+
4
+ const ReaderMode = Object.freeze({
5
+ UNSUPPORTED: 0,
6
+ ORIGINAL: 1,
7
+ CONVERTED: 2,
8
+ PENDING: 3,
9
+ FAILED: 4,
10
+ });
11
+ const modes = Object.freeze({
12
+ pdf: "pdf", epub: "epub", txt: "text", md: "markdown", markdown: "markdown",
13
+ jpg: "image", jpeg: "image", png: "image", gif: "image", bmp: "image", webp: "image",
14
+ });
15
+ const articleExtensions = Object.freeze(Object.keys(modes));
16
+
17
+ function capability(extension) {
18
+ const normalized = String(extension || "").toLowerCase();
19
+ return Object.freeze({
20
+ extension: normalized,
21
+ mode: modes[normalized] || null,
22
+ readerMode: modes[normalized] ? ReaderMode.ORIGINAL : ReaderMode.UNSUPPORTED,
23
+ article: !!modes[normalized],
24
+ });
25
+ }
26
+
27
+ function clampNumber(value, minimum, maximum, fallback) {
28
+ const numeric = Math.round(Number(value));
29
+ return Number.isFinite(numeric) ? Math.min(maximum, Math.max(minimum, numeric)) : fallback;
30
+ }
31
+
32
+ function readerUrl(record, basePath) {
33
+ const source = record && (record.ReaderLink || record.readerLink || record.Link || record.link);
34
+ const readerExtension = record && (record.ReaderExtension || record.readerExtension || record.Extension || record.extension);
35
+ if (!source || capability(readerExtension).readerMode === ReaderMode.UNSUPPORTED) return "";
36
+ const params = new URLSearchParams({
37
+ url: source,
38
+ title: (record.File || record.name || "") + ((record.Extension || record.extension) ? "." + (record.Extension || record.extension) : ""),
39
+ ext: readerExtension || "",
40
+ });
41
+ if (record.DownloadLink || record.downloadLink) params.set("download", record.DownloadLink || record.downloadLink);
42
+ if (record.OcrUrl || record.ocrUrl) params.set("ocr", record.OcrUrl || record.ocrUrl);
43
+ if (record.ReturnUrl || record.returnUrl) params.set("return", record.ReturnUrl || record.returnUrl);
44
+ return (basePath || "/reader.html") + "?" + params.toString();
45
+ }
46
+
47
+ root.VoiceOfMLReader = Object.freeze({ ReaderMode, articleExtensions, capability, clampNumber, readerUrl });
48
+ })(typeof self !== "undefined" ? self : window);
static/reader-store.js ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function (root) {
2
+ "use strict";
3
+ const DB_NAME = "voiceofml-reader";
4
+ const STORE_NAME = "entries";
5
+ const MAX_ENTRIES = 200;
6
+ let databasePromise = null;
7
+
8
+ function openDatabase() {
9
+ if (databasePromise) return databasePromise;
10
+ databasePromise = new Promise((resolve, reject) => {
11
+ const request = indexedDB.open(DB_NAME, 1);
12
+ request.onupgradeneeded = () => {
13
+ const store = request.result.createObjectStore(STORE_NAME, { keyPath: "url" });
14
+ store.createIndex("lastReadAt", "lastReadAt");
15
+ };
16
+ request.onsuccess = () => resolve(request.result);
17
+ request.onerror = () => { databasePromise = null; reject(request.error); };
18
+ });
19
+ return databasePromise;
20
+ }
21
+
22
+ async function transaction(mode, callback) {
23
+ const database = await openDatabase();
24
+ return new Promise((resolve, reject) => {
25
+ const tx = database.transaction(STORE_NAME, mode);
26
+ const result = callback(tx.objectStore(STORE_NAME));
27
+ tx.oncomplete = () => resolve(result);
28
+ tx.onerror = () => reject(tx.error);
29
+ tx.onabort = () => reject(tx.error);
30
+ });
31
+ }
32
+
33
+ async function get(url) {
34
+ const database = await openDatabase();
35
+ return new Promise((resolve, reject) => {
36
+ const tx = database.transaction(STORE_NAME, "readonly");
37
+ const request = tx.objectStore(STORE_NAME).get(url);
38
+ request.onsuccess = () => resolve(request.result || null);
39
+ request.onerror = () => reject(request.error);
40
+ });
41
+ }
42
+
43
+ async function put(entry) {
44
+ const database = await openDatabase();
45
+ await new Promise((resolve, reject) => {
46
+ const tx = database.transaction(STORE_NAME, "readwrite");
47
+ const store = tx.objectStore(STORE_NAME);
48
+ const existing = store.get(entry.url);
49
+ existing.onsuccess = () => {
50
+ if (existing.result) {
51
+ store.put(entry);
52
+ return;
53
+ }
54
+ const count = store.count();
55
+ count.onsuccess = () => {
56
+ if (count.result < MAX_ENTRIES) {
57
+ store.put(entry);
58
+ return;
59
+ }
60
+ const oldest = store.index("lastReadAt").openCursor();
61
+ oldest.onsuccess = () => {
62
+ if (oldest.result) oldest.result.delete();
63
+ store.put(entry);
64
+ };
65
+ };
66
+ };
67
+ tx.oncomplete = resolve;
68
+ tx.onerror = () => reject(tx.error);
69
+ tx.onabort = () => reject(tx.error);
70
+ });
71
+ }
72
+
73
+ async function list(limit = MAX_ENTRIES) {
74
+ const database = await openDatabase();
75
+ return new Promise((resolve, reject) => {
76
+ const entries = [];
77
+ const tx = database.transaction(STORE_NAME, "readonly");
78
+ const request = tx.objectStore(STORE_NAME).index("lastReadAt").openCursor(null, "prev");
79
+ request.onsuccess = () => {
80
+ const cursor = request.result;
81
+ if (!cursor || entries.length >= limit) return resolve(entries);
82
+ entries.push(cursor.value); cursor.continue();
83
+ };
84
+ request.onerror = () => reject(request.error);
85
+ });
86
+ }
87
+
88
+ async function remove(url) { await transaction("readwrite", (store) => store.delete(url)); }
89
+ root.VoiceOfMLReaderStore = Object.freeze({ DB_NAME, MAX_ENTRIES, get, put, list, remove });
90
+ })(typeof self !== "undefined" ? self : window);
static/reader.css ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #111315; color: #eceff1; }
2
+ * { box-sizing: border-box; letter-spacing: 0; }
3
+ body { margin: 0; height: 100vh; height: 100dvh; overflow: hidden; display: flex; flex-direction: column; }
4
+ .reader-toolbar { min-height: 44px; flex: none; display: flex; align-items: center; gap: 7px; padding: 3px 10px; background: #1b1e21; border-bottom: 1px solid #34383d; }
5
+ .reader-heading { min-width: 0; flex: 1; display: flex; flex-direction: column; }
6
+ .reader-heading strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; line-height: 18px; }
7
+ .reader-heading span { color: #9ba3aa; font-size: 12px; line-height: 16px; }
8
+ .reader-actions { display: flex; align-items: center; gap: 4px; }
9
+ .control-group { display: inline-flex; align-items: center; gap: 2px; }
10
+ .control-group[hidden] { display: none; }
11
+ .compact-input { height: 32px; display: inline-flex; align-items: center; border: 1px solid #3b4147; background: #25292d; border-radius: 4px; padding-right: 4px; color: #c6ccd1; font-size: 12px; }
12
+ .compact-input input { width: 34px; height: 28px; border: 0; background: transparent; color: inherit; text-align: right; font: inherit; outline: none; }
13
+ .icon-button { width: 32px; height: 32px; border: 0; background: transparent; color: inherit; display: inline-grid; place-items: center; text-decoration: none; cursor: pointer; border-radius: 4px; font-size: 18px; }
14
+ .text-button { height: 32px; padding: 0 8px; border: 0; background: transparent; color: inherit; display: inline-flex; align-items: center; text-decoration: none; border-radius: 4px; font-size: 12px; }
15
+ .text-button[hidden] { display: none; }
16
+ .icon-button:hover, .text-button:hover { background: #292d31; }
17
+ .history-panel { position: fixed; top: 44px; right: 0; bottom: 0; width: min(380px, 92vw); z-index: 20; background: #1b1e21; border-left: 1px solid #34383d; box-shadow: -5px 0 20px #0008; overflow: auto; }
18
+ .history-panel > header { height: 52px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #34383d; }
19
+ .history-item { display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; padding: 12px; border-bottom: 1px solid #2d3237; }
20
+ .history-item a { color: #e5e9ec; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
21
+ .history-item small { color: #929aa1; }
22
+ .history-item button { grid-row: 1 / 3; grid-column: 2; border: 0; background: transparent; color: #b5bbc0; cursor: pointer; }
23
+ .reader-viewport { min-height: 0; flex: 1; overflow: auto; background: #121416; }
24
+ .loading-status { display: none; color: #aeb5ba; font-size: 13px; text-align: center; padding: 12px; }
25
+ .loading-status[hidden] { display: none; }
26
+ .reader-content { --reader-zoom: 1; width: min(calc(100% * var(--reader-zoom)), calc(1100px * var(--reader-zoom))); margin: 0 auto; padding: 20px; }
27
+ .reader-page { position: relative; margin: 0 auto 18px; background: white; box-shadow: 0 2px 14px #0008; min-height: 160px; }
28
+ .reader-page canvas { display: block; width: 100%; height: auto; }
29
+ .reader-page canvas { position: relative; opacity: 0; transition: opacity 120ms ease; }
30
+ .reader-page canvas.ready { opacity: 1; }
31
+ .reader-image { display: block; max-width: 100%; height: auto; margin: 0 auto; background: #fff; }
32
+ .reader-text { margin: 0 auto; max-width: 76ch; white-space: pre-wrap; overflow-wrap: anywhere; font: 17px/1.8 ui-monospace, monospace; color: #e7e9eb; }
33
+ .reader-markdown { margin: 0 auto; max-width: 76ch; font-size: 17px; line-height: 1.75; }
34
+ .reader-markdown img { max-width: 100%; }
35
+ .reader-markdown a { color: #72a7df; }
36
+ .reader-error { margin: 20vh auto; max-width: 560px; color: #d7dadd; text-align: center; line-height: 1.6; }
37
+ .epub-frame { width: 100%; height: calc(100vh - 96px); background: white; }
38
+ @media (max-width: 600px) { .reader-toolbar { min-height: 36px; gap: 1px; padding: 2px 5px; } .reader-content { padding: 10px; } .reader-heading { display: none; } .reader-actions { flex: 1; justify-content: flex-end; gap: 1px; min-width: 0; } .reader-actions .control-group { gap: 0; } .reader-actions .icon-button { width: 26px; } .reader-actions .text-button { height: 30px; padding: 0 3px; } .loading-status { display: block; padding: 10px 6px 0; } .icon-button { width: 26px; height: 30px; flex: none; } .compact-input { height: 30px; padding-right: 3px; font-size: 11px; } .compact-input input { width: 24px; height: 28px; } .zoom-controls .compact-input input { width: 32px; } .history-panel { top: 36px; } }
static/reader.html ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="theme-color" content="#181a1d">
7
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: https://huggingface.co https://hf-mirror.com https://*.huggingface.co https://*.hf.co https://*.xethub.hf.co; connect-src 'self' blob: https://huggingface.co https://hf-mirror.com https://*.huggingface.co https://*.hf.co https://*.xethub.hf.co; worker-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; base-uri 'none'; form-action 'none'; object-src 'none'">
8
+ <title>VoiceOfML Reader</title>
9
+ <link rel="stylesheet" href="/static/reader.css">
10
+ <script src="/static/reader-contract.js"></script>
11
+ <script src="/static/reader-store.js"></script>
12
+ </head>
13
+ <body>
14
+ <header class="reader-toolbar">
15
+ <button id="back" class="icon-button" type="button" title="返回" aria-label="返回">&#8592;</button>
16
+ <div class="reader-heading"><strong id="title">在线阅读</strong><span id="status">正在准备...</span></div>
17
+ <div class="reader-actions">
18
+ <span class="control-group page-controls">
19
+ <button id="page-prev" class="icon-button" type="button" title="上一页" aria-label="上一页">&#8249;</button>
20
+ <label class="compact-input"><input id="page-number" type="number" min="1" value="1" inputmode="numeric" aria-label="页码"><span id="page-total">/ -</span></label>
21
+ <button id="page-next" class="icon-button" type="button" title="下一页" aria-label="下一页">&#8250;</button>
22
+ </span>
23
+ <span class="control-group zoom-controls">
24
+ <button id="zoom-out" class="icon-button" type="button" title="缩小" aria-label="缩小">&#8722;</button>
25
+ <label class="compact-input"><input id="zoom" type="number" min="50" max="250" step="10" value="100" inputmode="numeric" aria-label="缩放比例"><span>%</span></label>
26
+ <button id="zoom-in" class="icon-button" type="button" title="放大" aria-label="放大">+</button>
27
+ </span>
28
+ <a id="ocr" class="text-button" title="阅读 OCR 文本" target="_blank" rel="noopener noreferrer" hidden>OCR</a>
29
+ <a id="download" class="icon-button" title="代理下载原文件" aria-label="代理下载原文件">&#8681;</a>
30
+ </div>
31
+ <button id="history" class="icon-button" type="button" title="最近阅读" aria-label="最近阅读">&#9776;</button>
32
+ </header>
33
+ <main id="viewport" class="reader-viewport"><div id="loading-status" class="loading-status" role="status">正在加载原文件...</div><div id="content" class="reader-content"></div></main>
34
+ <aside id="history-panel" class="history-panel" hidden><header><strong>最近阅读</strong><button id="history-close" class="icon-button" type="button" aria-label="关闭">&times;</button></header><div id="history-list"></div></aside>
35
+ <script type="module" src="/static/reader.js"></script>
36
+ </body>
37
+ </html>
static/reader.js ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const PDFJS_URL = "/static/vendor/pdf.min.mjs";
2
+ const PDFJS_WORKER_URL = "/static/vendor/pdf.worker.min.mjs";
3
+ const EPUB_URL = "/static/vendor/epub.min.js";
4
+ const MARKED_URL = "/static/vendor/marked.min.js";
5
+ const PURIFY_URL = "/static/vendor/purify.min.js";
6
+ const params = new URLSearchParams(location.search);
7
+ const sourceUrl = params.get("url") || "";
8
+ const downloadUrl = params.get("download") || sourceUrl;
9
+ const extension = (params.get("ext") || "").toLowerCase();
10
+ const capability = VoiceOfMLReader.capability(extension);
11
+ const content = document.querySelector("#content");
12
+ const status = document.querySelector("#status");
13
+ const loadingStatus = document.querySelector("#loading-status");
14
+ const title = params.get("title") || "在线阅读";
15
+ const ocrUrl = params.get("ocr") || "";
16
+ const returnUrl = params.get("return") || "";
17
+ let zoom = 1;
18
+ let currentPage = 1;
19
+ let pageCount = 0;
20
+ let restoredEntry = null;
21
+ let saveTimer = 0;
22
+ let pdfDocument = null;
23
+ let pdfRenderGeneration = 0;
24
+ let pdfActiveRenders = 0;
25
+ const pdfRenderWaiters = [];
26
+ let epubRendition = null;
27
+ let epubLocation = "";
28
+ let lastSavedProgress = "";
29
+ let progressSaveChain = Promise.resolve();
30
+ const viewport = document.querySelector("#viewport");
31
+ const zoomInput = document.querySelector("#zoom");
32
+ const pageInput = document.querySelector("#page-number");
33
+ document.querySelector(".page-controls").hidden = !["pdf", "epub"].includes(capability.mode);
34
+
35
+ document.querySelector("#title").textContent = title;
36
+ document.title = title + " - VoiceOfML Reader";
37
+ document.querySelector("#back").addEventListener("click", () => {
38
+ if (history.length > 1) history.back();
39
+ else { try { const target = new URL(returnUrl); location.assign(target.origin === location.origin ? target.href : "/"); } catch (_) { location.assign("/"); } }
40
+ });
41
+ function setZoom(percent, persist = true) {
42
+ const normalized = VoiceOfMLReader.clampNumber(percent, 50, 250, 100);
43
+ zoom = normalized / 100;
44
+ content.style.setProperty("--reader-zoom", String(zoom));
45
+ zoomInput.value = String(normalized);
46
+ if (pdfDocument) rerenderVisiblePdfPages();
47
+ if (persist) scheduleSave();
48
+ }
49
+ for (const [id, delta] of [["#zoom-out", -10], ["#zoom-in", 10]]) {
50
+ document.querySelector(id).addEventListener("click", () => {
51
+ setZoom(Number(zoomInput.value) + delta);
52
+ });
53
+ }
54
+ zoomInput.addEventListener("change", () => setZoom(zoomInput.value));
55
+ zoomInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { setZoom(zoomInput.value); zoomInput.blur(); } });
56
+ pageInput.addEventListener("change", () => goToPage(pageInput.value));
57
+ pageInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { goToPage(pageInput.value); pageInput.blur(); } });
58
+ document.querySelector("#page-prev").addEventListener("click", () => epubRendition ? epubRendition.prev() : goToPage(currentPage - 1));
59
+ document.querySelector("#page-next").addEventListener("click", () => epubRendition ? epubRendition.next() : goToPage(currentPage + 1));
60
+
61
+ async function goToPage(value) {
62
+ if (!pageCount) return;
63
+ const page = VoiceOfMLReader.clampNumber(value, 1, pageCount, 1);
64
+ const shell = content.querySelector(`.reader-page[data-page="${page}"]`);
65
+ if (shell) {
66
+ await renderPdfShell(shell, false, true);
67
+ shell.scrollIntoView({ block: "start" });
68
+ if (restoredEntry && page === restoredEntry.page && restoredEntry.pageOffset) viewport.scrollTop += restoredEntry.pageOffset;
69
+ }
70
+ currentPage = page; pageInput.value = String(page); scheduleSave();
71
+ }
72
+ function scheduleSave() { clearTimeout(saveTimer); saveTimer = setTimeout(saveProgress, 500); }
73
+ async function saveProgress() {
74
+ if (!validSource(sourceUrl)) return;
75
+ const shell = pageCount ? content.querySelector(`.reader-page[data-page="${currentPage}"]`) : null;
76
+ const pageOffset = shell ? Math.max(0, viewport.scrollTop - shell.offsetTop) : 0;
77
+ const progress = { url: sourceUrl, title, extension, readerUrl: location.href, page: currentPage, pageCount, pageOffset, epubLocation, scrollTop: viewport.scrollTop, zoom: Math.round(zoom * 100) };
78
+ const signature = JSON.stringify(progress);
79
+ if (signature === lastSavedProgress) return progressSaveChain;
80
+ lastSavedProgress = signature;
81
+ progressSaveChain = progressSaveChain.catch(() => {}).then(() => VoiceOfMLReaderStore.put({ ...progress, lastReadAt: Date.now() })).catch((error) => {
82
+ if (lastSavedProgress === signature) lastSavedProgress = "";
83
+ console.warn("Reader progress was not saved", error);
84
+ });
85
+ return progressSaveChain;
86
+ }
87
+ async function renderHistory() {
88
+ const list = document.querySelector("#history-list"); list.textContent = "";
89
+ try {
90
+ for (const entry of await VoiceOfMLReaderStore.list()) {
91
+ const row = document.createElement("div"); row.className = "history-item";
92
+ const link = document.createElement("a"); link.href = entry.readerUrl; link.textContent = entry.title || entry.url;
93
+ const meta = document.createElement("small"); meta.textContent = `${entry.pageCount ? `第 ${entry.page || 1} / ${entry.pageCount} 页 · ` : ""}${new Date(entry.lastReadAt).toLocaleString()}`;
94
+ const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "删除";
95
+ remove.addEventListener("click", async () => { await VoiceOfMLReaderStore.remove(entry.url); row.remove(); });
96
+ row.append(link, meta, remove); list.appendChild(row);
97
+ }
98
+ if (!list.childElementCount) list.textContent = "暂无阅读记录";
99
+ } catch (_) { list.textContent = "无法读取本地记录"; }
100
+ }
101
+ document.querySelector("#history").addEventListener("click", async () => { const panel = document.querySelector("#history-panel"); panel.hidden = !panel.hidden; if (!panel.hidden) await renderHistory(); });
102
+ document.querySelector("#history-close").addEventListener("click", () => { document.querySelector("#history-panel").hidden = true; });
103
+ viewport.addEventListener("scroll", () => {
104
+ scheduleSave();
105
+ }, { passive: true });
106
+ window.addEventListener("pagehide", saveProgress);
107
+ document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") saveProgress(); });
108
+
109
+ function validSource(raw) {
110
+ try {
111
+ const url = new URL(raw);
112
+ if (url.protocol !== "https:" || !["huggingface.co", "hf-mirror.com"].includes(url.hostname)) return false;
113
+ return /^\/datasets\/VoiceOfML\/[^/]+\/(resolve|raw)\//.test(url.pathname)
114
+ || /^\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(document\.pdf|book\.epub)$/.test(url.pathname);
115
+ } catch (_) { return false; }
116
+ }
117
+ function validOcr(raw) {
118
+ try { const url = new URL(raw, location.origin); return url.origin === location.origin && url.pathname.startsWith("/txt/"); }
119
+ catch (_) { return false; }
120
+ }
121
+ function loadScript(url) {
122
+ return new Promise((resolve, reject) => {
123
+ const script = document.createElement("script"); script.src = url; script.onload = resolve; script.onerror = reject;
124
+ document.head.appendChild(script);
125
+ });
126
+ }
127
+ function fail(message) { loadingStatus.hidden = true; content.innerHTML = `<div class="reader-error">${message}</div>`; status.textContent = "无法打开"; }
128
+
129
+ async function renderPdf(prepared) {
130
+ const pdf = await prepared;
131
+ pdfDocument = pdf;
132
+ pageCount = pdf.numPages; pageInput.max = String(pageCount); document.querySelector("#page-total").textContent = `/ ${pageCount}`;
133
+ status.textContent = `${pdf.numPages} 页`;
134
+ const firstPage = await pdf.getPage(1);
135
+ const firstViewport = firstPage.getViewport({ scale: 1 });
136
+ const observer = new IntersectionObserver((entries) => entries.forEach((entry) => {
137
+ entry.target.dataset.renderVisible = entry.isIntersecting ? "1" : "0";
138
+ if (entry.isIntersecting) renderPdfShell(entry.target);
139
+ }), { root: document.querySelector("#viewport"), rootMargin: "1200px 0px" });
140
+ const pageObserver = new IntersectionObserver((entries) => {
141
+ const visible = entries.filter((entry) => entry.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
142
+ if (visible) { currentPage = Number(visible.target.dataset.page); pageInput.value = String(currentPage); scheduleSave(); }
143
+ }, { root: document.querySelector("#viewport"), threshold: [0.2, 0.5, 0.8] });
144
+ for (let page = 1; page <= pdf.numPages; page++) {
145
+ const shell = document.createElement("section"); shell.className = "reader-page"; shell.dataset.page = String(page);
146
+ shell.style.aspectRatio = `${firstViewport.width} / ${firstViewport.height}`;
147
+ const canvas = document.createElement("canvas"); canvas.setAttribute("aria-label", `第 ${page} 页`); shell.appendChild(canvas);
148
+ content.appendChild(shell); observer.observe(shell); pageObserver.observe(shell);
149
+ }
150
+ if (restoredEntry && restoredEntry.page) requestAnimationFrame(() => goToPage(restoredEntry.page));
151
+ }
152
+
153
+ async function renderPdfShell(shell, force = false, priority = false) {
154
+ if (!pdfDocument) return;
155
+ if (shell.dataset.renderState === "rendering") { if (force) shell.dataset.pendingRerender = "1"; return; }
156
+ if (!force && shell.dataset.renderState === "rendered") return;
157
+ const generation = pdfRenderGeneration;
158
+ shell.dataset.renderState = "rendering";
159
+ await acquirePdfRenderSlot(priority);
160
+ try {
161
+ const page = await pdfDocument.getPage(Number(shell.dataset.page));
162
+ const base = page.getViewport({ scale: 1 });
163
+ const scale = Math.min(3, Math.max(0.5, content.clientWidth / base.width));
164
+ const rendered = page.getViewport({ scale });
165
+ const canvas = shell.querySelector("canvas");
166
+ canvas.width = rendered.width; canvas.height = rendered.height;
167
+ shell.style.aspectRatio = `${rendered.width} / ${rendered.height}`;
168
+ await page.render({ canvasContext: canvas.getContext("2d"), viewport: rendered }).promise;
169
+ if (generation !== pdfRenderGeneration || shell.dataset.pendingRerender) {
170
+ shell.dataset.renderState = "idle";
171
+ delete shell.dataset.pendingRerender;
172
+ setTimeout(() => renderPdfShell(shell, true, priority), 0);
173
+ return;
174
+ }
175
+ canvas.classList.add("ready"); shell.dataset.renderState = "rendered";
176
+ shell.dataset.renderUsedAt = String(Date.now());
177
+ trimPdfCanvases();
178
+ } catch (error) {
179
+ shell.dataset.renderState = "idle";
180
+ console.warn(`PDF page ${shell.dataset.page} render failed`, error);
181
+ const retries = Number(shell.dataset.renderRetries || 0);
182
+ if (retries < 3) { shell.dataset.renderRetries = String(retries + 1); setTimeout(() => renderPdfShell(shell, true), 400 * (retries + 1)); }
183
+ } finally { releasePdfRenderSlot(); }
184
+ }
185
+
186
+ function acquirePdfRenderSlot(priority = false) {
187
+ const limit = matchMedia("(max-width: 700px)").matches ? 1 : 2;
188
+ if (pdfActiveRenders < limit) { pdfActiveRenders++; return Promise.resolve(); }
189
+ return new Promise((resolve) => {
190
+ const resume = () => { pdfActiveRenders++; resolve(); };
191
+ if (priority) pdfRenderWaiters.unshift(resume); else pdfRenderWaiters.push(resume);
192
+ });
193
+ }
194
+ function releasePdfRenderSlot() {
195
+ pdfActiveRenders = Math.max(0, pdfActiveRenders - 1);
196
+ const resume = pdfRenderWaiters.shift();
197
+ if (resume) resume();
198
+ }
199
+ function trimPdfCanvases() {
200
+ const limit = matchMedia("(max-width: 700px)").matches ? 7 : 11;
201
+ const rendered = [...content.querySelectorAll('.reader-page[data-render-state="rendered"]')];
202
+ if (rendered.length <= limit) return;
203
+ rendered.sort((a, b) => {
204
+ const aVisible = a.dataset.renderVisible === "1" || Number(a.dataset.page) === currentPage;
205
+ const bVisible = b.dataset.renderVisible === "1" || Number(b.dataset.page) === currentPage;
206
+ if (aVisible !== bVisible) return aVisible ? 1 : -1;
207
+ const distance = Math.abs(Number(b.dataset.page) - currentPage) - Math.abs(Number(a.dataset.page) - currentPage);
208
+ return distance || Number(a.dataset.renderUsedAt || 0) - Number(b.dataset.renderUsedAt || 0);
209
+ });
210
+ while (rendered.length > limit) {
211
+ const shell = rendered.shift();
212
+ if (shell.dataset.renderVisible === "1" || Number(shell.dataset.page) === currentPage) continue;
213
+ const canvas = shell.querySelector("canvas");
214
+ canvas.width = 0; canvas.height = 0; canvas.classList.remove("ready");
215
+ shell.dataset.renderState = "idle";
216
+ }
217
+ }
218
+
219
+ function rerenderVisiblePdfPages() {
220
+ pdfRenderGeneration++;
221
+ for (const shell of content.querySelectorAll(".reader-page")) {
222
+ const rect = shell.getBoundingClientRect();
223
+ if (rect.bottom >= -1200 && rect.top <= innerHeight + 1200) {
224
+ renderPdfShell(shell, true);
225
+ }
226
+ }
227
+ }
228
+ async function renderText(markdown, prepared) {
229
+ const response = await prepared.response; if (!response.ok) throw new Error(`HTTP ${response.status}`);
230
+ const text = await response.text();
231
+ if (!markdown) { const pre = document.createElement("pre"); pre.className = "reader-text"; pre.textContent = text; content.appendChild(pre); }
232
+ else {
233
+ await prepared.engines;
234
+ const article = document.createElement("article"); article.className = "reader-markdown";
235
+ article.innerHTML = DOMPurify.sanitize(marked.parse(text), { USE_PROFILES: { html: true } }); content.appendChild(article);
236
+ }
237
+ status.textContent = "已加载";
238
+ }
239
+ async function renderEpub(prepared) {
240
+ await prepared;
241
+ const frame = document.createElement("div"); frame.className = "epub-frame"; content.appendChild(frame);
242
+ const book = ePub(sourceUrl); epubRendition = book.renderTo(frame, { width: "100%", height: "100%", spread: "none", flow: "scrolled-doc" });
243
+ epubRendition.on("relocated", (location) => { epubLocation = location && location.start ? location.start.cfi : ""; scheduleSave(); });
244
+ await epubRendition.display(restoredEntry && restoredEntry.epubLocation || undefined); status.textContent = "EPUB";
245
+ }
246
+ async function start() {
247
+ if (!validSource(sourceUrl) || capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED) return fail("此文件暂不支持在线阅读,请下载原文件。");
248
+ document.querySelector("#download").href = `/api/download?file=${encodeURIComponent(title)}&link=${encodeURIComponent(downloadUrl)}`;
249
+ if (validOcr(ocrUrl)) { const ocr = document.querySelector("#ocr"); ocr.href = ocrUrl; ocr.hidden = false; }
250
+ try {
251
+ let prepared;
252
+ [restoredEntry, prepared] = await Promise.all([
253
+ VoiceOfMLReaderStore.get(sourceUrl).catch(() => null),
254
+ prepareDocument(),
255
+ ]);
256
+ if (restoredEntry && restoredEntry.zoom) setZoom(restoredEntry.zoom, false);
257
+ if (capability.mode === "pdf") await renderPdf(prepared);
258
+ else if (capability.mode === "image") { content.appendChild(prepared); status.textContent = "图片"; }
259
+ else if (capability.mode === "text") await renderText(false, prepared);
260
+ else if (capability.mode === "markdown") await renderText(true, prepared);
261
+ else if (capability.mode === "epub") await renderEpub(prepared);
262
+ loadingStatus.hidden = true;
263
+ if (!pageCount && restoredEntry) viewport.scrollTop = restoredEntry.scrollTop || 0;
264
+ scheduleSave();
265
+ } catch (error) { console.error(error); fail("原文件加载失败,请检查网络后重试,或下载原文件。"); }
266
+ }
267
+ function prepareDocument() {
268
+ if (capability.mode === "pdf") return import(PDFJS_URL).then((pdfjs) => { pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; return pdfjs.getDocument({ url: sourceUrl, withCredentials: false }).promise; });
269
+ if (capability.mode === "markdown") return Promise.all([fetch(sourceUrl), Promise.all([loadScript(MARKED_URL), loadScript(PURIFY_URL)])]).then(([response, engines]) => ({ response, engines }));
270
+ if (capability.mode === "text") return fetch(sourceUrl).then((response) => ({ response }));
271
+ if (capability.mode === "epub") return loadScript(EPUB_URL);
272
+ if (capability.mode === "image") return new Promise((resolve, reject) => { const image = new Image(); image.className = "reader-image"; image.alt = title; image.decoding = "async"; image.onload = () => resolve(image); image.onerror = reject; image.src = sourceUrl; });
273
+ return Promise.resolve(null);
274
+ }
275
+ start();
static/style.css CHANGED
@@ -1507,6 +1507,8 @@ body.mobile .result-actions {
1507
  }
1508
 
1509
  @media (max-width: 768px) {
 
 
1510
  body:not(.force-desktop) .sidebar {
1511
  position: fixed;
1512
  top: var(--header-h);
 
1507
  }
1508
 
1509
  @media (max-width: 768px) {
1510
+ html.mobile-boot .left-sidebar { transform: translateX(-100%); transition: none !important; }
1511
+ html.mobile-boot .right-sidebar { transform: translateX(100%); transition: none !important; }
1512
  body:not(.force-desktop) .sidebar {
1513
  position: fixed;
1514
  top: var(--header-h);
static/sw.js CHANGED
@@ -3,6 +3,11 @@ const CACHE_NAME = "voiceofml-search-hf-v1.0.0";
3
  const PRECACHE_URLS = [
4
  "/",
5
  "/static/style.css",
 
 
 
 
 
6
  "/static/app.js",
7
  "/data/initial/manifest.json",
8
  "/data/sidebar/manifest.json",
 
3
  const PRECACHE_URLS = [
4
  "/",
5
  "/static/style.css",
6
+ "/static/reader-contract.js",
7
+ "/static/reader-store.js",
8
+ "/static/reader.html",
9
+ "/static/reader.css",
10
+ "/static/reader.js",
11
  "/static/app.js",
12
  "/data/initial/manifest.json",
13
  "/data/sidebar/manifest.json",