Add Reader Assets online reading
Browse files- Dockerfile +7 -1
- app.py +98 -2
- requirements.txt +2 -2
- scripts/build_static_assets.mjs +2 -2
- scripts/copy_reader_vendor.mjs +56 -0
- static/app.js +76 -29
- static/index.html +2 -0
- static/reader-contract.js +48 -0
- static/reader-store.js +90 -0
- static/reader.css +38 -0
- static/reader.html +37 -0
- static/reader.js +275 -0
- static/style.css +2 -0
- static/sw.js +5 -0
Dockerfile
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
FROM node:22-bookworm-slim AS static-assets
|
| 2 |
|
| 3 |
-
ARG ESBUILD_VERSION=0.
|
| 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(
|
| 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.
|
| 2 |
-
uvicorn==0.
|
| 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-
|
| 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
|
| 1177 |
-
const
|
| 1178 |
-
|
| 1179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1355 |
</div>`;
|
| 1356 |
}
|
| 1357 |
|
|
@@ -2444,12 +2495,11 @@ async function randomBook() {
|
|
| 2444 |
}
|
| 2445 |
}
|
| 2446 |
|
| 2447 |
-
function
|
| 2448 |
if (!rec) return false;
|
| 2449 |
-
const
|
| 2450 |
-
|
| 2451 |
-
if (
|
| 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-
|
| 2463 |
const resp = await fetch(url);
|
| 2464 |
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
| 2465 |
const rec = await resp.json();
|
| 2466 |
-
if (!
|
| 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 |
-
|
| 2824 |
-
|
| 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
|
| 2883 |
-
if (
|
| 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="返回">←</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="上一页">‹</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="下一页">›</button>
|
| 22 |
+
</span>
|
| 23 |
+
<span class="control-group zoom-controls">
|
| 24 |
+
<button id="zoom-out" class="icon-button" type="button" title="缩小" aria-label="缩小">−</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="代理下载原文件">⇩</a>
|
| 30 |
+
</div>
|
| 31 |
+
<button id="history" class="icon-button" type="button" title="最近阅读" aria-label="最近阅读">☰</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="关闭">×</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",
|