mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 14:31:11 +08:00
合并来自 codex/source-plugins 的合并请求 #58
fix(reader): animate actual page displacement without scroll reversal PR-URL: #58
This commit is contained in:
commit
039171d089
55 files changed
+3275
-388
No files matched your search
@@ -1,5 +1,6 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
*.tgz
|
||||
|
||||
# build output
|
||||
server/dist/
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
| --- | --- | --- |
|
||||
| 服务端 | `server/` | Node.js + TypeScript,单容器,扫描书库并同步进度 |
|
||||
| 渲染层 | `web/` | TypeScript + Vite,EPUB 精排、TXT、漫画、PDF |
|
||||
| Android 壳 | `android/` | Kotlin + WebView,复用上面那一份渲染层 |
|
||||
|
||||
服务端已提供内置 local / OPDS 来源与受信任进程插件接口。OPDS 下载和插件纯文本章节均接入现有书架与阅读器;章节支持稳定阅读进度、已读缓存和手动刷新目录。插件与来源配置目前使用管理 API,远程富文本、整书预缓存和自动追更仍待实现。见 [插件设计与实施状态](docs/source-plugins.md) 和 [API 与示例插件安装](docs/api.md#来源与插件)。
|
||||
|
||||
---
|
||||
|
||||
@@ -139,13 +136,8 @@ volumes:
|
||||
| 引擎 | 声音来自 | 什么时候用 |
|
||||
| --- | --- | --- |
|
||||
| 系统语音(原生) | Android 外壳的 `TextToSpeech` | Android 客户端默认 |
|
||||
| 系统语音(浏览器) | 浏览器 / WebView 的 `speechSynthesis` | Web 端默认 |
|
||||
| HTTP 朗读 | 服务端 `TTS_URL` 指向的合成服务 | 前两者不可用时 |
|
||||
|
||||
**为什么 Android 要用原生引擎**:WebView 自带的合成器和系统的是**两个引擎**,
|
||||
手机上系统装好的中文语音经常不在 WebView 的语音列表里——于是读者听到英文或听不到声音,
|
||||
而 Web 层拿到的列表本身就是缺的,无从修起。这是「朗读没有中文声音」最常见的原因。
|
||||
|
||||
**HTTP 朗读**:给没有 `speechSynthesis` 的浏览器(比如 Linux 上的 Firefox)、
|
||||
或者没有中文语音的设备用。服务端只做代理,不内置合成模型——容器体积和 NAS 的 CPU 预算
|
||||
都不该为它买单。在 `docker-compose.yml` 里加:
|
||||
@@ -346,22 +338,11 @@ Back 走的是「离开书库」而不是「切回上一个页面」。
|
||||
|
||||
### 两端的关系
|
||||
|
||||
Android 端**不重写渲染**。它是一个 Kotlin 原生外壳,只负责文件缓存、手势、账号与书架,
|
||||
渲染复用同一份 `web/` 产物。原因很直接:排版策略一旦出现两份实现,就一定会分叉,
|
||||
而「忠于精排」正是这个产品的差异点。外壳只做 WebView 做不到或做不好的事:
|
||||
|
||||
- **连通性**:WebView 里的 `navigator.onLine` 只要有网卡就说「在线」——
|
||||
连上一个没有出口的 Wi-Fi 时它会骗人,客户端于是转圈而不是读缓存。
|
||||
Android 侧用系统的 `NET_CAPABILITY_VALIDATED` 判断。
|
||||
- **稳定的设备名**:UA 里的型号会随 Chrome 升级变化,作为「上次在哪个设备读的」标签不可靠。
|
||||
- **原生提示**:一行 Kotlin 的 Toast,不用在 Web 层造一套通知 UI。
|
||||
|
||||
还有一条刻意的例外:**固定版式的整页图由原生画**。一页漫画或一张扫描图在 WebView 里
|
||||
要走完解码、样式布局、合成三次开销,而屏幕上的结果和 `ImageView` 一模一样;
|
||||
对一章文字这些都是必要的,对「把这张图画满屏」则全是白付的。
|
||||
规则是「只画整页图,且全有或全无」——画不了就交给 WebView,绝不出现空白页。
|
||||
EPUB 永远留在 WebView:文字排版是差异点,只能有一份实现。
|
||||
|
||||
### 离线合并语义
|
||||
|
||||
进度和笔记都带 `updatedAt`,冲突按 **last-writer-wins** 解决:
|
||||
@@ -500,14 +481,6 @@ cd android
|
||||
|
||||
## 从源码开发
|
||||
|
||||
```bash
|
||||
# 服务端
|
||||
cd server
|
||||
npm install
|
||||
npm test # 137 个测试用例
|
||||
npm run typecheck
|
||||
npm run dev # 开发模式,热重载
|
||||
|
||||
# 指向一个测试书库
|
||||
BOOKS_DIR=/tmp/books DATA_DIR=/tmp/data npm run dev
|
||||
```
|
||||
@@ -516,7 +489,7 @@ BOOKS_DIR=/tmp/books DATA_DIR=/tmp/data npm run dev
|
||||
# 客户端(另开一个终端)
|
||||
cd web
|
||||
npm install
|
||||
npm test # 336 个测试用例(307 vitest + 29 node:test)
|
||||
npm test # Vitest 与 node:test
|
||||
npm run dev # http://localhost:5174,自动把 /api 代理到 8080
|
||||
npm run build # 产出 web/dist,服务端会在 / 上直接托管
|
||||
```
|
||||
@@ -572,13 +545,6 @@ web/src/
|
||||
reader-icons.ttf 图标字体(生成物,见 tools/icons/)
|
||||
tools/icons/ 图标字体的生成器:描边路径 → 字体
|
||||
|
||||
android/app/src/main/java/cool/cnb/reader/
|
||||
MainActivity.kt 单 Activity,只做三件事
|
||||
web/WebHost.kt WebView 配置与资源装载
|
||||
bridge/ReaderBridge.kt 暴露给 JS 的原生方法
|
||||
bridge/ConnectivityMonitor.kt 真正的连通性判断
|
||||
```
|
||||
|
||||
### 技术选型说明
|
||||
|
||||
- **Node.js 22+(`engines: >=22`,内置 `node:sqlite` 与 `scrypt` 所在的最低版本)+ TypeScript + Fastify**:单容器体积可控(约 150MB),迭代最快,
|
||||
@@ -602,13 +568,11 @@ android/app/src/main/java/cool/cnb/reader/
|
||||
`font-size`,因此没有可以漂移的几何——而三十个内联 SVG 组件里,颜色、尺寸、线宽
|
||||
每一项都是单个字形可以漂移的位置。
|
||||
- **界面用 URL 表达**:每个屏幕都有地址——`#/shelf`、`#/book/<id>`、`#/library/<路径>`。
|
||||
用 fragment 而不是路径,是因为同一份产物要被 WebView(`appassets.androidplatform.net`,
|
||||
没有服务端)和 reader 服务端(没有客户端路由回落)同时加载,只有 fragment 在两边都成立。
|
||||
换来的是:一本书可以分享、可以收藏、刷新不丢;Android 返回手势和浏览器返回键
|
||||
走应用自己的屏幕轨迹;走进书库子目录用 `replace`,所以返回是退出管理器而不是逐层退目录。
|
||||
带着链接进来而尚未登录时,链接会等在登录页后面,登录后直接落到那本书。
|
||||
- **单文件 bundle、相对路径**:产物要被两个宿主消费——服务端托管给浏览器、Android 打进
|
||||
assets。`file://` 或 WebView 里加载同源分片会踩平台特异性,所以 `inlineDynamicImports`
|
||||
打成一份,`base: './'`。这条约束也是选 Preact 而不是 React/Vue 的关键:
|
||||
`preact/compat` 不进包、没有 `react-dom`,只引入 `preact` 与 `preact/hooks`,
|
||||
两个模块压缩前合计约 15KB。
|
||||
|
||||
+30
-14
@@ -262,7 +262,7 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
|
||||
响应带 `Cache-Control: private, max-age=31536000, immutable`:文件资源由内容身份寻址;章节资源引用同时包含目录 revision,所以同一个资源 URL 的内容保持不变。
|
||||
|
||||
章节资源返回 `text/plain; charset=utf-8`,另带 `X-Content-Type-Options: nosniff` 和限制内容执行的 CSP。客户端必须按纯文本转义排版,即使正文包含 HTML 字符也不能直接交给 HTML 渲染器。正文读取后持久缓存,来源禁用或插件卸载不影响缓存命中;未缓存内容仍需要可用来源。旧 revision 的已缓存正文可读,未缓存正文返回 `409 CHAPTER_SNAPSHOT_EXPIRED`,客户端可重新打开当前目录。
|
||||
章节资源按 manifest 的 `mediaType` 返回 `text/plain; charset=utf-8` 或清洗后的 `text/html; charset=utf-8`,带 `nosniff` 和 `default-src 'none'; img-src data:; sandbox` CSP。纯文本必须转义排版;HTML 只保留白名单标签,图片经插件通道获取、校验 PNG/JPEG/GIF/WebP 签名并转为 data URL。脚本、样式、表单、外部链接、SVG 和任意网络图片被移除。正文读取后持久缓存,来源禁用或插件卸载不影响缓存命中;旧 revision 已缓存正文可读,未缓存正文返回 `409 CHAPTER_SNAPSHOT_EXPIRED`。
|
||||
|
||||
### `POST /books/:id/refresh`
|
||||
|
||||
@@ -277,7 +277,7 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
"mediaType": "text/plain; charset=utf-8", "format": "html" }] }
|
||||
```
|
||||
|
||||
`format: "html"` 表示客户端将纯文本排版为 HTML,不表示资源含可信 HTML。该接口无需请求体;文件书返回 `400 SOURCE_UNSUPPORTED`,其他用户的章节书返回 404。刷新失败保留旧目录;同一目录保持 revision 不变;插件应在正文变化时更新 `ManifestSnapshot.version`。阅读进度和笔记不重写,插章后仍以稳定 href 定位。它是手动刷新接口,没有后台追更或预下载副作用。
|
||||
`format: "html"` 表示客户端将纯文本排版为 HTML,不表示资源含 HTML;富文本条目省略 `format`、声明 `mediaType: text/html`,目录 `kind` 为 `reflowable`。该接口无需请求体;文件书返回 `400 SOURCE_UNSUPPORTED`,其他用户章节书返回 404。刷新失败保留旧目录;同一目录保持 revision 不变;正文变化时插件须更新 `ManifestSnapshot.version`。进度和笔记不重写,插章后以稳定 href 定位。手动刷新会更新检查时间和新增章计数,但不会自动开启订阅或预下载正文。
|
||||
|
||||
### 文件资源的流式与子资源行为
|
||||
|
||||
@@ -302,14 +302,6 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
其他任何端点(含触发扫描、写元数据)都不接受 URL 里的令牌 —— URL 会进日志、
|
||||
进历史、进 `Referer`,能改状态的令牌不该走那条路。
|
||||
|
||||
**epub 章节返回的是重写过的 HTML。** 章节内的相对资源引用(`images/pic.png`)已经被
|
||||
服务端改写成指向本端点的绝对地址。客户端直接把 HTML 交给 WebView 即可,不需要自己
|
||||
解析路径。绝对 URL、`data:` URI 和文内锚点(`#note7`)保持原样,所以脚注和外链仍然可用。
|
||||
服务端重写出的地址带 `?__reader-book-resource__=1` 作为标记,客户端据此把它和书自己
|
||||
写的外链区分开:带标记**且**同源的才允许取回,其余一律丢弃(书不能借阅读会话去访问
|
||||
第三方)。标记是一本书也能写出来的字符串,所以它单独不构成许可,两个条件一起才是书
|
||||
伪造不出来的组合。
|
||||
|
||||
**这三条允许名单决定的是「能不能取」,不是「怎么取」。** 重写出来的地址还要**带令牌**
|
||||
才真的能取到:章节文档里的 `<img src>`、`<link href>` 和 CSS 的 `url(...)` 都是浏览器
|
||||
自己发起的请求,用的是文档里的原始地址,读不到客户端内存里的会话。客户端在注入章节时
|
||||
@@ -837,7 +829,7 @@ Content-Type: application/json
|
||||
|
||||
返回 `201 { "source": {...} }`。可指定 `id`(2~80 位字母、数字、下划线或短横线),省略则自动生成。`sourceType` 和 `pluginId` 从 `/sources/types` 读取。`local` 仅接受空配置,使用宿主 `BOOKS_DIR`。
|
||||
|
||||
`config` 不能包含密码。OPDS 的 `url` 必须是 HTTP(S) 地址;Feed 和下载链接默认限制在该 origin。需要跨 origin 获取文件时,管理员可在 config 中增加 `allowedOrigins`(如 `["https://cdn.example.test"]`)。Basic 凭据不会转发到附加 origin。当前只支持通过 `PATCH /sources/:id` 更新 `enabled`,未提供修改名称/URL/config 或删除来源的接口。
|
||||
`config` 不能包含密码。OPDS 的 `url` 必须是 HTTP(S) 地址;Feed 和下载链接默认限制在该 origin。跨 origin 获取文件须配置 `allowedOrigins`;Basic 凭据不转发到附加 origin。管理员可 `PATCH /sources/:id` 更新 `name/config/enabled`,配置仍由提供者校验,修改 config 会清除该来源所有用户的凭据,防止向新地址发送旧凭据;来源有在途调用时修改 config 返回 `409 SOURCE_BUSY`。`DELETE /sources/:id` 只允许删除没有已获取内容的非内置来源,否则返回 `409 SOURCE_IN_USE`。Web 的 `#/sources` 提供对应表单。
|
||||
|
||||
### 来源浏览、搜索和详情
|
||||
|
||||
@@ -882,13 +874,23 @@ GET /api/v1/sources/:id/publications/:publicationRef/manifest
|
||||
GET /api/v1/sources/:id/publications/:publicationRef/resource?ref=<resource-ref>
|
||||
```
|
||||
|
||||
上述来源级资源接口只返回 JSON:文本用 `text`,二进制小资源用 `base64`,用于插件开发调试。实际入库阅读只接受 UTF-8 `text/plain`;章节目录最多 10,000 项及 2 MiB,单章最多 2 MiB,同时受 RPC 消息上限约束。HTML、图片及其他富文本暂不接入阅读器。
|
||||
上述来源级资源接口只返回 JSON:文本用 `text`,二进制资源用 `base64`。入库阅读接受 UTF-8 `text/plain`、`text/html`、`application/xhtml+xml`;目录最多 10,000 项及 2 MiB,原始正文最多 2 MiB。HTML 插图仅接受 `src="reader-res:<插件资源引用>"`,由同一来源的 `readResource` 返回图片;每章最多 32 个不同图片,单图 2 MiB,清洗与内嵌后的章节总计不超过 8 MiB,整章加载限时 60 秒。所有插件响应仍受单条 RPC 2 MiB 上限约束(含 base64)。
|
||||
|
||||
服务端和设备分别缓存已读正文。Web 客户端保存目录和正文时按服务器、账号及 resourceRef 隔离;断网或服务端 5xx 时可回退本账号缓存,授权失败或资源不存在时不回退。设备断网只能阅读已缓存章节;当前没有整书预下载、缓存总配额或后台追更。
|
||||
服务端和设备分别缓存已读正文(含已内嵌图片)。Web 按服务器、账号及 resourceRef 隔离;断网或 5xx 时目录可回退本账号缓存,授权失败或资源不存在时不回退。没有整书预下载和缓存总配额。后台目录检查由下面的订阅接口控制。
|
||||
|
||||
### 自动追更订阅
|
||||
|
||||
`GET /subscriptions` 返回 `{ "subscriptions": [...] }`,只列当前用户书架中的章节书:`bookId/title/enabled/intervalMinutes/nextCheckAt/lastCheckAt/lastSuccessAt/lastError/failures/newChapters`,时间为毫秒,未检查时间为 null。默认关闭、间隔 60 分钟。
|
||||
|
||||
`PATCH /books/:id/subscription` 接受 `{ "enabled": true, "intervalMinutes": 60 }` 或 `{ "acknowledge": true }`。间隔为 15~10080 分钟的整数;`acknowledge` 只清除新增章提示。管理员不能操作他人的章节书。
|
||||
|
||||
单实例服务每分钟扫描一次,每轮最多顺序处理 20 本;开始时写入 5 分钟执行租约,崩溃重启后到期重试。暂停来源、禁用账号或隐藏书籍时跳过。失败按订阅间隔和指数退避的较大值重试,保留旧目录及上次成功时间;不预下载正文,不发外部通知。状态中的错误是代码,不包含上游消息。浏览器关闭不影响调度;阅读中不会自动替换当前目录,用户刷新或重开时采用新版本。
|
||||
|
||||
插件类型可声明 `credentialKeys: [{ "key": "access-token", "label": "访问令牌" }]`;同时声明 `permissions.credentials: true` 后,宿主仅将该类型声明的当前用户键值放进 RPC `context.credentials`。凭据不出现在来源列表或普通 config 中。`AUTH_REQUIRED` 表示来源凭据问题,客户端不应退出 reader 账号。
|
||||
|
||||
### 插件管理(管理员)
|
||||
|
||||
插件包需要管理员预先放入 `DATA_DIR/plugins/<folder>`,当前安装接口只接受目录名,不上传或解压包:
|
||||
插件包需要管理员预先部署。`folder` 接受 `DATA_DIR/plugins/<folder>` 下的单个目录名,或 `npm:包名`(含 `npm:@scope/name`),后者从 `DATA_DIR/plugins/node_modules` 加载。接口不下载 npm 包、不运行安装脚本、不上传或解压包;包名中不接受版本或路径遍历。
|
||||
|
||||
```http
|
||||
POST /api/v1/plugins
|
||||
@@ -1061,3 +1063,17 @@ Invoke-RestMethod -Method Post -Uri "$readerApi/books/$readerBookId/refresh" -He
|
||||
(`ADMIN_REQUIRED`、`PATH_TRAVERSAL`、`READ_ONLY_MOUNT`)。
|
||||
只有前者该清掉凭据跳登录页;把后者也当会话失效,会把读者在读只读挂载上
|
||||
改个文件名时踢下线,顺手把他存的令牌也删了。按 `error.code` 判断,不要只看状态码。
|
||||
|
||||
|
||||
### 插件扩展与换源
|
||||
|
||||
- 管理员 `GET /api/v1/sources/:id/pages/:pageId`:来源实例的声明页面,返回 `title/description/notice/forms/sections/tabs/activeTab`。
|
||||
- 管理员 `POST /api/v1/sources/:id/pages/:pageId`:`{action,values}`,操作当前实例并返回页面;入口来自 sourceType 的 `extensions.pages`。暂停来源仍可管理,插件停用后不可访问。
|
||||
- 管理员 `GET /api/v1/plugins/:id/pages/:pageId`:已声明配置页,返回通用 title/description/forms/sections。
|
||||
- `GET /api/v1/sources/:id/search-filters`:插件声明的选择字段;搜索接口接受 JSON 编码的 `filters` 查询参数,值为字符串映射。
|
||||
- `GET /api/v1/books/:id/source-options`:`{canSwitch}`,限本人有权限的书籍。
|
||||
- `GET /api/v1/books/:id/alternatives?cursor=…`:候选 CatalogPage。
|
||||
- `POST /api/v1/books/:id/switch-preview`:`{entryRef}` → `{chapters:[{id,title}]}`。
|
||||
- `POST /api/v1/books/:id/switch-source`:`{entryRef,chapterId,revision}` → `{content,href}`;所选正文验证成功后事务切换,保留 bookId。目录变更返回冲突,客户端刷新再选择。
|
||||
|
||||
后台任务 RPC、持久化及限制见[插件扩展设计](plugin-extensions.md)。页面只接受声明式数据,不执行插件提供的浏览器脚本。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 插件页面、多书源搜索与换源
|
||||
|
||||
## 目标与职责
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| PluginManager / ProcessPlugin | 校验扩展声明、管理员页面 RPC、独立数据目录、持久任务时点与退避、进程启停 |
|
||||
| SourceProvider | 动态搜索选项、透传筛选条件、返回同书候选 |
|
||||
| ChapterPublications | 校验私有书籍、换源事务、稳定 bookId、目录修订和旧缓存 |
|
||||
| PluginPageScreen / ReaderScreen | 通用表单列表、搜索选择框、候选目录与明确章节选择 |
|
||||
| Catalog / Library(npm 包) | 订阅拉取与合并、多规则管理、搜索调度、引用路由、账号引擎隔离 |
|
||||
|
||||
## 声明式页面与持久化
|
||||
|
||||
实例扩展在 manifest 的 `sourceTypes[].extensions` 下声明(以下为扩展示意):
|
||||
|
||||
GET 调用 `extension.page`;POST 调用 `extension.action`,参数为 `{sourceType,context:{instance,userId},pageId,action,values}`。宿主根据 sourceId 从数据库解析插件与实例,客户端不能伪造上下文;暂停的来源仍可管理,停用插件则不可访问页面。普通读者无页面读取及写入权限。
|
||||
|
||||
返回 Page DTO:`title/description/notice/forms/sections/tabs/activeTab`。每个 Tab 包含 `id/title/description/forms/sections`,页面公共内容和当前 Tab 同时渲染。Form 包含 `id/title/submit/fields/values`,可选 `layout: "inline"` 和 `confirm` 声明紧凑布局及行内二次确认,fields 支持 text、textarea、number、boolean、select;values 携带不透明行 ID。Field 可选 `placeholder/min/max`,Page 可选 `noticeKind: "info" | "error"`;所有字段由宿主验证。section 支持 `emptyText`,item 支持 `collapsible`。一次动作返回新页面;可选 activeTab 请求切换到指定 Tab,否则保留当前选择。纯 Tab 切换保留输入草稿。提交后保留其它声明未变化的表单草稿;服务端改变表单声明或主动刷新时以新页面为准。
|
||||
|
||||
宿主校验声明、页面结构、选项和输入配额;未声明页面拒绝访问。渲染器只呈现文本,不接受 HTML、JS、iframe 或任意前端代码。授权在 HTTP 层执行,隐藏入口不代替鉴权。
|
||||
|
||||
## 后台任务
|
||||
|
||||
宿主每分钟检查启用插件及启用来源实例的声明任务。实例任务调用 `extension.task({taskId,sourceType,context:{instance,userId:""}})`,不携带个人凭据;全局任务继续使用 `{taskId}`。任务串行、同轮合并,执行前重新确认实例仍存在且启用。同一实例的后台任务与管理写入互斥,不阻塞其它实例的配置;下一执行时点和失败次数以插件、实例、任务组合键保存在通用 `plugin_storage`,重启恢复,失败退避。暂停/删除实例后不再调度它,停用插件或关闭宿主会终止进程。此调度独立于章节追更。
|
||||
|
||||
## 通用搜索选项
|
||||
|
||||
声明 `search.filters`,实现 `searchFilters(ctx)`,返回带不透明 key/value 的选择字段;`SearchRequest.filters` 为字符串映射,宿主只验证结构与大小。搜索历史与下一页保留筛选条件。
|
||||
|
||||
## 通用换源
|
||||
|
||||
1. 目录面板根据 `source-options.canSwitch` 显示换源入口,用户搜索候选。
|
||||
2. 点击候选取得其目录。唯一同名章节可预选,否则必须手动选择;确认后从所选章开头阅读,不推测百分比或沿用旧章内偏移。
|
||||
3. 请求携带旧 revision。宿主与刷新共用书籍串行队列,版本不符返回冲突。获取新目录、确认章节存在、读取正文并清洗/内嵌图片全部成功后才提交数据库事务。
|
||||
4. 事务保存新快照和已验证正文、替换 publicationRef、保留 bookId、书架与订阅,清零新增章计数并推进订阅 generation。失败保留原绑定、目录与缓存。
|
||||
5. 客户端读到落点正文后才替换阅读视图,并保存新进度。旧修订缓存继续可读;旧笔记/书签保留原 href,不自动迁移到相似章节。其它已打开客户端仍需刷新目录,未缓存旧章节返回快照过期。
|
||||
|
||||
重新获取现绑定复用当前书籍 ID;重新加入换源前版本创建独立记录,不会误返回换源后的版本。换源限同一来源实例内提供的候选,跨插件/跨实例不是本版功能。如果目标版本已作为另一条书籍记录入库,返回 SOURCE_ALREADY_ACQUIRED,需从书架打开已有版本,以免合并或覆盖另一本书的笔记和进度。
|
||||
|
||||
## 升级及验证
|
||||
|
||||
## 验证方式
|
||||
|
||||
生产 bundle 的 Chrome 验证脚本覆盖 390px 手机和 1280px 桌面布局、从自定义来源入口进入、三个 Tab、规则展开与编辑、订阅启停、搜索筛选和目录换源。运行 `node web/tools/ui-review/plugin-extensions.mjs`(仓库根目录,预先构建 Web;CHROME_PATH 可指定浏览器),截图输出到 docs/ui-review/plugin-*.png。
|
||||
|
||||
## 本次验证(2026-09-21)
|
||||
|
||||
- 服务端全量 287 通过,1 项 Windows 文件权限位测试跳过;类型检查与构建通过。
|
||||
- Web 全量 532 项 Vitest(maxWorkers=2)及 29 项 Node 测试通过,生产构建通过。
|
||||
- 插件原有 14 项测试通过(含真实 Chromium 和独立 tarball 安装);新增 Tab 声明测试及 Catalog 回归 3 项通过,共覆盖 15 项插件测试。
|
||||
- Chrome 生产界面检查通过:390px 手机、1280px 桌面、来源实例入口、Tab 切换、规则编辑、订阅启停、筛选及换源。
|
||||
+27
-22
@@ -1,6 +1,6 @@
|
||||
# 来源插件系统设计
|
||||
|
||||
状态:内置来源、进程插件和章节阅读闭环已实现
|
||||
状态:内置来源、插件、配置 UI、章节富文本/图片与自动追更已实现
|
||||
|
||||
适用接口:`/api/v1`,来源插件协议 1
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
|
||||
本文定义 reader 对本地书库、OPDS 书库和远程书源的统一扩展架构。目标是让核心程序只维护一套书架、阅读、缓存和任务逻辑;内置 `local`、`opds` 两种来源;远程书源以独立插件开发、安装和升级。
|
||||
|
||||
本文区分目标架构和本轮交付。当前已实现 local、OPDS 下载入库、受信任 Node 来源插件及管理接口;外部章节已接入持久化出版物、书架、按章节缓存、手动刷新和既有阅读进度。设备端缓存已读章节,不提供整书预下载或后台自动追更。交付状态与验证见 §13,调用示例见 [API 参考](api.md#来源与插件)。
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
### 1.1 目标
|
||||
@@ -169,7 +167,7 @@ interface PublicationContent {
|
||||
}
|
||||
```
|
||||
|
||||
SDK 的 `ResourceResponse` 支持 `mediaType`、`size`(若已知)、`data`、`text` 和 `stream`。内置 OPDS 提供者通过流下载。首个外部进程只支持章节来源,声明 `acquire.file` 会被拒绝;资源通过有大小上限的 `text/base64` JSON 传输。来源级开发预览始终返回 JSON,实际入库阅读只接受 UTF-8 `text/plain`,暂不支持远程 HTML、图片或其他富文本。后续资源通道支持背压和取消后,外部插件才适合传输大文件。
|
||||
SDK 的 `ResourceResponse` 支持 `mediaType`、`size`、`data`、`text` 和 `stream`。内置 OPDS 使用流下载;进程插件暂只支持章节,`acquire.file` 会被拒绝。进程资源通过有大小上限的 `text/base64` JSON 传输;入库章节接受 UTF-8 纯文本或 HTML/XHTML,HTML 内插图通过 `reader-res:<ref>` 引用同一插件的二进制资源。宿主清洗并内嵌图片后才返回给阅读器。来源开发预览始终返回 JSON。
|
||||
|
||||
### 4.3 能力
|
||||
|
||||
@@ -220,7 +218,7 @@ content.update
|
||||
|
||||
### 5.2 宿主能力
|
||||
|
||||
SDK 预留以下能力;首个切片的 `SourceContext` 中能力注入是可选的,独立进程内暂未实现宿主能力的反向 RPC,不能假设以下约束已强制生效:
|
||||
SDK 的目标能力如下。独立进程尚未实现宿主能力的反向 RPC;已实现的凭据注入会在每次调用时,按用户、来源实例、`credentialKeys` 和 `permissions.credentials` 选择允许传递的键,放入 `context.credentials`。这不等于以下全部运行约束都已强制生效:
|
||||
|
||||
| 能力 | 规则 |
|
||||
| --- | --- |
|
||||
@@ -239,7 +237,7 @@ SDK 预留以下能力;首个切片的 `SourceContext` 中能力注入是可
|
||||
|
||||
```text
|
||||
request = { jsonrpc: "2.0", id, method,
|
||||
params: { sourceType, context: { instance, userId }, ... } }
|
||||
params: { sourceType, context: { instance, userId, credentials? }, ... } }
|
||||
response = { jsonrpc: "2.0", id, result }
|
||||
error = { jsonrpc: "2.0", id, error: { code, message, data? } }
|
||||
```
|
||||
@@ -270,9 +268,9 @@ error = { jsonrpc: "2.0", id, error: { code, message, data? } }
|
||||
|
||||
目标上插件负责搜索、详情、目录快照、正文清洗和图片引用;宿主负责缓存、限流、任务、权限和阅读协议。插件返回 `chapters` 绑定后,宿主获取目录快照、分配稳定 book ID 并加入调用用户书架;HTTP 返回 `ready/publicationId`,阅读器继续使用 `/books/:id/manifest|toc|assets`。
|
||||
|
||||
当前入库阅读只接受 UTF-8 `text/plain`。正文按纯文本下发,客户端负责排版和转义;HTML、图片和其他富文本资源会被拒绝。章节资源第一次读取后持久化,插件禁用或卸载后仍可读取已缓存正文,尚未缓存的章节返回来源不可用。
|
||||
入库阅读支持 UTF-8 `text/plain`、`text/html` 和 `application/xhtml+xml`。纯文本由客户端转义排版;HTML 经 `sanitize-html` 白名单处理,只保留段落、标题、强调、列表、表格等静态标签。删除脚本、样式、表单、外链和 SVG。插图须声明 `reader-res:<插件资源引用>`,宿主调用同一来源 `readResource`,校验 PNG/JPEG/GIF/WebP 类型与文件签名,再内嵌 data URL。整章(含图片)原子缓存,任一资源失败不写半份缓存。禁用或卸载插件后已缓存正文和插图仍可读。
|
||||
|
||||
刷新目录通过 `POST /books/:id/refresh` 显式触发;Web 阅读器目录面板提供“刷新目录”,以稳定章节 href 恢复当前位置。获取或校验失败保留旧目录,界面在目标章节可读后才替换视图。本轮没有定时检查新章、整书预下载或后台追更任务。
|
||||
刷新目录通过 `POST /books/:id/refresh` 或订阅调度触发;阅读器目录面板以稳定 href 恢复当前位置。失败保留旧目录,界面在目标章节可读后才替换视图。`chapter_subscriptions` 持久保存开关、15~10080 分钟间隔、检查时间、失败次数和新增章提示。每分钟调度一次,单轮最多 20 本顺序检查,共用来源并发限制;手动与自动检查同书合并在途调用。失败退避,暂停来源、隐藏书籍、禁用账号时跳过,关闭浏览器不影响检查。新目录不强行替换正在阅读的旧快照,也不预下载正文。
|
||||
|
||||
## 7. 数据模型
|
||||
|
||||
@@ -286,7 +284,8 @@ error = { jsonrpc: "2.0", id, error: { code, message, data? } }
|
||||
| `source_acquisitions` | `(source_id, user_id, entry_ref, option_id)` 到 `book_id` 的映射。 |
|
||||
| `chapter_publications` | 按用户隔离的章节出版物、来源引用、当前 revision 和目录快照。 |
|
||||
| `chapter_snapshots` | 出版物历次目录快照;刷新后保留旧 revision。 |
|
||||
| `chapter_resources` | 每个 revision 的章节引用、已缓存正文和正文 SHA-256。 |
|
||||
| `chapter_resources` | 每个 revision 的章节引用、已缓存正文、`media_type` 和正文 SHA-256;HTML 正文包含校验后的内嵌图片。旧缓存增量迁移为纯文本。 |
|
||||
| `chapter_subscriptions` | 每书追更开关、间隔、检查时间、失败退避、新增章数及配置 generation。 |
|
||||
| `plugin_storage` | 宿主 KV,按来源和用户命名空间保存;外部进程尚无反向访问接口。 |
|
||||
| `installed_plugins` | 插件 ID、已放置的包目录名及启用状态。 |
|
||||
|
||||
@@ -362,9 +361,9 @@ QUOTA_EXCEEDED 缓存或下载配额不足
|
||||
- 内置 OPDS 的网络请求经过宿主校验:允许 origin、重定向目标、Basic 凭据转发、超时和响应大小;普通 OPDS 链接不能跳到未配置的 origin。
|
||||
- 外部 Node 插件当前是受信任进程,仍拥有本机 Node 的网络、文件和子进程权限;manifest 权限声明和 `SourceContext` 可选能力尚未构成 OS 级网络沙箱。管理员只能安装信任的包。
|
||||
- 插件凭据不出现在普通配置、前端 DTO 和日志中。
|
||||
- 外部插件资源预览只返回 JSON 文本或 base64;入库章节只接受纯文本,并带 `nosniff` 和限制内容执行的 CSP。阅读器自行转义正文,不执行插件 HTML;远程富文本和图片清洗属于后续工作。
|
||||
- 资源预览只返回 JSON;阅读正文纯文本转义或 HTML 白名单清洗,并带 `nosniff`、禁止脚本和外网资源的 CSP。图片经来源通道获取后内嵌,不让浏览器直接访问书源网站。
|
||||
- 本地和已获取文件路径通过宿主 `resolveInside()` 校验;外部插件直接读写路径的能力目前依赖管理员信任,系统沙箱属于后续工作。
|
||||
- 当前硬限制是宿主来源调用 60 秒、插件 RPC 30 秒、单条 JSON 2 MiB、文件获取 256 MiB;章节目录最多 10,000 项及 2 MiB,单章纯文本最多 2 MiB(仍受 RPC 消息上限约束)。CPU/内存和域名权限的强制隔离属于后续工作。
|
||||
- 硬限制:宿主调用及整章加载 60 秒、插件 RPC 30 秒、JSON 2 MiB、文件获取 256 MiB;目录最多 10,000 项及 2 MiB,原始正文 2 MiB;最多 32 个不同图片、每图 2 MiB,内嵌后整章最多 8 MiB。图片仍受 RPC base64 开销限制。CPU/内存和域名权限的系统级隔离属于后续工作。
|
||||
- 插件安装及来源实例配置需要管理员权限;实例配置共享,凭据按用户隔离。
|
||||
|
||||
manifest 中的权限声明用于宿主授权和展示,不能单独视为安全沙箱;运行不可信插件需要容器或操作系统策略。
|
||||
@@ -393,7 +392,7 @@ GET /sources/:id/publications/:publicationRef/resource?ref=...
|
||||
POST /books/:id/refresh 获取并提交新的章节目录快照
|
||||
```
|
||||
|
||||
HTTP 返回宿主 DTO,凭据不回显。插件管理均需管理员权限;来源浏览、搜索、获取和当前用户凭据设置需登录。来源的 URL、名称和 config 更新、来源删除、插件包上传及升级尚无接口。请求参数、响应和可运行示例见 [API 参考](api.md#来源与插件)。
|
||||
HTTP 返回宿主 DTO,凭据不回显。插件和来源配置管理需管理员权限;浏览、获取和个人凭据需登录。`PATCH /sources/:id` 更新名称/config/启停,config 变更清除所有用户的该来源凭据;有在途来源调用时拒绝变更 config。`DELETE` 只移除无获取记录的非本地来源。Web `#/sources` 根据类型 schema 绘制配置表单,普通成员只有浏览、凭据和个人追更操作。插件可声明 credentialKeys 与 credentials 权限,宿主只按调用用户向进程传递明确声明的键。包上传和在线升级尚未实现。
|
||||
|
||||
## 11. 分阶段实施
|
||||
|
||||
@@ -439,8 +438,6 @@ HTTP 返回宿主 DTO,凭据不回显。插件管理均需管理员权限;
|
||||
|
||||
## 13. 本轮实施状态与后续工作
|
||||
|
||||
本轮交付服务端扩展接口、OPDS 下载阅读闭环和纯文本章节阅读闭环。状态截至 2026-09-20;后续条目不能视为已交付。
|
||||
|
||||
| 工作 | 本轮状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 来源契约 | 已实现并测试 | `server/src/sources/types.ts`,包括目录、获取、章节和资源。 |
|
||||
@@ -448,22 +445,30 @@ HTTP 返回宿主 DTO,凭据不回显。插件管理均需管理员权限;
|
||||
| OPDS 解析、浏览、搜索、获取 | 已实现并测试 | OPDS 1.x/2.x、相对链接、OpenSearch、分页、Basic 认证和获取关系。 |
|
||||
| OPDS 文件下载入库 | 已实现并测试 | 同步下载,真实哈希、256 MiB 上限、失败清理、幂等复用、当前用户上架;复用现有阅读接口。 |
|
||||
| SourceHost、来源实例及凭据 | 已实现 | 实例由管理员配置共享,密码按来源/用户加密,调用预算 60 秒;并发限额每实例 2、全宿主 8。 |
|
||||
| 来源管理 HTTP 接口 | 已实现并测试 | 创建、列表、启停和来源调用;仅 `enabled` 可更新,不含前端配置页面。 |
|
||||
| 来源管理与配置 UI | 已实现并测试 | 创建、编辑、启停、删除空来源、浏览/搜索、获取及个人凭据。 |
|
||||
| 内置 local 与本地内容 seam | 已实现并测试 | 保留现有 book ID、书架及阅读接口,集中本地与下载文件解析。 |
|
||||
| 受信任 Node ProcessRunner | 已实现并测试 | JSON-RPC、30 秒调用预算、硬取消、异常退出、2 MiB 消息上限。 |
|
||||
| 插件安装、启停、卸载与启动恢复 | 已实现并测试 | 包由管理员预置到 `DATA_DIR/plugins`;卸载保留数据和包文件。 |
|
||||
| 最小外部来源示例 | 已实现并测试 | `examples/plugins/demo-chapters`,提供静态示例目录和正文,不联网。 |
|
||||
| 插件安装 UI、包上传、在线升级与状态回滚 | 后续 | 本轮不承诺完整插件市场和包管理。 |
|
||||
| 插件安装 UI | 已实现 | 安装预部署目录或 `npm:包名`、启停、重启失败进程、卸载;包上传、在线升级和回滚仍后续。 |
|
||||
| 后台下载任务、断点恢复、JobManager | 后续 | 当前完整文件会持久化,下载过程本身没有后台任务及恢复机制。 |
|
||||
| 外部章节写入书架与阅读 | 已实现并测试 | 私有稳定 book ID、现有 manifest/items/toc/assets、书架、继续阅读和进度。 |
|
||||
| 目录快照与服务端章节缓存 | 已实现并测试 | 稳定 href、revision 资源引用、已读缓存、失败保留旧目录、禁用/卸载/重启后缓存可读。 |
|
||||
| Web 章节阅读与设备离线缓存 | 已实现 | 目录与已读章节按服务器和用户持久缓存;断网或服务端 5xx 时回退;授权及资源不存在错误不回退。 |
|
||||
| 手动刷新目录 | 已实现 | 阅读器目录面板入口;恢复当前章节位置;无后台追更。 |
|
||||
| 远程富文本、图片、整书预缓存、缓存配额与淘汰 | 后续 | 当前只支持 UTF-8 纯文本,设备仅已读章节可离线。 |
|
||||
| 自动检查新章与追更任务 | 后续 | 当前只有显式刷新 API 和阅读器按钮。 |
|
||||
| 反向宿主能力 RPC、凭据和配额强制隔离 | 后续 | 类型预留不等于运行策略已生效。 |
|
||||
| 手动刷新目录 | 已实现 | 阅读器目录面板入口,恢复当前章节位置,记录检查状态。 |
|
||||
| 远程富文本与图片 | 已实现并测试 | 白名单 HTML、受控栅格图片、服务端与设备整章缓存;设备仅已读内容可离线。 |
|
||||
| 整书预缓存、缓存配额与淘汰 | 后续 | 保留历史目录与缓存,无自动清理配额。 |
|
||||
| 自动追更 | 已实现并测试 | 用户按书订阅,服务端定时检查、持久状态、失败退避与新增章提示。 |
|
||||
| 声明凭据注入 | 已实现并测试 | 每次调用仅传递获准声明键,按用户和来源隔离。 |
|
||||
| 反向宿主能力 RPC、系统权限隔离和缓存配额 | 后续 | 类型预留不等于运行策略已生效。 |
|
||||
| 容器/操作系统沙箱 | 后续 | 首个版本仅允许运行管理员信任的插件。 |
|
||||
|
||||
验证覆盖来源、插件进程、管理接口、OPDS 下载、用户隔离、并发限额、参数校验与既有本地阅读回归。章节端到端测试使用真实 Node 进程和可变目录 fixture,覆盖获取幂等、插章定位、版本缓存、旧快照拒绝、刷新失败、禁用/卸载/扫描/重启和 HTML 资源拒绝;核心测试另覆盖迁移、请求与刷新并发及资源边界。测试均使用本地 HTTP fixture 或示例插件,未验证特定公网 OPDS 服务或第三方采集站点。
|
||||
验证覆盖来源、插件进程、管理接口、OPDS 下载、用户隔离、并发限额、参数校验与既有本地阅读回归。章节端到端测试使用真实 Node 进程和可变目录 fixture,覆盖获取幂等、插章定位、版本缓存、旧快照拒绝、刷新失败、禁用/卸载/扫描/重启、媒体类型不一致拒绝及 HTML 清洗;核心测试另覆盖旧缓存迁移、请求与刷新并发及资源边界。原生规则插件测试使用真实 Node 进程和本地 HTTP fixture,覆盖 npm 安装、按用户 Cookie、插章后按 URL 定位、图片缓存和追更;包测试另覆盖真实 Chromium 与脚本兼容对象,未验证特定公网 OPDS 服务或第三方采集站点。
|
||||
|
||||
本轮验证结果:服务端 `npm test` 274 通过、1 项因 Windows 不支持 Unix 目录权限位而跳过;Web `npm test` 547 通过;两端类型检查和生产构建通过。真实 Chrome 验证了刷新按钮显示、插入章节后保留当前章节及新增章节提示。全页 UI 评审另有两项未通过(沉浸分页顶行截断、书架操作按钮透明度),未纳入本轮修复;Android 安装包未构建。
|
||||
上一轮(配置 UI/追更交付时)验证结果:服务端全量 `npm test` 281 通过、1 项因 Windows 不支持 Unix 目录权限位而跳过,包含旧缓存媒体类型迁移回归。Web `npm test` 552 通过;两端类型检查和生产构建通过。真实 Chrome 检查了书架入口、配置表单、追更列表及插件信任勾选,在 390×844 手机和 1280×900 桌面布局下没有横向溢出。本轮未重跑全页 UI 评审;上一轮记录的沉浸分页顶行截断、书架操作按钮透明度问题仍未纳入修复。Android 安装包未构建。
|
||||
|
||||
## 通用配置页、任务、筛选与换源
|
||||
|
||||
已实现 manifest.extensions.pages/tasks、独立数据目录、声明式页面、通用后台任务、search.filters 与 content.alternatives。详情、责任分配和失败语义见[扩展设计](plugin-extensions.md)。这些扩展不依赖具体插件 ID。
|
||||
|
||||
### 0.3 实例配置作用域
|
||||
@@ -0,0 +1,35 @@
|
||||
# 书架与书库样式优化
|
||||
|
||||
日期:2026-09-21。下文记录本轮最终行为。
|
||||
|
||||
- 标题与辅助文字统一;书架提供“书库 / 书源”,书库仅向管理员提供“文件管理”。书库浏览页没有上传输入框、上传入口和书架设置按钮。子目录的面包屑单列显示。
|
||||
- 搜索框统一圆角与高度;空书架隐藏排序栏,刷新移至标题栏。
|
||||
- 空状态统一为图标、标题、说明和主操作卡片。空书架可去搜书或打开书库;空书库引导管理员前往文件管理,普通成员只显示等待管理员添加书籍的提示。存在未识别文件时管理员可查看文件管理。空库不再显示单独的“0 本”状态栏。
|
||||
- 上传与新建文件夹仅在管理员文件管理页出现,只读目录隐藏这两个操作。文件行将名称与大小、时间、状态分两行显示,长名称最多显示两行;点击操作菜单不会再触发行本身的打开动作。
|
||||
- 文件管理返回:子目录回上一级,根目录回书库浏览页;刷新和直达链接也遵循此规则。书架设置 sheet 固定在视口底部,关闭按钮靠右且点击区域为 44px。
|
||||
- 上传 POST 和写入探测 GET 接口均验证管理员权限;普通成员直接调用返回 403,测试确认拒绝后目录未发生变化。
|
||||
- 保留搜索、排序、目录导航和有书时的封面网格。
|
||||
|
||||
验证:Web 构建、服务端类型检查;相关 Web 组件和路由测试,及服务端上传测试通过。生产 bundle 在 Chromium 中检查 320/390/1280px 空状态、有书、子目录、无搜索结果、只读和深色主题;验证书源、书库、文件管理上传选择器、目录返回、成员直达文件管理的拦截、设置 sheet 关闭按钮位置,以及页面和导航无横向溢出。
|
||||
|
||||
浏览器脚本使用可控 API fixture;没有上传实际文件,也未进行 Android/iOS 真机测试。
|
||||
|
||||
电脑端适配(2026-09-21):从 1024px 起,书架导航与搜索并排,书架、书库、书源和文件管理统一使用最大 80rem 内容宽度与两侧留白。文件操作收至标题右侧,列表采用文件名、元数据、菜单分列布局。书架设置改为右侧 26rem 面板,支持 Esc 关闭并恢复触发按钮焦点;缩窄窗口时恢复底部 sheet。书源及通用插件页面使用紧凑左对齐 tab,标题与正文保持统一宽度。
|
||||
|
||||
桌面验证:生产 Web 在 Chromium 中覆盖 1024×768、1366×768、1920×1080;检查书架、书库、文件列表、设置侧栏、键盘焦点、暗色主题和打开面板时跨断点缩放;同时保留 320/390px 手机回归及短视口吸顶断言。34 项书架/书库组件测试通过;真实 HTTP 服务、SQLite、Node 插件的订阅、隔离、筛选搜索、入架、阅读和换源端到端通过。桌面截图为 `collections-*-desktop-*.png`,插件页面截图为 `plugin-config-desktop-*.png`。尚未验证 Firefox、Safari。
|
||||
|
||||
阅读页桌面适配:正文采用 52rem 居中纸张区域,顶栏与纸张对齐;底部使用 50rem 悬浮控制条,进度与操作按钮用分隔线区分。纸张外围背景跟随当前主题,浅色、米黄、浅绿和夜间主题均使用主题色。目录使用左侧面板,界面、朗读和设置使用右侧面板,遮罩降低深度以便预览正文。面板支持 Esc 关闭及焦点恢复,聚焦不触发页面滚动。`desktop-reader.mjs` 验证三种桌面尺寸下四个面板、工具栏显隐、正文尺寸/页码/滚动位置不变、翻页模式、夜间主题,以及 1024×420 短窗口的滚轮操作。6 项阅读工具栏组件测试及移动端 `reader-regression.mjs` 全部通过,后者覆盖正文翻页、阅读信息、主题、插图、漫画、PDF 和 320px 面板。截图为 `desktop-reader-*.png`。
|
||||
|
||||
宽度回归:书库、书源主页、插件配置页共用 `--collection-measure`。在 1024/1366/1920px 下逐页比较内容容器及标题的左右坐标和宽度,断言相等;同时验证插件配置的嵌套卡片对齐正文边界,避免只有外层变宽而卡片仍受旧限制。
|
||||
|
||||
正文翻页回跳修复:滚动模式原先用整章高度的 `100%` 做滑动动画,实际滚动 619px 后又产生约 3089px 的动画位移。现在用本次真实滚动距离,同时限制章末对齐位置不超过可滚动范围,避免最后一页页码失真。`page-turn-regression.mjs` 在 1366/390px 下逐帧采集正文坐标及滚动偏移,验证从首翻至末、再翻回首时不反向移动,且每次恰好改变一页;修复前该脚本能重现失败,修复后通过。构建、80 项 reader-view/shadow 测试及移动阅读回归通过。
|
||||
|
||||
书架设置吸顶修复:移除滚动区域顶部内边距,并取消首个分组标题的负外边距,避免标题吸顶时露出下方控件。Web 构建及浏览器检查通过;在 320/390/1280px 宽、568px 高的视口实际滚动面板,验证吸顶标题与面板头部无间隙,顶部命中区域始终由标题覆盖。截图为 `collections-shelf-settings-sticky-*.png`。
|
||||
|
||||
```powershell
|
||||
npm --prefix web run build
|
||||
$env:CHROME_PATH='C:\Program Files\Google\Chrome\Application\chrome.exe'
|
||||
node web/tools/ui-review/collections.mjs
|
||||
```
|
||||
|
||||
截图输出到本目录 `collections-*.png`,重点预览:`collections-shelf-empty-390.png`、`collections-library-empty-390.png`、`collections-shelf-books.png`、`collections-library-books.png`。
|
||||
@@ -0,0 +1,64 @@
|
||||
# 书源端到端与 UI/UX 验证
|
||||
|
||||
日期:2026-09-21。浏览器:Windows Chrome,headless;生产 Web bundle、真实 Fastify HTTP 服务、SQLite 和独立 Node.js 插件进程。测试创建临时数据目录,运行完清理,不操作现有书库。上游网站由本地 HTTP fixture 提供 45 个书源、小说搜索、详情、目录与富文本正文;没有模拟 reader API。
|
||||
|
||||
## 实际发现与修复
|
||||
|
||||
| 问题 | 修复后行为 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 新用户注册后立即掉线 | 正确读取注册响应内的 session;页面重载后仍保持登录 | 浏览器注册、重载、管理员安装插件;API 回归 |
|
||||
| 第 2 页保存规则设置后回到第 1 页,且没有反馈 | 操作携带当前筛选和页码;编辑 JSON 再保存也回原页;明确显示保存结果 | 45 条书源的第 2 页操作 |
|
||||
| 长列表中 Tab 和反馈滚出视野 | 配置分类与状态提示放在固定工具区;切换分类回到其内容顶部 | 手机长列表截图、控件视口断言 |
|
||||
| 订阅失败只显示 SUBSCRIPTION_HTTP | 展示中文原因、HTTP 状态和旧规则保留说明;错误使用 alert;恢复成功清除错误 | 上游 HTTP 503 → 200 |
|
||||
| 修改订阅周期必须手动重填 URL | 每个订阅直接显示可编辑的当前周期,保存时不重新下载或改变暂停状态 | 周期 360 → 60 分钟 |
|
||||
| 保存其它表单会丢失导入草稿 | 原声明未变化的其它表单保留草稿;服务端改变表单内容时采用新值,避免覆盖规则编辑内容 | 导入草稿 → 添加订阅 → 保存周期 → 返回导入 |
|
||||
| 删除和移除订阅立即生效 | 插件声明确认文本;通用渲染器提供行内确认/取消 | 取消删除后原规则仍存在;组件测试确认前无 POST |
|
||||
| 选择器的无障碍名称混入选项文本 | 控件显式使用字段标签;支持键盘 Tab 导航、操作反馈焦点 | 精确 label 浏览器定位、组件焦点断言 |
|
||||
| 桌面表单占用过多纵向空间 | 插件声明 inline 布局,宽屏横排、窄屏换行;常用按钮至少 44px 高 | 320/390/768/1280px 截图 |
|
||||
| 创建和打开书源后缺乏下一步提示 | 保存后显示此实例配置入口;编辑/打开时定位到对应表单或搜索区 | 从 UI 创建两个独立来源并进入配置 |
|
||||
|
||||
## 端到端覆盖
|
||||
|
||||
- 首次注册、登录持久化、通过 UI 安装本地部署的 npm 插件。
|
||||
- 创建两个自定义来源;一个来源导入订阅,另一个保持空库。
|
||||
- 无效 JSON 错误提示及输入保留;导入草稿跨 Tab 和其它动作保留。
|
||||
- 订阅导入、周期修改、失败提示、保留原规则、恢复后更新。
|
||||
- 长列表分页、分组修改、原始规则编辑、删除确认与取消。
|
||||
- 分组与指定书源组合搜索,加入书架,真实插件正文渲染,目录选章换源。
|
||||
- 320px、390px、768px、1280px 无页面水平溢出;深色提示可读;无 pageerror。
|
||||
- 既有可控故障浏览器脚本继续覆盖换源失败时原正文保持不变。
|
||||
|
||||
## 复现
|
||||
|
||||
```powershell
|
||||
$env:CHROME_PATH='C:\Program Files\Google\Chrome\Application\chrome.exe'
|
||||
npm --prefix web run ui:sources
|
||||
```
|
||||
|
||||
单独运行既有故障交互检查(先构建 Web):
|
||||
|
||||
```powershell
|
||||
node web/tools/ui-review/plugin-extensions.mjs
|
||||
```
|
||||
|
||||
截图写入 docs/ui-review/sources-e2e-*.png;失败时输出当前页面文本及 sources-e2e-failure.png。脚本启动临时端口、临时数据库,并验证清理路径属于系统临时目录后移除。
|
||||
|
||||
回归:Web 534 项 Vitest、插件 16 项(含真实 Chromium 和 tarball 独立安装)、服务端协议及来源 API 11 项通过;前后端类型检查/构建通过。
|
||||
|
||||
## 范围限制
|
||||
|
||||
这是桌面 Chromium 的视口测试,未运行 Android APK 或 iOS 真机,也未逐一认证公网书源站点。定时调度的重启、隔离和暂停行为由现有服务端/插件测试覆盖,本次浏览器脚本未等待 15 分钟的最短实际订阅周期。未进行完整 WCAG 审计。
|
||||
|
||||
## 书源主页面层级调整(同日后续)
|
||||
|
||||
- 顶部“书源 / 自动追更 / 插件管理”改为真正的 Tab:等宽布局、选中下划线、ARIA Tab 与面板关联;支持左右方向键、Home、End 和键盘焦点提示。普通成员不显示插件管理。
|
||||
- 来源行默认只显示主要操作“打开”和次要入口“管理”;订阅与规则管理、基本设置、启停集中在行内展开区,一次只展开一个来源。Esc 关闭并返回管理入口。
|
||||
- 本轮验证:Web 生产构建及相关组件测试 8 项通过;真实服务端端到端脚本和既有故障交互浏览器脚本均通过。补充 320/390/1280px 主页面无横向溢出断言、Tab 键盘切换,以及管理展开截图。没有重复运行上文的全量测试。
|
||||
- 主页面截图:`sources-e2e-hub-390.png`、`sources-e2e-hub-320.png`、`sources-e2e-hub-1280.png`;展开管理截图:`sources-e2e-hub-management-mobile.png`。Tab 截图中的外框是键盘 focus-visible 提示。
|
||||
|
||||
## 表单弹窗与搜书分离(同日后续)
|
||||
|
||||
- 默认进入“搜书”:选择已启用的来源后展示其搜索筛选、目录和结果。“书源管理”成为单独的管理员 Tab,只负责来源配置;切换两者时保留当前搜索关键词、筛选和结果。暂停、卸载或删除导致来源不可用后,搜索选择器与旧结果同步清理。
|
||||
- 新增、编辑来源及个人登录凭据改为原生 dialog;背景不可交互,Tab/Shift+Tab 在弹窗内循环,Esc/关闭/取消返回操作入口,保持页面滚动位置。保存期间禁止重复操作或关闭;保存失败在弹窗内展示原因、聚焦提示并保留输入。
|
||||
- 表单内容独立滚动,标题与操作栏固定。320×568、390×844 和 1280×844 下,保存按钮均位于视口内。配置仍由通用 schema 声明,无插件类型特判。
|
||||
- 本轮组件测试扩展至 11 项,覆盖凭据不回显、管理与搜索隔离、搜索状态保留、来源暂停、弹窗保存失败/取消/保存/忙碌期间关闭等行为。真实服务端脚本增加新增和编辑弹窗的焦点返回、Esc、焦点循环与滚动位置验证;继续覆盖订阅、搜索、阅读和换源。
|
||||
Generated
+349
-1
@@ -14,10 +14,12 @@
|
||||
"fast-xml-parser": "^4.5.1",
|
||||
"fastify": "^5.2.1",
|
||||
"jszip": "^3.10.1",
|
||||
"pino": "^9.6.0"
|
||||
"pino": "^9.6.0",
|
||||
"sanitize-html": "^2.17.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
@@ -638,6 +640,121 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmmirror.com/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
|
||||
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"htmlparser2": "^10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/dom-serializer/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
@@ -775,6 +892,12 @@
|
||||
"ts-type": ">=2"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.0.0.tgz",
|
||||
@@ -787,6 +910,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -796,6 +928,85 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-3.1.1.tgz",
|
||||
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-3.0.0.tgz",
|
||||
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-6.0.1.tgz",
|
||||
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/domutils/-/domutils-4.0.2.tgz",
|
||||
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^3.0.0",
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-8.1.0.tgz",
|
||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/epub2": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/epub2/-/epub2-3.0.2.tgz",
|
||||
@@ -852,6 +1063,18 @@
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-decode-uri-component": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
||||
@@ -1018,6 +1241,28 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-12.0.0.tgz",
|
||||
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"domutils": "^4.0.2",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
@@ -1039,6 +1284,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.1.0.tgz",
|
||||
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
@@ -1082,6 +1336,15 @@
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/launder": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmmirror.com/launder/-/launder-1.7.1.tgz",
|
||||
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.7"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
@@ -1134,6 +1397,24 @@
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
@@ -1149,6 +1430,18 @@
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||
@@ -1186,6 +1479,34 @@
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -1309,6 +1630,24 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.7",
|
||||
"resolved": "https://registry.npmmirror.com/sanitize-html/-/sanitize-html-2.17.7.tgz",
|
||||
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"launder": "^1.7.1",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||
@@ -1367,6 +1706,15 @@
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
|
||||
+3
-1
@@ -21,10 +21,12 @@
|
||||
"fast-xml-parser": "^4.5.1",
|
||||
"fastify": "^5.2.1",
|
||||
"jszip": "^3.10.1",
|
||||
"pino": "^9.6.0"
|
||||
"pino": "^9.6.0",
|
||||
"sanitize-html": "^2.17.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
export const MIGRATIONS_SQL = `
|
||||
ALTER TABLE book_files ADD COLUMN parse_version INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE books ADD COLUMN content_hash_kind TEXT NOT NULL DEFAULT 'file';
|
||||
ALTER TABLE chapter_resources ADD COLUMN media_type TEXT NOT NULL DEFAULT 'text/plain';
|
||||
`;
|
||||
|
||||
export const SCHEMA_SQL = `
|
||||
@@ -162,6 +163,7 @@ CREATE TABLE IF NOT EXISTS chapter_resources (
|
||||
revision TEXT NOT NULL,
|
||||
chapter_id TEXT NOT NULL,
|
||||
provider_ref TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL DEFAULT 'text/plain; charset=utf-8',
|
||||
body TEXT,
|
||||
content_hash TEXT,
|
||||
PRIMARY KEY (book_id, revision, chapter_id),
|
||||
@@ -171,6 +173,19 @@ CREATE TABLE IF NOT EXISTS plugin_storage (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chapter_subscriptions (
|
||||
book_id TEXT PRIMARY KEY REFERENCES chapter_publications(book_id) ON DELETE CASCADE,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
interval_minutes INTEGER NOT NULL DEFAULT 60,
|
||||
next_check_at INTEGER NOT NULL DEFAULT 0,
|
||||
last_check_at INTEGER,
|
||||
last_success_at INTEGER,
|
||||
last_error TEXT,
|
||||
failures INTEGER NOT NULL DEFAULT 0,
|
||||
new_chapters INTEGER NOT NULL DEFAULT 0,
|
||||
generation INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chapter_due ON chapter_subscriptions(enabled, next_check_at);
|
||||
CREATE TABLE IF NOT EXISTS installed_plugins (
|
||||
plugin_id TEXT PRIMARY KEY,
|
||||
folder TEXT NOT NULL,
|
||||
|
||||
@@ -325,7 +325,7 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
|
||||
if (ctx.sources?.chapters.has(id)) {
|
||||
const payload = await withSignal(request, reply, (signal) => ctx.sources!.chapters.asset(user.id, id, ref, signal));
|
||||
reply.header('x-content-type-options', 'nosniff');
|
||||
reply.header('content-security-policy', "default-src 'none'; sandbox");
|
||||
reply.header('content-security-policy', "default-src 'none'; img-src data:; sandbox");
|
||||
return sendAssetPayload(request, reply, payload);
|
||||
}
|
||||
|
||||
@@ -632,7 +632,7 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
|
||||
* rather than two.
|
||||
*/
|
||||
app.post('/api/v1/library/upload', { preHandler: auth }, async (request) => {
|
||||
const user = currentUser(request);
|
||||
requireAdmin(request);
|
||||
if (!request.isMultipart()) {
|
||||
throw badRequest('expected multipart/form-data', 'NOT_MULTIPART');
|
||||
}
|
||||
@@ -689,7 +689,6 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
|
||||
|
||||
ensureTarget(target);
|
||||
if (staged.length === 0) throw badRequest('no file was uploaded', 'NO_FILES');
|
||||
void user;
|
||||
return ctx.uploads.commit(staged, target, policy);
|
||||
});
|
||||
|
||||
@@ -701,7 +700,8 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
|
||||
* uploading 400MB it cannot store. Not cached: a mount is remounted far more
|
||||
* often than this is called.
|
||||
*/
|
||||
app.get('/api/v1/library/upload', { preHandler: auth }, async () => {
|
||||
app.get('/api/v1/library/upload', { preHandler: auth }, async (request) => {
|
||||
requireAdmin(request);
|
||||
const listing = await ctx.browse.list('');
|
||||
return { writable: listing.writable };
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extensionValues } from '../../sources/extensions.ts';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AppContext } from '../context.ts';
|
||||
import { authenticate, currentUser, requireAdmin } from '../auth.ts';
|
||||
@@ -22,8 +23,15 @@ function parameter(value: unknown, name: string, required = false): string | und
|
||||
export function registerSourceRoutes(app: FastifyInstance, ctx: AppContext): void {
|
||||
const auth = authenticate(ctx);
|
||||
const host = ctx.sources ??= new SourceHost(ctx.db, ctx.config, () => ctx.shelf, app.log);
|
||||
app.addHook('onReady', () => host.plugins.loadInstalled());
|
||||
app.addHook('onClose', () => host.plugins.close());
|
||||
app.addHook('onReady', async () => { await host.plugins.loadInstalled(); host.updates.start(); host.plugins.startTasks(); });
|
||||
app.addHook('onClose', async () => { await host.updates.stop(); await host.plugins.close(); });
|
||||
|
||||
app.get('/api/v1/subscriptions', { preHandler: auth }, async (request) => ({ subscriptions: host.updates.list(currentUser(request).id) }));
|
||||
app.patch('/api/v1/books/:id/subscription', { preHandler: auth }, async (request) => {
|
||||
const user = currentUser(request); const { id } = request.params as { id: string };
|
||||
ctx.shelf.get(user.id, id);
|
||||
return { subscription: host.updates.configure(user.id, id, (request.body ?? {}) as Record<string, unknown>) };
|
||||
});
|
||||
|
||||
app.get('/api/v1/sources/types', { preHandler: auth }, async () => ({
|
||||
types: host.registry.list().map(({ pluginId, builtin, provider }) => ({
|
||||
@@ -50,11 +58,16 @@ export function registerSourceRoutes(app: FastifyInstance, ctx: AppContext): voi
|
||||
app.patch('/api/v1/sources/:id', { preHandler: auth }, async (request) => {
|
||||
requireAdmin(request);
|
||||
const { id } = request.params as { id: string };
|
||||
const enabled = (request.body as Record<string, unknown> | null)?.enabled;
|
||||
if (typeof enabled !== 'boolean') throw badRequest('enabled must be boolean');
|
||||
host.setEnabled(id, enabled);
|
||||
const patch = (request.body ?? {}) as Record<string, unknown>;
|
||||
if (!['name', 'config', 'enabled'].some((key) => key in patch)) throw badRequest('no source changes provided');
|
||||
await host.update(id, patch);
|
||||
return { source: host.list(true).find((source) => source.id === id) };
|
||||
});
|
||||
app.delete('/api/v1/sources/:id', { preHandler: auth }, async (request) => {
|
||||
requireAdmin(request);
|
||||
host.remove((request.params as { id: string }).id);
|
||||
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 };
|
||||
@@ -72,10 +85,10 @@ export function registerSourceRoutes(app: FastifyInstance, ctx: AppContext): voi
|
||||
});
|
||||
app.get('/api/v1/sources/:id/search', { preHandler: auth }, async (request, reply) => {
|
||||
const user = currentUser(request); const { id } = request.params as { id: string };
|
||||
const q = request.query as { q?: string; cursor?: string; limit?: string };
|
||||
const q = request.query as { q?: string; cursor?: string; limit?: string; filters?: string };
|
||||
const query = parameter(q.q, 'q', true)!;
|
||||
return withSignal(request, reply, (signal) => host.search(user.id, id, {
|
||||
query, cursor: parameter(q.cursor, 'cursor'), limit: pageLimit(q.limit),
|
||||
query, cursor: parameter(q.cursor, 'cursor'), limit: pageLimit(q.limit), filters: searchFilters(q.filters),
|
||||
}, signal));
|
||||
});
|
||||
app.get('/api/v1/sources/:id/entries', { preHandler: auth }, async (request, reply) => {
|
||||
@@ -104,6 +117,44 @@ export function registerSourceRoutes(app: FastifyInstance, ctx: AppContext): voi
|
||||
return { mediaType: resource.mediaType, ...(resource.text !== undefined ? { text: resource.text } : {}),
|
||||
...(resource.data ? { base64: Buffer.from(resource.data).toString('base64') } : {}) };
|
||||
});
|
||||
app.get('/api/v1/sources/:id/search-filters', { preHandler: auth }, async (request, reply) => {
|
||||
return withSignal(request, reply, signal => host.searchFilters(currentUser(request).id, (request.params as { id: string }).id, signal));
|
||||
});
|
||||
app.get('/api/v1/books/:id/source-options', { preHandler: auth }, async request => {
|
||||
const user = currentUser(request), { id } = request.params as { id: string }; ctx.shelf.get(user.id, id);
|
||||
return { canSwitch: host.chapters.has(id) && host.canSwitch(user.id, id) };
|
||||
});
|
||||
app.get('/api/v1/books/:id/alternatives', { preHandler: auth }, async (request, reply) => {
|
||||
const user = currentUser(request), { id } = request.params as { id: string }; ctx.shelf.get(user.id, id);
|
||||
const cursor = parameter((request.query as { cursor?: string }).cursor, 'cursor');
|
||||
return withSignal(request, reply, signal => host.alternatives(user.id, id, cursor, signal));
|
||||
});
|
||||
app.post('/api/v1/books/:id/switch-preview', { 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');
|
||||
return withSignal(request, reply, signal => host.switchPreview(user.id, id, ref, 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');
|
||||
return withSignal(request, reply, signal => host.switchSource(user.id, id, ref, chapter, revision, signal));
|
||||
});
|
||||
app.get('/api/v1/sources/:id/pages/:pageId', { preHandler: auth }, async request => {
|
||||
requireAdmin(request); const { id, pageId } = request.params as { id: string; pageId: string };
|
||||
return host.plugins.sourcePage(id, pageId, currentUser(request).id);
|
||||
});
|
||||
app.post('/api/v1/sources/:id/pages/:pageId', { preHandler: auth }, async request => {
|
||||
requireAdmin(request); const { id, pageId } = request.params as { id: string; pageId: string };
|
||||
return host.plugins.sourcePage(id, pageId, currentUser(request).id, textBody(request.body, 'action'), (request.body as Record<string, unknown>).values ?? {});
|
||||
});
|
||||
app.get('/api/v1/plugins/:id/pages/:pageId', { preHandler: auth }, async request => {
|
||||
requireAdmin(request); const { id, pageId } = request.params as { id: string; pageId: string };
|
||||
return host.plugins.page(id, pageId, currentUser(request).id);
|
||||
});
|
||||
app.post('/api/v1/plugins/:id/pages/:pageId', { preHandler: auth }, async request => {
|
||||
requireAdmin(request); const { id, pageId } = request.params as { id: string; pageId: string };
|
||||
return host.plugins.page(id, pageId, currentUser(request).id, textBody(request.body, 'action'), (request.body as Record<string, unknown>).values ?? {});
|
||||
});
|
||||
app.get('/api/v1/plugins', { preHandler: auth }, async (request) => {
|
||||
requireAdmin(request);
|
||||
return { plugins: host.plugins.list() };
|
||||
@@ -136,3 +187,13 @@ function pageLimit(value?: string): number | undefined {
|
||||
if (!Number.isSafeInteger(number) || number < 1 || number > 200) throw badRequest('limit must be an integer between 1 and 200');
|
||||
return number;
|
||||
}
|
||||
|
||||
function searchFilters(value?: string): Record<string, string> | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
try {
|
||||
if (typeof value !== 'string' || value.length > 16384) throw new Error();
|
||||
const filters = extensionValues(JSON.parse(value));
|
||||
if (Object.values(filters).some(item => typeof item !== 'string')) throw new Error();
|
||||
return filters as Record<string, string>;
|
||||
} catch { throw badRequest('Invalid search filters'); }
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import type { AssetPayload, Manifest } from '../indexer/formats/registry.ts';
|
||||
import { AppError, badRequest, conflict, notFound } from '../lib/errors.ts';
|
||||
import { decodeManifest } from '../sources/protocol.ts';
|
||||
import { chapterMedia, richContent } from './rich-content.ts';
|
||||
import type {
|
||||
AcquireRequest, CatalogEntry, ManifestSnapshot, ResourceResponse, SourceContext, SourceProvider,
|
||||
} from '../sources/types.ts';
|
||||
@@ -27,6 +28,7 @@ interface PublicationRow {
|
||||
|
||||
interface ResourceRow {
|
||||
provider_ref: string;
|
||||
media_type: string;
|
||||
body: string | null;
|
||||
content_hash: string | null;
|
||||
}
|
||||
@@ -35,16 +37,7 @@ function hash(value: string): string {
|
||||
return createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function isPlainUtf8(type: string): boolean {
|
||||
const [mediaType, ...parameters] = type.split(';');
|
||||
if (mediaType?.trim().toLowerCase() !== 'text/plain') return false;
|
||||
return parameters.every((parameter) => {
|
||||
const [name, value] = parameter.split('=');
|
||||
return name?.trim().toLowerCase() !== 'charset' || /^(?:utf-8|utf8)$/i.test((value ?? '').trim().replace(/^"|"$/g, ''));
|
||||
});
|
||||
}
|
||||
|
||||
/** Stable reader identities, versioned directories and cached plain-text chapters. */
|
||||
/** Stable reader identities, versioned directories and passive cached chapters. */
|
||||
export class ChapterPublications {
|
||||
private readonly queues = new Map<string, Promise<unknown>>();
|
||||
|
||||
@@ -58,7 +51,7 @@ export class ChapterPublications {
|
||||
const publication = this.owned(userId, bookId);
|
||||
const snapshot = JSON.parse(publication.snapshot_json) as ManifestSnapshot;
|
||||
return {
|
||||
kind: 'text', revision: publication.revision, total: snapshot.items.length,
|
||||
kind: snapshot.items.some((item) => chapterMedia(item.mediaType).startsWith('text/html')) ? 'reflowable' : 'text', revision: publication.revision, total: snapshot.items.length,
|
||||
groups: snapshot.items.length ? [{ id: 'chapters', seq: 0, title: '章节', count: snapshot.items.length, offset: 0 }] : [],
|
||||
items: snapshot.items.map((item, seq) => {
|
||||
const id = hash(item.id);
|
||||
@@ -67,7 +60,8 @@ export class ChapterPublications {
|
||||
resourceRef: `chapter-resource:${publication.revision}:${id}`,
|
||||
// The TXT reader typesets plain characters itself; never label a
|
||||
// plugin's characters as HTML or run a markup parser over them.
|
||||
mediaType: 'text/plain; charset=utf-8', format: 'html',
|
||||
mediaType: chapterMedia(item.mediaType),
|
||||
...(chapterMedia(item.mediaType).startsWith('text/plain') ? { format: 'html' as const } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -77,13 +71,22 @@ export class ChapterPublications {
|
||||
provider: SourceProvider, context: SourceContext, request: AcquireRequest,
|
||||
publicationRef: string, entry: CatalogEntry,
|
||||
): Promise<string> {
|
||||
const bookId = hash(JSON.stringify(['reader/chapter-publication/v1', context.instance.id, context.userId, publicationRef]));
|
||||
return this.serial(`publication:${bookId}`, async () => {
|
||||
const identity = hash(JSON.stringify(['reader/chapter-publication/v1', context.instance.id, context.userId, publicationRef]));
|
||||
return this.serial(`publication:${identity}`, async () => {
|
||||
context.signal.throwIfAborted();
|
||||
// A book keeps its id when switching providers. Reacquiring its former
|
||||
// binding must not silently return the replacement edition.
|
||||
const bound = this.db.get<{ book_id: string }>('SELECT book_id FROM chapter_publications WHERE user_id = ? AND source_id = ? AND publication_ref = ?', context.userId, context.instance.id, publicationRef);
|
||||
const bookId = bound?.book_id ?? (this.has(identity) ? hash(randomUUID()) : identity);
|
||||
if (!this.has(bookId)) {
|
||||
if (!provider.getManifest || !provider.readResource) throw badRequest('source has no chapter content', 'SOURCE_UNSUPPORTED');
|
||||
const snapshot = this.normalize(await provider.getManifest(context, publicationRef), publicationRef);
|
||||
context.signal.throwIfAborted();
|
||||
const changed = this.db.get<{ book_id: string }>('SELECT book_id FROM chapter_publications WHERE source_id = ? AND user_id = ? AND publication_ref = ?', context.instance.id, context.userId, publicationRef);
|
||||
if (changed) {
|
||||
this.db.transaction(() => this.addAcquisition(context, request, changed.book_id, Date.now()));
|
||||
return changed.book_id;
|
||||
}
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
const revision = hash(snapshotJson);
|
||||
const now = Date.now();
|
||||
@@ -136,6 +139,7 @@ export class ChapterPublications {
|
||||
}
|
||||
|
||||
asset(userId: string, bookId: string, ref: string, signal?: AbortSignal): Promise<AssetPayload> {
|
||||
signal = signal ? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000);
|
||||
this.owned(userId, bookId);
|
||||
const match = /^chapter-resource:([a-f0-9]{64}):([a-f0-9]{64})$/.exec(ref);
|
||||
if (!match) return Promise.reject(badRequest('chapter assets require the manifest resourceRef', 'INVALID_RESOURCE'));
|
||||
@@ -144,14 +148,25 @@ export class ChapterPublications {
|
||||
signal?.throwIfAborted();
|
||||
const publication = this.owned(userId, bookId);
|
||||
const resource = this.db.get<ResourceRow>(
|
||||
'SELECT provider_ref, body, content_hash FROM chapter_resources WHERE book_id = ? AND revision = ? AND chapter_id = ?',
|
||||
'SELECT provider_ref, media_type, body, content_hash FROM chapter_resources WHERE book_id = ? AND revision = ? AND chapter_id = ?',
|
||||
bookId, revision!, chapterId!,
|
||||
);
|
||||
if (!resource) throw notFound('chapter resource does not exist', 'RESOURCE_GONE');
|
||||
if (resource.body !== null) return this.payload(resource.body, resource.content_hash!);
|
||||
resource.media_type = chapterMedia(resource.media_type);
|
||||
if (resource.body !== null) return this.payload(resource.body, resource.content_hash!, resource.media_type);
|
||||
this.requireCurrent(publication, revision!);
|
||||
const response = await this.loader.resource(userId, publication.source_id, publication.publication_ref, resource.provider_ref, signal);
|
||||
const body = this.readBody(response, signal);
|
||||
if (chapterMedia(response.mediaType) !== resource.media_type) {
|
||||
response.stream?.destroy();
|
||||
throw badRequest('chapter resource type differs from its manifest', 'INVALID_RESOURCE');
|
||||
}
|
||||
let body = this.readBody(response, signal);
|
||||
if (resource.media_type.startsWith('text/html')) {
|
||||
body = await richContent(body, (imageRef) => {
|
||||
signal?.throwIfAborted();
|
||||
return this.loader.resource(userId, publication.source_id, publication.publication_ref, imageRef, signal);
|
||||
});
|
||||
}
|
||||
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.
|
||||
@@ -161,7 +176,48 @@ export class ChapterPublications {
|
||||
'UPDATE chapter_resources SET body = ?, content_hash = ? WHERE book_id = ? AND revision = ? AND chapter_id = ?',
|
||||
body, contentHash, bookId, revision!, chapterId!,
|
||||
);
|
||||
return this.payload(body, contentHash);
|
||||
return this.payload(body, contentHash, resource.media_type);
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
this.owned(context.userId, bookId);
|
||||
return this.serial('publication:' + bookId, async () => {
|
||||
const previous = this.owned(context.userId, bookId);
|
||||
if (previous.source_id !== context.instance.id) throw badRequest('alternative belongs to another source instance');
|
||||
if (previous.revision !== expectedRevision) throw conflict('Directory changed; reload before switching', 'CHAPTER_SNAPSHOT_EXPIRED');
|
||||
const checkBinding = () => {
|
||||
if (this.db.get('SELECT book_id FROM chapter_publications WHERE source_id = ? AND user_id = ? AND publication_ref = ? AND book_id <> ?',
|
||||
context.instance.id, context.userId, publicationRef, bookId)) {
|
||||
throw conflict('该版本已作为另一本书加入书架,请从书架打开', 'SOURCE_ALREADY_ACQUIRED');
|
||||
}
|
||||
};
|
||||
checkBinding();
|
||||
if (!provider.getManifest || !provider.readResource) throw badRequest('source has no chapter content');
|
||||
const snapshot = this.normalize(await provider.getManifest(context, publicationRef), publicationRef);
|
||||
const chapter = snapshot.items.find(item => item.id === chapterId);
|
||||
if (!chapter) throw conflict('Selected chapter is no longer available');
|
||||
const response = await provider.readResource(context, { publicationRef, ref: chapter.ref });
|
||||
if (chapterMedia(response.mediaType) !== chapterMedia(chapter.mediaType)) { response.stream?.destroy(); throw badRequest('Invalid chapter media type'); }
|
||||
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 }));
|
||||
context.signal.throwIfAborted();
|
||||
const snapshotJson = JSON.stringify(snapshot), revision = hash(snapshotJson), now = Date.now();
|
||||
this.db.transaction(() => {
|
||||
checkBinding();
|
||||
this.saveSnapshot(bookId, revision, snapshot, snapshotJson, now);
|
||||
this.db.run('UPDATE chapter_resources SET body = ?, content_hash = ? WHERE book_id = ? AND revision = ? AND chapter_id = ?', body, hash(body), bookId, revision, hash(chapterId));
|
||||
this.db.run('UPDATE chapter_publications SET publication_ref = ?, revision = ?, version = ?, snapshot_json = ?, updated_at = ? WHERE book_id = ?',
|
||||
publicationRef, revision, snapshot.version ?? null, snapshotJson, now, bookId);
|
||||
this.db.run('UPDATE books SET content_hash = ?, page_count = ?, updated_at = ? WHERE id = ?', revision, snapshot.items.length, now, bookId);
|
||||
this.db.run('DELETE FROM source_acquisitions WHERE book_id = ? AND user_id = ?', bookId, context.userId);
|
||||
this.addAcquisition(context, { entryRef }, bookId, now);
|
||||
this.db.run('UPDATE chapter_subscriptions SET new_chapters = 0, generation = generation + 1 WHERE book_id = ?', bookId);
|
||||
});
|
||||
return { content: this.manifest(context.userId, bookId), href: 'chapter:' + hash(chapterId) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,15 +245,14 @@ export class ChapterPublications {
|
||||
throw badRequest('invalid chapter directory version', 'INVALID_MANIFEST');
|
||||
}
|
||||
for (const item of snapshot.items) {
|
||||
if (item.kind !== 'chapter' || !isPlainUtf8(item.mediaType)) {
|
||||
throw badRequest('chapter publications currently support UTF-8 plain text only', 'UNSUPPORTED_FORMAT');
|
||||
}
|
||||
if (item.kind !== 'chapter') throw badRequest('chapter publications require chapter items', 'UNSUPPORTED_FORMAT');
|
||||
chapterMedia(item.mediaType);
|
||||
}
|
||||
return {
|
||||
publicationRef,
|
||||
...(snapshot.version === undefined ? {} : { version: snapshot.version }),
|
||||
items: [...snapshot.items].sort((left, right) => left.seq - right.seq).map((item, seq) => ({
|
||||
id: item.id, seq, title: item.title, kind: 'chapter', mediaType: 'text/plain; charset=utf-8', ref: item.ref,
|
||||
id: item.id, seq, title: item.title, kind: 'chapter', mediaType: chapterMedia(item.mediaType), ref: item.ref,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -209,8 +264,8 @@ export class ChapterPublications {
|
||||
);
|
||||
for (const item of snapshot.items) {
|
||||
this.db.run(
|
||||
'INSERT OR IGNORE INTO chapter_resources (book_id, revision, chapter_id, provider_ref) VALUES (?, ?, ?, ?)',
|
||||
bookId, revision, hash(item.id), item.ref,
|
||||
'INSERT OR IGNORE INTO chapter_resources (book_id, revision, chapter_id, provider_ref, media_type) VALUES (?, ?, ?, ?, ?)',
|
||||
bookId, revision, hash(item.id), item.ref, chapterMedia(item.mediaType),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -232,7 +287,7 @@ export class ChapterPublications {
|
||||
private readBody(resource: ResourceResponse, signal?: AbortSignal): string {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
if (!isPlainUtf8(resource.mediaType)) throw badRequest('chapter resource must be UTF-8 plain text', 'INVALID_RESOURCE');
|
||||
chapterMedia(resource.mediaType);
|
||||
const bodies = Number(resource.text !== undefined) + Number(resource.data !== undefined) + Number(resource.stream !== undefined);
|
||||
if (bodies !== 1) throw badRequest('chapter resource must contain exactly one body', 'INVALID_RESOURCE');
|
||||
// Chapter RPCs are bounded messages. A stream returned after the host call
|
||||
@@ -254,9 +309,9 @@ export class ChapterPublications {
|
||||
}
|
||||
}
|
||||
|
||||
private payload(body: string, contentHash: string): AssetPayload {
|
||||
private payload(body: string, contentHash: string, contentType = 'text/plain; charset=utf-8'): AssetPayload {
|
||||
const data = Buffer.from(body, 'utf8');
|
||||
return { data, contentType: 'text/plain; charset=utf-8', size: data.byteLength, etag: contentHash };
|
||||
return { data, contentType, size: data.byteLength, etag: contentHash };
|
||||
}
|
||||
|
||||
private serial<T>(key: string, action: () => Promise<T>): Promise<T> {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import sanitize from 'sanitize-html';
|
||||
import { badRequest, AppError } from '../lib/errors.ts';
|
||||
import type { ResourceResponse } from '../sources/types.ts';
|
||||
|
||||
export const MAX_RICH_BYTES = 8 * 1024 * 1024;
|
||||
const TAGS = ['p', 'div', 'span', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote',
|
||||
'strong', 'b', 'em', 'i', 'u', 's', 'sub', 'sup', 'ruby', 'rt', 'rp', 'pre', 'code', 'ul', 'ol', 'li',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'figure', 'figcaption', 'img'];
|
||||
export function chapterMedia(type: string): string {
|
||||
const [media = '', ...params] = type.toLowerCase().split(';');
|
||||
if (!['text/plain', 'text/html', 'application/xhtml+xml'].includes(media.trim()) ||
|
||||
params.some((p) => /^charset\s*=/i.test(p.trim()) && !/^charset\s*=\s*"?utf-?8"?$/i.test(p.trim()))) {
|
||||
throw badRequest('chapter content must be UTF-8 text or HTML', 'UNSUPPORTED_FORMAT');
|
||||
}
|
||||
return media.trim() === 'text/plain' ? 'text/plain; charset=utf-8' : 'text/html; charset=utf-8';
|
||||
}
|
||||
|
||||
/** Only passive raster images are embedded. No URLs from a plugin reach the browser. */
|
||||
export function raster(resource: ResourceResponse): { type: string; bytes: Buffer } {
|
||||
resource.stream?.destroy();
|
||||
if (!resource.data || resource.text !== undefined || resource.stream || resource.data.byteLength > 2 * 1024 * 1024) {
|
||||
throw badRequest('image must contain bounded binary data', 'INVALID_RESOURCE');
|
||||
}
|
||||
const bytes = Buffer.from(resource.data);
|
||||
const type = resource.mediaType.split(';')[0]!.trim().toLowerCase();
|
||||
const valid = (type === 'image/png' && bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) ||
|
||||
(type === 'image/jpeg' && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) ||
|
||||
(type === 'image/gif' && /^GIF8[79]a$/.test(bytes.subarray(0, 6).toString())) ||
|
||||
(type === 'image/webp' && bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP');
|
||||
if (!valid) throw badRequest('only PNG, JPEG, GIF and WebP images with matching bytes are supported', 'INVALID_RESOURCE');
|
||||
return { type, bytes };
|
||||
}
|
||||
|
||||
export async function richContent(html: string, read: (ref: string) => Promise<ResourceResponse>): Promise<string> {
|
||||
const references = new Set<string>();
|
||||
const cleaned = sanitize(html, {
|
||||
allowedTags: TAGS,
|
||||
allowedAttributes: { img: ['src', 'alt'], th: ['colspan', 'rowspan'], td: ['colspan', 'rowspan'] },
|
||||
allowedSchemes: ['reader-res'], allowProtocolRelative: false,
|
||||
transformTags: {
|
||||
img: (_tag, attrs): sanitize.Tag => {
|
||||
const src = attrs.src ?? '';
|
||||
if (!src.startsWith('reader-res:') || src.length > 16_384 || src.length === 11) {
|
||||
return { tagName: 'span', attribs: {}, text: attrs.alt || '[图片不可用]' };
|
||||
}
|
||||
references.add(src.slice(11));
|
||||
return { tagName: 'img', attribs: { src, alt: attrs.alt ?? '' } };
|
||||
},
|
||||
},
|
||||
});
|
||||
if (references.size > 32) throw new AppError(413, 'CHAPTER_TOO_LARGE', 'chapter has more than 32 images');
|
||||
const images = new Map<string, string>();
|
||||
let size = Buffer.byteLength(cleaned);
|
||||
for (const ref of references) {
|
||||
const { type, bytes } = raster(await read(ref));
|
||||
const url = `data:${type};base64,${bytes.toString('base64')}`;
|
||||
size += url.length;
|
||||
if (size > MAX_RICH_BYTES) throw new AppError(413, 'CHAPTER_TOO_LARGE', 'chapter and images exceed 8 MiB');
|
||||
images.set(`reader-res:${ref}`, url);
|
||||
}
|
||||
const result = sanitize(cleaned, {
|
||||
allowedTags: TAGS, allowedAttributes: { '*': ['colspan', 'rowspan'], img: ['src', 'alt'] },
|
||||
allowedSchemes: ['data'], allowedSchemesByTag: { img: ['data'] },
|
||||
transformTags: { img: (_tag, attrs) => ({ tagName: 'img', attribs: { ...attrs, src: images.get(attrs.src ?? '') ?? '' } }) },
|
||||
});
|
||||
if (Buffer.byteLength(result) > MAX_RICH_BYTES) throw new AppError(413, 'CHAPTER_TOO_LARGE', 'expanded chapter exceeds 8 MiB');
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Db } from '../db/index.ts';
|
||||
import type { ChapterPublications } from '../publications/chapters.ts';
|
||||
import { AppError, badRequest } from '../lib/errors.ts';
|
||||
import type { Manifest } from '../indexer/formats/registry.ts';
|
||||
|
||||
interface Subscription {
|
||||
bookId: string; userId: string; enabled: number; intervalMinutes: number;
|
||||
nextCheckAt: number; lastCheckAt: number | null; lastSuccessAt: number | null;
|
||||
lastError: string | null; failures: number; newChapters: number; generation: number;
|
||||
}
|
||||
const FIELDS = `p.book_id AS bookId, p.user_id AS userId, COALESCE(s.enabled,0) AS enabled,
|
||||
COALESCE(s.interval_minutes,60) AS intervalMinutes, COALESCE(s.next_check_at,0) AS nextCheckAt,
|
||||
s.last_check_at AS lastCheckAt, s.last_success_at AS lastSuccessAt, s.last_error AS lastError,
|
||||
COALESCE(s.failures,0) AS failures, COALESCE(s.new_chapters,0) AS newChapters, COALESCE(s.generation,0) AS generation`;
|
||||
|
||||
/** Durable opt-in subscriptions. One bounded sequential worker shares SourceHost's call budget. */
|
||||
export class ChapterUpdates {
|
||||
private timer?: NodeJS.Timeout;
|
||||
private active?: Promise<void>;
|
||||
private controller = new AbortController();
|
||||
private readonly checks = new Map<string, Promise<Manifest>>();
|
||||
constructor(private readonly db: Db, private readonly chapters: ChapterPublications, private readonly now = Date.now) {}
|
||||
|
||||
list(userId: string) {
|
||||
return this.db.all<Subscription & { title: string }>(`SELECT ${FIELDS}, b.title FROM chapter_publications p
|
||||
JOIN books b ON b.id = p.book_id JOIN user_books u ON u.book_id = p.book_id AND u.user_id = p.user_id AND u.hidden = 0
|
||||
LEFT JOIN chapter_subscriptions s ON s.book_id = p.book_id WHERE p.user_id = ? ORDER BY b.title`, userId)
|
||||
.map(({ userId: _user, generation: _generation, ...row }) => ({ ...row, enabled: row.enabled === 1 }));
|
||||
}
|
||||
|
||||
configure(userId: string, bookId: string, input: { enabled?: unknown; intervalMinutes?: unknown; acknowledge?: unknown }) {
|
||||
this.chapters.manifest(userId, bookId);
|
||||
if (input.enabled !== undefined && typeof input.enabled !== 'boolean') throw badRequest('enabled must be boolean');
|
||||
if (input.acknowledge !== undefined && typeof input.acknowledge !== 'boolean') throw badRequest('acknowledge must be boolean');
|
||||
if (input.intervalMinutes !== undefined && (!Number.isSafeInteger(input.intervalMinutes) || Number(input.intervalMinutes) < 15 || Number(input.intervalMinutes) > 10080)) {
|
||||
throw badRequest('intervalMinutes must be an integer between 15 and 10080');
|
||||
}
|
||||
this.db.transaction(() => {
|
||||
this.db.run('INSERT OR IGNORE INTO chapter_subscriptions (book_id) VALUES (?)', bookId);
|
||||
if (input.enabled !== undefined || input.intervalMinutes !== undefined) {
|
||||
this.db.run(`UPDATE chapter_subscriptions SET enabled = COALESCE(?,enabled), interval_minutes = COALESCE(?,interval_minutes),
|
||||
next_check_at = ?, failures = 0, last_error = NULL, generation = generation + 1 WHERE book_id = ?`,
|
||||
input.enabled === undefined ? null : Number(input.enabled), input.intervalMinutes === undefined ? null : Number(input.intervalMinutes), this.now(), bookId);
|
||||
}
|
||||
if (input.acknowledge === true) this.db.run('UPDATE chapter_subscriptions SET new_chapters = 0 WHERE book_id = ?', bookId);
|
||||
});
|
||||
return this.list(userId).find((row) => row.bookId === bookId);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.controller = new AbortController();
|
||||
this.timer = setInterval(() => { void this.runDue().catch(() => undefined); }, 60_000);
|
||||
this.timer.unref();
|
||||
void this.runDue().catch(() => undefined);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
clearInterval(this.timer); this.timer = undefined;
|
||||
this.controller.abort();
|
||||
await this.active;
|
||||
}
|
||||
|
||||
runDue(): Promise<void> {
|
||||
if (this.active) return this.active;
|
||||
this.active = this.run().finally(() => { this.active = undefined; });
|
||||
return this.active;
|
||||
}
|
||||
|
||||
check(userId: string, bookId: string, signal?: AbortSignal): Promise<Manifest> {
|
||||
this.chapters.manifest(userId, bookId);
|
||||
const pending = this.checks.get(bookId);
|
||||
if (pending) return pending;
|
||||
const action = this.performCheck(userId, bookId, signal);
|
||||
this.checks.set(bookId, action);
|
||||
void action.finally(() => { if (this.checks.get(bookId) === action) this.checks.delete(bookId); }).catch(() => undefined);
|
||||
return action;
|
||||
}
|
||||
|
||||
private async performCheck(userId: string, bookId: string, signal?: AbortSignal): Promise<Manifest> {
|
||||
this.db.run('INSERT OR IGNORE INTO chapter_subscriptions (book_id) VALUES (?)', bookId);
|
||||
const item = this.db.get<Subscription>(`SELECT ${FIELDS} FROM chapter_publications p
|
||||
JOIN chapter_subscriptions s ON s.book_id = p.book_id WHERE p.book_id = ?`, bookId)!;
|
||||
this.db.run('UPDATE chapter_subscriptions SET next_check_at = ?, last_check_at = ? WHERE book_id = ?', this.now() + 300_000, this.now(), bookId);
|
||||
try {
|
||||
const old = new Set(this.chapters.manifest(userId, bookId).items.map((entry) => entry.href));
|
||||
const fresh = await this.chapters.refresh(userId, bookId, signal);
|
||||
const added = fresh.items.filter((entry) => !old.has(entry.href)).length;
|
||||
this.db.run(`UPDATE chapter_subscriptions SET last_success_at = ?, next_check_at = ?, last_error = NULL,
|
||||
failures = 0, new_chapters = new_chapters + ? WHERE book_id = ? AND generation = ?`,
|
||||
this.now(), this.now() + item.intervalMinutes * 60_000, added, bookId, item.generation);
|
||||
return fresh;
|
||||
} catch (error) {
|
||||
const delay = Math.min(10080, Math.max(item.intervalMinutes, Math.min(1440, 15 * 2 ** Math.min(item.failures, 7))));
|
||||
const code = error instanceof AppError && /^[A-Z_]{1,80}$/.test(error.code) ? error.code : 'UPDATE_FAILED';
|
||||
this.db.run('UPDATE chapter_subscriptions SET next_check_at = ?, last_error = ?, failures = failures + 1 WHERE book_id = ? AND generation = ?',
|
||||
this.now() + delay * 60_000, code, bookId, item.generation);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
const due = this.db.all<Subscription>(`SELECT ${FIELDS} FROM chapter_publications p
|
||||
JOIN chapter_subscriptions s ON s.book_id = p.book_id
|
||||
JOIN source_instances i ON i.id = p.source_id AND i.enabled = 1
|
||||
JOIN users a ON a.id = p.user_id AND a.disabled = 0
|
||||
JOIN user_books u ON u.book_id = p.book_id AND u.user_id = p.user_id AND u.hidden = 0
|
||||
WHERE s.enabled = 1 AND s.next_check_at <= ? ORDER BY s.next_check_at LIMIT 20`, this.now());
|
||||
for (const item of due) {
|
||||
if (this.controller.signal.aborted) return;
|
||||
const eligible = this.db.get('SELECT book_id FROM chapter_subscriptions WHERE book_id = ? AND enabled = 1 AND generation = ? AND next_check_at <= ?', item.bookId, item.generation, this.now());
|
||||
if (!eligible) continue;
|
||||
try {
|
||||
await this.check(item.userId, item.bookId, this.controller.signal);
|
||||
} catch {
|
||||
if (this.controller.signal.aborted) return;
|
||||
// Failure is recorded per book; other subscriptions still get a turn.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { SourceContext, SourceInstance, SourceProvider, SourceStorage, Cred
|
||||
import { FileAcquisitions } from '../sources/acquisitions.ts';
|
||||
import { PluginManager } from '../sources/plugin-manager.ts';
|
||||
import { ChapterPublications } from '../publications/chapters.ts';
|
||||
import { ChapterUpdates } from './chapter-updates.ts';
|
||||
|
||||
function json(value: unknown): string { return JSON.stringify(value ?? {}); }
|
||||
function parse(value: string): unknown { try { return JSON.parse(value); } catch { return {}; } }
|
||||
@@ -20,6 +21,7 @@ export class SourceHost {
|
||||
readonly registry = new SourceRegistry();
|
||||
readonly plugins: PluginManager;
|
||||
readonly chapters: ChapterPublications;
|
||||
readonly updates: ChapterUpdates;
|
||||
private readonly key: Buffer;
|
||||
private readonly acquisitions: FileAcquisitions;
|
||||
private activeCalls = 0;
|
||||
@@ -31,10 +33,11 @@ export class SourceHost {
|
||||
this.chapters = new ChapterPublications(db, {
|
||||
manifest: (userId, sourceId, ref, signal) => this.manifest(userId, sourceId, ref, signal),
|
||||
resource: (userId, sourceId, publicationRef, ref, signal) => this.call(userId, sourceId, (provider, ctx) => provider.readResource
|
||||
? provider.readResource(ctx, { publicationRef, ref, rendition: 'text' })
|
||||
? provider.readResource(ctx, { publicationRef, ref })
|
||||
: Promise.reject(badRequest('source has no chapter resources', 'SOURCE_UNSUPPORTED')), signal),
|
||||
});
|
||||
this.registry.registerBuiltin('reader.local', createLocalProvider(db, shelf));
|
||||
this.updates = new ChapterUpdates(db, this.chapters);
|
||||
this.registry.registerBuiltin('reader.opds', createOpdsProvider());
|
||||
this.plugins = new PluginManager(db, config.dataDir, this.registry);
|
||||
this.ensureLocal();
|
||||
@@ -84,6 +87,39 @@ export class SourceHost {
|
||||
this.db.run('UPDATE source_instances SET enabled = ? WHERE id = ?', enabled ? 1 : 0, id);
|
||||
}
|
||||
|
||||
async update(id: string, patch: { name?: unknown; config?: unknown; enabled?: unknown }): Promise<void> {
|
||||
const row = this.instance(id);
|
||||
const name = patch.name ?? row.name;
|
||||
if (typeof name !== 'string' || !name.trim() || name.length > 128) throw badRequest('invalid source name');
|
||||
if (patch.enabled !== undefined && typeof patch.enabled !== 'boolean') throw badRequest('enabled must be boolean');
|
||||
const configuration = patch.config === undefined ? row.config_json : json(patch.config);
|
||||
if (configuration.length > 64 * 1024) throw badRequest('source configuration is too large');
|
||||
if (patch.config !== undefined) {
|
||||
const registration = this.registry.get(row.plugin_id, row.source_type);
|
||||
if (!registration) throw notFound('source type is unavailable', 'SOURCE_TYPE_NOT_FOUND');
|
||||
await registration.provider.validateConfig?.(patch.config);
|
||||
if (this.registry.get(row.plugin_id, row.source_type) !== registration) throw conflict('plugin changed; retry');
|
||||
}
|
||||
if (this.instance(id).config_json !== row.config_json) throw conflict('configuration changed; reload and retry');
|
||||
if (configuration !== row.config_json && (this.activeSources.get(id) ?? 0) > 0) throw conflict('source is busy; retry configuration when current calls finish', 'SOURCE_BUSY');
|
||||
this.db.transaction(() => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string): void {
|
||||
const row = this.instance(id);
|
||||
if (row.plugin_id === 'reader.local') throw badRequest('the built-in local source cannot be removed');
|
||||
if (this.db.get('SELECT book_id FROM source_acquisitions WHERE source_id = ? LIMIT 1', id) ||
|
||||
this.db.get('SELECT book_id FROM chapter_publications WHERE source_id = ? LIMIT 1', id)) {
|
||||
throw conflict('source has acquired books; disable it instead', 'SOURCE_IN_USE');
|
||||
}
|
||||
this.db.run('DELETE FROM source_instances WHERE id = ?', id);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -98,10 +134,44 @@ export class SourceHost {
|
||||
return this.call(userId, id, (provider, ctx) => provider.browse
|
||||
? provider.browse(ctx, request) : Promise.reject(badRequest('source does not support browse', 'SOURCE_UNSUPPORTED')), signal);
|
||||
}
|
||||
async search(userId: string, id: string, request: { query: string; cursor?: string; limit?: number }, signal?: AbortSignal) {
|
||||
async search(userId: string, id: string, request: { query: string; cursor?: string; limit?: number; filters?: Record<string, string> }, signal?: AbortSignal) {
|
||||
return this.call(userId, id, (provider, ctx) => provider.search
|
||||
? provider.search(ctx, request) : Promise.reject(badRequest('source does not support search', 'SOURCE_UNSUPPORTED')), signal);
|
||||
}
|
||||
async searchFilters(userId: string, id: string, signal?: AbortSignal) {
|
||||
return this.call(userId, id, (provider, ctx) => provider.searchFilters?.(ctx) ?? Promise.resolve([]), signal);
|
||||
}
|
||||
async alternatives(userId: string, bookId: string, cursor?: string, signal?: AbortSignal) {
|
||||
const binding = this.chapters.binding(userId, bookId);
|
||||
const book = this.db.get<{ title: string; author: string }>('SELECT title, author FROM books WHERE id = ?', bookId)!;
|
||||
return this.call(userId, binding.source_id, (provider, ctx) => provider.alternatives
|
||||
? provider.alternatives(ctx, { publicationRef: binding.publication_ref, query: book.title, authors: [book.author], cursor })
|
||||
: Promise.resolve({ items: [] }), signal);
|
||||
}
|
||||
canSwitch(userId: string, bookId: string): boolean {
|
||||
const binding = this.chapters.binding(userId, bookId);
|
||||
const row = this.instance(binding.source_id);
|
||||
return !!row.enabled && !!this.registry.get(row.plugin_id, row.source_type)?.provider.alternatives;
|
||||
}
|
||||
async switchPreview(userId: string, bookId: string, entryRef: 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' || !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 })) };
|
||||
}, 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) => {
|
||||
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.switchSource(provider, ctx, bookId, entryRef, acquisition.publicationRef, chapterId, revision);
|
||||
}, signal);
|
||||
}
|
||||
async detail(userId: string, id: string, ref: string, signal?: AbortSignal) {
|
||||
return this.call(userId, id, (provider, ctx) => provider.detail(ctx, ref), signal);
|
||||
}
|
||||
@@ -130,7 +200,7 @@ export class SourceHost {
|
||||
}
|
||||
|
||||
refreshPublication(userId: string, bookId: string, signal?: AbortSignal) {
|
||||
return this.chapters.refresh(userId, bookId, signal);
|
||||
return this.updates.check(userId, bookId, signal);
|
||||
}
|
||||
|
||||
async manifest(userId: string, id: string, ref: string, signal?: AbortSignal) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { badRequest } from '../lib/errors.ts';
|
||||
|
||||
export interface ExtensionField {
|
||||
placeholder?: string; min?: number; max?: number;
|
||||
key: string; label: string; type: 'text' | 'textarea' | 'number' | 'boolean' | 'select';
|
||||
required?: boolean; value?: string | number | boolean;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
export interface ExtensionForm {
|
||||
layout?: 'inline'; confirm?: string;
|
||||
id: string; title: string; submit: string; fields: ExtensionField[];
|
||||
values?: Record<string, string | number | boolean>;
|
||||
}
|
||||
export interface ExtensionContent {
|
||||
forms: ExtensionForm[];
|
||||
sections?: Array<{ title: string; emptyText?: string; items: Array<{ title: string; description?: string; collapsible?: boolean; forms?: ExtensionForm[] }> }>;
|
||||
}
|
||||
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 PluginExtensions {
|
||||
pages?: Array<{ id: string; title: string }>;
|
||||
tasks?: Array<{ id: string; intervalMinutes: number }>;
|
||||
}
|
||||
const identifier = /^[a-z][a-z0-9._-]{0,63}$/;
|
||||
function check(ok: unknown): asserts ok { if (!ok) throw badRequest('Invalid plugin extension payload', 'INVALID_EXTENSION'); }
|
||||
function record(value: unknown): asserts value is Record<string, unknown> { check(value && typeof value === 'object' && !Array.isArray(value)); }
|
||||
function label(value: unknown): asserts value is string { check(typeof value === 'string' && value.length <= 8192); }
|
||||
export function extensionValues(input: unknown): Record<string, string | number | boolean> {
|
||||
record(input); check(Object.keys(input).length <= 32);
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
check(identifier.test(key)); check(typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value) || typeof value === 'string' && value.length <= 512_000);
|
||||
}
|
||||
return input as Record<string, string | number | boolean>;
|
||||
}
|
||||
export function extensionDeclarations(input: unknown): PluginExtensions | undefined {
|
||||
if (input === undefined) return undefined;
|
||||
record(input);
|
||||
for (const key of ['pages', 'tasks']) {
|
||||
const entries = input[key]; if (entries === undefined) continue;
|
||||
check(Array.isArray(entries) && entries.length <= 16);
|
||||
const ids = new Set();
|
||||
for (const entry of entries) {
|
||||
record(entry); check(typeof entry.id === 'string' && identifier.test(entry.id) && !ids.has(entry.id)); ids.add(entry.id);
|
||||
if (key === 'pages') label(entry.title);
|
||||
else check(Number.isInteger(entry.intervalMinutes) && Number(entry.intervalMinutes) >= 1 && Number(entry.intervalMinutes) <= 10080);
|
||||
}
|
||||
}
|
||||
return input as PluginExtensions;
|
||||
}
|
||||
export function extensionFields(input: unknown): ExtensionField[] {
|
||||
check(Array.isArray(input) && input.length <= 32);
|
||||
const keys = new Set();
|
||||
for (const field of input) {
|
||||
record(field); check(typeof field.key === 'string' && identifier.test(field.key) && !keys.has(field.key)); keys.add(field.key);
|
||||
label(field.label); check(['text', 'textarea', 'number', 'boolean', 'select'].includes(String(field.type)));
|
||||
if (field.placeholder !== undefined) label(field.placeholder);
|
||||
for (const bound of [field.min, field.max]) if (bound !== undefined) check(field.type === 'number' && typeof bound === 'number' && Number.isFinite(bound));
|
||||
if (field.min !== undefined && field.max !== undefined) check(Number(field.min) <= Number(field.max));
|
||||
if (field.value !== undefined) extensionValues({ value: field.value });
|
||||
if (field.required !== undefined) check(typeof field.required === 'boolean');
|
||||
if (field.type === 'select') {
|
||||
check(Array.isArray(field.options) && field.options.length <= 10001);
|
||||
for (const option of field.options) { record(option); label(option.value); label(option.label); }
|
||||
}
|
||||
}
|
||||
return input as ExtensionField[];
|
||||
}
|
||||
export function searchFilterFields(input: unknown): ExtensionField[] {
|
||||
const fields = extensionFields(input);
|
||||
check(fields.every(field => field.type === 'select'));
|
||||
return fields;
|
||||
}
|
||||
export function extensionPage(input: unknown): ExtensionPage {
|
||||
record(input); label(input.title); if (input.description !== undefined) label(input.description);
|
||||
const forms = (value: unknown) => {
|
||||
check(Array.isArray(value) && value.length <= 32);
|
||||
for (const form of value) {
|
||||
record(form); check(typeof form.id === 'string' && identifier.test(form.id)); label(form.title); label(form.submit);
|
||||
if (form.layout !== undefined) check(form.layout === 'inline');
|
||||
if (form.confirm !== undefined) label(form.confirm);
|
||||
extensionFields(form.fields); if (form.values !== undefined) extensionValues(form.values);
|
||||
}
|
||||
};
|
||||
const content = (input: Record<string, unknown>) => {
|
||||
forms(input.forms);
|
||||
if (input.sections !== undefined) {
|
||||
check(Array.isArray(input.sections) && input.sections.length <= 16);
|
||||
for (const section of input.sections) {
|
||||
record(section); label(section.title); if (section.emptyText !== undefined) label(section.emptyText); check(Array.isArray(section.items) && section.items.length <= 200);
|
||||
for (const item of section.items) { record(item); label(item.title); if (item.description !== undefined) label(item.description); if (item.forms !== undefined) forms(item.forms); if (item.collapsible !== undefined) check(typeof item.collapsible === 'boolean'); }
|
||||
}
|
||||
}
|
||||
};
|
||||
content(input);
|
||||
if (input.notice !== undefined) label(input.notice);
|
||||
if (input.noticeKind !== undefined) check(['info', 'error'].includes(String(input.noticeKind)));
|
||||
const ids = new Set<string>();
|
||||
if (input.tabs !== undefined) {
|
||||
check(Array.isArray(input.tabs) && input.tabs.length > 0 && input.tabs.length <= 16);
|
||||
for (const tab of input.tabs) {
|
||||
record(tab); check(typeof tab.id === 'string' && identifier.test(tab.id) && !ids.has(tab.id)); ids.add(tab.id);
|
||||
label(tab.title); if (tab.description !== undefined) label(tab.description); content(tab);
|
||||
}
|
||||
}
|
||||
if (input.activeTab !== undefined) check(typeof input.activeTab === 'string' && ids.has(input.activeTab));
|
||||
return input as unknown as ExtensionPage;
|
||||
}
|
||||
@@ -342,6 +342,7 @@ export function createOpdsProvider(options: OpdsProviderOptions = {}): SourcePro
|
||||
descriptor: {
|
||||
id: 'opds', label: 'OPDS', version: '1.0.0',
|
||||
capabilities: ['browse', 'search', 'detail', 'acquire.file'],
|
||||
credentialKeys: [{ key: 'password', label: 'OPDS 密码' }],
|
||||
configSchema: {
|
||||
type: 'object', required: ['url'], additionalProperties: false,
|
||||
properties: {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { extensionPage, extensionValues, type PluginExtensions } from './extensions.ts';
|
||||
import { realpath, stat } from 'node:fs/promises';
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { AppError } from '../lib/errors.ts';
|
||||
import { ProcessPlugin } from './process-plugin.ts';
|
||||
import { SourceRegistry, SourceRegistryError } from './registry.ts';
|
||||
import type { PluginRuntimeStatus, SourceDescriptor, SourceProvider } from './types.ts';
|
||||
import type { PluginRuntimeStatus, SourceDescriptor, SourceProvider, SourceInstance } from './types.ts';
|
||||
|
||||
interface InstanceRow { id: string; plugin_id: string; source_type: string; name: string; config_json: string; enabled: number }
|
||||
interface InstalledPlugin { plugin_id: string; folder: string; enabled: number }
|
||||
interface ManagedPlugin {
|
||||
plugin?: ProcessPlugin;
|
||||
@@ -15,6 +17,7 @@ interface ManagedPlugin {
|
||||
}
|
||||
|
||||
export interface PluginInfo {
|
||||
extensions?: PluginExtensions;
|
||||
pluginId: string;
|
||||
folder?: string;
|
||||
builtin: boolean;
|
||||
@@ -43,6 +46,90 @@ export class PluginManager {
|
||||
private readonly managed = new Map<string, ManagedPlugin>();
|
||||
private pending: Promise<void> = Promise.resolve();
|
||||
private closed = false;
|
||||
private taskTimer?: ReturnType<typeof setInterval>;
|
||||
private taskRun?: Promise<void>;
|
||||
private readonly extensionBusy = new Set<string>();
|
||||
|
||||
startTasks(): void {
|
||||
if (this.taskTimer || this.closed) return;
|
||||
this.taskTimer = setInterval(() => { void this.runTasks().catch(() => undefined); }, 60_000);
|
||||
this.taskTimer.unref(); void this.runTasks().catch(() => undefined);
|
||||
}
|
||||
|
||||
runTasks(now = Date.now()): Promise<void> {
|
||||
if (this.taskRun) return this.taskRun;
|
||||
if (this.closed) return Promise.resolve();
|
||||
const work = async () => {
|
||||
for (const [id, managed] of this.managed) {
|
||||
const candidates = (managed.plugin?.manifest.extensions?.tasks ?? []).map(task => ({ task, sourceId: undefined as string | undefined }));
|
||||
for (const row of this.db.all<InstanceRow>('SELECT * FROM source_instances WHERE plugin_id = ? AND enabled = 1', id)) {
|
||||
const type = managed.plugin?.manifest.sourceTypes.find(type => type.id === row.source_type);
|
||||
candidates.push(...(type?.extensions?.tasks ?? []).map(task => ({ task, sourceId: row.id })));
|
||||
}
|
||||
for (const { task, sourceId } of candidates) {
|
||||
if (this.closed || !managed.active) break;
|
||||
const busyKey = sourceId ? JSON.stringify([id, sourceId]) : id;
|
||||
if (this.extensionBusy.has(busyKey)) continue;
|
||||
const key = sourceId ? 'source-task:' + JSON.stringify([id, sourceId, task.id]) : 'plugin-task:' + id + ':' + task.id;
|
||||
const instance = sourceId ? this.sourceInstance(sourceId) : undefined;
|
||||
if (sourceId && (!instance?.enabled || instance.pluginId !== id)) continue;
|
||||
const saved = this.db.get<{ value: string }>('SELECT value FROM plugin_storage WHERE key = ?', key);
|
||||
const previous = saved ? JSON.parse(saved.value) : { next: 0, failures: 0 };
|
||||
if (previous.next > now) continue;
|
||||
let failures = 0;
|
||||
this.extensionBusy.add(busyKey);
|
||||
try {
|
||||
if (managed.plugin!.status().state === 'failed') await managed.plugin!.close();
|
||||
if (!managed.active || this.closed) break;
|
||||
const current = sourceId ? this.sourceInstance(sourceId) : undefined;
|
||||
if (sourceId && (!current?.enabled || current.pluginId !== id)) continue;
|
||||
await managed.plugin!.invoke('extension.task', { taskId: task.id,
|
||||
...(current ? { sourceType: current.sourceType, context: { instance: current, userId: '' } } : {}),
|
||||
});
|
||||
} catch { failures = previous.failures + 1; }
|
||||
finally { this.extensionBusy.delete(busyKey); }
|
||||
if (this.closed) break;
|
||||
const minutes = failures ? Math.min(1440, Math.max(task.intervalMinutes, 2 ** Math.min(failures, 10))) : task.intervalMinutes;
|
||||
this.db.run('INSERT INTO plugin_storage (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
key, JSON.stringify({ next: now + minutes * 60_000, failures, lastError: failures ? 'TASK_FAILED' : null }));
|
||||
}
|
||||
}
|
||||
};
|
||||
this.taskRun = work().finally(() => { this.taskRun = undefined; });
|
||||
return this.taskRun;
|
||||
}
|
||||
|
||||
private sourceInstance(id: string): SourceInstance | undefined {
|
||||
const row = this.db.get<InstanceRow>('SELECT * FROM source_instances WHERE id = ?', id);
|
||||
return row ? { id: row.id, pluginId: row.plugin_id, sourceType: row.source_type, name: row.name,
|
||||
config: JSON.parse(row.config_json), enabled: row.enabled === 1 } : undefined;
|
||||
}
|
||||
|
||||
async sourcePage(sourceId: string, pageId: string, userId: string, action?: string, values: unknown = {}) {
|
||||
const instance = this.sourceInstance(sourceId);
|
||||
if (!instance) throw pluginError(404, 'SOURCE_NOT_FOUND', 'Source instance is unavailable');
|
||||
return this.page(instance.pluginId, pageId, userId, action, values, instance);
|
||||
}
|
||||
|
||||
async page(pluginId: string, pageId: string, userId: string, action?: string, values: unknown = {}, instance?: SourceInstance) {
|
||||
if (this.closed) throw pluginError(503, 'PLUGIN_UNAVAILABLE', 'Plugin manager is closed');
|
||||
const managed = this.managed.get(pluginId);
|
||||
if (!managed?.active || !managed.plugin) throw pluginError(404, 'PLUGIN_UNAVAILABLE', 'Plugin is unavailable');
|
||||
const extensions = instance ? managed.plugin.manifest.sourceTypes.find(type => type.id === instance.sourceType)?.extensions : managed.plugin.manifest.extensions;
|
||||
if (!extensions?.pages?.some(page => page.id === pageId)) throw pluginError(404, 'PAGE_NOT_FOUND', 'Plugin page is not declared');
|
||||
if (action !== undefined && !/^[a-z][a-z0-9._-]{0,63}$/.test(action)) throw pluginError(400, 'INVALID_ACTION', 'Invalid action');
|
||||
const input = extensionValues(values);
|
||||
const busyKey = instance ? JSON.stringify([pluginId, instance.id]) : pluginId;
|
||||
if (action && this.extensionBusy.has(busyKey)) throw pluginError(429, 'PLUGIN_BUSY', '插件正在更新,请稍后重试');
|
||||
if (action) this.extensionBusy.add(busyKey);
|
||||
try {
|
||||
return extensionPage(await managed.plugin.invoke(action ? 'extension.action' : 'extension.page', {
|
||||
pageId, userId, action, values: input,
|
||||
...(instance ? { sourceType: instance.sourceType, context: { instance, userId } } : {}),
|
||||
}));
|
||||
} finally { if (action) this.extensionBusy.delete(busyKey); }
|
||||
}
|
||||
|
||||
|
||||
constructor(private readonly db: Db, private readonly dataDir: string, private readonly registry: SourceRegistry) {}
|
||||
|
||||
@@ -139,7 +226,9 @@ export class PluginManager {
|
||||
return this.enqueue(async () => {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
clearInterval(this.taskTimer);
|
||||
const outcomes = await Promise.allSettled([...this.managed.values()].map((managed) => this.stop(managed)));
|
||||
await this.taskRun;
|
||||
const failure = outcomes.find((outcome) => outcome.status === 'rejected');
|
||||
if (failure?.status === 'rejected') throw failure.reason;
|
||||
}, true);
|
||||
@@ -171,13 +260,18 @@ export class PluginManager {
|
||||
}
|
||||
|
||||
private async packageDirectory(folder: string): Promise<string> {
|
||||
if (typeof folder !== 'string' || !/^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,126}[a-zA-Z0-9_-])?$/.test(folder)) {
|
||||
throw pluginError(400, 'PLUGIN_INVALID_FOLDER', 'Plugin folder must be a single directory name');
|
||||
const npmName = typeof folder === 'string' && folder.startsWith('npm:') ? folder.slice(4) : undefined;
|
||||
if (npmName !== undefined ? !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(npmName) || npmName.length > 214
|
||||
: typeof folder !== 'string' || !/^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,126}[a-zA-Z0-9_-])?$/.test(folder)) {
|
||||
throw pluginError(400, 'PLUGIN_INVALID_FOLDER', 'Use a single directory name or npm:package-name');
|
||||
}
|
||||
const data = await realpath(this.dataDir);
|
||||
const root = await realpath(resolve(data, 'plugins'));
|
||||
if (!inside(data, root)) throw pluginError(400, 'PLUGIN_PATH_ESCAPE', 'Plugin root must remain within DATA_DIR');
|
||||
const directory = await realpath(join(root, folder));
|
||||
const packageRoot = npmName === undefined ? root : await realpath(join(root, 'node_modules'));
|
||||
if (npmName !== undefined && !inside(root, packageRoot)) throw pluginError(400, 'PLUGIN_PATH_ESCAPE', 'npm packages must remain within DATA_DIR/plugins');
|
||||
const directory = await realpath(join(packageRoot, npmName ?? folder));
|
||||
if (!inside(packageRoot, directory)) throw pluginError(400, 'PLUGIN_PATH_ESCAPE', 'Plugin must remain within its package root');
|
||||
if (!inside(root, directory)) throw pluginError(400, 'PLUGIN_PATH_ESCAPE', 'Plugin package must remain within DATA_DIR/plugins');
|
||||
const manifest = await realpath(join(directory, 'plugin.json'));
|
||||
if (!inside(directory, manifest)) {
|
||||
@@ -188,7 +282,7 @@ export class PluginManager {
|
||||
}
|
||||
|
||||
private async loadPackage(folder: string): Promise<ProcessPlugin> {
|
||||
try { return await ProcessPlugin.load(await this.packageDirectory(folder)); }
|
||||
try { return await ProcessPlugin.load(await this.packageDirectory(folder), { dataRoot: join(this.dataDir, 'plugin-data') }); }
|
||||
catch (error) { throw this.packageInputError(error); }
|
||||
}
|
||||
|
||||
@@ -269,6 +363,7 @@ export class PluginManager {
|
||||
const managed = this.managed.get(row.plugin_id);
|
||||
return {
|
||||
pluginId: row.plugin_id, folder: row.folder, builtin: false, enabled: row.enabled === 1,
|
||||
extensions: managed?.plugin?.manifest.extensions,
|
||||
name: managed?.plugin?.manifest.name, version: managed?.plugin?.manifest.version,
|
||||
sourceTypes: managed?.providers.map((provider) => provider.descriptor) ?? [],
|
||||
runtime: managed?.plugin?.status() ?? null, error: managed?.error,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { searchFilterFields } from './extensions.ts';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||
import { readFile, realpath, stat, mkdir } from 'node:fs/promises';
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { AppError } from '../lib/errors.ts';
|
||||
import { validatePluginManifest } from './registry.ts';
|
||||
@@ -22,6 +24,7 @@ export class PluginError extends AppError {
|
||||
}
|
||||
|
||||
export interface ProcessPluginOptions {
|
||||
readonly dataRoot?: string;
|
||||
readonly timeoutMs?: number;
|
||||
/** Applies to each JSON line and limits the first version's inline resource payloads. */
|
||||
readonly maxMessageBytes?: number;
|
||||
@@ -45,6 +48,7 @@ export class ProcessPlugin {
|
||||
private readonly pending = new Map<number, PendingRequest>();
|
||||
private readonly exits = new Set<Promise<void>>();
|
||||
private readonly timeoutMs: number;
|
||||
private dataDir?: string;
|
||||
private readonly maxMessageBytes: number;
|
||||
|
||||
private constructor(
|
||||
@@ -80,7 +84,12 @@ export class ProcessPlugin {
|
||||
if (!(await stat(entry)).isFile()) {
|
||||
throw new PluginError('PLUGIN_INVALID', 'plugin entry must be a file');
|
||||
}
|
||||
return new ProcessPlugin(manifest, root, entry, options);
|
||||
const plugin = new ProcessPlugin(manifest, root, entry, options);
|
||||
if (options.dataRoot && manifest.permissions?.storage) {
|
||||
plugin.dataDir = resolve(options.dataRoot, createHash('sha256').update(manifest.id).digest('hex'));
|
||||
await mkdir(plugin.dataDir, { recursive: true });
|
||||
}
|
||||
return plugin;
|
||||
}
|
||||
|
||||
status(): PluginRuntimeStatus {
|
||||
@@ -89,9 +98,18 @@ export class ProcessPlugin {
|
||||
|
||||
providers(): readonly SourceProvider[] {
|
||||
return this.manifest.sourceTypes.map((type) => {
|
||||
const context = (ctx: SourceContext) => ({ instance: ctx.instance, userId: ctx.userId });
|
||||
const call = <T>(method: string, ctx: SourceContext, args: Record<string, unknown>): Promise<T> =>
|
||||
this.request(method, { sourceType: type.id, context: context(ctx), ...args }, ctx.signal) as Promise<T>;
|
||||
const call = async <T>(method: string, ctx: SourceContext, args: Record<string, unknown>): Promise<T> => {
|
||||
const credentials: Record<string, string> = Object.create(null);
|
||||
if (this.manifest.permissions?.credentials) {
|
||||
for (const { key } of type.credentialKeys ?? []) {
|
||||
const value = await ctx.credentials?.get(key, ctx.signal);
|
||||
if (value !== undefined) credentials[key] = value;
|
||||
}
|
||||
}
|
||||
return this.request(method, {
|
||||
sourceType: type.id, context: { instance: ctx.instance, userId: ctx.userId, credentials }, ...args,
|
||||
}, ctx.signal) as Promise<T>;
|
||||
};
|
||||
const provider: SourceProvider = {
|
||||
descriptor: { ...type, version: this.manifest.version },
|
||||
validateConfig: async (config) => {
|
||||
@@ -100,6 +118,8 @@ export class ProcessPlugin {
|
||||
detail: async (ctx, entryRef) => decodeCatalogEntry(await call('detail', ctx, { entryRef })),
|
||||
acquire: async (ctx, request) => decodeChapterAcquisition(await call('acquire', ctx, { request })),
|
||||
};
|
||||
if (type.capabilities.includes('search.filters')) provider.searchFilters = async (ctx) => searchFilterFields(await call('searchFilters', ctx, {}));
|
||||
if (type.capabilities.includes('content.alternatives')) provider.alternatives = async (ctx, request) => decodeCatalogPage(await call('alternatives', ctx, { request }));
|
||||
if (type.capabilities.includes('browse')) {
|
||||
provider.browse = async (ctx, request) => decodeCatalogPage(await call('browse', ctx, { request }));
|
||||
}
|
||||
@@ -120,6 +140,10 @@ export class ProcessPlugin {
|
||||
});
|
||||
}
|
||||
|
||||
invoke(method: 'extension.page' | 'extension.action' | 'extension.task', params: Record<string, unknown>): Promise<unknown> {
|
||||
return this.request(method, params);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const child = this.process;
|
||||
this.process = undefined;
|
||||
@@ -171,7 +195,10 @@ export class ProcessPlugin {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const id = ++this.sequence;
|
||||
const line = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
||||
const instanceId = (params.context as { instance?: { id?: string } } | undefined)?.instance?.id;
|
||||
const instanceDataDir = this.dataDir && instanceId
|
||||
? resolve(this.dataDir, 'sources', createHash('sha256').update(instanceId).digest('hex')) : undefined;
|
||||
const line = JSON.stringify({ jsonrpc: '2.0', id, method, params: { ...params, host: { dataDir: this.dataDir, instanceDataDir } } }) + '\n';
|
||||
if (Buffer.byteLength(line) > this.maxMessageBytes) {
|
||||
return Promise.reject(new PluginError('PLUGIN_MESSAGE_TOO_LARGE', 'plugin request exceeds the message limit'));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extensionDeclarations } from './extensions.ts';
|
||||
import type {
|
||||
PluginManifest,
|
||||
SourceCapability,
|
||||
@@ -15,11 +16,18 @@ export class SourceRegistryError extends Error {
|
||||
|
||||
const ID_RE = /^[a-z][a-z0-9._-]{0,127}$/;
|
||||
const CAPABILITIES = new Set<SourceCapability>([
|
||||
'browse', 'search', 'detail', 'acquire.file', 'acquire.chapters',
|
||||
'search.filters', 'content.alternatives', 'browse', 'search', 'detail', 'acquire.file', 'acquire.chapters',
|
||||
'content.manifest', 'content.resource', 'content.update',
|
||||
]);
|
||||
|
||||
function assertDescriptor(descriptor: SourceDescriptor): void {
|
||||
extensionDeclarations(descriptor.extensions);
|
||||
if (descriptor.credentialKeys !== undefined && (!Array.isArray(descriptor.credentialKeys) ||
|
||||
descriptor.credentialKeys.length > 16 || descriptor.credentialKeys.some((field) =>
|
||||
!field || !/^[a-z][a-z0-9_.-]{0,63}$/.test(field.key) || typeof field.label !== 'string' || !field.label.trim()) ||
|
||||
new Set(descriptor.credentialKeys.map((field) => field.key)).size !== descriptor.credentialKeys.length)) {
|
||||
throw new SourceRegistryError('invalid credential fields');
|
||||
}
|
||||
if (!ID_RE.test(descriptor.id)) throw new SourceRegistryError(`invalid source id: ${descriptor.id}`);
|
||||
if (!descriptor.label.trim()) throw new SourceRegistryError(`source ${descriptor.id} has an empty label`);
|
||||
if (!descriptor.version.trim()) throw new SourceRegistryError(`source ${descriptor.id} has no version`);
|
||||
@@ -50,6 +58,8 @@ function assertProvider(provider: SourceProvider): void {
|
||||
};
|
||||
requireMethod('browse', 'browse');
|
||||
requireMethod('search', 'search');
|
||||
requireMethod('search.filters', 'searchFilters');
|
||||
requireMethod('content.alternatives', 'alternatives');
|
||||
requireMethod('content.manifest', 'getManifest');
|
||||
requireMethod('content.resource', 'readResource');
|
||||
requireMethod('content.update', 'getManifest');
|
||||
@@ -140,6 +150,7 @@ export function validatePluginManifest(input: unknown): PluginManifest {
|
||||
if (!ID_RE.test(id)) throw new SourceRegistryError(`invalid plugin id: ${id}`);
|
||||
const manifest: PluginManifest = {
|
||||
id,
|
||||
extensions: extensionDeclarations(value.extensions),
|
||||
name: requiredString('name'),
|
||||
version: requiredString('version'),
|
||||
apiVersion: value.apiVersion as number,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { ExtensionField, PluginExtensions } from './extensions.ts';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
/** Stable source capabilities exposed to the host and clients. */
|
||||
export type SourceCapability =
|
||||
| 'browse'
|
||||
| 'search'
|
||||
| 'search.filters'
|
||||
| 'content.alternatives'
|
||||
| 'detail'
|
||||
| 'acquire.file'
|
||||
| 'acquire.chapters'
|
||||
@@ -12,6 +15,7 @@ export type SourceCapability =
|
||||
| 'content.update';
|
||||
|
||||
export interface SourceDescriptor {
|
||||
readonly extensions?: PluginExtensions;
|
||||
/** Stable source type id, e.g. `local`, `opds`, or a plugin-defined id. */
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
@@ -19,6 +23,7 @@ export interface SourceDescriptor {
|
||||
readonly capabilities: readonly SourceCapability[];
|
||||
/** JSON Schema for an instance's public configuration, when applicable. */
|
||||
readonly configSchema?: unknown;
|
||||
readonly credentialKeys?: readonly { key: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface SourceInstance {
|
||||
@@ -86,6 +91,7 @@ export interface BrowseRequest {
|
||||
}
|
||||
|
||||
export interface SearchRequest {
|
||||
readonly filters?: Record<string, string>;
|
||||
readonly query: string;
|
||||
readonly cursor?: string;
|
||||
readonly limit?: number;
|
||||
@@ -191,6 +197,9 @@ export interface ResourceResponse {
|
||||
|
||||
export interface SourceProvider {
|
||||
readonly descriptor: SourceDescriptor;
|
||||
/** Dynamic select fields; keys and option values are opaque to the host. */
|
||||
searchFilters?(ctx: SourceContext): Promise<ExtensionField[]>;
|
||||
alternatives?(ctx: SourceContext, request: SearchRequest & { publicationRef: string; authors?: readonly string[] }): Promise<CatalogPage>;
|
||||
validateConfig?(config: unknown): void | Promise<void>;
|
||||
browse?(ctx: SourceContext, request: BrowseRequest): Promise<CatalogPage>;
|
||||
search?(ctx: SourceContext, request: SearchRequest): Promise<CatalogPage>;
|
||||
@@ -209,13 +218,16 @@ export interface SourceRegistration {
|
||||
}
|
||||
|
||||
export interface PluginSourceType {
|
||||
readonly extensions?: PluginExtensions;
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly capabilities: readonly SourceCapability[];
|
||||
readonly configSchema?: unknown;
|
||||
readonly credentialKeys?: readonly { key: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
readonly extensions?: PluginExtensions;
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DatabaseSync } from 'node:sqlite';
|
||||
import { Db } from '../src/db/index.ts';
|
||||
import { SCHEMA_SQL } from '../src/db/schema.ts';
|
||||
import { ChapterPublications, MAX_CHAPTER_BYTES } from '../src/publications/chapters.ts';
|
||||
import { ChapterUpdates } from '../src/services/chapter-updates.ts';
|
||||
import type { SourceContext, SourceProvider } from '../src/sources/types.ts';
|
||||
|
||||
async function fixture() {
|
||||
@@ -37,6 +38,92 @@ async function fixture() {
|
||||
return { root, db, context, provider, chapters, setItems(next: typeof items) { items = next; } };
|
||||
}
|
||||
|
||||
test('rich chapter snapshots cache sanitised HTML and images across reloads', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.setItems([{ id: 'rich', seq: 0, title: '图文', kind: 'chapter', mediaType: 'text/html', ref: 'rich' }]);
|
||||
f.provider.readResource = async (_ctx, request) => request.ref === 'pic'
|
||||
? { mediaType: 'image/png', data: Buffer.from('89504e470d0a1a0a', 'hex') }
|
||||
: { mediaType: 'text/html', text: '<p>图文<strong>正文</strong><img src="reader-res:pic"></p><script>evil()</script>' };
|
||||
const id = await f.chapters.acquire(f.provider, f.context, { entryRef: 'book' }, 'book', { ref: 'book', title: '图文' });
|
||||
const manifest = f.chapters.manifest('u1', id);
|
||||
assert.equal(manifest.kind, 'reflowable'); assert.equal(manifest.items[0]!.format, undefined);
|
||||
const resource = await f.chapters.asset('u1', id, manifest.items[0]!.resourceRef!);
|
||||
assert.equal(resource.contentType, 'text/html; charset=utf-8');
|
||||
assert.match(resource.data!.toString(), /data:image\/png;base64,/); assert.doesNotMatch(resource.data!.toString(), /script|evil/);
|
||||
f.provider.readResource = async () => { throw new Error('offline'); };
|
||||
assert.deepEqual((await f.chapters.asset('u1', id, manifest.items[0]!.resourceRef!)).data, resource.data);
|
||||
} finally { f.db.close(); await rm(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('switching keeps book identity, old cache and subscriptions; failures never replace the binding', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const book = await f.chapters.acquire(f.provider, f.context, { entryRef: 'book' }, 'book', { ref: 'book', title: '远程书' });
|
||||
const previous = f.chapters.manifest('u1', book);
|
||||
const old = await f.chapters.asset('u1', book, previous.items[0]!.resourceRef!);
|
||||
const updates = new ChapterUpdates(f.db, f.chapters); updates.configure('u1', book, { enabled: true });
|
||||
const replacement: SourceProvider = { ...f.provider,
|
||||
async getManifest(_ctx, ref) { return { publicationRef: ref, items: [{ id: 'alternative', seq: 0, title: '第二章', kind: 'chapter', mediaType: 'text/html', ref: 'alternative-body' }] }; },
|
||||
async readResource() { throw new Error('offline'); },
|
||||
};
|
||||
await assert.rejects(f.chapters.switchSource(replacement, f.context, book, 'new', 'new', 'alternative', previous.revision), /offline/);
|
||||
assert.equal(f.chapters.binding('u1', book).publication_ref, 'book');
|
||||
await assert.rejects(f.chapters.switchSource(replacement, f.context, book, 'new', 'new', 'missing', previous.revision));
|
||||
await assert.rejects(f.chapters.switchSource(replacement, f.context, book, 'new', 'new', 'alternative', 'stale'), { code: 'CHAPTER_SNAPSHOT_EXPIRED' });
|
||||
assert.throws(() => f.chapters.binding('other', book), { code: 'BOOK_NOT_FOUND' });
|
||||
replacement.readResource = async () => ({ mediaType: 'text/html', text: '<p>新源正文</p><script>evil()</script>' });
|
||||
const switched = await f.chapters.switchSource(replacement, f.context, book, 'new', 'new', 'alternative', previous.revision);
|
||||
assert.equal(f.chapters.binding('u1', book).publication_ref, 'new');
|
||||
assert.equal(switched.href, switched.content.items[0]!.href);
|
||||
assert.equal(updates.list('u1')[0]!.enabled, true);
|
||||
assert.deepEqual((await f.chapters.asset('u1', book, previous.items[0]!.resourceRef!)).data, old.data);
|
||||
const body = await f.chapters.asset('u1', book, switched.content.items[0]!.resourceRef!);
|
||||
assert.match(body.data!.toString(), /新源正文/); assert.doesNotMatch(body.data!.toString(), /script|evil/);
|
||||
const acquiredAgain = await f.chapters.acquire(replacement, f.context, { entryRef: 'new' }, 'new', { ref: 'new', title: '远程书' });
|
||||
assert.equal(acquiredAgain, book);
|
||||
const original = await f.chapters.acquire(f.provider, f.context, { entryRef: 'book' }, 'book', { ref: 'book', title: '远程书' });
|
||||
assert.notEqual(original, book); assert.equal(f.chapters.binding('u1', original).publication_ref, 'book');
|
||||
await assert.rejects(f.chapters.switchSource(f.provider, f.context, book, 'book', 'book', 'one', switched.content.revision), { code: 'SOURCE_ALREADY_ACQUIRED' });
|
||||
assert.equal(f.chapters.binding('u1', book).publication_ref, 'new');
|
||||
await updates.stop();
|
||||
} finally { f.db.close(); await rm(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('durable subscriptions update once, accumulate new chapters, retry failures and respect account/shelf state', async () => {
|
||||
const f = await fixture();
|
||||
let now = 1000;
|
||||
try {
|
||||
const id = await f.chapters.acquire(f.provider, f.context, { entryRef: 'book' }, 'book', { ref: 'book', title: '远程书' });
|
||||
let updates = new ChapterUpdates(f.db, f.chapters, () => now);
|
||||
assert.throws(() => updates.configure('other', id, { enabled: true }), { code: 'BOOK_NOT_FOUND' });
|
||||
assert.throws(() => updates.configure('u1', id, { intervalMinutes: 1 }), { code: 'BAD_REQUEST' });
|
||||
updates.configure('u1', id, { enabled: true, intervalMinutes: 15 });
|
||||
f.setItems([{ id: 'three', seq: 0, title: '新章', kind: 'chapter', mediaType: 'text/plain', ref: 'new' }]);
|
||||
await Promise.all([updates.runDue(), updates.runDue()]);
|
||||
assert.equal(updates.list('u1')[0]!.newChapters, 1);
|
||||
assert.equal(updates.list('u1')[0]!.lastSuccessAt, now);
|
||||
updates = new ChapterUpdates(f.db, f.chapters, () => now);
|
||||
await updates.runDue(); assert.equal(updates.list('u1')[0]!.newChapters, 1);
|
||||
now += 15 * 60_000;
|
||||
const original = f.provider.getManifest;
|
||||
f.provider.getManifest = async () => { throw new Error('upstream secret must not leak'); };
|
||||
await updates.runDue();
|
||||
assert.equal(updates.list('u1')[0]!.lastError, 'UPDATE_FAILED');
|
||||
assert.equal(updates.list('u1')[0]!.lastSuccessAt, 1000);
|
||||
assert.ok(updates.list('u1')[0]!.nextCheckAt > now);
|
||||
f.provider.getManifest = original;
|
||||
updates.configure('u1', id, { acknowledge: true, enabled: false });
|
||||
assert.equal(updates.list('u1')[0]!.newChapters, 0);
|
||||
const snapshot = f.chapters.manifest('u1', id).revision;
|
||||
updates.configure('u1', id, { enabled: true });
|
||||
f.db.run('UPDATE user_books SET hidden = 1 WHERE book_id = ?', id);
|
||||
await updates.runDue(); assert.equal(f.chapters.manifest('u1', id).revision, snapshot);
|
||||
assert.equal(updates.list('u1').length, 0);
|
||||
await updates.stop();
|
||||
} finally { f.db.close(); await rm(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('chapter acquisition creates isolated stable publication and caches plain text assets', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
@@ -77,7 +164,7 @@ test('refresh keeps stable href, versions resource refs, and leaves old cached b
|
||||
test('unsupported manifest media is rejected before creating a publication', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.setItems([{ id: 'bad', seq: 0, title: '坏', kind: 'chapter', mediaType: 'text/html', ref: 'bad' }]);
|
||||
f.setItems([{ id: 'bad', seq: 0, title: '坏', kind: 'chapter', mediaType: 'application/javascript', ref: 'bad' }]);
|
||||
await assert.rejects(() => f.chapters.acquire(f.provider, f.context, { entryRef: 'book' }, 'book', { ref: 'book', title: '坏' }), { code: 'UNSUPPORTED_FORMAT' });
|
||||
assert.equal(f.db.get<{ count: number }>('SELECT count(*) AS count FROM books')!.count, 0);
|
||||
} finally { f.db.close(); await rm(f.root, { recursive: true, force: true }); }
|
||||
@@ -106,6 +193,29 @@ test('old database gains content_hash_kind as an additive migration', async () =
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('old plain chapter caches survive media type migration and repeated restart', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const id = await f.chapters.acquire(f.provider, f.context, { entryRef: 'book' }, 'book', { ref: 'book', title: '旧缓存' });
|
||||
const ref = f.chapters.manifest('u1', id).items[0]!.resourceRef!;
|
||||
const original = await f.chapters.asset('u1', id, ref);
|
||||
f.db.run('ALTER TABLE chapter_resources DROP COLUMN media_type');
|
||||
for (let restart = 0; restart < 2; restart += 1) {
|
||||
const upgraded = new Db(join(f.root, 'reader.db'));
|
||||
try {
|
||||
const chapters = new ChapterPublications(upgraded, {
|
||||
manifest: async () => { throw new Error('source unavailable'); },
|
||||
resource: async () => { throw new Error('source unavailable'); },
|
||||
});
|
||||
const cached = await chapters.asset('u1', id, ref);
|
||||
assert.equal(cached.contentType, 'text/plain; charset=utf-8');
|
||||
assert.deepEqual(cached.data, original.data);
|
||||
assert.deepEqual(upgraded.all('PRAGMA foreign_key_check'), []);
|
||||
} finally { upgraded.close(); }
|
||||
}
|
||||
} finally { f.db.close(); await rm(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('a refresh racing with an uncached resource never labels new bytes with an old revision', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extensionDeclarations, extensionFields, extensionPage, extensionValues } from '../src/sources/extensions.ts';
|
||||
|
||||
test('extension protocol rejects malformed declarations, unbounded options and structured action values', () => {
|
||||
assert.throws(() => extensionDeclarations({ pages: [{ id: '../escape', title: 'x' }] }));
|
||||
assert.throws(() => extensionDeclarations({ tasks: [{ id: 'poll', intervalMinutes: 0 }] }));
|
||||
assert.throws(() => extensionDeclarations({ tasks: [{ id: 'poll', intervalMinutes: 1 }, { id: 'poll', intervalMinutes: 2 }] }));
|
||||
assert.throws(() => extensionFields([{ key: 'region', label: 'Region', type: 'select', options: Array(10002).fill({ value: 'a', label: 'A' }) }]));
|
||||
assert.throws(() => extensionFields([{ key: 'x', label: 'X', type: 'html' }]));
|
||||
assert.throws(() => extensionValues({ items: ['unvalidated'] }));
|
||||
assert.throws(() => extensionValues(JSON.parse('{"__proto__":"injected"}')));
|
||||
assert.throws(() => extensionPage({ title: 'x', forms: [], sections: [{ title: 'x', items: Array(201).fill({ title: 'x' }) }] }));
|
||||
assert.deepEqual(extensionPage({ title: 'Settings', forms: [{ id: 'save', title: '', submit: 'Save', fields: [{ key: 'enabled', label: 'Enabled', type: 'boolean', value: false }] }] }).forms[0]!.fields[0]!.value, false);
|
||||
});
|
||||
|
||||
test('tabbed extension pages enforce bounded content and declared active tabs', () => {
|
||||
const tab = { id: 'sources', title: 'Sources', forms: [], sections: [{ title: 'List', emptyText: 'Empty', items: [{ title: 'A', collapsible: true, forms: [] }] }] };
|
||||
assert.equal(extensionPage({ title: 'Library', forms: [], tabs: [tab], activeTab: 'sources', notice: 'Saved' }).tabs?.[0]?.id, 'sources');
|
||||
for (const tabs of [[tab, tab], [{ ...tab, id: '../escape' }], [{ ...tab, forms: null }], Array(17).fill(tab)]) {
|
||||
assert.throws(() => extensionPage({ title: 'x', forms: [], tabs }));
|
||||
}
|
||||
assert.throws(() => extensionPage({ title: 'x', forms: [], tabs: [tab], activeTab: 'missing' }));
|
||||
});
|
||||
|
||||
test('extension UX hints are validated without allowing executable or unbounded values', () => {
|
||||
assert.doesNotThrow(() => extensionPage({ title: 'x', forms: [{ id: 'save', title: '', submit: 'Save', layout: 'inline', confirm: 'Delete?', fields: [{ key: 'count', label: 'Count', type: 'number', min: 1, max: 100, placeholder: '1' }] }], noticeKind: 'error' }));
|
||||
for (const field of [{ min: Infinity }, { min: 2, max: 1 }, { placeholder: {} }]) {
|
||||
assert.throws(() => extensionFields([{ key: 'n', label: 'N', type: 'number', ...field }]));
|
||||
}
|
||||
assert.throws(() => extensionPage({ title: 'x', forms: [], noticeKind: 'html' }));
|
||||
assert.throws(() => extensionPage({ title: 'x', forms: [{ id: 'save', title: '', submit: 'Save', fields: [], confirm: {} }] }));
|
||||
});
|
||||
@@ -80,6 +80,61 @@ test('installation registers a real provider and lists builtins alongside the pe
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
|
||||
test('generic extension pages and durable scheduled tasks work without provider-specific host code', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const directory = await f.package();
|
||||
await writeFile(join(directory, 'plugin.json'), JSON.stringify({
|
||||
id: 'test.plugin', name: 'Generic', version: '1', apiVersion: 1, runtime: 'node', entry: 'main.mjs',
|
||||
permissions: { storage: true }, sourceTypes: [{ id: 'test', label: 'Test', capabilities: ['detail'] }],
|
||||
extensions: { pages: [{ id: 'settings', title: 'Settings' }], tasks: [{ id: 'poll', intervalMinutes: 5 }] },
|
||||
}));
|
||||
await writeFile(join(directory, 'main.mjs'), `
|
||||
import { createInterface } from 'node:readline';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
for await (const line of createInterface({ input: process.stdin })) {
|
||||
const { id, method, params } = JSON.parse(line), path = join(params.host.dataDir, 'state.json');
|
||||
let n = 0; try { n = JSON.parse(await readFile(path, 'utf8')); } catch {}
|
||||
if (method === 'extension.task') { n++; await writeFile(path, JSON.stringify(n)); }
|
||||
if (method === 'extension.action') { n = Number(params.values.count); await writeFile(path, JSON.stringify(n)); }
|
||||
const result = method === 'extension.task' ? {} : { title: String(n), forms: [], sections: [] };
|
||||
process.stdout.write(JSON.stringify({jsonrpc:'2.0', id, result}) + '\\n');
|
||||
}
|
||||
`);
|
||||
const info = await f.manager.install('example'); assert.equal(info.extensions?.pages?.[0]?.id, 'settings');
|
||||
assert.equal((await f.manager.page('test.plugin', 'settings', 'admin')).title, '0');
|
||||
await f.manager.page('test.plugin', 'settings', 'admin', 'set', { count: 4 });
|
||||
await Promise.all([f.manager.runTasks(1000), f.manager.runTasks(1000)]);
|
||||
assert.equal((await f.manager.page('test.plugin', 'settings', 'admin')).title, '5');
|
||||
await f.manager.close();
|
||||
const restarted = f.restart(); await restarted.loadInstalled(); await restarted.runTasks(1001);
|
||||
assert.equal((await restarted.page('test.plugin', 'settings', 'admin')).title, '5', 'task deadline and plugin data survive restart');
|
||||
await restarted.runTasks(301001); assert.equal((await restarted.page('test.plugin', 'settings', 'admin')).title, '6');
|
||||
await assert.rejects(restarted.page('test.plugin', 'missing', 'admin'), { code: 'PAGE_NOT_FOUND' });
|
||||
await restarted.setEnabled('test.plugin', false); await restarted.runTasks(700000);
|
||||
await assert.rejects(restarted.page('test.plugin', 'settings', 'admin'), { code: 'PLUGIN_UNAVAILABLE' });
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
|
||||
test('npm packages support scopes, restart and reject traversal or outside links', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.package('node_modules/@reader/source');
|
||||
await f.manager.install('npm:@reader/source');
|
||||
assert.equal((await f.registry.require('test.plugin', 'test').provider.detail(context(), 'book')).title, 'Book');
|
||||
await f.manager.close();
|
||||
const restarted = f.restart(); await restarted.loadInstalled();
|
||||
assert.equal(restarted.list().find(p => p.pluginId === 'test.plugin')?.folder, 'npm:@reader/source');
|
||||
for (const input of ['npm:../outside', 'npm:@reader/../../outside', 'npm:source@latest', 'npm:C:\\outside', 'npm:']) {
|
||||
await assert.rejects(restarted.install(input), { code: 'PLUGIN_INVALID_FOLDER' });
|
||||
}
|
||||
const outside = join(f.root, 'outside'); await mkdir(outside);
|
||||
await symlink(outside, join(f.dataDir, 'plugins', 'node_modules', 'escape'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
await assert.rejects(restarted.install('npm:escape'), { code: 'PLUGIN_PATH_ESCAPE' });
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
|
||||
test('disable stops in-flight work, rejects retained providers, and persists across restart', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
@@ -237,3 +292,52 @@ test('changed package identity is refused on restart and lifecycle mutations rem
|
||||
await assert.rejects(restarted.setEnabled('test.plugin', true), { code: 'PLUGIN_MANAGER_CLOSED' });
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
|
||||
test('source extension pages and schedules isolate instances, persist and skip paused/deleted sources', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const directory = await f.package();
|
||||
await writeFile(join(directory, 'plugin.json'), JSON.stringify({
|
||||
id: 'test.plugin', name: 'Generic', version: '1', apiVersion: 1, runtime: 'node', entry: 'main.mjs', permissions: { storage: true },
|
||||
sourceTypes: [{ id: 'test', label: 'Test', capabilities: ['detail'], extensions: {
|
||||
pages: [{ id: 'settings', title: 'Settings' }], tasks: [{ id: 'poll', intervalMinutes: 5 }],
|
||||
} }],
|
||||
}));
|
||||
await writeFile(join(directory, 'main.mjs'), `
|
||||
import { createInterface } from 'node:readline';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
for await (const line of createInterface({ input: process.stdin })) {
|
||||
const { id, method, params } = JSON.parse(line), directory = params.host.instanceDataDir;
|
||||
await mkdir(directory, { recursive: true });
|
||||
const path = join(directory, 'state.json');
|
||||
let n = 0; try { n = JSON.parse(await readFile(path, 'utf8')); } catch {}
|
||||
if (method === 'extension.task') n++;
|
||||
if (method === 'extension.action') n = Number(params.values.count);
|
||||
await writeFile(path, JSON.stringify(n));
|
||||
const result = { title: params.context.instance.name, description: String(n), forms: [] };
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\\n');
|
||||
}
|
||||
`);
|
||||
await f.manager.install('example');
|
||||
for (const id of ['a', 'b']) f.db.run('INSERT INTO source_instances (id, plugin_id, source_type, name, config_json, enabled, created_at) VALUES (?, ?, ?, ?, ?, 1, 0)', id, 'test.plugin', 'test', id, '{}');
|
||||
await f.manager.sourcePage('a', 'settings', 'admin', 'save', { count: 10 });
|
||||
assert.equal((await f.manager.sourcePage('b', 'settings', 'admin')).description, '0');
|
||||
await assert.rejects(f.manager.page('test.plugin', 'settings', 'admin'), { code: 'PAGE_NOT_FOUND' });
|
||||
await assert.rejects(f.manager.sourcePage('a', 'unknown', 'admin'), { code: 'PAGE_NOT_FOUND' });
|
||||
await Promise.all([f.manager.runTasks(1000), f.manager.runTasks(1000)]);
|
||||
assert.equal((await f.manager.sourcePage('a', 'settings', 'admin')).description, '11');
|
||||
assert.equal((await f.manager.sourcePage('b', 'settings', 'admin')).description, '1');
|
||||
await f.manager.close(); const restarted = f.restart(); await restarted.loadInstalled();
|
||||
await restarted.runTasks(2000);
|
||||
assert.equal((await restarted.sourcePage('a', 'settings', 'admin')).description, '11');
|
||||
f.db.run('UPDATE source_instances SET enabled = 0 WHERE id = ?', 'a');
|
||||
await restarted.runTasks(400000);
|
||||
assert.equal((await restarted.sourcePage('a', 'settings', 'admin')).description, '11');
|
||||
assert.equal((await restarted.sourcePage('b', 'settings', 'admin')).description, '2');
|
||||
f.db.run('DELETE FROM source_instances WHERE id = ?', 'b');
|
||||
await restarted.runTasks(800000);
|
||||
await assert.rejects(restarted.sourcePage('b', 'settings', 'admin'), { code: 'SOURCE_NOT_FOUND' });
|
||||
assert.equal(f.db.all('SELECT key FROM plugin_storage WHERE key LIKE ?', 'source-task:%').length, 2);
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { richContent, chapterMedia } from '../src/publications/rich-content.ts';
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aHf8AAAAASUVORK5CYII=', 'base64');
|
||||
|
||||
test('rich chapters keep semantic markup and embed only declared raster resources', async () => {
|
||||
const calls: string[] = [];
|
||||
const html = await richContent(`<h2 onclick="evil()">标题</h2><p>正文 <strong>强调</strong></p>
|
||||
<script>evil()</script><style>@import 'https://evil.test'</style><iframe srcdoc="bad"></iframe>
|
||||
<img src="reader-res:opaque?x=1&y=2" onerror="evil()"><img src="https://evil.test/pixel">
|
||||
<svg><image href="https://evil.test"></image></svg><form action="https://evil.test"><input></form>
|
||||
<a href="javascript:evil()">链接</a><div style="background:url(https://evil.test)">安全文本</div>`, async (ref) => {
|
||||
calls.push(ref); return { mediaType: 'image/png', data: png };
|
||||
});
|
||||
assert.deepEqual(calls, ['opaque?x=1&y=2']);
|
||||
assert.match(html, /<h2>标题<\/h2>/); assert.match(html, /<strong>强调<\/strong>/);
|
||||
assert.match(html, /src="data:image\/png;base64,/);
|
||||
assert.doesNotMatch(html, /evil|script|style|iframe|svg|form|input|onclick|onerror|https:/);
|
||||
});
|
||||
|
||||
test('rich chapter resource failures, active image formats and quotas fail without partial output', async () => {
|
||||
await assert.rejects(() => richContent('<img src="reader-res:a">', async () => ({ mediaType: 'image/svg+xml', data: Buffer.from('<svg/>') })), { code: 'INVALID_RESOURCE' });
|
||||
await assert.rejects(() => richContent('<img src="reader-res:a">', async () => ({ mediaType: 'image/png', data: Buffer.from('<script>bad</script>') })), { code: 'INVALID_RESOURCE' });
|
||||
await assert.rejects(() => richContent(Array.from({ length: 33 }, (_, n) => `<img src="reader-res:${n}">`).join(''), async () => ({ mediaType: 'image/png', data: png })), { code: 'CHAPTER_TOO_LARGE' });
|
||||
const big = Buffer.alloc(1024 * 1024); png.copy(big);
|
||||
await assert.rejects(() => richContent('<img src="reader-res:repeat">'.repeat(8), async () => ({ mediaType: 'image/png', data: big })), { code: 'CHAPTER_TOO_LARGE' });
|
||||
assert.throws(() => chapterMedia('text/html; charset = gbk'), { code: 'UNSUPPORTED_FORMAT' });
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { cp, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { cp, mkdir, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
@@ -76,6 +76,26 @@ async function harness() {
|
||||
};
|
||||
}
|
||||
|
||||
test('source editing validates configuration, clears destination credentials and keeps acquired sources', async (t) => {
|
||||
const h = await harness(); t.after(() => h.close());
|
||||
const member = await h.member();
|
||||
const create = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: {
|
||||
id: 'editable', pluginId: 'reader.opds', sourceType: 'opds', name: 'Before', config: { url: 'https://one.test/opds' },
|
||||
} });
|
||||
assert.equal(create.statusCode, 201, create.body);
|
||||
await h.app.inject({ method: 'PUT', url: '/api/v1/sources/editable/credentials/password', headers: auth(member), payload: { value: 'private' } });
|
||||
const forbidden = await h.app.inject({ method: 'PATCH', url: '/api/v1/sources/editable', headers: auth(member), payload: { name: 'bad' } });
|
||||
assert.equal(forbidden.statusCode, 403);
|
||||
const edited = await h.app.inject({ method: 'PATCH', url: '/api/v1/sources/editable', headers: auth(h.admin), payload: { name: 'After', config: { url: 'https://two.test/opds' } } });
|
||||
assert.equal(edited.statusCode, 200, edited.body); assert.equal(edited.json().source.name, 'After');
|
||||
assert.equal(h.ctx.db.get<{ count: number }>('SELECT count(*) AS count FROM source_credentials WHERE source_id = ?', 'editable')!.count, 0);
|
||||
const invalid = await h.app.inject({ method: 'PATCH', url: '/api/v1/sources/editable', headers: auth(h.admin), payload: { config: { url: 'file:///secret' } } });
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
const removed = await h.app.inject({ method: 'DELETE', url: '/api/v1/sources/editable', headers: auth(h.admin) });
|
||||
assert.equal(removed.statusCode, 200, removed.body);
|
||||
assert.equal((await h.app.inject({ method: 'DELETE', url: '/api/v1/sources/local', headers: auth(h.admin) })).statusCode, 400);
|
||||
});
|
||||
|
||||
async function opdsFixture() {
|
||||
const body = '第一章 远程下载\n\n这是经 OPDS 获取的本地可读正文。\n\n第二章 继续阅读\n\n下载后继续复用现有书架和阅读进度。';
|
||||
let downloads = 0;
|
||||
|
||||
@@ -481,6 +481,19 @@ describe('uploading a book', () => {
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal((res.json() as { writable: boolean }).writable, true);
|
||||
});
|
||||
|
||||
test('members cannot probe or upload, and rejected uploads leave the library unchanged', async () => {
|
||||
await ctx.users.createAsAdmin({ username: 'upload-member', password: 'password123', role: 'member' });
|
||||
const member = await ctx.users.login('upload-member', 'password123');
|
||||
const headers = { authorization: `Bearer ${member.accessToken}` };
|
||||
const before = await readdir(booksDir);
|
||||
const probe = await app.inject({ method: 'GET', url: '/api/v1/library/upload', headers });
|
||||
assert.equal(probe.statusCode, 403);
|
||||
const body = multipart([{ field: 'files', filename: 'member-forbidden.txt', content: Buffer.from('not allowed') }]);
|
||||
const upload = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...headers, 'content-type': body.contentType }, payload: body.body });
|
||||
assert.equal(upload.statusCode, 403);
|
||||
assert.deepEqual(await readdir(booksDir), before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploading an archive', () => {
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@
|
||||
"test:watch": "vitest",
|
||||
"icons": "node tools/icons/build.mjs",
|
||||
"ui:review": "npm run build && node tools/ui-review/run.mjs",
|
||||
"ui:review:only": "node tools/ui-review/run.mjs"
|
||||
"ui:review:only": "node tools/ui-review/run.mjs",
|
||||
"ui:sources": "npm run build && node --import ../server/node_modules/tsx/dist/loader.mjs tools/ui-review/sources-e2e.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
+55
-1
@@ -1,3 +1,4 @@
|
||||
import type { ExtensionField, ExtensionPage } from './sources.ts';
|
||||
import { ApiError, errorForStatus, parseErrorBody } from './errors.ts';
|
||||
import type {
|
||||
BatchResult,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
User,
|
||||
} from './types.ts';
|
||||
import type { Platform } from '../core/platform.ts';
|
||||
import type { SourceType, SourceInstance, SourcePlugin, SourceEntry, SourcePage, SourceAcquisition, ChapterSubscription } from './sources.ts';
|
||||
|
||||
export interface SessionStore {
|
||||
load(): Promise<Session | null>;
|
||||
@@ -199,7 +201,7 @@ export class ReaderApi {
|
||||
}
|
||||
|
||||
async register(username: string, password: string, displayName?: string): Promise<Session> {
|
||||
const session = await this.call<Session>('/api/v1/auth/register', 'POST', {
|
||||
const { session } = await this.call<{ user: User; session: Session }>('/api/v1/auth/register', 'POST', {
|
||||
username,
|
||||
password,
|
||||
...(displayName ? { displayName } : {}),
|
||||
@@ -227,6 +229,58 @@ export class ReaderApi {
|
||||
|
||||
// ---- shelf ----
|
||||
|
||||
async sourcePage(id: string, pageId: string, action?: string, values?: Record<string, unknown>): Promise<ExtensionPage> {
|
||||
const path = '/api/v1/sources/' + encodeURIComponent(id) + '/pages/' + encodeURIComponent(pageId);
|
||||
return action ? this.call(path, 'POST', { action, values }) : this.get(path);
|
||||
}
|
||||
|
||||
async pluginPage(id: string, pageId: string, action?: string, values?: Record<string, unknown>): Promise<ExtensionPage> {
|
||||
const path = '/api/v1/plugins/' + encodeURIComponent(id) + '/pages/' + encodeURIComponent(pageId);
|
||||
return action ? this.call(path, 'POST', { action, values }) : this.get(path);
|
||||
}
|
||||
async sourceFilters(id: string): Promise<ExtensionField[]> { return this.get('/api/v1/sources/' + encodeURIComponent(id) + '/search-filters'); }
|
||||
async sourceOptions(id: string): Promise<{ canSwitch: boolean }> { return this.get('/api/v1/books/' + encodeURIComponent(id) + '/source-options'); }
|
||||
async alternatives(id: string, cursor?: string): Promise<SourcePage> { return this.get('/api/v1/books/' + encodeURIComponent(id) + '/alternatives' + (cursor ? '?cursor=' + encodeURIComponent(cursor) : '')); }
|
||||
async switchPreview(id: string, entryRef: string): Promise<{ chapters: Array<{ id: string; title: 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 }> {
|
||||
return this.call('/api/v1/books/' + encodeURIComponent(id) + '/switch-source', 'POST', { entryRef, chapterId, revision });
|
||||
}
|
||||
async sourceTypes(): Promise<SourceType[]> { return (await this.get<{ types: SourceType[] }>('/api/v1/sources/types')).types; }
|
||||
async sources(): Promise<SourceInstance[]> { return (await this.get<{ sources: SourceInstance[] }>('/api/v1/sources')).sources; }
|
||||
async plugins(): Promise<SourcePlugin[]> { return (await this.get<{ plugins: SourcePlugin[] }>('/api/v1/plugins')).plugins; }
|
||||
async saveSource(id: string | null, input: Record<string, unknown>): Promise<SourceInstance> {
|
||||
return (await this.call<{ source: SourceInstance }>(`/api/v1/sources${id ? `/${encodeURIComponent(id)}` : ''}`, id ? 'PATCH' : 'POST', input)).source;
|
||||
}
|
||||
async removeSource(id: string): Promise<void> { await this.call(`/api/v1/sources/${encodeURIComponent(id)}`, 'DELETE'); }
|
||||
async sourceCredential(id: string, key: string, value: string): Promise<void> {
|
||||
await this.call(`/api/v1/sources/${encodeURIComponent(id)}/credentials/${encodeURIComponent(key)}`, 'PUT', { value });
|
||||
}
|
||||
async installPlugin(folder: string): Promise<void> { await this.call('/api/v1/plugins', 'POST', { folder, trusted: true }); }
|
||||
async enablePlugin(id: string, enabled: boolean): Promise<void> { await this.call(`/api/v1/plugins/${encodeURIComponent(id)}`, 'PATCH', { enabled }); }
|
||||
async uninstallPlugin(id: string): Promise<void> { await this.call(`/api/v1/plugins/${encodeURIComponent(id)}`, 'DELETE'); }
|
||||
async sourceCatalog(id: string, query: { ref?: string; query?: string; cursor?: string; filters?: Record<string, string> } = {}): Promise<SourcePage> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.ref) params.set('ref', query.ref);
|
||||
if (query.query) params.set('q', query.query);
|
||||
if (query.cursor) params.set('cursor', query.cursor);
|
||||
if (query.filters) params.set('filters', JSON.stringify(query.filters));
|
||||
return this.get(`/api/v1/sources/${encodeURIComponent(id)}/${query.query ? 'search' : 'browse'}?${params}`);
|
||||
}
|
||||
async sourceDetail(id: string, ref: string): Promise<SourceEntry> {
|
||||
return this.get(`/api/v1/sources/${encodeURIComponent(id)}/entries?ref=${encodeURIComponent(ref)}`);
|
||||
}
|
||||
async acquireSource(id: string, entryRef: string, optionId?: string): Promise<SourceAcquisition> {
|
||||
return this.call(`/api/v1/sources/${encodeURIComponent(id)}/acquire`, 'POST', { entryRef, ...(optionId ? { optionId } : {}) });
|
||||
}
|
||||
async subscriptions(): Promise<ChapterSubscription[]> {
|
||||
return (await this.get<{ subscriptions: ChapterSubscription[] }>('/api/v1/subscriptions')).subscriptions;
|
||||
}
|
||||
async configureSubscription(id: string, patch: { enabled?: boolean; intervalMinutes?: number; acknowledge?: boolean }): Promise<void> {
|
||||
await this.call(`/api/v1/books/${encodeURIComponent(id)}/subscription`, 'PATCH', patch);
|
||||
}
|
||||
|
||||
async listBooks(query: ListQuery = {}, options: RequestOptions = {}): Promise<BookListPage> {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
|
||||
@@ -51,6 +51,8 @@ export class ApiError extends Error {
|
||||
* the client cannot classify.
|
||||
*/
|
||||
get isAuthFailure(): boolean {
|
||||
// Source credentials are independent of the reader account session.
|
||||
if (this.code === 'AUTH_REQUIRED') return false;
|
||||
if (this.kind === 'unauthorized') return true;
|
||||
if (this.kind !== 'forbidden') return false;
|
||||
return AUTH_FAILURE_CODES.has(this.code);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface SourceType {
|
||||
extensions?: { pages?: Array<{ id: string; title: string }> };
|
||||
id: string; pluginId: string; label: string; version: string; builtin: boolean; capabilities: string[];
|
||||
configSchema?: { properties?: Record<string, { type?: string; title?: string; default?: unknown }>; required?: string[] };
|
||||
credentialKeys?: Array<{ key: string; label: string }>;
|
||||
}
|
||||
export interface SourceInstance {
|
||||
id: string; pluginId: string; sourceType: string; name: string; enabled: boolean;
|
||||
config?: Record<string, unknown>; descriptor: SourceType | null;
|
||||
}
|
||||
export interface SourceEntry {
|
||||
ref: string; title: string; authors?: string[]; description?: string;
|
||||
options?: Array<{ id: string; label: string; available?: boolean }>;
|
||||
}
|
||||
export interface SourcePage {
|
||||
items: SourceEntry[]; navigation?: Array<{ ref: string; title: string }>; nextCursor?: string; title?: string;
|
||||
}
|
||||
export interface SourceAcquisition {
|
||||
kind: 'ready' | 'action-required'; publicationId?: string;
|
||||
action?: { label: string; url?: string };
|
||||
}
|
||||
export interface SourcePlugin {
|
||||
extensions?: { pages?: Array<{ id: string; title: string }> };
|
||||
pluginId: string; builtin: boolean; name?: string; version?: string; folder?: string; enabled: boolean;
|
||||
runtime?: { state: string } | null; error?: { code: string; message: string };
|
||||
}
|
||||
export interface ChapterSubscription {
|
||||
bookId: string; title: string; enabled: boolean; intervalMinutes: number;
|
||||
nextCheckAt: number; lastCheckAt: number | null; lastSuccessAt: number | null;
|
||||
lastError: string | null; newChapters: number;
|
||||
}
|
||||
|
||||
export interface ExtensionField {
|
||||
placeholder?: string; min?: number; max?: number;
|
||||
key: string; label: string; type: 'text' | 'textarea' | 'number' | 'boolean' | 'select'; required?: boolean;
|
||||
value?: string | number | boolean; options?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
export interface ExtensionForm {
|
||||
layout?: 'inline'; confirm?: string; id: string; title: string; submit: string; fields: ExtensionField[]; values?: Record<string, string | number | boolean> }
|
||||
export interface ExtensionContent {
|
||||
forms: ExtensionForm[];
|
||||
sections?: Array<{ title: string; emptyText?: string; items: Array<{ title: string; description?: string; collapsible?: boolean; forms?: ExtensionForm[] }> }>;
|
||||
}
|
||||
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 }>;
|
||||
}
|
||||
+25
-1
@@ -1,3 +1,4 @@
|
||||
import { PluginPageScreen } from './ui/plugin-page-screen.tsx';
|
||||
import { ReaderApi, type SessionStore } from './api/client.ts';
|
||||
import { ApiError } from './api/errors.ts';
|
||||
import type { Book, Session } from './api/types.ts';
|
||||
@@ -16,11 +17,12 @@ import { OfflineStore } from './store/offline.ts';
|
||||
import { publicationScope } from './store/publications.ts';
|
||||
import { SettingsStore, DEFAULT_APP_SETTINGS, type AppSettings } from './store/settings.ts';
|
||||
import { ShelfScreen } from './ui/shelf-screen.tsx';
|
||||
import { SourcesScreen } from './ui/sources-screen.tsx';
|
||||
import { ReaderScreen } from './ui/reader-screen.tsx';
|
||||
import { LoginScreen } from './ui/login-screen.tsx';
|
||||
import { LibraryBrowseScreen, LibraryFilesScreen } from './ui/library-screen.tsx';
|
||||
import { el } from './ui/dom.ts';
|
||||
import { Router, type LibraryView, type Route, type RouteLocation } from './ui/router.ts';
|
||||
import { Router, parentOf, type LibraryView, type Route, type RouteLocation } from './ui/router.ts';
|
||||
|
||||
/**
|
||||
* Application shell: routing, lifecycle and the wiring between the layers.
|
||||
@@ -81,6 +83,8 @@ export class App {
|
||||
private isAdmin = false;
|
||||
|
||||
private shelf: ShelfScreen | null = null;
|
||||
private sources: SourcesScreen | null = null;
|
||||
private pluginPage: PluginPageScreen | null = null;
|
||||
private reader: ReaderScreen | null = null;
|
||||
/**
|
||||
* The library, as two screens: the browsing page and the file manager.
|
||||
@@ -261,6 +265,20 @@ export class App {
|
||||
return;
|
||||
}
|
||||
switch (route.name) {
|
||||
case 'source-page':
|
||||
case 'plugin-page':
|
||||
this.clearScreens(); this.route = route;
|
||||
this.pluginPage = new PluginPageScreen({ api: this.api, ...(route.name === 'source-page' ? { sourceId: route.sourceId } : { pluginId: route.pluginId }), pageId: route.pageId,
|
||||
onBack: () => location.back(), onSignedOut: () => this.handleSignedOut() });
|
||||
this.root.append(this.pluginPage.element); void this.pluginPage.show(); return;
|
||||
case 'sources':
|
||||
this.clearScreens();
|
||||
this.route = route;
|
||||
this.sources = new SourcesScreen({ api: this.api, admin: this.isAdmin, onBack: () => location.back(),
|
||||
onOpen: (book) => this.openBook(book), onSignedOut: () => this.handleSignedOut() });
|
||||
this.root.append(this.sources.element);
|
||||
void this.sources.show();
|
||||
return;
|
||||
case 'shelf':
|
||||
this.showShelf(route);
|
||||
return;
|
||||
@@ -273,6 +291,9 @@ export class App {
|
||||
}
|
||||
|
||||
private clearScreens(): void {
|
||||
this.pluginPage?.dispose(); this.pluginPage = null;
|
||||
this.sources?.dispose();
|
||||
this.sources = null;
|
||||
this.reader?.dispose();
|
||||
this.reader = null;
|
||||
this.shelf?.dispose();
|
||||
@@ -339,6 +360,7 @@ export class App {
|
||||
}
|
||||
this.clearScreens();
|
||||
const shelf = new ShelfScreen({
|
||||
onOpenSources: () => this.router?.navigate({ name: 'sources' }),
|
||||
api: this.api,
|
||||
offline: this.offline,
|
||||
platform: this.platform,
|
||||
@@ -467,6 +489,7 @@ export class App {
|
||||
this.clearScreens();
|
||||
const files = new LibraryFilesScreen({
|
||||
...common,
|
||||
onClose: () => this.router?.navigate(parentOf({ name: 'library', ...here() }), { replace: true }),
|
||||
path: route.path,
|
||||
page: route.page,
|
||||
fromShelf: route.fromShelf,
|
||||
@@ -497,6 +520,7 @@ export class App {
|
||||
this.clearScreens();
|
||||
const browse = new LibraryBrowseScreen({
|
||||
...common,
|
||||
admin: this.isAdmin,
|
||||
path: route.path,
|
||||
page: route.page,
|
||||
search: route.search,
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
--reader-line: #e3e0da;
|
||||
--reader-accent: #7a5c3e;
|
||||
--reader-radius: 10px;
|
||||
--collection-measure: 80rem;
|
||||
|
||||
/*
|
||||
* How far a search field is inset inside its content column, on *both* sides.
|
||||
@@ -3647,6 +3648,26 @@ book-content {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
/* Source management shares the reader's type and colour tokens. */
|
||||
.sources-screen { display:flex; flex-direction:column; min-height:0; height:100%; flex:1; }
|
||||
.sources-header { display:flex; align-items:center; gap:1rem; padding:1rem; border-bottom:1px solid var(--reader-line); }
|
||||
.sources-header h1 { font-size:1.25rem; flex:1; margin:0; }
|
||||
.sources-tabs { display:flex; flex-wrap:wrap; gap:.5rem; padding:.75rem 1rem; }
|
||||
.sources-body { overflow:auto; padding:0 1rem 2rem; flex:1; }
|
||||
.sources-card { max-width:64rem; margin:0 auto 1rem; padding:1rem; border:1px solid var(--reader-line); border-radius:1rem; }
|
||||
.sources-card h2 { margin:.25rem 0 1rem; font-size:1.1rem; }
|
||||
.sources-card label { display:flex; flex-direction:column; gap:.4rem; margin:.75rem 0; }
|
||||
.sources-card input:not([type=checkbox]), .sources-card select, .sources-card textarea { width:100%; box-sizing:border-box; padding:.65rem; border:1px solid var(--reader-line); border-radius:.5rem; background:var(--reader-surface); color:inherit; font:inherit; }
|
||||
.sources-card textarea { min-height:6rem; }
|
||||
.sources-row { display:flex; flex-wrap:wrap; gap:1rem; align-items:center; justify-content:space-between; padding:1rem 0; border-top:1px solid var(--reader-line); }
|
||||
.sources-row > div:first-child { flex:1 1 15rem; min-width:0; overflow-wrap:anywhere; }
|
||||
.sources-row small { display:block; margin-top:.35rem; opacity:.75; }
|
||||
.sources-actions { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; }
|
||||
.sources-actions label { margin:0; }
|
||||
.sources-card .sources-consent { flex-direction:row; align-items:center; }
|
||||
.sources-search { display:flex; flex-wrap:wrap; align-items:end; gap:.5rem; margin:1rem 0; }
|
||||
.sources-search label { flex:1 1 12rem; min-width:0; margin:0; }
|
||||
.source-description { max-height:6rem; overflow:auto; font-size:.9rem; white-space:pre-wrap; }
|
||||
|
||||
/* Mobile reading: the surface and its information strip never resize with chrome. */
|
||||
[data-theme='green'] {
|
||||
@@ -3743,3 +3764,203 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
|
||||
.reader-screen .field input[type='range']::-webkit-slider-thumb { appearance: none; width: 18px; height: 18px; margin-top: -7px; border-radius: 50%; background: var(--reader-accent); }
|
||||
.reader-screen .field input[type='range']::-moz-range-track { height: 4px; border-radius: 2px; background: var(--reader-line); }
|
||||
.reader-screen .field input[type='range']::-moz-range-thumb { width: 18px; height: 18px; border: 0; border-radius: 50%; background: var(--reader-accent); }
|
||||
|
||||
.sources-card h3, .sources-body > p { overflow-wrap:anywhere; }
|
||||
.sources-card form + form { margin-top:.65rem; }
|
||||
section[aria-label="切换书源"] { padding:.75rem 0; overflow-wrap:anywhere; }
|
||||
section[aria-label="切换书源"] select { display:block; width:100%; padding:.6rem; margin:.5rem 0; color:inherit; background:var(--reader-surface); }
|
||||
section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; padding:.5rem; }
|
||||
|
||||
/* Generic declarative extension pages, scoped to the selected source instance. */
|
||||
.extension-screen .sources-body { width: 100%; max-width: 64rem; margin-inline: auto; box-sizing: border-box; }
|
||||
.extension-description { color: var(--muted, #777); margin: 0 0 16px; line-height: 1.6; }
|
||||
.extension-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border, #ddd); margin-bottom: 20px; }
|
||||
.extension-tabs button { flex: 1; padding: 12px 8px; background: transparent; color: inherit; border: 0; border-bottom: 3px solid transparent; cursor: pointer; font: inherit; }
|
||||
.extension-tabs button[aria-selected="true"] { border-bottom-color: currentColor; font-weight: 700; }
|
||||
.extension-tabs button:focus-visible { outline: 2px solid currentColor; outline-offset: -3px; }
|
||||
.extension-screen h2 { font-size: 1rem; margin: 20px 0 12px; }
|
||||
.extension-screen h3 { margin: 0 0 8px; }
|
||||
.extension-item-heading { display: flex; gap: 16px; align-items: start; justify-content: space-between; }
|
||||
.extension-item-heading > div { min-width: 0; }
|
||||
.extension-item-heading > button { flex-shrink: 0; }
|
||||
.extension-item .source-description { margin: 0; overflow-wrap: anywhere; }
|
||||
.extension-actions:not([hidden]) { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
|
||||
.extension-actions form:has(label) { flex-basis: 100%; }
|
||||
.extension-screen input:not([type=checkbox]), .extension-screen textarea, .extension-screen select { box-sizing: border-box; max-width: 100%; }
|
||||
.extension-screen textarea { min-height: 9rem; }
|
||||
.extension-empty { padding: 28px 16px; text-align: center; color: var(--muted, #777); line-height: 1.7; }
|
||||
|
||||
.extension-toolbar { flex: none; width: 100%; max-width: 64rem; margin-inline: auto; padding: 0 1rem; box-sizing: border-box; }
|
||||
.extension-toolbar .extension-tabs { margin-bottom: .75rem; border-color: var(--reader-line); }
|
||||
.extension-toolbar .notice { margin: 0 0 .75rem; overflow-wrap: anywhere; }
|
||||
.extension-toolbar .extension-error { color: #a32c22; border: 1px solid currentColor; background: var(--reader-surface); }
|
||||
.extension-description, .extension-empty { color: var(--reader-muted); }
|
||||
.extension-screen .sources-header h1 { min-width: 0; overflow-wrap: anywhere; }
|
||||
.extension-form-inline { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; }
|
||||
.extension-form-inline h3 { flex-basis: 100%; }
|
||||
.extension-screen .extension-form-inline label { flex: 1 1 14rem; min-width: 0; margin: 0; }
|
||||
.extension-screen .extension-form-inline label[data-field-type=number] { flex: 0 1 15rem; }
|
||||
.extension-screen label[data-field-type=boolean] { flex-direction: row; align-items: center; }
|
||||
.extension-screen input[type=checkbox] { width: 1.25rem; height: 1.25rem; }
|
||||
.extension-actions form + form { margin-top: 0; }
|
||||
.extension-actions .extension-form-inline { border-top: 1px solid var(--reader-line); padding-top: .75rem; }
|
||||
.extension-confirm { border: 1px solid var(--reader-line); border-radius: .5rem; padding: .75rem; margin-top: .5rem; }
|
||||
.extension-confirm p { margin: 0 0 .75rem; }
|
||||
.extension-confirm button + button { margin-inline-start: .5rem; }
|
||||
.extension-screen button, .source-next-step .button { min-height: 44px; }
|
||||
.source-catalog, .source-editor { scroll-margin-top: 1rem; }
|
||||
@media (max-width: 600px) {
|
||||
.extension-screen .extension-form-inline label { flex-basis: 100%; }
|
||||
.extension-screen .extension-form-inline label[data-field-type=number] { flex: 1 1 9rem; }
|
||||
.extension-item-heading { gap: .75rem; }
|
||||
}
|
||||
[data-theme='dark'] .extension-toolbar .extension-error { color: #ffaaa0; }
|
||||
.extension-tabs button:disabled { opacity: .55; cursor: default; }
|
||||
|
||||
/* Source hub: navigation tabs, one primary action per row, secondary settings on demand. */
|
||||
.sources-hub .sources-tabs { flex: none; flex-wrap: nowrap; gap: 0; padding: 0 1rem; width: 100%; max-width: 66rem; margin: 0 auto 1rem; }
|
||||
.sources-hub .sources-tabs [role=tab] { flex: 1; min-width: 0; min-height: 52px; padding: .85rem .35rem; border: 0; border-bottom: 2px solid var(--reader-line); background: transparent; color: var(--reader-muted); font: inherit; cursor: pointer; }
|
||||
.sources-hub .sources-tabs [aria-selected=true] { color: var(--reader-ink); border-bottom-color: var(--reader-accent); font-weight: 650; }
|
||||
.sources-hub .sources-tabs [role=tab]:focus-visible, .source-manage-trigger:focus-visible, .source-management-link:focus-visible { outline: var(--focus-ring); outline-offset: -2px; }
|
||||
.sources-tab-dot { display: inline-block; width: 6px; height: 6px; margin: 0 0 .2rem .35rem; border-radius: 50%; background: var(--reader-accent); }
|
||||
.sources-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .5rem; }
|
||||
.sources-list-heading h2 { margin: 0; }
|
||||
.source-count { margin-inline-start: .3rem; font-size: .8rem; color: var(--reader-muted); font-weight: 400; }
|
||||
.source-add { display: inline-flex; align-items: center; gap: .3rem; min-height: 44px; padding: .5rem .7rem; }
|
||||
.sources-list .source-entry { display: block; padding: 1rem 0; }
|
||||
.sources-list .source-entry:last-child { padding-bottom: 0; }
|
||||
.source-entry-top { display: flex; align-items: center; gap: 1rem; }
|
||||
.source-entry-info { flex: 1; min-width: 0; }
|
||||
.source-entry-info strong { display: block; line-height: 1.5; }
|
||||
.source-entry-info small { font-size: .8rem; color: var(--reader-muted); opacity: 1; }
|
||||
.source-state { display: inline-block; font-size: .72rem; color: var(--reader-muted); margin-top: .5rem; }
|
||||
.source-state::before { content: ''; display: inline-block; height: 5px; width: 5px; border-radius: 50%; background: currentColor; vertical-align: middle; margin-right: .35rem; }
|
||||
.source-state.is-enabled { color: var(--reader-accent); }
|
||||
.source-entry-actions { display: flex; align-items: center; gap: .6rem; flex-shrink: 0; }
|
||||
.source-manage-trigger { display: inline-flex; align-items: center; justify-content: center; gap: .25rem; min-height: 44px; padding: .4rem; border: 0; background: transparent; font: inherit; font-size: .85rem; color: var(--reader-muted); cursor: pointer; }
|
||||
.source-manage-trigger[aria-expanded=true] .icon { transform: rotate(90deg); }
|
||||
.source-management { display: flex; flex-wrap: wrap; align-items: center; gap: .25rem 1rem; margin-top: .85rem; padding: .5rem .75rem; border-radius: .6rem; background: color-mix(in srgb, var(--reader-ink) 4%, transparent); }
|
||||
.source-management-link { display: inline-flex; align-items: center; justify-content: space-between; gap: .5rem; min-height: 44px; padding: .5rem 0; border: 0; background: transparent; color: var(--reader-ink); font: inherit; font-size: .85rem; text-decoration: none; cursor: pointer; }
|
||||
.source-management-link:disabled, .source-manage-trigger:disabled, .sources-hub [role=tab]:disabled { opacity: .5; cursor: default; }
|
||||
.source-toggle { color: var(--reader-muted); }
|
||||
.sources-hub .sources-tabs [role=tab] { font-size: .9rem; }
|
||||
.source-picker label { max-width: 32rem; }
|
||||
.source-modal { color: var(--reader-ink); background: var(--reader-paper); border: 1px solid var(--reader-line); border-radius: 1rem; padding: 0; width: min(36rem, calc(100% - 2rem)); max-height: calc(100dvh - 2rem); overflow: hidden; }
|
||||
.source-modal[open] { display: flex; flex-direction: column; }
|
||||
.source-modal::backdrop { background: #0007; }
|
||||
.source-modal-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .75rem 1rem; border-bottom: 1px solid var(--reader-line); flex: none; }
|
||||
.source-modal-header h2 { margin: 0; font-size: 1.15rem; }
|
||||
.source-modal-form.sources-card { display: flex; flex-direction: column; margin: 0; border: 0; padding: 0; width: 100%; min-height: 0; overflow: hidden; }
|
||||
.source-modal-content { overflow-y: auto; overscroll-behavior: contain; padding: 0 1rem 1rem; }
|
||||
.source-modal-actions { display: flex; flex-wrap: wrap; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid var(--reader-line); flex: none; }
|
||||
.source-modal-actions button { min-height: 44px; }
|
||||
|
||||
/* Collection pages share a quiet heading, labelled destinations and useful empty states. */
|
||||
.shelf-head { align-items: center; padding-top: calc(1.25rem + var(--safe-top)); padding-bottom: .85rem; }
|
||||
.shelf-head-actions { margin-top: 0; }
|
||||
.shelf-head .icon-button, .collection-heading .icon-button { width: 44px; height: 44px; border-radius: .8rem; }
|
||||
.shelf-subtitle { margin-top: .45rem; line-height: 1.5; }
|
||||
.collection-header { padding: calc(1.25rem + var(--safe-top)) calc(.9rem + var(--safe-right)) .75rem calc(.9rem + var(--safe-left)); flex: none; }
|
||||
.collection-heading { display: flex; align-items: center; gap: .5rem; }
|
||||
.collection-heading-text { flex: 1; min-width: 0; }
|
||||
.collection-heading h1 { margin: 0; font-size: 1.5rem; font-weight: 650; letter-spacing: -.02em; line-height: 1.15; }
|
||||
.collection-heading p { margin: .45rem 0 0; font-size: .78rem; line-height: 1.5; }
|
||||
.collection-links { display: flex; gap: .65rem; margin-top: .85rem; }
|
||||
.shelf-links { max-width: calc(var(--shelf-measure) - 1.8rem); margin: 0 auto .9rem; padding-inline: calc(.9rem + var(--safe-left)) calc(.9rem + var(--safe-right)); }
|
||||
.collection-link.button { display: flex; align-items: center; gap: .6rem; flex: 1; min-width: 0; min-height: 48px; padding: .7rem .85rem; font-size: .875rem; border-radius: .8rem; background: color-mix(in srgb, var(--reader-accent) 4%, var(--reader-paper)); border-color: color-mix(in srgb, var(--reader-line) 65%, transparent); text-align: start; }
|
||||
.collection-link > span { flex: 1; }
|
||||
.collection-link[hidden] { display: none; }
|
||||
.collection-link > .icon:first-child { color: var(--reader-accent); }
|
||||
.collection-link > .icon:last-child { color: var(--reader-muted); font-size: .8rem; }
|
||||
.shelf-screen .shelf-search input, .library-search input { border-radius: .85rem; min-height: 48px; }
|
||||
.library-browse-screen .library-search { margin-top: .35rem; }
|
||||
.library-path { margin-top: .75rem; padding: .25rem 0 0; }
|
||||
.library-path .manager-crumb { min-height: 44px; }
|
||||
.collection-empty.empty-state { width: calc(100% - 2rem); max-width: 30rem; margin: clamp(1.5rem, 7vh, 4rem) auto 2rem; padding: 1.75rem 1.25rem; gap: .7rem; border: 1px solid var(--reader-line); border-radius: 1.25rem; background: color-mix(in srgb, var(--reader-surface) 45%, var(--reader-paper)); }
|
||||
.collection-empty .empty-glyph { box-sizing: content-box; width: 30px; height: 30px; padding: 1rem; border-radius: 1.1rem; background: color-mix(in srgb, var(--reader-accent) 9%, transparent); color: var(--reader-accent); opacity: 1; margin-bottom: .5rem; }
|
||||
.collection-empty p { font-size: 1.1rem; line-height: 1.5; }
|
||||
.collection-empty p.muted { max-width: 23rem; font-size: .875rem; line-height: 1.8; text-wrap: pretty; }
|
||||
.collection-empty .empty-actions { margin-top: .6rem; gap: .65rem; }
|
||||
.collection-empty .button { min-height: 44px; font-size: .875rem; }
|
||||
.library-browse-body > .manager-status { background: transparent; border: 0; margin: .75rem auto; }
|
||||
.file-manager-header .manager-crumbs { margin-top: .75rem; padding: .25rem 0; }
|
||||
.file-manager-header .manager-crumb { min-height: 44px; }
|
||||
.library-files-screen .manager-list { margin: .5rem 1rem; border: 1px solid var(--reader-line); border-radius: 1rem; overflow: hidden; }
|
||||
.library-files-screen .manager-row { grid-template-columns: 24px minmax(0, 1fr) 44px; gap: .4rem .65rem; padding: .9rem .75rem; }
|
||||
.library-files-screen .manager-check { grid-column: 1; grid-row: 1 / 3; }
|
||||
.library-files-screen .manager-name { grid-column: 2; grid-row: 1; }
|
||||
.library-files-screen .manager-label { white-space: normal; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.5; }
|
||||
.library-files-screen .manager-meta { grid-column: 2; grid-row: 2; flex-wrap: wrap; gap: .3rem .65rem; white-space: normal; padding-left: 1.5rem; }
|
||||
.library-files-screen .manager-more { grid-column: 3; grid-row: 1 / 3; width: 44px; height: 44px; }
|
||||
.library-files-screen .manager-status { position: static; border: 0; background: transparent; padding: .5rem 1rem 1rem; }
|
||||
.shelf-settings.panel { position: fixed; }
|
||||
.shelf-settings .panel-header { padding: .75rem 1rem; flex: none; }
|
||||
.shelf-settings .panel-header h2 { flex: 1; }
|
||||
.shelf-settings .panel-header > .icon-button { flex: none; margin-inline-start: auto; width: 44px; height: 44px; }
|
||||
.shelf-settings .panel-body { padding-top: 0; }
|
||||
.shelf-settings .section-title:first-child { margin-top: 0; }
|
||||
.shelf-discovery { display: contents; }
|
||||
/* Desktop keeps navigation compact and settings beside the collection. */
|
||||
@media (min-width: 64rem) {
|
||||
.shelf { --shelf-measure: var(--collection-measure); }
|
||||
.shelf-head, .shelf-discovery, .shelf-section { width: calc(100% - 3rem); }
|
||||
.shelf-head { padding-top: 2rem; padding-bottom: 1.25rem; }
|
||||
.shelf-discovery { display: flex; align-items: center; gap: 1.5rem; max-width: var(--shelf-measure); margin: 0 auto 1rem; padding-inline: .9rem; }
|
||||
.shelf-discovery .shelf-links { flex: none; margin: 0; padding: 0; }
|
||||
.shelf-discovery .shelf-search { flex: 1; max-width: 36rem; margin: 0 0 0 auto; }
|
||||
.collection-link.button { flex: none; min-width: 9rem; min-height: 44px; }
|
||||
.collection-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .5rem 2rem; padding-top: 2rem; }
|
||||
.collection-header .collection-links { margin-top: 0; }
|
||||
.collection-header .manager-crumbs { grid-column: 1 / -1; }
|
||||
.collection-header, .library-browse-body, .library-files-screen .manager-body { width: calc(100% - 3rem); max-width: var(--collection-measure); margin-inline: auto; }
|
||||
.library-files-screen .manager-row { grid-template-columns: 24px minmax(0, 1fr) minmax(15rem, 22rem) 44px; min-height: 64px; padding-block: .5rem; }
|
||||
.library-files-screen .manager-check { grid-row: 1; }
|
||||
.library-files-screen .manager-meta { grid-column: 3; grid-row: 1; justify-content: flex-end; padding-left: 0; }
|
||||
.library-files-screen .manager-more { grid-column: 4; grid-row: 1; }
|
||||
.shelf-settings.panel { inset: 1rem 1rem auto auto; width: 26rem; height: min(42rem, calc(100dvh - 2rem)); max-height: calc(100dvh - 2rem); border: 1px solid var(--reader-line); border-radius: 1rem; box-shadow: 0 12px 48px #0003; overflow: hidden; animation-name: shelf-settings-in; }
|
||||
.shelf-settings .switch-field { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.sources-header { width: calc(100% - 3rem); max-width: var(--collection-measure); margin-inline: auto; padding: 2rem .9rem .75rem; border-bottom: 0; }
|
||||
.sources-header h1 { font-size: 1.5rem; font-weight: 650; }
|
||||
.sources-hub .sources-tabs [role=tab], .extension-tabs button { flex: 0 1 auto; min-width: 9rem; padding-inline: 1.5rem; }
|
||||
.sources-hub .sources-body, .extension-screen .sources-body, .sources-hub .sources-tabs, .extension-toolbar { width: calc(100% - 3rem); max-width: var(--collection-measure); margin-inline: auto; padding-inline: .9rem; }
|
||||
.sources-body .sources-card { max-width: none; }
|
||||
.reader-screen { --reader-footer-height: 5.5rem; }
|
||||
.reader-screen { --desktop-page-width: 52rem; }
|
||||
.reader-screen .stage { background: color-mix(in srgb, var(--reader-paper) 95%, var(--reader-ink)); }
|
||||
.reader-screen .stage::before { content: ''; position: absolute; inset: 0; width: var(--desktop-page-width); max-width: 100%; margin-inline: auto; background: var(--reader-paper); border-inline: 1px solid var(--reader-line); box-shadow: 0 0 40px #00000005; pointer-events: none; }
|
||||
.reader-screen .book-host[data-layout='reflowable'] { width: var(--desktop-page-width); max-width: 100%; margin-inline: auto; }
|
||||
.reader-screen .topbar { inset-inline: 2rem; width: calc(100% - 4rem); max-width: var(--desktop-page-width); min-height: 4.5rem; margin-inline: auto; padding-inline: 1.5rem; border-bottom: 1px solid var(--reader-line); background: var(--reader-paper); box-shadow: none; }
|
||||
.reader-screen .topbar .reader-heading strong { font-size: 1rem; }
|
||||
.reader-screen .topbar .reader-heading span { font-size: .72rem; }
|
||||
.reader-screen .footer { display: flex; align-items: center; gap: 1rem; inset-inline: 2rem; bottom: 1.5rem; width: calc(100% - 4rem); max-width: 50rem; height: 4.5rem; margin-inline: auto; padding: .55rem 1rem; border: 1px solid var(--reader-line); border-radius: .85rem; background: var(--reader-surface); box-shadow: 0 8px 32px #00000012; }
|
||||
.reader-screen .progress-row { flex: 1; min-width: 0; }
|
||||
.reader-screen .reader-actions { display: flex; gap: .25rem; padding-left: .75rem; border-left: 1px solid var(--reader-line); }
|
||||
.reader-screen .reader-actions .top-button { flex-direction: row; min-width: 4.75rem; min-height: 44px; padding: .5rem .7rem; border-radius: .7rem; }
|
||||
.reader-screen .reader-actions .top-button:hover { background: color-mix(in srgb, var(--reader-accent) 10%, transparent); }
|
||||
.reader-screen .reader-actions .icon { font-size: 1.1rem; }
|
||||
.reader-screen .reader-rail { right: max(1rem, calc((100% - var(--desktop-page-width)) / 2 - 4.5rem)); bottom: 2.25rem; }
|
||||
.reader-screen .reading-indicator { width: var(--desktop-page-width); max-width: 100%; margin-inline: auto; padding-inline: 1.5rem; font-size: .75rem; }
|
||||
.reader-screen .scrim { background: color-mix(in srgb, var(--reader-ink) 12%, transparent); }
|
||||
.reader-screen .panel { inset: 1rem 1rem 1rem auto; width: 26rem; height: calc(100% - 2rem); max-height: none; border: 1px solid var(--reader-line); border-radius: 1rem; overflow: hidden; animation-name: shelf-settings-in; }
|
||||
.reader-screen .panel[data-placement='start'] { inset-inline: 1rem auto; width: 23rem; animation-name: reader-contents-in; }
|
||||
.reader-screen .panel-grip { display: none; }
|
||||
.reader-screen .panel-header { flex: none; padding: .75rem 1.25rem; }
|
||||
.reader-screen .panel-header .icon-button { width: 40px; height: 40px; border-radius: .6rem; }
|
||||
.reader-screen .panel .section-title { font-size: .78rem; font-weight: 650; letter-spacing: .05em; color: var(--reader-accent); padding-top: .9rem; padding-bottom: .65rem; }
|
||||
.reader-screen .panel-body { padding-top: 0; }
|
||||
.reader-screen .section-title:first-child { margin-top: 0; }
|
||||
}
|
||||
@keyframes reader-contents-in {
|
||||
from { opacity: 0; transform: translateX(-1rem); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes shelf-settings-in {
|
||||
from { opacity: 0; transform: translateX(1rem); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.source-entry-actions { flex-direction: column; gap: 0; }
|
||||
.source-entry-top { align-items: center; }
|
||||
.source-management-link { width: 100%; }
|
||||
.source-management-link + .source-management-link { border-top: 1px solid var(--reader-line); }
|
||||
}
|
||||
+44
-158
@@ -7,7 +7,6 @@ import { formatBytes, formatDate } from './dom.ts';
|
||||
import { mountUI } from './mount.ts';
|
||||
import { DialogView, METADATA_FIELDS, type Dialog, type DialogAnswer } from './dialog.tsx';
|
||||
import { Pager } from './pager.tsx';
|
||||
import { ShelfSettingsPanel } from './shelf-settings.tsx';
|
||||
import { sortBooks } from './shelf-order.ts';
|
||||
import { describeShelfAction, SHELF_ACTION_LABELS } from './shelf-membership.ts';
|
||||
import { Button, Icon, IconButton, IconTextButton } from './toolkit.tsx';
|
||||
@@ -61,8 +60,8 @@ export interface LibraryBrowseScreenOptions {
|
||||
api: ReaderApi;
|
||||
offline: OfflineStore;
|
||||
settings: AppSettings;
|
||||
/** The screen's own preferences (density, author visibility), persisted like the shelf's. */
|
||||
onSettingsChange(patch: Partial<AppSettings>): void;
|
||||
/** Whether this reader may open the file manager. */
|
||||
admin: boolean;
|
||||
/** The folder, page and search the route asks for. */
|
||||
path: string;
|
||||
page: number;
|
||||
@@ -144,9 +143,6 @@ interface BrowseState {
|
||||
bootstrapping: boolean;
|
||||
busy: boolean;
|
||||
status: string;
|
||||
settingsOpen: boolean;
|
||||
/** A question the screen is waiting on an answer to (the upload conflict policy). */
|
||||
dialog: Dialog | null;
|
||||
/** Bumped after a mutation so the cached cover URLs re-key, not re-fetch. */
|
||||
revision: number;
|
||||
}
|
||||
@@ -172,7 +168,6 @@ interface BrowseState {
|
||||
export class LibraryBrowseScreen {
|
||||
readonly element: HTMLDivElement;
|
||||
private readonly ui: ReturnType<typeof mountUI>;
|
||||
private readonly uploadInput = document.createElement('input');
|
||||
private readonly draw: () => void;
|
||||
/** The message a completed write left for the next render. */
|
||||
private outcome: string | null = null;
|
||||
@@ -184,22 +179,11 @@ export class LibraryBrowseScreen {
|
||||
this.element.className = 'library-screen library-browse-screen';
|
||||
this.element.dataset['view'] = 'browse';
|
||||
|
||||
// A file input lives outside the tree and is never re-created, so its `files`
|
||||
// list and its focus survive every re-render. On some WebViews a file input
|
||||
// removed from the document loses its value, and the second upload after a
|
||||
// cancel would then send nothing.
|
||||
this.uploadInput.type = 'file';
|
||||
this.uploadInput.multiple = true;
|
||||
this.uploadInput.className = 'manager-upload-input';
|
||||
this.uploadInput.setAttribute('aria-hidden', 'true');
|
||||
this.uploadInput.tabIndex = -1;
|
||||
this.uploadInput.addEventListener('change', () => void this.handlePicked());
|
||||
|
||||
const host = document.createElement('div');
|
||||
// `display: contents`, so the host adds no layout box and the screen stays the
|
||||
// flex column its children are written against.
|
||||
host.className = 'library-ui';
|
||||
this.element.append(host, this.uploadInput);
|
||||
this.element.append(host);
|
||||
|
||||
this.state = {
|
||||
path: options.path,
|
||||
@@ -214,8 +198,6 @@ export class LibraryBrowseScreen {
|
||||
bootstrapping: true,
|
||||
busy: false,
|
||||
status: '',
|
||||
settingsOpen: false,
|
||||
dialog: null,
|
||||
revision: 0,
|
||||
};
|
||||
|
||||
@@ -389,66 +371,6 @@ export class LibraryBrowseScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads picked files into the folder being browsed.
|
||||
*
|
||||
* The shop is where a reader *lands*, so 上传 is offered here as well as on the file
|
||||
* page. The two do exactly the same things — same endpoint, same conflict question,
|
||||
* same reload-then-report order — because the alternative is a reader who has just
|
||||
* been told "这个文件夹是空的" having to find the administrator's page to fix it.
|
||||
*/
|
||||
private async handlePicked(): Promise<void> {
|
||||
const files = [...(this.uploadInput.files ?? [])];
|
||||
this.uploadInput.value = '';
|
||||
if (files.length === 0) return;
|
||||
if (this.state.listing?.writable === false) {
|
||||
this.toast('书库是只读挂载,无法上传');
|
||||
return;
|
||||
}
|
||||
const policy = await this.pickConflictPolicy();
|
||||
if (!policy) return;
|
||||
const total = files.reduce((sum, file) => sum + file.size, 0);
|
||||
this.state.busy = true;
|
||||
this.draw();
|
||||
try {
|
||||
const result = await this.options.api.upload(files, this.state.path, policy, (fraction) => {
|
||||
this.toast(`上传中… ${Math.round(fraction * 100)}%(${formatBytes(total)})`);
|
||||
});
|
||||
/*
|
||||
* The folder is re-read *before* the report, and both are in this `try`.
|
||||
*
|
||||
* A report with no reload is a grid that does not contain the thing just
|
||||
* uploaded, which reads as a failed upload whatever the message above it says.
|
||||
*/
|
||||
this.outcome = describeUpload(result);
|
||||
await this.load(this.state.path);
|
||||
this.outcome = null;
|
||||
this.toast(describeUpload(result));
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
} finally {
|
||||
this.state.busy = false;
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
private async pickConflictPolicy(): Promise<'rename' | 'skip' | 'overwrite' | 'fail' | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.state.dialog = {
|
||||
kind: 'pick',
|
||||
title: '同名文件怎么办?',
|
||||
options: [
|
||||
{ value: 'rename', label: '两份都留(加 (2))' },
|
||||
{ value: 'skip', label: '跳过已有的' },
|
||||
{ value: 'overwrite', label: '覆盖(会替换原文件)' },
|
||||
{ value: 'fail', label: '有重名就整批不动' },
|
||||
],
|
||||
resolve: (answer) => resolve(answer as 'rename' | 'skip' | 'overwrite' | 'fail' | null),
|
||||
};
|
||||
this.draw();
|
||||
});
|
||||
}
|
||||
|
||||
private toast(text: string): void {
|
||||
this.state.status = text;
|
||||
this.draw();
|
||||
@@ -467,11 +389,6 @@ export class LibraryBrowseScreen {
|
||||
this.toast(err instanceof Error ? err.message : '出错了');
|
||||
}
|
||||
|
||||
private patch(patch: Partial<BrowseState>): void {
|
||||
Object.assign(this.state, patch);
|
||||
this.draw();
|
||||
}
|
||||
|
||||
// ---- the tree ----
|
||||
|
||||
private view(): ComponentChildren {
|
||||
@@ -479,14 +396,16 @@ export class LibraryBrowseScreen {
|
||||
const listing = state.listing;
|
||||
return (
|
||||
<>
|
||||
<div className="panel-header library-header">
|
||||
<IconButton label="返回" icon="arrow-left" onClick={() => this.options.onClose()} />
|
||||
{/* The way to the other half. A *place*, not a filter, so it is a labelled
|
||||
control rather than a bare glyph: the reader has to know that pressing
|
||||
it leaves the books and opens a file manager, because that page can
|
||||
delete things. */}
|
||||
<IconTextButton label="文件管理" icon="folder" onClick={() => this.options.onOpenFiles()} />
|
||||
<div className="manager-crumbs">
|
||||
<header className="library-header collection-header">
|
||||
<div className="collection-heading">
|
||||
<IconButton label="返回" icon="arrow-left" onClick={() => this.options.onClose()} />
|
||||
<div className="collection-heading-text"><h1>书库</h1><p className="muted">浏览本地书籍,发现下一本好书</p></div>
|
||||
</div>
|
||||
{this.options.admin && <nav className="collection-links" aria-label="书库操作">
|
||||
<button type="button" className="button collection-link" onClick={() => this.options.onOpenFiles()}><Icon name="folder" /><span>文件管理</span><Icon name="chevron-right" /></button>
|
||||
</nav>}
|
||||
{(listing?.crumbs.length ?? 0) > 1 && <>
|
||||
<div className="manager-crumbs library-path" aria-label="当前文件夹">
|
||||
{(listing?.crumbs ?? []).map((crumb, index) => (
|
||||
<>
|
||||
{index > 0 ? <span className="manager-crumb-sep">/</span> : null}
|
||||
@@ -502,19 +421,8 @@ export class LibraryBrowseScreen {
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<IconButton
|
||||
label="上传书籍"
|
||||
icon="upload"
|
||||
hidden={listing?.writable === false}
|
||||
disabled={state.busy}
|
||||
onClick={() => this.uploadInput.click()}
|
||||
/>
|
||||
<IconButton
|
||||
label={`书架设置 · ${this.options.settings.shelfDensity}`}
|
||||
icon="tune"
|
||||
onClick={() => this.patch({ settingsOpen: !state.settingsOpen })}
|
||||
/>
|
||||
</div>
|
||||
</>}
|
||||
</header>
|
||||
|
||||
<div className="manager-body library-browse-body">
|
||||
{/*
|
||||
@@ -558,18 +466,9 @@ export class LibraryBrowseScreen {
|
||||
onGo={(page) => this.goToPage(page)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="manager-status muted">{state.status || this.summary()}</div>
|
||||
{(state.status || state.total > 0) && <div className="manager-status muted">{state.status || this.summary()}</div>}
|
||||
</div>
|
||||
|
||||
{state.dialog ? (
|
||||
<DialogView dialog={state.dialog} onClose={(answer) => this.closeDialog(answer)} destructive="删除" />
|
||||
) : null}
|
||||
<ShelfSettingsPanel
|
||||
open={state.settingsOpen}
|
||||
settings={this.options.settings}
|
||||
onPatch={(patch) => this.options.onSettingsChange(patch)}
|
||||
onClose={() => this.patch({ settingsOpen: false })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -577,24 +476,27 @@ export class LibraryBrowseScreen {
|
||||
private grid(): ComponentChildren {
|
||||
const state = this.state;
|
||||
if (state.books.length === 0) {
|
||||
if (state.bootstrapping) return null;
|
||||
const hasFiles = (state.listing?.files ?? 0) > 0;
|
||||
const hasQuery = state.query.length > 0;
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state collection-empty">
|
||||
<Icon name={hasQuery ? 'search' : 'book'} class="empty-glyph" />
|
||||
<p>{hasQuery ? '没有匹配的书' : '这个文件夹里没有可阅读的书'}</p>
|
||||
<p>{hasQuery ? '没有匹配的书' : hasFiles ? '这个文件夹里没有可阅读的书' : '书库还没有书'}</p>
|
||||
<p className="muted">
|
||||
{hasQuery
|
||||
? `「${state.query}」在这个文件夹里没有匹配`
|
||||
: `${state.listing?.files ?? 0} 个文件里没有被扫描成书的,文件页能看到每一行的原因`}
|
||||
: hasFiles ? (this.options.admin ? '这里有 ' + state.listing!.files + ' 个文件,可在文件管理中查看识别结果。' : '当前文件夹暂时没有可阅读的书籍,请联系管理员。')
|
||||
: this.options.admin ? '前往文件管理添加书籍,再回到这里浏览。' : '管理员添加书籍后,就能在这里浏览并加入书架。'}
|
||||
</p>
|
||||
<div className="empty-actions">
|
||||
{hasQuery ? (
|
||||
<button type="button" className="button" onClick={() => this.clearSearch()}>
|
||||
<button type="button" className="button primary" onClick={() => this.clearSearch()}>
|
||||
清除搜索
|
||||
</button>
|
||||
) : (
|
||||
<IconTextButton label="文件管理" icon="folder" onClick={() => this.options.onOpenFiles()} />
|
||||
)}
|
||||
) : this.options.admin ? (
|
||||
<IconTextButton className="primary" label="文件管理" icon="folder" onClick={() => this.options.onOpenFiles()} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -637,17 +539,9 @@ export class LibraryBrowseScreen {
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
private closeDialog(answer: DialogAnswer): void {
|
||||
const dialog = this.state.dialog;
|
||||
this.state.dialog = null;
|
||||
this.draw();
|
||||
if (dialog?.kind === 'pick') dialog.resolve(answer as string | null);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
this.searchTimer = null;
|
||||
this.state.dialog = null;
|
||||
this.ui.unmount();
|
||||
}
|
||||
}
|
||||
@@ -1066,9 +960,16 @@ export class LibraryFilesScreen {
|
||||
const listing = this.state.listing;
|
||||
return (
|
||||
<>
|
||||
<div className="panel-header library-header">
|
||||
<IconButton label="返回" icon="arrow-left" onClick={() => this.options.onClose()} />
|
||||
<IconTextButton label="浏览书籍" icon="book" onClick={() => this.options.onOpenBrowse(this.state.path)} />
|
||||
<header className="collection-header file-manager-header">
|
||||
<div className="collection-heading">
|
||||
<IconButton label="返回" icon="arrow-left" onClick={() => this.options.onClose()} />
|
||||
<div className="collection-heading-text"><h1>文件管理</h1><p className="muted">管理书库中的文件与文件夹</p></div>
|
||||
</div>
|
||||
<nav className="collection-links" aria-label="文件操作">
|
||||
<button type="button" className="button collection-link" aria-label="上传书籍" hidden={!this.writable} disabled={this.state.busy} onClick={() => this.uploadInput.click()}><Icon name="upload" /><span>上传书籍</span></button>
|
||||
<button type="button" className="button collection-link" aria-label="新建文件夹" hidden={!this.writable} disabled={this.state.busy} onClick={() => void this.promptMkdir()}><Icon name="plus" /><span>新建文件夹</span></button>
|
||||
{!this.writable && <p className="muted">当前目录只读</p>}
|
||||
</nav>
|
||||
<div className="manager-crumbs">
|
||||
{(listing?.crumbs ?? []).map((crumb, index) => (
|
||||
<>
|
||||
@@ -1085,24 +986,7 @@ export class LibraryFilesScreen {
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
{/* Hidden rather than omitted, with `hidden` as the state: a control that
|
||||
can only answer 403 teaches the reader to distrust every other control,
|
||||
and *which* controls are missing is what this says. */}
|
||||
<IconButton
|
||||
label="上传书籍"
|
||||
icon="upload"
|
||||
hidden={!this.writable}
|
||||
disabled={this.state.busy}
|
||||
onClick={() => this.uploadInput.click()}
|
||||
/>
|
||||
<IconButton
|
||||
label="新建文件夹"
|
||||
icon="plus"
|
||||
hidden={!this.writable}
|
||||
disabled={this.state.busy}
|
||||
onClick={() => void this.promptMkdir()}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="manager-body"
|
||||
@@ -1116,8 +1000,9 @@ export class LibraryFilesScreen {
|
||||
{this.state.loading && listing === null ? <div className="spinner" /> : null}
|
||||
<div className="manager-list">
|
||||
{listing && listing.total === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>这个文件夹是空的</p>
|
||||
<div className="empty-state collection-empty">
|
||||
<Icon name="folder" class="empty-glyph" /><p>这个文件夹是空的</p>
|
||||
<p className="muted">{this.writable ? '使用上方的上传或新建文件夹,整理你的书库。' : '当前目录只读,可以浏览已有文件。'}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{(listing?.entries ?? []).map((entry) => (
|
||||
@@ -1176,7 +1061,7 @@ export class LibraryFilesScreen {
|
||||
private summary(listing: BrowseListing | null): string {
|
||||
if (listing === null) return '载入中…';
|
||||
if (listing.total === 0) {
|
||||
return listing.writable ? '空文件夹 · 可以用右上角的新建按钮添加子目录' : '空文件夹';
|
||||
return listing.writable ? '空文件夹 · 可以使用上方的新建文件夹按钮添加子目录' : '空文件夹';
|
||||
}
|
||||
/*
|
||||
* The counts are the directory's, and the page is named when there is more than
|
||||
@@ -1399,6 +1284,7 @@ function Row({ entry, selecting, selected, onActivate, onToggle, onMenu }: RowPr
|
||||
onActivate();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onActivate();
|
||||
@@ -1432,8 +1318,8 @@ function Row({ entry, selecting, selected, onActivate, onToggle, onMenu }: RowPr
|
||||
label={`${entry.name} 的操作`}
|
||||
icon="more"
|
||||
class="manager-more"
|
||||
onClick={() => {
|
||||
cancel();
|
||||
onClick={(event) => {
|
||||
event.stopPropagation(); cancel();
|
||||
onMenu();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { type ComponentChildren, useLayoutEffect, useRef } from './vendor/preact.ts';
|
||||
import { IconButton } from './toolkit.tsx';
|
||||
|
||||
/** Native modal supplies focus containment and makes the background inert. */
|
||||
export function Modal({ title, busy, onClose, children }: {
|
||||
title: string; busy: boolean; onClose(): void; children: ComponentChildren;
|
||||
}) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const dialog = ref.current!;
|
||||
dialog.showModal();
|
||||
return () => {
|
||||
dialog.close();
|
||||
queueMicrotask(() => { if (previous?.isConnected) previous.focus({ preventScroll: true }); });
|
||||
};
|
||||
}, []);
|
||||
return <dialog ref={ref} className="source-modal" aria-label={title} aria-busy={busy}
|
||||
onKeyDown={event => {
|
||||
if (event.key !== 'Tab') return;
|
||||
const dialog = event.currentTarget;
|
||||
const controls = [...dialog.querySelectorAll<HTMLElement>('button, input, select, textarea, a[href], [tabindex]')]
|
||||
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
|
||||
const first = controls[0], last = controls.at(-1);
|
||||
if (!first) { event.preventDefault(); return; }
|
||||
if (event.shiftKey && (document.activeElement === first || !controls.includes(document.activeElement as HTMLElement))) {
|
||||
event.preventDefault(); last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault(); first.focus();
|
||||
}
|
||||
}}
|
||||
onCancel={event => { event.preventDefault(); if (!busy) onClose(); }}>
|
||||
<header className="source-modal-header"><h2>{title}</h2><IconButton icon="xmark" label="关闭弹窗" disabled={busy} onClick={onClose} /></header>
|
||||
{children}
|
||||
</dialog>;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ApiError, type ReaderApi } from '../api/client.ts';
|
||||
import type { ExtensionContent, ExtensionForm, ExtensionPage } from '../api/sources.ts';
|
||||
import { mountUI } from './mount.ts';
|
||||
import { Button, IconButton } from './toolkit.tsx';
|
||||
|
||||
/** Safe declarative UI: plugins supply data and actions, never app-origin HTML. */
|
||||
export class PluginPageScreen {
|
||||
readonly element = document.createElement('div');
|
||||
private readonly ui: ReturnType<typeof mountUI>;
|
||||
private page?: ExtensionPage;
|
||||
private disposed = false;
|
||||
private busy = false;
|
||||
private error = '';
|
||||
private activeTab = '';
|
||||
private noticeTab = '';
|
||||
private expanded = new Set<string>();
|
||||
private confirmation: ExtensionForm | undefined;
|
||||
private drafts = new Map<string, Record<string, string | number | boolean>>();
|
||||
private inputs = new Map<ExtensionForm, Record<string, string | number | boolean>>();
|
||||
constructor(private readonly options: { api: ReaderApi; pageId: string; onBack(): void; onSignedOut(): void } & ({ sourceId: string; pluginId?: never } | { pluginId: string; sourceId?: never })) {
|
||||
this.element.className = 'sources-screen extension-screen'; this.ui = mountUI(this.element, () => this.view(), null);
|
||||
}
|
||||
show() { return this.run(); }
|
||||
dispose() { this.disposed = true; this.inputs.clear(); this.drafts.clear(); this.ui.unmount(); }
|
||||
private draw() { if (!this.disposed) this.ui.update(null); }
|
||||
private async run(action?: string, values?: Record<string, unknown>, submitted?: ExtensionForm) {
|
||||
if (this.busy || this.disposed) return;
|
||||
const previousTab = this.activeTab;
|
||||
this.busy = true; this.error = ''; this.draw();
|
||||
try {
|
||||
const page = await (this.options.sourceId !== undefined
|
||||
? this.options.api.sourcePage(this.options.sourceId, this.options.pageId, action, values)
|
||||
: this.options.api.pluginPage(this.options.pluginId!, this.options.pageId, action, values));
|
||||
if (this.disposed) return; this.page = page;
|
||||
const draftCandidates = new Map([...this.drafts, ...[...this.inputs].map(([form, data]) => [JSON.stringify(form), { ...data }] as const)]);
|
||||
if (submitted) draftCandidates.delete(JSON.stringify(submitted));
|
||||
const forms = [page, ...(page.tabs ?? [])].flatMap(content => [...content.forms, ...(content.sections ?? []).flatMap(section => section.items.flatMap(item => item.forms ?? []))]);
|
||||
const keys = new Set(forms.map(form => JSON.stringify(form)));
|
||||
this.drafts = action ? new Map([...draftCandidates].filter(([key]) => keys.has(key))) : new Map();
|
||||
this.inputs.clear(); this.expanded.clear(); this.confirmation = undefined;
|
||||
this.activeTab = page.activeTab ?? (page.tabs?.some(tab => tab.id === this.activeTab) ? this.activeTab : page.tabs?.[0]?.id ?? '');
|
||||
this.noticeTab = this.activeTab;
|
||||
} catch (error) {
|
||||
if (this.disposed) return;
|
||||
if (error instanceof ApiError && error.isAuthFailure) this.options.onSignedOut();
|
||||
else this.error = error instanceof Error ? error.message : '操作失败';
|
||||
} finally {
|
||||
this.busy = false; this.draw();
|
||||
if (previousTab !== this.activeTab) { const body = this.element.querySelector('.sources-body'); if (body) body.scrollTop = 0; }
|
||||
if (action) (this.element.querySelector<HTMLElement>('[data-extension-feedback]') ?? this.element.querySelector<HTMLElement>('[role=tabpanel]'))?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
private form(form: ExtensionForm) {
|
||||
let values = this.inputs.get(form);
|
||||
if (!values) { values = { ...form.values }; for (const field of form.fields) values[field.key] = field.value ?? (field.type === 'boolean' ? false : ''); Object.assign(values, this.drafts.get(JSON.stringify(form))); this.inputs.set(form, values); }
|
||||
const data = values;
|
||||
return <form className={form.layout === 'inline' ? 'extension-form-inline' : undefined} onSubmit={event => {
|
||||
event.preventDefault(); if (form.confirm) { this.confirmation = form; this.draw(); } else void this.run(form.id, data, form);
|
||||
}}>
|
||||
{form.title && <h3>{form.title}</h3>}
|
||||
{form.fields.map(field => <label key={field.key} data-field-type={field.type}>{field.label}
|
||||
{field.type === 'textarea' ? <textarea aria-label={field.label} placeholder={field.placeholder} required={field.required} disabled={this.busy} value={String(data[field.key] ?? '')} onInput={event => { data[field.key] = event.currentTarget.value; }} />
|
||||
: field.type === 'boolean' ? <input aria-label={field.label} type="checkbox" disabled={this.busy} checked={data[field.key] === true} onChange={event => { data[field.key] = event.currentTarget.checked; }} />
|
||||
: field.type === 'select' ? <select aria-label={field.label} disabled={this.busy} value={String(data[field.key] ?? '')} onChange={event => { data[field.key] = event.currentTarget.value; }}>
|
||||
{field.options?.map(option => <option value={option.value}>{option.label}</option>)}
|
||||
</select> : <input aria-label={field.label} placeholder={field.placeholder} min={field.min} max={field.max} required={field.required} disabled={this.busy} type={field.type === 'number' ? 'number' : 'text'} value={String(data[field.key] ?? '')}
|
||||
onInput={event => { data[field.key] = field.type === 'number' ? Number(event.currentTarget.value) : event.currentTarget.value; }} />}
|
||||
</label>)}
|
||||
<Button type="submit" disabled={this.busy}>{form.submit}</Button>
|
||||
{this.confirmation === form && <div className="extension-confirm" role="group" aria-label="确认操作"><p>{form.confirm}</p>
|
||||
<Button disabled={this.busy} onClick={() => void this.run(form.id, data, form)}>确认{form.submit}</Button>
|
||||
<Button disabled={this.busy} onClick={() => { this.confirmation = undefined; this.draw(); }}>取消</Button>
|
||||
</div>}
|
||||
</form>;
|
||||
}
|
||||
private content(content: ExtensionContent, scope = 'page') {
|
||||
return <>
|
||||
{content.forms.map(form => <section className="sources-card">{this.form(form)}</section>)}
|
||||
{content.sections?.map((section, sectionIndex) => <section className="extension-section"><h2>{section.title}</h2>
|
||||
{!section.items.length && section.emptyText && <p className="extension-empty">{section.emptyText}</p>}
|
||||
{section.items.map((item, index) => {
|
||||
const key = scope + ':' + sectionIndex + ':' + index, open = this.expanded.has(key);
|
||||
return <article className="sources-card extension-item" key={key}>
|
||||
<div className="extension-item-heading"><div><h3>{item.title}</h3><p className="source-description">{item.description}</p></div>
|
||||
{item.collapsible && <button className="button" type="button" aria-expanded={open} aria-controls={'extension-item-' + key} onClick={() => {
|
||||
if (open) this.expanded.delete(key); else this.expanded.add(key); this.draw();
|
||||
}}>{open ? '收起' : '管理'}</button>}</div>
|
||||
<div id={'extension-item-' + key} hidden={item.collapsible && !open} className="extension-actions">{item.forms?.map(form => this.form(form))}</div>
|
||||
</article>;
|
||||
})}
|
||||
</section>)}
|
||||
</>;
|
||||
}
|
||||
private selectTab(id: string) {
|
||||
this.activeTab = id; this.error = ''; this.confirmation = undefined; this.draw();
|
||||
const body = this.element.querySelector('.sources-body'); if (body) body.scrollTop = 0;
|
||||
}
|
||||
private view() {
|
||||
const tabs = this.page?.tabs, selected = tabs?.find(tab => tab.id === this.activeTab);
|
||||
const status = this.busy ? '正在处理…' : this.error || (this.activeTab === this.noticeTab ? this.page?.notice : '');
|
||||
const isError = !this.busy && (!!this.error || this.page?.noticeKind === 'error');
|
||||
return <>
|
||||
<header className="sources-header"><IconButton label="返回" icon="arrow-left" onClick={this.options.onBack} /><h1>{this.page?.title ?? '书源配置'}</h1>
|
||||
<Button disabled={this.busy} onClick={() => void this.run()}>刷新</Button></header>
|
||||
<div className="extension-toolbar">
|
||||
{tabs && <div className="extension-tabs" role="tablist" aria-label="配置分类">{tabs.map((tab, index) => <button type="button"
|
||||
disabled={this.busy} id={'extension-tab-' + tab.id} role="tab" aria-selected={this.activeTab === tab.id} aria-controls={'extension-panel-' + tab.id}
|
||||
tabIndex={this.activeTab === tab.id ? 0 : -1} onClick={() => this.selectTab(tab.id)} onKeyDown={event => {
|
||||
let next = index;
|
||||
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
|
||||
else if (event.key === 'ArrowLeft') next = (index + tabs.length - 1) % tabs.length;
|
||||
else if (event.key === 'Home') next = 0;
|
||||
else if (event.key === 'End') next = tabs.length - 1;
|
||||
else return;
|
||||
event.preventDefault(); this.selectTab(tabs[next]!.id);
|
||||
document.getElementById('extension-tab-' + this.activeTab)?.focus();
|
||||
}}>{tab.title}</button>)}</div>}
|
||||
{status && <div data-extension-feedback tabIndex={-1} role={isError ? 'alert' : 'status'} className={isError ? 'notice extension-error' : 'notice'}>{status}</div>}
|
||||
</div>
|
||||
<main className="sources-body" aria-busy={this.busy}>
|
||||
{this.page?.description && <p className="extension-description">{this.page.description}</p>}
|
||||
{this.page && this.content(this.page)}
|
||||
{selected && <div key={selected.id} tabIndex={-1} role="tabpanel" id={'extension-panel-' + selected.id} aria-labelledby={'extension-tab-' + selected.id}>
|
||||
{selected.description && <p className="extension-description">{selected.description}</p>}{this.content(selected, 'tab-' + selected.id)}
|
||||
</div>}
|
||||
</main>
|
||||
</>;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SourcePage } from '../api/sources.ts';
|
||||
/**
|
||||
* The reader's chrome: everything around the page.
|
||||
*
|
||||
@@ -19,8 +20,8 @@ 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 } from './vendor/preact.ts';
|
||||
import { IconButton, IconTextButton, SectionTitle } from './toolkit.tsx';
|
||||
import { type ComponentChildren, type JSX, useLayoutEffect, useRef } from './vendor/preact.ts';
|
||||
import { Button, IconButton, IconTextButton, SectionTitle } from './toolkit.tsx';
|
||||
import { Icon, type IconName } from './icon.tsx';
|
||||
|
||||
/**
|
||||
@@ -77,6 +78,12 @@ export interface ChromeState {
|
||||
/** True while a window is being fetched, so the chapter buttons can say so. */
|
||||
navigating: boolean;
|
||||
canRefresh?: boolean;
|
||||
canSwitch?: boolean;
|
||||
switching?: boolean;
|
||||
alternatives?: SourcePage | null;
|
||||
alternativeChapters?: Array<{ id: string; title: string }> | undefined;
|
||||
alternativeChapter?: string;
|
||||
alternativeTitle?: string;
|
||||
refreshing?: boolean;
|
||||
tts: SpeechBarState;
|
||||
layout: string;
|
||||
@@ -129,6 +136,11 @@ export interface ChromeHandlers {
|
||||
onTocEntry(ref: string): void;
|
||||
onChapter(delta: 1 | -1): void;
|
||||
onRefresh?(): void;
|
||||
onAlternatives?(cursor?: string): void;
|
||||
onAlternative?(ref: string, title: string): void;
|
||||
onAlternativeChapter?(id: string): void;
|
||||
onSwitchSource?(): void;
|
||||
onCancelSwitch?(): void;
|
||||
/**
|
||||
* Jump to a page *inside the current chapter*, from the footer scrubber.
|
||||
*
|
||||
@@ -204,7 +216,7 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
|
||||
</div>
|
||||
</div>
|
||||
{state.tocOpen ? (
|
||||
<Panel title="目录" subtitle={`${state.toc.length} 章`} onClose={handlers.toggleToc}>
|
||||
<Panel title="目录" placement="start" subtitle={`${state.toc.length} 章`} onClose={handlers.toggleToc}>
|
||||
{state.canRefresh ? (
|
||||
<IconTextButton
|
||||
icon="refresh"
|
||||
@@ -213,6 +225,19 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
|
||||
onClick={() => handlers.onRefresh?.()}
|
||||
/>
|
||||
) : null}
|
||||
{state.canSwitch && <Button type="button" disabled={state.switching || state.refreshing || state.navigating} onClick={() => handlers.onAlternatives?.()}>切换书源</Button>}
|
||||
{state.switching && <p role="status">正在获取书源内容…</p>}
|
||||
{state.alternatives && <section aria-label="切换书源"><h3>选择其它书源</h3><p>{state.alternatives.title}</p>
|
||||
<p>请核对书名、作者,并选择新目录中的章节。原书签和笔记仍关联原章节。</p>
|
||||
{!state.alternatives.items.length && <p>本批没有同名书籍,可继续下一批。</p>}
|
||||
{state.alternatives.items.map(entry => <div className="sources-row"><div><strong>{entry.title}</strong><small>{entry.authors?.join(' / ')}</small><p>{entry.description}</p></div>
|
||||
<Button type="button" disabled={state.switching === true} onClick={() => handlers.onAlternative?.(entry.ref, entry.title)}>查看此源目录</Button></div>)}
|
||||
{state.alternatives.nextCursor && <Button type="button" disabled={state.switching === true} onClick={() => handlers.onAlternatives?.(state.alternatives?.nextCursor)}>下一批书源</Button>}
|
||||
{state.alternativeChapters && <div><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>}
|
||||
<Button type="button" disabled={state.switching === true} onClick={() => handlers.onCancelSwitch?.()}>取消换源</Button>
|
||||
</section>}
|
||||
{state.toc.length === 0 ? (
|
||||
<div className="empty-state">这本书没有目录</div>
|
||||
) : (
|
||||
@@ -282,7 +307,7 @@ function StageHost({ stage }: { stage: HTMLElement }): JSX.Element {
|
||||
}
|
||||
|
||||
/**
|
||||
* A half-screen sheet over the reading area.
|
||||
* A bottom sheet on phones and a side panel on desktop.
|
||||
*
|
||||
* Bottom sheet rather than a full-screen page, and the comment on `.panel` in the
|
||||
* stylesheet is where the reasoning lives. What belongs here is the *interaction*:
|
||||
@@ -302,22 +327,31 @@ function StageHost({ stage }: { stage: HTMLElement }): JSX.Element {
|
||||
*/
|
||||
function Panel({
|
||||
title,
|
||||
placement = 'end',
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
placement?: 'start' | 'end';
|
||||
subtitle?: string;
|
||||
onClose(): void;
|
||||
children: ComponentChildren;
|
||||
}): JSX.Element {
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const trigger = document.activeElement;
|
||||
panel.current?.querySelector<HTMLButtonElement>('.panel-header button')?.focus({ preventScroll: true });
|
||||
return () => { if (trigger instanceof HTMLElement && trigger.isConnected) trigger.focus({ preventScroll: true }); };
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
{/* `onClick` on the scrim rather than a document listener: the scrim *is* the
|
||||
dismiss control, so it should be a node the reader can hit, with the same
|
||||
processing as any other tap on the page. */}
|
||||
<div className="scrim" onClick={onClose} role="presentation" />
|
||||
<div className="panel" role="group" aria-label={title}>
|
||||
<div className="panel" data-placement={placement} ref={panel} role="group" aria-label={title}
|
||||
onKeyDown={(event) => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } }}>
|
||||
<div className="panel-grip" aria-hidden="true" />
|
||||
<div className="panel-header">
|
||||
<h2>{title}</h2>
|
||||
|
||||
@@ -185,6 +185,7 @@ export class ReaderScreen {
|
||||
private readonly viewListeners: Array<() => void> = [];
|
||||
private readonly publicationCache: PublicationCache;
|
||||
private loadingToken = 0;
|
||||
private alternativeRef = '';
|
||||
private viewEpoch = 0;
|
||||
|
||||
constructor(private readonly options: ReaderScreenOptions) {
|
||||
@@ -227,6 +228,11 @@ export class ReaderScreen {
|
||||
onTocEntry: (ref) => void this.goToChapterRef(ref),
|
||||
onChapter: (delta) => void this.goToChapter(delta),
|
||||
onRefresh: () => void this.refreshPublication(),
|
||||
onAlternatives: cursor => void this.loadAlternatives(cursor),
|
||||
onAlternative: (ref, title) => void this.previewAlternative(ref, title),
|
||||
onAlternativeChapter: id => this.patch({ alternativeChapter: id }),
|
||||
onSwitchSource: () => void this.switchSource(),
|
||||
onCancelSwitch: () => this.patch({ alternatives: null, alternativeChapters: undefined, alternativeChapter: '' }),
|
||||
onScrubPage: (page) => void this.scrubToPage(page),
|
||||
onTurnPage: (direction) => void this.turnPage(direction),
|
||||
onSpeechToggle: () => this.toggleSpeech(),
|
||||
@@ -247,13 +253,20 @@ export class ReaderScreen {
|
||||
async open(book: Book): Promise<void> {
|
||||
const token = ++this.loadingToken;
|
||||
this.book = book;
|
||||
this.patch({ title: book.title, author: book.author, canRefresh: book.format === 'chapters' });
|
||||
this.patch({ title: book.title, author: book.author, canRefresh: book.format === 'chapters', canSwitch: false, alternatives: null });
|
||||
this.setStatus('loading', '正在载入…');
|
||||
this.setChromeVisible(true);
|
||||
|
||||
try {
|
||||
this.manifest = await this.loadManifest(book);
|
||||
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 });
|
||||
}).catch(error => {
|
||||
if (token === this.loadingToken && error instanceof ApiError && error.isAuthFailure) this.options.onSignedOut();
|
||||
});
|
||||
}
|
||||
|
||||
// A staged book is read through the windowed contract and never downloads
|
||||
// the file. That is the whole answer to "客户端需要下载完整的书籍,那消耗太大":
|
||||
@@ -452,10 +465,63 @@ export class ReaderScreen {
|
||||
}
|
||||
}
|
||||
|
||||
private async sourceAction(action: () => Promise<void>): Promise<void> {
|
||||
if (this.chrome.switching || this.chrome.refreshing || this.navigating) return;
|
||||
const token = this.loadingToken; this.patch({ switching: true });
|
||||
try { await action(); }
|
||||
catch (error) {
|
||||
if (token !== this.loadingToken) return;
|
||||
if (error instanceof ApiError && error.isAuthFailure) this.options.onSignedOut();
|
||||
else this.flashStatus(error instanceof Error ? error.message : '换源操作失败', 5000);
|
||||
} finally { if (token === this.loadingToken) this.patch({ switching: false }); }
|
||||
}
|
||||
private async loadAlternatives(cursor?: string): Promise<void> {
|
||||
const book = this.book, token = this.loadingToken; if (!book) return;
|
||||
await this.sourceAction(async () => {
|
||||
const page = await this.options.api.alternatives(book.id, cursor);
|
||||
if (token === this.loadingToken) this.patch({ alternatives: page, alternativeChapters: undefined, alternativeChapter: '' });
|
||||
});
|
||||
}
|
||||
private async previewAlternative(ref: string, title: string): Promise<void> {
|
||||
const book = this.book, token = this.loadingToken; if (!book) return;
|
||||
await this.sourceAction(async () => {
|
||||
const preview = await this.options.api.switchPreview(book.id, ref);
|
||||
if (token !== this.loadingToken) return;
|
||||
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 : '' });
|
||||
});
|
||||
}
|
||||
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();
|
||||
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 }));
|
||||
const doc = createStagedDoc({ kind: content.kind, content, toc, orderedByBook: true, loader: { read: item => this.readStagedSection(book, item) } });
|
||||
(doc as BookDoc & { staged?: unknown }).staged = doc;
|
||||
const index = doc.sections.findIndex(section => section.id === result.href);
|
||||
if (index < 0) throw new Error('新目录中找不到已选章节,请刷新目录');
|
||||
await doc.loadSection(index);
|
||||
if (token !== this.loadingToken) return;
|
||||
this.stopSpeech();
|
||||
this.pendingPosition = null; if (this.progressTimer) clearTimeout(this.progressTimer);
|
||||
this.manifest = { ...previous, ...content, content }; this.content = content; this.doc = doc;
|
||||
void this.publicationCache.putManifest(book.id, this.manifest).catch(() => undefined);
|
||||
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.flashStatus('已切换书源,从所选章节开头继续阅读', 4000);
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshPublication(): Promise<void> {
|
||||
const book = this.book;
|
||||
const previousManifest = this.manifest;
|
||||
if (!book || book.format !== 'chapters' || !previousManifest || this.chrome.refreshing || this.navigating) return;
|
||||
if (!book || book.format !== 'chapters' || !previousManifest || this.chrome.refreshing || this.chrome.switching || this.navigating) return;
|
||||
const token = this.loadingToken;
|
||||
const previousHrefs = new Set(previousManifest.content?.items.map((item) => item.href));
|
||||
this.patch({ refreshing: true });
|
||||
@@ -1070,7 +1136,7 @@ export class ReaderScreen {
|
||||
*/
|
||||
private async goToChapterRef(ref: string): Promise<void> {
|
||||
const view = this.view;
|
||||
if (!view || this.navigating || this.chrome.refreshing) return;
|
||||
if (!view || this.navigating || this.chrome.refreshing || this.chrome.switching) return;
|
||||
const book = this.book;
|
||||
if (!book) return;
|
||||
|
||||
@@ -1179,7 +1245,7 @@ export class ReaderScreen {
|
||||
*/
|
||||
private async goToChapter(delta: 1 | -1): Promise<void> {
|
||||
const view = this.view;
|
||||
if (!view || this.navigating || this.chrome.refreshing) return;
|
||||
if (!view || this.navigating || this.chrome.refreshing || this.chrome.switching) return;
|
||||
// The *whole-book* position, not the window-local index: `sectionCount` is
|
||||
// the loaded window's length (forty chapters, or one comic volume), so a
|
||||
// reader at the last chapter of a window would be told "已经是最后一章" while
|
||||
|
||||
@@ -179,6 +179,7 @@ export class ReaderView {
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private currentObjectUrl: string | null = null;
|
||||
private animationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pageTurnDistance: number | null = null;
|
||||
private spokenChunks: SpokenChunk[] = [];
|
||||
private speechHighlight: HTMLElement | null = null;
|
||||
/**
|
||||
@@ -420,6 +421,10 @@ export class ReaderView {
|
||||
// different document), so it takes the horizontal pair: a comic page sliding in
|
||||
// from the direction the reader turned is the convention the format has.
|
||||
const axis = this.doc.layout === 'fixed' || this.settings.mode === 'paged' ? 'x' : 'y';
|
||||
// In scroll mode the flow is an entire chapter, so percentages of its height
|
||||
// cannot describe a page turn. Use the actual displacement (including a short
|
||||
// final screen), or one viewport when entering a different chapter.
|
||||
this.host.style.setProperty('--reader-turn-distance', `${this.pageTurnDistance ?? this.host.clientHeight}px`);
|
||||
const value = `${this.settings.pageAnimation}-${direction}-${axis}`;
|
||||
this.host.setAttribute('data-animating', value);
|
||||
if (this.animationTimer !== null) clearTimeout(this.animationTimer);
|
||||
@@ -584,6 +589,7 @@ export class ReaderView {
|
||||
* from the same measurement, and a step is exactly one of it.
|
||||
*/
|
||||
async next(): Promise<boolean> {
|
||||
this.pageTurnDistance = null;
|
||||
if (this.doc.layout === 'fixed') return this.stepFixed(1);
|
||||
if (this.settings.mode === 'paged') {
|
||||
const advanced = this.stepColumn(1);
|
||||
@@ -596,6 +602,7 @@ export class ReaderView {
|
||||
}
|
||||
|
||||
async previous(): Promise<boolean> {
|
||||
this.pageTurnDistance = null;
|
||||
if (this.doc.layout === 'fixed') return this.stepFixed(-1);
|
||||
if (this.settings.mode === 'paged') {
|
||||
const moved = this.stepColumn(-1);
|
||||
@@ -1001,7 +1008,9 @@ export class ReaderView {
|
||||
const page = this.scrollPage();
|
||||
const target = page + delta;
|
||||
if (target < 0 || target >= this.screenCount()) return false;
|
||||
const before = scroller.scrollTop;
|
||||
scroller.scrollTop = this.screenOffset(target);
|
||||
this.pageTurnDistance = Math.abs(scroller.scrollTop - before);
|
||||
this.emitPosition();
|
||||
return true;
|
||||
}
|
||||
@@ -1186,7 +1195,9 @@ export class ReaderView {
|
||||
if (total <= 1) return 0;
|
||||
if (page === 0) return 0;
|
||||
const target = (this.scrollRange() * page) / (total - 1);
|
||||
return this.snapToLine(target);
|
||||
// A line beyond the scroll range cannot be reached. Keep the page boundary
|
||||
// at the same clamped position the browser actually accepts at chapter end.
|
||||
return Math.min(this.scrollRange(), this.snapToLine(target));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,6 +70,9 @@ export type LibraryView = 'browse' | 'files';
|
||||
|
||||
/** A parsed location. */
|
||||
export type Route =
|
||||
| { name: 'sources' }
|
||||
| { name: 'source-page'; sourceId: string; pageId: string }
|
||||
| { name: 'plugin-page'; pluginId: string; pageId: string }
|
||||
| {
|
||||
name: 'shelf';
|
||||
/**
|
||||
@@ -176,6 +179,13 @@ export function parseRoute(hash: string, context?: RouteContext): Route {
|
||||
const parts = pathOf(raw).split('/').filter((part) => part.length > 0);
|
||||
if (parts.length === 0) return shelfRoute(context);
|
||||
const [head, ...rest] = parts;
|
||||
if (head === 'plugins' && rest.length === 2) {
|
||||
try { return { name: 'plugin-page', pluginId: decodeURIComponent(rest[0]!), pageId: decodeURIComponent(rest[1]!) }; } catch { return shelfRoute(context); }
|
||||
}
|
||||
if (head === 'sources' && rest.length === 2) {
|
||||
try { return { name: 'source-page', sourceId: decodeURIComponent(rest[0]!), pageId: decodeURIComponent(rest[1]!) }; } catch { return shelfRoute(context); }
|
||||
}
|
||||
if (head === 'sources') return { name: 'sources' };
|
||||
if (head === 'shelf') {
|
||||
/*
|
||||
* A shelf page is the second segment: `#/shelf/2`.
|
||||
@@ -295,6 +305,9 @@ function shelfRoute(context?: RouteContext, page = 1): Route {
|
||||
*/
|
||||
export function routeHash(route: Route): string {
|
||||
switch (route.name) {
|
||||
case 'source-page': return '#/sources/' + encodeURIComponent(route.sourceId) + '/' + encodeURIComponent(route.pageId);
|
||||
case 'plugin-page': return '#/plugins/' + encodeURIComponent(route.pluginId) + '/' + encodeURIComponent(route.pageId);
|
||||
case 'sources': return '#/sources';
|
||||
case 'shelf':
|
||||
// `/1` is the absence of a page: one screen, two URLs, is the thing the
|
||||
// router's equality check exists to prevent, and emitting both here is how
|
||||
@@ -324,6 +337,8 @@ export function routeHash(route: Route): string {
|
||||
/** Whether two routes point at the same place. Used to skip a redundant repaint. */
|
||||
export function sameRoute(a: Route, b: Route): boolean {
|
||||
if (a.name !== b.name) return false;
|
||||
if (a.name === 'source-page' && b.name === 'source-page') return a.sourceId === b.sourceId && a.pageId === b.pageId;
|
||||
if (a.name === 'plugin-page' && b.name === 'plugin-page') return a.pluginId === b.pluginId && a.pageId === b.pageId;
|
||||
if (a.name === 'book' && b.name === 'book') return a.bookId === b.bookId;
|
||||
if (a.name === 'library' && b.name === 'library') {
|
||||
/*
|
||||
@@ -344,6 +359,10 @@ export function sameRoute(a: Route, b: Route): boolean {
|
||||
|
||||
/** Where a "back" from this route lands when the app has no trail of its own. */
|
||||
export function parentOf(route: Route): Route {
|
||||
if (route.name === 'plugin-page' || route.name === 'source-page') return { name: 'sources' };
|
||||
if (route.name === 'library' && route.view === 'files') {
|
||||
return { ...route, path: route.path.split('/').slice(0, -1).join('/'), page: 1, search: '', view: route.path ? 'files' : 'browse' };
|
||||
}
|
||||
return route.name === 'shelf' ? SHELF : SHELF;
|
||||
}
|
||||
|
||||
|
||||
@@ -384,8 +384,9 @@ const FLOW_STYLESHEET = `
|
||||
* page starts one page away. The animation is a transform on the already-
|
||||
* repositioned flow, which is why it composites and why it cannot disturb the
|
||||
* column layout the paginator measured — the scroll offset is set *first*, and the
|
||||
* animation only draws the result. The -y pair is the same statement about a
|
||||
* screenful in scroll mode.
|
||||
* animation only draws the result. In scroll mode the flow is an entire chapter;
|
||||
* the -y pair uses the actual scroll displacement supplied by ReaderView, so it
|
||||
* starts at the previous visible position even when the last step is shorter.
|
||||
*
|
||||
* No opacity on the slide. A fade belongs to the fade-* pair, which is the setting's
|
||||
* own alternative for a reader who finds motion distracting; combining the two means
|
||||
@@ -400,11 +401,11 @@ const FLOW_STYLESHEET = `
|
||||
to { transform: none; }
|
||||
}
|
||||
@keyframes slide-next-y {
|
||||
from { transform: translateY(100%); }
|
||||
from { transform: translateY(var(--reader-turn-distance)); }
|
||||
to { transform: none; }
|
||||
}
|
||||
@keyframes slide-previous-y {
|
||||
from { transform: translateY(-100%); }
|
||||
from { transform: translateY(calc(-1 * var(--reader-turn-distance))); }
|
||||
to { transform: none; }
|
||||
}
|
||||
/*
|
||||
|
||||
+43
-55
@@ -13,6 +13,7 @@ import { Icon, IconButton, IconTextButton } from './toolkit.tsx';
|
||||
import { type ComponentChildren, type JSX, useEffect, useState } from './vendor/preact.ts';
|
||||
|
||||
export interface ShelfScreenOptions {
|
||||
onOpenSources?(): void;
|
||||
api: ReaderApi;
|
||||
offline: OfflineStore;
|
||||
platform: Platform;
|
||||
@@ -835,27 +836,7 @@ export class ShelfScreen {
|
||||
</p>
|
||||
</div>
|
||||
<div className="shelf-head-actions">
|
||||
{/*
|
||||
The library's entry point travels with the title rather than sitting
|
||||
in the toolbar: it is a *place*, not a filter, and the toolbar is where
|
||||
the filters are. It carries the folder and page the reader was last in
|
||||
*there*, so switching back and forth is a toggle rather than a reset.
|
||||
|
||||
The two glyphs are `library` and `tune`. The row has now been through
|
||||
two rounds of "这个 icon 还是很丑" (#40), and the second round is the
|
||||
reason the *names* changed as well as the shapes: `gear` and
|
||||
`magnifying-glass` were Font Awesome's names on our own outlines, so
|
||||
anyone who knows that set opens `paths.mjs` expecting Font Awesome's
|
||||
artwork and finds a different shape. The set is ours, so it is named
|
||||
ours (`settings`, `search`, `menu` — see `docs/ui.md` §1.2), and the
|
||||
header's two are drawn as few long strokes at 18px: `library` is three
|
||||
spines on a shelf, and `tune` is one rail with two knobs.
|
||||
*/}
|
||||
<IconButton
|
||||
label="书库"
|
||||
icon="library"
|
||||
onClick={() => this.options.onOpenLibrary(state.libraryPath, state.libraryPage)}
|
||||
/>
|
||||
{empty && !hasQuery && <IconButton label="刷新" icon="refresh" disabled={state.loading} onClick={() => void this.manualRefresh()} />}
|
||||
<IconButton
|
||||
label={`书架设置 · ${DENSITY_LABELS[density]}`}
|
||||
icon="tune"
|
||||
@@ -864,36 +845,42 @@ export class ShelfScreen {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="shelf-search" role="search">
|
||||
<Icon name="search" class="search-glyph" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索书名、作者、系列"
|
||||
aria-label="搜索书库"
|
||||
enterKeyHint="search"
|
||||
value={state.search}
|
||||
onInput={(event) => this.onSearchInput((event.currentTarget as HTMLInputElement).value)}
|
||||
onKeyDown={(event) => {
|
||||
// `search` inputs fire a non-standard `search` event on clear, but
|
||||
// only in some browsers; Enter is the one that is reliable
|
||||
// everywhere and it also means "stop waiting for the debounce".
|
||||
if (event.key === 'Enter') {
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
this.searchTimer = null;
|
||||
this.patch({ query: state.search.trim() });
|
||||
void this.refresh();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{state.search.length > 0 ? (
|
||||
<button type="button" className="search-clear" aria-label="清除搜索" onClick={() => this.clearSearch()}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="shelf-discovery">
|
||||
<nav className="collection-links shelf-links" aria-label="发现书籍">
|
||||
<button type="button" className="button collection-link" aria-label="书库" onClick={() => this.options.onOpenLibrary(state.libraryPath, state.libraryPage)}><Icon name="library" /><span>书库</span><Icon name="chevron-right" /></button>
|
||||
{this.options.onOpenSources && <button type="button" className="button collection-link" onClick={this.options.onOpenSources}><Icon name="search" /><span>书源</span><Icon name="chevron-right" /></button>}
|
||||
</nav>
|
||||
<div className="shelf-search" role="search">
|
||||
<Icon name="search" class="search-glyph" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索书名、作者、系列"
|
||||
aria-label="搜索书库"
|
||||
enterKeyHint="search"
|
||||
value={state.search}
|
||||
onInput={(event) => this.onSearchInput((event.currentTarget as HTMLInputElement).value)}
|
||||
onKeyDown={(event) => {
|
||||
// `search` inputs fire a non-standard `search` event on clear, but
|
||||
// only in some browsers; Enter is the one that is reliable
|
||||
// everywhere and it also means "stop waiting for the debounce".
|
||||
if (event.key === 'Enter') {
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
this.searchTimer = null;
|
||||
this.patch({ query: state.search.trim() });
|
||||
void this.refresh();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{state.search.length > 0 ? (
|
||||
<button type="button" className="search-clear" aria-label="清除搜索" onClick={() => this.clearSearch()}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="shelf-section" aria-label={hasQuery ? '搜索结果' : '全部书籍'}>
|
||||
<div className="shelf-toolbar">
|
||||
{(!empty || hasQuery) && <div className="shelf-toolbar">
|
||||
{hasQuery || state.total > 0 ? (
|
||||
<span className="shelf-count muted" data-testid="shelf-count">
|
||||
{hasQuery ? `找到 ${state.total} 本` : `共 ${state.total} 本`}
|
||||
@@ -915,7 +902,7 @@ export class ShelfScreen {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="shelf-refresh" hidden={!state.refreshing}>
|
||||
{state.refreshing ? '正在刷新…' : ''}
|
||||
@@ -925,7 +912,7 @@ export class ShelfScreen {
|
||||
|
||||
{!state.bootstrapping && empty ? (
|
||||
hasQuery ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state collection-empty">
|
||||
<Icon name="search" class="empty-glyph" />
|
||||
<p>没有匹配的书</p>
|
||||
<p className="muted">换个关键词,或者检查一下作者名的写法</p>
|
||||
@@ -934,19 +921,20 @@ export class ShelfScreen {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state collection-empty">
|
||||
<Icon name="book" class="empty-glyph" />
|
||||
<p>书库还是空的</p>
|
||||
<p>书架还没有书</p>
|
||||
<p className="muted">
|
||||
把书籍放进挂载的目录,扫一次,它们就会出现在这里
|
||||
从书库挑选喜欢的书,或通过书源搜索,加入书架后就能开始阅读。
|
||||
</p>
|
||||
<div className="empty-actions">
|
||||
{this.options.onOpenSources && <IconTextButton className="primary" icon="search" label="去搜书" onClick={this.options.onOpenSources} />}
|
||||
<IconTextButton
|
||||
className={this.options.onOpenSources ? '' : 'primary'}
|
||||
icon="library"
|
||||
label="打开书库"
|
||||
onClick={() => this.options.onOpenLibrary(state.libraryPath, state.libraryPage)}
|
||||
/>
|
||||
<IconTextButton icon="refresh" label="刷新" onClick={() => void this.manualRefresh()} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1027,7 +1015,7 @@ export class ShelfScreen {
|
||||
}
|
||||
return base;
|
||||
}
|
||||
return '自部署书库';
|
||||
return '收藏好书,随时接着读';
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
import type { AppSettings } from '../store/settings.ts';
|
||||
import { IconButton, SectionTitle, Segmented, SwitchRow } from './toolkit.tsx';
|
||||
import type { JSX } from './vendor/preact.ts';
|
||||
import { type JSX, useLayoutEffect, useRef } from './vendor/preact.ts';
|
||||
|
||||
export const DENSITY_LABELS: Record<AppSettings['shelfDensity'], string> = {
|
||||
compact: '紧凑',
|
||||
@@ -58,8 +58,16 @@ export function ShelfSettingsPanel({
|
||||
onPatch,
|
||||
onClose,
|
||||
}: ShelfSettingsOptions & { open: boolean }): JSX.Element {
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const trigger = document.activeElement;
|
||||
panel.current?.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
|
||||
return () => { if (trigger instanceof HTMLElement && trigger.isConnected) trigger.focus({ preventScroll: true }); };
|
||||
}, [open]);
|
||||
return (
|
||||
<div className="panel shelf-settings" hidden={!open}>
|
||||
<div className="panel shelf-settings" ref={panel} role="dialog" aria-label="书架设置" hidden={!open}
|
||||
onKeyDown={(event) => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } }}>
|
||||
<div className="panel-header">
|
||||
<h2>书架设置</h2>
|
||||
<IconButton label="关闭" icon="close" onClick={onClose} />
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { ExtensionField } from '../api/sources.ts';
|
||||
import { ApiError, type ReaderApi } from '../api/client.ts';
|
||||
import type { Book } from '../api/types.ts';
|
||||
import type { ChapterSubscription, SourceEntry, SourceInstance, SourcePage, SourcePlugin, SourceType } from '../api/sources.ts';
|
||||
import { Modal } from './modal.tsx';
|
||||
import { mountUI } from './mount.ts';
|
||||
import { Button, IconButton, Icon } from './toolkit.tsx';
|
||||
|
||||
interface Options { api: ReaderApi; admin: boolean; onBack(): void; onOpen(book: Book): void; onSignedOut(): void }
|
||||
type SourceTab = 'search' | 'sources' | 'updates' | 'plugins';
|
||||
interface Editor { id: string | null; typeKey: string; name: string; config: Record<string, unknown>; raw: string }
|
||||
const keyFor = (type: { pluginId: string; id: string }) => `${type.pluginId}/${type.id}`;
|
||||
const date = (value: number | null) => value ? new Date(value).toLocaleString() : '尚未检查';
|
||||
|
||||
/** Source configuration and browsing use the same declared provider capabilities. */
|
||||
export class SourcesScreen {
|
||||
readonly element = document.createElement('div');
|
||||
private readonly ui: ReturnType<typeof mountUI>;
|
||||
private disposed = false;
|
||||
private busy = false;
|
||||
private message = '';
|
||||
private savedSource: SourceInstance | null = null;
|
||||
private types: SourceType[] = [];
|
||||
private sources: SourceInstance[] = [];
|
||||
private plugins: SourcePlugin[] = [];
|
||||
private subscriptions: ChapterSubscription[] = [];
|
||||
private tab: SourceTab = 'search';
|
||||
private credentialsOpen = false;
|
||||
private managing: string | null = null;
|
||||
private editor: Editor | null = null;
|
||||
private selected: SourceInstance | null = null;
|
||||
private page: SourcePage | null = null;
|
||||
private path: Array<{ ref?: string; query?: string; cursor?: string; filters?: Record<string, string> }> = [];
|
||||
private query = '';
|
||||
private filters: ExtensionField[] = [];
|
||||
private filterValues: Record<string, string> = {};
|
||||
private credentialValues: Record<string, string> = {};
|
||||
private folder = '';
|
||||
private trusted = false;
|
||||
private acquired: Book | null = null;
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
this.element.className = 'sources-screen sources-hub';
|
||||
this.ui = mountUI(this.element, () => this.view(), null);
|
||||
}
|
||||
async show(): Promise<void> { await this.run(() => this.reload()); }
|
||||
dispose(): void { this.disposed = true; this.credentialValues = {}; this.ui.unmount(); }
|
||||
private draw(): void { if (!this.disposed) this.ui.update(null); }
|
||||
private async run(action: () => Promise<void>): Promise<void> {
|
||||
if (this.busy || this.disposed) return;
|
||||
this.busy = true; this.message = ''; this.draw();
|
||||
try { await action(); }
|
||||
catch (error) {
|
||||
if (this.disposed) return;
|
||||
if (error instanceof ApiError && error.isAuthFailure) this.options.onSignedOut();
|
||||
else this.message = error instanceof Error ? error.message : '操作失败,请重试';
|
||||
} finally {
|
||||
this.busy = false; this.draw();
|
||||
this.element.querySelector<HTMLElement>('dialog [role=alert]')?.focus();
|
||||
}
|
||||
}
|
||||
private async reload(): Promise<void> {
|
||||
const [types, sources, subscriptions, plugins] = await Promise.all([
|
||||
this.options.api.sourceTypes(), this.options.api.sources(), this.options.api.subscriptions(),
|
||||
this.options.admin ? this.options.api.plugins() : Promise.resolve([]),
|
||||
]);
|
||||
this.types = types; this.sources = sources; this.subscriptions = subscriptions; this.plugins = plugins;
|
||||
if (this.savedSource) this.savedSource = sources.find(source => source.id === this.savedSource?.id) ?? null;
|
||||
if (this.selected) {
|
||||
this.selected = sources.find(source => source.id === this.selected?.id && source.enabled && source.descriptor) ?? null;
|
||||
if (!this.selected) { this.page = null; this.filters = []; this.credentialValues = {}; }
|
||||
}
|
||||
}
|
||||
private edit(source?: SourceInstance): void {
|
||||
const type = source ? this.types.find((t) => t.pluginId === source.pluginId && t.id === source.sourceType) : this.types.find((t) => t.id === 'opds') ?? this.types[0];
|
||||
if (!type) return;
|
||||
this.editor = { id: source?.id ?? null, typeKey: keyFor(type), name: source?.name ?? '', config: { ...source?.config }, raw: JSON.stringify(source?.config ?? {}, null, 2) };
|
||||
this.message = ''; this.draw();
|
||||
}
|
||||
private async save(): Promise<void> {
|
||||
const editor = this.editor; if (!editor) return;
|
||||
const type = this.types.find((t) => keyFor(t) === editor.typeKey)!;
|
||||
const config = type.configSchema?.properties ? editor.config : JSON.parse(editor.raw);
|
||||
this.savedSource = await this.options.api.saveSource(editor.id, { name: editor.name, config, ...(!editor.id ? { pluginId: type.pluginId, sourceType: type.id } : {}) });
|
||||
this.editor = null; await this.reload(); this.message = this.savedSource?.descriptor?.extensions?.pages?.length ? '来源已保存,接下来可以配置此书源。' : editor.id ? '来源已保存;修改连接配置后请重新保存个人凭据。' : '来源已创建,可在“搜书”中选择此来源。';
|
||||
}
|
||||
private async catalog(query: { ref?: string; query?: string; cursor?: string; filters?: Record<string, string> }, push = true): Promise<void> {
|
||||
if (!this.selected) return;
|
||||
const page = await this.options.api.sourceCatalog(this.selected.id, query);
|
||||
this.page = page; if (push) this.path.push(query);
|
||||
}
|
||||
private select(source: SourceInstance): void {
|
||||
this.selected = source; this.page = null; this.path = []; this.query = ''; this.credentialValues = {}; this.acquired = null;
|
||||
this.filters = []; this.filterValues = {};
|
||||
void this.run(async () => {
|
||||
if (source.descriptor?.capabilities.includes('search.filters')) this.filters = await this.options.api.sourceFilters(source.id);
|
||||
if (source.descriptor?.capabilities.includes('browse')) await this.catalog({});
|
||||
this.draw();
|
||||
});
|
||||
}
|
||||
private async acquire(entry: SourceEntry, optionId?: string): Promise<void> {
|
||||
if (!this.selected) return;
|
||||
const result = await this.options.api.acquireSource(this.selected.id, entry.ref, optionId);
|
||||
if (result.kind === 'action-required') { this.message = result.action?.label ?? '请先在来源服务完成授权'; return; }
|
||||
if (!result.publicationId) throw new Error('来源没有返回书籍');
|
||||
this.acquired = (await this.options.api.getBook(result.publicationId)).book;
|
||||
this.subscriptions = await this.options.api.subscriptions();
|
||||
this.message = '已加入书架,可在“自动追更”中开启检查。';
|
||||
}
|
||||
private changeTab(tab: SourceTab): void {
|
||||
this.tab = tab; this.managing = null; this.message = ''; this.draw();
|
||||
const body = this.element.querySelector('.sources-body'); if (body) body.scrollTop = 0;
|
||||
}
|
||||
private view() {
|
||||
const type = this.types.find((t) => keyFor(t) === this.editor?.typeKey);
|
||||
const tabs: Array<{ id: SourceTab; title: string }> = [
|
||||
{ id: 'search', title: '搜书' },
|
||||
...(this.options.admin ? [{ id: 'sources' as const, title: '书源管理' }] : []),
|
||||
{ id: 'updates', title: '自动追更' },
|
||||
...(this.options.admin ? [{ id: 'plugins' as const, title: '插件管理' }] : []),
|
||||
];
|
||||
return <>
|
||||
<header className="sources-header"><IconButton label="返回" icon="arrow-left" onClick={this.options.onBack} /><h1>书源与追更</h1>
|
||||
<Button disabled={this.busy} onClick={() => void this.run(() => this.reload())}>刷新</Button></header>
|
||||
<nav className="sources-tabs" role="tablist" aria-label="书源功能">{tabs.map((tab, index) => <button key={tab.id} type="button"
|
||||
role="tab" id={'sources-tab-' + tab.id} aria-controls={'sources-panel-' + tab.id} aria-selected={this.tab === tab.id}
|
||||
tabIndex={this.tab === tab.id ? 0 : -1} disabled={this.busy} onClick={() => this.changeTab(tab.id)} onKeyDown={event => {
|
||||
let next = index;
|
||||
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
|
||||
else if (event.key === 'ArrowLeft') next = (index + tabs.length - 1) % tabs.length;
|
||||
else if (event.key === 'Home') next = 0;
|
||||
else if (event.key === 'End') next = tabs.length - 1;
|
||||
else return;
|
||||
event.preventDefault(); this.changeTab(tabs[next]!.id);
|
||||
this.element.querySelector<HTMLElement>('#sources-tab-' + tabs[next]!.id)?.focus();
|
||||
}}>{tab.title}{tab.id === 'updates' && this.subscriptions.some(s => s.newChapters > 0) && <span className="sources-tab-dot" aria-label="有更新" />}</button>)}</nav>
|
||||
{!this.editor && !this.credentialsOpen && (this.busy || this.message) && <div role="status" className="notice">{this.busy ? '正在处理…' : this.message}</div>}
|
||||
<main key="body" className="sources-body" role="tabpanel" id={'sources-panel-' + this.tab} aria-labelledby={'sources-tab-' + this.tab} tabIndex={0}>
|
||||
{this.tab === 'sources' && this.options.admin && <>
|
||||
{!!this.savedSource?.descriptor?.extensions?.pages?.length && <section className="sources-card source-next-step"><strong>{this.savedSource!.name}</strong>
|
||||
<p>可为此书源单独管理订阅和规则。</p>{this.savedSource!.descriptor!.extensions!.pages!.map(page => <a className="button primary" href={'#/sources/' + encodeURIComponent(this.savedSource!.id) + '/' + encodeURIComponent(page.id)}>{page.title}</a>)}
|
||||
</section>}
|
||||
<section key="source-list" className="sources-card sources-list"><div className="sources-list-heading"><h2>我的来源 <span className="source-count">{this.sources.length}</span></h2>
|
||||
{this.options.admin && <Button className="source-add" disabled={this.busy} onClick={() => this.edit()}><Icon name="plus" />添加来源</Button>}
|
||||
</div>
|
||||
{this.sources.map(source => <article className="sources-row source-entry" key={source.id}>
|
||||
<div className="source-entry-top"><div className="source-entry-info"><strong>{source.name}</strong>
|
||||
<small>{source.descriptor?.label ?? '插件未启用或未安装'}</small>
|
||||
<span className={'source-state' + (source.enabled ? ' is-enabled' : '')}>{source.enabled ? '已启用' : '已暂停'}</span>
|
||||
</div><div className="source-entry-actions">
|
||||
{this.options.admin && <button type="button" className="source-manage-trigger" disabled={this.busy}
|
||||
aria-expanded={this.managing === source.id} aria-controls={'source-manage-' + source.id}
|
||||
onClick={() => { this.managing = this.managing === source.id ? null : source.id; this.draw(); }}
|
||||
onKeyDown={event => { if (event.key === 'Escape') { this.managing = null; this.draw(); } }}>管理<Icon name="chevron-right" /></button>}
|
||||
</div></div>
|
||||
{this.options.admin && this.managing === source.id && <div className="source-management" id={'source-manage-' + source.id} role="group" aria-label={source.name + '管理'}
|
||||
onKeyDown={event => { if (event.key === 'Escape') { this.managing = null; this.draw();
|
||||
this.element.querySelectorAll<HTMLElement>('.source-manage-trigger')[this.sources.indexOf(source)]?.focus();
|
||||
} }}>
|
||||
{source.descriptor?.extensions?.pages?.map(page => <a className="source-management-link" href={'#/sources/' + encodeURIComponent(source.id) + '/' + encodeURIComponent(page.id)}>{page.title}<Icon name="chevron-right" /></a>)}
|
||||
<button className="source-management-link" type="button" disabled={this.busy || !source.descriptor} onClick={() => this.edit(source)}>基本设置<Icon name="chevron-right" /></button>
|
||||
<button className="source-management-link source-toggle" type="button" disabled={this.busy} onClick={() => void this.run(async () => {
|
||||
await this.options.api.saveSource(source.id, { enabled: !source.enabled }); await this.reload(); this.managing = null;
|
||||
this.message = source.enabled ? '来源已暂停' : '来源已启用';
|
||||
})}>{source.enabled ? '暂停' : '启用'}</button>
|
||||
</div>}
|
||||
</article>)}
|
||||
</section>
|
||||
</>}
|
||||
{this.tab === 'search' && <>
|
||||
<section className="sources-card source-picker"><h2>搜书</h2>
|
||||
<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);
|
||||
}}><option value="" disabled>请选择一个来源</option>{this.sources.filter(source => source.enabled && source.descriptor).map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label>
|
||||
{!this.selected && <p className="muted">{this.sources.some(source => source.enabled && source.descriptor) ? '选择来源后,即可搜索书籍或浏览目录。' : '暂无可用来源,请先添加或启用来源。'}</p>}
|
||||
</section>
|
||||
{this.selected && <section className="sources-card source-catalog"><h2>{this.selected.name}</h2>
|
||||
{(this.selected.descriptor?.credentialKeys?.length ?? 0) > 0 && <Button disabled={this.busy} onClick={() => { this.credentialsOpen = true; this.message = ''; this.draw(); }}>登录凭据</Button>}
|
||||
{this.selected.descriptor?.capabilities.includes('search') && <form className="sources-search" onSubmit={(event) => { event.preventDefault(); void this.run(() => this.catalog({ query: this.query.trim(), filters: { ...this.filterValues } })); }}>
|
||||
{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(); }}>
|
||||
{field.options?.map(option => <option value={option.value}>{option.label}</option>)}
|
||||
</select></label>)}
|
||||
<label>搜索书籍<input type="search" required value={this.query} onInput={(event) => { this.query = event.currentTarget.value; }} /></label><Button type="submit" disabled={this.busy}>搜索</Button></form>}
|
||||
{this.path.length > 1 && <Button disabled={this.busy} onClick={() => void this.run(async () => { const previous = this.path[this.path.length - 2]!; await this.catalog(previous, false); this.path.pop(); })}>上一页目录</Button>}
|
||||
{this.page?.title && <h3>{this.page.title}</h3>}
|
||||
{this.page?.navigation?.map((entry) => <Button disabled={this.busy} onClick={() => void this.run(() => this.catalog({ ref: entry.ref }))}>{entry.title}</Button>)}
|
||||
{this.page?.items.length === 0 && <p>这里还没有书籍。可以进入分类或换一个关键词。</p>}
|
||||
{this.page?.items.map((entry) => <article className="sources-row" key={entry.ref}>
|
||||
<div><strong>{entry.title}</strong><small>{entry.authors?.join(' / ')}</small><p className="source-description">{entry.description}</p></div>
|
||||
<div className="sources-actions"><Button disabled={this.busy} onClick={() => void this.run(async () => {
|
||||
const detail = await this.options.api.sourceDetail(this.selected!.id, entry.ref); Object.assign(entry, detail);
|
||||
})}>详情</Button>
|
||||
{(entry.options?.length ? entry.options : [{ id: '', label: '加入书架' }]).map((option) => <Button disabled={this.busy || option.available === false}
|
||||
onClick={() => void this.run(() => this.acquire(entry, option.id))}>{option.label}</Button>)}</div>
|
||||
</article>)}
|
||||
{this.page?.nextCursor && <Button disabled={this.busy} onClick={() => void this.run(() => this.catalog({ ...this.path.at(-1), cursor: this.page!.nextCursor! }))}>下一页</Button>}
|
||||
{this.acquired && <Button onClick={() => this.options.onOpen(this.acquired!)}>阅读《{this.acquired.title}》</Button>}
|
||||
</section>}
|
||||
</>}
|
||||
{this.tab === 'updates' && <section className="sources-card"><h2>自动追更</h2><p className="notice">服务端定时检查目录,浏览器关闭后继续运行。正文和图片在阅读时缓存。</p>
|
||||
{!this.subscriptions.length && <p>先从章节书源加入书籍,再开启追更。</p>}
|
||||
{this.subscriptions.map((subscription) => <article className="sources-row" key={subscription.bookId}><div><strong>{subscription.title}</strong>
|
||||
<small>{subscription.newChapters ? `新增 ${subscription.newChapters} 章 · ` : ''}上次成功:{date(subscription.lastSuccessAt)}</small>
|
||||
<small>{subscription.enabled ? `下次检查:${date(subscription.nextCheckAt)}` : '自动追更已关闭'}{subscription.lastError ? ` · 检查失败:${subscription.lastError}` : ''}</small></div>
|
||||
<div className="sources-actions"><label>检查间隔<select aria-label={`${subscription.title}检查间隔`} value={subscription.intervalMinutes} disabled={this.busy}
|
||||
onChange={(event) => { const intervalMinutes = Number(event.currentTarget.value); void this.run(async () => { await this.options.api.configureSubscription(subscription.bookId, { intervalMinutes }); await this.reload(); }); }}>
|
||||
{[...new Set([15, 60, 360, 1440, subscription.intervalMinutes])].sort((a, b) => a - b).map((minutes) => <option value={minutes}>{minutes} 分钟</option>)}</select></label>
|
||||
<Button disabled={this.busy} onClick={() => void this.run(async () => { await this.options.api.configureSubscription(subscription.bookId, { enabled: !subscription.enabled }); await this.reload(); })}>{subscription.enabled ? '关闭追更' : '开启追更'}</Button>
|
||||
<Button disabled={this.busy} onClick={() => void this.run(async () => { await this.options.api.refreshPublication(subscription.bookId); await this.reload(); this.message = '目录已更新'; })}>立即检查</Button>
|
||||
<Button disabled={this.busy} onClick={() => void this.run(async () => {
|
||||
await this.options.api.configureSubscription(subscription.bookId, { acknowledge: true });
|
||||
const book = (await this.options.api.getBook(subscription.bookId)).book; if (!this.disposed) this.options.onOpen(book);
|
||||
})}>阅读</Button>
|
||||
</div></article>)}
|
||||
</section>}
|
||||
{this.tab === 'plugins' && this.options.admin && <section className="sources-card"><h2>插件管理</h2>
|
||||
<form onSubmit={(event) => { event.preventDefault(); if (!this.trusted) return; void this.run(async () => { await this.options.api.installPlugin(this.folder); this.folder = ''; this.trusted = false; await this.reload(); }); }}>
|
||||
<label>已部署的插件目录或 npm 包<input required value={this.folder} placeholder="npm:external-source" onInput={(event) => { this.folder = event.currentTarget.value; }} /></label>
|
||||
<p className="notice">支持插件目录名,或管理员已安装的 npm:包名。插件以服务端权限运行,部署方式见插件说明。</p>
|
||||
<label className="sources-consent"><input type="checkbox" checked={this.trusted} onChange={(event) => { this.trusted = event.currentTarget.checked; this.draw(); }} />我信任这个插件的代码</label>
|
||||
<Button type="submit" disabled={this.busy || !this.trusted}>安装插件</Button>
|
||||
</form>
|
||||
{this.plugins.map((plugin) => <article className="sources-row" key={plugin.pluginId}><div><strong>{plugin.name ?? plugin.pluginId}</strong>
|
||||
<small>{plugin.version} · {plugin.builtin ? '内置' : plugin.enabled ? '已启用' : '已停用'}{plugin.error ? ` · ${plugin.error.message}` : ''}{plugin.runtime?.state === 'failed' ? ' · 运行失败,请重启插件' : ''}</small></div>
|
||||
{plugin.enabled && plugin.extensions?.pages?.map(page => <a className="button" href={'#/plugins/' + encodeURIComponent(plugin.pluginId) + '/' + encodeURIComponent(page.id)}>{page.title}</a>)}
|
||||
{!plugin.builtin && <div className="sources-actions"><Button disabled={this.busy} onClick={() => void this.run(async () => { await this.options.api.enablePlugin(plugin.pluginId, !plugin.enabled); await this.reload(); })}>{plugin.enabled ? '停用' : '启用'}</Button>
|
||||
{plugin.runtime?.state === 'failed' && <Button disabled={this.busy} onClick={() => void this.run(async () => { await this.options.api.enablePlugin(plugin.pluginId, true); await this.reload(); })}>重启插件</Button>}
|
||||
<Button disabled={this.busy} onClick={() => void this.run(async () => { await this.options.api.uninstallPlugin(plugin.pluginId); await this.reload(); this.message = '插件已卸载,书籍及已缓存内容保留。'; })}>卸载</Button></div>}
|
||||
</article>)}
|
||||
</section>}
|
||||
</main>
|
||||
{this.editor && <Modal title={this.editor.id ? '编辑来源' : '添加来源'} busy={this.busy} onClose={() => { this.editor = null; this.message = ''; this.draw(); }}>
|
||||
<form className="sources-card source-editor source-modal-form" onSubmit={(event) => { event.preventDefault(); void this.run(() => this.save()); }}>
|
||||
<div className="source-modal-content">
|
||||
{(this.busy || this.message) && <p className="notice" tabIndex={-1} role={this.busy ? 'status' : 'alert'}>{this.busy ? '正在保存…' : this.message}</p>}
|
||||
<label>来源类型<select disabled={this.busy || !!this.editor.id} value={this.editor.typeKey} onChange={(event) => {
|
||||
this.editor!.typeKey = event.currentTarget.value; this.editor!.config = {}; this.editor!.raw = '{}'; this.draw();
|
||||
}}>{this.types.map((t) => <option value={keyFor(t)}>{t.label}</option>)}</select></label>
|
||||
<label>名称<input autoFocus required disabled={this.busy} maxLength={128} value={this.editor.name} onInput={(event) => { this.editor!.name = event.currentTarget.value; }} /></label>
|
||||
{type?.configSchema?.properties ? Object.entries(type.configSchema.properties).map(([key, field]) => <label key={key}>{field.title ?? key}
|
||||
{field.type === 'array' || field.type === 'object' ? <textarea disabled={this.busy} aria-label={field.title ?? key} value={JSON.stringify(this.editor!.config[key] ?? (field.type === 'array' ? [] : {}), null, 2)}
|
||||
onChange={(event) => { try { this.editor!.config[key] = JSON.parse(event.currentTarget.value); event.currentTarget.setCustomValidity(''); } catch { event.currentTarget.setCustomValidity('请输入有效 JSON'); } }} />
|
||||
: field.type === 'boolean' ? <input disabled={this.busy} type="checkbox" checked={this.editor!.config[key] === true}
|
||||
onChange={(event) => { this.editor!.config[key] = event.currentTarget.checked; }} />
|
||||
: <input disabled={this.busy} type={field.type === 'number' || field.type === 'integer' ? 'number' : 'text'} step={field.type === 'number' ? 'any' : '1'} required={type.configSchema?.required?.includes(key)} value={String(this.editor!.config[key] ?? '')}
|
||||
onInput={(event) => { const value = event.currentTarget.value;
|
||||
if (!value && !type.configSchema?.required?.includes(key)) delete this.editor!.config[key];
|
||||
else this.editor!.config[key] = field.type === 'number' || field.type === 'integer' ? Number(value) : value;
|
||||
}} />}
|
||||
</label>) : <label>配置(JSON)<textarea disabled={this.busy} value={this.editor.raw} onInput={(event) => { this.editor!.raw = event.currentTarget.value; }} /></label>}
|
||||
<p className="notice">配置不包含密码。修改连接配置会清除该来源保存的个人凭据。</p>
|
||||
</div><footer className="source-modal-actions"><Button className="primary" type="submit" disabled={this.busy}>保存来源</Button>
|
||||
<Button disabled={this.busy} onClick={() => { this.editor = null; this.message = ''; this.draw(); }}>取消</Button>
|
||||
{this.editor.id && <Button disabled={this.busy || type?.id === 'local'} onClick={() => void this.run(async () => {
|
||||
await this.options.api.removeSource(this.editor!.id!); this.editor = null; await this.reload();
|
||||
})}>删除空来源</Button>}</footer>
|
||||
</form></Modal>}
|
||||
{this.credentialsOpen && this.selected && <Modal title="我的登录凭据" busy={this.busy} onClose={() => { this.credentialsOpen = false; this.credentialValues = {}; this.message = ''; this.draw(); }}><form className="sources-card source-modal-form" onSubmit={(event) => { event.preventDefault(); void this.run(async () => {
|
||||
const source = this.selected!;
|
||||
for (const field of source.descriptor?.credentialKeys ?? []) {
|
||||
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.busy || this.message) && <p className="notice" tabIndex={-1} role={this.busy ? 'status' : 'alert'}>{this.busy ? '正在保存…' : this.message}</p>}{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>)}
|
||||
</div><footer className="source-modal-actions"><Button className="primary" type="submit" disabled={this.busy}>保存个人凭据</Button></footer></form></Modal>}
|
||||
|
||||
</>;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,13 @@ async function setup() {
|
||||
}
|
||||
|
||||
describe('requests across an account change', () => {
|
||||
it('keeps the reader signed in when an OPDS source requires separate credentials', async () => {
|
||||
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 });
|
||||
expect(env.api.currentSession()?.user.id).toBe('a');
|
||||
expect(env.transport.requests).toHaveLength(1);
|
||||
});
|
||||
it('rotates an expired token before loading chapter bytes through the same guarded request path', async () => {
|
||||
const env = await setup();
|
||||
env.transport.respondWith((request) => {
|
||||
@@ -91,3 +98,13 @@ describe('requests across an account change', () => {
|
||||
expect(await old).toMatchObject({ kind: 'aborted' });
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts and persists the nested registration session for authenticated requests and reload', async () => {
|
||||
const env = await setup(), registered = session('new-admin');
|
||||
registered.user.role = 'admin';
|
||||
env.transport.respondWith(request => request.url.endsWith('/register') ? ok({ user: registered.user, session: registered }) : ok({ user: registered.user }));
|
||||
expect(await env.api.register('new-admin', 'password123')).toEqual(registered);
|
||||
expect(env.stored()).toEqual(registered);
|
||||
await env.api.restore(); await env.api.me();
|
||||
expect(env.transport.requests.at(-1)?.headers.authorization).toBe('Bearer access-new-admin');
|
||||
});
|
||||
@@ -102,6 +102,55 @@ async function click(screen: ReaderScreen, label: string): Promise<void> {
|
||||
}
|
||||
|
||||
describe('chapter publication reading', () => {
|
||||
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 => {
|
||||
if (request.url.endsWith('/source-options')) return { status: 200, headers: {}, json: { canSwitch: true } };
|
||||
if (request.url.endsWith('/manifest')) return { status: 200, headers: {}, json: manifest(content(['a', 'b'])) };
|
||||
if (request.url.endsWith('/alternatives')) return { status: 200, headers: {}, json: { items: [{ ref: 'other-book', title: '同一本书', authors: ['作者'], description: '其它书源' }] } };
|
||||
if (request.url.endsWith('/switch-preview')) return { status: 200, headers: {}, json: { chapters: [{ id: 'x', title: '另一个章节' }] } };
|
||||
if (request.url.endsWith('/switch-source')) return failSwitch
|
||||
? { status: 502, headers: {}, json: { error: { code: 'SOURCE_ERROR', message: '正文获取失败' } } }
|
||||
: { status: 200, headers: {}, json: { content: content(['x', 'y'], 'r2'), href: 'chapter:x' } };
|
||||
if (request.url.includes('/assets?')) return { status: 200, headers: {}, bytes: new TextEncoder().encode(new URL(request.url, 'http://test').searchParams.get('ref')!) };
|
||||
return { status: 200, headers: {}, json: { progress: null } };
|
||||
});
|
||||
await env.screen.open(book); await click(env.screen, '目录'); await click(env.screen, '切换书源');
|
||||
await vi.waitFor(() => expect(env.screen.element.textContent).toContain('其它书源')); await click(env.screen, '查看此源目录');
|
||||
await vi.waitFor(() => expect(env.screen.element.querySelector('section[aria-label="切换书源"] select')).not.toBeNull());
|
||||
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, '确认换源并阅读');
|
||||
await vi.waitFor(() => expect(env.screen.element.textContent).toContain('正文获取失败'));
|
||||
expect(body(env.screen)?.textContent).toContain('resource:r1:a');
|
||||
expect(env.screen.element.querySelectorAll('.toc-list li')).toHaveLength(2);
|
||||
failSwitch = false; await click(env.screen, '确认换源并阅读');
|
||||
await vi.waitFor(() => expect(body(env.screen)?.textContent).toContain('resource:r2:x'));
|
||||
await vi.waitFor(() => expect(env.offline.current.progress[book.id]?.locator).toContain('chapter:x'));
|
||||
expect(env.screen.element.textContent).toContain('已切换书源');
|
||||
});
|
||||
it('renders cached rich chapters with images without requesting an external origin, including offline reopening', async () => {
|
||||
const kv = new MemoryKv(); const blobs = new MemoryBlobs();
|
||||
const first = await setup(kv, blobs);
|
||||
const rich = content(['rich']); rich.kind = 'reflowable';
|
||||
delete rich.items[0]!.format; rich.items[0]!.mediaType = 'text/html; charset=utf-8';
|
||||
const html = '<h2>插图章节</h2><p>含有<strong>强调</strong>的正文。</p><img alt="插图" src="data:image/png;base64,iVBORw0KGgo=">';
|
||||
first.transport.respondWith((request) => {
|
||||
if (request.url.endsWith('/manifest')) return { status: 200, headers: {}, json: manifest(rich) };
|
||||
if (request.url.includes('/assets?')) return { status: 200, headers: {}, bytes: new TextEncoder().encode(html) };
|
||||
return { status: 200, headers: {}, json: { progress: null } };
|
||||
});
|
||||
await first.screen.open(book);
|
||||
expect(body(first.screen)?.querySelector('strong')?.textContent).toBe('强调');
|
||||
expect(body(first.screen)?.querySelector('img')?.getAttribute('src')).toMatch(/^data:image\/png/);
|
||||
first.screen.dispose(); screens.splice(screens.indexOf(first.screen), 1); await first.offline.flush();
|
||||
const second = await setup(kv, blobs); second.transport.failWith(new ApiError('offline', 'offline'));
|
||||
await second.screen.open(book);
|
||||
expect(body(second.screen)?.querySelector('h2')?.textContent).toBe('插图章节');
|
||||
expect(body(second.screen)?.querySelector('img')?.getAttribute('src')).toMatch(/^data:image\/png/);
|
||||
expect(second.transport.requests.some((request) => request.url.includes('/assets?'))).toBe(false);
|
||||
});
|
||||
it('ignores delayed chapter callbacks after disposal and an offline account switch', async () => {
|
||||
const env = await setup();
|
||||
let release!: () => void;
|
||||
|
||||
@@ -121,7 +121,7 @@ function makeScreen(
|
||||
api,
|
||||
offline: new OfflineStore(platform.kv),
|
||||
settings: { ...DEFAULT_APP_SETTINGS },
|
||||
onSettingsChange: (patch) => calls.push(`settings:${JSON.stringify(patch)}`),
|
||||
admin: true,
|
||||
path: '',
|
||||
page: 1,
|
||||
search: '',
|
||||
@@ -256,7 +256,8 @@ describe('the browsing half of the library', () => {
|
||||
);
|
||||
void walker;
|
||||
void walkerCalls;
|
||||
expect(screen.element.querySelector('.manager-crumb')).not.toBeNull();
|
||||
expect(screen.element.querySelector('.library-header h1')?.textContent).toBe('书库');
|
||||
expect(screen.element.querySelector('.library-path')).toBeNull();
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -337,27 +338,16 @@ describe('the browsing half of the library', () => {
|
||||
expect(payload.bookIds).toEqual(['b1']);
|
||||
});
|
||||
|
||||
it('offers 上传 on the browsing page, because this is where a reader lands', async () => {
|
||||
const transport = new FakeTransport();
|
||||
serveFolder(transport, listing({ entries: [entry('a.epub')], total: 1, files: 1 }), [book(1)]);
|
||||
const { screen } = makeScreen(transport);
|
||||
await screen.open('', 1, '');
|
||||
// The empty state sends the reader here, so the fix has to be here too. It is a
|
||||
// write, and the file page's own upload is the same call — one endpoint, two
|
||||
// pages that can open it.
|
||||
expect(screen.element.querySelector('.panel-header [aria-label="上传书籍"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides 上传 on a read-only mount', async () => {
|
||||
const transport = new FakeTransport();
|
||||
serveFolder(transport, listing({ writable: false, entries: [entry('a.epub')], total: 1, files: 1 }), [book(1)]);
|
||||
const { screen } = makeScreen(transport);
|
||||
await screen.open('', 1, '');
|
||||
// `:ro` is the documented deployment, so the common case is "this page can only
|
||||
// look" — and a button that can only answer 403 teaches the reader to distrust
|
||||
// every other button.
|
||||
const upload = screen.element.querySelector<HTMLElement>('[aria-label="上传书籍"]')!;
|
||||
expect(upload.hasAttribute('hidden')).toBe(true);
|
||||
it('keeps upload and shelf settings out of browsing for admins and members', async () => {
|
||||
for (const admin of [true, false]) {
|
||||
const transport = new FakeTransport(); serveFolder(transport, listing(), []);
|
||||
const { screen } = makeScreen(transport, { admin }); await screen.open('', 1, '');
|
||||
expect(screen.element.querySelector('[aria-label="上传书籍"]')).toBeNull();
|
||||
expect(screen.element.querySelector('input[type=file]')).toBeNull();
|
||||
expect(screen.element.querySelector('.shelf-settings')).toBeNull();
|
||||
expect(screen.element.textContent?.includes('文件管理')).toBe(admin);
|
||||
screen.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('offers the file manager from the empty state when a folder has no books', async () => {
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('library manager', () => {
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0]!.method).toBe('GET');
|
||||
// The mkdir button is the only write affordance outside selection mode.
|
||||
const buttons = [...screen.element.querySelectorAll<HTMLButtonElement>('.icon-button')];
|
||||
const buttons = [...screen.element.querySelectorAll<HTMLButtonElement>('button')];
|
||||
const mkdir = buttons.find((button) => button.getAttribute('aria-label') === '新建文件夹');
|
||||
expect(mkdir?.hidden).toBe(true);
|
||||
});
|
||||
@@ -428,7 +428,7 @@ describe('uploading from the manager', () => {
|
||||
transport.respondWithBoth(listing({ writable: false, entries: [] }));
|
||||
const { screen } = makeScreen(transport);
|
||||
await screen.open('', 1);
|
||||
const buttons = [...screen.element.querySelectorAll<HTMLButtonElement>('.icon-button')];
|
||||
const buttons = [...screen.element.querySelectorAll<HTMLButtonElement>('button')];
|
||||
expect(buttons.find((button) => button.getAttribute('aria-label') === '上传书籍')?.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -756,3 +756,11 @@ describe('error classification', () => {
|
||||
expect(new ApiError('unauthorized', 'token expired', 'TOKEN_EXPIRED', 401).isAuthFailure).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a folder menu without also navigating into that folder', async () => {
|
||||
const transport = new FakeTransport(); transport.respondWithBoth(listing({ entries: [entry({ name: '子目录', type: 'dir' })], total: 1, dirs: 1 }));
|
||||
const { screen, calls } = makeScreen(transport); await screen.open('', 1);
|
||||
screen.element.querySelector<HTMLButtonElement>('.manager-more')!.click();
|
||||
expect(screen.element.querySelector('.dialog-list')).not.toBeNull(); expect(calls).toEqual([]);
|
||||
screen.dispose();
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { PluginPageScreen } from '../src/ui/plugin-page-screen.tsx';
|
||||
import { SourcesScreen } from '../src/ui/sources-screen.tsx';
|
||||
import { ReaderApi } from '../src/api/client.ts';
|
||||
import { FakeTransport, makePlatform, bodyText } from './helpers/env.ts';
|
||||
import { parseRoute, routeHash, sameRoute, parentOf } from '../src/ui/router.ts';
|
||||
const screens: Array<{ dispose(): void }> = [];
|
||||
afterEach(() => { screens.splice(0).forEach(screen => screen.dispose()); document.body.replaceChildren(); });
|
||||
function fixture() {
|
||||
const transport = new FakeTransport();
|
||||
const api = new ReaderApi(makePlatform(transport), { async load() { return null; }, async save() {}, async clear() {} });
|
||||
return { api, transport };
|
||||
}
|
||||
it('renders generic plugin forms as text and submits typed values on an independent route', async () => {
|
||||
const { api, transport } = fixture();
|
||||
const route = { name: 'plugin-page' as const, pluginId: 'third.party', pageId: 'settings' };
|
||||
expect(parseRoute(routeHash(route))).toEqual(route);
|
||||
expect(sameRoute(route, { ...route, pageId: 'other' })).toBe(false);
|
||||
expect(parentOf(route)).toEqual({ name: 'sources' });
|
||||
const screen = new PluginPageScreen({ api, ...route, onBack() {}, onSignedOut() {} });
|
||||
screens.push(screen); document.body.append(screen.element);
|
||||
transport.respondWith(() => ({ status: 200, headers: {}, json: { title: '扩展页面', description: '<script>evil()</script>',
|
||||
forms: [{ id: 'subscribe', title: '订阅', submit: '保存', values: { id: 'opaque' }, fields: [
|
||||
{ key: 'url', label: '链接', type: 'text', required: true }, { key: 'enabled', label: '启用', type: 'boolean', value: true },
|
||||
{ key: 'interval', label: '间隔', type: 'number', value: 60 }
|
||||
] }] } }));
|
||||
await screen.show(); expect(screen.element.querySelector('script')).toBeNull();
|
||||
const url = screen.element.querySelector<HTMLInputElement>('input[type=text]')!; url.value = 'https://source.test'; url.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
screen.element.querySelector<HTMLButtonElement>('button[type=submit]')!.click();
|
||||
await vi.waitFor(() => expect(transport.requests.filter(request => request.method === 'POST')).toHaveLength(1));
|
||||
expect(JSON.parse(bodyText(transport.requests.find(request => request.method === 'POST')))).toEqual({ action: 'subscribe', values: { id: 'opaque', url: 'https://source.test', enabled: true, interval: 60 } });
|
||||
});
|
||||
it('uses provider-defined search filter keys and retains filters across pagination', async () => {
|
||||
const { api, transport } = fixture();
|
||||
const descriptor = { id: 'custom', pluginId: 'third.party', capabilities: ['search', 'search.filters'] };
|
||||
transport.respondWith(request => {
|
||||
let json: unknown = {};
|
||||
if (request.url.endsWith('/sources/types')) json = { types: [descriptor] };
|
||||
else if (request.url.endsWith('/sources')) json = { sources: [{ ...descriptor, id: 's1', name: '第三方', sourceType: 'custom', enabled: true, descriptor }] };
|
||||
else if (request.url.endsWith('/subscriptions')) json = { subscriptions: [] };
|
||||
else if (request.url.endsWith('/search-filters')) json = [{ key: 'region', label: '地区', type: 'select', options: [{ value: '', label: '全部' }, { value: 'asia', label: '亚洲' }] }];
|
||||
else if (request.url.includes('/search?')) json = { items: [], nextCursor: 'next' };
|
||||
return { status: 200, headers: {}, json };
|
||||
});
|
||||
const screen = new SourcesScreen({ api, admin: false, onOpen() {}, onBack() {}, onSignedOut() {} }); screens.push(screen); document.body.append(screen.element); await screen.show();
|
||||
const click = (label: string) => [...screen.element.querySelectorAll('button')].find(button => button.textContent === label)!.click();
|
||||
const picker = screen.element.querySelector<HTMLSelectElement>('[aria-label="选择来源"]')!;
|
||||
picker.value = 's1'; picker.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(screen.element.querySelector('.sources-search select')).not.toBeNull());
|
||||
const select = screen.element.querySelector<HTMLSelectElement>('.sources-search select')!; select.value = 'asia'; select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
const input = screen.element.querySelector<HTMLInputElement>('input[type=search]')!; input.value = '测试'; input.dispatchEvent(new Event('input', { bubbles: true })); click('搜索');
|
||||
await vi.waitFor(() => expect(screen.element.textContent).toContain('下一页')); click('下一页');
|
||||
await vi.waitFor(() => expect(transport.requests.filter(request => request.url.includes('/search?'))).toHaveLength(2));
|
||||
for (const request of transport.requests.filter(request => request.url.includes('/search?'))) expect(new URL(request.url, 'http://test').searchParams.get('filters')).toBe('{"region":"asia"}');
|
||||
});
|
||||
|
||||
it('keeps tab drafts and refresh selection, follows action navigation and uses source instance endpoints', async () => {
|
||||
const { api, transport } = fixture();
|
||||
const route = { name: 'source-page' as const, sourceId: 'custom/one', pageId: 'settings' };
|
||||
expect(parseRoute(routeHash(route))).toEqual(route);
|
||||
expect(sameRoute(route, { ...route, sourceId: 'two' })).toBe(false);
|
||||
expect(parentOf(route)).toEqual({ name: 'sources' });
|
||||
transport.respondWith(request => ({ status: 200, headers: {}, json: {
|
||||
title: '我的书源', forms: [], ...(request.method === 'POST' ? { activeTab: 'sources', notice: '已保存' } : {}), tabs: [
|
||||
{ id: 'sources', title: '书源管理', forms: [], sections: [{ title: '列表', items: [{ title: 'A', collapsible: true,
|
||||
forms: [{ id: 'save', title: '', submit: '保存', fields: [] }] }] }] },
|
||||
{ id: 'import', title: '导入书源', forms: [{ id: 'import', title: 'JSON', submit: '导入', fields: [{ key: 'json', label: '规则', type: 'textarea' }] }] },
|
||||
],
|
||||
} }));
|
||||
const screen = new PluginPageScreen({ api, ...route, onBack() {}, onSignedOut() {} }); screens.push(screen); document.body.append(screen.element);
|
||||
await screen.show();
|
||||
const tabs = () => [...screen.element.querySelectorAll<HTMLButtonElement>('[role=tab]')];
|
||||
expect(screen.element.querySelector('.extension-actions')?.hasAttribute('hidden')).toBe(true);
|
||||
(screen.element.querySelector('[aria-expanded]') as HTMLButtonElement).click();
|
||||
expect(screen.element.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('true');
|
||||
tabs()[1]!.click();
|
||||
const textarea = screen.element.querySelector('textarea')!; textarea.value = '{"draft":true}'; textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
tabs()[0]!.click(); expect(screen.element.querySelector('textarea')).toBeNull();
|
||||
tabs()[1]!.click(); expect(screen.element.querySelector('textarea')!.value).toBe('{"draft":true}');
|
||||
await screen.show(); expect(screen.element.querySelector('[aria-selected=true]')?.textContent).toBe('导入书源');
|
||||
tabs()[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
|
||||
expect(document.activeElement?.textContent).toBe('书源管理');
|
||||
tabs()[1]!.click(); screen.element.querySelector<HTMLButtonElement>('button[type=submit]')!.click();
|
||||
await vi.waitFor(() => expect(screen.element.querySelector('[aria-selected=true]')?.textContent).toBe('书源管理'));
|
||||
expect(screen.element.querySelector('[role=status]')?.textContent).toBe('已保存');
|
||||
expect(transport.requests.every(request => request.url.endsWith('/sources/custom%2Fone/pages/settings'))).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves unrelated tab drafts after actions and requires inline confirmation before deletion', async () => {
|
||||
const { api, transport } = fixture();
|
||||
const payload = { title: '实例', forms: [], tabs: [
|
||||
{ id: 'rules', title: '规则', forms: [{ id: 'delete', title: '', submit: '删除', confirm: '删除当前规则?', fields: [] }] },
|
||||
{ id: 'draft', title: '草稿', forms: [{ id: 'import', title: '', submit: '保存草稿', fields: [{ key: 'json', label: 'JSON', type: 'textarea' }] }] },
|
||||
] };
|
||||
transport.respondWith(request => ({ status: 200, headers: {}, json: { ...payload, ...(request.method === 'POST' ? { notice: '删除成功' } : {}) } }));
|
||||
const screen = new PluginPageScreen({ api, sourceId: 'one', pageId: 'settings', onBack() {}, onSignedOut() {} }); screens.push(screen); document.body.append(screen.element);
|
||||
await screen.show();
|
||||
const tab = (name: string) => [...screen.element.querySelectorAll<HTMLButtonElement>('[role=tab]')].find(button => button.textContent === name)!;
|
||||
const button = (name: string) => [...screen.element.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === name)!;
|
||||
tab('草稿').click(); const area = screen.element.querySelector('textarea')!; area.value = 'unsaved'; area.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
tab('规则').click(); button('删除').click();
|
||||
expect(screen.element.querySelector('[role=group]')?.textContent).toContain('删除当前规则?');
|
||||
expect(transport.requests.filter(request => request.method === 'POST')).toHaveLength(0);
|
||||
button('取消').click(); expect(screen.element.querySelector('[role=group]')).toBeNull();
|
||||
button('删除').click(); button('确认删除').click();
|
||||
await vi.waitFor(() => expect(screen.element.querySelector('[role=status]')?.textContent).toBe('删除成功'));
|
||||
expect(document.activeElement?.getAttribute('role')).toBe('status');
|
||||
tab('草稿').click(); expect(screen.element.querySelector('textarea')?.value).toBe('unsaved');
|
||||
});
|
||||
@@ -502,3 +502,9 @@ describe('router navigation', () => {
|
||||
expect(router.current()).toEqual({ name: 'book', bookId: 'a' });
|
||||
});
|
||||
});
|
||||
|
||||
it('backs out of file management through its folder hierarchy and then browsing', () => {
|
||||
expect(parentOf(parseRoute('#/library/files/a/b'))).toMatchObject({ name: 'library', view: 'files', path: 'a', page: 1 });
|
||||
expect(parentOf(parseRoute('#/library/files/a'))).toMatchObject({ name: 'library', view: 'files', path: '', page: 1 });
|
||||
expect(parentOf(parseRoute('#/library/files'))).toMatchObject({ name: 'library', view: 'browse', path: '', page: 1 });
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { ReaderApi } from '../src/api/client.ts';
|
||||
import { SourcesScreen } from '../src/ui/sources-screen.tsx';
|
||||
import { FakeTransport, makePlatform, bodyText } from './helpers/env.ts';
|
||||
import { parseRoute, routeHash } from '../src/ui/router.ts';
|
||||
|
||||
const opds = { id: 'opds', pluginId: 'reader.opds', builtin: true, label: 'OPDS', version: '1', capabilities: ['browse', 'search'],
|
||||
credentialKeys: [{ key: 'password', label: 'OPDS 密码' }], configSchema: { required: ['url'], properties: { url: { type: 'string', title: 'OPDS 地址' } } } };
|
||||
const source = { id: 'source-1', name: '远程书库', pluginId: 'reader.opds', sourceType: 'opds', enabled: true, descriptor: opds, config: { url: 'https://books.test' } };
|
||||
const screens: SourcesScreen[] = [];
|
||||
// jsdom has no top-layer dialog implementation; native focus containment is covered in Chromium.
|
||||
beforeAll(() => {
|
||||
HTMLDialogElement.prototype.showModal = function () { this.open = true; this.querySelector<HTMLElement>('[autofocus]')?.focus(); };
|
||||
HTMLDialogElement.prototype.close = function () { this.open = false; };
|
||||
});
|
||||
async function chooseSource(root: HTMLElement) {
|
||||
const select = root.querySelector<HTMLSelectElement>('[aria-label="选择来源"]')!;
|
||||
select.value = 'source-1'; select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(root.querySelector('[role=status]')?.textContent ?? '').not.toMatch(/正在处理|正在保存/));
|
||||
}
|
||||
afterEach(() => { screens.splice(0).forEach((screen) => screen.dispose()); document.body.replaceChildren(); });
|
||||
async function setup(admin = true) {
|
||||
const transport = new FakeTransport();
|
||||
const api = new ReaderApi(makePlatform(transport), { async load() { return null; }, async save() {}, async clear() {} });
|
||||
const onOpen = vi.fn();
|
||||
const screen = new SourcesScreen({ api, admin, onOpen, onBack() {}, onSignedOut() {} });
|
||||
screens.push(screen); document.body.append(screen.element);
|
||||
transport.respondWith((request) => {
|
||||
let json: unknown = {};
|
||||
if (request.url.endsWith('/sources/types')) json = { types: [opds] };
|
||||
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 }] };
|
||||
else if (request.url.includes('/browse?') || request.url.includes('/search?')) json = { items: [{ ref: 'book-ref', title: '来源小说', options: [{ id: 'epub', label: '下载 EPUB' }] }] };
|
||||
else if (request.url.endsWith('/acquire')) json = { kind: 'ready', publicationId: 'book1' };
|
||||
else if (request.url.endsWith('/books/book1')) json = { book: { id: 'book1', title: '来源小说' } };
|
||||
return { status: 200, headers: {}, json };
|
||||
});
|
||||
await screen.show();
|
||||
return { api, screen, transport, onOpen };
|
||||
}
|
||||
function button(root: HTMLElement, label: string) {
|
||||
const found = [...root.querySelectorAll('button')].find((element) => element.textContent?.trim() === label || element.getAttribute('aria-label') === label);
|
||||
expect(found, label).toBeDefined(); return found!;
|
||||
}
|
||||
async function click(root: HTMLElement, label: string) {
|
||||
button(root, label).click();
|
||||
await vi.waitFor(() => expect(root.querySelector('[role=status]')?.textContent ?? '').not.toMatch(/正在处理|正在保存/));
|
||||
}
|
||||
function input(root: HTMLElement, label: string, value: string) {
|
||||
const element = [...root.querySelectorAll('label')].find((item) => item.textContent?.startsWith(label))?.querySelector('input');
|
||||
expect(element, label).toBeDefined(); element!.value = value; element!.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('sources and subscriptions UI', () => {
|
||||
it('provides a stable route and separates member browsing from admin configuration', async () => {
|
||||
expect(parseRoute('#/sources')).toEqual({ name: 'sources' }); expect(routeHash({ name: 'sources' })).toBe('#/sources');
|
||||
const { screen, transport } = await setup(false);
|
||||
expect(screen.element.textContent).not.toContain('插件管理'); expect(screen.element.textContent).not.toContain('添加来源');
|
||||
expect(transport.requests.some((request) => request.url.endsWith('/plugins'))).toBe(false);
|
||||
await chooseSource(screen.element); expect(screen.element.textContent).toContain('来源小说');
|
||||
await click(screen.element, '登录凭据'); input(screen.element, 'OPDS 密码', 'private'); await click(screen.element, '保存个人凭据');
|
||||
expect(bodyText(transport.requests.find((request) => request.url.endsWith('/credentials/password')))).toBe('{"value":"private"}');
|
||||
expect(screen.element.querySelector('dialog')).toBeNull();
|
||||
await click(screen.element, '登录凭据'); expect(screen.element.querySelector<HTMLInputElement>('input[type=password]')!.value).toBe('');
|
||||
});
|
||||
it('creates OPDS configuration, acquires books and enables automatic updates', async () => {
|
||||
const { screen, transport, onOpen } = await setup();
|
||||
await click(screen.element, '书源管理'); await click(screen.element, '添加来源'); input(screen.element, '名称', '家庭书库'); input(screen.element, 'OPDS 地址', 'https://home.test/opds');
|
||||
await click(screen.element, '保存来源');
|
||||
expect(JSON.parse(bodyText(transport.requests.find((request) => request.url.endsWith('/sources') && request.method === 'POST')))).toMatchObject({
|
||||
name: '家庭书库', pluginId: 'reader.opds', config: { url: 'https://home.test/opds' },
|
||||
});
|
||||
await click(screen.element, '搜书'); await chooseSource(screen.element); await click(screen.element, '下载 EPUB');
|
||||
expect(JSON.parse(bodyText(transport.requests.find((request) => request.url.endsWith('/acquire'))))).toEqual({ entryRef: 'book-ref', optionId: 'epub' });
|
||||
await click(screen.element, '阅读《来源小说》'); expect(onOpen).toHaveBeenCalledWith({ id: 'book1', title: '来源小说' });
|
||||
await click(screen.element, '自动追更'); await click(screen.element, '开启追更');
|
||||
expect(JSON.parse(bodyText(transport.requests.find((request) => request.url.endsWith('/subscription'))))).toEqual({ enabled: true });
|
||||
});
|
||||
it('requires the explicit trust control before installing and offers plugin lifecycle actions', async () => {
|
||||
const { screen, transport } = await setup();
|
||||
await click(screen.element, '插件管理'); expect(button(screen.element, '安装插件').disabled).toBe(true);
|
||||
input(screen.element, '已部署的插件目录或 npm 包', 'npm:external-source');
|
||||
const trusted = screen.element.querySelector<HTMLInputElement>('input[type=checkbox]')!;
|
||||
trusted.checked = true; trusted.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await click(screen.element, '安装插件');
|
||||
expect(JSON.parse(bodyText(transport.requests.find((request) => request.url.endsWith('/plugins') && request.method === 'POST')))).toEqual({ folder: 'npm:external-source', trusted: true });
|
||||
await click(screen.element, '停用'); expect(transport.requests.some((request) => request.url.endsWith('/plugins/remote') && request.method === 'PATCH')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses keyboard tabs and reveals source settings only through management', async () => {
|
||||
const { screen, transport } = await setup();
|
||||
const tabs = [...screen.element.querySelectorAll<HTMLButtonElement>('[role=tab]')];
|
||||
expect(tabs).toHaveLength(4); expect(tabs[0]!.getAttribute('aria-selected')).toBe('true');
|
||||
expect(screen.element.querySelector('[role=tabpanel]')?.getAttribute('aria-labelledby')).toBe(tabs[0]!.id);
|
||||
expect(screen.element.textContent).not.toContain('基本设置');
|
||||
await click(screen.element, '书源管理');
|
||||
await click(screen.element, '管理'); expect(screen.element.textContent).toContain('基本设置');
|
||||
expect(screen.element.querySelector('.source-manage-trigger')?.getAttribute('aria-expanded')).toBe('true');
|
||||
screen.element.querySelector('.source-management')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(screen.element.querySelector('.source-management')).toBeNull();
|
||||
expect(document.activeElement?.textContent).toBe('管理');
|
||||
await click(screen.element, '管理'); await click(screen.element, '暂停');
|
||||
expect(JSON.parse(bodyText(transport.requests.find(request => request.url.endsWith('/sources/source-1') && request.method === 'PATCH')))).toEqual({ enabled: false });
|
||||
expect(screen.element.querySelector('.source-management')).toBeNull();
|
||||
tabs[0]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(document.activeElement?.textContent).toBe('书源管理');
|
||||
expect(tabs[1]!.getAttribute('aria-selected')).toBe('true');
|
||||
tabs[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
|
||||
expect(tabs[3]!.getAttribute('aria-selected')).toBe('true');
|
||||
expect(screen.element.querySelector('[role=tabpanel]')?.getAttribute('aria-labelledby')).toBe(tabs[3]!.id);
|
||||
});
|
||||
|
||||
it('keeps failed configuration inside the modal and restores focus after cancel or save', async () => {
|
||||
const { screen, api } = await setup();
|
||||
await click(screen.element, '书源管理');
|
||||
button(screen.element, '添加来源').focus(); await click(screen.element, '添加来源');
|
||||
expect(screen.element.querySelector('main .source-editor')).toBeNull();
|
||||
expect(screen.element.querySelector('dialog')?.open).toBe(true);
|
||||
input(screen.element, '名称', '草稿名称'); input(screen.element, 'OPDS 地址', 'https://draft.test');
|
||||
const save = vi.spyOn(api, 'saveSource').mockRejectedValueOnce(new Error('测试保存失败'));
|
||||
await click(screen.element, '保存来源');
|
||||
expect(screen.element.querySelector('dialog [role=alert]')?.textContent).toBe('测试保存失败');
|
||||
expect(document.activeElement?.getAttribute('role')).toBe('alert');
|
||||
expect(screen.element.querySelector<HTMLInputElement>('input[maxlength]')!.value).toBe('草稿名称');
|
||||
await click(screen.element, '取消');
|
||||
expect(screen.element.querySelector('dialog')).toBeNull();
|
||||
expect(document.activeElement).toBe(button(screen.element, '添加来源'));
|
||||
await click(screen.element, '添加来源');
|
||||
expect(screen.element.querySelector<HTMLInputElement>('input[maxlength]')!.value).toBe('');
|
||||
input(screen.element, '名称', '保存名称'); input(screen.element, 'OPDS 地址', 'https://saved.test');
|
||||
save.mockRestore(); await click(screen.element, '保存来源');
|
||||
expect(screen.element.querySelector('dialog')).toBeNull();
|
||||
expect(document.activeElement).toBe(button(screen.element, '添加来源'));
|
||||
});
|
||||
|
||||
it('separates browsing from management and removes paused sources from the picker', async () => {
|
||||
const { screen, api } = await setup();
|
||||
await chooseSource(screen.element);
|
||||
input(screen.element, '搜索书籍', '保留的关键词'); await click(screen.element, '搜索');
|
||||
await click(screen.element, '书源管理');
|
||||
expect(screen.element.querySelector('.source-catalog')).toBeNull();
|
||||
expect(screen.element.querySelector('.sources-search')).toBeNull();
|
||||
await click(screen.element, '搜书');
|
||||
expect(screen.element.querySelector<HTMLInputElement>('input[type=search]')!.value).toBe('保留的关键词');
|
||||
expect(screen.element.textContent).toContain('来源小说');
|
||||
expect(screen.element.querySelector('.sources-list')).toBeNull();
|
||||
await click(screen.element, '书源管理'); await click(screen.element, '管理');
|
||||
vi.spyOn(api, 'sources').mockResolvedValue([{ ...source, descriptor: { ...opds, capabilities: ['browse', 'search'] }, enabled: false }]);
|
||||
await click(screen.element, '暂停'); await click(screen.element, '搜书');
|
||||
expect(screen.element.querySelector('.source-catalog')).toBeNull();
|
||||
expect(screen.element.querySelector('[aria-label="选择来源"]')!.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prevents closing while saving and preserves the form on failure', async () => {
|
||||
const { screen, api } = await setup();
|
||||
await click(screen.element, '书源管理'); await click(screen.element, '添加来源');
|
||||
input(screen.element, '名称', '正在保存'); input(screen.element, 'OPDS 地址', 'https://test');
|
||||
let fail!: (error: Error) => void;
|
||||
vi.spyOn(api, 'saveSource').mockImplementation(() => new Promise((_, reject) => { fail = reject; }));
|
||||
button(screen.element, '保存来源').click();
|
||||
const dialog = screen.element.querySelector('dialog')!;
|
||||
expect(button(screen.element, '关闭弹窗').disabled).toBe(true);
|
||||
const cancel = new Event('cancel', { cancelable: true }); dialog.dispatchEvent(cancel);
|
||||
expect(cancel.defaultPrevented).toBe(true); expect(dialog.open).toBe(true);
|
||||
fail(new Error('请稍后重试'));
|
||||
await vi.waitFor(() => expect(dialog.querySelector('[role=alert]')?.textContent).toBe('请稍后重试'));
|
||||
dialog.dispatchEvent(new Event('cancel', { cancelable: true }));
|
||||
expect(screen.element.querySelector('dialog')).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { chromium } from 'playwright';
|
||||
import { createReviewServer } from './server.mjs';
|
||||
|
||||
const server = createReviewServer({ port: 0 });
|
||||
await server.listen();
|
||||
const base = 'http://127.0.0.1:' + server.server.address().port;
|
||||
const browser = await chromium.launch({ headless: true, ...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}) });
|
||||
try {
|
||||
await mkdir('docs/ui-review', { recursive: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
const errors = []; page.on('pageerror', error => errors.push(error.message));
|
||||
let empty = true, writable = true, files = 0;
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if ((empty || url.searchParams.get('search') === '不存在的关键词') && url.pathname === '/api/v1/books') return route.fulfill({ json: { items: [], total: 0, page: 1, pageSize: 60 } });
|
||||
if (empty && url.pathname === '/api/v1/library/browse') return route.fulfill({ json: {
|
||||
path: '', crumbs: [{ name: '书库', path: '' }], parent: null, entries: [], total: files, files, dirs: 0, size: 0, writable,
|
||||
} });
|
||||
if (!empty && url.pathname === '/api/v1/library/browse') {
|
||||
const response = await route.fetch(), json = await response.json();
|
||||
const file = json.entries.find(entry => entry.type === 'file');
|
||||
if (file) file.name = 'DanXiaoShiManHuaYuZhou_胆小师漫画宇宙_第一卷_完整修订版.epub';
|
||||
return route.fulfill({ json });
|
||||
}
|
||||
if (url.pathname === '/api/v1/sources/types') return route.fulfill({ json: { types: [] } });
|
||||
if (url.pathname === '/api/v1/sources') return route.fulfill({ json: { sources: [] } });
|
||||
if (url.pathname === '/api/v1/plugins') return route.fulfill({ json: { plugins: [] } });
|
||||
if (url.pathname === '/api/v1/subscriptions') return route.fulfill({ json: { subscriptions: [] } });
|
||||
return route.continue();
|
||||
});
|
||||
const button = name => page.getByRole('button', { name, exact: true });
|
||||
async function shot(name) {
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, name + ' page overflow');
|
||||
if (await page.locator('.collection-links').count()) assert.equal(await page.locator('.collection-links').evaluate(el => el.scrollWidth > el.clientWidth), false, name + ' navigation overflow');
|
||||
await page.screenshot({ path: 'docs/ui-review/collections-' + name + '.png', animations: 'disabled' });
|
||||
}
|
||||
await page.goto(base);
|
||||
await page.locator('input[autocomplete=username]').fill('review'); await page.locator('input[type=password]').fill('password12');
|
||||
await page.locator('button[type=submit]').click(); await page.getByText('书架还没有书', { exact: true }).waitFor();
|
||||
await button('刷新').click(); await page.getByText('书架还没有书', { exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole('group', { name: '排序方式' }).count(), 0);
|
||||
for (const width of [320, 390, 1280]) { await page.setViewportSize({ width, height: 844 }); await shot('shelf-empty-' + width); }
|
||||
await button('去搜书').click(); await page.getByRole('tab', { name: '搜书', exact: true }).waitFor();
|
||||
await page.goto(base + '/#/shelf'); await button('打开书库').click();
|
||||
await page.getByText('书库还没有书', { exact: true }).waitFor();
|
||||
assert.equal(await page.getByText('0 本', { exact: true }).count(), 0);
|
||||
for (const width of [320, 390, 1280]) { await page.setViewportSize({ width, height: 844 }); await shot('library-empty-' + width); }
|
||||
assert.equal(await button('上传书籍').count(), 0);
|
||||
assert.equal(await page.locator('.library-header button[aria-label^=书架设置]').count(), 0);
|
||||
await page.evaluate(() => document.documentElement.dataset.theme = 'dark'); await shot('library-empty-dark');
|
||||
await page.evaluate(() => document.documentElement.dataset.theme = 'light');
|
||||
writable = false; await page.reload(); await page.getByText('前往文件管理添加书籍,再回到这里浏览。').waitFor();
|
||||
assert.equal(await button('上传书籍').count(), 0); assert.equal(await button('上传第一本书').count(), 0);
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await shot('library-readonly');
|
||||
files = 3; await page.reload(); await page.getByText('这里有 3 个文件,可在文件管理中查看识别结果。').waitFor();
|
||||
await page.locator('.empty-actions').getByRole('button', { name: '文件管理', exact: true }).click();
|
||||
await page.locator('.library-files-screen').waitFor();
|
||||
await page.getByText('当前目录只读', { exact: true }).waitFor();
|
||||
assert.equal(await button('上传书籍').count(), 0);
|
||||
await button('返回').click(); await page.waitForURL('**/#/library');
|
||||
empty = false; await page.goto(base + '/#/shelf'); await page.locator('.book-grid .book-card').first().waitFor(); await shot('shelf-books');
|
||||
for (const width of [320, 390, 1280]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await page.locator('.shelf-head button[aria-label^=书架设置]').click();
|
||||
const close = page.locator('.shelf-settings button[aria-label="关闭"]');
|
||||
await page.locator('.shelf-settings .panel-body').evaluate(el => { el.scrollTop = 0; });
|
||||
await shot('shelf-settings-' + width);
|
||||
assert.equal(await close.evaluate(el => { const r = el.getBoundingClientRect(), p = el.closest('.panel-header').getBoundingClientRect(); return p.right - r.right <= 20 && r.top >= p.top && r.bottom <= p.bottom; }), true);
|
||||
if (width >= 1280) {
|
||||
const desktopPanel = await page.locator('.shelf-settings').evaluate(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { rightGap: innerWidth - r.right, width: r.width, top: r.top, bottom: innerHeight - r.bottom };
|
||||
});
|
||||
assert.ok(desktopPanel.width >= 400 && desktopPanel.width <= 450, 'desktop settings should be a compact side panel');
|
||||
assert.ok(desktopPanel.rightGap <= 20 && desktopPanel.top >= 10 && desktopPanel.bottom >= 10, 'desktop settings should sit inside the viewport');
|
||||
assert.equal(await page.locator('.shelf-discovery').evaluate(el => getComputedStyle(el).display), 'flex');
|
||||
}
|
||||
await page.setViewportSize({ width, height: width >= 1024 ? 420 : 568 });
|
||||
const body = page.locator('.shelf-settings .panel-body');
|
||||
await body.evaluate(el => { el.scrollTop = 45; });
|
||||
await shot('shelf-settings-sticky-' + width);
|
||||
const sticky = await body.evaluate(el => {
|
||||
const first = el.querySelector('.section-title').getBoundingClientRect();
|
||||
const bounds = el.getBoundingClientRect();
|
||||
const header = el.previousElementSibling.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(bounds.left + 20, bounds.top + 2);
|
||||
return { gap: first.top - header.bottom, scrolled: el.scrollTop, covered: !!hit?.closest('.section-title') };
|
||||
});
|
||||
assert.ok(sticky.scrolled > 0, 'settings must actually scroll');
|
||||
assert.ok(Math.abs(sticky.gap) <= 1, 'sticky heading must touch the sheet header');
|
||||
assert.equal(sticky.covered, true, 'controls must not show through above the sticky heading');
|
||||
await close.click(); assert.equal(await page.locator('.shelf-settings').isVisible(), false);
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.getByRole('button', { name: '按书名排序', exact: true }).click();
|
||||
await button('书库').click(); await page.locator('.library-browse-grid .book-card').first().waitFor(); await shot('library-books');
|
||||
await page.goto(base + '/#/library/folder'); await page.locator('.library-path').waitFor(); await shot('library-folder');
|
||||
await page.getByRole('button', { name: '书库', exact: true }).click(); await page.waitForURL('**/#/library');
|
||||
await page.getByRole('searchbox').fill('不存在的关键词'); await page.getByRole('searchbox').press('Enter');
|
||||
await page.getByText('没有匹配的书', { exact: true }).waitFor(); await shot('library-no-results'); await button('清除搜索').last().click();
|
||||
await page.goto(base + '/#/library/files'); await page.getByRole('heading', { name: '文件管理', exact: true }).waitFor();
|
||||
for (const width of [320, 390, 1280]) { await page.setViewportSize({ width, height: 844 }); await shot('files-' + width); }
|
||||
const chooser = page.waitForEvent('filechooser'); await button('上传书籍').click(); await chooser;
|
||||
await page.goto(base + '/#/library/files/folder'); await page.reload(); await button('返回').click(); await page.waitForURL('**/#/library/files');
|
||||
await button('返回').click(); await page.waitForURL('**/#/library');
|
||||
// Real desktop sizes, including a narrow window and a full-HD monitor.
|
||||
for (const [width, height] of [[1024, 768], [1366, 768], [1920, 1080]]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(base + '/#/shelf'); await page.locator('.book-grid .book-card').first().waitFor();
|
||||
await shot('shelf-desktop-' + width);
|
||||
const trigger = page.locator('.shelf-head button[aria-label^=书架设置]');
|
||||
await trigger.click();
|
||||
await page.locator('.shelf-settings .panel-body').evaluate(el => { el.scrollTop = 0; });
|
||||
await shot('settings-desktop-' + width);
|
||||
if (width === 1366) {
|
||||
await page.evaluate(() => document.documentElement.dataset.theme = 'dark');
|
||||
await shot('settings-desktop-dark');
|
||||
await page.evaluate(() => document.documentElement.dataset.theme = 'light');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await shot('settings-resized-mobile');
|
||||
assert.equal(await page.locator('.shelf-settings').evaluate(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return Math.abs(r.left) < 1 && Math.abs(r.right - innerWidth) < 1 && Math.abs(r.bottom - innerHeight) < 1;
|
||||
}), true, 'resizing an open desktop panel restores the mobile bottom sheet');
|
||||
await page.setViewportSize({ width, height });
|
||||
}
|
||||
await page.locator('.shelf-settings button[aria-label="关闭"]').press('Escape');
|
||||
assert.equal(await page.locator('.shelf-settings').isVisible(), false);
|
||||
assert.equal(await trigger.evaluate(el => document.activeElement === el), true, 'closing settings restores keyboard focus');
|
||||
await page.goto(base + '/#/library'); await page.locator('.library-browse-grid .book-card').first().waitFor();
|
||||
await shot('library-desktop-' + width);
|
||||
await page.goto(base + '/#/library/files'); await page.locator('.manager-row').first().waitFor();
|
||||
assert.equal(await page.locator('.manager-row').first().evaluate(el => {
|
||||
const name = el.querySelector('.manager-name').getBoundingClientRect();
|
||||
const meta = el.querySelector('.manager-meta').getBoundingClientRect();
|
||||
return meta.left >= name.right && meta.top < name.bottom;
|
||||
}), true, 'desktop file metadata occupies a separate column');
|
||||
await shot('files-desktop-' + width);
|
||||
}
|
||||
// A member cannot reach file management even through a direct link.
|
||||
await page.route('**/api/v1/auth/me', async route => { const response = await route.fetch(); const json = await response.json(); json.user.role = 'member'; await route.fulfill({ json }); });
|
||||
await page.goto(base + '/#/library/files'); await page.reload(); await page.locator('.library-browse-screen').waitFor();
|
||||
assert.equal(await button('文件管理').count(), 0); assert.equal(await button('上传书籍').count(), 0);
|
||||
assert.equal(await page.locator('.manager-upload-input').count(), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS collection UI: permissions, navigation, sticky settings, desktop sidebar and keyboard focus, file columns, dark theme, responsive resize; 320/390/1024/1280/1366/1920px');
|
||||
} finally { await browser.close(); await server.close(); }
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { chromium } from 'playwright';
|
||||
import { BOOK_ID, createReviewServer } from './server.mjs';
|
||||
|
||||
const server = createReviewServer({ port: 0 });
|
||||
await server.listen();
|
||||
const base = 'http://127.0.0.1:' + server.server.address().port;
|
||||
const browser = await chromium.launch({ headless: true, ...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}) });
|
||||
try {
|
||||
await mkdir('docs/ui-review', { recursive: true });
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(base);
|
||||
await page.locator('input[autocomplete=username]').fill('review');
|
||||
await page.locator('input[type=password]').fill('password12');
|
||||
await page.locator('button[type=submit]').click();
|
||||
await page.locator('.shelf-screen').waitFor();
|
||||
|
||||
const surface = () => page.locator('book-content').evaluate(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const flow = el.shadowRoot?.querySelector('.book-flow') ?? el.querySelector('.book-flow');
|
||||
const scroller = el.dataset.paginated === 'true' ? flow : el;
|
||||
return { width: rect.width, height: rect.height, top: rect.top, x: scroller.scrollLeft, y: scroller.scrollTop,
|
||||
page: document.querySelector('.progress-scrubber').value };
|
||||
});
|
||||
async function shot(name) {
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, name + ' horizontal overflow');
|
||||
await page.screenshot({ path: 'docs/ui-review/desktop-reader-' + name + '.png', animations: 'disabled' });
|
||||
}
|
||||
|
||||
for (const [width, height] of [[1024, 768], [1366, 768], [1920, 1080]]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(base + '/#/shelf');
|
||||
await page.goto(base + '/#/book/' + BOOK_ID);
|
||||
await page.waitForFunction(() => Number(document.querySelector('.progress-scrubber')?.max) > 1);
|
||||
await shot('toolbar-' + width);
|
||||
assert.ok(await page.locator('.footer').evaluate(el => el.getBoundingClientRect().width <= 800), 'desktop controls stay within the reading column');
|
||||
assert.equal(await page.locator('.topbar').evaluate(el => {
|
||||
const bar=el.getBoundingClientRect(), book=document.querySelector('book-content').getBoundingClientRect();
|
||||
return Math.abs(bar.left-book.left)<1 && Math.abs(bar.right-book.right)<1;
|
||||
}), true, 'book header aligns with the paper');
|
||||
for (const label of ['目录', '界面', '设置', '朗读']) {
|
||||
const before = await surface();
|
||||
const trigger = page.locator('.reader-actions').getByRole('button', { name: label, exact: true });
|
||||
await trigger.click();
|
||||
const panel = page.locator('.reader-screen .panel');
|
||||
await panel.waitFor();
|
||||
await shot(label + '-' + width);
|
||||
const bounds = await panel.evaluate(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.left, rightGap: innerWidth - r.right, width: r.width, top: r.top, bottomGap: innerHeight - r.bottom,
|
||||
overflow: el.scrollWidth > el.clientWidth };
|
||||
});
|
||||
assert.ok(bounds.width <= 420 && bounds.width >= 360, 'bounded panel width');
|
||||
assert.ok(bounds.top >= 10 && bounds.bottomGap >= 10 && !bounds.overflow, 'panel fits viewport');
|
||||
assert.ok((label === '目录' ? bounds.left : bounds.rightGap) <= 20, 'contents on left, settings on right');
|
||||
assert.deepEqual(await surface(), before, 'opening ' + label + ' preserves the reading position and geometry');
|
||||
await panel.getByRole('button', { name: '关闭', exact: true }).press('Escape');
|
||||
await panel.waitFor({ state: 'detached' });
|
||||
assert.equal(await trigger.evaluate(el => document.activeElement === el), true, 'Escape returns focus to the opener');
|
||||
assert.deepEqual(await surface(), before, 'closing ' + label + ' preserves the reading position');
|
||||
}
|
||||
const before = await surface();
|
||||
await page.mouse.click(width / 2, height / 2);
|
||||
await page.locator('.reader-screen[data-chrome=hidden]').waitFor();
|
||||
await shot('immersive-' + width);
|
||||
assert.deepEqual(await surface(), before, 'hiding chrome preserves the page');
|
||||
await page.mouse.click(width / 2, height / 2);
|
||||
await page.locator('.reader-screen[data-chrome=visible]').waitFor();
|
||||
}
|
||||
await page.locator('.reader-actions').getByRole('button', {name:'设置',exact:true}).click();
|
||||
await page.locator('.panel').getByRole('button',{name:'翻页',exact:true}).click();
|
||||
await page.locator('.panel button[aria-label="关闭"]').click();
|
||||
await page.locator('.progress-scrubber').fill('2');
|
||||
await page.waitForFunction(() => document.querySelector('.progress-page')?.textContent?.includes('第 2/'));
|
||||
await shot('paged');
|
||||
const paged = await surface();
|
||||
await page.locator('.reader-actions').getByRole('button',{name:'目录',exact:true}).click();
|
||||
await page.locator('.panel button[aria-label="关闭"]').press('Escape');
|
||||
assert.deepEqual(await surface(),paged,'side panels preserve the selected page in paged mode');
|
||||
await page.getByRole('button',{name:'夜间',exact:true}).click();
|
||||
await shot('dark');
|
||||
await page.locator('.reader-actions').getByRole('button',{name:'界面',exact:true}).click();
|
||||
await shot('dark-settings');
|
||||
await page.locator('.panel button[aria-label="关闭"]').click();
|
||||
await page.getByRole('button',{name:'日间',exact:true}).click();
|
||||
await page.setViewportSize({ width: 1024, height: 420 });
|
||||
await page.locator('.reader-actions').getByRole('button', { name: '界面', exact: true }).click();
|
||||
const body = page.locator('.panel-body');
|
||||
await body.hover(); await page.mouse.wheel(0, 300);
|
||||
await page.waitForFunction(() => document.querySelector('.panel-body')?.scrollTop > 0);
|
||||
await shot('short-window-scroll');
|
||||
await page.locator('.panel button[aria-label="关闭"]').click();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS desktop reader: 1024/1366/1920px, four panels, Escape/focus, immersive mode, stable page geometry and short-window scrolling');
|
||||
} finally {
|
||||
await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
import { BOOK_ID, createReviewServer } from './server.mjs';
|
||||
const server = createReviewServer({ port: 0 });
|
||||
await server.listen();
|
||||
const base = 'http://127.0.0.1:' + server.server.address().port;
|
||||
const browser = await chromium.launch({ headless: true, ...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}) });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
|
||||
await page.goto(base);
|
||||
await page.locator('input[autocomplete=username]').fill('review');
|
||||
await page.locator('input[type=password]').fill('password12');
|
||||
await page.locator('button[type=submit]').click();
|
||||
await page.locator('.shelf-screen').waitFor();
|
||||
for (const width of [1366, 390]) {
|
||||
await page.setViewportSize({width,height:768});
|
||||
await page.goto(base + '/#/shelf');
|
||||
await page.goto(base + '/#/book/' + BOOK_ID);
|
||||
await page.waitForFunction(() => Number(document.querySelector('.progress-scrubber')?.max) > 3);
|
||||
await page.waitForTimeout(300);
|
||||
const pages=Number(await page.locator('.progress-scrubber').getAttribute('max'));
|
||||
for (const direction of [1,-1]) for (let turn=1; turn<pages; turn++) {
|
||||
await page.evaluate(() => {
|
||||
const host = document.querySelector('book-content');
|
||||
const flow = host.shadowRoot?.querySelector('.book-flow') ?? host.querySelector('.book-flow');
|
||||
window.turnTrace=[];
|
||||
const until=performance.now()+400;
|
||||
const sample=() => {
|
||||
window.turnTrace.push({y:host.scrollTop,top:flow.getBoundingClientRect().top});
|
||||
if(performance.now()<until) requestAnimationFrame(sample);
|
||||
};
|
||||
sample();
|
||||
});
|
||||
const rect=await page.locator('book-content').boundingBox();
|
||||
await page.mouse.click(rect.x+rect.width*(direction===1 ? .85 : .15), rect.y+rect.height*.5);
|
||||
await page.waitForTimeout(450);
|
||||
const trace=await page.evaluate(()=>window.turnTrace);
|
||||
const detail=JSON.stringify({width,direction,turn,trace});
|
||||
assert.ok((trace.at(-1).y-trace[0].y)*direction>1,'page must move in the requested direction: '+detail);
|
||||
assert.ok(trace.every((v,i)=>!i || (v.y-trace[i-1].y)*direction>=-1),'scroll offset must not reverse: '+detail);
|
||||
assert.ok(trace.every((v,i)=>!i || (v.top-trace[i-1].top)*direction<=1),'visible content must not reverse: '+detail);
|
||||
assert.equal(Number(await page.locator('.progress-scrubber').inputValue()),direction===1 ? turn+1 : pages-turn,'each click advances exactly one page');
|
||||
}
|
||||
}
|
||||
console.log('PASS: desktop/mobile next and previous clicks, including the short final page, move without visual reversal');
|
||||
} finally { await browser.close(); await server.close(); }
|
||||
Reference in new issue
Block a user