feat(reader): deliver P0-P2 reading tools and ecosystem integrations

Add reliable reading progress, verified backup and restore, reading tools, offline downloads, source quality checks, personal credential status, OPDS serving and verified WebDAV backup uploads.
This commit is contained in:
hectorqin committed 2026-09-24 07:47:32 +08:00
1 parent e3cabf4b1f
commit 7bff77eaf8
82 files changed
+6158 -166

No files matched your search

+2
View File
@@ -28,3 +28,5 @@ web/dist/
# reviewer reads, and the part CI asserts on.
docs/ui-review/*.png
docs/ui-review/mobile/*.png
docs/ui-review/p1/*.png
docs/ui-review/p2/*.png
+8
View File
@@ -0,0 +1,8 @@
# Use with: docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
services:
reader:
image: reader:local
pull_policy: never
build:
context: .
dockerfile: server/Dockerfile
+8 -1
View File
@@ -6,7 +6,9 @@
# automatically becomes the instance admin.
services:
reader:
image: cnb.cool/hectorqin/reader:latest
# CI publishes branch and commit tags; it does not publish :latest.
# Pin READER_IMAGE to a published commit tag for repeatable upgrades.
image: ${READER_IMAGE:-cnb.cool/hectorqin/reader:main}
# To build from source instead of pulling the published image. The context is
# the repository root because the image bakes in the H5 client built from
# ./web as well as the server.
@@ -15,6 +17,11 @@ services:
# dockerfile: server/Dockerfile
container_name: reader
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
ports:
- "8080:8080"
environment:
+8
View File
@@ -13,6 +13,7 @@ The project README introduces reader. Use the guides below for installation, eve
| 书架、阅读、朗读、来源和离线 / Shelves, reading, TTS, sources, offline | [使用手册](user-guide.zh-CN.md) | Chinese only |
| 安装到桌面、离线启动和版本更新 / PWA installation, offline startup and updates | [PWA 指南](pwa.zh-CN.md) | Chinese only |
| 环境变量、目录权限、HTTPS、排错 / Settings, permissions, HTTPS, troubleshooting | [部署配置](configuration.zh-CN.md) | [Configuration](configuration.en.md) |
| 数据备份、校验与恢复 / Data backup, verification and restore | [备份恢复](backup.zh-CN.md) | Chinese only |
| 本地开发、验证、镜像与 Android 构建 / Development, checks, Docker and Android builds | [开发指南](development.zh-CN.md) | [Development](development.en.md) |
## 技术参考 · Technical reference
@@ -36,3 +37,10 @@ These records describe checks at specific delivery points. Use tests and CI for
- [书源界面 / Source UI](ui-review/sources-ux.md)
- [书库与阅读界面 / Library and reader UI](ui-review/collections-ux.md)
- [书源搜索 / Source search](ui-review/source-search.md)
- [P1 阅读工具实施与验证](p1-progress-2026-09-23.md)
- [P2 交付与验证记录](p2-progress-2026-09-24.md)
- [生态互通与客户端覆盖方案](ecosystem-roadmap.zh-CN.md)
- [OPDS 服务端接入与 WebDAV 备份上传](opds-webdav.zh-CN.md)
+19
View File
@@ -1112,3 +1112,22 @@ Invoke-RestMethod -Method Post -Uri "$readerApi/books/$readerBookId/refresh" -He
- `POST /api/v1/books/:id/switch-source`:`{entryRef,chapterId,revision}` → `{content,href}`;所选正文验证成功后事务切换,保留 bookId。目录变更返回冲突,客户端刷新再选择。
后台任务 RPC、持久化及限制见[插件扩展设计](plugin-extensions.md)。页面只接受声明式数据,不执行插件提供的浏览器脚本。
## Personal reading overrides and TTS diagnostics
All endpoints require the account access token in the Authorization header. Reading overrides additionally check book access.
- GET /api/v1/books/:id/reading-overrides returns { version, headingPrefix, corrections }.
- PUT /api/v1/books/:id/reading-overrides accepts the same object. A correction contains id, anchor (sectionId, start, end, quote, prefix, suffix), and replacement. The submitted version must match the latest version; stale writes return 409. Up to 500 corrections are allowed, with each quote/replacement at most 4,000 characters. headingPrefix is a literal prefix of at most 60 characters.
- POST /api/v1/books/:id/reading-overrides/undo accepts { version } and saves the preceding snapshot as a new version. History is scoped to account/book and stored in DATA_DIR/reader.db; it never rewrites library files.
- POST /api/v1/tts/test accepts { voice?, speed? }, synthesizes a fixed sample and returns { bytes, contentType, elapsedMs, cached }. Failure codes include TTS_DISABLED, TTS_AUTH, TTS_TIMEOUT, TTS_CONTENT, TTS_EMPTY and TTS_UPSTREAM. It does not accept an upstream URL. Audio playback remains GET /api/v1/tts.
### 章节质量和个人凭据状态(P2)
- `POST /api/v1/books/:id/switch-quality`:已登录且拥有该书,body 为 `{ entryRef, chapterId }`。沿当前来源插件获取候选正文,返回 `{ chapterId, title, latestChapter, characters, images, bytes, elapsedMs }`;不写入书架、绑定或正文缓存。耗时包含目录、正文、内嵌图片处理,不含前置 acquire 和网络往返。
- `switch-preview` 额外返回 `latestChapter`。质量检测按需执行;确认换源时重新获取并验证正文。空白或清洗后为空的正文返回 `EMPTY_CHAPTER`,不覆盖旧缓存/绑定。图片章可通过,字符统计不包含空白与 HTML 标签。
- `GET /api/v1/sources/:id/credentials`:仅当前账号的 `{ state, checkedAt, available, fields: [{ key, label, configured }] }`,不包含凭据值。state 为 unknown / reachable / auth-required / verification-required。checkedAt 是毫秒时间戳或 null;仅代表最近观察。
- `DELETE /api/v1/sources/:id/credentials/:key`:只删除当前账号该项。原 PUT 保存空字符串也会删除;保存/删除后访问状态回到 unknown。修改连接配置会清除该来源所有个人凭据并重置状态。
- 插件抛出 `AUTH_REQUIRED`、`AUTH_EXPIRED` 或 `VERIFICATION_REQUIRED` 时记录访问状态;它们不使 Reader 账号退出。部分搜索错误中出现同类码也会记录;多个站点共用一个实例时只表示其中有站点需要处理。普通网络失败不推断为凭据失效。
- [OPDS 服务端接入与 WebDAV 备份上传](opds-webdav.zh-CN.md)
+70
View File
@@ -0,0 +1,70 @@
# 数据备份与恢复
维护命令随服务端构建发布,覆盖整个 `DATA_DIR`:SQLite(含 WAL)、账号、书架、进度、笔记、签名密钥、插件包/状态、下载与缓存。SQLite 的共享内存文件会重新生成,不计入备份。**`BOOKS_DIR` 原书、Compose 配置和环境变量需另行备份。** 如果签名密钥由环境变量提供,也需保留原配置。
这是离线维护工具。运行 backup/restore 前停止所有使用该数据目录的服务和插件;`--server-stopped` 是操作者确认,不会自动检测或停止服务。校验和能发现意外损坏,不证明备份来自可信来源。
## 创建和校验
源码方式先在 `server` 运行 `npm run build`,之后执行:
```sh
node server/dist/maintenance/cli.js backup /absolute/data /absolute/backups/reader-20260923 --server-stopped
node server/dist/maintenance/cli.js verify /absolute/backups/reader-20260923
```
目标目录必须不存在,父目录必须已存在;不能与数据目录重叠。工具逐文件复制并计算 SHA-256,复制后再次核对源/目标,并检查 SQLite 完整性。不会覆盖旧备份。
Docker Compose(以下为 Linux/macOS shell;先确保当前镜像包含维护命令):
```sh
mkdir -p ./backups
docker compose stop reader
docker compose run --rm --no-deps --user 0:0 --entrypoint node \
-v "$(pwd)/backups:/backups" reader \
dist/maintenance/cli.js backup /data /backups/reader-20260923 --server-stopped
docker compose run --rm --no-deps --user 0:0 --entrypoint node \
-v "$(pwd)/backups:/backups" reader \
dist/maintenance/cli.js verify /backups/reader-20260923
docker compose up -d reader
```
如果备份/校验失败,先处理错误,不要继续升级。只执行 `verify` 可以在原服务运行时检查独立的备份目录,但不要同时修改备份。
Windows 源码命令可使用 `"D:/reader/data"` 等绝对路径。Docker bind mount 用绝对路径替换 `$(pwd)`。
## 恢复到新目录
先验证备份,再恢复到尚不存在的新数据目录:
```sh
node server/dist/maintenance/cli.js restore /absolute/backups/reader-20260923 /absolute/data-restored --server-stopped
```
Docker 示例使用独立临时容器,不挂载旧数据目录。`READER_IMAGE` 替换为含维护命令的实际镜像标签:
```sh
docker compose stop reader
docker run --rm --user 0:0 --entrypoint node \
-v "$(pwd)/backups:/backups" \
-v "$(pwd):/restore" READER_IMAGE \
dist/maintenance/cli.js restore /backups/reader-20260923 /restore/data-restored --server-stopped
# 上面的维护容器以 root 写入;只调整新恢复目录的所有权。
docker run --rm --user 0:0 --entrypoint chown \
-v "$(pwd)/data-restored:/data" READER_IMAGE -R reader:reader /data
```
恢复成功且完成上述所有权调整后,把 Compose 的数据挂载改成 `./data-restored:/data`,保留原书挂载和环境变量,再启动同一版本服务。正常启动入口只处理 `/data` 根目录,不会递归修复恢复文件的所有权;源码运行时同样需保证运行账号可读写整个恢复目录。
验收:原账号可登录;书架、进度、笔记和来源配置正确;已下载内容可读。确认前保留旧数据目录,需要回退时停止服务并切回旧挂载。
## 失败与限制
- 失败目标保留 `.reader-backup-incomplete`,服务拒绝从该目录启动;不要手动删除标记绕过校验,应修复原因后重新操作到新目录。
- `reader-backup.json` 是完成备份的文件清单;不能直接把备份目录作为运行数据目录,必须执行 restore。
- 仅支持当前 Node 版 reader 数据库;不转换旧 Java 版 `/storage` 数据。
- 保留目录内部的相对符号链接;拒绝绝对链接、外部链接和特殊设备文件。Windows 创建符号链接可能需要开发者模式或相应权限。
- 本工具不保证在服务运行中复制的一致性;停机是使用前提。书籍多时备份可能较大,请确保目标空间充足。
- 备份含账号及插件凭据,应使用受限权限存放。
- [OPDS 服务端接入与 WebDAV 备份上传](opds-webdav.zh-CN.md)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,26 @@
[
{
"issue_url": "https://api.github.com/repos/hectorqin/reader/issues/719",
"html_url": "https://github.com/hectorqin/reader/issues/719#issuecomment-5772817643",
"created_at": "2026-09-22T07:35:24Z",
"updated_at": "2026-09-22T07:35:24Z",
"author": "eking929",
"body": "谢谢你的来信,看到后我会尽快回复!"
},
{
"issue_url": "https://api.github.com/repos/hectorqin/reader/issues/719",
"html_url": "https://github.com/hectorqin/reader/issues/719#issuecomment-5773422708",
"created_at": "2026-09-22T08:26:51Z",
"updated_at": "2026-09-22T08:26:51Z",
"author": "hectorqin",
"body": "新版本目前聚焦本地书库,暂时不考虑支持远程书源相关功能"
},
{
"issue_url": "https://api.github.com/repos/hectorqin/reader/issues/720",
"html_url": "https://github.com/hectorqin/reader/issues/720#issuecomment-5790657565",
"created_at": "2026-09-23T07:14:13Z",
"updated_at": "2026-09-23T07:14:13Z",
"author": "hectorqin",
"body": "目前还在开发中,未发布镜像,而且目前只聚焦本地书库,是不支持legado书源的。"
}
]
+179
View File
@@ -0,0 +1,179 @@
# 最近两天关闭 Issue 的需求方向分析
分析日期:2026-09-23(北京时间)。对象:hectorqin/reader 的关闭 issue,以及当前本地工作区代码。
## 结论
建议以“可靠的个人书库阅读器”为主线:优先保证进度、阅读和数据不会出错,再补齐书内检索、批注、多级目录、缓存管理等日常能力。远程来源继续沿用插件架构,避免把旧版 Legado 的全部兼容性负担重新放回核心。
建议顺序:**发布与接入说明 → 阅读/进度可靠性与数据恢复 → 书内搜索、批注和目录 → 朗读与离线体验 → 书源质量与生态互通。**
下文优先级是根据影响范围、当前基础和产品定位给出的建议,不是维护者已承诺的排期。
## 1. 抓取范围与解释
- GitHub REST Search:`repo:hectorqin/reader is:issue is:closed closed:>=2026-09-21`,分页抓取 101 条,再核对关闭时间。
- 以 2026-09-23 17:31 北京时间为分析截止,最近 48 小时内共 101 条;按北京时间 9 月 22—23 日统计结果相同。9 月 22 日 1 条,9 月 23 日 100 条。
- `state_reason`:97 条 `not_planned`,4 条 `completed`(#339、#353、#719、#720)。旧问题在数分钟内密集关闭,与批量清理历史 backlog 的特征一致;不能解释为两天新增的需求,更不能解释为全部已修复。
- 完整抓取全部标题、正文、创建/关闭时间、标签和评论数量;另抓取 9 月 21 日起更新的仓库 issue 评论,相关评论为 #719 的 2 条和 #720 的 1 条。**没有抓取全部历史评论,也没有逐一检查图片和附件。**
- 当前能力通过 README、使用手册、架构文档及关键源码交叉核对。没有启动应用执行兼容性测试,因此“已有实现”不等于“已验证旧问题全部消失”。
- 完整正文和索引见 [issue 存档](closed-issues-2026-09-23/issues.md),结构化数据见 [issues.json](closed-issues-2026-09-23/issues.json)。
### 发布口径与工作区能力存在差异
[维护者在 #719 的回复](https://github.com/hectorqin/reader/issues/719#issuecomment-5773422708)表示“新版本目前聚焦本地书库,暂时不考虑支持远程书源相关功能”;[在 #720 的回复](https://github.com/hectorqin/reader/issues/720#issuecomment-5790657565)表示“目前还在开发中,未发布镜像……不支持 legado 书源”。
本地工作区已包含 local、OPDS、通用进程插件、远程章节、换源和追更。这说明本地开发能力与对外口径需要同步,但不能据此推断公开镜像已经提供这些功能。通用插件能力也不等于原生支持 Legado JSON 书源。
## 2. 需求方向与当前差距
### A. 安装、迁移和来源接入:P0
**依据:** [#719](https://github.com/hectorqin/reader/issues/719)、[#720](https://github.com/hectorqin/reader/issues/720)、#714、#666、#588。
**用户目标:** 部署成功后知道如何读到第一本书,旧用户知道哪些数据和书源可以继续使用。
**当前基础:** 已有 Docker 入门文档、插件管理 UI、npm/.tgz 安装和来源实例管理。入门文档仍展示 `cnb.cool/hectorqin/reader:latest`,但上述回复称尚未发布镜像;本次未核验镜像仓库标签。
**建议需求:**
1. 明确稳定版、开发版、旧版的入口、镜像标签、能力范围和数据目录,发布前核验文档命令可用。
2. 首次使用提供本地目录、OPDS、插件三种接入路径,并显示是否配置成功。
3. Legado JSON/订阅链接导入时给出明确兼容性说明;若由独立插件支持,提供具体插件及安装步骤,不能只提示“添加书源”。
4. 旧版迁移先明确账号、书架、进度、书源的可迁移范围,再设计预检查和迁移摘要;不能把旧 `/storage` 直接当新 `/data`。
**验收:** 新用户按发布文档部署并读到一本书;旧用户能在导入前知道兼容范围;不兼容输入显示可操作的解释。
### B. 阅读与进度可靠性:P0
**依据:** #702、#698、#696、#692、#687、#663、#655、[#652](https://github.com/hectorqin/reader/issues/652)、#645、#624、#596、#585、#580、#539、#536、#529、#512、#482。
**用户目标:** 不跳章、不漏段,换设备或重新打开仍在刚才的位置。
**当前基础:** 已有分页/滚动、手势和定位器、跨窗口加载、离线 outbox、按账号同步、按设备保存显示设置。它们是完善和回归的基础,不应重做一套阅读器。
**建议需求:**
1. 将“打开位置正确、翻页连续、退出前保存”作为版本验收门槛,覆盖 iOS Safari、Android WebView 和桌面浏览器。
2. 明确滚动到章末、点击翻页、键盘和音量键的跨章行为;手势一次只触发一个动作。
3. 字号、主题、旋转屏幕、工具栏显隐后保留正文锚点,不能靠旧页码恢复位置。
4. 显示同步状态和失败重试入口;提供进度历史/恢复能力,处理多设备离线后回传旧位置的情况。
**验收:** 连续读过章节边界后退出重进,正文位置与书架进度一致;A/B 两设备交替阅读及断网重连不发生无提示回退;切换排版不丢当前段落。
### C. 数据保护与大书库:P0/P1
**依据:** [#674](https://github.com/hectorqin/reader/issues/674)、#657、#642、#637、#632、#638、[#484](https://github.com/hectorqin/reader/issues/484)、#463、#644、#704、#697、#707、#618、#614。
**当前基础:** SQLite、独立 DATA_DIR、直接扫描 BOOKS_DIR、稳定内容身份、元数据覆盖、分页和目录递归筛选已有实现。书架服务默认每页 50、最多 200;不能再把“新增分页”作为空白功能立项。现有备份文档要求停机后复制完整数据目录。
**建议需求:**
1. 提供备份/恢复的产品入口或可靠管理命令,包含账号、进度、笔记、插件配置及一致性校验;恢复前可预览。
2. 把“文件暂时离线”“索引失效”“账号书架隐藏”“数据损坏”区分显示,提供重扫或恢复路径。
3. 验证 1 万本及更大书库下首屏、查询、增量扫描和同步性能;前端也要避免一次性载入全部数据。
4. 增加存储用量、封面/章节/下载缓存分类、日志轮转部署示例与可预览清理。
5. 本地 TXT 追加更新应考虑版本关系和进度继承。当前内容哈希参与身份,文件内容变化可能成为新副本;“自动重扫”并不自动保证追更连续性。
**验收:** 升级、重启和移动文件不丢阅读记录;备份恢复后账号和进度可用;缓存清理不删原书;大书库结果按页展示。
### D. 书内搜索、批注与内容整理:P1
**依据:** [#692](https://github.com/hectorqin/reader/issues/692)、[#709](https://github.com/hectorqin/reader/issues/709)、#710、#498、#654、#659、#688、#594、#643、#584。
**当前差距:**
- 书架/书库的书名、作者搜索已存在;没有找到完整的书内全文搜索入口和命中段落定位流程。
- 笔记、高亮、书签的数据类型、API 和离线同步已存在;没有找到阅读 UI 中创建、编辑、列表和定位的完整操作链。因此应归为“补齐产品交互”,不宜直接写成已完成。
- Web EPUB nav 解析将条目设为 `depth: 0`,章节匹配去掉 fragment;NCX 使用正则提取嵌套结构。服务端也主要提取章节标题。真实多级目录和章内锚点需要专项补齐。
- 元数据编辑已持久化,但不代表正文纠错、标题替换或 TXT 目录规则已支持。
**建议需求:**
1. 书内全文检索:结果摘要、命中高亮、跳到具体段落,保留返回原阅读位置的入口。
2. 选区高亮 → 添加批注 → 笔记列表 → 点击回正文,复用现有同步与墓碑删除机制。
3. 正确保留 EPUB nav/NCX 层级和 fragment,支持折叠、多级定位;TXT 增加可复用的章节规则及预览。
4. 正文纠错和过滤规则先采用可撤销的覆盖层,保存于 DATA_DIR;刷新不丢,规则更新可回退。只有用户明确选择且书库可写时才考虑写回原文件。
**验收:** 同一章多个搜索命中定位不同段落;批注跨设备恢复;三级目录不丢节点;刷新后纠错仍在且可撤销。
### E. 朗读体验与配置诊断:P1
**依据:** #690、[#689](https://github.com/hectorqin/reader/issues/689)、#635、#623、#616、#613、#608、#590、#580、#482。
**当前基础:** 已有浏览器、Android 原生及 HTTP 三类引擎,语音选择、逐句高亮、自动续章、服务端代理及 TTS 缓存上限。
**建议需求:** 增加配置检测与试听;区分无语音、鉴权失败、上游超时和浏览器播放限制;验证 HTTPS/反代/iOS 下播放、后台返回和续章定位;失败可重试或切换引擎。
**验收:** 管理员可用一次试听确认配置;续章从章首开始;失败有具体原因,不返回空成功;停止后没有残留音频继续播放。
### F. 离线缓存与书源质量:P1/P2
**依据:** [#513](https://github.com/hectorqin/reader/issues/513)、#717、#718、#716、#573、#672、#706、#703、#650、#658、#647、#491、#697。
**当前基础:** 已读章节优先使用服务端缓存,来源失效前已缓存的内容有继续读取的基础;搜索可流式展示和取消;已有同一来源实例内的换源和个人凭据接口。凭据接口不等于支持任意网站的交互登录,通用插件不等于兼容全部 Legado JS。
**建议需求:**
1. P1:整书/章节范围预下载、暂停/继续/取消、离线可读状态、按用户/书籍的配额和清理。明确“服务端缓存”与“设备断网可读”两个层次。
2. P2:换源候选显示当前章字数、最新章节、响应时间和获取错误;先解决明显空章,再考虑相似度,避免把相似度当内容正确性的证明。
3. P2:插件能力兼容表、登录状态/过期提示与必要的人工验证流程;解析和网站适配留在插件侧。
4. EPUB 导出和 NAS 保存作为独立能力评估,需要版本、保存位置、失败重试及存储边界。它与 OPDS 浏览是不同需求。
**验收:** 预下载后断网可读指定范围;来源停用不损坏已有书架和缓存;任务可取消且配额可见;换源失败仍保留原内容和进度。
### G. 生态互通和客户端覆盖:P2
**依据:** [#679](https://github.com/hectorqin/reader/issues/679)、#673、#717、#700、#353、#519、#693、#588、#712、#523、#634。
**当前基础:** 已有 OPDS 客户端获取能力和 Web/Android;当前检查范围内没有找到 OPDS 服务端目录发布和 Legado WebDAV 同步路由。
**建议:**
- 连接 Calibre 等 OPDS 书库归为已有功能的易用性完善。
- “把 reader 书库供其他客户端使用”需要 OPDS 服务端能力,另行立项,不能与 OPDS 客户端混为一谈。
- WebDAV 文件备份、账号数据备份、第三方阅读进度互通是三种问题。若做 Legado 兼容,单独定义书籍身份与定位映射适配器。
- 先明确支持的浏览器版本和电纸书低动画方案;独立 iOS/Windows ARM 客户端按需求规模另评估。
### H. 可选增强:P3
**依据:** #694 AI 章节总结、#699 外部评论、#711 自定义标题、#454 背景透明化、#475 RSS。
AI 速读适合在书内定位、内容访问和插件边界稳定后评估,需包含章节来源定位、调用成本和用户配置。网页标题隐私选项成本可能较低,可穿插实现。外部评论和 RSS 会扩展产品范围,建议暂缓。
旧 Java 安装、JVM 内存参数、Jackson CVE 等(#453、#626、#486、#670、#695)不应直接转为当前 Node 服务端的对应修改;应保留其“易部署、受限资源运行、依赖维护”的目标。#339、#613 属于生态分享,不直接按缺陷计数。
## 3. 当前已覆盖、应转为验收的能力
| 历史诉求 | 当前证据 | 后续动作 |
| --- | --- | --- |
| 本地目录挂载、减少复制、子目录搜索 | `ShelfService.list`、扫描器、文件管理 | 做数据规模与权限场景验收 |
| 大书架分页 | `server/src/services/shelf.ts` 的 page/pageSize | 检查前端、同步和首屏是否仍全量加载 |
| OPDS 接入 | `server/src/sources/opds.ts` | 完善添加与鉴权体验;勿宣称已有 OPDS 服务端 |
| 不同设备独立主题 | `web/src/store/settings.ts` 按设备持久化 | 验证账号同步不覆盖显示设置 |
| 自动追更和换源 | `chapter-updates.ts`、`source-host.ts` | 验证插件能力声明、换源落点与失败回滚 |
| 失效源已有章节可读 | `ChapterPublications.asset` 在取源前返回已存正文 | 验证目录与设备缓存;补整书预下载 |
| TTS 引擎与续章 | `web/src/render/speech.ts`、`server/src/services/tts.ts` | 补配置诊断与平台验收 |
| 笔记/高亮同步 | `SyncService`、`OfflineStore` 和 notes API | 补阅读器完整交互,不作为已完成产品功能 |
## 4. 建议下一批需求拆分
| 顺序 | 交付切片 | 可评审成果 |
| --- | --- | --- |
| 1 | 新旧版与来源接入说明 | 已核验部署命令、发布能力表、首次接入流程 |
| 2 | 进度可靠性 | 跨设备/离线/重排场景验收、同步状态、进度恢复方案 |
| 3 | 数据恢复 | 一致性备份、恢复校验、升级与目录变化验证 |
| 4 | 书内检索和目录 | 段落命中定位、三级目录、fragment 保留 |
| 5 | 笔记操作闭环 | 选区批注、笔记列表、回跳和跨设备同步 |
| 6 | 离线及听书 | 下载任务、缓存管理、TTS 试听和异常提示 |
| 7 | 插件生态增强 | 登录能力声明、换源质量、兼容矩阵和互通方案 |
优先级不按关闭时间或关闭原因机械排序;这批数据混有多年旧版本反馈、使用问题和生态分享。建议在立项时对各切片补充新版本真实用户验证,而非直接复刻旧版功能集合。
## 5. 源码与文档核对入口
- [使用手册](user-guide.zh-CN.md)、[架构](architecture.md)、[来源插件](source-plugins.md)、[插件扩展](plugin-extensions.md)。
- [书架分页与递归筛选](../server/src/services/shelf.ts)、[同步服务](../server/src/services/sync.ts)、[客户端同步](../web/src/core/sync.ts)。
- [设备设置](../web/src/store/settings.ts)、[阅读器交互](../web/src/ui/reader-screen.tsx)、[阅读工具栏](../web/src/ui/reader-chrome.tsx)。
- [Web EPUB 目录解析](../web/src/formats/epub.ts)、[服务端 EPUB 解析](../server/src/indexer/formats/epub.ts)。
- [章节缓存](../server/src/publications/chapters.ts)、[来源 API](../server/src/http/routes/sources.ts)、[TTS 服务](../server/src/services/tts.ts)。
+1 -1
View File
@@ -81,7 +81,7 @@ UI 改动可运行 `npm run ui:review --prefix web`,书源流程可运行 `npm
docker build -f server/Dockerfile -t reader:local .
```
构建上下文必须是仓库根目录,镜像会同时构建 Web 和服务端。部署时将 Compose 的镜像名改为 `reader:local`,或使用文件内的 `build` 配置。
构建上下文必须是仓库根目录,镜像会同时构建 Web 和服务端。可执行 `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build` 使用源码部署;后续管理也保留两个 `-f` 参数。
### 不使用容器
+37
View File
@@ -0,0 +1,37 @@
# P2 生态互通与客户端覆盖方案
日期:2026-09-24。依据:[关闭 issue 分析](closed-issues-analysis-2026-09-23.md) F、G。
## 当前可使用的能力
- Reader 的 OPDS **客户端**可连接 Calibre 等外部目录,浏览、搜索并获取文件;具体搜索、分页、下载和鉴权取决于目录服务。
- 来源页可查看插件声明的能力。个人凭据仅显示是否配置,以及当前账号最近一次访问的结果;不代表网站会话持续有效。
- 章节换源可按所选章节检测正文、查看最新章节和耗时。字符数和可读性不能证明章节完整、内容正确或站点授权。
- 阅读设置提供电纸书预设:浅色、分页、无翻页动画。设备自行管理刷新率、残影与全刷;这不是原生电纸书客户端。
## 独立交付项目
| 项目 | 首版建议边界 | 关键决策及验收 |
| --- | --- | --- |
| OPDS 服务端 | 只读发布当前账号有权访问的文件书籍;分页、搜索、封面和原文件下载 | 使用可撤销的客户端凭据;不向外泄露文件路径;两个账号互不可见;停用凭据后不能继续获取资源;用 Calibre/至少一个移动客户端验收。章节书暂不发布,待有可下载出版物再接入。 |
| WebDAV 文件备份 | 上传已完成并校验的一致性备份包,复用 P0 备份/恢复流程 | 不直接同步运行中的 SQLite;临时上传后完成发布;断网重试、配额、保留策略、凭据加密;下载后校验并在独立目录恢复。与书籍文件保存任务分离。 |
| Legado 进度互通 | 独立适配器,明确对方版本、备份格式、书籍身份及定位映射 | 先只读导入并预览冲突;书名作者相同不能直接判为同一本;来源变化、缺章、多设备冲突、时间差均有明确策略;无法映射的记录必须可解释且不覆盖本地。 |
| 章节 EPUB 导出 | 将用户有权获取的指定章节快照导出为一个任务产物 | 固定目录版本,包含图片与目录,记录缺章;取消、配额、重复请求与失败重试可控;通过 EPUB 检验工具并在外部阅读器打开。不能用导出来绕过站点访问限制。 |
| NAS 保存 | 为导出产物选择管理员允许的根目录,保存完成后再扫描入库 | 默认不覆盖同名文件;路径限制、空间不足、写入中断清理、并发导出和原子完成;数据库与书籍文件备份分别说明。 |
2026-09-24 续批已实现 OPDS 服务端只读文件书籍及备份包 WebDAV 上传,使用边界见 [接入指南](opds-webdav.zh-CN.md)。进度互通、章节导出与 NAS 保存仍为独立方案,尚未实现。
## 浏览器验收范围
构建目标 `es2020` 仅描述语法,不保证浏览器具备所需 Web API。当前桌面验收使用 Playwright Chromium,具体版本及结果见 [P2 记录](p2-progress-2026-09-24.md)。
| 客户端 | 支持目标 / 验收要求 | 当前证据 |
| --- | --- | --- |
| Chrome / Edge | 当前稳定版与上一稳定版;阅读、登录、换源、下载和 IndexedDB | 自动化 Chromium 验收,不等同于全部 Chrome/Edge 版本实测 |
| Firefox | 当前稳定版与 ESR;特别检查排版、弹窗和离线存储 | 本轮未实机验收 |
| Safari / iOS | 当前与上一主版本;检查后台冻结、存储回收、语音和安全区域 | 本轮未实机验收,暂不承诺具体最低版本 |
| Android WebView | 当前稳定版;应用桥接、文件访问和生命周期另验收 | 本轮未设备验收 |
| 电纸书内置浏览器 | 必须先验证 JS、IndexedDB、Fetch/流、AbortController 与 dialog;使用电纸书预设 | 无实机证据,旧内核可能无法使用 |
| iOS 原生 / Windows ARM 原生 | 根据用户规模另评估;优先验证现有 Web 使用路径 | 未作为本轮开发项目 |
离线应用安装和更新的环境要求参见 [PWA 指南](pwa.zh-CN.md)。设备低动画预设只关闭阅读翻页动画;其他界面动画可配合系统减少动态效果设置,无法代替硬件刷新控制。
+17 -1
View File
@@ -4,6 +4,8 @@
This guide takes you through Docker deployment, account registration, and opening your first book. To run from source, use the [development guide](development.en.md).
This guide describes the current Node implementation. CI publishes `main` (development) and short commit tags, not `latest`; availability depends on a successful build. Pin a published commit tag for repeatable deployments. Legacy Java images, `/storage` data and Legado JSON sources are not directly compatible with this version.
## 1. Prepare your environment
- Install Docker Engine or Docker Desktop and the Docker Compose plugin.
@@ -61,6 +63,14 @@ To allow later self-registration, set `ALLOW_REGISTRATION: "true"` in the Compos
The health endpoint is `/api/v1/health`. For startup failures, missing books, or write errors, see [troubleshooting](configuration.en.md#troubleshooting).
If the image is unavailable, build the checked-out source:
```sh
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
This still requires access to the Node base image and npm dependencies. Keep both `-f` arguments for subsequent management commands. For published images, set `READER_IMAGE=cnb.cool/hectorqin/reader:<published-commit-tag>` in `.env` to pin a version.
### Without Compose
On Linux/macOS with a POSIX shell, you can also run the container using absolute mount paths:
@@ -71,7 +81,7 @@ docker run -d --name reader --restart unless-stopped \
-p 8080:8080 \
-v /path/to/your/books:/books:ro \
-v "$(pwd)/data:/data" \
cnb.cool/hectorqin/reader:latest
cnb.cool/hectorqin/reader:main
```
On Windows, the Compose setup above avoids shell-specific path and line-continuation syntax.
@@ -90,6 +100,8 @@ As an administrator, add an OPDS source under Sources (书源) and configure its
### External source plugins
The generic plugin host does not parse Legado JSON. Do not enter a legacy JSON file or subscription URL as an npm package. Compatibility requires a separate plugin that explicitly supports that format; otherwise use local books or OPDS.
In Sources → Plugin management, administrators can confirm that they trust the plugin, then enter an npm package name (optionally with a version or tag) or upload a `.tgz` archive produced by `npm pack` (up to 100 MiB). Successful installations are enabled automatically; no manual deployment is needed. Add a source instance using the plugin’s source type and configure it from that instance’s management entry. One plugin can provide several independent instances. Capabilities and runtime dependencies are defined by each plugin.
Plugins run with server process privileges. Only install trusted code. The server needs Node.js and npm, plus network access for npm packages and dependencies not bundled in uploaded archives. npm lifecycle scripts are disabled, so plugins must ship prebuilt runtime files. Installation files and temporary files stay in `DATA_DIR`, never in the books directory.
@@ -98,6 +110,8 @@ See the [plugin protocol](source-plugins.md) and [extension pages](plugin-extens
## 5. Upgrade and back up
The [offline backup and restore tool (中文)](backup.zh-CN.md) checks file hashes and SQLite integrity and restores into a new directory. For older builds without this command, use a stopped-service copy as described below.
Back up both the book and data directories before upgrading. For a simple file-based SQLite backup, stop the service and copy the entire `/data` directory, including database auxiliary files, to avoid copying inconsistent live state.
```sh
@@ -109,6 +123,8 @@ docker compose up -d
Keep your data directory: it contains accounts, reading records, signing keys, and plugin state. Plugin packages are upgraded separately; disable the plugin in the UI before replacing its package.
When moving from the legacy Java version, keep a full backup and use a separate new data directory. Existing book files can be mounted, but accounts, shelves, progress, notes and source settings have no automatic migration tool. Do not copy legacy `/storage` over the new `/data`. Source-built deployments must rebuild using both Compose files instead of relying on `pull`.
## Next steps
- [User guide (中文)](user-guide.zh-CN.md): reading settings, shelves, sources, TTS, and offline access.
+17 -1
View File
@@ -4,6 +4,8 @@
本文带你完成 Docker 部署、注册账号和读取第一本书。直接从源码运行请看[开发指南](development.zh-CN.md)。
本指南对应当前 Node 版工作区。CI 发布 `main`(开发分支)和提交短哈希标签,没有发布 `latest` 的配置;标签可用性以对应构建成功为准。部署后核对版本,长期使用建议固定提交标签。旧 Java 版镜像、`/storage` 数据和 Legado JSON 书源不与新版直接兼容。
## 1. 准备环境
- 安装 Docker Engine 或 Docker Desktop,以及 Docker Compose 插件。
@@ -61,6 +63,14 @@ docker compose logs --tail=100 reader
健康检查地址为 `/api/v1/health`。若启动失败、目录为空或无法写入,请看[故障排查](configuration.zh-CN.md#故障排查)。
若镜像尚未发布或无法拉取,可从当前检出的源码构建:
```sh
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
此方式仍需能下载 Node 基础镜像和 npm 依赖。后续管理该部署时继续使用同样的两个 `-f` 参数。发布镜像部署可在 `.env` 设置 `READER_IMAGE=cnb.cool/hectorqin/reader:<已发布的提交标签>` 固定版本。
### 不使用 Compose
在 Linux/macOS 的 POSIX shell 中,也可使用绝对路径启动容器:
@@ -71,7 +81,7 @@ docker run -d --name reader --restart unless-stopped \
-p 8080:8080 \
-v /path/to/your/books:/books:ro \
-v "$(pwd)/data:/data" \
cnb.cool/hectorqin/reader:latest
cnb.cool/hectorqin/reader:main
```
Windows 推荐使用上面的 Compose 配置,避免不同 shell 的路径和换行语法差异。
@@ -90,6 +100,8 @@ Windows 推荐使用上面的 Compose 配置,避免不同 shell 的路径和
### 外部书源插件
通用来源插件不是 Legado JSON 解析器。旧版书源 JSON 或订阅链接不能直接当 npm 包安装;只有明确声明支持该格式的独立插件才能提供兼容,是否支持以该插件说明为准。尚无适用插件时,请使用本地书库或 OPDS。
管理员打开「书源 → 插件管理」,确认信任插件代码后,输入 npm 包名(可带版本或标签),或上传 `npm pack` 生成的 `.tgz` 安装包(最大 100 MiB),安装成功后自动启用。无需手动部署包目录。随后在「书源管理」选择插件提供的来源类型创建实例,从该实例的管理入口配置。一个插件可创建多个独立实例;具体功能与运行依赖由插件声明。
插件拥有服务端进程权限,请只安装可信代码。服务端需要 Node.js 和 npm;npm 包及未打包的依赖需要联网下载。安装期间禁用 npm 生命周期脚本,插件应预先构建并打包运行所需文件。所有安装文件与临时文件保存在 `DATA_DIR`,不会写入书库目录。
@@ -98,6 +110,8 @@ Windows 推荐使用上面的 Compose 配置,避免不同 shell 的路径和
## 5. 更新与备份
推荐使用[离线备份与恢复工具](backup.zh-CN.md),可验证文件清单和数据库完整性,并恢复到新目录。首次从没有维护命令的旧构建升级时,仍可按以下停机复制方式保留完整数据。
更新前备份书籍目录和数据目录。对 SQLite 采用简单文件备份时,先停止服务,再复制完整 `/data`(包括数据库及其辅助文件),避免复制运行中的不一致状态。
```sh
@@ -109,6 +123,8 @@ docker compose up -d
不要删除数据目录:其中包含账号、阅读记录、签名密钥和插件状态。插件包需单独升级;替换包前先在界面停用插件。
从旧 Java 版迁移时,先保留其完整备份,使用独立的新数据目录部署新版。本地书文件可挂载到新书库;账号、书架、进度、笔记和旧书源配置目前没有自动迁移工具,不要直接用旧 `/storage` 覆盖新 `/data`。源码构建部署更新时使用两份 Compose 文件重新构建,不能仅执行 `pull`。
## 下一步
- [使用手册](user-guide.zh-CN.md):阅读设置、书架、书源、朗读和离线。
+45
View File
@@ -0,0 +1,45 @@
# OPDS 服务端与 WebDAV 备份
## 在其他阅读器中打开 Reader 书架
1. 登录 Reader,打开“书源与追更 → 搜书 → 连接外部阅读器”。
2. 填写客户端名称并创建 OPDS 凭据。复制目录地址、OPDS 用户名和密码到支持 OPDS 1.2 / HTTP Basic 的阅读器。
3. 密码只显示一次,有效期一年。丢失后撤销并创建新的;不使用 Reader 登录密码。
目录地址通常为 `https://你的域名/opds`。反代含路径前缀时,将 `PUBLIC_URL` 配置为完整外部地址,并确保反代转发 OPDS 路由与 Authorization 头。对外部署须使用 HTTPS。
首版提供当前账号未隐藏书架中的文件书籍、搜索、每页 50 本、封面与支持 Range 的原文件下载。章节书和目录漫画暂不发布。目录和下载请求都重新验证客户端凭据及账号状态,并设为 private/no-store。撤销或禁用账号后,新请求立即失效;已经下载到外部设备的文件无法远程删除。
只读应用凭据存储为 SHA-256 摘要,不可用于 Reader 普通 API 或写入。每个账号最多 20 个客户端。第三方客户端支持程度不同,本轮已对接 Reader 内置 OPDS 客户端,尚未对 Calibre、iOS 或电纸书客户端逐一实测。
接口:
| 接口 | 认证 | 作用 |
| --- | --- | --- |
| GET/POST `/api/v1/opds/credentials` | Reader Bearer | 列出/创建自己的客户端凭据;创建返回一次性 password |
| DELETE `/api/v1/opds/credentials/:id` | Reader Bearer | 撤销自己的凭据 |
| GET `/opds?search=关键词&page=1` | OPDS Basic | Atom acquisition feed |
| GET `/opds/search.xml` | OPDS Basic | OpenSearch 描述 |
| GET `/opds/books/:id/content`、`/cover` | OPDS Basic | 原文件/封面,均检查账号书架权限 |
## 上传一致性备份到 WebDAV
这是管理员 CLI,上传已完成的备份目录;不直接同步运行中的 DATA_DIR,也不是 Legado 进度互通。先按 [备份指南](backup.zh-CN.md) 停止全部相关服务和插件、创建并验证备份。上传阶段可重新启动 Reader,备份目录保持不变。
在编译后的服务端目录运行:
```sh
# 从安全的环境/秘密管理器注入,不要把密码写进仓库或 URL
export WEBDAV_URL='https://dav.example.com/reader-backups/'
export WEBDAV_USERNAME='backup-user'
# WEBDAV_PASSWORD 由运行环境注入
node dist/maintenance/webdav-cli.js /backups/reader-20260924 reader-20260924.zip
```
目标 WebDAV collection 必须已存在,账号需有 HEAD、PUT、GET、MOVE、DELETE 权限。仅接受 HTTPS(loopback 测试允许 HTTP),不跟随重定向转发凭据。原始文件及最终 ZIP 默认限 2 GiB,可通过 `WEBDAV_MAX_BYTES` 设置;ZIP32 上限小于 4 GiB,最多 60000 条目录记录。打包使用本地临时磁盘,服务端需有足够空间。
流程为:验证目录 → 流式 ZIP 打包 → 再次验证目录 → 检查同名目标 → 上传随机临时文件 → 下载并核对大小/SHA-256 → `MOVE` 且 `Overwrite: F` 发布。暂时性 PUT 错误最多尝试三次;身份验证失败、校验失败或最终 MOVE 错误不会强行覆盖。最终发布结果不确定时须核对远端,不自动重试 MOVE。
取消/失败会尝试删除此次随机临时文件;网络持续故障时可能残留 `.reader-upload-*.zip`,管理员核对后清理。首版不自动删除历史备份、不设定计划任务。密码仅存在调用进程环境,不写入配置和日志。
恢复时,下载并在新的空目录解压 ZIP(Linux 上保留权限和内部符号链接),运行 `maintenance/cli.js verify`,通过后按备份指南恢复到新的 DATA_DIR。请使用可信工具解压;备份包含账号数据和 token.secret,应使用私有 WebDAV 存储及受控下载权限。
+26
View File
@@ -0,0 +1,26 @@
# P0 推进记录:第一批
范围来自 [issue 需求分析](closed-issues-analysis-2026-09-23.md)。本批没有改数据库 schema,也没有执行部署或操作现有用户数据。
## 已实现
1. 进度可靠性:后台/隐藏事件经过 App.flush,先等待阅读器待保存位置和本地写队列,再触发同步;打开书籍比较本地和服务端时间,保留较新的记录。服务端查询最长等待 5 秒,不可达时回退本地。存储写入错误向调用者报告,后续写入可重试,阅读器显示保存失败。
2. 数据保护:服务端离线 backup/verify/restore 命令,完整 DATA_DIR 文件校验、SQLite 完整性检查、恢复到新目录、拒绝覆盖与路径重叠。未完成目录和备份目录不能直接启动服务。操作说明见 [备份恢复](backup.zh-CN.md)。
3. 部署接入:按当前 CI 标签规则把 latest 改为 main,可通过 READER_IMAGE 固定版本;添加源码构建 Compose 覆盖文件;限制容器日志为 3 × 10 MiB;补充新旧版和 Legado 兼容范围。
## 验证方式
- Web 真实 ReaderScreen/OfflineStore 测试覆盖较新服务端、较新离线记录、网络失败、后台落盘顺序和读取期间不写临时位置。
- 存储失败测试先观察到错误被吞,再修复并验证重试及 outbox 保留。
- 服务端真实 SQLite 测试覆盖账号/进度/笔记/密钥/插件状态往返、损坏/缺失文件、路径篡改、恢复覆盖拒绝和启动保护。
- Compose 默认和源码构建配置可解析;两端 TypeScript/构建检查。
## 未完成的 P0 范围
- 进度历史和用户主动恢复入口;多设备系统时间偏差下的冲突策略。
- iOS Safari 与 Android 真机的手势、跨章、旋转、关闭/强制退出测试。本地 jsdom 测试不能替代真实渲染与系统生命周期。
- 备份/恢复图形界面和运行中一致性快照;本批只提供需要停机的维护命令。
- 首次接入向导、旧版数据自动迁移、数据损坏诊断界面。
- 镜像拉取及真实容器升级演练:当前 Docker 引擎未运行,无法执行。Linux 符号链接和权限分支需在 Linux CI 验证。
当前实现仍以客户端 updatedAt 合并进度,不能据此承诺所有历史跳章/进度问题都已修复。
+40
View File
@@ -0,0 +1,40 @@
# P1 实施与验证记录(2026-09-23)
本次实现原需求分析 D/E/F 的阅读工具主流程,并补充万本索引的查询基线。改动保留在工作区,未提交、未部署。
## 已实现
| 能力 | 交付行为 |
| --- | --- |
| 书内检索 | EPUB/TXT/远程章节逐章搜索、摘要、200 条上限、取消、命中高亮、返回原阅读位置;不对图片或扫描 PDF 做 OCR |
| 批注 | 正文选区高亮、批注、书签、编辑、删除、回正文;沿用现有离线同步和墓碑机制;修复同 ID 笔记跨账号覆盖 |
| EPUB 目录 | Web/服务端解析有序 nav/NCX 树,保留层级、fragment、spine,支持折叠和章内定位 |
| 个人纠错 | 精确文字替换、选区过滤、移除及撤销;按账号/书籍保存在 DATA_DIR/reader.db,版本冲突返回 409;不改原书 |
| TXT 目录 | 中文、英文、方括号模板和自定义标题前缀;预览后应用,规则持久化并可撤销 |
| 设备离线 | TXT/远程章节范围下载;EPUB/PDF/CBZ 整文件下载,保留 EPUB 内部资源;暂停、继续、取消;失败可重试并跳过完成资源 |
| 缓存管理 | 服务地址与账号隔离;默认 256 MiB 配额、实际资源字节占用、按书清理;配额写入串行,失败回滚旧资源 |
| 朗读诊断 | 当前引擎试听;HTTP 实际合成检测,分别报告配置、鉴权、响应体超时、非音频、空音频;停止和关面板释放试听 |
| 大书库基线 | 10,000 条索引的首尾分页、搜索、增量同步验证,单次查询按页返回 |
主要实现位于 web/src/ui/reading-tools.tsx、text-anchor.ts、reading-overrides.ts、core/offline-download.ts、store/publications.ts,以及 server/src/services/reading-overrides.ts 和对应路由。
## 验证
- Web 全量 Vitest:40 个测试文件、585 项通过。此后新增 1 项下载重试/自定义 TXT 测试,相关 34 项定向回归通过。
- Web Node 测试:29 项通过。
- 服务端全量:338 项,336 通过、2 项 Windows 环境跳过;最后的覆盖层参数校验和万本测试再次通过。
- 两端 TypeScript 检查、生产构建通过;git diff --check 通过。
- 真实 Chromium + 真实 HTTP/SQLite + 生产 Web:320/390/1280 宽度无横向溢出;搜索高亮、返回、三级目录折叠/展开、选区批注、整 EPUB 缓存后阻断所有 API 并重开、纠错刷新保留/撤销、TXT 预览应用通过。
- 浏览器验证脚本:web/tools/ui-review/p1-reading.mjs。在 web 运行 node --import tsx tools/ui-review/p1-reading.mjs;截图在 docs/ui-review/p1/。
- 万本基线:内存 SQLite,10,000 条书籍/文件/书架记录;本机独立运行三次查询合计约 91ms。并发测试受负载影响,数值只用于基线,不作为 NAS/机械盘性能承诺。
## 明确边界
- 本轮完成的是上述可用流程;真实 iOS/Android、HTTPS 反代、后台音频和跨真机恢复尚待实机验收。
- 未做磁盘上万本真实书籍的全量/增量扫描压测;内存索引基线不能替代该验收。
- TXT 自定义规则是有界标题前缀匹配,不支持任意正则或跨书自动套用的规则库;变更章节结构后从首章打开,旧定位可能失效。
- 纠错规则联网保存,已缓存规则离线应用;其他设备下次打开书籍拉取,不通过进度同步接口实时推送。空替换只过滤选区,未实现全书正则过滤。
- 下载任务关闭面板即暂停。整文件下载重试从头请求;章节下载按已完成资源续传。图片目录不支持整书预下载。
- 配额统计是新账号作用域下的正文/整文件缓存,未包含封面、服务端缓存和旧版本非账号隔离缓存;不自动淘汰。
- 浏览器缓存可能被系统回收;未缓存的章节仍需联网。正文版本变化、目录重排或换源后的旧笔记不会强行匹配到不确定文本。
- Docker 引擎未启动,本轮未做容器和实际部署验收。
+50
View File
@@ -0,0 +1,50 @@
# P2 交付与验证记录
本轮依据 [需求分析](closed-issues-analysis-2026-09-23.md) F2、F3、G,实现书源质量与插件接入体验,并完成生态互通的独立方案。P0/P1 和其他已有改动保留;未提交、未部署。
## 已交付
1. **换源质量检测**:按所选章节获取并清洗正文,展示字符数、图片数、字节大小、最新章节和检测耗时;候选卡片保留本次检测摘要或错误。不批量预抓所有来源。切换章节或关闭面板会取消检测并忽略迟到结果。
2. **空章保护**:刷新和确认换源时均强制验证,空白/清洗后为空的正文返回 `EMPTY_CHAPTER`;失败不替换当前绑定或已有缓存。质量预览不写正文缓存或修改书籍。图片章允许通过。
3. **个人凭据状态**:增加当前账号的配置状态与最近访问观察,支持删除单项;保存、删除、修改连接配置后重置观察。用 generation 防止旧请求覆盖新凭据状态,不返回凭据值。
4. **插件接入说明**:显示全部已知能力的“已声明/未声明”;站点登录过期、未登录、人工验证三个错误码不会清除 Reader 会话。说明 OPDS 客户端、网站适配、Legado 与宿主边界。
5. **电纸书预设**:一键浅色、分页、无翻页动画,沿用当前设备设置存储,不影响其他设备。不是设备全刷控制。
6. **生态方案**:[OPDS 服务端、WebDAV、进度互通、导出、NAS 与浏览器矩阵](ecosystem-roadmap.zh-CN.md)。这些协议仍需独立实施,本轮没有宣称已提供。
## 核心变更入口
- `server/src/publications/chapter-quality.ts`、`chapters.ts`:质量统计与读取/提交保护。
- `server/src/services/source-access.ts`、`source-host.ts`、`db/schema.ts`:账号隔离的访问观察和凭据事务。
- `server/src/http/routes/sources.ts`、`web/src/api/*`:质量接口、状态读取/删除与错误分类。
- `web/src/ui/source-capabilities.tsx`、`sources-screen.tsx`:能力表及凭据页。
- `web/src/ui/reader-chrome.tsx`、`reader-screen.tsx`:检测交互、取消与电纸书预设。
- [API 说明](api.md)、[来源插件说明](source-plugins.md)、[使用手册](user-guide.zh-CN.md)。
## 验证
- Web TypeScript `tsc -p tsconfig.json --noEmit`、Vite 生产构建通过。
- Server TypeScript `tsc -p tsconfig.build.json` 通过。
- Web 全量 Vitest:41 个文件、594 项通过;独立 Node 测试 29 项通过。
- Server 全量:342 项,其中 340 通过、2 项 Windows 平台跳过,0 失败。
- 核心定向测试覆盖:Unicode/空白/HTML/图片章、质量预览不写入、空章不覆盖旧缓存或绑定、书籍所有权、凭据不泄漏、账号隔离、状态重置、旧请求失效、站点错误不退出 Reader、检测结果清理与迟到结果丢弃。
- `web/tools/ui-review/p2-sources.mjs`:真实 HTTP + SQLite + 受控来源插件 + 生产 Web,Chromium **153.0.8010.12**。凭据保存/删除/状态重置、空章检测和确认换源失败、成功检测/换源、电纸书预设通过;320/390/1280 宽度无横向溢出,页面无未处理异常。
- 最终文案和取消回归补充后:Web 定向 50 项、Server 来源 API 16 项再次通过,生产构建与浏览器流程再次通过。
- `git diff --check` 通过。
截图见 [窄屏凭据页](ui-review/p2/credentials-320.png)、[窄屏质量检测](ui-review/p2/quality-320.png)、[桌面能力表](ui-review/p2/capabilities-1280.png)。
## 边界与后续
质量结果是一次读取快照,不能验证缺段、广告、标题错配或版权授权;确认换源会重新读取。耗时包括目录、正文、内嵌图片处理,不包括 acquire 和客户端网络往返。字符数不含空白和 HTML 标记。
访问状态表示来源实例最近一次请求观察;聚合插件的部分站点错误也会影响状态,不是逐网站会话管理。插件未报告过期时间时不推测,也不提供任意验证链接。人工验证仍需站点/插件实现。
浏览器流程使用受控插件,未对真实付费站点、验证码站点、Safari、Firefox、Android 或电纸书硬件做兼容认证。后续按生态方案单独验收。
## 续批:OPDS 服务端与 WebDAV(2026-09-24)
已新增只读 OPDS 1.2 服务端、个人应用凭据管理界面、独立撤销/到期、账号隔离、分页/搜索/封面/Range 下载。与 Reader 内置 OPDS 客户端完成真实协议对接。
新增管理员 WebDAV 备份 CLI:离线快照验证、流式 ZIP、临时上传重试、远端 SHA-256 校验、MOVE 不覆盖发布、取消和失败清理。未配置用户 WebDAV 地址,因此未向用户外部存储上传;已用受控 HTTP WebDAV fixture 验证并解压校验备份。详见 [接入指南](opds-webdav.zh-CN.md)。
续批验证:Server 全量在 test-concurrency=2 下 344 项(342 通过、2 Windows 跳过),Web 595 项及已有 Node 29 项;类型与生产构建通过。默认高并发服务端运行曾使既有 1 秒插件超时测试失败,降低并发重跑全量通过。浏览器覆盖新 OPDS 凭据创建、仅一次显示、重新打开不回显、撤销后目录返回 401。
+11 -1
View File
@@ -8,7 +8,7 @@
本文定义 reader 对本地书库、OPDS 书库和远程书源的统一扩展架构。目标是让核心程序只维护一套书架、阅读、缓存和任务逻辑;内置 `local`、`opds` 两种来源;远程书源以独立插件开发、安装和升级。
本文区分目标架构和当前交付。local、OPDS 下载、Node 来源插件、配置界面、持久化章节出版物、富文本/图片缓存、手动刷新和服务端自动追更已经接通。外部来源通过通用进程协议接入。设备只缓存已读章节,整书预下载和缓存淘汰仍未实现。交付状态见 §13,调用示例见 [API 参考](api.md#来源与插件)。
本文区分目标架构和当前交付。local、OPDS 下载、Node 来源插件、配置界面、持久化章节出版物、富文本/图片缓存、手动刷新和服务端自动追更已经接通。外部来源通过通用进程协议接入。P1 已增加用户主动预下载与配额检查,自动缓存淘汰仍未实现。交付状态见 §13,调用示例见 [API 参考](api.md#来源与插件)。
## 1. 目标与范围
@@ -487,3 +487,13 @@ HTTP 返回宿主 DTO,凭据不回显。插件和来源配置管理需管理
来源实例可声明独立配置页面与后台任务,同一插件的多个实例互不共享配置。页面的 Tab、表单和输出由通用 DTO 定义,见[扩展协议](plugin-extensions.md)。
## P2 接入体验补充(2026-09-24)
来源页按 descriptor 展示能力声明,不把声明当作站点可用性保证。个人凭据页显示配置状态和最近访问结果,用户可以编辑、保留或删除指定字段;不回显秘密。
插件遇到未登录、登录过期、人工验证时应分别抛出 AUTH_REQUIRED、AUTH_EXPIRED、VERIFICATION_REQUIRED(也可作为搜索部分失败项返回)。不要将登录页面当作成功正文返回。宿主不会识别任意站点的登录页面或自动绕过验证码;实际登录、验证码和网站适配仍由插件负责。未提供会话过期时间时,界面不估算过期时间。
换源的质量检测复用 acquire / getManifest / readResource,可能拉取内嵌图片;不要在这些读取调用中加入不可重入的副作用。检测结果是一次快照,确认换源时会重新读取并验证。宿主拒绝空白正文;正文正确性、缺段与广告识别仍须插件验证。
生态协议和客户端覆盖范围见 [独立方案](ecosystem-roadmap.zh-CN.md),交付证据见 [P2 记录](p2-progress-2026-09-24.md)。
+20 -1
View File
@@ -40,6 +40,8 @@
字号、主题和界面设置按设备保存;阅读进度和笔记随账号同步。
重新打开书籍时会比较本机与服务端进度,采用更新时间较新的位置;离线或服务端暂时不可达时使用本机记录。切到后台会先保存待写入的位置再尝试同步,关闭页面仍受浏览器生命周期限制。出现“阅读位置保存失败”时先保持页面打开,检查设备可用空间后重试,避免刷新丢失尚未保存的记录。
| 设置 | 作用 |
| --- | --- |
| 分页 / 滚动 | 按屏翻页或连续阅读 |
@@ -82,7 +84,7 @@ EPUB 章节按需加载,长书的目录与传输窗口分开处理,跨窗口
首个账号是管理员,后续成员默认由管理员创建。每个账号的书架、进度、笔记和高亮独立。阅读界面参数属于当前设备,不随账号覆盖其他设备的设置。
离线时可使用已经缓存的内容,重新连通后同步阅读状态。远程章节当前以已读取章节缓存为主,不保证尚未读取的整本书可离线。停用或卸载插件后已有缓存可能仍可读,获取新内容需要恢复来源。整书预下载、缓存配额和自动淘汰尚未实现。
离线时可使用已经缓存的内容,重新连通后同步阅读状态。在阅读器底部「工具 → 离线缓存」可下载远程章节/TXT 的指定范围,或下载完整 EPUB、PDF、CBZ;自定义目录的 TXT 下载完整文件。任务支持暂停、继续和取消,关闭面板会暂停,已下载内容保留。默认设备账号配额为 256 MiB,可调整或按书清理;不自动淘汰,也不删除原书、进度、笔记。服务端缓存与当前设备缓存独立,只有本设备完成下载的内容才能保证断网读取。停用来源后新内容需要恢复来源;图片目录暂不支持整书下载。
## 进一步阅读
@@ -90,3 +92,20 @@ EPUB 章节按需加载,长书的目录与传输窗口分开处理,跨窗口
- [API:目录、资源、进度、上传和来源](api.md)
- [数据边界与实现决策](architecture.md)
- [书源插件扩展](plugin-extensions.md)
## 阅读工具:搜索、笔记与内容整理
- **书内搜索**:打开「工具 → 书内搜索」,输入文字后逐章检索,最多展示 200 个命中,支持停止。点结果定位并高亮原文,再次打开工具可返回搜索前的位置。支持 EPUB/TXT/远程文字章节;扫描 PDF 和图片不提供 OCR。未缓存章节需要联网。
- **高亮和批注**:先在正文选择文字,再打开「工具 → 笔记」,选择颜色并保存。填写批注即可形成笔记;无选区也可保存当前位置书签。支持编辑、删除、点击回正文,联网后沿用账号同步。原文变化导致定位不唯一时不会猜测位置。
- **多级目录**:EPUB nav/NCX 保留三级及更深层级和章内锚点,有子项的目录可展开、折叠,选择章节后关闭面板。
- **内容整理**:先选择文字,在「工具 → 内容整理」填写替换内容,留空可以过滤选区。修改以个人覆盖层保存在服务器 DATA_DIR 的 SQLite 中,不修改书籍文件。支持逐项移除和撤销上一次修改;跨设备并发保存冲突时需重新打开书籍。保存需联网,已缓存规则可离线应用。
- **TXT 目录**:可选择中文章回、Chapter、方括号模板,或输入自定义标题前缀,预览后应用;留空恢复默认识别。规则匹配不超过 80 字的标题行,不执行任意正则。重新划分章节后从首章打开,旧笔记保留但可能需要重新定位。预览需要完整 TXT 文件。
- **朗读检测**:按当前引擎试听。HTTP 引擎先实际请求合成,分别提示未配置、鉴权失败、超时、非音频和空音频。关闭面板或点击停止会释放试听;浏览器仍可能要求允许声音播放。
## 书源质量、登录状态与电纸书
在“切换书源”中选择来源与章节,点击“检测所选章节 / 重试”,可查看字符数、图片数、大小和检测耗时。空章会给出错误;检测不会切换当前书籍。关闭选章弹窗后,候选列表保留本次检测摘要。确认换源仍会再次验证,失败保留原内容。检测通过不保证章节完整或与原书一致。
在来源页展开“插件能力与兼容性”,查看当前插件声明的能力;打开“登录凭据”查看哪些字段已配置和最近访问结果。未编辑字段会保留,清空后保存会删除。保存成功只代表已存储,请重新搜索或浏览来检查访问。需要人工验证时,到站点或插件提供的管理页面处理后重试,不必退出 Reader 账号。
阅读设置中的“应用电纸书预设”会切换为浅色、分页和无翻页动画,仅影响当前设备;其他选项仍可调整。该预设不控制电纸书全刷或硬件残影。浏览器验收范围与 OPDS/WebDAV 后续边界见 [生态互通方案](ecosystem-roadmap.zh-CN.md)。
+3
View File
@@ -98,6 +98,9 @@ function loadOrCreateSecret(dataDir: string, file = 'token.secret'): string {
export function loadConfig(): AppConfig {
const booksDir = resolve(process.env.BOOKS_DIR ?? '/books');
const dataDir = resolve(process.env.DATA_DIR ?? join(process.cwd(), DATA_DIR_DEFAULT_NAME));
if (existsSync(join(dataDir, '.reader-backup-incomplete')) || existsSync(join(dataDir, 'reader-backup.json'))) {
throw new Error('DATA_DIR is an incomplete operation or backup directory; restore a verified backup to a new directory before starting.');
}
const dataRelativeToBooks = relative(booksDir, dataDir);
const insideBooks = dataRelativeToBooks === '' || (
dataRelativeToBooks !== '..' && !dataRelativeToBooks.startsWith(`..${sep}`) && !isAbsolute(dataRelativeToBooks)
+19
View File
@@ -243,4 +243,23 @@ CREATE TABLE IF NOT EXISTS metadata_overrides (
updated_at INTEGER NOT NULL,
PRIMARY KEY (book_id, field)
);
CREATE TABLE IF NOT EXISTS reading_overrides (user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE, version INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(user_id,book_id,version));
CREATE TABLE IF NOT EXISTS opds_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS source_access (
source_id TEXT NOT NULL REFERENCES source_instances(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
generation INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'unknown',
checked_at INTEGER,
PRIMARY KEY(source_id,user_id)
);
`;
+4
View File
@@ -5,9 +5,11 @@ import { registerErrorHandler } from './errors.ts';
import { registerAuthRoutes } from './routes/auth.ts';
import { registerLibraryRoutes } from './routes/library.ts';
import { registerSyncRoutes } from './routes/sync.ts';
import { registerReadingOverrideRoutes } from './routes/reading-overrides.ts';
import { registerTtsRoutes } from './routes/tts.ts';
import { registerWebRoutes } from './routes/web.ts';
import { registerSourceRoutes } from './routes/sources.ts';
import { registerOpdsRoutes } from './routes/opds.ts';
import { isOriginAllowed, resolveCorsOrigin } from './cors.ts';
export function buildApp(ctx: AppContext): FastifyInstance {
@@ -62,7 +64,9 @@ export function buildApp(ctx: AppContext): FastifyInstance {
registerLibraryRoutes(app, ctx);
registerSyncRoutes(app, ctx);
registerTtsRoutes(app, ctx);
registerReadingOverrideRoutes(app, ctx);
registerSourceRoutes(app, ctx);
registerOpdsRoutes(app, ctx);
// Registered last: the SPA fallback must not shadow an API route.
registerWebRoutes(app, ctx);
+94
View File
@@ -0,0 +1,94 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { createReadStream } from 'node:fs';
import { realpath, stat } from 'node:fs/promises';
import { isAbsolute, relative } from 'node:path';
import type { AppContext } from '../context.ts';
import { authenticate, currentUser } from '../auth.ts';
import { OpdsCredentials, xml } from '../../services/opds.ts';
import { FilePublications } from '../../publications/files.ts';
import { allFileHandlers, contentTypeFor } from '../../indexer/formats/index.ts';
import { badRequest, notFound } from '../../lib/errors.ts';
import { assertSafeRel, resolveInside } from '../../lib/paths.ts';
import { sendAssetPayload } from '../assets.ts';
const feedType = 'application/atom+xml;profile=opds-catalog;kind=acquisition';
export function registerOpdsRoutes(app: FastifyInstance, ctx: AppContext): void {
const credentials = new OpdsCredentials(ctx.db), auth = authenticate(ctx);
const formats = allFileHandlers().map(handler => handler.format);
const root = `${ctx.config.publicUrl.replace(/\/$/, '')}/opds`;
const access = async (request: FastifyRequest, reply: FastifyReply) => {
try {
const id = credentials.authenticate(request.headers.authorization);
const user = ctx.users.byId(id)!;
request.currentUser = { id, username: user.username, displayName: user.display_name, role: user.role, createdAt: user.created_at };
} catch (error) { reply.header('www-authenticate', 'Basic realm="Reader OPDS", charset="UTF-8"'); throw error; }
};
app.addHook('onSend', async (request, reply, payload) => {
if (request.url.startsWith('/opds') || request.url.startsWith('/api/v1/opds/')) {
reply.header('cache-control', 'private, no-store'); reply.header('referrer-policy', 'no-referrer');
}
return payload;
});
app.get('/api/v1/opds/credentials', { preHandler: auth }, async request => ({ credentials: credentials.list(currentUser(request).id), catalogUrl: root }));
app.post('/api/v1/opds/credentials', { preHandler: auth }, async (request, reply) => {
reply.code(201);
return { ...credentials.create(currentUser(request).id, (request.body as { name?: unknown } | null)?.name), catalogUrl: root };
});
app.delete('/api/v1/opds/credentials/:id', { preHandler: auth }, async request => {
credentials.revoke(currentUser(request).id, (request.params as { id: string }).id); return { ok: true };
});
app.get('/opds/', { preHandler: access }, async (request, reply) => reply.redirect(root + (request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '')));
app.get('/opds', { preHandler: access }, async (request, reply) => {
const query = request.query as { page?: unknown; search?: unknown };
const page = query.page === undefined ? 1 : Number(query.page);
if (!Number.isSafeInteger(page) || page < 1 || page > 100000 ||
(query.search !== undefined && (typeof query.search !== 'string' || query.search.length > 200))) throw badRequest('invalid OPDS query');
const search = query.search as string | undefined;
const result = ctx.shelf.list(currentUser(request).id, { page, pageSize: 50, fileFormats: formats, sort: 'title', ...(search ? { search } : {}) });
const link = (rel: string, href: string, type = feedType) => `<link rel="${rel}" href="${xml(href)}" ${type ? `type="${type}"` : ''}/>`;
const pageUrl = (number: number) => root + '?' + new URLSearchParams({ page: String(number), ...(search ? { search } : {}) });
const entries = result.items.map(book => {
const href = `${root}/books/${encodeURIComponent(book.id)}`;
const file = new FilePublications(ctx.db, ctx.config).context(book.id);
return `<entry><id>urn:reader:book:${xml(book.id)}</id><title>${xml(book.title)}</title><updated>${new Date(book.updatedAt).toISOString()}</updated>
<author><name>${xml(book.author)}</name></author><summary type="text">${xml(book.description)}</summary>
${link('http://opds-spec.org/acquisition', href + '/content', contentTypeFor(file.relPath))}
${book.coverUrl ? link('http://opds-spec.org/image', href + '/cover', '') : ''}</entry>`;
}).join('');
return reply.type(feedType).send(`<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
<id>urn:reader:opds:${xml(currentUser(request).id)}</id><title>Reader 个人书架</title><updated>${new Date().toISOString()}</updated>
${link('self', pageUrl(page))}${link('start', root)}${link('search', root + '/search.xml', 'application/opensearchdescription+xml')}
${page > 1 ? link('previous', pageUrl(page - 1)) : ''}${page * result.pageSize < result.total ? link('next', pageUrl(page + 1)) : ''}
<opensearch:totalResults>${result.total}</opensearch:totalResults><opensearch:startIndex>${(page - 1) * result.pageSize + 1}</opensearch:startIndex><opensearch:itemsPerPage>${result.pageSize}</opensearch:itemsPerPage>${entries}</feed>`);
});
app.get('/opds/search.xml', { preHandler: access }, async (_request, reply) => reply.type('application/opensearchdescription+xml').send(
`<?xml version="1.0" encoding="utf-8"?><OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"><ShortName>Reader</ShortName><Description>搜索个人书架</Description><Url type="${feedType}" template="${xml(root + '?search={searchTerms}')}"/><InputEncoding>UTF-8</InputEncoding></OpenSearchDescription>`,
));
app.get('/opds/books/:id/content', { preHandler: access }, async (request, reply) => {
const book = ctx.shelf.get(currentUser(request).id, (request.params as { id: string }).id);
if (!formats.includes(book.format)) throw notFound('publication not available');
const files = new FilePublications(ctx.db, ctx.config);
await containedFile(files.context(book.id).absPath, [ctx.config.booksDir, ctx.config.dataDir]);
return sendAssetPayload(request, reply, await files.content(book.id, book.format));
});
app.get('/opds/books/:id/cover', { preHandler: access }, async (request, reply) => {
const book = ctx.shelf.get(currentUser(request).id, (request.params as { id: string }).id);
if (!formats.includes(book.format)) throw notFound('publication not available');
const row = ctx.db.get<{ cover_path: string | null }>('SELECT cover_path FROM books WHERE id=?', book.id);
if (!row?.cover_path) throw notFound('cover unavailable');
const path = resolveInside(ctx.config.dataDir, assertSafeRel(row.cover_path));
await containedFile(path, [ctx.config.dataDir]);
return reply.type(contentTypeFor(path)).send(createReadStream(path));
});
}
async function containedFile(path: string, roots: string[]): Promise<void> {
let actual: string;
try { actual = await realpath(path); } catch { throw notFound('file unavailable'); }
for (const root of roots) {
const rel = relative(await realpath(root), actual);
if (rel && !isAbsolute(rel) && rel !== '..' && !rel.startsWith('../') && !rel.startsWith('..\\') && (await stat(actual)).isFile()) return;
}
throw notFound('file unavailable');
}
@@ -0,0 +1,19 @@
import type { FastifyInstance } from 'fastify';
import type { AppContext } from '../context.ts';
import { authenticate, currentUser } from '../auth.ts';
import { ReadingOverrideService, type ReadingOverrides } from '../../services/reading-overrides.ts';
import { badRequest } from '../../lib/errors.ts';
export function registerReadingOverrideRoutes(app: FastifyInstance, ctx: AppContext): void {
const service = new ReadingOverrideService(ctx.db), auth = authenticate(ctx);
const access = (request: Parameters<ReturnType<typeof authenticate>>[0]) => {
const user = currentUser(request), { id } = request.params as { id: string };
ctx.shelf.get(user.id,id); return { userId:user.id,bookId:id };
};
app.get('/api/v1/books/:id/reading-overrides',{preHandler:auth},async request => { const {userId,bookId}=access(request); return service.get(userId,bookId); });
app.put('/api/v1/books/:id/reading-overrides',{preHandler:auth},async request => { const {userId,bookId}=access(request); return service.save(userId,bookId,request.body as ReadingOverrides); });
app.post('/api/v1/books/:id/reading-overrides/undo',{preHandler:auth},async request => {
const {userId,bookId}=access(request); const version = (request.body as {version?:number}|null)?.version;
if (!Number.isSafeInteger(version) || !version || version < 1) throw badRequest('invalid version');
return service.undo(userId,bookId,version);
});
}
+9
View File
@@ -84,6 +84,10 @@ export function registerSourceRoutes(app: FastifyInstance, ctx: AppContext): voi
host.remove((request.params as { id: string }).id);
return { ok: true };
});
app.get('/api/v1/sources/:id/credentials', {preHandler:auth}, async request => host.credentialStatus((request.params as {id:string}).id,currentUser(request).id));
app.delete('/api/v1/sources/:id/credentials/:key', {preHandler:auth}, async request => {
const {id,key} = request.params as {id:string;key:string}; host.deleteCredential(id,currentUser(request).id,key); return {ok:true};
});
app.put('/api/v1/sources/:id/credentials/:key', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { id, key } = request.params as { id: string; key: string };
@@ -173,6 +177,11 @@ export function registerSourceRoutes(app: FastifyInstance, ctx: AppContext): voi
const ref = textBody(request.body, 'entryRef');
return withSignal(request, reply, signal => host.switchPreview(user.id, id, ref, signal));
});
app.post('/api/v1/books/:id/switch-quality', { preHandler: auth }, async (request, reply) => {
const user = currentUser(request), { id } = request.params as { id: string }; ctx.shelf.get(user.id,id);
const ref = textBody(request.body,'entryRef'), chapter = textBody(request.body,'chapterId');
return withSignal(request,reply,signal => host.switchQuality(user.id,id,ref,chapter,signal));
});
app.post('/api/v1/books/:id/switch-source', { preHandler: auth }, async (request, reply) => {
const user = currentUser(request), { id } = request.params as { id: string }; ctx.shelf.get(user.id, id);
const ref = textBody(request.body, 'entryRef'), chapter = textBody(request.body, 'chapterId'), revision = textBody(request.body, 'revision');
+12
View File
@@ -36,6 +36,18 @@ export function registerTtsRoutes(app: FastifyInstance, ctx: AppContext): void {
return { ...capabilities, voices: await ctx.tts.voices() };
});
app.post('/api/v1/tts/test', { preHandler: auth }, async request => {
const input = request.body as { voice?: unknown; speed?: unknown } | null;
if (input?.voice !== undefined && typeof input.voice !== 'string') throw badRequest('voice must be a string');
if (input?.speed !== undefined && (typeof input.speed !== 'number' || !Number.isFinite(input.speed))) throw badRequest('speed must be a number');
const started = Date.now();
const audio = await ctx.tts.synthesize({ text: '这是阅读器朗读试听。愿你享受阅读的时光。',
...(typeof input?.voice === 'string' ? { voice: input.voice } : {}),
...(typeof input?.speed === 'number' ? { speed: input.speed } : {}),
});
return { bytes: audio.bytes.length, contentType: audio.contentType, elapsedMs: Date.now() - started, cached: audio.cached };
});
app.get('/api/v1/tts', { preHandler: auth }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>;
const text = query.text ?? '';
+27 -3
View File
@@ -13,6 +13,7 @@ import {
} from './registry.ts';
import { parseEpub, extractEpubCover } from '../metadata.ts';
import { XMLParser } from 'fast-xml-parser';
import { parseNavigation } from './navigation.ts';
/**
* EPUB 2/3.
@@ -153,6 +154,7 @@ export const epubHandler = registerFileHandler({
const archive = await ZipArchive.open(ctx.absPath);
const pkg = await readPackage(archive);
const headingCache = new Map<string, string>();
if (pkg.toc.length) return pkg.toc;
/**
* A chapter's own first heading, read only when the package gave no title.
@@ -334,6 +336,7 @@ interface SpineItem {
interface Package {
spine: SpineItem[];
titles: Map<string, string>;
toc: TocEntry[];
}
/**
@@ -358,7 +361,7 @@ async function readPackage(archive: ZipArchive): Promise<Package> {
* being used as a fallback.
*/
async function readPackageWith(archive: ZipArchive, withTitles: boolean): Promise<Package> {
const empty: Package = { spine: [], titles: new Map() };
const empty: Package = { spine: [], titles: new Map(), toc: [] };
const containerEntry = archive.get('META-INF/container.xml');
let opfPath = '';
if (containerEntry) {
@@ -401,9 +404,30 @@ async function readPackageWith(archive: ZipArchive, withTitles: boolean): Promis
* is what this did) threw all three away for any book whose titles could not
* be read.
*/
if (!withTitles) return { spine, titles: new Map() };
if (!withTitles) return { spine, titles: new Map(), toc: [] };
const declared = asArray(pkg?.manifest?.item);
const nav = declared.find(item => String(item?.['@_properties'] ?? '').split(/\s+/).includes('nav'));
const ncx = declared.find(item => item?.['@_media-type'] === 'application/x-dtbncx+xml');
let toc: TocEntry[] = [];
for (const [item, kind] of [[nav, 'nav'], [ncx, 'ncx']] as const) {
if (!item) continue;
const path = resolveHref(base, String(item['@_href']));
try {
const entries = parseNavigation((await archive.read(path, CHAPTER_PROBE_BYTES * 4)).toString('utf8'), kind);
toc = entries.flatMap(entry => {
const hash = entry.href.indexOf('#');
const href = hash < 0 ? entry.href : entry.href.slice(0, hash);
const fragment = hash < 0 ? '' : entry.href.slice(hash);
const resolved = href ? resolveHref(posix.dirname(path), href) : path;
const index = spine.findIndex(s => s.path === resolved);
return index < 0 ? [] : [{ href: `xhtml:${resolved}${fragment}`, title: entry.title, level: entry.depth, spine: index }];
});
if (toc.length) break;
} catch { /* Fall back to NCX or spine titles for malformed navigation. */ }
}
const titles = await readTitles(archive, base).catch(() => new Map<string, string>());
return { spine, titles };
for (const entry of toc) if (!titles.has(spine[entry.spine!]!.path)) titles.set(spine[entry.spine!]!.path, entry.title);
return { spine, titles, toc };
}
/**
+55
View File
@@ -0,0 +1,55 @@
import { XMLParser } from 'fast-xml-parser';
export interface NavigationItem { href: string; title: string; depth: number }
type Node = Record<string, any>;
const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false, removeNSPrefix: true, processEntities: false, trimValues: false, parseTagValue: false });
const decode = (text: string) => text.replace(/&#(x[0-9a-f]+|\d+);|&(amp|lt|gt|quot|apos);/gi, (all, number: string, name: string) => {
if (!number) return ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" } as Record<string, string>)[name.toLowerCase()] ?? all;
const code = parseInt(number[0]?.toLowerCase() === 'x' ? number.slice(1) : number, number[0]?.toLowerCase() === 'x' ? 16 : 10);
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : all;
});
const textOf = (nodes: Node[]): string => nodes.map(node => Object.entries(node).filter(([key]) => key !== ':@').map(([key, value]) => key === '#text' ? String(value) : Array.isArray(value) ? textOf(value) : '').join('')).join('');
function find(nodes: Node[], tag: string): Node[] {
const result: Node[] = [];
for (const node of nodes) for (const [key, children] of Object.entries(node)) {
if (key === tag) result.push(node);
if (Array.isArray(children)) result.push(...find(children, tag));
}
return result;
}
/** Ordered XML tree traversal preserves nested navPoint/li entries and fragment hrefs. */
export function parseNavigation(xml: string, kind: 'nav' | 'ncx'): NavigationItem[] {
const tree = parser.parse(xml) as Node[];
const result: NavigationItem[] = [];
if (kind === 'nav') {
const navs = find(tree, 'nav');
const nav = navs.find(n => String(n[':@']?.['@_type'] ?? '').split(/\s+/).includes('toc')) ?? navs[0];
if (!nav) return [];
const walk = (nodes: Node[], depth: number): void => {
for (const node of nodes) for (const [tag, children] of Object.entries(node)) {
if (!Array.isArray(children)) continue;
if (tag === 'a') {
const href = node[':@']?.['@_href'];
const title = decode(textOf(children)).trim();
if (typeof href === 'string' && title) result.push({ href: decode(href), title, depth: Math.max(0, depth - 1) });
} else walk(children, depth + (tag === 'li' ? 1 : 0));
}
};
walk(nav.nav, 0);
} else {
const map = find(tree, 'navMap')[0];
const walk = (nodes: Node[], depth: number): void => {
for (const node of nodes) if (Array.isArray(node.navPoint)) {
const label = node.navPoint.find((n: Node) => n.navLabel);
const content = node.navPoint.find((n: Node) => n.content);
const href = content?.[':@']?.['@_src'];
const title = label ? decode(textOf(label.navLabel)).trim() : '';
if (typeof href === 'string' && title) result.push({ href: decode(href), title, depth });
walk(node.navPoint, depth + 1);
}
};
if (map) walk(map.navMap, 0);
}
return result;
}
+138
View File
@@ -0,0 +1,138 @@
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { chmod, copyFile, lstat, mkdir, readFile, readdir, readlink, realpath, symlink, unlink, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
const MANIFEST = 'reader-backup.json';
const INCOMPLETE = '.reader-backup-incomplete';
interface Entry { path: string; kind: 'file' | 'directory' | 'link'; size?: number; sha256?: string; target?: string }
export interface BackupManifest { format: 'reader-data-backup'; version: 1; createdAt: string; entries: Entry[] }
function inside(root: string, path: string): boolean {
const rel = relative(root, path);
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
}
async function digest(path: string): Promise<string> {
const hash = createHash('sha256');
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest('hex');
}
async function inventory(root: string, rel = ''): Promise<Entry[]> {
const entries: Entry[] = [];
for (const name of (await readdir(join(root, rel))).sort()) {
// SQLite rebuilds shared memory from the database/WAL. Reader locks in this
// file can change even during a read-only integrity check.
if (!rel && (name === MANIFEST || name === INCOMPLETE || name === 'reader.db-shm')) continue;
const path = rel ? `${rel}/${name}` : name;
const absolute = join(root, path);
const stat = await lstat(absolute);
if (stat.isSymbolicLink()) {
const target = await readlink(absolute);
if (isAbsolute(target) || !inside(root, resolve(dirname(absolute), target)) || !inside(root, await realpath(absolute))) {
throw new Error(`不支持指向数据目录外的符号链接:${path}`);
}
entries.push({ path, kind: 'link', target });
} else if (stat.isDirectory()) {
entries.push({ path, kind: 'directory' });
entries.push(...await inventory(root, path));
} else if (stat.isFile()) {
entries.push({ path, kind: 'file', size: stat.size, sha256: await digest(absolute) });
} else {
throw new Error(`数据目录包含不支持的文件类型:${path}`);
}
}
return entries;
}
function checkDatabase(root: string): void {
// Read the existing schema without running migrations or creating a database.
const db = new DatabaseSync(join(root, 'reader.db'), { readOnly: true });
try {
const rows = db.prepare('PRAGMA integrity_check').all();
if (rows.length !== 1 || rows[0]?.integrity_check !== 'ok') throw new Error('SQLite 完整性校验失败');
if (db.prepare('PRAGMA foreign_key_check').get()) throw new Error('SQLite 外键完整性校验失败');
for (const table of ['users', 'books', 'reading_progress', 'notes']) {
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table)) {
throw new Error(`不是当前 reader 数据库:缺少 ${table}`);
}
}
} finally { db.close(); }
}
async function newDestination(source: string, destination: string): Promise<string> {
const parent = await realpath(dirname(resolve(destination)));
const target = join(parent, relative(dirname(resolve(destination)), resolve(destination)));
if (inside(source, target) || inside(target, source)) throw new Error('备份/恢复目录不能与源目录重叠');
// Exclusive creation: never merge into or overwrite existing user data.
await mkdir(target, { mode: 0o700 });
await writeFile(join(target, INCOMPLETE), '操作未完成,请勿用此目录启动服务。\n', { flag: 'wx', mode: 0o600 });
return target;
}
async function copyEntries(source: string, target: string, entries: Entry[]): Promise<void> {
for (const entry of entries) {
const from = join(source, entry.path), to = join(target, entry.path);
if (entry.kind === 'directory') await mkdir(to, { mode: 0o700 });
else if (entry.kind === 'file') {
await copyFile(from, to);
// Preserve plugin executables without making credentials world-readable.
await chmod(to, (await lstat(from)).mode & 0o700);
}
}
// Create links last, after all their targets, and never traverse them while copying.
for (const entry of entries.filter(value => value.kind === 'link')) {
await symlink(entry.target!, join(target, entry.path), 'file');
}
}
/** Offline only: stop every server/plugin process using DATA_DIR before calling. */
export async function createBackup(dataDir: string, destination: string): Promise<BackupManifest> {
const source = await realpath(dataDir);
if ((await readdir(source)).some(name => name === INCOMPLETE || name === MANIFEST)) {
throw new Error('源目录是备份或未完成操作目录,不能作为运行数据目录备份');
}
checkDatabase(source);
const entries = await inventory(source);
const target = await newDestination(source, destination);
await copyEntries(source, target, entries);
if (JSON.stringify(await inventory(source)) !== JSON.stringify(entries) ||
JSON.stringify(await inventory(target)) !== JSON.stringify(entries)) {
throw new Error('复制期间数据发生变化或校验失败;请停止服务后重新备份到新目录');
}
checkDatabase(target);
const manifest: BackupManifest = { format: 'reader-data-backup', version: 1, createdAt: new Date().toISOString(), entries };
await writeFile(join(target, MANIFEST), JSON.stringify(manifest, null, 2), { flag: 'wx', mode: 0o600 });
await unlink(join(target, INCOMPLETE));
return manifest;
}
export async function verifyBackup(directory: string): Promise<BackupManifest> {
const source = await realpath(directory);
if ((await readdir(source)).includes(INCOMPLETE)) throw new Error('备份未完成,拒绝恢复');
const manifest = JSON.parse(await readFile(join(source, MANIFEST), 'utf8')) as BackupManifest;
if (manifest.format !== 'reader-data-backup' || manifest.version !== 1 || !Array.isArray(manifest.entries)) {
throw new Error('不支持的备份格式');
}
// Rebuild from actual files, not manifest paths: a modified manifest cannot
// instruct restore to write outside the new data directory.
if (JSON.stringify(await inventory(source)) !== JSON.stringify(manifest.entries)) {
throw new Error('备份文件校验失败:文件缺失、多余或内容已改变');
}
checkDatabase(source);
return manifest;
}
/** Restore to a new directory; switching the deployed DATA_DIR remains explicit. */
export async function restoreBackup(directory: string, destination: string): Promise<BackupManifest> {
const source = await realpath(directory);
const manifest = await verifyBackup(source);
const target = await newDestination(source, destination);
await copyEntries(source, target, manifest.entries);
if (JSON.stringify(await inventory(target)) !== JSON.stringify(manifest.entries)) throw new Error('恢复后文件校验失败');
checkDatabase(target);
await unlink(join(target, INCOMPLETE));
return manifest;
}
+30
View File
@@ -0,0 +1,30 @@
import { createBackup, restoreBackup, verifyBackup } from './backup.ts';
const HELP = `reader 离线数据维护(不包含 BOOKS_DIR 原书)
backup <DATA_DIR> <新备份目录> --server-stopped
verify <备份目录>
restore <备份目录> <新DATA_DIR> --server-stopped
backup/restore 前必须停止使用该数据目录的所有服务和插件。
--server-stopped 是操作者确认,不是运行状态检测。目标目录必须不存在,父目录必须存在。
失败时保留带 .reader-backup-incomplete 的目录供检查,请勿启动该目录。`;
async function main(): Promise<void> {
const [command, source, target, confirmation, ...extra] = process.argv.slice(2);
if (!command || command === '--help') { console.log(HELP); return; }
if (!source || extra.length || !['backup', 'verify', 'restore'].includes(command) ||
(command === 'verify' ? !!target : !target || confirmation !== '--server-stopped')) {
throw new Error(HELP);
}
const result = command === 'verify' ? await verifyBackup(source)
: command === 'backup' ? await createBackup(source, target!) : await restoreBackup(source, target!);
console.log(JSON.stringify({ operation: command, createdAt: result.createdAt,
files: result.entries.filter(entry => entry.kind === 'file').length,
bytes: result.entries.reduce((sum, entry) => sum + (entry.size ?? 0), 0),
destination: target ?? source }, null, 2));
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : '数据维护失败');
process.exitCode = 1;
});
+19
View File
@@ -0,0 +1,19 @@
import { uploadBackup } from './webdav.ts';
const controller = new AbortController();
process.once('SIGINT', () => controller.abort());
process.once('SIGTERM', () => controller.abort());
const [directory, name, ...extra] = process.argv.slice(2);
if (!directory || !name || extra.length) {
console.error('用法:node dist/maintenance/webdav-cli.js <已验证备份目录> <远端名称.zip>\n环境变量:WEBDAV_URL、WEBDAV_USERNAME、WEBDAV_PASSWORD;可选 WEBDAV_MAX_BYTES。');
process.exitCode = 1;
} else {
uploadBackup(directory, { name, url: process.env.WEBDAV_URL ?? '', username: process.env.WEBDAV_USERNAME ?? '',
password: process.env.WEBDAV_PASSWORD ?? '', signal: controller.signal,
...(process.env.WEBDAV_MAX_BYTES ? { maxBytes: Number(process.env.WEBDAV_MAX_BYTES) } : {}),
}).then(result => console.log(JSON.stringify(result, null, 2))).catch(() => {
// Network exceptions may contain destination URLs; keep credentials and URLs out of logs.
console.error(controller.signal.aborted ? '备份上传已取消' : '备份上传失败,请检查备份校验、远端权限、配额、同名文件及 WebDAV 日志。');
process.exitCode = 1;
});
}
+105
View File
@@ -0,0 +1,105 @@
import { createHash, randomUUID } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { pipeline } from 'node:stream/promises';
import JSZip from 'jszip';
import { verifyBackup } from './backup.ts';
export interface WebDavOptions {
url: string;
username: string;
password: string;
name: string;
maxBytes?: number;
signal?: AbortSignal;
}
async function hashFile(path: string): Promise<string> {
const hash = createHash('sha256');
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest('hex');
}
/** Publish an immutable, verified backup ZIP to an existing WebDAV collection. */
export async function uploadBackup(directory: string, options: WebDavOptions) {
const base = new URL(options.url);
if (base.protocol !== 'https:' && !(base.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(base.hostname))) {
throw new Error('WebDAV 必须使用 HTTPS(本机测试可用 HTTP)');
}
if (base.username || base.password || base.search || base.hash) throw new Error('WebDAV URL 不得包含凭据、查询或片段');
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}\.zip$/.test(options.name)) throw new Error('备份名称必须是安全的 .zip 文件名');
if (options.username.includes(':') || !options.username || !options.password) throw new Error('需要有效的 WebDAV 账号和密码');
if (!base.pathname.endsWith('/')) base.pathname += '/';
const limit = options.maxBytes ?? 2 * 1024 ** 3;
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 4 * 1024 ** 3 - 1) throw new Error('备份上限必须介于 1 字节与 4 GiB 之间');
const manifest = await verifyBackup(directory);
if (manifest.entries.reduce((sum, entry) => sum + (entry.size ?? 0), 0) > limit) throw new Error('备份超过配置大小上限');
if (manifest.entries.length > 60000) throw new Error('备份文件过多,超出 ZIP32 支持范围');
const scratch = await mkdtemp(join(tmpdir(), 'reader-webdav-'));
const archive = join(scratch, 'backup.zip');
const destination = new URL(options.name, base), temporary = new URL('.reader-upload-' + randomUUID() + '.zip', base);
const authorization = 'Basic ' + Buffer.from(options.username + ':' + options.password).toString('base64');
const request = (url: URL, init: RequestInit = {}) => fetch(url, {
...init, redirect: 'manual', headers: { authorization, ...init.headers },
signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(120_000)]) : AbortSignal.timeout(120_000),
});
let staged = false;
try {
const zip = new JSZip();
for (const entry of manifest.entries) {
options.signal?.throwIfAborted();
if (entry.kind === 'directory') zip.folder(entry.path);
else if (entry.kind === 'link') zip.file(entry.path, entry.target!, { unixPermissions: 0o120777 });
else zip.file(entry.path, createReadStream(join(directory, entry.path)), { unixPermissions: (await stat(join(directory, entry.path))).mode });
}
zip.file('reader-backup.json', await readFile(join(directory, 'reader-backup.json')), { unixPermissions: 0o100600 });
await pipeline(zip.generateNodeStream({ streamFiles: true, platform: 'UNIX', compression: 'STORE' }), createWriteStream(archive, { flags: 'wx', mode: 0o600 }), ...(options.signal ? [{ signal: options.signal }] : []));
await verifyBackup(directory);
const size = (await stat(archive)).size;
if (size > limit) throw new Error('打包后超过配置大小上限');
const sha256 = await hashFile(archive);
const existing = await request(destination, { method: 'HEAD' });
await existing.body?.cancel();
if (existing.status !== 404) throw new Error(existing.ok ? '远端同名备份已存在,不会覆盖' : `检查远端失败 HTTP ${existing.status}`);
for (let attempt = 0; attempt < 3; attempt++) {
options.signal?.throwIfAborted();
staged = true;
const input = createReadStream(archive);
let response: Response;
try {
response = await request(temporary, { method: 'PUT', body: input, duplex: 'half', headers: { 'content-type': 'application/zip', 'content-length': String(size) } } as RequestInit);
} catch {
if (options.signal?.aborted) options.signal.throwIfAborted();
if (attempt < 2) continue;
throw new Error('WebDAV 上传连接失败,临时文件将尝试清理');
} finally { input.destroy(); }
await response.body?.cancel();
if (response.ok) break;
if (attempt === 2 || ![408, 429, 500, 502, 503, 504].includes(response.status)) throw new Error(`WebDAV 上传失败 HTTP ${response.status}`);
}
const uploaded = await request(temporary);
if (!uploaded.ok || !uploaded.body) { await uploaded.body?.cancel(); throw new Error(`远端校验读取失败 HTTP ${uploaded.status}`); }
const remoteHash = createHash('sha256'); let bytes = 0;
for await (const chunk of uploaded.body) {
bytes += chunk.byteLength;
if (bytes > size) throw new Error('远端校验失败:长度不一致');
remoteHash.update(chunk);
}
if (bytes !== size || remoteHash.digest('hex') !== sha256) throw new Error('远端校验失败:内容不一致');
const moved = await request(temporary, { method: 'MOVE', headers: { destination: destination.href, overwrite: 'F' } });
await moved.body?.cancel();
if (moved.status !== 201) throw new Error(`WebDAV 发布失败 HTTP ${moved.status};请核对远端,未自动重试 MOVE`);
staged = false;
return { name: options.name, bytes: size, sha256 };
} finally {
if (staged) {
await fetch(temporary, { method: 'DELETE', headers: { authorization }, redirect: 'manual', signal: AbortSignal.timeout(10_000) })
.then(response => response.body?.cancel()).catch(() => {});
}
const cleanupPath = relative(resolve(tmpdir()), resolve(scratch));
if (!cleanupPath || isAbsolute(cleanupPath) || cleanupPath.startsWith('..')) throw new Error('Unsafe cleanup path');
await rm(scratch, { recursive: true, force: true });
}
}
@@ -0,0 +1,19 @@
import sanitize from 'sanitize-html';
import { badRequest } from '../lib/errors.ts';
/** Counts readable Unicode characters, excluding whitespace; never treats markup as prose. */
export function chapterQuality(body: string, mediaType: string): { characters: number; images: number; bytes: number } {
let text = body, images = 0;
if (mediaType.startsWith('text/html')) {
text = sanitize(body, { allowedTags: [], allowedAttributes: {} });
text = text.replace(/&(?:nbsp|#160|#xA0);/gi, ' ').replace(/&(?:#x[0-9a-f]+|#\d+|[a-z]+);/gi, 'x');
// Images were already validated and embedded by richContent.
images = (body.match(/<img\s[^>]*src="data:image\//g) ?? []).length;
}
const characters = Array.from(text.replace(/[\s\u200b-\u200f\u202a-\u202e\u2060\ufeff]/g, '')).length;
return { characters, images, bytes: Buffer.byteLength(body,'utf8') };
}
export function requireChapterContent(body: string, mediaType: string): ReturnType<typeof chapterQuality> {
const quality = chapterQuality(body,mediaType);
if (!quality.characters && !quality.images) throw badRequest('此章节没有可读正文,请选择其他章节或书源', 'EMPTY_CHAPTER');
return quality;
}
+18
View File
@@ -1,3 +1,4 @@
import { requireChapterContent } from './chapter-quality.ts';
import { createHash, randomUUID } from 'node:crypto';
import type { Db } from '../db/index.ts';
import type { AssetPayload, Manifest } from '../indexer/formats/registry.ts';
@@ -168,6 +169,7 @@ export class ChapterPublications {
return this.loader.resource(userId, publication.source_id, publication.publication_ref, imageRef, signal);
});
}
requireChapterContent(body, resource.media_type);
signal?.throwIfAborted();
// The provider protocol cannot request an old version. If a refresh won
// while this request was in flight, never cache new bytes as old content.
@@ -181,6 +183,21 @@ export class ChapterPublications {
});
}
async inspect(provider: SourceProvider, context: SourceContext, publicationRef: string, chapterId: string) {
if (!provider.getManifest || !provider.readResource) throw badRequest('source has no chapter content');
const started = performance.now();
const snapshot = this.normalize(await provider.getManifest(context,publicationRef),publicationRef);
const chapter = snapshot.items.find(item => item.id === chapterId);
if (!chapter) throw notFound('所选章节已不存在,请重新获取目录', 'CHAPTER_GONE');
const response = await provider.readResource(context,{publicationRef,ref:chapter.ref});
const media = chapterMedia(chapter.mediaType);
if (chapterMedia(response.mediaType) !== media) { response.stream?.destroy(); throw badRequest('Invalid chapter media type'); }
let body = this.readBody(response,context.signal);
if (media.startsWith('text/html')) body = await richContent(body,ref => provider.readResource!(context,{publicationRef,ref}));
context.signal.throwIfAborted();
return { chapterId, title:chapter.title, latestChapter:snapshot.items.at(-1)?.title ?? '', ...requireChapterContent(body,media), elapsedMs:Math.round(performance.now()-started) };
}
binding(userId: string, bookId: string) { return this.owned(userId, bookId); }
switchSource(provider: SourceProvider, context: SourceContext, bookId: string, entryRef: string, publicationRef: string, chapterId: string, expectedRevision: string) {
@@ -205,6 +222,7 @@ export class ChapterPublications {
let body = this.readBody(response, context.signal);
if (chapterMedia(chapter.mediaType).startsWith('text/html')) body = await richContent(body,
imageRef => provider.readResource!(context, { publicationRef, ref: imageRef }));
requireChapterContent(body, chapterMedia(chapter.mediaType));
context.signal.throwIfAborted();
const snapshotJson = JSON.stringify(snapshot), revision = hash(snapshotJson), now = Date.now();
this.db.transaction(() => {
+45
View File
@@ -0,0 +1,45 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type { Db } from '../db/index.ts';
import { badRequest, unauthorized } from '../lib/errors.ts';
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
/** Read-only application passwords, deliberately separate from Reader sessions. */
export class OpdsCredentials {
constructor(private readonly db: Db) {}
list(userId: string) {
return this.db.all<{ id: string; name: string; createdAt: number; expiresAt: number }>(
'SELECT id,name,created_at AS createdAt,expires_at AS expiresAt FROM opds_credentials WHERE user_id=? ORDER BY created_at DESC', userId,
);
}
create(userId: string, name: unknown) {
if (typeof name !== 'string' || !name.trim() || name.length > 80) throw badRequest('请输入不超过 80 字的客户端名称');
if (this.list(userId).length >= 20) throw badRequest('最多保存 20 个 OPDS 客户端,请先撤销不用的凭据');
const id = randomUUID(), password = randomBytes(32).toString('base64url');
const createdAt = Date.now(), expiresAt = createdAt + 365 * 86400_000;
this.db.run('INSERT INTO opds_credentials(id,user_id,name,token_hash,created_at,expires_at) VALUES(?,?,?,?,?,?)',
id, userId, name.trim(), digest(password), createdAt, expiresAt);
return { id, name: name.trim(), username: id, password, createdAt, expiresAt };
}
revoke(userId: string, id: string): void {
this.db.run('DELETE FROM opds_credentials WHERE user_id=? AND id=?', userId, id);
}
authenticate(header: string | undefined): string {
if (!header?.startsWith('Basic ') || header.length > 512) throw unauthorized('OPDS client credentials required', 'OPDS_AUTH_REQUIRED');
const value = Buffer.from(header.slice(6), 'base64').toString('utf8'), split = value.indexOf(':');
const row = this.db.get<{ user_id: string }>(
`SELECT c.user_id FROM opds_credentials c JOIN users u ON u.id=c.user_id
WHERE c.id=? AND c.token_hash=? AND c.expires_at>? AND u.disabled=0`,
value.slice(0, split), digest(value.slice(split + 1)), Date.now(),
);
if (split < 1 || !row) throw unauthorized('OPDS client credentials invalid or expired', 'OPDS_AUTH_REQUIRED');
return row.user_id;
}
}
export const xml = (value: string | number) => String(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '')
.replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[character]!);
+33
View File
@@ -0,0 +1,33 @@
import type { Db } from '../db/index.ts';
import { badRequest, conflict } from '../lib/errors.ts';
export interface ReadingOverrides { version: number; corrections: Array<{ id: string; anchor: { sectionId: string; start: number; end: number; quote: string; prefix: string; suffix: string }; replacement: string }>; headingPrefix: string }
const empty = (): ReadingOverrides => ({ version: 0, corrections: [], headingPrefix: '' });
/** Personal, versioned overlays live beside progress in DATA_DIR; source files are never changed. */
export class ReadingOverrideService {
constructor(private readonly db: Db) {}
get(userId: string, bookId: string): ReadingOverrides {
const row = this.db.get<{ payload: string; version: number }>('SELECT payload,version FROM reading_overrides WHERE user_id=? AND book_id=? ORDER BY version DESC LIMIT 1',userId,bookId);
return row ? { ...JSON.parse(row.payload) as ReadingOverrides, version: row.version } : empty();
}
save(userId: string, bookId: string, input: ReadingOverrides): ReadingOverrides {
if (!input || !Number.isSafeInteger(input.version) || typeof input.headingPrefix !== 'string' || input.headingPrefix.length > 60 || /[\r\n]/.test(input.headingPrefix) || !Array.isArray(input.corrections) || input.corrections.length > 500) throw badRequest('invalid reading overrides');
const ids = new Set<string>();
for (const correction of input.corrections) {
const a = correction?.anchor;
if (!correction || typeof correction.id !== 'string' || !correction.id || correction.id.length > 100 || ids.has(correction.id) || typeof correction.replacement !== 'string' || correction.replacement.length > 4000 || !a || typeof a.sectionId !== 'string' || a.sectionId.length > 4096 || typeof a.quote !== 'string' || !a.quote || a.quote.length > 4000 || typeof a.prefix !== 'string' || a.prefix.length > 32 || typeof a.suffix !== 'string' || a.suffix.length > 32 || !Number.isSafeInteger(a.start) || a.start < 0 || !Number.isSafeInteger(a.end) || a.end <= a.start || a.end - a.start !== a.quote.length) throw badRequest('invalid correction');
ids.add(correction.id);
}
return this.db.transaction(() => {
const previous = this.get(userId,bookId);
if (input.version !== previous.version) throw conflict('整理规则已被另一设备修改,请重新打开后重试');
const next = { version: previous.version + 1, headingPrefix: input.headingPrefix, corrections: input.corrections };
this.db.run('INSERT INTO reading_overrides(user_id,book_id,version,payload) VALUES(?,?,?,?)',userId,bookId,next.version,JSON.stringify(next));
return next;
});
}
undo(userId: string, bookId: string, version: number): ReadingOverrides {
const row = this.db.get<{ payload: string }>('SELECT payload FROM reading_overrides WHERE user_id=? AND book_id=? AND version<? ORDER BY version DESC LIMIT 1',userId,bookId,version);
return this.save(userId,bookId,{ ...(row ? JSON.parse(row.payload) as ReadingOverrides : empty()),version });
}
}
+6
View File
@@ -83,6 +83,8 @@ const BOOK_COLUMNS = `id, identifier, content_hash, format, title, author, publi
description, series, series_index, tags, pubdate, cover_path, file_size, page_count, source, updated_at`;
export interface ListOptions {
/** Internal publication filtering; applied before pagination for OPDS. */
fileFormats?: string[];
search?: string;
author?: string;
series?: string;
@@ -171,6 +173,10 @@ export class ShelfService {
OR EXISTS (SELECT 1 FROM acquired_files a WHERE a.book_id = b.id)
OR EXISTS (SELECT 1 FROM chapter_publications c WHERE c.book_id = b.id AND c.user_id = ub.user_id))`];
const params: Array<string | number> = library ? [] : [userId];
if (options.fileFormats) {
where.push(`b.format IN (${options.fileFormats.map(() => '?').join(',') || 'NULL'})`);
params.push(...options.fileFormats);
}
if (options.search) {
where.push('(b.title LIKE ? OR b.author LIKE ? OR b.series LIKE ? OR b.isbn = ?)');
+43
View File
@@ -0,0 +1,43 @@
import type { Db } from '../db/index.ts';
export type AccessState = 'unknown' | 'reachable' | 'auth-required' | 'verification-required';
/** Last observed access, never a claim that a website session is still logged in. */
export class SourceAccess {
constructor(private readonly db: Db) {}
version(sourceId: string, userId: string): number {
return this.db.get<{ generation: number }>(
'SELECT generation FROM source_access WHERE source_id=? AND user_id=?', sourceId, userId,
)?.generation ?? 0;
}
reset(sourceId: string, userId?: string): void {
if (userId === undefined) {
this.db.run("UPDATE source_access SET generation=generation+1,state='unknown',checked_at=NULL WHERE source_id=?", sourceId);
return;
}
this.db.run(
`INSERT INTO source_access(source_id,user_id,generation,state) VALUES(?,?,1,'unknown')
ON CONFLICT(source_id,user_id) DO UPDATE SET generation=generation+1,state='unknown',checked_at=NULL`,
sourceId, userId,
);
}
record(sourceId: string, userId: string, generation: number, state: AccessState): void {
if (this.version(sourceId, userId) !== generation) return;
this.db.run(
`INSERT INTO source_access(source_id,user_id,generation,state,checked_at) VALUES(?,?,?,?,?)
ON CONFLICT(source_id,user_id) DO UPDATE SET state=excluded.state,checked_at=excluded.checked_at
WHERE source_access.generation=excluded.generation`,
sourceId, userId, generation, state, Date.now(),
);
}
get(sourceId: string, userId: string) {
const row = this.db.get<{ state: AccessState; checked_at: number | null }>(
'SELECT state,checked_at FROM source_access WHERE source_id=? AND user_id=?', sourceId, userId,
);
return { state: row?.state ?? 'unknown', checkedAt: row?.checked_at ?? null };
}
}
+43 -8
View File
@@ -1,3 +1,4 @@
import { SourceAccess } from './source-access.ts';
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import type { FastifyBaseLogger } from 'fastify';
import type { AppConfig } from '../config/index.ts';
@@ -22,12 +23,14 @@ export class SourceHost {
readonly plugins: PluginManager;
readonly chapters: ChapterPublications;
readonly updates: ChapterUpdates;
private readonly access: SourceAccess;
private readonly key: Buffer;
private readonly acquisitions: FileAcquisitions;
private activeCalls = 0;
private readonly activeSources = new Map<string, number>();
constructor(private readonly db: Db, private readonly config: AppConfig, shelf: () => ShelfService, private readonly log?: FastifyBaseLogger) {
this.access = new SourceAccess(db);
this.key = createHash('sha256').update('reader/source-credentials/v1\0').update(config.jwtSecret).digest();
this.acquisitions = new FileAcquisitions(db, config);
this.chapters = new ChapterPublications(db, {
@@ -112,7 +115,7 @@ export class SourceHost {
this.db.run('UPDATE source_instances SET name = ?, config_json = ?, enabled = ? WHERE id = ?',
name.trim(), configuration, patch.enabled === undefined ? row.enabled : Number(patch.enabled), id);
// Changing a destination must not forward existing users' secrets to it.
if (configuration !== row.config_json) this.db.run('DELETE FROM source_credentials WHERE source_id = ?', id);
if (configuration !== row.config_json) { this.db.run('DELETE FROM source_credentials WHERE source_id = ?', id); this.access.reset(id); }
});
}
@@ -126,14 +129,28 @@ export class SourceHost {
this.db.run('DELETE FROM source_instances WHERE id = ?', id);
}
credentialStatus(sourceId: string, userId: string) {
const row = this.instance(sourceId), descriptor = this.registry.get(row.plugin_id,row.source_type)?.provider.descriptor;
const saved = new Set(this.db.all<{key:string}>('SELECT key FROM source_credentials WHERE source_id=? AND user_id=?',sourceId,userId).map(item=>item.key));
return { ...this.access.get(sourceId,userId), available: !!row.enabled && !!descriptor,
fields: (descriptor?.credentialKeys ?? []).map(field=>({key:field.key,label:field.label,configured:saved.has(field.key)})) };
}
deleteCredential(sourceId: string, userId: string, name: string): void {
this.instance(sourceId);
this.db.transaction(()=>{ this.db.run('DELETE FROM source_credentials WHERE source_id=? AND user_id=? AND key=?',sourceId,userId,name); this.access.reset(sourceId,userId); });
}
setCredential(sourceId: string, userId: string, name: string, value: string): void {
if (!/^[a-z][a-z0-9_.-]{0,63}$/.test(name) || value.length > 16_384) throw badRequest('invalid credential');
this.instance(sourceId);
this.db.run(
`INSERT INTO source_credentials (source_id, user_id, key, encrypted_value) VALUES (?, ?, ?, ?)
if (!value) { this.deleteCredential(sourceId,userId,name); return; }
this.db.transaction(() => {
this.db.run(
`INSERT INTO source_credentials (source_id, user_id, key, encrypted_value) VALUES (?, ?, ?, ?)
ON CONFLICT(source_id, user_id, key) DO UPDATE SET encrypted_value = excluded.encrypted_value`,
sourceId, userId, name, this.encrypt(value),
);
sourceId, userId, name, this.encrypt(value),
);
this.access.reset(sourceId, userId);
});
}
async browse(userId: string, id: string, request: { ref?: string; cursor?: string; limit?: number }, signal?: AbortSignal) {
@@ -172,9 +189,18 @@ export class SourceHost {
const acquisition = await provider.acquire(ctx, { entryRef });
if (acquisition.kind !== 'chapters' || !provider.getManifest) throw badRequest('alternative has no chapters');
const snapshot = await provider.getManifest(ctx, acquisition.publicationRef);
return { chapters: snapshot.items.map(item => ({ id: item.id, title: item.title })) };
return { chapters: snapshot.items.map(item => ({ id: item.id, title: item.title })), latestChapter: [...snapshot.items].sort((a,b)=>a.seq-b.seq).at(-1)?.title ?? '' };
}, signal);
}
async switchQuality(userId: string, bookId: string, entryRef: string, chapterId: string, signal?: AbortSignal) {
const binding = this.chapters.binding(userId,bookId);
return this.call(userId,binding.source_id,async (provider,ctx) => {
if (!provider.alternatives) throw badRequest('source does not support alternatives');
const acquisition = await provider.acquire(ctx,{entryRef});
if (acquisition.kind !== 'chapters') throw badRequest('alternative has no chapters');
return this.chapters.inspect(provider,ctx,acquisition.publicationRef,chapterId);
},signal);
}
async switchSource(userId: string, bookId: string, entryRef: string, chapterId: string, revision: string, signal?: AbortSignal) {
const binding = this.chapters.binding(userId, bookId);
return this.call(userId, binding.source_id, async (provider, ctx) => {
@@ -229,6 +255,7 @@ export class SourceHost {
if (!row.enabled) throw badRequest('source is disabled', 'SOURCE_DISABLED');
const registration = this.registry.get(row.plugin_id, row.source_type);
if (!registration) throw notFound('source plugin is not installed', 'PLUGIN_UNAVAILABLE');
const accessVersion = this.access.version(id,userId);
const active = this.activeSources.get(id) ?? 0;
if (active >= 2 || this.activeCalls >= 8) {
throw new AppError(429, 'RATE_LIMITED', 'source is busy; retry after current requests complete');
@@ -240,8 +267,16 @@ export class SourceHost {
const signal = externalSignal ? AbortSignal.any([externalSignal, controller.signal]) : controller.signal;
try {
signal.throwIfAborted();
return await action(registration.provider, this.context(row, userId, signal));
const result = await action(registration.provider, this.context(row, userId, signal));
if (userId && !signal.aborted) {
const errors = (result as {errors?:Array<{code?:string}>} | null)?.errors;
const state = errors?.some(e=>e.code === 'VERIFICATION_REQUIRED') ? 'verification-required' : errors?.some(e=>['AUTH_REQUIRED','AUTH_EXPIRED'].includes(e.code ?? '')) ? 'auth-required' : errors?.length ? null : 'reachable';
if (state) this.access.record(id,userId,accessVersion,state);
}
return result;
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
if (userId && !signal.aborted && ['AUTH_REQUIRED','AUTH_EXPIRED','VERIFICATION_REQUIRED'].includes(code)) this.access.record(id,userId,accessVersion,code === 'VERIFICATION_REQUIRED' ? 'verification-required' : 'auth-required');
if (controller.signal.aborted) throw new AppError(504, 'SOURCE_TIMEOUT', 'source request exceeded its execution deadline');
if (externalSignal?.aborted) throw new AppError(499, 'SOURCE_CANCELLED', 'source request cancelled');
throw error;
@@ -278,7 +313,7 @@ export class SourceHost {
return row ? this.decrypt(row.encrypted_value) : undefined;
},
set: async (key, value) => this.setCredential(sourceId, userId, key, value),
delete: async (key) => this.db.run('DELETE FROM source_credentials WHERE source_id = ? AND user_id = ? AND key = ?', sourceId, userId, key),
delete: async (key) => this.deleteCredential(sourceId,userId,key),
};
}
private storage(sourceId: string, userId: string): SourceStorage {
+5 -1
View File
@@ -99,6 +99,8 @@ export class SyncService {
rejected += 1;
continue;
}
const owner = this.db.get<{user_id:string;book_id:string}>('SELECT user_id,book_id FROM notes WHERE id=?',item.id);
if (owner && (owner.user_id !== userId || owner.book_id !== item.bookId)) { rejected++; continue; }
this.upsertNote(userId, item);
accepted += 1;
}
@@ -131,12 +133,14 @@ export class SyncService {
`INSERT INTO notes (id, user_id, book_id, type, locator, text, comment, color, updated_at, deleted)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
type = CASE WHEN excluded.updated_at >= notes.updated_at THEN excluded.type ELSE notes.type END,
locator = CASE WHEN excluded.updated_at >= notes.updated_at THEN excluded.locator ELSE notes.locator END,
text = CASE WHEN excluded.updated_at >= notes.updated_at THEN excluded.text ELSE notes.text END,
comment = CASE WHEN excluded.updated_at >= notes.updated_at THEN excluded.comment ELSE notes.comment END,
color = CASE WHEN excluded.updated_at >= notes.updated_at THEN excluded.color ELSE notes.color END,
deleted = CASE WHEN excluded.updated_at >= notes.updated_at THEN excluded.deleted ELSE notes.deleted END,
updated_at = MAX(excluded.updated_at, notes.updated_at)`,
updated_at = MAX(excluded.updated_at, notes.updated_at)
WHERE notes.user_id = excluded.user_id AND notes.book_id = excluded.book_id`,
record.id, userId, record.bookId, record.type ?? 'note', record.locator ?? '',
record.text ?? '', record.comment ?? '', record.color ?? '',
record.updatedAt ?? Date.now(), record.deleted ? 1 : 0,
+15 -24
View File
@@ -149,33 +149,24 @@ export class TtsService {
const target = this.buildUrl({ text, voice, speed, ...(input.format ? { format: input.format } : {}) });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
let response: Response;
let bytes: Uint8Array, contentType: string;
try {
response = await fetch(target, {
method: 'GET',
headers: {
accept: 'audio/*',
...(config.token ? { authorization: `Bearer ${config.token}` } : {}),
},
const response = await fetch(target, {
method: 'GET', headers: { accept: 'audio/*', ...(config.token ? { authorization: 'Bearer ' + config.token } : {}) },
signal: controller.signal,
});
} catch (err) {
const message = err instanceof Error && err.name === 'AbortError' ? 'timed out' : 'unreachable';
throw badRequest(`HTTP TTS service ${message}`, 'TTS_UPSTREAM');
} finally {
clearTimeout(timer);
}
if (!response.ok) {
throw badRequest(`HTTP TTS service answered ${response.status}`, 'TTS_UPSTREAM');
}
const contentType = (response.headers.get('content-type') ?? 'audio/mpeg').split(';')[0]!.trim();
// A service that answers with HTML is a misconfiguration (a wrong URL landing
// on a web page), and streaming that into an `<audio>` element produces the
// most confusing possible failure: silence with a 200.
if (!contentType.startsWith('audio/')) {
throw badRequest(`HTTP TTS service returned ${contentType}, expected audio`, 'TTS_UPSTREAM');
}
const bytes = new Uint8Array(await response.arrayBuffer());
if (!response.ok) throw badRequest('HTTP TTS service answered ' + response.status,
response.status === 401 || response.status === 403 ? 'TTS_AUTH' : 'TTS_UPSTREAM');
contentType = (response.headers.get('content-type') ?? '').split(';')[0]!.trim();
if (!contentType.startsWith('audio/')) throw badRequest('HTTP TTS service returned non-audio content', 'TTS_CONTENT');
bytes = new Uint8Array(await response.arrayBuffer());
if (!bytes.length) throw badRequest('HTTP TTS service returned empty audio', 'TTS_EMPTY');
} catch (error) {
if (controller.signal.aborted) throw badRequest('HTTP TTS service timed out', 'TTS_TIMEOUT');
if (error && typeof error === 'object' && 'code' in error) throw error;
throw badRequest('HTTP TTS service unreachable', 'TTS_UPSTREAM');
} finally { clearTimeout(timer); }
this.writeCache(key, bytes, contentType);
return { bytes, contentType, cached: false };
}
+79
View File
@@ -0,0 +1,79 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, writeFile, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Db } from '../src/db/index.ts';
import { createBackup, restoreBackup, verifyBackup } from '../src/maintenance/backup.ts';
async function fixture(t: import('node:test').TestContext) {
const root = await mkdtemp(join(tmpdir(), 'reader-backup-test-'));
t.after(() => rm(root, { recursive: true, force: true }));
const data = join(root, 'data'); await mkdir(data);
const db = new Db(join(data, 'reader.db'));
db.run("INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES('u','reader','hash','admin',1,1)");
db.run("INSERT INTO books(id,content_hash,title,format,created_at,updated_at) VALUES('b','hash','书','txt',1,1)");
db.run("INSERT INTO reading_progress(user_id,book_id,locator,percentage,updated_at) VALUES('u','b','chapter:12',0.6,10)");
db.run("INSERT INTO notes(id,user_id,book_id,type,locator,text,updated_at) VALUES('n','u','b','highlight','chapter:12','选中文字',10)");
db.close();
await writeFile(join(data, 'token.secret'), 'test-secret');
await mkdir(join(data, 'plugin-data', 'source'), { recursive: true });
await writeFile(join(data, 'plugin-data', 'source', 'state.json'), '{"enabled":true}');
return { root, data, backup: join(root, 'snapshot'), restored: join(root, 'restored') };
}
test('offline backup verifies and restores accounts, progress, notes, secret and plugin state', async t => {
const f = await fixture(t);
const manifest = await createBackup(f.data, f.backup);
assert.ok(manifest.entries.some(e => e.path === 'reader.db'));
assert.ok(!manifest.entries.some(e => e.path === 'reader.db-shm'));
assert.deepEqual(await verifyBackup(f.backup), manifest);
await restoreBackup(f.backup, f.restored);
const db = new Db(join(f.restored, 'reader.db'));
try {
assert.equal(db.get<{ locator: string }>('SELECT locator FROM reading_progress')?.locator, 'chapter:12');
assert.equal(db.get<{ text: string }>('SELECT text FROM notes')?.text, '选中文字');
assert.equal(db.get<{ username: string }>('SELECT username FROM users')?.username, 'reader');
} finally { db.close(); }
assert.equal(await readFile(join(f.restored, 'token.secret'), 'utf8'), 'test-secret');
assert.equal(await readFile(join(f.restored, 'plugin-data', 'source', 'state.json'), 'utf8'), '{"enabled":true}');
// Restored directories can themselves be backed up; they are not snapshot directories.
await createBackup(f.restored, join(f.root, 'second'));
});
test('refuses existing destinations and overlapping paths without touching original data', async t => {
const f = await fixture(t);
await assert.rejects(createBackup(f.data, join(f.data, 'nested')), /重叠/);
await assert.rejects(createBackup(f.data, f.data), /重叠/);
await createBackup(f.data, f.backup);
await assert.rejects(restoreBackup(f.backup, f.data), /EEXIST/);
assert.equal(await readFile(join(f.data, 'token.secret'), 'utf8'), 'test-secret');
});
test('tampered, incomplete and missing files are rejected before creating restore destination', async t => {
const f = await fixture(t); await createBackup(f.data, f.backup);
await writeFile(join(f.backup, '.reader-backup-incomplete'), 'incomplete');
await assert.rejects(restoreBackup(f.backup, f.restored), /未完成/);
await rm(join(f.backup, '.reader-backup-incomplete'));
await writeFile(join(f.backup, 'token.secret'), 'changed');
await assert.rejects(verifyBackup(f.backup), /校验失败/);
await rm(join(f.backup, 'token.secret'));
await assert.rejects(restoreBackup(f.backup, f.restored), /校验失败/);
await assert.rejects(readFile(join(f.restored, 'reader.db')), /ENOENT/);
});
test('rejects manifest path injection instead of writing outside the destination', async t => {
const f = await fixture(t); const manifest = await createBackup(f.data, f.backup);
manifest.entries[0]!.path = '../outside';
await writeFile(join(f.backup, 'reader-backup.json'), JSON.stringify(manifest));
await assert.rejects(restoreBackup(f.backup, f.restored), /校验失败/);
});
test('preserves internal plugin symlinks and rejects external links', { skip: process.platform === 'win32' }, async t => {
const f = await fixture(t);
await symlink('token.secret', join(f.data, 'internal'));
await createBackup(f.data, f.backup); await restoreBackup(f.backup, f.restored);
assert.equal(await readFile(join(f.restored, 'internal'), 'utf8'), 'test-secret');
await symlink('../snapshot/token.secret', join(f.data, 'external'));
await assert.rejects(createBackup(f.data, join(f.root, 'bad')), /数据目录外/);
});
+23
View File
@@ -10,6 +10,29 @@ import { ChapterPublications, MAX_CHAPTER_BYTES } from '../src/publications/chap
import { ChapterUpdates } from '../src/services/chapter-updates.ts';
import type { SourceContext, SourceProvider } from '../src/sources/types.ts';
test('quality inspection is read-only and empty refresh or switch preserves the previous book', async () => {
const f = await fixture();
try {
const book = await f.chapters.acquire(f.provider, f.context, { entryRef: 'book' }, 'book', { ref: 'book', title: '远程书' });
const before = f.chapters.manifest('u1', book), ref = before.items[0]!.resourceRef!;
const cached = await f.chapters.asset('u1', book, ref);
const rows = () => f.db.all('SELECT * FROM chapter_resources WHERE book_id=?', book);
const previousRows = rows();
const quality = await f.chapters.inspect(f.provider, f.context, 'book', 'one');
assert.ok(quality.characters > 0); assert.equal(quality.latestChapter, '第二章');
assert.deepEqual(rows(), previousRows);
assert.equal(f.chapters.binding('u1', book).publication_ref, 'book');
f.provider.readResource = async () => ({ mediaType: 'text/plain', text: ' \n\u200b' });
await assert.rejects(f.chapters.asset('u1', book, ref, undefined, true), { code: 'EMPTY_CHAPTER' });
await assert.rejects(f.chapters.switchSource(f.provider, f.context, book, 'new', 'book', 'one', before.revision), { code: 'EMPTY_CHAPTER' });
assert.deepEqual(rows(), previousRows);
assert.deepEqual((await f.chapters.asset('u1', book, ref)).data, cached.data);
assert.equal(f.chapters.binding('u1', book).publication_ref, 'book');
await assert.rejects(f.chapters.inspect(f.provider, f.context, 'book', 'missing'), { code: 'CHAPTER_GONE' });
await assert.rejects(f.chapters.inspect(f.provider, { ...f.context, signal: AbortSignal.abort() }, 'book', 'one'));
} finally { f.db.close(); await rm(f.root, { recursive: true, force: true }); }
});
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'reader-chapters-'));
const db = new Db(join(root, 'reader.db'));
+12
View File
@@ -0,0 +1,12 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { chapterQuality, requireChapterContent } from '../src/publications/chapter-quality.ts';
test('quality counts visible Unicode characters, ignores markup and rejects empty chapters', () => {
assert.equal(chapterQuality(' 中文 😀 \n', 'text/plain').characters, 3);
assert.equal(chapterQuality('<p>中&nbsp;文 &amp; 😀</p><script>evil()</script>', 'text/html').characters, 4);
for (const body of [' ', '\u200b\u2060', '<p>&nbsp;</p>', '<script>evil()</script>']) {
assert.throws(() => requireChapterContent(body, 'text/html'), { code: 'EMPTY_CHAPTER' });
}
assert.equal(requireChapterContent('<img src="data:image/png;base64,abcd" />', 'text/html').images, 1);
});
+13 -1
View File
@@ -10,13 +10,25 @@
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, chmod, rm, stat } from 'node:fs/promises';
import { mkdtemp, mkdir, readFile, writeFile, chmod, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadConfig } from '../src/config/index.ts';
const SECRET_ENV = ['READER_TOKEN_SECRET', 'JWT_SECRET'] as const;
test('refuses to start from a backup or incomplete restore directory', async t => {
const root = await mkdtemp(join(tmpdir(), 'reader-config-backup-'));
t.after(() => rm(root, { recursive: true, force: true }));
for (const marker of ['reader-backup.json', '.reader-backup-incomplete']) {
const data = join(root, marker + '-data'); await mkdir(data);
await writeFile(join(data, marker), 'marker');
await withEnv({ DATA_DIR: data, BOOKS_DIR: join(root, 'books') }, () => {
assert.throws(() => loadConfig(), /backup directory/);
});
}
});
async function withEnv(
vars: Record<string, string | undefined>,
fn: () => Promise<void> | void,
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Db } from '../src/db/index.ts';
import { ShelfService } from '../src/services/shelf.ts';
import { SyncService } from '../src/services/sync.ts';
test('10,000 indexed books keep shelf queries paginated and incremental sync bounded', t => {
const db = new Db(':memory:');
try {
db.run('INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES(?,?,?,?,?,?)','u','scale','x','member',1,1);
const started=performance.now();
db.transaction(()=>{for(let i=0;i<10000;i++){
const id='b'+String(i).padStart(5,'0');
db.run('INSERT INTO books(id,content_hash,format,title,author,created_at,updated_at) VALUES(?,?,?,?,?,?,?)',id,id,'txt','书籍 '+id,'作者 '+i%20,1,i+1);
db.run('INSERT INTO book_files(id,book_id,rel_path,size,mtime_ms,first_seen,last_seen) VALUES(?,?,?,?,?,?,?)',id,id,id+'.txt',100,1,1,1);
db.run('INSERT INTO user_books(user_id,book_id,added_at) VALUES(?,?,?)','u',id,i+1);
}});
const seedMs=performance.now()-started,shelf=new ShelfService(db);
const query=performance.now(),first=shelf.list('u'),last=shelf.list('u',{page:200});
assert.equal(first.total,10000);assert.equal(first.items.length,50);assert.equal(last.items.length,50);
assert.equal(new Set([...first.items,...last.items].map(b=>b.id)).size,100);
assert.equal(shelf.list('u',{search:'b09999'}).items.length,1);
const queryMs=performance.now()-query,sync=new SyncService(db,shelf),syncStart=performance.now();
sync.push('u',{progress:[{bookId:'b09999',locator:'r1:0:s',percentage:0,chapterTitle:'s',device:'test',updatedAt:10}]});
assert.equal(sync.pull('u',9).progress.length,1);assert.equal(sync.pull('u',10).progress.length,0);
t.diagnostic(JSON.stringify({rows:10000,seedMs:Math.round(seedMs),threeQueriesMs:Math.round(queryMs),syncMs:Math.round(performance.now()-syncStart)}));
}finally{db.close();}
});
+93
View File
@@ -0,0 +1,93 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { XMLParser, XMLValidator } from 'fast-xml-parser';
import { Db } from '../src/db/index.ts';
import { buildApp } from '../src/http/app.ts';
import type { AppContext } from '../src/http/context.ts';
import { UserService } from '../src/services/users.ts';
import { ShelfService } from '../src/services/shelf.ts';
import { createOpdsProvider } from '../src/sources/opds.ts';
import type { SourceContext } from '../src/sources/types.ts';
test('OPDS credentials publish a scoped, paginated read-only feed and revoke every resource', async t => {
const root = await mkdtemp(join(tmpdir(), 'reader-opds-test-'));
const booksDir = join(root, 'books'), dataDir = join(root, 'data');
await mkdir(booksDir); await mkdir(dataDir); await writeFile(join(booksDir, 'test.txt'), 'hello OPDS');
const db = new Db(join(dataDir, 'reader.db'));
const config = { booksDir, dataDir, host: '127.0.0.1', port: 0, jwtSecret: 'test-secret', accessTokenTtl: 3600, refreshTokenTtl: 3600,
scanInterval: 0, watchInterval: 0, logLevel: 'silent' as const, publicUrl: 'https://reader.test/prefix', corsOrigins: [] };
const ctx = { config, db } as AppContext;
ctx.users = new UserService(db, config); ctx.shelf = new ShelfService(db);
const app = buildApp(ctx);
t.after(async () => { await app.close(); db.close(); await rm(root, { recursive: true, force: true }); });
const user = await ctx.users.create({ username: 'alice', password: 'password123' });
const other = await ctx.users.create({ username: 'other', password: 'password123' });
const session = await ctx.users.login('alice', 'password123', 'test');
const otherSession = await ctx.users.login('other', 'password123', 'test');
const bearer = { authorization: 'Bearer ' + session.accessToken };
for (let i = 0; i < 52; i++) {
const id = 'b' + String(i).padStart(3, '0');
db.run('INSERT INTO books(id,content_hash,format,title,author,created_at,updated_at) VALUES(?,?,?,?,?,?,?)', id, id, 'txt', 'Book ' + i + ' <&>', 'Author', 1, 1);
await writeFile(join(booksDir, id + '.txt'), 'hello OPDS');
db.run('INSERT INTO book_files(id,book_id,rel_path,size,mtime_ms,first_seen,last_seen) VALUES(?,?,?,?,?,?,?)', id, id, id + '.txt', 10, 1, 1, 1);
db.run('INSERT INTO user_books(user_id,book_id,added_at,hidden) VALUES(?,?,?,?)', user.id, id, 1, i === 51 ? 1 : 0);
}
const noAuth = await app.inject('/opds'); assert.equal(noAuth.statusCode, 401); assert.match(String(noAuth.headers['www-authenticate']), /Basic/);
const response = await app.inject({ method: 'POST', url: '/api/v1/opds/credentials', headers: bearer, payload: { name: 'Tablet' } });
assert.equal(response.statusCode, 201, response.body);
const credential = response.json(), basic = { authorization: 'Basic ' + Buffer.from(credential.username + ':' + credential.password).toString('base64') };
assert.notEqual(db.get<{ token_hash: string }>('SELECT token_hash FROM opds_credentials')!.token_hash, credential.password);
const listed = await app.inject({ url: '/api/v1/opds/credentials', headers: bearer });
assert.ok(!listed.body.includes(credential.password));
const feed = await app.inject({ url: '/opds', headers: basic });
assert.equal(feed.statusCode, 200, feed.body); assert.equal(XMLValidator.validate(feed.body), true);
const parsed = new XMLParser({ ignoreAttributes: false }).parse(feed.body).feed;
assert.equal(parsed.entry.length, 50); assert.equal(parsed['opensearch:totalResults'], 51);
assert.ok(parsed.link.some((link: any) => link['@_rel'] === 'next'));
assert.ok(feed.body.includes('https://reader.test/prefix/opds/books/'));
assert.ok(feed.body.includes('&lt;&amp;&gt;')); assert.ok(!feed.body.includes('test.txt'));
assert.equal(feed.headers['cache-control'], 'private, no-store');
const client = createOpdsProvider({ fetch: async (input, init) => {
const target = new URL(String(input));
const response = await app.inject({ url: target.pathname.replace('/prefix', '') + target.search, headers: Object.fromEntries(new Headers(init?.headers)) });
return new Response(response.rawPayload, { status: response.statusCode, headers: { 'content-type': String(response.headers['content-type']) } });
} });
const clientContext: SourceContext = {
userId: user.id, signal: new AbortController().signal,
instance: { id: 'client', pluginId: 'reader.opds', sourceType: 'opds', name: 'Client', enabled: true,
config: { url: 'https://reader.test/prefix/opds', username: credential.username } },
credentials: { async get() { return credential.password; }, async set() {}, async delete() {} },
};
const catalog = await client.browse!(clientContext, {});
assert.equal(catalog.items.length, 50); assert.ok(catalog.nextCursor);
const found = await client.search!(clientContext, { query: 'Book 10' });
assert.equal(found.items.length, 1); assert.match(found.items[0]!.title, /Book 10/);
const second = await app.inject({ url: '/opds?page=2', headers: basic });
assert.equal(new XMLParser({ isArray: name => name === 'entry' }).parse(second.body).feed.entry.length, 1);
const search = await app.inject({ url: '/opds?search=Book%2010', headers: basic });
assert.match(search.body, /Book 10/); assert.doesNotMatch(search.body, /Book 11/);
assert.equal((await app.inject({ url: '/opds?page=abc', headers: basic })).statusCode, 400);
assert.equal(XMLValidator.validate((await app.inject({ url: '/opds/search.xml', headers: basic })).body), true);
const resource = await app.inject({ url: '/opds/books/b000/content', headers: { ...basic, range: 'bytes=0-4' } });
assert.equal(resource.statusCode, 206); assert.equal(resource.body, 'hello'); assert.equal(resource.headers['cache-control'], 'private, no-store');
assert.equal((await app.inject({ url: '/opds/books/b051/content', headers: basic })).statusCode, 404);
assert.equal((await app.inject({ url: '/api/v1/books', headers: basic })).statusCode, 401);
await app.inject({ method: 'DELETE', url: '/api/v1/opds/credentials/' + credential.id, headers: { authorization: 'Bearer ' + otherSession.accessToken } });
assert.equal((await app.inject({ url: '/opds', headers: basic })).statusCode, 200);
db.run('UPDATE users SET disabled=1 WHERE id=?', user.id);
assert.equal((await app.inject({ url: '/opds/books/b000/content', headers: basic })).statusCode, 401);
db.run('UPDATE users SET disabled=0 WHERE id=?', user.id);
db.run('UPDATE opds_credentials SET expires_at=0 WHERE id=?', credential.id);
assert.equal((await app.inject({ url: '/opds', headers: basic })).statusCode, 401);
db.run('UPDATE opds_credentials SET expires_at=? WHERE id=?', Date.now() + 10000, credential.id);
const otherCred = (await app.inject({ method: 'POST', url: '/api/v1/opds/credentials', headers: { authorization: 'Bearer ' + otherSession.accessToken }, payload: { name: 'Other' } })).json();
const otherBasic = { authorization: 'Basic ' + Buffer.from(otherCred.username + ':' + otherCred.password).toString('base64') };
assert.ok(!(await app.inject({ url: '/opds', headers: otherBasic })).body.includes('<entry>'));
assert.equal((await app.inject({ url: '/opds/books/b000/content', headers: otherBasic })).statusCode, 404);
await app.inject({ method: 'DELETE', url: '/api/v1/opds/credentials/' + credential.id, headers: bearer });
for (const url of ['/opds', '/opds/books/b000/content', '/opds/books/b000/cover']) assert.equal((await app.inject({ url, headers: basic })).statusCode, 401);
assert.ok(other.id);
});
+35
View File
@@ -0,0 +1,35 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Db } from '../src/db/index.ts';
import { ReadingOverrideService } from '../src/services/reading-overrides.ts';
import { SyncService } from '../src/services/sync.ts';
import type { ShelfService } from '../src/services/shelf.ts';
import { parseNavigation } from '../src/indexer/formats/navigation.ts';
test('personal reading overlays persist, reject stale writes and undo without modifying another user', () => {
const db = new Db(':memory:');
try {
for (const id of ['one','two']) db.run('INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES(?,?,?,?,?,?)',id,id,'x','member',1,1);
db.run('INSERT INTO books(id,content_hash,format,created_at,updated_at) VALUES(?,?,?,?,?)','b','hash','txt',1,1);
const service = new ReadingOverrideService(db);
const first = service.save('one','b',{version:0,headingPrefix:'第',corrections:[]});
const next = service.save('one','b',{...first,headingPrefix:'Chapter '});
assert.equal(new ReadingOverrideService(db).get('one','b').version,2);
assert.equal(next.headingPrefix,'Chapter ');
assert.equal(service.get('two','b').version,0);
assert.throws(()=>service.save('one','b',first),/另一设备/);
assert.equal(service.undo('one','b',next.version).headingPrefix,'第');
assert.throws(()=>service.save('one','b',{version:3,headingPrefix:'x',corrections:[{} as never]}),/invalid correction/);
const sync = new SyncService(db,{} as ShelfService);
const note = {id:'n',bookId:'b',type:'highlight' as const,locator:'rt1:anchor',text:'原文',comment:'',color:'',updatedAt:1,deleted:false};
sync.push('one',{notes:[note]});
sync.push('two',{notes:[{...note,text:'他人修改',updatedAt:2}]});
assert.equal(sync.pull('one',0).notes[0]?.text,'原文');
sync.push('one',{notes:[{...note,type:'note',comment:'批注',updatedAt:3}]});
assert.equal(sync.pull('one',0).notes[0]?.type,'note');
} finally { db.close(); }
});
test('server navigation keeps nested NCX sibling order and fragments', () => {
const xml = '<ncx><navMap><navPoint><navLabel><text>一</text></navLabel><content src="a#first"/><navPoint><navLabel><text>二</text></navLabel><content src="a#second"/><navPoint><navLabel><text>三</text></navLabel><content src="a#third"/></navPoint></navPoint></navPoint><navPoint><navLabel><text>四</text></navLabel><content src="b"/></navPoint></navMap></ncx>';
assert.deepEqual(parseNavigation(xml,'ncx').map(e => [e.href,e.depth]),[['a#first',0],['a#second',1],['a#third',2],['b',0]]);
});
+48 -1
View File
@@ -1,3 +1,4 @@
import { AppError } from '../src/lib/errors.ts';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
@@ -20,6 +21,49 @@ import type { CatalogPage, SourceProvider } from '../src/sources/types.ts';
import { pluginArchive } from './helpers/plugin-package.ts';
interface Session { token: string; id: string }
test('personal credential state is private, invalidated on change and ignores stale in-flight access', async t => {
const h = await harness(); t.after(() => h.close()); const member = await h.member();
const host = h.ctx.sources!;
let errorCode = '', release: (() => void) | undefined;
const provider: SourceProvider = {
descriptor: { id: 'access', label: 'Access', version: '1', capabilities: ['browse'], credentialKeys: [{ key: 'password', label: '密码' }] },
async detail() { return { ref: 'book', title: 'Book' }; },
async acquire() { return { kind: 'chapters', publicationRef: 'book' }; },
async browse() {
if (errorCode === 'pending') await new Promise<void>(resolve => { release = resolve; });
else if (errorCode) throw new AppError(401, errorCode, '需要重新验证');
return { items: [] };
},
};
host.registry.register({ pluginId: 'test.access', provider });
await host.create({ id: 'access', pluginId: 'test.access', sourceType: 'access', name: 'Access', config: {} });
const url = '/api/v1/sources/access/credentials';
const status = async (session = h.admin) => (await h.app.inject({ method: 'GET', url, headers: auth(session) })).json();
assert.equal((await h.app.inject({ method: 'GET', url })).statusCode, 401);
host.setCredential('access',h.admin.id,'password','secret-value');
assert.deepEqual((await status()).fields, [{ key: 'password', label: '密码', configured: true }]);
assert.equal((await status(member)).fields[0].configured, false);
assert.ok(!JSON.stringify(await status()).includes('secret-value'));
await host.browse(h.admin.id,'access',{});
assert.equal((await status()).state,'reachable');
for (const code of ['AUTH_EXPIRED','VERIFICATION_REQUIRED']) {
errorCode = code;
await assert.rejects(host.browse(h.admin.id,'access',{}), { code });
assert.equal((await status()).state,code === 'AUTH_EXPIRED' ? 'auth-required' : 'verification-required');
}
assert.equal((await status(member)).state,'unknown');
errorCode = 'pending'; const pending = host.browse(h.admin.id,'access',{});
host.setCredential('access',h.admin.id,'password','new-secret');
release!(); await pending;
assert.equal((await status()).state,'unknown'); assert.equal((await status()).checkedAt,null);
await h.app.inject({ method: 'DELETE', url: url + '/password', headers: auth(member) });
assert.equal((await status()).fields[0].configured,true);
await h.app.inject({ method: 'DELETE', url: url + '/password', headers: auth(h.admin) });
assert.equal((await status()).fields[0].configured,false);
host.setCredential('access',h.admin.id,'password','');
assert.equal((await status()).fields[0].configured,false);
});
function streamEvents(body: string): Array<{ event: string; data: any }> {
return body.split('\n\n').filter(frame => frame.startsWith('event:')).map(frame => ({
event: /^event: (.+)/m.exec(frame)![1]!, data: JSON.parse(/^data: (.+)/m.exec(frame)![1]!),
@@ -637,11 +681,14 @@ test('alternative streams use owned book identity and forced chapter refresh upd
const response = await h.app.inject({ method: 'POST', url: '/api/v1/books/' + id + '/alternatives', headers: auth(h.admin), payload: { sessionId: 'alternative-session', resultLimit: 3, query: 'untrusted' } });
assert.equal(response.statusCode, 200); assert.match(String(response.headers['content-type']), /text\/event-stream/);
assert.deepEqual(streamEvents(response.body).map(event => event.event), ['results', 'results', 'done']); assert.deepEqual(calls, ['first', 'next']);
const quality = await h.app.inject({ method: 'POST', url: '/api/v1/books/' + id + '/switch-quality', headers: auth(h.admin), payload: { entryRef: 'book', chapterId: 'ch' } });
assert.equal(quality.statusCode, 200, quality.body); assert.equal(quality.json().characters, 3);
assert.equal(h.ctx.db.get<{body:string|null}>('SELECT body FROM chapter_resources WHERE book_id=?',id)!.body,null);
await h.ctx.sources!.chapters.asset(h.admin.id, id, ref); text = 'new';
const refreshed = await h.app.inject({ method: 'POST', url: '/api/v1/books/' + id + '/refresh-chapter', headers: auth(h.admin), payload: { ref } });
assert.equal(refreshed.statusCode, 200, refreshed.body); assert.equal(refreshed.body, 'new'); assert.equal(refreshed.headers['cache-control'], 'no-store');
assert.equal((await h.ctx.sources!.chapters.asset(h.admin.id, id, ref)).data!.toString(), 'new');
for (const endpoint of ['alternatives', 'refresh-chapter']) {
for (const endpoint of ['alternatives', 'refresh-chapter', 'switch-quality']) {
const denied = await h.app.inject({ method: 'POST', url: '/api/v1/books/' + id + '/' + endpoint, headers: auth(member), payload: { ref, sessionId: 'alternative-session' } });
assert.equal(denied.statusCode, 404, denied.body);
}
+19 -2
View File
@@ -43,7 +43,7 @@ let token = '';
let upstream: Server | null = null;
let upstreamUrl = '';
/** What the stub upstream should answer with on the next call. */
let upstreamBehaviour: 'audio' | 'html' | 'error' = 'audio';
let upstreamBehaviour: 'audio' | 'html' | 'error' | 'empty' | 'auth' | 'slow' = 'audio';
let lastUpstreamQuery = '';
/** Minimal stand-in for an edge-tts-compatible service. */
@@ -57,6 +57,9 @@ function startUpstream(): Promise<void> {
res.end(JSON.stringify([{ id: 'zh-CN-XiaoxiaoNeural', name: '晓晓', lang: 'zh-CN' }]));
return;
}
if (upstreamBehaviour === 'empty') { res.writeHead(200, { 'content-type': 'audio/mpeg' }); res.end(); return; }
if (upstreamBehaviour === 'auth') { res.writeHead(401); res.end(); return; }
if (upstreamBehaviour === 'slow') { res.writeHead(200, { 'content-type': 'audio/mpeg' }); res.flushHeaders(); setTimeout(() => res.end('audio'), 200); return; }
if (upstreamBehaviour === 'html') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end('<html><body>not audio</body></html>');
@@ -206,7 +209,7 @@ test('an upstream that answers HTML is refused instead of served as silence', as
url: `/api/v1/tts?text=hello&access_token=${token}`,
});
assert.equal(res.statusCode, 400);
assert.equal((res.json() as { error: { code: string } }).error.code, 'TTS_UPSTREAM');
assert.equal((res.json() as { error: { code: string } }).error.code, 'TTS_CONTENT');
});
test('an upstream error is reported as such, not as a 200', async () => {
@@ -282,3 +285,17 @@ test('the audio route requires a token, in the header or the query', async () =>
function auth(): Record<string, string> {
return { authorization: `Bearer ${token}` };
}
test('diagnostic endpoint synthesizes audio and requires header authentication', async () => {
const unauth = await app.inject({method:'POST',url:'/api/v1/tts/test',payload:{}}); assert.equal(unauth.statusCode,401);
const result = await app.inject({method:'POST',url:'/api/v1/tts/test',headers:{authorization:'Bearer '+token},payload:{}});
assert.equal(result.statusCode,200,result.body); assert.ok(result.json().bytes > 0);
});
for (const [behaviour,code] of [['empty','TTS_EMPTY'],['auth','TTS_AUTH'],['slow','TTS_TIMEOUT']] as const) {
test('diagnosis classifies ' + behaviour, async () => {
upstreamBehaviour = behaviour;
if (behaviour === 'slow') { process.env.TTS_TIMEOUT_MS = '30'; ctx.tts = new TtsService(ctx.config); }
try { const result = await app.inject({method:'POST',url:'/api/v1/tts/test',headers:{authorization:'Bearer '+token},payload:{}}); assert.equal(result.statusCode,400,result.body); assert.equal(result.json().error.code,code); }
finally { delete process.env.TTS_TIMEOUT_MS; }
});
}
+62
View File
@@ -0,0 +1,62 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import JSZip from 'jszip';
import { Db } from '../src/db/index.ts';
import { createBackup, verifyBackup } from '../src/maintenance/backup.ts';
import { uploadBackup } from '../src/maintenance/webdav.ts';
test('WebDAV publishes only verified backup archives, retries staging and never overwrites destinations', async t => {
const root = await mkdtemp(join(tmpdir(), 'reader-dav-test-')), data = join(root, 'data'), backup = join(root, 'snapshot');
await mkdir(data); const db = new Db(join(data, 'reader.db')); db.close();
await writeFile(join(data, 'token.secret'), 'private'); await createBackup(data, backup);
const objects = new Map<string, Buffer>(); let failOnce = true, tamper = false, conflict = false, requests = 0, puts = 0;
const server = createServer(async (req, res) => {
requests++;
assert.equal(req.headers.authorization, 'Basic ' + Buffer.from('reader:private-password').toString('base64'));
const path = req.url!;
if (req.method === 'HEAD') { res.statusCode = objects.has(path) ? 200 : 404; res.end(); }
else if (req.method === 'PUT') {
puts++; const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(Buffer.from(chunk));
if (failOnce) { failOnce = false; res.statusCode = 503; res.end(); return; }
objects.set(path, Buffer.concat(chunks)); res.statusCode = 201; res.end();
} else if (req.method === 'GET') { res.end(tamper ? Buffer.from('corrupt') : objects.get(path)); }
else if (req.method === 'MOVE') {
assert.equal(req.headers.overwrite, 'F'); const target = new URL(String(req.headers.destination)).pathname;
if (conflict || objects.has(target)) { res.statusCode = 412; res.end(); return; }
objects.set(target, objects.get(path)!); objects.delete(path); res.statusCode = 201; res.end();
} else if (req.method === 'DELETE') { objects.delete(path); res.statusCode = 204; res.end(); }
else { res.statusCode = 405; res.end(); }
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(async () => { await new Promise<void>(resolve => server.close(() => resolve())); await rm(root, { recursive: true, force: true }); });
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/backups/`;
const options = { url, username: 'reader', password: 'private-password', name: 'snapshot.zip' };
const result = await uploadBackup(backup, options);
assert.equal(puts, 2); assert.equal(result.bytes, objects.get('/backups/snapshot.zip')!.length);
assert.equal(objects.size, 1);
const extracted = join(root, 'extracted'); await mkdir(extracted);
const zip = await JSZip.loadAsync(objects.get('/backups/snapshot.zip')!);
for (const file of Object.values(zip.files)) {
if (file.dir) await mkdir(join(extracted, file.name), { recursive: true });
else { await mkdir(dirname(join(extracted, file.name)), { recursive: true }); await writeFile(join(extracted, file.name), await file.async('nodebuffer')); }
}
assert.deepEqual(await verifyBackup(extracted), await verifyBackup(backup));
await assert.rejects(uploadBackup(backup, options), /同名/);
tamper = true; await assert.rejects(uploadBackup(backup, { ...options, name: 'tampered.zip' }), /校验/);
assert.equal(objects.size, 1);
tamper = false; conflict = true; await assert.rejects(uploadBackup(backup, { ...options, name: 'conflict.zip' }), /发布失败/);
assert.equal(objects.size, 1);
const before = requests;
await assert.rejects(uploadBackup(backup, { ...options, maxBytes: 1 }), /上限/);
await assert.rejects(uploadBackup(backup, { ...options, name: '../escape.zip' }), /文件名/);
await assert.rejects(uploadBackup(backup, { ...options, url: 'http://remote.test/backups' }), /HTTPS/);
await assert.rejects(uploadBackup(backup, { ...options, signal: AbortSignal.abort() }));
assert.equal(requests, before);
await writeFile(join(backup, 'token.secret'), 'changed');
await assert.rejects(uploadBackup(backup, options), /校验失败/);
assert.equal(requests, before);
});
+35
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"fast-xml-parser": "^4.5.1",
"jszip": "^3.10.1",
"lucide-preact": "^1.47.0",
"notyf": "^3.10.0"
@@ -1406,6 +1407,28 @@
"node": ">=12.0.0"
}
},
"node_modules/fast-xml-parser": {
"version": "4.5.1",
"resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-4.5.1.tgz",
"integrity": "sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
},
{
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1898,6 +1921,18 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+1
View File
@@ -17,6 +17,7 @@
"ui:sources": "npm run build && node --import ../server/node_modules/tsx/dist/loader.mjs tools/ui-review/host-source-flow.mjs"
},
"dependencies": {
"fast-xml-parser": "^4.5.1",
"jszip": "^3.10.1",
"lucide-preact": "^1.47.0",
"notyf": "^3.10.0"
+19 -1
View File
@@ -1,3 +1,5 @@
import type { ChapterQuality, CredentialStatus, OpdsCredential } from './sources.ts';
import type { ReadingOverrides } from './types.ts';
import { eventStream } from './event-stream.ts';
import type { ExtensionField, ExtensionPage } from './sources.ts';
import { ApiError, errorForStatus, parseErrorBody } from './errors.ts';
@@ -252,7 +254,15 @@ export class ReaderApi {
const bytes = new Uint8Array(response.bytes ?? []);
return new Blob([bytes.buffer]);
}
async switchPreview(id: string, entryRef: string): Promise<{ chapters: Array<{ id: string; title: string }> }> {
async switchQuality(id: string, entryRef: string, chapterId: string, options: RequestOptions = {}): Promise<ChapterQuality> {
return this.call('/api/v1/books/' + encodeURIComponent(id) + '/switch-quality','POST',{entryRef,chapterId},options);
}
async credentialStatus(id: string): Promise<CredentialStatus> { return this.get('/api/v1/sources/' + encodeURIComponent(id) + '/credentials'); }
async opdsCredentials(): Promise<{ credentials: OpdsCredential[]; catalogUrl: string }> { return this.get('/api/v1/opds/credentials'); }
async createOpdsCredential(name: string): Promise<OpdsCredential & { username: string; password: string; catalogUrl: string }> { return this.call('/api/v1/opds/credentials', 'POST', { name }); }
async revokeOpdsCredential(id: string): Promise<void> { await this.call('/api/v1/opds/credentials/' + encodeURIComponent(id), 'DELETE'); }
async deleteCredential(id: string, key: string): Promise<void> { await this.call('/api/v1/sources/' + encodeURIComponent(id) + '/credentials/' + encodeURIComponent(key),'DELETE'); }
async switchPreview(id: string, entryRef: string): Promise<{ chapters: Array<{ id: string; title: string }>; latestChapter?: string }> {
return this.call('/api/v1/books/' + encodeURIComponent(id) + '/switch-preview', 'POST', { entryRef });
}
async switchSource(id: string, entryRef: string, chapterId: string, revision: string): Promise<{ content: BookContent; href: string }> {
@@ -402,6 +412,14 @@ export class ReaderApi {
}
/** Whether this instance has a server-side speech engine, and what it offers. */
async readingOverrides(id: string): Promise<ReadingOverrides> { return this.get('/api/v1/books/' + encodeURIComponent(id) + '/reading-overrides'); }
async saveReadingOverrides(id: string, value: ReadingOverrides): Promise<ReadingOverrides> { return this.call('/api/v1/books/' + encodeURIComponent(id) + '/reading-overrides','PUT',value); }
async undoReadingOverrides(id: string, version: number): Promise<ReadingOverrides> { return this.call('/api/v1/books/' + encodeURIComponent(id) + '/reading-overrides/undo','POST',{version}); }
async testSpeech(voice: string, speed: number, options: RequestOptions = {}): Promise<{ bytes: number; contentType: string; elapsedMs: number }> {
return this.call('/api/v1/tts/test', 'POST', { voice, speed }, options);
}
async ttsCapabilities(options: RequestOptions = {}): Promise<TtsCapabilities | null> {
try {
return await this.get<TtsCapabilities>('/api/v1/tts/voices', options);
+1 -1
View File
@@ -52,7 +52,7 @@ export class ApiError extends Error {
*/
get isAuthFailure(): boolean {
// Source credentials are independent of the reader account session.
if (this.code === 'AUTH_REQUIRED') return false;
if (['AUTH_REQUIRED','AUTH_EXPIRED','VERIFICATION_REQUIRED'].includes(this.code)) return false;
if (this.kind === 'unauthorized') return true;
if (this.kind !== 'forbidden') return false;
return AUTH_FAILURE_CODES.has(this.code);
+4
View File
@@ -51,3 +51,7 @@ export interface ExtensionPage extends ExtensionContent {
title: string; description?: string; notice?: string; noticeKind?: 'info' | 'error'; activeTab?: string;
tabs?: Array<ExtensionContent & { id: string; title: string; description?: string }>;
}
export interface ChapterQuality { chapterId:string; title:string; latestChapter:string; characters:number; images:number; bytes:number; elapsedMs:number }
export interface CredentialStatus { state:'unknown'|'reachable'|'auth-required'|'verification-required'; checkedAt:number|null; available:boolean; fields:Array<{key:string;label:string;configured:boolean}> }
export interface OpdsCredential { id: string; name: string; createdAt: number; expiresAt: number }
+2
View File
@@ -336,3 +336,5 @@ export type ShelfAction = 'add' | 'remove' | 'hide' | 'unhide';
export interface ApiErrorBody {
error: { code: string; message: string };
}
export interface ReadingOverrides { version: number; corrections: Array<{ id: string; anchor: { sectionId: string; start: number; end: number; quote: string; prefix: string; suffix: string }; replacement: string }>; headingPrefix: string }
+3 -2
View File
@@ -676,10 +676,10 @@ export class App {
private installLifecycleHooks(): void {
if (typeof document === 'undefined') return;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') void this.sync.flush();
if (document.visibilityState === 'hidden') void this.flush();
});
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', () => void this.sync.flush());
window.addEventListener('pagehide', () => void this.flush());
}
}
@@ -693,6 +693,7 @@ export class App {
*/
async flush(): Promise<void> {
try {
await this.reader?.flushProgress();
await this.offline.flush();
await this.sync.flush();
} catch {
+85
View File
@@ -0,0 +1,85 @@
import type { ReaderApi } from '../api/client.ts';
import type { Manifest } from '../api/types.ts';
import { OFFLINE_FILE, PublicationCache } from '../store/publications.ts';
import { renditionRef } from '../ui/rendition.ts';
export interface DownloadState {
status: 'idle' | 'running' | 'paused' | 'cancelled' | 'complete' | 'error';
completed: number; total: number; from: number; to: number; message: string; revision?: string | undefined;
}
export class OfflineDownload {
state: DownloadState = { status: 'idle', completed: 0, total: 0, from: 1, to: 1, message: '' };
private run: AbortController | null = null;
private pending: Promise<void> = Promise.resolve();
constructor(private readonly api: ReaderApi, private readonly cache: PublicationCache,
private readonly manifest: Manifest, private readonly changed: (state: DownloadState) => void) {}
async load(): Promise<void> {
const raw = await this.cache.task(this.manifest.book.id);
if (!raw) return;
try {
const saved = JSON.parse(raw) as DownloadState;
if (saved.revision !== this.manifest.content?.revision) return;
this.state = saved;
if (this.state.status === 'running') this.state.status = 'paused';
this.changed({ ...this.state });
} catch { /* A missing task never invalidates cached chapters. */ }
}
async pause(cancel = false): Promise<void> {
this.run?.abort(); this.run = null;
await this.pending;
await this.update({ status: cancel ? 'cancelled' : 'paused', message: cancel ? '已取消,已下载内容保留' : '已暂停,可继续下载' });
}
async start(from = 1, to = ['chapters', 'txt'].includes(this.manifest.book.format) ? this.manifest.content?.total ?? 1 : 1): Promise<void> {
if (this.run) return;
const run = this.run = new AbortController();
this.pending = this.download(run, from, to).finally(() => { if (this.run === run) this.run = null; });
await this.pending;
}
private async update(patch: Partial<DownloadState>): Promise<void> {
this.state = { ...this.state, ...patch }; this.changed({ ...this.state });
await this.cache.putTask(this.manifest.book.id, JSON.stringify(this.state));
}
private async download(run: AbortController, from: number, to: number): Promise<void> {
const book = this.manifest.book, signal = run.signal;
const overrides = await this.cache.overrides(book.id);
const chapters = ['chapters', 'txt'].includes(book.format) && !!this.manifest.content && !(book.format === 'txt' && overrides?.headingPrefix);
const total = chapters ? this.manifest.content?.total ?? 0 : 1;
try {
if (book.format === 'image' && this.manifest.files.length > 1) throw new Error('图片目录暂不支持整书离线下载');
if (!['chapters', 'epub', 'txt', 'pdf', 'cbz', 'zip', 'image'].includes(book.format)) throw new Error('此格式暂不支持整书离线下载');
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < from || to > total) throw new Error('章节范围无效');
await this.update({ status: 'running', from, to, total: to - from + 1, completed: 0, message: '正在下载', revision: this.manifest.content?.revision });
await this.cache.putManifest(book.id, this.manifest);
if (!chapters) {
// Complete EPUB archives carry CSS/fonts/images, unlike isolated chapter HTML.
const bytes = await this.cache.resource(book.id, OFFLINE_FILE) ?? await this.api.bookBytes(book.id, { signal });
signal.throwIfAborted(); await this.cache.putResource(book.id, OFFLINE_FILE, bytes);
await this.update({ completed: 1 });
} else {
const content = this.manifest.content!;
const items = [];
for (let group = 0; group < Math.max(1, content.groups.length); group++) {
signal.throwIfAborted();
const shape = content.groups[group];
if (shape && (shape.offset >= to || shape.offset + shape.count < from)) continue;
const window = group === (content.group ?? 0) ? content : await this.api.items(book.id, group, { signal });
await this.cache.putWindow(book.id, group, window);
items.push(...window.items.filter(item => item.seq >= from - 1 && item.seq < to));
}
if (items.length !== to - from + 1) throw new Error('目录已变化,请重新打开书籍再下载');
for (const item of items) {
signal.throwIfAborted(); const ref = renditionRef(item);
if (!await this.cache.resource(book.id, ref)) {
const blob = await this.api.asset(book.id, ref, { signal });
signal.throwIfAborted(); await this.cache.putResource(book.id, ref, new Uint8Array(await blob.arrayBuffer()));
}
signal.throwIfAborted(); await this.update({ completed: this.state.completed + 1 });
}
}
signal.throwIfAborted(); await this.update({ status: 'complete', message: chapters ? '所选章节已可离线阅读' : '整书已可离线阅读' });
} catch (error) {
if (!signal.aborted) await this.update({ status: 'error', message: error instanceof Error ? error.message : '下载失败,可重试' });
}
}
}
+11 -34
View File
@@ -1,3 +1,4 @@
import { parseNavigation } from './navigation.ts';
import { BookArchive, dirnameOf, extensionOf, resolveHref } from './zip.ts';
import type { BookDoc, LoadContext, Resource, Section, TocEntry } from './types.ts';
@@ -284,40 +285,16 @@ async function readToc(archive: BookArchive, pkg: Package, sections: Section[]):
return sections.map((section) => ({ id: section.id, label: section.label, depth: 0 }));
}
function parseNav(html: string, baseDir: string, sections: Section[]): TocEntry[] {
const navBlock = /<nav\b[^>]*epub:type\s*=\s*"toc"[^>]*>([\s\S]*?)<\/nav>/i.exec(html)?.[1]
?? /<nav\b[^>]*>([\s\S]*?)<\/nav>/i.exec(html)?.[1];
if (!navBlock) return [];
const entries: TocEntry[] = [];
const pattern = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(navBlock)) !== null) {
const href = attr(match[1] ?? '', 'href');
const label = stripTags(match[2] ?? '');
if (!href || !label) continue;
const id = matchSectionId(resolveHref(baseDir, href), sections);
if (!id) continue;
entries.push({ id, label, depth: 0 });
}
return entries;
}
function parseNcx(xml: string, baseDir: string, sections: Section[]): TocEntry[] {
const entries: TocEntry[] = [];
const pattern = /<navPoint\b[^>]*>([\s\S]*?)<\/navPoint>/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(xml)) !== null) {
const block = match[1] ?? '';
const src = /<content\b[^>]*src\s*=\s*"([^"]+)"/i.exec(block)?.[1] ?? '';
const label = stripTags(/<text\b[^>]*>([\s\S]*?)<\/text>/i.exec(block)?.[1] ?? '');
if (!src || !label) continue;
const id = matchSectionId(resolveHref(baseDir, src), sections);
if (!id) continue;
const depth = (block.match(/<navPoint\b/gi)?.length ?? 1) - 1;
entries.push({ id, label, depth });
}
return entries;
function navigationEntries(xml: string, kind: 'nav' | 'ncx', baseDir: string, sections: Section[]): TocEntry[] {
return parseNavigation(xml, kind).flatMap(entry => {
const resolved = resolveHref(baseDir, entry.href);
const id = matchSectionId(resolved, sections);
const fragment = entry.href.includes('#') ? entry.href.slice(entry.href.indexOf('#')) : '';
return id ? [{ id: id + fragment, label: entry.title, depth: entry.depth }] : [];
});
}
function parseNav(html: string, baseDir: string, sections: Section[]): TocEntry[] { return navigationEntries(html, 'nav', baseDir, sections); }
function parseNcx(xml: string, baseDir: string, sections: Section[]): TocEntry[] { return navigationEntries(xml, 'ncx', baseDir, sections); }
/** Matches a TOC href to a section, tolerating fragment-only links. */
function matchSectionId(resolved: string, sections: Section[]): string | null {
@@ -330,7 +307,7 @@ function matchSectionId(resolved: string, sections: Section[]): string | null {
}
function applyTocLabels(sections: Section[], toc: TocEntry[]): void {
const depthById = new Map(toc.map((entry) => [entry.id, entry]));
const depthById = new Map([...toc].reverse().map((entry) => [entry.id.split('#')[0], entry]));
for (const section of sections) {
const entry = depthById.get(section.id);
if (entry) {
+55
View File
@@ -0,0 +1,55 @@
import { XMLParser } from 'fast-xml-parser';
export interface NavigationItem { href: string; title: string; depth: number }
type Node = Record<string, any>;
const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false, removeNSPrefix: true, processEntities: false, trimValues: false, parseTagValue: false });
const decode = (text: string) => text.replace(/&#(x[0-9a-f]+|\d+);|&(amp|lt|gt|quot|apos);/gi, (all, number: string, name: string) => {
if (!number) return ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" } as Record<string, string>)[name.toLowerCase()] ?? all;
const code = parseInt(number[0]?.toLowerCase() === 'x' ? number.slice(1) : number, number[0]?.toLowerCase() === 'x' ? 16 : 10);
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : all;
});
const textOf = (nodes: Node[]): string => nodes.map(node => Object.entries(node).filter(([key]) => key !== ':@').map(([key, value]) => key === '#text' ? String(value) : Array.isArray(value) ? textOf(value) : '').join('')).join('');
function find(nodes: Node[], tag: string): Node[] {
const result: Node[] = [];
for (const node of nodes) for (const [key, children] of Object.entries(node)) {
if (key === tag) result.push(node);
if (Array.isArray(children)) result.push(...find(children, tag));
}
return result;
}
/** Ordered XML tree traversal preserves nested navPoint/li entries and fragment hrefs. */
export function parseNavigation(xml: string, kind: 'nav' | 'ncx'): NavigationItem[] {
const tree = parser.parse(xml) as Node[];
const result: NavigationItem[] = [];
if (kind === 'nav') {
const navs = find(tree, 'nav');
const nav = navs.find(n => String(n[':@']?.['@_type'] ?? '').split(/\s+/).includes('toc')) ?? navs[0];
if (!nav) return [];
const walk = (nodes: Node[], depth: number): void => {
for (const node of nodes) for (const [tag, children] of Object.entries(node)) {
if (!Array.isArray(children)) continue;
if (tag === 'a') {
const href = node[':@']?.['@_href'];
const title = decode(textOf(children)).trim();
if (typeof href === 'string' && title) result.push({ href: decode(href), title, depth: Math.max(0, depth - 1) });
} else walk(children, depth + (tag === 'li' ? 1 : 0));
}
};
walk(nav.nav, 0);
} else {
const map = find(tree, 'navMap')[0];
const walk = (nodes: Node[], depth: number): void => {
for (const node of nodes) if (Array.isArray(node.navPoint)) {
const label = node.navPoint.find((n: Node) => n.navLabel);
const content = node.navPoint.find((n: Node) => n.content);
const href = content?.[':@']?.['@_src'];
const title = label ? decode(textOf(label.navLabel)).trim() : '';
if (typeof href === 'string' && title) result.push({ href: decode(href), title, depth });
walk(node.navPoint, depth + 1);
}
};
if (map) walk(map.navMap, 0);
}
return result;
}
+3 -1
View File
@@ -47,6 +47,8 @@ const CHAPTER_PATTERNS: RegExp[] = [
const MAX_HEADING_LENGTH = 80;
export interface TxtOptions {
/** Literal heading prefix; bounded lines avoid untrusted regular expressions. */
headingPrefix?: string;
/** Force an encoding label such as `gb18030`; detected when omitted. */
encoding?: string;
/** Split into chapters. When false the whole book is one section. */
@@ -74,7 +76,7 @@ export function loadTxt(ctx: LoadContext, options: TxtOptions = {}): TxtSplitRes
if (splitChapters) {
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length > 0 && trimmed.length <= MAX_HEADING_LENGTH && matchHeading(trimmed)) {
if (trimmed.length > 0 && trimmed.length <= MAX_HEADING_LENGTH && (options.headingPrefix ? trimmed.startsWith(options.headingPrefix) : matchHeading(trimmed))) {
chapters.push({ title: trimmed, start: offset });
}
offset += line.length + 1;
+3 -1
View File
@@ -147,7 +147,9 @@ export class OfflineStore {
private persist(): Promise<void> {
const payload = JSON.stringify(this.snapshot);
const key = this.key;
this.writeChain = this.writeChain.then(() => this.kv.set(key, payload)).catch(() => undefined);
// Recover the queue from an earlier failure, but report this write's error
// to its caller. Treating disk/quota failure as success loses the outbox.
this.writeChain = this.writeChain.catch(() => undefined).then(() => this.kv.set(key, payload));
return this.writeChain;
}
+72 -2
View File
@@ -1,5 +1,10 @@
import type { ReadingOverrides } from '../api/types.ts';
import type { BlobStore, KeyValueStore } from '../core/platform.ts';
import type { Manifest } from '../api/types.ts';
import type { BookContent, Manifest, TocEntry } from '../api/types.ts';
export interface CachedResource { bookId: string; ref: string; bytes: number }
const writes = new Map<string, Promise<unknown>>();
export const OFFLINE_FILE = '__complete_file__';
/**
* Device cache for staged publications.
@@ -39,7 +44,72 @@ export class PublicationCache {
}
async putResource(bookId: string, ref: string, bytes: Uint8Array): Promise<void> {
await this.blobs.put(this.resourceKey(bookId, ref), bytes);
await this.exclusive(async () => {
const entries = await this.entries();
const retained = entries.filter(e => e.bookId !== bookId || e.ref !== ref);
const used = retained.reduce((sum, entry) => sum + entry.bytes, 0);
if (used + bytes.byteLength > await this.quota()) throw new Error('离线缓存配额不足,请清理缓存或提高配额');
const key = this.resourceKey(bookId, ref);
const previous = await this.blobs.get(key);
await this.blobs.put(key, bytes);
try { await this.kv.set(this.indexKey(), JSON.stringify([...retained, { bookId, ref, bytes: bytes.byteLength }])); }
catch (error) { if (previous) await this.blobs.put(key, previous); else await this.blobs.remove(key); throw error; }
});
}
async overrides(bookId: string): Promise<ReadingOverrides | null> { const raw = await this.kv.get(this.metaKey(bookId) + ':overrides'); try { return raw ? JSON.parse(raw) as ReadingOverrides : null; } catch { return null; } }
async putOverrides(bookId: string, value: ReadingOverrides): Promise<void> { await this.kv.set(this.metaKey(bookId) + ':overrides',JSON.stringify(value)); }
async window(bookId: string, group: number): Promise<BookContent | null> {
const raw = await this.kv.get(this.metaKey(bookId) + ':window:' + group);
try { return raw ? JSON.parse(raw) as BookContent : null; } catch { return null; }
}
async putWindow(bookId: string, group: number, content: BookContent): Promise<void> {
await this.kv.set(this.metaKey(bookId) + ':window:' + group, JSON.stringify(content));
}
async toc(bookId: string): Promise<TocEntry[] | null> {
const value = await this.kv.get(this.metaKey(bookId) + ':toc');
try { return value ? JSON.parse(value) as TocEntry[] : null; } catch { return null; }
}
async putToc(bookId: string, toc: TocEntry[]): Promise<void> { await this.kv.set(this.metaKey(bookId) + ':toc', JSON.stringify(toc)); }
async task(bookId: string): Promise<string | null> { return this.kv.get(this.metaKey(bookId) + ':task'); }
async putTask(bookId: string, value: string): Promise<void> { await this.kv.set(this.metaKey(bookId) + ':task', value); }
async quota(): Promise<number> { return Number(await this.kv.get(this.indexKey() + ':quota')) || 256 * 1024 * 1024; }
async setQuota(bytes: number): Promise<void> {
if (!Number.isSafeInteger(bytes) || bytes < 1024 * 1024 || bytes > 10 * 1024 * 1024 * 1024) throw new Error('缓存配额应为 1–10240 MiB');
await this.exclusive(async () => {
if ((await this.entries()).reduce((n, e) => n + e.bytes, 0) > bytes) throw new Error('新配额低于当前占用,请先清理缓存');
await this.kv.set(this.indexKey() + ':quota', String(bytes));
});
}
async entries(): Promise<CachedResource[]> {
const raw = await this.kv.get(this.indexKey());
if (raw) return JSON.parse(raw) as CachedResource[];
// Adopt previously read chapters into the quota without exposing another account.
const prefix = `reader.publication.resource.v1:${this.scopeKey}:`;
const entries: CachedResource[] = [];
for (const key of await this.blobs.list()) {
if (!key.startsWith(prefix)) continue;
const [book, ref] = key.slice(prefix.length).split(':');
const bytes = await this.blobs.get(key);
if (book && ref && bytes) entries.push({ bookId: decodeURIComponent(book), ref: decodeURIComponent(ref), bytes: bytes.byteLength });
}
return entries;
}
async removeBook(bookId: string): Promise<void> {
await this.exclusive(async () => {
const entries = await this.entries();
for (const entry of entries.filter(e => e.bookId === bookId)) await this.blobs.remove(this.resourceKey(bookId, entry.ref));
await this.kv.set(this.indexKey(), JSON.stringify(entries.filter(e => e.bookId !== bookId)));
await this.kv.remove(this.metaKey(bookId) + ':task');
// Keep the lightweight manifest and reading records; never delete the user's source file.
});
}
private indexKey(): string { return `reader.publication.index.v1:${this.scopeKey}`; }
private async exclusive<T>(action: () => Promise<T>): Promise<T> {
const key = this.indexKey();
const next = (writes.get(key) ?? Promise.resolve()).catch(() => undefined).then(action);
writes.set(key, next);
try { return await next; } finally { if (writes.get(key) === next) writes.delete(key); }
}
private metaKey(bookId: string): string {
+33 -1
View File
@@ -3631,7 +3631,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.reader-scrub { flex: 1; min-width: 0; display: grid; text-align: center; }
.reader-screen .progress-scrubber { width: 100%; min-height: 28px; margin: 0; }
.reader-screen .progress-page { font-size: 0.65rem; color: var(--reader-muted); }
.reader-actions { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.25rem; }
.reader-actions { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0.25rem; }
.reader-actions .top-button { min-width: 0; max-width: none; min-height: 60px; padding: 0.45rem 0; gap: 0.45rem; }
.reader-actions .icon { font-size: 1.4rem; }
.reader-actions .top-button-label { font-size: 0.75rem; font-weight: 400; }
@@ -3941,3 +3941,35 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.source-switch-chapter { display: flex; align-items: baseline; gap: .5rem; font-size: .78rem; line-height: 1.6; color: var(--reader-muted); }
.source-switch-chapter > span { flex: none; font-size: .7rem; }
.source-switch .catalog-feedback { margin-top: 1rem; }
/* Reading tools share the reader modal, including narrow phone layouts. */
.reading-tools { display: grid; gap: 1rem; padding: 1rem; overflow-wrap: anywhere; }
.reading-tool-tabs { display: flex; flex-wrap: wrap; gap: .5rem; }
.reading-tool-tabs [aria-pressed="true"] { font-weight: bold; outline: 2px solid currentColor; }
.reading-tools section { display: grid; gap: .75rem; }
.reading-tools label { display: grid; gap: .3rem; }
.reading-tools input, .reading-tools textarea, .reading-tools select { max-width: 100%; min-width: 0; }
.reading-tools blockquote { margin: 0; max-height: 8rem; overflow: auto; }
.reading-tool-results { max-height: 45vh; overflow: auto; margin: 0; padding-left: 1.5rem; }
.reading-tool-results li { margin-block: .6rem; }
.reading-tool-results span { display: block; white-space: pre-wrap; }
.reading-tool-range { display: flex; gap: 1rem; }
.reading-tool-range label { min-width: 0; }
.toc-list li:has(.toc-collapse) { display: flex; }
.toc-list .toc-collapse { width: 2rem; flex: 0 0 2rem; justify-content: center; }
.reading-tools { min-height: 0; }
.reading-tools button, .reading-tools input, .reading-tools textarea, .reading-tools select { font: inherit; }
.reading-tool-tabs .button { flex: 1 1 auto; min-height: 2.5rem; font-size: .875rem; }
.reading-tool-tabs .button[aria-pressed="true"] { color: var(--reader-accent); background: var(--reader-surface); outline: none; border-color: var(--reader-accent); }
.reading-tools input, .reading-tools textarea, .reading-tools select { width: 100%; box-sizing: border-box; border: 1px solid var(--reader-line); border-radius: .5rem; padding: .6rem; background: var(--reader-paper); color: var(--reader-ink); }
.reading-tools textarea { min-height: 5rem; resize: vertical; }
.reading-tools form { display: grid; gap: .6rem; }
.reading-tools p { margin: .3rem 0; font-size: .9rem; line-height: 1.6; }
.reading-tool-results .button { text-align: start; width: 100%; margin-top: .25rem; }
.reading-tools blockquote { border-inline-start: 3px solid var(--reader-accent); padding: .5rem .75rem; line-height: 1.6; }
.source-capabilities { margin-block: 12px; overflow-wrap: anywhere; }
.source-capabilities summary { cursor: pointer; }
.source-capabilities dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; }
.source-capabilities dl > div { padding: 8px; border: 1px solid currentColor; border-radius: 6px; }
.source-capabilities dd { margin: 4px 0 0; }
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from './vendor/preact.ts';
import { ApiError, type ReaderApi } from '../api/client.ts';
import type { OpdsCredential } from '../api/sources.ts';
import { Modal } from './modal.tsx';
import { Button } from './toolkit.tsx';
export function OpdsAccess({ api, onClose, onSignedOut }: { api: ReaderApi; onClose(): void; onSignedOut(): void }) {
const [items, setItems] = useState<OpdsCredential[]>([]), [name, setName] = useState('');
const [created, setCreated] = useState<{ username: string; password: string } | null>(null);
const [url, setUrl] = useState(''), [error, setError] = useState(''), [busy, setBusy] = useState(false);
const active = useRef(true), running = useRef(false);
const run = async (action: () => Promise<void>) => {
if (running.current) return;
running.current = true; setBusy(true); setError('');
try { await action(); }
catch (reason) {
if (active.current) {
if (reason instanceof ApiError && reason.isAuthFailure) onSignedOut();
else setError(reason instanceof Error ? reason.message : '操作失败');
}
} finally { running.current = false; if (active.current) setBusy(false); }
};
const reload = async () => {
const value = await api.opdsCredentials();
if (!active.current) return;
setItems(value.credentials); setUrl(new URL(value.catalogUrl, api.baseUrl || location.href).href);
};
useEffect(() => { void run(reload); return () => { active.current = false; }; }, []);
return <Modal title="连接外部阅读器" busy={busy} onClose={onClose}>
<div className="sources-card source-modal-form"><div className="source-modal-content">
{busy && <p role="status">正在处理…</p>}
<p>通过 OPDS 1.2 只读目录访问当前账号书架中的文件书籍。章节书和目录漫画暂不发布。</p>
<label>目录地址<input readOnly value={url} /></label>
<p>在外部阅读器中填入目录地址和专用账号/密码。请使用 HTTPS;凭据有效期一年,可随时撤销。</p>
{error && <p role="alert">{error}</p>}
{created && <div role="status"><p>密码仅展示这一次,请先保存。关闭后无法重新查看。</p>
<label>OPDS 用户名<input readOnly value={created.username} /></label>
<label>OPDS 密码<input readOnly value={created.password} autoComplete="off" /></label>
</div>}
<form onSubmit={event => { event.preventDefault(); void run(async () => {
const result = await api.createOpdsCredential(name);
if (!active.current) return;
setCreated(result); setName(''); await reload();
}); }}>
<label>客户端名称<input required maxLength={80} value={name} disabled={busy} onInput={event => setName(event.currentTarget.value)} placeholder="例如:平板阅读器" /></label>
<Button type="submit" disabled={busy || !name.trim()}>创建 OPDS 凭据</Button>
</form>
{items.map(item => <article className="sources-row" key={item.id}><div><strong>{item.name}</strong><small>到期:{new Date(item.expiresAt).toLocaleDateString()}</small></div>
<Button disabled={busy} onClick={() => void run(async () => { await api.revokeOpdsCredential(item.id); if (!active.current) return; if (created?.username === item.id) setCreated(null); await reload(); })}>撤销 {item.name}</Button></article>)}
</div></div>
</Modal>;
}
+34 -6
View File
@@ -1,3 +1,4 @@
import type { ChapterQuality } from '../api/sources.ts';
import { Modal } from './modal.tsx';
import type { SourcePage } from '../api/sources.ts';
import { CatalogFeedback } from './catalog-feedback.tsx';
@@ -23,7 +24,7 @@ import type { AppSettings } from '../store/settings.ts';
import { DEFAULT_APP_SETTINGS, READOUT_FIELDS, READOUT_OPTIONS, type ReadoutMode } from '../store/settings.ts';
import { ReaderIndicators } from './reader-indicators.tsx';
import type { SpeechEngineKind } from '../render/speech.ts';
import { type ComponentChildren, type JSX, useLayoutEffect, useRef } from './vendor/preact.ts';
import { type ComponentChildren, type JSX, useLayoutEffect, useRef, useState } from './vendor/preact.ts';
import { Button, IconButton, SectionTitle } from './toolkit.tsx';
import { Icon, type IconName } from './icon.tsx';
@@ -92,6 +93,11 @@ export interface ChromeState {
refreshingChapter?: boolean;
alternativeChapters?: Array<{ id: string; title: string }> | undefined;
alternativeChapter?: string;
alternativeQuality?: ChapterQuality | null;
alternativeChecks?: Record<string, { quality?: ChapterQuality; error?: string; chapter: string }>;
alternativeQualityBusy?: boolean;
alternativeQualityError?: string;
alternativeLatest?: string;
alternativeTitle?: string;
refreshing?: boolean;
tts: SpeechBarState;
@@ -134,6 +140,7 @@ export interface ChromeState {
}
export interface ChromeHandlers {
onTools?(): void;
onBack(): void;
toggleToc(): void;
toggleSettings(tab?: 'appearance' | 'behavior' | 'speech'): void;
@@ -151,6 +158,7 @@ export interface ChromeHandlers {
onAlternative?(ref: string, title: string): void;
onAlternativeChapter?(id: string): void;
onSwitchSource?(): void;
onCheckAlternative?(): void;
onCancelSwitch?(): void;
onCancelAlternative?(): void;
/**
@@ -181,9 +189,12 @@ export interface ReaderChromeProps {
*/
stage: HTMLElement;
handlers: ChromeHandlers;
tools?: ComponentChildren;
}
export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX.Element {
export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProps): JSX.Element {
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
let hiddenDepth: number | null = null;
const fixed = state.layout === 'fixed';
const totalPages = Math.max(1, state.chapterPages);
const currentPage = Math.min(totalPages, Math.max(1, state.pageInChapter));
@@ -191,6 +202,7 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
const tab = state.settingsTab ?? 'behavior';
return (
<>
{tools}
<div className="topbar" hidden={!state.chromeVisible}>
<IconButton label="返回" icon="arrow-left" onClick={handlers.onBack} />
<button type="button" className="reader-heading reader-book-heading" aria-label="书籍信息" onClick={handlers.onBookInfo}><strong>{state.title}</strong><span>{state.chapterLabel}</span></button>
@@ -223,6 +235,7 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
onClick={() => handlers.onChapter(1)}>{fixed ? '下一页' : '下一章'}</button>
</div>
<div className="reader-actions">
{handlers.onTools && <TopButton icon="search" label="工具" onClick={handlers.onTools} />}
<TopButton icon="menu" label="目录" onClick={handlers.toggleToc} />
<TopButton icon="volume" label="朗读" onClick={() => handlers.toggleSettings('speech')} />
<TopButton icon="text-size" label="界面" onClick={() => handlers.toggleSettings('appearance')} />
@@ -241,12 +254,17 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
// it would make the list disagree with the chapter count in the
// header; and leaving it looking tappable is how the panel accepts a
// tap and does nothing, which reads as a broken panel.
const reachable = entry.spine !== undefined;
if (hiddenDepth !== null && entry.depth > hiddenDepth) return null;
hiddenDepth = null;
const hasChildren = (state.toc[index + 1]?.depth ?? -1) > entry.depth;
if (hasChildren && collapsed.has(entry.id)) hiddenDepth = entry.depth;
const reachable = entry.spine !== undefined && entry.spine >= 0;
return (
<li key={entry.id} data-section={entry.id}>
<li key={entry.id + index} data-section={entry.id}>
{hasChildren && <button className="toc-collapse" aria-label={entry.label + (collapsed.has(entry.id) ? ' 展开' : ' 折叠')} aria-expanded={!collapsed.has(entry.id)} onClick={() => { const next = new Set(collapsed); if (next.has(entry.id)) next.delete(entry.id); else next.add(entry.id); setCollapsed(next); }}>{collapsed.has(entry.id) ? '+' : '−'}</button>}
<button
type="button"
aria-current={entry.id === state.currentSectionId}
aria-current={entry.id.split('#')[0] === state.currentSectionId}
data-unreachable={reachable ? undefined : 'true'}
disabled={!reachable}
title={reachable ? undefined : '这一章不在当前窗口中'}
@@ -275,11 +293,19 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
<span className="source-switch-card-heading"><strong>{entry.sourceName || '未命名书源'}</strong><Icon name="chevron-right" /></span>
<span className="source-switch-book">{entry.title}<span> · {entry.authors?.join(' / ') || '作者未知'}</span></span>
<span className="source-switch-chapter"><span>最新</span>{entry.latestChapter || '暂无章节信息'}</span>
{state.alternativeChecks?.[entry.ref] && <span className="source-switch-chapter">已检测 {state.alternativeChecks[entry.ref]!.chapter}:{state.alternativeChecks[entry.ref]!.quality
? `${state.alternativeChecks[entry.ref]!.quality!.characters} 字符 · ${state.alternativeChecks[entry.ref]!.quality!.elapsedMs} ms`
: state.alternativeChecks[entry.ref]!.error}</span>}
</button>)}</div>
{!!state.alternatives.errors?.length && <CatalogFeedback page={state.alternatives} merged compact searching={!!state.alternativesSearching} />}
{state.alternativeChapters && <Modal title="选择阅读章节" busy={!!state.switching} onClose={() => handlers.onCancelAlternative?.()}><div className="alternative-preview source-modal-content"><h4>{state.alternativeTitle}</h4><label>切换后阅读的章节<select value={state.alternativeChapter ?? ''} disabled={state.switching === true} onChange={event => handlers.onAlternativeChapter?.(event.currentTarget.value)}>
<option value="">请选择章节</option>{state.alternativeChapters.map(chapter => <option value={chapter.id}>{chapter.title}</option>)}
</select></label><Button type="button" disabled={state.switching || !state.alternativeChapter} onClick={() => handlers.onSwitchSource?.()}>确认换源并阅读</Button></div></Modal>}
</select></label>
{state.alternativeLatest && <p>最新章节:{state.alternativeLatest}</p>}
<Button disabled={state.switching || state.alternativeQualityBusy || !state.alternativeChapter} onClick={() => handlers.onCheckAlternative?.()}>{state.alternativeQualityBusy ? '正在检测正文…' : '检测所选章节 / 重试'}</Button>
{state.alternativeQuality && <div role="status" className="chapter-quality"><strong>正文检测通过</strong><p>{state.alternativeQuality.characters} 字符 · {state.alternativeQuality.images} 张图片 · {(state.alternativeQuality.bytes / 1024).toFixed(1)} KiB · {state.alternativeQuality.elapsedMs} ms</p><p>检测仅说明本次内容可读,不保证章节完整或与原书一致。</p></div>}
{state.alternativeQualityError && <p role="alert">{state.alternativeQualityError}</p>}
<Button type="button" disabled={state.switching || state.alternativeQualityBusy || !state.alternativeChapter} onClick={() => handlers.onSwitchSource?.()}>确认换源并阅读</Button></div></Modal>}
</section></Panel>}
{state.settingsOpen ? (
@@ -519,6 +545,8 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
/>
: null}
<SectionTitle>翻页</SectionTitle>
<Button onClick={() => handlers.onSetting({ pageAnimation: 'none', mode: 'paged', theme: 'light' })}>应用电纸书预设</Button>
<p className="muted">浅色、分页、无翻页动画;仅保存到当前设备,可继续单独调整。屏幕刷新由设备控制。</p>
<SegmentedRow
label="点击区域"
options={[
+277 -59
View File
@@ -1,3 +1,5 @@
import { applyCorrections, emptyOverrides, type ReadingOverrides } from './reading-overrides.ts';
import { loadTxt } from '../formats/txt.ts';
import { ApiError } from '../api/errors.ts';
import { dismissNotice, notify } from './notifications.ts';
import { renditionRef } from './rendition.ts';
@@ -27,7 +29,10 @@ import {
import type { NativeSpeechBridge } from '../android-bridge.ts';
import { mountUI } from './mount.ts';
import { ReaderChrome, type ChromeState, type ChromeTocEntry } from './reader-chrome.tsx';
import { PublicationCache, publicationScope } from '../store/publications.ts';
import { OFFLINE_FILE, PublicationCache, publicationScope } from '../store/publications.ts';
import { ReadingTools, type SearchSection } from './reading-tools.tsx';
import { decodeAnchor, searchableText, type TextAnchor } from './text-anchor.ts';
import { textToChapterHtml } from '../formats/segments.ts';
import { parseLocator } from './locator.ts';
export interface ReaderScreenOptions {
@@ -179,6 +184,7 @@ export class ReaderScreen {
private navigating = false;
private detachGestures: (() => void) | null = null;
private progressTimer: ReturnType<typeof setTimeout> | null = null;
private progressWrite: Promise<void> = Promise.resolve();
private readonly noticeId = Symbol('reader-status');
private disposed = false;
private pendingPosition: Position | null = null;
@@ -187,11 +193,18 @@ export class ReaderScreen {
private readonly viewListeners: Array<() => void> = [];
private readonly publicationCache: PublicationCache;
private loadingToken = 0;
private overrides: ReadingOverrides = emptyOverrides();
private toolsOpen = false;
private toolsSelection: TextAnchor | null = null;
private toolsReturn = '';
private preview: SpeechEngine | null = null;
private previewRun: AbortController | null = null;
private alternativeRef = '';
private viewEpoch = 0;
private alternativesRun: AbortController | null = null;
private chapterRefresh: AbortController | null = null;
private alternativeEpoch = 0;
private qualityRun: AbortController | null = null;
constructor(private readonly options: ReaderScreenOptions) {
this.settings = { ...options.settings };
@@ -219,9 +232,19 @@ export class ReaderScreen {
this.element,
() => (
<ReaderChrome
tools={this.toolsOpen && this.manifest ? <ReadingTools
api={this.options.api} cache={this.publicationCache} offline={this.options.offline}
sync={this.options.sync} manifest={this.manifest} selection={this.toolsSelection}
locator={this.view?.position()?.locator ?? ''} returnLocator={this.toolsReturn}
sections={signal => this.searchSections(signal)} navigate={target => this.navigateTool(target)}
overrides={this.overrides} saveOverrides={value => this.saveOverrides(value)} undoOverrides={() => this.undoOverrides()}
previewHeadings={prefix => this.previewHeadings(prefix)}
onNotes={() => this.paintNotes()} onClose={() => { this.toolsOpen = false; this.patch({}); }}
previewSpeech={() => this.previewSpeech()} stopPreview={() => this.stopPreview()} /> : null}
state={this.chrome}
stage={this.stage}
handlers={{
onTools: () => this.openTools(),
onBack: () => this.options.onBack(),
toggleToc: () => this.toggleToc(),
toggleSettings: (tab) => this.toggleSettings(tab),
@@ -237,10 +260,11 @@ export class ReaderScreen {
onRefreshChapter: () => void this.refreshChapter(),
onAlternatives: cursor => void this.loadAlternatives(cursor),
onAlternative: (ref, title) => void this.previewAlternative(ref, title),
onAlternativeChapter: id => this.patch({ alternativeChapter: id }),
onAlternativeChapter: id => { this.qualityRun?.abort(); this.patch({ alternativeChapter: id, alternativeQuality:null, alternativeQualityError:'', alternativeQualityBusy:false }); },
onCheckAlternative: () => void this.checkAlternative(),
onSwitchSource: () => void this.switchSource(),
onCancelSwitch: () => this.closeAlternatives(),
onCancelAlternative: () => this.patch({ alternativeChapters: undefined, alternativeChapter: '' }),
onCancelAlternative: () => { this.qualityRun?.abort(); this.patch({ alternativeChapters: undefined, alternativeChapter: '', alternativeQuality:null, alternativeQualityError:'', alternativeQualityBusy:false }); },
onScrubPage: (page) => void this.scrubToPage(page),
onTurnPage: (direction) => void this.turnPage(direction),
onSpeechToggle: () => this.toggleSpeech(),
@@ -261,14 +285,26 @@ export class ReaderScreen {
async open(book: Book): Promise<void> {
this.closeAlternatives(); this.chapterRefresh?.abort(); this.chapterRefresh = null;
const token = ++this.loadingToken;
this.toolsOpen = false; this.toolsReturn = ''; this.toolsSelection = null; this.stopPreview();
this.content = null; this.book = book;
this.patch({ title: book.title, author: book.author, description: book.description, bookInfoOpen: false, canRefresh: book.format === 'chapters', canSwitch: false, alternatives: null, refreshingChapter: false, sourceName: '', chapterUrl: '' });
this.patch({ title: book.title, author: book.author, description: book.description, bookInfoOpen: false, canRefresh: book.format === 'chapters', canSwitch: false, alternatives: null, refreshingChapter: false, sourceName: '', chapterUrl: '', toc: [] });
this.setStatus('loading', '正在载入…');
this.setChromeVisible(true);
try {
this.manifest = await this.loadManifest(book);
if (token !== this.loadingToken) return;
const storedOverrides = await this.publicationCache.overrides(book.id) ?? emptyOverrides();
if (token !== this.loadingToken) return;
this.overrides = storedOverrides;
try {
const remote = await this.options.api.readingOverrides(book.id);
if (token !== this.loadingToken) return;
if (remote && Number.isSafeInteger(remote.version) && Array.isArray(remote.corrections)) {
this.overrides = remote; await this.publicationCache.putOverrides(book.id,remote).catch(() => undefined);
}
} catch (error) { if (error instanceof ApiError && error.isAuthFailure) throw error; }
if (token !== this.loadingToken) return;
if (book.format === 'chapters' && typeof this.options.api.sourceOptions === 'function') {
void this.options.api.sourceOptions(book.id).then(options => {
if (token === this.loadingToken) this.patch({ canSwitch: options.canSwitch });
@@ -326,6 +362,8 @@ export class ReaderScreen {
* omnibus would otherwise be a 50MB download to show one page of chapter one.
*/
private async openStaged(book: Book, token: number): Promise<{ doc: BookDoc } | null> {
if (book.format === 'txt' && this.overrides.headingPrefix) return null;
if (!['chapters', 'txt'].includes(book.format) && await this.publicationCache.resource(book.id, OFFLINE_FILE)) return null;
const manifest = this.manifest;
const content = manifest?.content;
if (!content || !isStagedKind(content.kind)) return null;
@@ -348,9 +386,9 @@ export class ReaderScreen {
if (book.format !== 'chapters') {
try {
const fetched = await this.options.api.toc(book.id);
if (Array.isArray(fetched)) toc = fetched;
if (Array.isArray(fetched)) { toc = fetched; await this.publicationCache.putToc(book.id, toc).catch(() => undefined); }
} catch {
// Covered by the fallback below.
toc = await this.publicationCache.toc(book.id) ?? [];
}
}
if (toc.length === 0) {
@@ -406,20 +444,13 @@ export class ReaderScreen {
// Keyed by the rendition, not just the section: a client that read the plain
// text before this change and the markup after it must not serve the cached
// plain text for a chapter it is now rendering as a document.
const legacyCacheKey = `section:${book.id}:${ref}`;
const cached = book.format === 'chapters'
? await this.publicationCache.resource(book.id, ref)
: await this.options.platform.blobs.get(legacyCacheKey);
const cached = await this.publicationCache.resource(book.id, ref);
const blob = cached ? new Blob([toArrayBuffer(cached)]) : await this.options.api.asset(book.id, ref);
if (!cached) {
// Cached per section, not per book, so a partially read book is partially
// readable offline and re-opening one does not re-fetch what was read.
const bytes = new Uint8Array(await blob.arrayBuffer());
if (book.format === 'chapters') {
await this.publicationCache.putResource(book.id, ref, bytes).catch(() => undefined);
} else {
await this.options.platform.blobs.put(legacyCacheKey, bytes).catch(() => undefined);
}
await this.publicationCache.putResource(book.id, ref, bytes).catch(() => undefined);
}
if (item.kind === 'page') {
return { image: { mediaType: item.mediaType, bytes: new Uint8Array(await blob.arrayBuffer()) } };
@@ -429,12 +460,13 @@ export class ReaderScreen {
dispose(): void {
this.closeAlternatives(); this.chapterRefresh?.abort();
this.stopPreview();
this.disposed = true;
this.loadingToken += 1;
this.viewEpoch += 1;
this.tts?.dispose();
this.tts = null;
this.flushProgress();
void this.flushProgress().catch(() => undefined);
this.book = null;
if (this.progressTimer) clearTimeout(this.progressTimer);
dismissNotice(this.noticeId);
@@ -456,22 +488,166 @@ export class ReaderScreen {
this.element.remove();
}
private async previewHeadings(prefix: string): Promise<string[]> {
if (!this.book || !this.manifest) throw new Error('阅读器已关闭');
const bytes = await this.fetchBookBytes(this.book,this.manifest);
const result = loadTxt({ bytes,bookId:this.book.id,fileName:'preview.txt' },{ headingPrefix:prefix, ...(this.settings.txtEncoding ? {encoding:this.settings.txtEncoding} : {}) });
return result.doc.toc.map(entry => entry.label);
}
private async saveOverrides(value: ReadingOverrides): Promise<void> {
if (!this.book) return;
const token = this.loadingToken;
const next = await this.options.api.saveReadingOverrides(this.book.id,value);
if (token === this.loadingToken) await this.applyOverrides(next);
}
private async undoOverrides(): Promise<void> {
if (!this.book) return;
const token = this.loadingToken;
const next = await this.options.api.undoReadingOverrides(this.book.id,this.overrides.version);
if (token === this.loadingToken) await this.applyOverrides(next);
}
private async applyOverrides(next: ReadingOverrides): Promise<void> {
const book = this.book; if (!book) return;
const oldPrefix = this.overrides.headingPrefix;
this.overrides = next;
await this.publicationCache.putOverrides(book.id,next).catch(() => undefined);
if (oldPrefix !== next.headingPrefix) {
await this.flushProgress();
this.content = null; this.patch({toc:[]});
if (!this.manifest) return;
const staged = await this.openStaged(book,this.loadingToken);
this.doc = staged?.doc ?? (await this.loadDoc(book,this.manifest,await this.fetchBookBytes(book,this.manifest))).doc;
this.afterDocLoaded(); await this.view?.open(0,0); this.toolsReturn = '';
} else {
const position = this.view?.position();
if (position) await this.view?.openLocator(position.locator);
}
this.toolsSelection = null; this.paintNotes(); this.patch({});
}
private openTools(): void {
if (!this.manifest || !this.view) return;
this.toolsSelection = this.view.selectionAnchor();
if (!this.toolsReturn) this.toolsReturn = this.view.position()?.locator ?? '';
this.toolsOpen = true;
this.patch({ tocOpen: false, settingsOpen: false });
}
private paintNotes(): void {
if (!this.book) return;
this.view?.paintAnnotations(this.options.offline.notesFor(this.book.id).flatMap(note => {
const anchor = decodeAnchor(note.locator);
return anchor ? [{ anchor: { ...anchor, sectionId: this.localSectionId(anchor.sectionId) }, color: note.color || '#ffd54f' }] : [];
}));
}
private searchText(html: string, sectionId: string): string {
const root = new DOMParser().parseFromString(html,'text/html').body;
applyCorrections(root,sectionId,this.overrides);
return searchableText(root.innerHTML);
}
private async *searchSections(signal: AbortSignal): AsyncGenerator<SearchSection> {
const doc = this.doc, book = this.book, content = this.manifest?.content;
if (!doc || !book || doc.layout === 'fixed') throw new Error('此格式暂不支持正文搜索');
if (this.content && content) {
for (let group = 0; group < Math.max(1, content.groups.length); group++) {
signal.throwIfAborted();
const window = group === 0 ? content : await this.readWindow(book.id, group, signal);
for (const item of window.items) {
signal.throwIfAborted();
const ref = renditionRef(item);
const bytes = await this.publicationCache.resource(book.id, ref);
const html = bytes ? new TextDecoder().decode(bytes) : await (await this.options.api.asset(book.id, ref, { signal })).text();
yield { id: item.href, title: item.title, text: this.searchText(/(?:xhtml|html|xml)/i.test(item.mediaType) ? html : textToChapterHtml(html),item.href) };
}
}
} else {
const lazy = doc as BookDoc & { loadSection?(index: number): Promise<import('../formats/types.ts').Section | null> };
for (let i = 0; i < doc.sections.length; i++) {
signal.throwIfAborted();
const section = lazy.loadSection ? await lazy.loadSection(i) : doc.sections[i];
if (section) yield { id: section.id, title: section.label, text: this.searchText(section.bodyIsMarkup === false ? textToChapterHtml(section.html ?? '') : section.html ?? '',section.id) };
}
}
}
private async readWindow(bookId: string, group: number, signal?: AbortSignal): Promise<BookContent> {
try { const content = await this.options.api.items(bookId, group, signal ? { signal } : {}); await this.publicationCache.putWindow(bookId, group, content).catch(() => undefined); return content; }
catch (error) {
if (error instanceof ApiError && !error.isConnectivity && error.kind !== 'server') throw error;
const cached = await this.publicationCache.window(bookId, group);
if (cached && cached.revision === this.manifest?.content?.revision) return cached;
throw error;
}
}
private localSectionId(id: string): string {
if (!this.content && this.book?.format === 'epub') return id.replace(/^xhtml:/, '');
if (this.content && this.book?.format === 'epub' && !id.startsWith('xhtml:')) return 'xhtml:' + id;
return id;
}
private compatibleLocator(locator: string): string {
const match = /^r1:([^:]+):(.+)$/.exec(locator);
return match ? 'r1:' + match[1] + ':' + this.localSectionId(match[2]!) : locator;
}
private async navigateTool(target: TextAnchor | string): Promise<void> {
if (!this.view) throw new Error('阅读器已关闭');
const locator = typeof target === 'string' ? /^r1:([^:]+):(.+)$/.exec(target) : null;
const section = typeof target === 'string' ? locator?.[2] ?? target : target.sectionId;
await this.goToChapterRef(section);
if (typeof target === 'string') {
if (!await this.view.openLocator(this.compatibleLocator(target))) throw new Error('无法恢复此位置,章节可能已更新');
if (target === this.toolsReturn) this.toolsReturn = '';
} else if (!this.view.showTextAnchor({ ...target, sectionId: this.localSectionId(target.sectionId) })) throw new Error('原文位置已变化,无法准确定位');
this.paintNotes();
}
private stopPreview(): void { this.previewRun?.abort(); this.previewRun = null; this.preview?.dispose(); this.preview = null; }
private async previewSpeech(): Promise<string> {
this.stopPreview(); this.tts?.stop();
const run = this.previewRun = new AbortController();
const requested = this.settings.ttsEngine;
const available = this.speechAvailability();
const kind = requested === 'auto' ? available.preferred : requested;
if (!kind) throw new Error('当前设备没有可用语音,请配置系统语音或 HTTP 服务');
if (kind === 'http') {
try { await this.options.api.testSpeech(this.settings.ttsVoice, this.settings.ttsRate, { signal: run.signal }); }
catch (error) {
const labels: Record<string, string> = { TTS_DISABLED: '服务端未配置 HTTP 朗读', TTS_AUTH: '朗读上游鉴权失败,请检查服务端凭据', TTS_TIMEOUT: '朗读上游超时,请重试', TTS_EMPTY: '上游返回空音频', TTS_CONTENT: '上游返回的不是音频,请检查服务地址', TTS_UPSTREAM: '无法访问朗读上游服务' };
if (error instanceof ApiError) throw new Error(labels[error.code] ?? error.message);
throw error;
}
}
run.signal.throwIfAborted();
let failure = '';
const engine = createSpeechEngine({ kind, baseUrl: this.options.api.baseUrl,
accessToken: () => this.options.api.currentSession()?.accessToken ?? null,
nativeBridge: this.options.speechBridge ?? null,
onError: message => { failure = message; this.flashStatus(message, 6000); },
});
if (!engine) throw new Error('所选语音引擎不可用,请切换引擎');
this.preview = engine;
engine.setRate(this.settings.ttsRate); engine.setPitch(this.settings.ttsPitch);
engine.setVolume(this.settings.ttsVolume); engine.setVoice(this.settings.ttsVoice);
engine.loadQueue = async from => ({ chunks: from ? [] : [{ text: '这是阅读器朗读试听。愿你享受阅读的时光。', node: null, start: 0, blockIndex: 0 }], startIndex: 0 });
await engine.play(0);
if (failure) throw new Error(failure);
return '已启动试听:' + SPEECH_ENGINE_LABELS[kind] + ';请确认能听到声音';
}
// ---- loading ----
private async loadManifest(book: Book): Promise<Manifest> {
try {
const manifest = await this.options.api.manifest(book.id);
if (book.format === 'chapters') {
await this.publicationCache.putManifest(book.id, manifest).catch(() => undefined);
}
await this.publicationCache.putManifest(book.id, manifest).catch(() => undefined);
return manifest;
} catch (error) {
// Authentication failures must never be hidden behind cached content.
if (error instanceof ApiError && !error.isConnectivity && error.kind !== 'server') throw error;
if (book.format === 'chapters') {
const cached = await this.publicationCache.manifest(book.id);
if (cached) return cached;
}
const cached = await this.publicationCache.manifest(book.id);
if (cached) return cached;
throw error;
}
}
@@ -487,8 +663,9 @@ export class ReaderScreen {
} finally { if (token === this.loadingToken) this.patch({ switching: false }); }
}
private closeAlternatives(): void {
this.qualityRun?.abort(); this.qualityRun = null;
this.alternativeEpoch++; this.alternativesRun?.abort(); this.alternativesRun = null;
this.patch({ alternatives: null, alternativesSearching: false, alternativeChapters: undefined, alternativeChapter: '' });
this.patch({ alternatives: null, alternativeChecks: {}, alternativesSearching: false, alternativeChapters: undefined, alternativeChapter: '', alternativeQuality:null, alternativeQualityError:'', alternativeQualityBusy:false });
}
private async loadAlternatives(_cursor?: string): Promise<void> {
const book = this.book, token = this.loadingToken; if (!book) return;
@@ -534,7 +711,9 @@ export class ReaderScreen {
if (latest === index) break;
index = latest;
}
this.stopSpeech(); this.flushProgress(); this.doc = doc; this.afterDocLoaded();
this.stopSpeech(); await this.flushProgress();
if (token !== this.loadingToken || run.signal.aborted) return;
this.doc = doc; this.afterDocLoaded();
await this.view?.open(index, parsed?.offset ?? 0); this.flashStatus('章节内容已更新');
} catch (error) {
if (!run.signal.aborted && token === this.loadingToken) {
@@ -545,6 +724,7 @@ export class ReaderScreen {
}
private async previewAlternative(ref: string, title: string): Promise<void> {
const book = this.book, token = this.loadingToken; if (!book) return;
this.qualityRun?.abort(); this.qualityRun = null;
const epoch = ++this.alternativeEpoch;
await this.sourceAction(async () => {
const preview = await this.options.api.switchPreview(book.id, ref);
@@ -553,14 +733,34 @@ export class ReaderScreen {
this.alternativeRef = ref;
const label = this.chrome.toc.find(entry => entry.id === this.chrome.currentSectionId)?.label;
const matching = preview.chapters.filter(chapter => chapter.title === label);
this.patch({ alternativeChapters: preview.chapters, alternativeTitle: title, alternativeChapter: matching.length === 1 ? matching[0]!.id : '' });
this.patch({ alternativeChapters: preview.chapters, alternativeTitle: title, alternativeLatest:preview.latestChapter ?? '', alternativeQuality:null, alternativeQualityError:'', alternativeQualityBusy:false, alternativeChapter: matching.length === 1 ? matching[0]!.id : '' });
});
}
private async checkAlternative(): Promise<void> {
const book = this.book, ref = this.alternativeRef, chapter = this.chrome.alternativeChapter;
if (!book || !ref || !chapter) return;
this.qualityRun?.abort(); const run = this.qualityRun = new AbortController();
this.patch({alternativeQuality:null,alternativeQualityError:'',alternativeQualityBusy:true});
try {
const quality = await this.options.api.switchQuality(book.id,ref,chapter,{signal:run.signal});
if (!run.signal.aborted && this.qualityRun === run) this.patch({alternativeQuality:quality,alternativeLatest:quality.latestChapter, alternativeChecks:{...this.chrome.alternativeChecks,[ref]:{quality,chapter:quality.title}}});
} catch (error) {
if (!run.signal.aborted && this.qualityRun === run) {
if (error instanceof ApiError && error.isAuthFailure) this.options.onSignedOut();
else {
const message = error instanceof Error ? error.message : '获取正文失败,请重试';
const title = this.chrome.alternativeChapters?.find(item => item.id === chapter)?.title ?? chapter;
this.patch({alternativeQualityError:message,alternativeChecks:{...this.chrome.alternativeChecks,[ref]:{error:message,chapter:title}}});
}
}
} finally { if (this.qualityRun === run) { this.qualityRun=null; this.patch({alternativeQualityBusy:false}); } }
}
private async switchSource(): Promise<void> {
const book = this.book, previous = this.manifest, token = this.loadingToken;
if (!book || !previous || !this.alternativeRef || !this.chrome.alternativeChapter) return;
await this.sourceAction(async () => {
this.flushProgress();
await this.flushProgress();
if (token !== this.loadingToken) return;
const result = await this.options.api.switchSource(book.id, this.alternativeRef, this.chrome.alternativeChapter!, String(this.content?.revision ?? previous.content?.revision ?? previous.revision));
if (token !== this.loadingToken) return;
const content = result.content, toc = content.items.map(item => ({ id: item.href, label: item.title, depth: 0, spine: item.seq }));
@@ -577,7 +777,7 @@ export class ReaderScreen {
this.closeAlternatives();
this.patch({ toc, alternatives: null, alternativeChapters: undefined, tocOpen: false });
this.afterDocLoaded(); await this.view?.open(index, 0);
this.onPosition(this.view?.position() ?? null); this.flushProgress();
this.onPosition(this.view?.position() ?? null); await this.flushProgress();
this.flashStatus('已切换书源,从所选章节开头继续阅读', 4000);
});
}
@@ -619,7 +819,8 @@ export class ReaderScreen {
const manifest: Manifest = { ...previousManifest, ...content, content };
if (token !== this.loadingToken) return;
this.stopSpeech();
this.flushProgress();
await this.flushProgress();
if (token !== this.loadingToken) return;
this.manifest = manifest;
this.content = content;
this.doc = doc;
@@ -641,17 +842,17 @@ export class ReaderScreen {
}
private async fetchBookBytes(book: Book, manifest: Manifest): Promise<Uint8Array> {
const cacheKey = `book:${book.id}`;
// Offline first, always. A cached book must open with no network at all —
// that is the whole point of the按书粒度 download.
const cached = await this.options.platform.blobs.get(cacheKey);
const cached = await this.publicationCache.resource(book.id, OFFLINE_FILE);
if (cached && cached.byteLength > 0) {
// Refresh in the background only when the file has not been superseded.
// Book bytes are immutable per hash, so this is genuinely optional.
return cached;
}
const bytes = await this.options.api.bookBytes(book.id);
await this.options.platform.blobs.put(cacheKey, bytes);
await this.publicationCache.putResource(book.id, OFFLINE_FILE, bytes).catch(() => undefined);
void manifest;
return bytes;
}
@@ -681,6 +882,7 @@ export class ReaderScreen {
{
fileName,
...(this.settings.txtEncoding ? { encoding: this.settings.txtEncoding } : {}),
...(this.overrides.headingPrefix ? { headingPrefix: this.overrides.headingPrefix } : {}),
},
);
}
@@ -752,6 +954,7 @@ export class ReaderScreen {
...(token ? { accessToken: token } : { accessToken: '' }),
});
this.view = new ReaderView({
transformText: (root, sectionId) => { applyCorrections(root,sectionId,this.overrides); },
container: this.stage,
doc,
...(this.options.pageHost ? { pageHost: this.options.pageHost } : {}),
@@ -925,25 +1128,24 @@ export class ReaderScreen {
}
private async restorePosition(book: Book, token: number): Promise<void> {
const local = await this.options.offline.getProgress(book.id);
let locator = local?.locator ?? '';
// Ask the server only when there is nothing local: the local value is what
// this device last showed, and a server value from another device would
// yank the reader backwards on every open.
if (!locator) {
try {
const remote = await this.options.api.getProgress(book.id);
locator = remote?.locator ?? '';
} catch (err) {
if (err instanceof ApiError && err.isAuthFailure) this.options.onSignedOut();
// Anything else (offline, server error) leaves us on the local value.
}
let latest = await this.options.offline.getProgress(book.id);
try {
const remote = await this.options.api.getProgress(book.id, { signal: AbortSignal.timeout(5000) });
// A newer offline edit wins; a stale device must not reopen its old page
// and immediately publish it as a new reading action.
const local = await this.options.offline.getProgress(book.id);
latest = local ?? latest;
if (remote?.locator && (!latest || remote.updatedAt > latest.updatedAt)) latest = remote;
} catch (err) {
if (err instanceof ApiError && err.isAuthFailure) this.options.onSignedOut();
}
const locator = latest?.locator ?? '';
if (token !== this.loadingToken) return;
if (locator && this.view) {
const restored = await this.view.openLocator(locator);
const match = /^r1:([^:]+):(.+)$/.exec(this.compatibleLocator(locator));
if (match && this.view.indexOfSection(match[2]!) < 0 && this.content) await this.goToChapterRef(match[2]!);
const restored = await this.view.openLocator(this.compatibleLocator(locator));
if (restored) {
this.flashStatus('已恢复到上次阅读位置', 2400);
return;
@@ -980,6 +1182,7 @@ export class ReaderScreen {
*/
private onPosition(position: Position | null): void {
if (!position || !this.book) return;
this.paintNotes();
const title = position.chapterTitle;
const sectionId = position.sectionId;
@@ -1002,7 +1205,7 @@ export class ReaderScreen {
// each one would otherwise be a write to IndexedDB plus a network round trip.
// 1.5s of stillness is the point at which the reader has decided where they are.
if (this.progressTimer) clearTimeout(this.progressTimer);
this.progressTimer = setTimeout(() => this.flushProgress(), 1500);
this.progressTimer = setTimeout(() => { void this.flushProgress().catch(() => undefined); }, 1500);
}
/**
@@ -1019,13 +1222,16 @@ export class ReaderScreen {
* `SyncEngine.schedule()` debounces the push across flushes and guarantees a
* trailing one, so the last page turn of a session still reaches the server.
*/
private flushProgress(): void {
/** Awaited by the shell before background sync; also retains failed writes for retry. */
async flushProgress(): Promise<void> {
if (this.progressTimer) clearTimeout(this.progressTimer);
this.progressTimer = null;
const position = this.pendingPosition;
const book = this.book;
if (!position || !book) return;
if (!position || !book) return this.progressWrite;
this.pendingPosition = null;
const now = Date.now();
void this.options.offline
this.progressWrite = this.options.offline
.setProgress({
bookId: book.id,
locator: position.locator,
@@ -1035,7 +1241,12 @@ export class ReaderScreen {
updatedAt: now,
})
.then(() => this.options.sync.schedule())
.catch(() => undefined);
.catch((error: unknown) => {
if (this.book === book && !this.pendingPosition) this.pendingPosition = position;
this.setStatus('error', '阅读位置保存失败,请重试');
throw error;
});
return this.progressWrite;
}
// ---- chrome ----
@@ -1120,6 +1331,10 @@ export class ReaderScreen {
private async renderToc(): Promise<void> {
const book = this.book;
if (!book) return;
if (!this.content && this.doc) {
this.patch({ toc: this.doc.toc.map(entry => ({ ...entry, spine: this.doc!.sections.findIndex(section => section.id === entry.id.split('#')[0]) })) });
return;
}
// Fetched once per book. The panel is opened and closed constantly and the
// TOC cannot change under a reader, so refetching it is a request per tap on
// the ☰ button — which, on a NAS over a tunnel, is a visible pause before the
@@ -1202,17 +1417,19 @@ export class ReaderScreen {
* without a word. It is derived from the loaded window's own `seq` instead.
*/
private async goToChapterRef(ref: string): Promise<void> {
const fragment = ref.includes('#') ? ref.slice(ref.indexOf('#') + 1) : '';
const sectionRef = this.localSectionId(ref.split('#')[0]!);
const view = this.view;
if (!view || this.navigating || this.chrome.refreshing || this.chrome.switching) return;
const book = this.book;
if (!book) return;
const local = view.indexOfSection(ref);
const local = view.indexOfSection(sectionRef);
// The overwhelmingly common case: the chapter is one the window already
// holds. No network, no window, no second render.
if (local >= 0) {
try { await view.open(local, 0); } catch (error) { this.handleLoadError(error); }
try { await view.open(local, 0); if (fragment) view.revealFragment(fragment); this.patch({ tocOpen: false }); } catch (error) { this.handleLoadError(error); }
return;
}
if (!this.content) return;
@@ -1221,7 +1438,7 @@ export class ReaderScreen {
this.patch({ navigating: true });
this.setStatus('loading', '正在跳到这一章…');
try {
const spine = view.windowIndexOfRef(ref) ?? this.tocSpineFor(ref);
const spine = view.windowIndexOfRef(sectionRef) ?? this.tocSpineFor(ref);
if (spine === null) {
// A chapter the server's windowing cannot address at all — an image
// whose reference names a page rather than a position. Saying so is the
@@ -1231,10 +1448,10 @@ export class ReaderScreen {
return;
}
const group = windowIndexOf(this.content ?? this.windowShape(), spine);
const content = await this.options.api.items(book.id, group);
const content = await this.readWindow(book.id, group);
const landed = await view.loadWindow(content, spine);
if (!landed) this.flashStatus('这一章暂时无法跳转', 2600);
else this.hideStatus();
else { if (fragment) view.revealFragment(fragment); this.patch({ tocOpen: false }); this.hideStatus(); }
} catch {
this.flashStatus('无法跳到这一章', 2600);
this.setStatus('error', '无法跳到这一章');
@@ -1343,7 +1560,7 @@ export class ReaderScreen {
* when the client genuinely does not know.
*/
private get chapterCount(): number {
return this.manifest?.total ?? this.doc?.sections.length ?? 0;
return this.content ? this.manifest?.content?.total ?? this.manifest?.total ?? this.doc?.sections.length ?? 0 : this.doc?.sections.length ?? 0;
}
/**
@@ -1371,7 +1588,7 @@ export class ReaderScreen {
this.patch({ navigating: true });
this.setStatus('loading', '正在切换章节…');
try {
const content = await this.options.api.items(book.id, windowIndexOf(this.content ?? this.windowShape(), spine));
const content = await this.readWindow(book.id, windowIndexOf(this.content ?? this.windowShape(), spine));
const landed = await view.loadWindow(content, spine);
if (landed) {
// The loaded window is a new set of sections; the *shape* does not change
@@ -1683,6 +1900,7 @@ export class ReaderScreen {
private bindSyncStatus(): void {
const update = (): void => {
const status = this.options.sync.status();
if (status.state === 'idle') this.paintNotes();
if (status.state === 'syncing') {
this.setStatus('syncing', '同步中…');
} else if (status.state === 'offline') {
+62
View File
@@ -14,8 +14,10 @@ import { bookPercentage, formatLocator, parseLocator } from './locator.ts';
import { collectSpokenChunks, type SpokenChunk } from '../render/tts-text.ts';
import { adoptWindow } from '../formats/windowed.ts';
import { BOOK_RESOURCE_MARKER, chapterRefFromLink } from '../formats/book-resource.ts';
import { anchorRange, selectedAnchor, type TextAnchor } from './text-anchor.ts';
export interface ReaderViewOptions {
transformText?(root: HTMLElement, sectionId: string): void;
container: HTMLElement;
doc: BookDoc;
/**
@@ -532,6 +534,65 @@ export class ReaderView {
};
}
selectionAnchor(): TextAnchor | null {
const shadow = this.host.shadow as ShadowRoot & { getSelection?: () => Selection | null };
return selectedAnchor(this.host.flow, this.doc.sections[this.sectionIndex]?.id ?? '',
shadow.getSelection?.() ?? this.host.ownerDocument.getSelection());
}
revealFragment(fragment: string): boolean {
let id = fragment;
try { id = decodeURIComponent(fragment); } catch { /* literal id */ }
const element = this.host.shadow.getElementById(id) ?? [...this.host.flow.querySelectorAll('[name]')].find(e => e.getAttribute('name') === id);
if (!element) return false;
const range = document.createRange(); range.selectNodeContents(element);
this.revealRange(range); return true;
}
showTextAnchor(anchor: TextAnchor): boolean {
if (this.doc.sections[this.sectionIndex]?.id !== anchor.sectionId) return false;
const range = anchorRange(this.host.flow, anchor);
if (!range) return false;
this.revealRange(range);
this.paintRange(range, '#f5a623', 'search');
return true;
}
paintAnnotations(anchors: Array<{ anchor: TextAnchor; color: string }>): void {
for (const mark of this.host.flow.querySelectorAll('[data-reader-mark="note"]')) mark.remove();
const sectionId = this.doc.sections[this.sectionIndex]?.id;
for (const { anchor, color } of anchors) {
if (anchor.sectionId !== sectionId) continue;
const range = anchorRange(this.host.flow, anchor);
if (range) this.paintRange(range, /^#[0-9a-f]{6}$/i.test(color) ? color : '#ffd54f', 'note');
}
}
private paintRange(range: Range, color: string, kind: string): void {
if (kind === 'search') for (const node of this.host.flow.querySelectorAll('[data-reader-mark="search"]')) node.remove();
const box = this.host.flow.getBoundingClientRect();
const rects = typeof range.getClientRects === 'function' ? [...range.getClientRects()] : [];
for (const rect of rects) {
const mark = document.createElement('span'); mark.dataset['readerMark'] = kind;
mark.setAttribute('aria-hidden', 'true');
mark.style.cssText = `position:absolute;pointer-events:none;background:${color};opacity:.32;left:${rect.left - box.left + this.host.flow.scrollLeft}px;top:${rect.top - box.top + this.host.flow.scrollTop}px;width:${rect.width}px;height:${rect.height}px;`;
this.host.flow.append(mark);
}
}
private revealRange(range: Range): void {
const rect = rangeRect(range);
if (rect) {
const scroller = this.scroller, box = scroller.getBoundingClientRect();
if (this.settings.mode === 'paged') {
const stride = this.columnStride(this.columnCount());
scroller.scrollLeft = Math.floor((scroller.scrollLeft + rect.left - box.left) / stride) * stride;
}
else scroller.scrollTop += rect.top - box.top - box.height / 3;
}
this.emitPosition();
}
/**
* The page the reader is on, and how many the chapter has.
*
@@ -815,6 +876,7 @@ export class ReaderView {
sanitiseInjectedContent(this.host.shadow, {
...(this.options.signAssetUrl ? { signAssetUrl: this.options.signAssetUrl } : {}),
});
this.options.transformText?.(this.host.flow, section.id);
await hydrateResources(this.host.shadow, this.resolver);
// The page's own geometry is decided here, once the text is in the DOM: the grid
// the chapter is set on is how tall a page may be before its last line is cut, and
+15
View File
@@ -0,0 +1,15 @@
import { anchorRange } from './text-anchor.ts';
import type { ReadingOverrides } from '../api/types.ts';
export type { ReadingOverrides } from '../api/types.ts';
export const emptyOverrides = (): ReadingOverrides => ({ version: 0, corrections: [], headingPrefix: '' });
/** Apply only exact anchored text; inserting text nodes never executes replacement markup. */
export function applyCorrections(root: HTMLElement, sectionId: string, overrides: ReadingOverrides): number {
let applied = 0;
const normal = (id: string) => id.replace(/^xhtml:/, '');
for (const c of overrides.corrections.filter(c => normal(c.anchor.sectionId) === normal(sectionId))) {
const range = anchorRange(root,c.anchor);
if (!range || range.toString() !== c.anchor.quote) continue;
range.deleteContents(); range.insertNode(document.createTextNode(c.replacement)); applied++;
}
return applied;
}
+142
View File
@@ -0,0 +1,142 @@
import type { ReadingOverrides } from './reading-overrides.ts';
import { useEffect, useMemo, useRef, useState } from './vendor/preact.ts';
import { Modal } from './modal.tsx';
import type { ReaderApi } from '../api/client.ts';
import type { Manifest, Note } from '../api/types.ts';
import type { OfflineStore } from '../store/offline.ts';
import type { SyncEngine } from '../core/sync.ts';
import { PublicationCache } from '../store/publications.ts';
import { OfflineDownload, type DownloadState } from '../core/offline-download.ts';
import { decodeAnchor, encodeAnchor, findText, type SearchHit, type TextAnchor } from './text-anchor.ts';
export interface SearchSection { id: string; title: string; text: string }
export interface ReadingToolsProps {
api: ReaderApi; cache: PublicationCache; offline: OfflineStore; sync: SyncEngine; manifest: Manifest;
selection: TextAnchor | null; locator: string; returnLocator: string;
sections(signal: AbortSignal): AsyncGenerator<SearchSection>;
navigate(target: TextAnchor | string): Promise<void>;
overrides: ReadingOverrides;
saveOverrides(value: ReadingOverrides): Promise<void>;
undoOverrides(): Promise<void>;
previewHeadings(prefix: string): Promise<string[]>;
onNotes(): void; onClose(): void;
previewSpeech(): Promise<string>; stopPreview(): void;
}
export function ReadingTools(props: ReadingToolsProps) {
const [tab, setTab] = useState<'search' | 'notes' | 'offline' | 'speech' | 'edit'>('search');
const [query, setQuery] = useState(''), [hits, setHits] = useState<SearchHit[]>([]);
const [scanned, setScanned] = useState(0), [searching, setSearching] = useState(false);
const [message, setMessage] = useState('');
const [replacement, setReplacement] = useState(''), [prefix, setPrefix] = useState(props.overrides.headingPrefix);
const [headingPreview, setHeadingPreview] = useState<string[] | null>(null), [saving, setSaving] = useState(false);
async function edit(work: () => Promise<void>) { if (saving) return; setSaving(true); await action(work); if (alive.current) setSaving(false); }
const [notes, setNotes] = useState<Note[]>(props.offline.notesFor(props.manifest.book.id));
const [comment, setComment] = useState(''), [editing, setEditing] = useState<Note | null>(null);
const [color, setColor] = useState('#ffd54f');
const [from, setFrom] = useState(1), [to, setTo] = useState(['chapters', 'txt'].includes(props.manifest.book.format) ? props.manifest.content?.total ?? 1 : 1);
const [downloadReady, setDownloadReady] = useState(false);
const [download, setDownload] = useState<DownloadState | null>(null);
const [usage, setUsage] = useState(0), [books, setBooks] = useState<Array<{ id: string; bytes: number }>>([]), [quota, setQuota] = useState(256);
const [removeId, setRemoveId] = useState('');
const [returnTo] = useState(props.returnLocator);
const searchRun = useRef<AbortController | null>(null), alive = useRef(true);
const task = useMemo(() => new OfflineDownload(props.api, props.cache, props.manifest, state => { if (alive.current) setDownload(state); }), []);
const bookId = props.manifest.book.id;
const rangedDownload = ['chapters', 'txt'].includes(props.manifest.book.format) && !props.overrides.headingPrefix;
async function storage() {
const entries = await props.cache.entries(), grouped = new Map<string, number>();
for (const entry of entries) grouped.set(entry.bookId, (grouped.get(entry.bookId) ?? 0) + entry.bytes);
if (!alive.current) return;
setUsage(entries.reduce((sum, e) => sum + e.bytes, 0)); setBooks([...grouped].map(([id, bytes]) => ({ id, bytes })));
setQuota((await props.cache.quota()) / 1024 / 1024);
}
async function action(work: () => Promise<unknown>) {
try { await work(); } catch (error) { if (alive.current) setMessage(error instanceof Error ? error.message : '操作失败'); }
}
useEffect(() => {
void action(async () => { await task.load(); if (!alive.current) return; setDownloadReady(true); if (task.state.status !== 'idle') { setFrom(task.state.from); setTo(task.state.to); } await storage(); });
return () => { alive.current = false; searchRun.current?.abort(); if (task.state.status === 'running') void task.pause().catch(() => undefined); props.stopPreview(); };
}, []);
async function search() {
searchRun.current?.abort(); const run = searchRun.current = new AbortController();
setHits([]); setScanned(0); setSearching(true); setMessage('');
const found: SearchHit[] = []; let count = 0;
try {
for await (const section of props.sections(run.signal)) {
run.signal.throwIfAborted();
found.push(...findText(section.id, section.title, section.text, query.trim(), 200 - found.length));
if (alive.current) { setHits([...found]); setScanned(++count); }
if (found.length >= 200) { setMessage('已显示前 200 个命中,请缩小关键词范围'); break; }
}
} catch (error) {
if (!run.signal.aborted && alive.current) setMessage(error instanceof Error ? error.message : '搜索失败,已有结果保留');
} finally { if (searchRun.current === run && alive.current) setSearching(false); }
}
async function saveNote(bookmark = false) {
const selected = props.selection;
if (!bookmark && !editing && !selected) throw new Error('先在正文选择文字,再打开阅读工具');
const note: Note = editing && !bookmark ? { ...editing, type: editing.type === 'bookmark' ? 'bookmark' : comment.trim() ? 'note' : 'highlight', comment, color, updatedAt: Date.now() } : {
id: crypto.randomUUID(), bookId, type: bookmark ? 'bookmark' : comment.trim() ? 'note' : 'highlight',
locator: bookmark ? props.locator : encodeAnchor(selected!), text: bookmark ? props.manifest.book.title : selected!.quote,
comment, color, updatedAt: Date.now(),
};
await props.offline.upsertNotes([note]); props.sync.schedule();
setNotes(props.offline.notesFor(bookId)); setComment(''); setEditing(null); props.onNotes(); setMessage('已保存,联网后同步');
}
return <Modal title="阅读工具" busy={saving} onClose={props.onClose}>
<div className="reading-tools source-modal-content">
<nav className="reading-tool-tabs" aria-label="阅读工具分类">{([
['search', '书内搜索'], ['notes', '笔记'], ['offline', '离线缓存'], ['speech', '朗读检测'], ['edit', '内容整理'],
] as const).map(([id, label]) => <button className="button" type="button" aria-pressed={tab === id} onClick={() => { setTab(id); setMessage(''); }}>{label}</button>)}</nav>
{message && <p role="status">{message}</p>}
{tab === 'search' && <section aria-label="书内搜索">
<form onSubmit={event => { event.preventDefault(); if (query.trim()) void search(); }}><label>关键词<input value={query} maxLength={200} onInput={event => setQuery(event.currentTarget.value)} /></label><button className="button" disabled={searching || !query.trim()}>搜索全文</button></form>
{searching && <button className="button" onClick={() => { searchRun.current?.abort(); setSearching(false); }}>停止搜索</button>}
<p role="status">已扫描 {scanned} 章,找到 {hits.length} 处</p>
<button className="button" disabled={!returnTo} onClick={() => void action(async () => { await props.navigate(returnTo); props.onClose(); })}>返回原阅读位置</button>
<ol className="reading-tool-results">{hits.map((hit, index) => <li key={index}><button className="button" onClick={() => void action(async () => { await props.navigate(hit.anchor); props.onClose(); })}><strong>{hit.title}</strong><span>{hit.excerpt}</span></button></li>)}</ol>
</section>}
{tab === 'notes' && <section aria-label="笔记管理">
<blockquote>{editing?.text ?? props.selection?.quote ?? '在正文选择文字可高亮或批注;也可保存当前位置书签。'}</blockquote>
<label>批注<textarea value={comment} maxLength={4000} onInput={event => setComment(event.currentTarget.value)} /></label>
<label>高亮颜色<select value={color} onChange={event => setColor(event.currentTarget.value)}><option value="#ffd54f">黄色</option><option value="#80cbc4">绿色</option><option value="#ce93d8">紫色</option></select></label>
<button className="button" disabled={!editing && !props.selection} onClick={() => void action(() => saveNote())}>{editing ? '保存修改' : '保存高亮或批注'}</button>
<button className="button" disabled={!props.locator} onClick={() => void action(() => saveNote(true))}>添加当前位置书签</button>
{editing && <button className="button" onClick={() => { setEditing(null); setComment(''); }}>取消编辑</button>}
<ul className="reading-tool-results">{notes.map(note => <li key={note.id}><button className="button" onClick={() => void action(async () => { await props.navigate(decodeAnchor(note.locator) ?? note.locator); props.onClose(); })}>{note.text || '书签'}{note.comment && <span>{note.comment}</span>}</button>
<button className="button" onClick={() => { setEditing(note); setComment(note.comment); setColor(note.color || '#ffd54f'); }}>编辑</button><button className="button" onClick={() => void action(async () => { await props.offline.deleteNote(note.id); props.sync.schedule(); setNotes(props.offline.notesFor(bookId)); props.onNotes(); })}>删除笔记</button></li>)}</ul>
{!notes.length && <p>暂无笔记</p>}
</section>}
{tab === 'offline' && <section aria-label="离线缓存管理">
<p>缓存保存在当前设备、当前账号。关闭此面板会暂停任务,已下载内容保留。</p>
{rangedDownload && <div className="reading-tool-range"><label>起始章<input type="number" min={1} max={props.manifest.content?.total} value={from} onInput={e => setFrom(Number(e.currentTarget.value))} /></label><label>结束章<input type="number" min={from} max={props.manifest.content?.total} value={to} onInput={e => setTo(Number(e.currentTarget.value))} /></label></div>}
<button className="button" disabled={!downloadReady || download?.status === 'running'} onClick={() => void action(async () => { await task.start(rangedDownload ? from : 1, rangedDownload ? to : 1); await storage(); })}>下载 / 继续</button>
<button className="button" disabled={download?.status !== 'running'} onClick={() => void action(() => task.pause())}>暂停</button>
<button className="button" disabled={download?.status !== 'running'} onClick={() => void action(() => task.pause(true))}>取消任务</button>
{download && <p role="status">{download.message} · {download.completed}/{download.total}</p>}
<p>当前账号缓存占用 {(usage / 1024 / 1024).toFixed(1)} MiB</p>
<label>设备账号配额(MiB)<input type="number" min={1} max={10240} value={quota} onInput={e => setQuota(Number(e.currentTarget.value))} /></label>
<button className="button" onClick={() => void action(async () => { await props.cache.setQuota(quota * 1024 * 1024); setMessage('配额已保存'); })}>保存配额</button>
<ul>{books.map(book => <li key={book.id}>{props.offline.current.books[book.id]?.title ?? (book.id === bookId ? props.manifest.book.title : book.id)} · {(book.bytes / 1024 / 1024).toFixed(1)} MiB <button className="button" disabled={download?.status === 'running'} onClick={() => setRemoveId(book.id)}>清理缓存</button></li>)}</ul>
{removeId && <div role="group" aria-label="确认清理缓存"><p>只删除此设备的书籍缓存,保留原书、进度和笔记。</p><button className="button" onClick={() => void action(async () => { await props.cache.removeBook(removeId); setRemoveId(''); setDownload(null); await storage(); })}>确认清理</button><button className="button" onClick={() => setRemoveId('')}>保留缓存</button></div>}
</section>}
{tab === 'edit' && <section aria-label="内容整理">
<p>个人整理规则保存在服务器,联网时保存;原书保持不变。清空替换内容可过滤选中的文字。</p>
<blockquote>{props.selection?.quote ?? '请先在正文选择需要纠正的文字'}</blockquote>
<label>替换为<textarea maxLength={4000} value={replacement} onInput={e => setReplacement(e.currentTarget.value)} /></label>
<button className="button" disabled={saving || !props.selection || props.selection.quote.length > 4000} onClick={() => void edit(async () => { await props.saveOverrides({ ...props.overrides, corrections: [...props.overrides.corrections, {id: crypto.randomUUID(),anchor:props.selection!,replacement}] }); setMessage('纠错已保存,正文已更新'); })}>保存纠错 / 过滤</button>
<ul>{props.overrides.corrections.map(c => <li key={c.id}>{c.anchor.quote.slice(0,60)} → {c.replacement.slice(0,60) || '(过滤)'} <button className="button" disabled={saving} onClick={() => void edit(() => props.saveOverrides({ ...props.overrides,corrections:props.overrides.corrections.filter(item => item.id !== c.id) }))}>移除此项</button></li>)}</ul>
<button className="button" disabled={saving || props.overrides.version === 0} onClick={() => void edit(async () => { await props.undoOverrides(); setMessage('已撤销上一次整理修改'); })}>撤销上一次整理修改</button>
{props.manifest.book.format === 'txt' && <>
<h3>TXT 目录规则</h3><p>匹配以指定文字开头、长度不超过 80 字的行;留空恢复自动识别。应用会重新划分章节并从首章打开,旧笔记仍保留。</p>
<label>常用模板<select value="" onChange={e => { setPrefix(e.currentTarget.value); setHeadingPreview(null); }}><option value="">自动识别</option><option value="第">中文章回</option><option value="Chapter ">英文 Chapter</option><option value="【">方括号标题</option></select></label>
<label>标题开头<input maxLength={60} value={prefix} onInput={e => { setPrefix(e.currentTarget.value); setHeadingPreview(null); }} /></label>
<button className="button" disabled={saving} onClick={() => void edit(async () => setHeadingPreview(await props.previewHeadings(prefix)))}>预览目录</button>
{headingPreview && <><p>识别 {headingPreview.length} 个章节,展示前 100 项</p><ol>{headingPreview.slice(0,100).map((title,i) => <li key={i}>{title}</li>)}</ol><button className="button" disabled={saving} onClick={() => void edit(async () => { await props.saveOverrides({ ...props.overrides, headingPrefix:prefix }); setMessage('目录规则已保存'); })}>应用预览规则</button></>}
</>}
</section>}
{tab === 'speech' && <section aria-label="朗读检测"><p>使用当前朗读设置试听。HTTP 引擎会请求服务端合成试听音频。</p><button className="button" onClick={() => void action(async () => setMessage(await props.previewSpeech()))}>检测并试听</button><button className="button" onClick={props.stopPreview}>停止试听</button><p>若浏览器限制播放,请点击试听后允许音频;可在朗读设置切换系统或 HTTP 引擎。</p></section>}
</div>
</Modal>;
}
+17
View File
@@ -0,0 +1,17 @@
import type { SourceType } from '../api/sources.ts';
const capabilities: Record<string, string> = {
browse: '浏览目录', search: '搜索', 'search.filters': '搜索筛选', 'search.cancel': '取消搜索',
'search.session': '继续搜索', 'content.alternatives': '换源', detail: '书籍详情',
'acquire.file': '获取文件', 'acquire.chapters': '获取章节', 'content.manifest': '章节目录',
'content.resource': '读取正文', 'content.update': '更新目录',
};
export function SourceCapabilities({ type }: { type: SourceType }) {
return <details className="source-capabilities"><summary>插件能力与兼容性</summary>
<p>{type.label} · {type.version}</p>
<dl>{Object.entries(capabilities).map(([key, label]) => <div key={key}><dt>{label}</dt><dd>{type.capabilities.includes(key) ? '已声明' : '未声明'}</dd></div>)}</dl>
<p className="muted">能力由插件声明,实际可用性取决于站点、账号权限及插件适配。网站登录、验证码与付费内容由站点和插件处理。</p>
<p className="muted">OPDS 来源用于连接外部书库;通过“连接外部阅读器”可创建 Reader OPDS 服务端的只读凭据。Legado 规则需要专用适配插件,不能直接作为 Reader 插件导入。</p>
</details>;
}
+30 -4
View File
@@ -1,3 +1,6 @@
import { SourceCapabilities } from './source-capabilities.tsx';
import { OpdsAccess } from './opds-access.tsx';
import type { CredentialStatus } from '../api/sources.ts';
import { groupSourceResults } from './source-results.ts';
import type { ExtensionField } from '../api/sources.ts';
import { ApiError, type ReaderApi } from '../api/client.ts';
@@ -38,6 +41,8 @@ export class SourcesScreen {
private subscriptions: ChapterSubscription[] = [];
private tab: SourceTab = 'search';
private credentialsOpen = false;
private opdsOpen = false;
private credentialStatus: CredentialStatus | null = null;
private managing: string | null = null;
private editor: Editor | null = null;
private selected: SourceInstance | null = null;
@@ -95,6 +100,15 @@ export class SourcesScreen {
if (preferred) await this.activateSource(preferred);
}
}
private async openCredentials(): Promise<void> {
this.credentialsOpen = true; this.credentialValues = {}; this.credentialStatus = null;
this.draw();
await this.run(async () => {
const source = this.selected!;
const status = await this.options.api.credentialStatus(source.id);
if (!this.disposed && this.selected?.id === source.id && this.credentialsOpen) this.credentialStatus = status;
});
}
private async installPlugin(upload: boolean): Promise<void> {
if (!this.trusted || (upload && !this.pluginFile)) return;
await this.run(async () => {
@@ -287,6 +301,7 @@ export class SourcesScreen {
</section>
</>}
{this.tab === 'search' && <>
<section className="sources-card"><Button disabled={this.busy} onClick={() => { this.opdsOpen = true; this.draw(); }}>连接外部阅读器</Button></section>
<section className="sources-card source-picker"><div><h2>搜书</h2><p className="muted">选择来源,发现想读的书。</p></div>
<label>选择来源<select aria-label="选择来源" disabled={this.busy} value={this.selected?.id ?? ''} onChange={event => {
const source = this.sources.find(source => source.id === event.currentTarget.value); if (source) this.select(source);
@@ -294,7 +309,8 @@ export class SourcesScreen {
{!this.selected && <p className="muted">{this.sources.some(source => source.enabled && source.descriptor) ? '选择来源后,即可搜索书籍或浏览目录。' : '暂无可用来源,请先添加或启用来源。'}</p>}
</section>
{this.selected && <section className="sources-card source-catalog"><h2>搜索与浏览</h2>
{(this.selected.descriptor?.credentialKeys?.length ?? 0) > 0 && <Button disabled={this.busy} onClick={() => { this.credentialsOpen = true; this.message = ''; this.draw(); }}>登录凭据</Button>}
{this.selected.descriptor && <SourceCapabilities type={this.selected.descriptor} />}
{(this.selected.descriptor?.credentialKeys?.length ?? 0) > 0 && <Button disabled={this.busy} onClick={() => void this.openCredentials()}>登录凭据</Button>}
{this.selected.descriptor?.capabilities.includes('search') && <form className="sources-search" onSubmit={(event) => { event.preventDefault(); void this.search(resume); }}>
{this.filters.map(field => <label key={field.key}>{field.label}<select aria-label={field.label} value={this.filterValues[field.key] ?? ''} disabled={this.busy}
onChange={event => { this.filterValues[field.key] = event.currentTarget.value; this.draw(); }}>
@@ -399,6 +415,7 @@ export class SourcesScreen {
</article>)}
</section>}
</main>
{this.opdsOpen && <OpdsAccess api={this.options.api} onSignedOut={this.options.onSignedOut} onClose={() => { this.opdsOpen = false; this.draw(); }} />}
{activeGroup && <Modal title="书源列表" busy={this.working} onClose={() => { this.resultGroup = null; this.draw(); }}>
<div className="source-modal-content catalog-source-list">
<header className="catalog-source-heading"><h3>{activeGroup.entry.title}</h3><p>{activeGroup.entry.authors?.join(' / ') || '作者未知'}</p><small role="status">{activeGroup.entries.length} 条书源{this.searchRun ? ' · 搜索中,列表持续更新' : ''}</small></header>
@@ -452,9 +469,18 @@ export class SourcesScreen {
if (this.disposed) return;
if (field.key in this.credentialValues) await this.options.api.sourceCredential(source.id, field.key, this.credentialValues[field.key]!);
}
this.credentialValues = {}; this.credentialsOpen = false; this.message = '个人凭据已保存,不会回显。';
}); }}><div className="source-modal-content">{this.selected.descriptor?.credentialKeys?.map((field) => <label key={field.key}>{field.label}
<input autoFocus disabled={this.busy} type="password" autoComplete="off" value={this.credentialValues[field.key] ?? ''} onInput={(event) => { this.credentialValues[field.key] = event.currentTarget.value; }} /></label>)}
this.credentialValues = {}; this.credentialsOpen = false; this.message = '个人凭据已保存,不会回显。修改过的凭据需重新搜索或浏览检查访问。';
}); }}><div className="source-modal-content">
<p>未编辑的字段会保留;编辑后留空并保存会删除该项。保存凭据不代表登录有效。</p>
{this.credentialStatus ? <div role="status">
<p>{({ unknown: '尚未验证访问', reachable: '最近一次访问成功', 'auth-required': '需要登录或凭据已过期', 'verification-required': '需要人工验证' })[this.credentialStatus.state]} · {date(this.credentialStatus.checkedAt)}</p>
{!this.credentialStatus.available && <p>来源当前不可用,请联系管理员检查插件。</p>}
{this.credentialStatus.state === 'verification-required' && <p>请在站点或插件提供的管理页面完成验证,再重试。Reader 不会绕过验证码。</p>}
<p className="muted">这是当前账号最近一次请求的结果,不保证会话仍有效;站点未提供的过期时间无法判断。</p>
</div> : <p>尚未取得访问状态,可重试打开凭据页。</p>}
{this.selected.descriptor?.credentialKeys?.map((field) => <label key={field.key}>{field.label} · {this.credentialStatus?.fields.find(item => item.key === field.key)?.configured ? '已配置' : this.credentialStatus ? '未配置' : '状态未知'}
<input aria-label={field.label} autoFocus disabled={this.busy} type="password" autoComplete="off" value={this.credentialValues[field.key] ?? ''} onInput={(event) => { this.credentialValues[field.key] = event.currentTarget.value; }} />
<Button disabled={this.busy} onClick={() => { this.credentialValues[field.key] = ''; this.draw(); }}>清空{field.label}(保存后删除)</Button></label>)}
</div><footer className="source-modal-actions"><Button className="primary" type="submit" disabled={this.busy}>保存个人凭据</Button></footer></form></Modal>}
</>;
+83
View File
@@ -0,0 +1,83 @@
/** Text offsets plus an exact quote keep search and annotations independent of pagination. */
export interface TextAnchor { sectionId: string; start: number; end: number; quote: string; prefix: string; suffix: string }
export function textNodes(root: Node): Text[] {
const walker = root.ownerDocument!.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
const parent = node.parentElement;
if (parent?.closest('script,style,noscript,[data-reader-mark]')) continue;
nodes.push(node as Text);
}
return nodes;
}
export function anchorFor(sectionId: string, text: string, start: number, end: number): TextAnchor {
return { sectionId, start, end, quote: text.slice(start, end), prefix: text.slice(Math.max(0, start - 32), start), suffix: text.slice(end, end + 32) };
}
export function encodeAnchor(anchor: TextAnchor): string { return 'rt1:' + JSON.stringify(anchor); }
export function decodeAnchor(locator: string): TextAnchor | null {
if (!locator.startsWith('rt1:')) return null;
try {
const a = JSON.parse(locator.slice(4)) as TextAnchor;
return typeof a.sectionId === 'string' && typeof a.quote === 'string' && a.quote.length > 0 &&
typeof a.prefix === 'string' && typeof a.suffix === 'string' && Number.isSafeInteger(a.start) &&
Number.isSafeInteger(a.end) && a.start >= 0 && a.end > a.start ? a : null;
} catch { return null; }
}
export function anchorRange(root: Node, anchor: TextAnchor): Range | null {
const nodes = textNodes(root), text = nodes.map(n => n.data).join('');
let start = anchor.start;
if (text.slice(start, start + anchor.quote.length) !== anchor.quote) {
const context = anchor.prefix + anchor.quote + anchor.suffix;
const exact = text.indexOf(context);
if (exact >= 0 && text.indexOf(context, exact + 1) < 0) start = exact + anchor.prefix.length;
else {
start = text.indexOf(anchor.quote);
// Ambiguous matches must not silently move a note onto another paragraph.
if (start < 0 || text.indexOf(anchor.quote, start + 1) >= 0) return null;
}
}
const range = root.ownerDocument!.createRange();
const end = start + anchor.quote.length;
let offset = 0, started = false;
for (const node of nodes) {
const next = offset + node.length;
if (!started && start < next) { range.setStart(node, start - offset); started = true; }
if (started && end <= next) { range.setEnd(node, end - offset); return range; }
offset = next;
}
return null;
}
export function selectedAnchor(root: Node, sectionId: string, selection: Selection | null): TextAnchor | null {
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;
const prefix = range.cloneRange(); prefix.selectNodeContents(root); prefix.setEnd(range.startContainer, range.startOffset);
const text = textNodes(root).map(n => n.data).join('');
// cloneContents excludes styles via the same traversal used by search.
const start = textNodes(prefix.cloneContents()).map(n => n.data).join('').length;
const quote = textNodes(range.cloneContents()).map(n => n.data).join('');
return quote ? anchorFor(sectionId, text, start, start + quote.length) : null;
}
export function searchableText(html: string, markup = true): string {
if (!markup) return html;
const doc = new DOMParser().parseFromString(html, 'text/html');
return textNodes(doc.body).map(n => n.data).join('');
}
export interface SearchHit { title: string; excerpt: string; anchor: TextAnchor }
export function findText(sectionId: string, title: string, text: string, query: string, limit = 200): SearchHit[] {
const result: SearchHit[] = [];
if (!query.trim()) return result;
let start = text.indexOf(query);
while (start >= 0 && result.length < limit) {
result.push({ title, excerpt: text.slice(Math.max(0, start - 35), start + query.length + 65), anchor: anchorFor(sectionId, text, start, start + query.length) });
start = text.indexOf(query, start + Math.max(1, query.length));
}
return result;
}
+3 -3
View File
@@ -34,10 +34,10 @@ async function setup() {
}
describe('requests across an account change', () => {
it('keeps the reader signed in when an OPDS source requires separate credentials', async () => {
it.each(['AUTH_REQUIRED', 'AUTH_EXPIRED', 'VERIFICATION_REQUIRED'])('keeps the Reader session for source error %s', async code => {
const env = await setup();
env.transport.respondWith(() => ({ status: 401, headers: {}, json: { error: { code: 'AUTH_REQUIRED', message: 'OPDS password required' } } }));
await expect(env.api.sourceCatalog('private')).rejects.toMatchObject({ code: 'AUTH_REQUIRED', isAuthFailure: false });
env.transport.respondWith(() => ({ status: 401, headers: {}, json: { error: { code, message: '站点需要验证' } } }));
await expect(env.api.sourceCatalog('private')).rejects.toMatchObject({ code, isAuthFailure: false });
expect(env.api.currentSession()?.user.id).toBe('a');
expect(env.transport.requests).toHaveLength(1);
});
+94 -1
View File
@@ -9,6 +9,7 @@ import { OfflineStore } from '../src/store/offline.ts';
import { PublicationCache, publicationScope } from '../src/store/publications.ts';
import { DEFAULT_APP_SETTINGS } from '../src/store/settings.ts';
import { ReaderScreen } from '../src/ui/reader-screen.tsx';
import { App } from '../src/app.ts';
import type { SyncEngine } from '../src/core/sync.ts';
import { FakeTransport, MemoryBlobs, MemoryKv, makePlatform } from './helpers/env.ts';
@@ -105,6 +106,62 @@ async function click(screen: ReaderScreen, label: string): Promise<void> {
}
describe('chapter publication reading', () => {
it('does not save a temporary opening position while waiting for remote progress', async () => {
const env = await setup(); serve(env.transport, content(['a', 'b']));
const old = { bookId: book.id, locator: 'r1:0.0000:chapter:b', percentage: 0.5,
chapterTitle: 'b', device: 'phone', updatedAt: 10 };
await env.offline.setProgress(old);
let finish!: (value: typeof old) => void;
vi.spyOn(env.api, 'getProgress').mockImplementation(() => new Promise(resolve => { finish = resolve; }));
const opening = env.screen.open(book);
await vi.waitFor(() => expect(finish).toBeDefined());
await new Promise(resolve => setTimeout(resolve, 1700));
const duringOpen = env.offline.progressFor(book.id);
finish({ ...old, updatedAt: 20 }); await opening;
expect(duringOpen).toEqual(old);
});
it.each([{ localTime: 10, chapter: 'b' }, { localTime: 30, chapter: 'a' }])(
'opens the latest position when the local timestamp is $localTime', async ({ localTime, chapter }) => {
const env = await setup();
await env.offline.setProgress({ bookId: book.id, locator: 'r1:0.0000:chapter:a', percentage: 0,
chapterTitle: 'a', device: 'phone', updatedAt: localTime });
env.transport.respondWith(request => {
if (request.url.endsWith('/manifest')) return { status: 200, headers: {}, json: manifest(content(['a', 'b'])) };
if (request.url.includes('/assets?')) return { status: 200, headers: {}, bytes: new TextEncoder().encode(new URL(request.url, 'http://one.test').searchParams.get('ref')!) };
return { status: 200, headers: {}, json: { progress: { bookId: book.id, locator: 'r1:0.0000:chapter:b', percentage: 0.5,
chapterTitle: 'b', device: 'new phone', updatedAt: 20 } } };
});
await env.screen.open(book);
expect(body(env.screen)?.textContent).toContain(`resource:r1:${chapter}`);
});
it('flushes the visible chapter before app background sync, without waiting for the reading debounce', async () => {
const env = await setup();
serve(env.transport, content(['a', 'b']));
await env.screen.open(book);
// Exercise the actual App lifecycle flush and actual ReaderScreen/OfflineStore.
let savedLocator: string | undefined;
const sync = { flush: vi.fn(async () => {
const persisted = new OfflineStore(env.platform.kv);
await persisted.setScope(publicationScope(env.api.baseUrl, 'u1'));
savedLocator = persisted.current.progress[book.id]?.locator;
}) };
await App.prototype.flush.call({ reader: env.screen, offline: env.offline, sync } as unknown as App);
expect(sync.flush).toHaveBeenCalledOnce();
expect(savedLocator).toContain('chapter:a');
expect(env.offline.current.progress[book.id]?.locator).toContain('chapter:a');
});
it('keeps the local reading position when the progress service is unavailable', async () => {
const env = await setup(); serve(env.transport, content(['a', 'b']));
await env.offline.setProgress({ bookId: book.id, locator: 'r1:0.0000:chapter:b', percentage: 0.5,
chapterTitle: 'b', device: 'phone', updatedAt: 10 });
vi.spyOn(env.api, 'getProgress').mockRejectedValue(new ApiError('offline', 'offline'));
await env.screen.open(book);
expect(body(env.screen)?.textContent).toContain('resource:r1:b');
});
it('switches from the contents panel only after chapter selection and preserves the old view on failure', async () => {
const env = await setup(); let failSwitch = true;
env.transport.respondWith(request => {
@@ -124,7 +181,26 @@ describe('chapter publication reading', () => {
const submit = [...env.screen.element.querySelectorAll('button')].find(button => button.textContent === '确认换源并阅读')!;
expect(submit.disabled).toBe(true);
const select = env.screen.element.querySelector<HTMLSelectElement>('section[aria-label="切换书源"] select')!;
select.value = 'x'; select.dispatchEvent(new Event('change', { bubbles: true })); await click(env.screen, '确认换源并阅读');
select.value = 'x'; select.dispatchEvent(new Event('change', { bubbles: true }));
const quality = vi.spyOn(env.api, 'switchQuality');
quality.mockRejectedValueOnce(new ApiError('bad_request', '空章不能读取', 'EMPTY_CHAPTER', 400));
await click(env.screen, '检测所选章节 / 重试');
await vi.waitFor(() => expect(env.screen.element.textContent).toContain('空章不能读取'));
quality.mockResolvedValueOnce({ chapterId: 'x', title: '另一个章节', latestChapter: '末章', characters: 100, images: 0, bytes: 300, elapsedMs: 10 });
await click(env.screen, '检测所选章节 / 重试');
await vi.waitFor(() => expect(env.screen.element.textContent).toContain('100 字符'));
expect(body(env.screen)?.textContent).toContain('resource:r1:a');
select.dispatchEvent(new Event('change', { bubbles: true }));
expect(env.screen.element.querySelector('dialog')?.textContent).not.toContain('100 字符');
let finish!: (value: {chapterId:string;title:string;latestChapter:string;characters:number;images:number;bytes:number;elapsedMs:number}) => void;
quality.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await click(env.screen, '检测所选章节 / 重试');
select.dispatchEvent(new Event('change', { bubbles: true }));
expect(quality.mock.calls.at(-1)?.[3]?.signal?.aborted).toBe(true);
finish({chapterId:'x',title:'迟到结果',latestChapter:'末章',characters:999,images:0,bytes:999,elapsedMs:1});
await new Promise(resolve => setTimeout(resolve, 0));
expect(env.screen.element.querySelector('dialog')?.textContent).not.toContain('999 字符');
await click(env.screen, '确认换源并阅读');
await vi.waitFor(() => expect(noticeText()).toContain('正文获取失败'));
expect(body(env.screen)?.textContent).toContain('resource:r1:a');
expect(env.screen.element.querySelector('section[aria-label="切换书源"]')?.textContent).toContain('最新章节');
@@ -311,3 +387,20 @@ describe('legacy offline migration', () => {
expect(reopened.current.progress).toEqual({});
});
});
it('opens tools, searches the whole book, navigates and returns, and persists a bookmark', async () => {
const env = await setup(); serve(env.transport,content(['a','b'])); await env.screen.open(book);
await click(env.screen,'工具'); await vi.waitFor(()=>expect(env.screen.element.textContent).toContain('搜索全文'));
const input = env.screen.element.querySelector<HTMLInputElement>('input[maxlength="200"]')!;
input.value = '正文'; input.dispatchEvent(new Event('input',{bubbles:true}));
await vi.waitFor(()=>expect(env.screen.element.querySelector<HTMLButtonElement>('form button')?.disabled).toBe(false));
await click(env.screen,'搜索全文');
await vi.waitFor(()=>expect(env.screen.element.querySelectorAll('.reading-tool-results li')).toHaveLength(2));
const results = env.screen.element.querySelectorAll<HTMLButtonElement>('.reading-tool-results li button'); results[1]!.click();
await vi.waitFor(()=>expect(body(env.screen)?.textContent).toContain('resource:r1:b'));
await click(env.screen,'工具'); await click(env.screen,'返回原阅读位置');
await vi.waitFor(()=>expect(body(env.screen)?.textContent).toContain('resource:r1:a'));
await click(env.screen,'工具'); await click(env.screen,'笔记'); await click(env.screen,'添加当前位置书签');
await vi.waitFor(()=>expect(env.offline.notesFor(book.id)).toHaveLength(1));
expect(env.offline.notesFor(book.id)[0]?.type).toBe('bookmark');
});
+101
View File
@@ -0,0 +1,101 @@
import { applyCorrections } from '../src/ui/reading-overrides.ts';
import { loadTxt } from '../src/formats/txt.ts';
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { Blob as NodeBlob } from 'node:buffer';
import { parseNavigation } from '../src/formats/navigation.ts';
import { anchorFor, anchorRange, selectedAnchor, encodeAnchor, decodeAnchor, findText, searchableText } from '../src/ui/text-anchor.ts';
import { PublicationCache } from '../src/store/publications.ts';
import { OfflineDownload } from '../src/core/offline-download.ts';
import { MemoryKv, MemoryBlobs } from './helpers/env.ts';
import type { Manifest } from '../src/api/types.ts';
import type { ReaderApi } from '../src/api/client.ts';
describe('navigation and text anchors', () => {
it('preserves three levels, fragments, entities, and TOC navigation selection', () => {
expect(parseNavigation('<html><nav><a href="cover">封面</a></nav><nav epub:type="toc"><ol><li><a href="a#top">一 &amp; 二</a><ol><li><a href="a#middle">第二层</a><ol><li><a href="b#end">第三层</a></li></ol></li></ol></li><li><a href="c">尾章</a></li></ol></nav></html>', 'nav')).toEqual([
{ href: 'a#top', title: '一 & 二', depth: 0 }, { href: 'a#middle', title: '第二层', depth: 1 }, { href: 'b#end', title: '第三层', depth: 2 }, { href: 'c', title: '尾章', depth: 0 },
]);
expect(parseNavigation('<ncx><navMap><navPoint><navLabel><text>父</text></navLabel><content src="a#x"/><navPoint><navLabel><text>子</text></navLabel><content src="b#y"/></navPoint></navPoint></navMap></ncx>', 'ncx').map(n => n.depth)).toEqual([0,1]);
});
it('locates different occurrences across text nodes and rejects ambiguous moved text', () => {
const root = document.createElement('div'); root.innerHTML = '<p>开头词语<b>结尾</b></p><p>后面词语结束</p>';
const hits = findText('s', '章', searchableText(root.innerHTML), '词语');
expect(hits).toHaveLength(2);
const first = anchorRange(root, hits[0]!.anchor)!, second = anchorRange(root, hits[1]!.anchor)!;
expect(first.startContainer).not.toBe(second.startContainer);
expect(decodeAnchor(encodeAnchor(hits[0]!.anchor))).toEqual(hits[0]!.anchor);
expect(anchorRange(root, { ...hits[0]!.anchor, start: 90, prefix: '不存在', suffix: '不存在' })).toBeNull();
const spanning = anchorFor('s', searchableText(root.innerHTML), 2, 6);
expect(anchorRange(root, spanning)?.toString()).toBe('词语结尾');
});
it('captures a browser selection and relocates a unique quote after edits', () => {
const root = document.createElement('div'); root.innerHTML = '<p>前文<b>选中文字</b>后文</p>'; document.body.append(root);
const range = document.createRange(); range.selectNodeContents(root.querySelector('b')!);
const selection = document.getSelection()!; selection.removeAllRanges(); selection.addRange(range);
const anchor = selectedAnchor(root, 's', selection)!; expect(anchor.quote).toBe('选中文字');
root.prepend(document.createTextNode('新内容')); expect(anchorRange(root, anchor)?.toString()).toBe('选中文字'); root.remove();
});
});
describe('scoped quota and offline tasks', () => {
it('serializes quota checks, isolates accounts, and rolls back failed metadata writes', async () => {
const kv = new MemoryKv(), blobs = new MemoryBlobs(); const cache = new PublicationCache(kv, blobs, 'one');
await cache.setQuota(1024 * 1024);
const results = await Promise.allSettled([cache.putResource('a','x',new Uint8Array(700000)),cache.putResource('b','y',new Uint8Array(700000))]);
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1);
expect(await new PublicationCache(kv,blobs,'two').resource('a','x')).toBeNull();
const write = vi.spyOn(kv,'set').mockRejectedValueOnce(new Error('disk full'));
await expect(cache.putResource('a','x',new Uint8Array([1]))).rejects.toThrow('disk full'); write.mockRestore();
expect((await cache.resource('a','x'))?.length).toBe(700000);
await cache.removeBook('a'); expect(await cache.entries()).toEqual([]);
});
it('downloads selected windows and resumes without downloading completed resources', async () => {
const cache = new PublicationCache(new MemoryKv(), new MemoryBlobs(),'scope');
const item = (seq: number) => ({ id: String(seq), seq, title: String(seq), href: 'chapter:' + seq, resourceRef: 'r:' + seq, kind: 'chapter', format: 'html', mediaType: 'text/plain' });
const content = { revision: 'one', kind: 'text', total: 3, groups: [{ id:'a',seq:0,offset:0,count:1,title:'a' },{ id:'b',seq:1,offset:1,count:2,title:'b' }], items:[item(0)] };
const manifest = { book:{ id:'b', format:'txt' }, content } as unknown as Manifest;
const asset = vi.fn(async (_id: string, ref: string) => new NodeBlob([ref]));
const api = { asset, items: vi.fn(async () => ({ ...content, group: 1, items:[item(1),item(2)] })) } as unknown as ReaderApi;
const task = new OfflineDownload(api,cache,manifest,()=>{}); await task.start(2,3);
expect(task.state.status).toBe('complete'); expect(asset).toHaveBeenCalledTimes(2);
expect((await cache.window('b',1))?.items).toHaveLength(2);
const resumed = new OfflineDownload(api,cache,manifest,()=>{}); await resumed.load(); await resumed.start(2,3);
expect(asset).toHaveBeenCalledTimes(2); expect(resumed.state.completed).toBe(2);
const changed = new OfflineDownload(api,cache,{ ...manifest,content:{ ...manifest.content!,revision:'two' } },()=>{});
await changed.load(); expect(changed.state.status).toBe('idle');
});
it('aborts an in-flight task without marking incomplete bytes as cached', async () => {
const cache = new PublicationCache(new MemoryKv(),new MemoryBlobs(),'pause');
const manifest = { book:{id:'b',format:'epub'},files:[] } as unknown as Manifest;
const bookBytes = vi.fn((_id: string,{signal}: {signal:AbortSignal}) => new Promise((_resolve,reject) => signal.addEventListener('abort',()=>reject(signal.reason))));
const task = new OfflineDownload({bookBytes} as unknown as ReaderApi,cache,manifest,()=>{});
const run = task.start(); await vi.waitFor(()=>expect(bookBytes).toHaveBeenCalled()); await task.pause(true); await run;
expect(task.state.status).toBe('cancelled'); expect(await cache.entries()).toEqual([]);
});
});
it('applies literal corrections safely, supports chained edits, and preserves original source', () => {
const root = document.createElement('div'); root.innerHTML = '<p>需要纠错的原文</p>';
const first = anchorFor('xhtml:a',root.textContent!,2,4);
const second = anchorFor('a','需要修改的原文',2,4);
expect(applyCorrections(root,'a',{version:1,headingPrefix:'',corrections:[{id:'1',anchor:first,replacement:'修改'},{id:'2',anchor:second,replacement:'<script>文本</script>'}]})).toBe(2);
expect(root.querySelector('script')).toBeNull(); expect(root.textContent).toContain('<script>文本</script>');
});
it('previews and applies a literal TXT heading prefix without executing regular expressions', () => {
const bytes = new TextEncoder().encode('前言\n@@ 一\n正文\n@@ 二\n正文');
const result = loadTxt({bytes,fileName:'a.txt',bookId:'a'},{headingPrefix:'@@'});
expect(result.doc.toc.map(t => t.label)).toEqual(['前言','@@ 一','@@ 二']);
expect(loadTxt({bytes,fileName:'a.txt',bookId:'a'},{headingPrefix:'(a+)+'}).chapterCount).toBe(0);
});
it('retries a failed chapter download and uses a complete TXT file for custom headings', async () => {
const cache=new PublicationCache(new MemoryKv(),new MemoryBlobs(),'retry');
const content={kind:'text',total:2,revision:'v',groups:[{id:'all',seq:0,offset:0,count:2,title:'全部'}],items:[0,1].map(seq=>({id:String(seq),seq,title:String(seq),href:'chapter:'+seq,resourceRef:'r:'+seq,kind:'chapter',mediaType:'text/plain'}))};
const manifest={book:{id:'b',format:'txt'},files:[],content} as unknown as Manifest;
const asset=vi.fn().mockResolvedValueOnce(new NodeBlob(['a'])).mockRejectedValueOnce(new Error('offline')).mockResolvedValue(new NodeBlob(['b']));
const api={asset,bookBytes:vi.fn(async()=>new Uint8Array([1,2,3]))} as unknown as ReaderApi;
const task=new OfflineDownload(api,cache,manifest,()=>{});await task.start(1,2);expect(task.state.status).toBe('error');expect(task.state.completed).toBe(1);
await task.start(1,2);expect(task.state.status).toBe('complete');expect(asset).toHaveBeenCalledTimes(3);
await cache.putOverrides('b',{version:1,headingPrefix:'@@',corrections:[]});await task.start(1,1);
expect(api.bookBytes).toHaveBeenCalledTimes(1);expect(task.state.message).toBe('整书已可离线阅读');
});
+44 -4
View File
@@ -31,6 +31,7 @@ async function setup(admin = true) {
transport.respondWith((request) => {
let json: unknown = {};
if (request.url.endsWith('/sources/types')) json = { types: [opds] };
else if (request.url.endsWith('/credentials')) json = { state: 'unknown', checkedAt: null, available: true, fields: [{ key: 'password', label: 'OPDS 密码', configured: false }] };
else if (request.url.endsWith('/sources')) json = { sources: [source], source };
else if (request.url.endsWith('/plugins')) json = { plugins: [{ pluginId: 'remote', name: '远程插件', enabled: true, builtin: false }] };
else if (request.url.endsWith('/subscriptions')) json = { subscriptions: [{ bookId: 'book1', title: '追更小说', enabled: false, intervalMinutes: 60, newChapters: 2, lastSuccessAt: 1000, nextCheckAt: 0 }] };
@@ -48,24 +49,63 @@ async function setup(admin = true) {
async function* pages(...values: Array<SourcePage | Promise<SourcePage>>): AsyncGenerator<SourcePage> { for (const value of values) yield await value; }
it('creates external reader credentials, shows the password once and revokes only the chosen client', async () => {
const { screen, api } = await setup(false);
const item = { id: 'client-1', name: '平板', createdAt: 1, expiresAt: Date.now() + 100000 };
const list = vi.spyOn(api, 'opdsCredentials').mockResolvedValue({ credentials: [], catalogUrl: '/opds' });
const create = vi.spyOn(api, 'createOpdsCredential').mockResolvedValue({ ...item, username: item.id, password: 'once-secret', catalogUrl: '/opds' });
const revoke = vi.spyOn(api, 'revokeOpdsCredential').mockResolvedValue();
await click(screen.element, '连接外部阅读器');
await vi.waitFor(() => expect(screen.element.querySelector<HTMLInputElement>('dialog input[readonly]')?.value).toContain('/opds'));
list.mockResolvedValue({ credentials: [item], catalogUrl: '/opds' });
input(screen.element, '客户端名称', '平板'); await vi.waitFor(() => expect(button(screen.element, '创建 OPDS 凭据').disabled).toBe(false)); await click(screen.element, '创建 OPDS 凭据');
await vi.waitFor(() => expect(create).toHaveBeenCalledWith('平板'));
await vi.waitFor(() => expect([...screen.element.querySelectorAll<HTMLInputElement>('input')].some(field => field.value === 'once-secret')).toBe(true));
await vi.waitFor(() => expect(button(screen.element, '关闭弹窗').disabled).toBe(false));
await click(screen.element, '关闭弹窗'); await vi.waitFor(() => expect(screen.element.querySelector('dialog')).toBeNull());
await click(screen.element, '连接外部阅读器');
await vi.waitFor(() => expect(screen.element.textContent).toContain('撤销 平板'));
expect([...screen.element.querySelectorAll<HTMLInputElement>('input')].some(field => field.value === 'once-secret')).toBe(false);
list.mockResolvedValue({ credentials: [], catalogUrl: '/opds' });
await click(screen.element, '撤销 平板'); await vi.waitFor(() => expect(revoke).toHaveBeenCalledWith('client-1'));
});
it('shows declared capabilities and personal access state without exposing or resubmitting stored secrets', async () => {
const { screen, api } = await setup(false); await chooseSource(screen.element);
expect(screen.element.querySelector('.source-capabilities')?.textContent).toContain('OPDS 服务端');
vi.spyOn(api, 'credentialStatus').mockResolvedValue({ state: 'verification-required', checkedAt: 1000, available: true,
fields: [{ key: 'password', label: 'OPDS 密码', configured: true }] });
const save = vi.spyOn(api, 'sourceCredential').mockResolvedValue();
await click(screen.element, '登录凭据');
expect(screen.element.querySelector('dialog')?.textContent).toContain('需要人工验证');
expect(screen.element.querySelector('dialog')?.textContent).toContain('已配置');
expect(screen.element.querySelector<HTMLInputElement>('input[type=password]')!.value).toBe('');
await click(screen.element, '保存个人凭据'); expect(save).not.toHaveBeenCalled();
await click(screen.element, '登录凭据'); await click(screen.element, '清空OPDS 密码(保存后删除)');
await click(screen.element, '保存个人凭据'); expect(save).toHaveBeenCalledWith('source-1', 'password', '');
vi.spyOn(api, 'credentialStatus').mockRejectedValue(new Error('状态暂不可用'));
await click(screen.element, '登录凭据'); input(screen.element, 'OPDS 密码', 'replacement');
await click(screen.element, '保存个人凭据'); expect(save).toHaveBeenLastCalledWith('source-1', 'password', 'replacement');
});
it('separates partial failures, preserves single-source pagination and clears old results on a new search', async () => {
const { screen, api } = await setup(); await chooseSource(screen.element);
const search = vi.spyOn(api, 'searchSource');
const errors = [{ source: '测试来源', code: 'HTTP_ERROR', message: '站点返回 HTTP 503' }];
search.mockImplementationOnce(() => pages({ items: [{ ref: 'ok', title: '成功结果' }], errors }));
input(screen.element, '搜索书籍', '小说'); await click(screen.element, '搜索');
expect(screen.element.querySelector('details')?.open).toBe(false);
expect(screen.element.querySelector<HTMLDetailsElement>('details:not(.source-capabilities)')?.open).toBe(false);
expect(screen.element.textContent).toContain('成功结果');
search.mockImplementationOnce(() => pages({ items: [], errors, nextCursor: 'more' }));
await click(screen.element, '搜索');
expect(screen.element.querySelector('details')?.open).toBe(true);
expect(screen.element.querySelector<HTMLDetailsElement>('details:not(.source-capabilities)')?.open).toBe(true);
expect(screen.element.textContent).toContain('部分来源搜索失败');
search.mockImplementationOnce(() => pages({ items: [] })); await click(screen.element, '加载更多结果');
expect(search.mock.calls.at(-1)?.[1]).toMatchObject({ query: '小说', cursor: 'more', filters: {} });
expect(screen.element.querySelector('details')).not.toBeNull();
expect(screen.element.querySelector<HTMLDetailsElement>('details:not(.source-capabilities)')).not.toBeNull();
search.mockImplementationOnce(() => pages({ items: [] }));
input(screen.element, '搜索书籍', '新关键词'); await click(screen.element, '搜索');
expect(screen.element.querySelector('details')).toBeNull();
expect(screen.element.querySelector<HTMLDetailsElement>('details:not(.source-capabilities)')).toBeNull();
expect(screen.element.textContent).toContain('没有找到匹配书籍');
});
+13
View File
@@ -13,6 +13,19 @@ const SESSION = {
refreshTokenExpiresAt: Date.now() + 86_400_000,
};
it('reports a failed local progress write and permits retry without losing the outbox', async () => {
const kv = new MemoryKv();
const offline = new OfflineStore(kv);
const progress = { bookId: 'b', locator: 'chapter:12', percentage: 0.5, chapterTitle: '12', device: 'phone', updatedAt: 20 };
vi.spyOn(kv, 'set').mockRejectedValueOnce(new Error('disk full'));
await expect(offline.setProgress(progress)).rejects.toThrow('disk full');
expect(offline.outbox().progress).toEqual([progress]);
await offline.setProgress(progress);
const restored = new OfflineStore(kv);
await restored.load();
expect(restored.progressFor('b')).toEqual(progress);
});
function sessions() {
const kv = new MemoryKv();
return {
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve, relative, isAbsolute } from 'node:path';
import { chromium } from 'playwright';
import JSZip from 'jszip';
import { openDatabase } from '../../../server/src/db/index.ts';
import { buildApp } from '../../../server/src/http/app.ts';
import { Scanner } from '../../../server/src/indexer/scanner.ts';
import { UserService } from '../../../server/src/services/users.ts';
import { ShelfService } from '../../../server/src/services/shelf.ts';
import { SyncService } from '../../../server/src/services/sync.ts';
import { TtsService } from '../../../server/src/services/tts.ts';
import { BrowseService } from '../../../server/src/services/browse.ts';
const repo=resolve(import.meta.dirname,'../../..'), root=await mkdtemp(join(tmpdir(),'reader-p1-'));
const shots=join(repo,'docs/ui-review/p1');
const config={booksDir:join(root,'books'),dataDir:join(root,'data'),host:'127.0.0.1',port:0,jwtSecret:'isolated-p1-test-secret',accessTokenTtl:86400,refreshTokenTtl:86400,scanInterval:0,watchInterval:0,logLevel:'silent',publicUrl:'',corsOrigins:[],webDir:join(repo,'web/dist')};
let app,db,browser;
try {
await Promise.all([mkdir(config.booksDir),mkdir(config.dataDir),mkdir(shots,{recursive:true})]);
const zip=new JSZip();zip.file('mimetype','application/epub+zip');zip.file('META-INF/container.xml','<container><rootfiles><rootfile full-path="book.opf"/></rootfiles></container>');
zip.file('book.opf','<package version="3.0"><metadata><dc:title>阅读工具测试</dc:title><dc:creator>测试</dc:creator></metadata><manifest><item id="c" href="chapter.xhtml" media-type="application/xhtml+xml"/><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/></manifest><spine><itemref idref="c"/></spine></package>');
zip.file('nav.xhtml','<html><body><nav epub:type="toc"><ol><li><a href="chapter.xhtml#one">第一层</a><ol><li><a href="chapter.xhtml#two">第二层</a><ol><li><a href="chapter.xhtml#three">第三层</a></li></ol></li></ol></li></ol></nav></body></html>');
zip.file('chapter.xhtml','<html><body><h1 id="one">第一层</h1><p>这里是需要纠错的文字,检索目标一。</p>'+Array.from({length:24},(_,i)=>'<p>这是用于测试阅读定位的正文段落,第 '+i+' 段。</p>').join('')+'<h2 id="two">第二层</h2><p>检索目标二。</p><h3 id="three">第三层</h3><p>结尾。</p></body></html>');
await writeFile(join(config.booksDir,'test.epub'),await zip.generateAsync({type:'nodebuffer'}));
await writeFile(join(config.booksDir,'test.txt'),'前言\n@@ 第一章\n正文一\n@@ 第二章\n正文二');
db=openDatabase(config);const ctx={config,db};app=buildApp(ctx);ctx.log=app.log;ctx.scanner=new Scanner(db,config,{info(){},warn(){}});ctx.users=new UserService(db,config);ctx.shelf=new ShelfService(db);ctx.sync=new SyncService(db,ctx.shelf);ctx.tts=new TtsService(config);ctx.browse=new BrowseService(db,config,ctx.shelf);
await ctx.scanner.scan();const base=await app.listen({host:'127.0.0.1',port:0});
const registered=await app.inject({method:'POST',url:'/api/v1/auth/register',payload:{username:'p1-reader',password:'password123'}});assert.equal(registered.statusCode,201);
const books=db.all('SELECT id,format FROM books'), epub=books.find(b=>b.format==='epub'),txt=books.find(b=>b.format==='txt');
browser=await chromium.launch({headless:true,...(process.env.CHROME_PATH?{executablePath:process.env.CHROME_PATH}:{})});
const context=await browser.newContext({viewport:{width:390,height:844}}), page=await context.newPage();page.setDefaultTimeout(12000);
const errors=[];page.on('pageerror',e=>errors.push(e.message));const button=name=>page.getByRole('button',{name,exact:true});
await page.goto(base);await page.locator('input[autocomplete=username]').fill('p1-reader');await page.locator('input[type=password]').fill('password123');await page.locator('button[type=submit]').click();await page.locator('.shelf-screen').waitFor();
await page.goto(base+'/#/book/'+epub.id);await page.locator('book-content h1').waitFor();assert.ok(await button('设置').evaluate(el=>el.getBoundingClientRect().bottom<=innerHeight));await button('工具').click();
for(const width of [320,390,1280]){await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'tools-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
await page.setViewportSize({width:390,height:844});await page.getByLabel('关键词',{exact:true}).fill('检索目标');await button('搜索全文').click();await page.locator('.reading-tool-results li').nth(1).waitFor();await page.locator('.reading-tool-results li button').nth(1).click();await page.getByRole('dialog').waitFor({state:'hidden'});
assert.ok(await page.locator('book-content').evaluate(host=>host.shadowRoot.querySelectorAll('[data-reader-mark="search"]').length)>0);
await button('工具').click();await button('返回原阅读位置').click();
await button('目录').click();await page.getByRole('button',{name:'第三层',exact:false}).last().waitFor();await page.getByRole('button',{name:'第一层 折叠',exact:true}).click();assert.equal(await page.getByRole('button',{name:'第三层',exact:false}).count(),0);await page.getByRole('button',{name:'第一层 展开',exact:true}).click();await page.getByRole('button',{name:/3.*第三层/}).click();
await button('工具').click();await button('离线缓存').click();await button('下载 / 继续').click();await page.getByText('整书已可离线阅读',{exact:false}).waitFor();await page.screenshot({path:join(shots,'offline.png')});await button('关闭弹窗').click();
await context.route('**/api/**',route=>route.abort('internetdisconnected'));await page.reload();await page.locator('book-content h1').waitFor();await context.unroute('**/api/**');
// Select actual shadow DOM text, then create a persisted personal correction.
await page.locator('book-content').evaluate(host=>{const node=host.shadowRoot.querySelector('p').firstChild;const range=document.createRange();range.setStart(node,3);range.setEnd(node,7);const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);});
await button('工具').click();await button('笔记').click();await page.getByLabel('批注',{exact:true}).fill('跨设备批注测试');await button('保存高亮或批注').click();await page.getByText('已保存,联网后同步').waitFor();
await button('关闭弹窗').click();assert.ok(await page.locator('book-content').evaluate(host=>host.shadowRoot.querySelectorAll('[data-reader-mark="note"]').length)>0);
await page.locator('book-content').evaluate(host=>{const node=host.shadowRoot.querySelector('p').firstChild;const range=document.createRange();range.setStart(node,3);range.setEnd(node,7);const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);});
await button('工具').click();await button('内容整理').click();await page.getByLabel('替换为',{exact:true}).fill('已经修正');await button('保存纠错 / 过滤').click();await page.getByText('纠错已保存,正文已更新').waitFor();await button('关闭弹窗').click();await page.reload();await page.locator('book-content p').filter({hasText:'已经修正'}).waitFor();
await button('工具').click();await button('内容整理').click();await button('撤销上一次整理修改').click();await page.getByText('已撤销上一次整理修改').waitFor();await button('关闭弹窗').click();assert.ok(!(await page.locator('book-content p').first().innerText()).includes('已经修正'));
await page.goto(base+'/#/book/'+txt.id);await page.locator('book-content p').first().waitFor();await button('工具').click();await button('内容整理').click();await page.getByLabel('标题开头').fill('@@');await button('预览目录').click();await button('应用预览规则').waitFor();await page.screenshot({path:join(shots,'txt-preview.png')});await button('应用预览规则').click();await page.getByText('目录规则已保存').waitFor();
assert.deepEqual(errors,[]);console.log('P1 browser flow passed: search/navigation/highlight, 320–1280px, nested TOC, offline EPUB reopen, persisted correction/undo, TXT preview/apply');
}finally{await browser?.close();await app?.close();db?.close();const cleanupRelative=relative(resolve(tmpdir()),resolve(root));assert.ok(!cleanupRelative.startsWith('..')&&!isAbsolute(cleanupRelative)&&cleanupRelative.startsWith('reader-p1-'));await rm(root,{recursive:true,force:true});}
+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve, relative, isAbsolute } from 'node:path';
import { chromium } from 'playwright';
import { openDatabase } from '../../../server/src/db/index.ts';
import { buildApp } from '../../../server/src/http/app.ts';
import { Scanner } from '../../../server/src/indexer/scanner.ts';
import { UserService } from '../../../server/src/services/users.ts';
import { ShelfService } from '../../../server/src/services/shelf.ts';
import { SyncService } from '../../../server/src/services/sync.ts';
import { TtsService } from '../../../server/src/services/tts.ts';
import { BrowseService } from '../../../server/src/services/browse.ts';
const repo=resolve(import.meta.dirname,'../../..'), root=await mkdtemp(join(tmpdir(),'reader-p2-'));
const shots=join(repo,'docs/ui-review/p2');
const config={booksDir:join(root,'books'),dataDir:join(root,'data'),host:'127.0.0.1',port:0,jwtSecret:'isolated-p2-test-secret',accessTokenTtl:86400,refreshTokenTtl:86400,scanInterval:0,watchInterval:0,logLevel:'silent',publicUrl:'',corsOrigins:[],webDir:join(repo,'web/dist')};
let app,db,browser,page;
try {
await Promise.all([mkdir(config.booksDir),mkdir(config.dataDir),mkdir(shots,{recursive:true})]);
await writeFile(join(config.booksDir,'opds-test.txt'),'OPDS 文件正文');
db=openDatabase(config);const ctx={config,db};app=buildApp(ctx);ctx.log=app.log;ctx.scanner=new Scanner(db,config,{info(){},warn(){}});ctx.users=new UserService(db,config);ctx.shelf=new ShelfService(db);ctx.sync=new SyncService(db,ctx.shelf);ctx.tts=new TtsService(config);ctx.browse=new BrowseService(db,config,ctx.shelf);
await ctx.scanner.scan();
const base=await app.listen({host:'127.0.0.1',port:0});
const registered=await app.inject({method:'POST',url:'/api/v1/auth/register',payload:{username:'p2-reader',password:'password123'}});assert.equal(registered.statusCode,201);
const user=db.get('SELECT id FROM users');
const provider={descriptor:{id:'quality',label:'质量测试插件',version:'1',capabilities:['browse','detail','acquire.chapters','content.manifest','content.resource','content.alternatives'],credentialKeys:[{key:'password',label:'站点密码'}]},
async browse(){return {items:[]};},async detail(){return {ref:'original',title:'换源质量测试',authors:['测试作者']};},
async acquire(_ctx,request){return {kind:'chapters',publicationRef:request.entryRef};},
async alternatives(){return {items:[{ref:'alternative',title:'换源质量测试',authors:['测试作者'],sourceName:'备选来源',latestChapter:'空白章'}]};},
async getManifest(_ctx,ref){return {publicationRef:ref,items:[{id:'one',seq:0,title:'第一章',kind:'chapter',mediaType:'text/plain',ref:'body'},{id:'empty',seq:1,title:'空白章',kind:'chapter',mediaType:'text/plain',ref:'empty'}]};},
async readResource(_ctx,request){return {mediaType:'text/plain',text:request.ref==='empty'?' ':request.publicationRef==='original'?'原来的正文,换源失败时仍然保留。':'新来源的正文,供质量检测和阅读。'};}};
ctx.sources.registry.register({pluginId:'test.quality',provider});
await ctx.sources.create({id:'quality',pluginId:'test.quality',sourceType:'quality',name:'质量测试来源',config:{}});
const acquired=await ctx.sources.acquire(user.id,'quality','original');assert.equal(acquired.kind,'ready');
browser=await chromium.launch({headless:true,...(process.env.CHROME_PATH?{executablePath:process.env.CHROME_PATH}:{})});
page=await browser.newPage({viewport:{width:390,height:844}});page.setDefaultTimeout(12000);
const errors=[];page.on('pageerror',e=>errors.push(e.message));const button=name=>page.getByRole('button',{name,exact:true});
await page.goto(base);await page.locator('input[autocomplete=username]').fill('p2-reader');await page.locator('input[type=password]').fill('password123');await page.locator('button[type=submit]').click();await page.locator('.shelf-screen').waitFor();
await page.goto(base+'/#/sources');await button('连接外部阅读器').click();
await page.getByLabel('客户端名称').fill('浏览器验收客户端');await button('创建 OPDS 凭据').click();await page.getByLabel('OPDS 密码',{exact:true}).waitFor();
const opdsUser=await page.getByLabel('OPDS 用户名',{exact:true}).inputValue(),opdsPassword=await page.getByLabel('OPDS 密码',{exact:true}).inputValue();
const authorization='Basic '+Buffer.from(opdsUser+':'+opdsPassword).toString('base64');
const feed=await fetch(base+'/opds',{headers:{authorization}});assert.equal(feed.status,200);assert.match(await feed.text(),/opds-test/);
await button('关闭弹窗').click();await button('连接外部阅读器').click();await button('撤销 浏览器验收客户端').waitFor();assert.equal(await page.getByLabel('OPDS 密码',{exact:true}).count(),0);
for(const width of [320,390,1280]) {await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'opds-access-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
await button('撤销 浏览器验收客户端').click();await button('撤销 浏览器验收客户端').waitFor({state:'hidden'});assert.equal((await fetch(base+'/opds',{headers:{authorization}})).status,401);await button('关闭弹窗').click();
await page.getByLabel('选择来源').selectOption('quality');
await page.locator('.source-capabilities summary').click();
for(const width of [320,390,1280]) {await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'capabilities-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
await button('登录凭据').click();await page.getByText('最近一次访问成功',{exact:false}).waitFor();await page.locator('input[type=password]').fill('test-secret');await button('保存个人凭据').click();await page.getByRole('dialog').waitFor({state:'hidden'});
await button('登录凭据').click();await page.getByText('尚未验证访问',{exact:false}).waitFor();assert.equal(await page.locator('input[type=password]').inputValue(),'');
for(const width of [320,390,1280]) {await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'credentials-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
await button('清空站点密码(保存后删除)').click();await button('保存个人凭据').click();await page.getByRole('dialog').waitFor({state:'hidden'});assert.equal(ctx.sources.credentialStatus('quality',user.id).fields[0].configured,false);
await page.goto(base+'/#/book/'+acquired.publicationId);await page.locator('book-content p').first().waitFor();await button('切换书源').click();await button('查看此源目录:备选来源').click();
await page.getByLabel('切换后阅读的章节').selectOption('empty');await button('检测所选章节 / 重试').click();await page.getByRole('dialog').getByText('此章节没有可读正文',{exact:false}).waitFor();assert.equal(ctx.sources.chapters.binding(user.id,acquired.publicationId).publication_ref,'original');
await button('确认换源并阅读').click();await page.locator('.notyf').getByText('此章节没有可读正文',{exact:false}).waitFor();assert.equal(ctx.sources.chapters.binding(user.id,acquired.publicationId).publication_ref,'original');
await button('关闭提示').click();
await page.getByLabel('切换后阅读的章节').selectOption('one');await button('检测所选章节 / 重试').click();await page.getByText('正文检测通过',{exact:true}).waitFor();
for(const width of [320,390,1280]) {await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'quality-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
await button('确认换源并阅读').click();await page.getByRole('dialog').waitFor({state:'hidden'});await page.locator('book-content p').filter({hasText:'新来源的正文'}).waitFor();assert.equal(ctx.sources.chapters.binding(user.id,acquired.publicationId).publication_ref,'alternative');
await button('设置').click();await button('应用电纸书预设').click();
await page.waitForFunction(async()=>{const db=await new Promise((resolve,reject)=>{const request=indexedDB.open('reader');request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});try {const stored=await new Promise((resolve,reject)=>{const request=db.transaction('kv','readonly').objectStore('kv').get('reader.settings.v1');request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const value=JSON.parse(stored||'{}');return value.pageAnimation==='none'&&value.mode==='paged'&&value.theme==='light';}finally{db.close();}});
assert.deepEqual(errors,[]);console.log('PASS P2 real HTTP + SQLite + Chromium '+browser.version()+': capability matrix, private credentials save/delete/reset, empty chapter rejection, quality preview and source switch; 320/390/1280px without overflow; e-ink preset; OPDS credential lifecycle and authenticated catalog');
} catch(error) {if(page){await page.screenshot({path:join(shots,'failure.png')});console.error((await page.locator('body').innerText()).slice(-3500));}throw error;}
finally {await browser?.close();await app?.close();db?.close();const cleanupRelative=relative(resolve(tmpdir()),resolve(root));if(!cleanupRelative||cleanupRelative.startsWith('..')||isAbsolute(cleanupRelative))throw new Error('Unsafe cleanup path');await rm(root,{recursive:true,force:true});}