mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 13:04:08 +08:00
feat: 客户端 Android 与 H5 双端,含精排渲染层
按产品设计 §5「原生外壳 + WebView 渲染」落地客户端,Android 与浏览器共用同一份
渲染层;同时把服务端的格式范围从 epub + pdf 扩展到 txt 与漫画。
## 为什么两端共用一份渲染层
Android 端不重写渲染。它是一个 Kotlin 原生外壳,只负责文件缓存、手势、账号与书架,
渲染复用 web/ 构建出的同一份产物。原因很直接:排版策略一旦有第二份实现就一定会分叉,
而「忠于精排」正是这个产品的差异点。
共用的抽象在 web/src/core/platform.ts(传输、键值存储、二进制存储、连通性)。
外壳只覆盖 WebView 做不到或做不好的三件事:
- 连通性:WebView 里的 navigator.onLine 只要有网卡就报「在线」,连上没有出口的
Wi-Fi 时会骗人,客户端于是转圈而不是读缓存。改用平台的 NET_CAPABILITY_VALIDATED。
- 稳定的设备名:UA 里的型号会随 Chrome 升级变化,作为「上次在哪个设备读的」标签不可靠。
- 原生提示:一行 Kotlin 的 Toast,不用在 Web 层造通知 UI。
跨语言的接缝(Kotlin 桥接 ↔ TypeScript 声明)没法互相类型检查,因此加了
android/scripts/check-shell.sh 做双向契约校验,并接进 CI。
## 精排 CSS 策略
这是整个项目最产品化的一块 CSS,所以规则是写明的而不是凑出来的。
三条原则:不设任何属性除非不设会坏;每个可调项都是默认 inherit 的可继承自定义属性
(「关」的意思是「不动它」,不是「用我们的默认值」);任何改变作者意图的行为都是
用户主动开启且持久化。
实际 override 只有七处,每处都在注释里写了为什么:图片/表格/pre 在窄屏会撑破布局、
ruby 振假名在 16px 下按默认 50% 太小、长中文标题不换行会溢出。除此之外不动。
被刻意删掉的做法:reset 样式表、统一的「阅读主题」、给 p 加边距。它们能让排版
「看起来一致」,而一致性恰恰是这里要避免的。
书的样式装进 shadow root。书里一条 p { color: red } 不能改到书架,App 的 .topbar
也不能被书撞上。用 iframe 也能隔离,但每次换章要重建文档,会丢滚动位置并在 Android
上多花几倍内存。关键是**不往 shadow root 里注入任何 reset**。
## 格式层
服务端只负责流式给出原始文件,拆包、编码、章节与分页都是渲染层的事——
这些恰好是产品要自己拿主意的部分。
- EPUB:OPF/spine/nav 解析,作者样式全部保留,相对引用改写成内部 reader-res: 协议,
绝对 URL 原样保留交给 CSP 挡掉。加载器按文档逐个容错,一个坏章节不会毁掉整本书。
- TXT:先严格校验 UTF-8(自己实现,不用宽松模式——宽松模式永远「成功」,于是永远
发现不了解错了),失败再依次试 GB18030 与 Big5,并把选择告诉用户、允许覆盖。
章节只能从标题推断,策略是宁可少认不要错认:800 字的段落以「第三章」开头不算标题,
正文里提到「他在第二章读过」也不算。两条都有测试固定。
- CBZ / 图片目录:自然序排页(1,2,10 而不是 1,10,2),一页一个 fixed-layout section。
排错不是观感问题,是故事被静默打乱。
- 检测以魔数优先:本地库里 .txt 其实是 EPUB、.cbz 里其实是 PDF 的情况很多,
按扩展名开错加载器只会得到一个看不懂的报错。
服务端扫描器相应扩展:txt/cbz 直接索引;图片目录按文件夹识别为一本书,
文件夹路径作为主键锚点,所以**往漫画文件夹加一页不会丢进度**——这条有测试。
## 客户端三态与离线队列
离线队列记的是**脏集合**(哪些记录还没送到服务端),而不是「当前全部状态」。
两者只在一种情况下结果不同,而这种情况一定会发生:记录在请求发出之后、响应到达之前
被修改了。脏集合下这次更新仍是脏的,要等它自己的确认才清除;「全发」模式下它会被
那个更早的响应顺手清掉,然后永远送不到服务端——一个丢失的阅读位置,是这个产品
最不能有的失败。
阅读位置格式 r1:<偏移>:<章节 id>。三点都是踩过之后定的:版本前缀(服务端把 locator
当不透明字符串长期保存,客户端将来改格式必须能认出「这不是我认识的格式」)、
章节 id 放最后(它是文件路径,会含冒号,只从前两个冒号切才不会丢数据)、
偏移是章节内的比例而非像素或列号(列号会随字号变化,于是读者一调字号位置就跳)。
TXT、漫画、PDF 的支持也一并接上,PDF 用浏览器自带阅读器保底可读。
## 服务端
- GET /books/:id/file?path=… :按页取图。path 必须是这本书已登记的 rel_path,
服务端按表精确匹配,不用 query 拼路径,所以穿越只会匹配到空。
- CORS 白名单(CORS_ORIGINS,默认回显任意来源)+ H5 静态托管(WEB_DIR)。
托管在同一容器里,这才保住「一条命令跑起来」。
- Android 用 WebViewAssetLoader 从 https://appassets.androidplatform.net/ 提供
打包好的客户端,而不是 file://。后者发出的 fetch 是不透明的跨源请求,所有访问
服务端的调用都会被拦,而通常的绕法是把 web security 关掉——那等于把 WebView
变成一扇敞开的门。用资产加载器拿到的是正常安全源,请求按普通 CORS 走,
isSecureContext 为真,IndexedDB 等行为与浏览器一致。这一个选择是「一份 bundle
两个宿主」能成立的前提。
## 过程中发现并修掉的问题
- **ETag 里塞了中文路径**:HTTP 头值必须是 ISO-8859-1,漫画页码路径是中文时会抛
ERR_INVALID_CHAR,把一个正常的取图请求变成 500。改成对路径取散列。
- **漫画文件夹的主键用了页集合散列**:注释写的是「加一页不产生新书」,实现却是散列
页集合,加一页就换 id、进度全丢。改成用文件夹路径作为锚点,和书用 dc:identifier
作锚点是同一个道理。测试固定住了。
- **locator 解析与写出不对称**:写出是两段、解析却按三段切,导致位置根本存不回来。
章节 id 含冒号时更糟。
- **离线队列的清理条件**:原本按「清空全部」处理,会把请求在途时产生的新位置一并
抹掉。改成只清那一批、且只清时间戳吻合的条目。
- **测试自身的错误**:并发刷新去重的用例里,我一开始用 resolveRefresh?.() 调一个
类型已收窄成 never 的变量,断言等于什么都没测;「outbox 为空时不推」的用例里把
登录的 POST 也数了进去,断言恒不成立。两处都是测试写错了。
- **凭据用错了**:一个权限用例里我用 createAsAdmin 建账号却拿另一组凭据登录,
401 被当成「越权被拒」而通过。这也是测试写错了。
## 验证方式
```
cd server && npm run typecheck && npm test && npm run build # 37/37 通过
cd web && npm run typecheck && npm test && npm run build # 147/147 通过
sh android/scripts/check-shell.sh # 桥接契约双向校验通过
```
另用真实 HTTP 请求端到端验证过:注册 → 扫描 → 书架(四种格式全部可见)→ 漫画按页
取图(含中文路径 ETag)→ 列表页 → 进度同步 → 继续阅读;H5 静态托管、SPA 回落、
CORS 回显、路径穿越拒绝均按预期。
Android 壳本轮**没有真机编译验证**:环境里没有 JDK 和 Android SDK。Kotlin 侧做了
静态校验(包名与目录一致、项目内 import 可解析、桥接方法与 web 侧声明双向对齐),
并接进了 CI。第一次真机构建可能需要微调 Gradle 版本或 AGP 版本。
## 说明
- docker-compose.yml 与 Dockerfile 已改为从仓库根构建并打包 H5 产物;
build 上下文变了,如果之前用了 `build: ./server` 需要改成根目录。
- iOS 未做设计(按设计文档 §7,等有赞助再说)。
- 移动端批注输入仍未做(先保证读,后补写)。
Ref: #1
This commit is contained in:
1 parent
0417bcdf72
commit
78bfe557c3
89 files changed
+13909
-61
No files matched your search
@@ -1,40 +1,87 @@
|
||||
# 服务端 CI:类型检查、测试、构建。
|
||||
# 忽略文档改动,避免改 README 也跑一遍 Node。
|
||||
# CI:服务端(Node/TypeScript)、客户端(Web/TypeScript)、Android 壳(Kotlin)。
|
||||
#
|
||||
# 每个任务都用 ifModify 限定到自己那部分,避免改一行 README 就把三条流水线
|
||||
# 全跑一遍。
|
||||
|
||||
# 服务端与客户端都是 Node 生态,共用同一个镜像锚点。
|
||||
.node-env: &node-env
|
||||
docker:
|
||||
image: node:24
|
||||
ifModify:
|
||||
- server/**
|
||||
|
||||
# 推送:完整检查
|
||||
.server-check: &server-check
|
||||
- name: install
|
||||
script: cd server && npm ci
|
||||
- name: typecheck
|
||||
script: cd server && npm run typecheck
|
||||
- name: test
|
||||
script: cd server && npm test
|
||||
- name: build
|
||||
script: cd server && npm run build
|
||||
|
||||
.client-check: &client-check
|
||||
- name: install
|
||||
script: cd web && npm ci
|
||||
- name: typecheck
|
||||
script: cd web && npm run typecheck
|
||||
- name: test
|
||||
script: cd web && npm test
|
||||
- name: build
|
||||
script: cd web && npm run build
|
||||
|
||||
main:
|
||||
push:
|
||||
- name: server-ci
|
||||
<<: *node-env
|
||||
ifModify:
|
||||
- server/**
|
||||
stages:
|
||||
- name: install
|
||||
script: cd server && npm ci
|
||||
- name: typecheck
|
||||
script: cd server && npm run typecheck
|
||||
- name: test
|
||||
script: cd server && npm test
|
||||
- name: build
|
||||
script: cd server && npm run build
|
||||
- *server-check
|
||||
failStages:
|
||||
- name: report-failure
|
||||
script: echo "CI 失败:$CNB_BUILD_FAILED_MSG"
|
||||
script: echo "服务端 CI 失败:$CNB_BUILD_FAILED_MSG"
|
||||
|
||||
- name: client-ci
|
||||
<<: *node-env
|
||||
ifModify:
|
||||
- web/**
|
||||
stages:
|
||||
- *client-check
|
||||
failStages:
|
||||
- name: report-failure
|
||||
script: echo "客户端 CI 失败:$CNB_BUILD_FAILED_MSG"
|
||||
|
||||
# Android 壳。
|
||||
#
|
||||
# 先跑一个不依赖 JDK 的静态检查(秒级),再编译。原因是桥接契约的两半分别
|
||||
# 用 Kotlin 和 TypeScript 写,互相没法类型检查:客户端调用一个壳里没有的
|
||||
# 方法是最容易出、也最难在本地发现的错误。静态检查把它提前到这里。
|
||||
- name: android-ci
|
||||
docker:
|
||||
image: docker.cnb.cool/dockerhub/androidsdk/android-35:latest
|
||||
ifModify:
|
||||
- android/**
|
||||
- web/src/android-bridge.d.ts
|
||||
stages:
|
||||
- name: shell-contract
|
||||
script: sh android/scripts/check-shell.sh
|
||||
- name: assemble
|
||||
script: cd android && ./gradlew --no-daemon :app:assembleDebug
|
||||
failStages:
|
||||
- name: report-failure
|
||||
script: echo "Android 构建失败:$CNB_BUILD_FAILED_MSG"
|
||||
|
||||
# PR:同样的检查,作为评审依据
|
||||
"**":
|
||||
pull_request:
|
||||
- name: server-checks
|
||||
<<: *node-env
|
||||
ifModify:
|
||||
- server/**
|
||||
stages:
|
||||
- name: install
|
||||
script: cd server && npm ci
|
||||
- name: typecheck
|
||||
script: cd server && npm run typecheck
|
||||
- name: test
|
||||
script: cd server && npm test
|
||||
- name: build
|
||||
script: cd server && npm run build
|
||||
- *server-check
|
||||
|
||||
- name: client-checks
|
||||
<<: *node-env
|
||||
ifModify:
|
||||
- web/**
|
||||
stages:
|
||||
- *client-check
|
||||
@@ -0,0 +1,6 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/build
|
||||
**/.gradle
|
||||
data
|
||||
.git
|
||||
@@ -17,3 +17,6 @@ server/data/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# web client build output (regenerated by `npm run build`)
|
||||
web/dist/
|
||||
@@ -1,9 +1,17 @@
|
||||
# reader
|
||||
|
||||
**自部署精品书库阅读器。** 把你自己磁盘上的电子书挂载成书库,用移动端以「精品级排版」阅读。
|
||||
**自部署精品书库阅读器。** 把你自己磁盘上的书籍挂载成书库,用 Android 或浏览器以「精品级排版」阅读。
|
||||
|
||||
书文件始终留在你自己的磁盘上,不经过任何厂商服务器。开源免费,AGPL-3.0。
|
||||
|
||||
三个部分:
|
||||
|
||||
| 部分 | 位置 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 服务端 | `server/` | Node.js + TypeScript,单容器,扫描书库并同步进度 |
|
||||
| 渲染层 | `web/` | TypeScript + Vite,EPUB 精排、TXT、漫画、PDF |
|
||||
| Android 壳 | `android/` | Kotlin + WebView,复用上面那一份渲染层 |
|
||||
|
||||
---
|
||||
|
||||
## 一条命令跑起来
|
||||
@@ -20,6 +28,9 @@ docker compose up -d
|
||||
|
||||
打开 `http://<你的主机>:8080`,**第一个注册的账号自动成为管理员**。
|
||||
|
||||
这时你已经有一个能用的 H5 阅读器了——服务端镜像里已经带了客户端,不用再起第二个容器。
|
||||
手机浏览器打开同一个地址即可阅读。要原生 App 见下面的「Android 客户端」。
|
||||
|
||||
> 只有第一次注册是开放的。之后新账号由管理员在后台创建(默认关闭公开注册),
|
||||
> 这样你的实例不会被陌生人占用。想让家人自行注册,在 `docker-compose.yml` 里
|
||||
> 把 `ALLOW_REGISTRATION` 设为 `"true"`。
|
||||
@@ -53,10 +64,18 @@ volumes:
|
||||
|
||||
## 支持格式
|
||||
|
||||
| 格式 | 第一版状态 |
|
||||
| --- | --- |
|
||||
| EPUB | 精排支持(内嵌元数据、封面、calibre 系列、dc:identifier) |
|
||||
| PDF | 保底可读(页数、基础内嵌元数据) |
|
||||
| 格式 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| EPUB | 精排 | 保留出版方样式、嵌入字体、脚注跳转、图文混排、竖排、振假名 |
|
||||
| TXT | 支持 | 自动识别 UTF-8 / GB18030 / Big5 / UTF-16,按章节标题切分 |
|
||||
| CBZ / ZIP | 支持 | 按页自然排序,逐页铺满屏幕 |
|
||||
| 图片目录 | 支持 | 一个文件夹里全是图片时,按文件夹当一本书 |
|
||||
| PDF | 保底可读 | 用浏览器自带阅读器,页数与进度照常上报,服务端不解析内容 |
|
||||
|
||||
> TXT 是本地书库里最容易被忽视的一块。相当一部分中文 TXT 没有 BOM、没有编码声明,
|
||||
> 解码成 UTF-8 会满屏乱码。客户端先严格校验 UTF-8,失败再依次尝试 GB18030 与 Big5,
|
||||
> 并**在界面上告诉你选了哪个编码**,可手动覆盖。
|
||||
> 章节同样没有结构,只能从标题推断——推测不出时按长度分段,不会整本卡死。
|
||||
|
||||
---
|
||||
|
||||
@@ -120,6 +139,18 @@ volumes:
|
||||
|
||||
同步的只有轻量数据:书架元数据、阅读进度、笔记、高亮。书文件不走同步。
|
||||
|
||||
### 两端的关系
|
||||
|
||||
Android 端**不重写渲染**。它是一个 Kotlin 原生外壳,只负责文件缓存、手势、账号与书架,
|
||||
渲染复用同一份 `web/` 产物。原因很直接:排版策略一旦出现两份实现,就一定会分叉,
|
||||
而「忠于精排」正是这个产品的差异点。外壳只做 WebView 做不到或做不好的事:
|
||||
|
||||
- **连通性**:WebView 里的 `navigator.onLine` 只要有网卡就说「在线」——
|
||||
连上一个没有出口的 Wi-Fi 时它会骗人,客户端于是转圈而不是读缓存。
|
||||
Android 侧用系统的 `NET_CAPABILITY_VALIDATED` 判断。
|
||||
- **稳定的设备名**:UA 里的型号会随 Chrome 升级变化,作为「上次在哪个设备读的」标签不可靠。
|
||||
- **原生提示**:一行 Kotlin 的 Toast,不用在 Web 层造一套通知 UI。
|
||||
|
||||
### 离线合并语义
|
||||
|
||||
进度和笔记都带 `updatedAt`,冲突按 **last-writer-wins** 解决:
|
||||
@@ -178,9 +209,44 @@ reader.example.com {
|
||||
|
||||
---
|
||||
|
||||
## Android 客户端
|
||||
|
||||
APK 走 GitHub Releases / 侧载分发,不上商店。开源项目没有收入覆盖 Apple 的
|
||||
99 美元开发者账号,所以 Android 先做、iOS 等有赞助再说(这条是设计文档 §5 定的)。
|
||||
|
||||
### 自己构建
|
||||
|
||||
```bash
|
||||
# 1. 先把 H5 渲染层构建好并放进 assets
|
||||
sh android/scripts/build-web-assets.sh
|
||||
|
||||
# 2. 构建 APK
|
||||
cd android
|
||||
./gradlew :app:assembleDebug
|
||||
# 产物:android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
# 想预置服务端地址(个人自用构建):
|
||||
./gradlew :app:assembleRelease -PdefaultServerUrl=http://nas.local:8080
|
||||
```
|
||||
|
||||
不预置地址是刻意的:没有人知道你会用什么 IP 访问自己的 NAS,
|
||||
所以首次启动时问一次、之后记住。
|
||||
|
||||
### 装好之后
|
||||
|
||||
打开 App → 填服务端地址(如 `http://192.168.1.10:8080`)→ 登录。
|
||||
之后所有书和进度都跟着账号走,与浏览器端互通。
|
||||
|
||||
### 为什么允许明文 HTTP
|
||||
|
||||
主要部署场景就是局域网 IP,而私有 IP 拿不到证书。要求 HTTPS 等于把用户挡在门外,
|
||||
这正是设计文档里说的「部署门槛是最大的获客阻力」。
|
||||
**但对公网暴露时请务必套一层 HTTPS**,否则令牌是明文传输的。
|
||||
|
||||
## 从源码开发
|
||||
|
||||
```bash
|
||||
# 服务端
|
||||
cd server
|
||||
npm install
|
||||
npm test # 27 个测试用例
|
||||
@@ -191,6 +257,15 @@ npm run dev # 开发模式,热重载
|
||||
BOOKS_DIR=/tmp/books DATA_DIR=/tmp/data npm run dev
|
||||
```
|
||||
|
||||
```bash
|
||||
# 客户端(另开一个终端)
|
||||
cd web
|
||||
npm install
|
||||
npm test # 147 个测试用例
|
||||
npm run dev # http://localhost:5174,自动把 /api 代理到 8080
|
||||
npm run build # 产出 web/dist,服务端会在 / 上直接托管
|
||||
```
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
@@ -208,7 +283,34 @@ server/src/
|
||||
shelf.ts 书架查询、手动覆盖层
|
||||
sync.ts 进度/笔记同步与冲突合并
|
||||
merge.ts 元数据优先级链
|
||||
http/ Fastify 路由
|
||||
http/ Fastify 路由(含 CORS 与 H5 静态托管)
|
||||
|
||||
web/src/
|
||||
api/ 服务端契约与请求层(令牌刷新在这里,全局只做一次)
|
||||
core/ 平台抽象、同步引擎
|
||||
platform.ts 两端共同接口:传输、存储、连通性
|
||||
sync.ts 待发送队列 + 增量拉取 + 退避重试
|
||||
android-platform.ts Android 侧的实现(很薄)
|
||||
store/
|
||||
offline.ts 本地镜像与待发送队列
|
||||
idb.ts IndexedDB 包装,不可用时降级到内存
|
||||
formats/ 格式层:拆包、编码识别、章节切分
|
||||
epub.ts OPF/spine/nav 解析、资源重写
|
||||
txt.ts 编码探测 + 章节推断 + 超长分段
|
||||
comic.ts CBZ / 图片目录
|
||||
detect.ts 魔数优先的格式识别
|
||||
ui/
|
||||
reader-view.ts 分页、滚动、手势、定位
|
||||
locator.ts 阅读位置格式
|
||||
shadow.ts 书的样式与 App 样式隔离
|
||||
resources.ts 资源按需解出,不预展开
|
||||
styles/reader.css ← 干预策略都在这里,注释说明每条为什么必要
|
||||
|
||||
android/app/src/main/java/cool/cnb/reader/
|
||||
MainActivity.kt 单 Activity,只做三件事
|
||||
web/WebHost.kt WebView 配置与资源装载
|
||||
bridge/ReaderBridge.kt 暴露给 JS 的原生方法
|
||||
bridge/ConnectivityMonitor.kt 真正的连通性判断
|
||||
```
|
||||
|
||||
### 技术选型说明
|
||||
@@ -220,6 +322,24 @@ server/src/
|
||||
会把镜像撑大并让安装变脆。
|
||||
- **SQLite 存 metadata,不存书**:schema 里的 `books` 表可以随时重建,书本身才是真相源。
|
||||
|
||||
### 客户端技术选型说明
|
||||
|
||||
- **不用框架**:渲染层是命令式 DOM 操作——分页、注入文档、shadow root、量测列宽。
|
||||
引一个虚拟 DOM 只在代码和它要量测的布局之间多加一层,而这一层恰好是这个产品最不该
|
||||
透过它去调试的东西。产物是一个 169KB 的单文件 bundle。
|
||||
- **单文件 bundle、相对路径**:产物要被两个宿主消费——服务端托管给浏览器、Android 打进
|
||||
assets。`file://` 或 WebView 里加载同源分片会踩平台特异性,所以 `inlineDynamicImports`
|
||||
打成一份,`base: './'`。
|
||||
- **不注入 reset 样式**:见 `web/src/styles/reader.css` 顶部。整个产品的差异点是「忠于
|
||||
出版方的排版」,而一份 reset 正好会抹掉出版方的字体、缩进和行距。
|
||||
可调项全部是可继承的自定义属性,默认值是 `inherit`——「关」的意思是「不动它」,
|
||||
不是「用我们的默认值」。
|
||||
- **书的样式装进 shadow root**:书里一条 `p { color: red }` 不能改到书架。
|
||||
用 iframe 也能隔离,但每次换章要重建文档,会丢滚动位置、在 Android 上多花几倍内存。
|
||||
- **书的内容不允许联网**:章节里所有本地引用在加载时被改写成内部 `reader-res:` 协议,
|
||||
由渲染层从内存里的资源表解出。绝对 URL 原样保留,然后被 CSP 挡掉——
|
||||
一本来路不明的书不能把阅读行为报到远端。
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Build output
|
||||
build/
|
||||
app/build/
|
||||
.gradle/
|
||||
local.properties
|
||||
|
||||
# The staged web bundle is generated by scripts/build-web-assets.sh and copied
|
||||
# out of web/dist. Committing it would put a built artifact under version control
|
||||
# and make every client change show up twice in review.
|
||||
app/src/main/assets/web-assets/*
|
||||
!app/src/main/assets/web-assets/README.txt
|
||||
|
||||
# Keystores and signing material must never be committed.
|
||||
*.jks
|
||||
*.keystore
|
||||
keystore.properties
|
||||
@@ -0,0 +1,95 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "cool.cnb.reader"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "cool.cnb.reader"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
/*
|
||||
* Where the WebView points on first launch.
|
||||
*
|
||||
* Left empty on purpose. Baking in an address would produce an app that
|
||||
* cannot be used by anyone but its author, and the reader does not know
|
||||
* the IP it will be reached at (product design §8.1: deployment friction
|
||||
* is the biggest adoption barrier). The login screen asks for it once and
|
||||
* remembers it.
|
||||
*
|
||||
* `DEFAULT_SERVER_URL` can be set at build time for a preconfigured
|
||||
* personal build: ./gradlew assembleDebug -PdefaultServerUrl=http://nas:8080
|
||||
*/
|
||||
buildConfigField(
|
||||
"String",
|
||||
"DEFAULT_SERVER_URL",
|
||||
"\"${(project.findProperty("defaultServerUrl") as String?) ?: ""}\"",
|
||||
)
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
viewBinding = false
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// Same applicationId as release so a debug build upgrades over a
|
||||
// release one and keeps the reader's session and offline books.
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
// Signed with the debug key unless a keystore is provided. An
|
||||
// unsigned release APK cannot be side-loaded at all, and this project
|
||||
// has no signing infrastructure to promise (product design §7).
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
/*
|
||||
* The shared web client is built by Vite into `web/dist` and packed
|
||||
* into assets. This is the single mechanism that keeps the Android
|
||||
* and H5 clients from becoming two implementations: one bundle, two
|
||||
* hosts.
|
||||
*
|
||||
* The directory has to exist for a clean checkout to build. The
|
||||
* placeholder committed under `web-assets` is what makes that work.
|
||||
*/
|
||||
assets.srcDirs("src/main/assets", project.layout.projectDirectory.dir("web-assets").asFile)
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources.excludes += setOf("META-INF/*.kotlin_module")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.activity:activity-ktx:1.9.3")
|
||||
// WebViewAssetLoader: serves the bundled web client over an https:// origin
|
||||
// instead of file://, which is what makes fetch() to the reader server work
|
||||
// without disabling web security. See WebHost.kt.
|
||||
implementation("androidx.webkit:webkit:1.12.1")
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# The bridge methods are called from JavaScript by name via the @JavascriptInterface
|
||||
# annotation. R8 cannot see those call sites, so without this the release build
|
||||
# would strip them and the client would silently lose connectivity reporting and
|
||||
# the device label.
|
||||
-keepclassmembers class cool.cnb.reader.bridge.ReaderBridge {
|
||||
public *;
|
||||
}
|
||||
-keepattributes JavascriptInterface
|
||||
-keepattributes *Annotation*
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!--
|
||||
Reading is the one thing this app does with the network, and it has to reach
|
||||
a server on the LAN, on a VPN address, or over the public internet. There is
|
||||
no manifest way to express "any of those".
|
||||
|
||||
Note what is NOT requested: no location, no storage permission (the app
|
||||
writes only inside its own private directory), no account access, no
|
||||
analytics. A reader that asks for the camera is a reader nobody trusts with
|
||||
their library.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Declared so the app still installs on a device without a touchscreen and
|
||||
so an app store listing is honest about what it needs. -->
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<!--
|
||||
Cleartext HTTP is allowed.
|
||||
|
||||
This is a deliberate, product-relevant exception rather than an oversight.
|
||||
The primary deployment is a LAN address like http://192.168.1.10:8080, and
|
||||
requiring HTTPS would make the app unusable for exactly the users it is for:
|
||||
there is no certificate authority for a private IP, and telling a NAS owner
|
||||
to run their own CA before they can read a book is the deployment friction
|
||||
the product is trying to remove.
|
||||
|
||||
The exposure is bounded — reading your own book from your own NAS over your
|
||||
own Wi-Fi. The README instructs users to put HTTPS in front of the server
|
||||
before exposing it to the internet.
|
||||
-->
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Reader"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|smallestScreenSize|density"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
Placeholder so a clean checkout can be built.
|
||||
|
||||
`scripts/build-web-assets.sh` copies the Vite output (web/dist) here, and the
|
||||
Gradle build packages this directory as `assets/`. Gradle fails if a declared
|
||||
asset source directory does not exist, which is why this file is committed.
|
||||
@@ -0,0 +1,126 @@
|
||||
package cool.cnb.reader
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import android.widget.FrameLayout
|
||||
import cool.cnb.reader.bridge.ConnectivityMonitor
|
||||
import cool.cnb.reader.bridge.ReaderBridge
|
||||
import cool.cnb.reader.web.WebHost
|
||||
|
||||
/**
|
||||
* The only activity.
|
||||
*
|
||||
* The shell is thin by design: it hosts the shared web client, exposes a small
|
||||
* bridge for the things a WebView cannot do, and handles the back gesture. Every
|
||||
* feature that is not one of those three lives in the web layer, because that is
|
||||
* what keeps the Android and browser clients from diverging.
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var webView: WebView
|
||||
private lateinit var connectivity: ConnectivityMonitor
|
||||
private var host: WebHost? = null
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
connectivity = ConnectivityMonitor(this)
|
||||
|
||||
val root = FrameLayout(this).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
}
|
||||
|
||||
webView = WebView(this).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
// Transparent background: the activity window already shows the paper
|
||||
// colour, so anything else would flash on every page transition.
|
||||
setBackgroundColor(Color.TRANSPARENT)
|
||||
// A long-press on text is how the reader selects a passage; the
|
||||
// platform's own context menu is the wrong affordance for that and
|
||||
// covers the text being selected.
|
||||
isLongClickable = true
|
||||
isHapticFeedbackEnabled = true
|
||||
overScrollMode = View.OVER_SCROLL_NEVER
|
||||
}
|
||||
root.addView(webView)
|
||||
setContentView(root)
|
||||
|
||||
applyInsets(root)
|
||||
|
||||
host = WebHost(this, webView).apply {
|
||||
install(ReaderBridge(this@MainActivity, webView, connectivity))
|
||||
}
|
||||
|
||||
// Back should walk the reader's own history (shelf → book → shelf) rather
|
||||
// than exiting the app, and only leave when there is nothing left.
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack()
|
||||
} else {
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the WebView clear of the status bar, the navigation bar and the
|
||||
* on-screen keyboard.
|
||||
*
|
||||
* Without this the reader's footer sits under the gesture bar, which is
|
||||
* infuriating on a phone with no physical buttons: the last line of a page is
|
||||
* permanently covered. `adjustResize` in the manifest is not sufficient on
|
||||
* its own from API 30 onwards.
|
||||
*/
|
||||
private fun applyInsets(root: FrameLayout) {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime())
|
||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
// Ask the web layer to flush its outbox before the process can be frozen.
|
||||
// This is the hook that makes "read, then immediately switch apps" not
|
||||
// lose the last few pages: the reader's own debounce would never fire.
|
||||
webView.evaluateJavascript("window.__readerFlush && window.__readerFlush()", null)
|
||||
webView.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
webView.onResume()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
host?.destroy()
|
||||
connectivity.close()
|
||||
// Detaching the WebView explicitly: leaving it in the hierarchy leaks the
|
||||
// whole renderer process across a configuration change.
|
||||
(webView.parent as? ViewGroup)?.removeView(webView)
|
||||
webView.destroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package cool.cnb.reader.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import java.io.Closeable
|
||||
|
||||
/**
|
||||
* Real connectivity, which the WebView cannot provide.
|
||||
*
|
||||
* `navigator.onLine` inside a WebView reports "true" whenever a network interface
|
||||
* exists. On a phone that has joined a Wi-Fi network with no route to the NAS —
|
||||
* a captive portal, a hotel network, a VPN that has not connected yet — it says
|
||||
* "online" and the client happily attempts requests that will all time out. The
|
||||
* user then sees a spinner instead of their cached library.
|
||||
*
|
||||
* `NetworkCapabilities.NET_CAPABILITY_VALIDATED` is the platform's own answer to
|
||||
* "can this device actually reach the internet", which is the question the client
|
||||
* is really asking. Reporting it is what makes the offline path switch at the
|
||||
* right moment instead of several seconds later.
|
||||
*/
|
||||
class ConnectivityMonitor(context: Context) : Closeable {
|
||||
|
||||
private val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
private val listeners = mutableSetOf<(String) -> Unit>()
|
||||
private var validated = currentValidation(manager)
|
||||
|
||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) = recompute()
|
||||
override fun onLost(network: Network) = recompute()
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) = recompute()
|
||||
}
|
||||
|
||||
init {
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
// Registering for a request, not `registerDefaultNetworkCallback`: the app
|
||||
// may be on Wi-Fi with no uplink while a VPN provides the route to the
|
||||
// server, and the default network would miss that.
|
||||
runCatching { manager.registerNetworkCallback(request, callback) }
|
||||
.onFailure {
|
||||
// Android 10+ requires ACCESS_NETWORK_STATE, which the manifest
|
||||
// declares. If registration still fails the monitor degrades to a
|
||||
// best-effort snapshot rather than taking the app down.
|
||||
validated = currentValidation(manager)
|
||||
}
|
||||
}
|
||||
|
||||
/** 'online' or 'offline', matching the strings the web bridge expects. */
|
||||
fun current(): String = if (validated) "online" else "offline"
|
||||
|
||||
fun watch(listener: (String) -> Unit) {
|
||||
listeners.add(listener)
|
||||
// Report the current state immediately: the client registers its listener
|
||||
// after the bridge exists, so without this it would wait for the next
|
||||
// network change to learn that it is offline.
|
||||
listener(current())
|
||||
}
|
||||
|
||||
private fun recompute() {
|
||||
val next = currentValidation(manager)
|
||||
if (next == validated) return
|
||||
validated = next
|
||||
val state = current()
|
||||
for (listener in listeners.toList()) listener(state)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
listeners.clear()
|
||||
runCatching { manager.unregisterNetworkCallback(callback) }
|
||||
}
|
||||
|
||||
private fun currentValidation(manager: ConnectivityManager): Boolean {
|
||||
val network = manager.activeNetwork ?: return false
|
||||
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) return false
|
||||
// VALIDATED means the system has confirmed the network reaches the
|
||||
// internet. A network without it may still reach a LAN server, so this is
|
||||
// treated as a hint rather than the whole truth: the client's own requests
|
||||
// are what ultimately decide, and they fall back to cache either way.
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) ||
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package cool.cnb.reader.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The native surface exposed to the web client as `window.ReaderAndroid`.
|
||||
*
|
||||
* Scope is decided by one rule: a method belongs here only if the WebView cannot
|
||||
* do it, or does it wrong. Everything else stays in the shared web layer, which is
|
||||
* what keeps the Android and browser clients one implementation instead of two.
|
||||
*
|
||||
* By that rule the bridge has exactly four responsibilities:
|
||||
*
|
||||
* - connectivity, because `navigator.onLine` lies in a WebView;
|
||||
* - a stable device label, because the UA-derived one changes when Chrome
|
||||
* updates and would fragment a reader's own history;
|
||||
* - native feedback (a toast), which is one line here and a whole notification
|
||||
* UI in the web layer;
|
||||
* - a way for the client to ask about its host, so it can adapt rather than
|
||||
* guess.
|
||||
*
|
||||
* Every method is safe to call from any thread because `@JavascriptInterface`
|
||||
* calls arrive on a WebView-owned thread, not the main one: the ones that touch UI
|
||||
* post back to the main looper.
|
||||
*/
|
||||
class ReaderBridge(
|
||||
private val context: Context,
|
||||
private val webView: WebView,
|
||||
private val connectivity: ConnectivityMonitor,
|
||||
) {
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* Shell version.
|
||||
*
|
||||
* The web client requires a minimum (see MIN_SHELL_VERSION in the client) so
|
||||
* that an old APK paired with a new client is refused up front rather than
|
||||
* failing later in a way that looks like a reader bug.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun shellVersion(): Int = SHELL_VERSION
|
||||
|
||||
/**
|
||||
* A device label that is stable and meaningful to a human.
|
||||
*
|
||||
* `Build.MODEL` is what the reader recognises — "Pixel 7", "SM-G991B" — and
|
||||
* unlike a user-agent string it does not change when the WebView is updated.
|
||||
* It is recorded on every progress row so the UI can say where a book was last
|
||||
* read.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun deviceLabel(): String {
|
||||
val model = Build.MODEL?.trim().orEmpty()
|
||||
val manufacturer = Build.MANUFACTURER?.trim().orEmpty().replaceFirstChar { it.uppercase() }
|
||||
val label = when {
|
||||
model.isEmpty() -> "Android"
|
||||
// Some OEMs prefix the model with the manufacturer ("Xiaomi Redmi…"),
|
||||
// which reads as a duplicate.
|
||||
model.startsWith(manufacturer, ignoreCase = true) -> model
|
||||
manufacturer.isEmpty() -> model
|
||||
else -> "$manufacturer $model"
|
||||
}
|
||||
return label.take(60)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validated connectivity, not merely "an interface exists".
|
||||
*
|
||||
* See ConnectivityMonitor for why this distinction is the whole reason the
|
||||
* bridge exists.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun connectivity(): String = connectivity.current()
|
||||
|
||||
/**
|
||||
* Registers a callback invoked whenever connectivity changes.
|
||||
*
|
||||
* The web client passes a JavaScript function; it is invoked by name through
|
||||
* `evaluateJavascript` because that is the only direction a WebView allows
|
||||
* without a custom `WebMessagePort` handshake, which would be more machinery
|
||||
* for the same result.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun watchConnectivity(callbackName: String) {
|
||||
if (callbackName.isBlank() || !SAFE_IDENTIFIER.matches(callbackName)) return
|
||||
connectivity.watch { state ->
|
||||
mainHandler.post {
|
||||
// JSON-encoded so the string cannot break out of the call site.
|
||||
webView.evaluateJavascript(
|
||||
"window['$callbackName'] && window['$callbackName'](${JSONObject.quote(state)})",
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport in physical pixels plus the density.
|
||||
*
|
||||
* Fixed-layout books (comics, PDF) size themselves against the real viewport,
|
||||
* and a WebView's CSS pixel size is not the same as the display's. Reporting
|
||||
* both lets the client reason in CSS pixels while knowing the true resolution.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun viewport(): String {
|
||||
val metrics = context.resources.displayMetrics
|
||||
return JSONObject()
|
||||
.put("width", metrics.widthPixels)
|
||||
.put("height", metrics.heightPixels)
|
||||
.put("density", metrics.density.toDouble())
|
||||
.put("densityDpi", metrics.densityDpi)
|
||||
.toString()
|
||||
}
|
||||
|
||||
/** Native toast, for feedback that should survive the web layer being busy. */
|
||||
@JavascriptInterface
|
||||
fun toast(message: String) {
|
||||
val text = message.take(200)
|
||||
mainHandler.post { Toast.makeText(context, text, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun hasNetwork(): Boolean = connectivity.current() == "online"
|
||||
|
||||
/** Diagnostics for a bug report the reader can copy out of the app. */
|
||||
@JavascriptInterface
|
||||
fun diagnostics(): String {
|
||||
val info = JSONObject()
|
||||
.put("shellVersion", SHELL_VERSION)
|
||||
.put("connectivity", connectivity.current())
|
||||
.put("device", deviceLabel())
|
||||
.put("androidRelease", Build.VERSION.RELEASE)
|
||||
.put("androidSdk", Build.VERSION.SDK_INT)
|
||||
.put("appVersion", appVersion())
|
||||
.put("time", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).format(Date()))
|
||||
return info.toString()
|
||||
}
|
||||
|
||||
/** Files the client has cached, so a storage screen can be honest about it. */
|
||||
@JavascriptInterface
|
||||
fun cacheUsage(): String {
|
||||
val info = JSONObject()
|
||||
info.put("webViewBytes", directorySize(context.cacheDir))
|
||||
info.put("dataBytes", directorySize(context.filesDir))
|
||||
info.put("availableBytes", context.filesDir.usableSpace)
|
||||
return info.toString()
|
||||
}
|
||||
|
||||
private fun appVersion(): String = runCatching {
|
||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
packageInfo.versionName ?: "unknown"
|
||||
}.getOrDefault("unknown")
|
||||
|
||||
/** Recursive size of a directory, bounded so a huge tree cannot stall a call. */
|
||||
private fun directorySize(dir: java.io.File, budget: Int = 20_000): Long {
|
||||
if (budget <= 0 || !dir.exists()) return 0
|
||||
var total = 0L
|
||||
var remaining = budget
|
||||
val stack = ArrayDeque<java.io.File>()
|
||||
stack.addLast(dir)
|
||||
while (stack.isNotEmpty() && remaining > 0) {
|
||||
val current = stack.removeLast()
|
||||
val children = current.listFiles() ?: continue
|
||||
for (child in children) {
|
||||
remaining -= 1
|
||||
if (remaining <= 0) break
|
||||
if (child.isDirectory) stack.addLast(child) else total += child.length()
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Name the web client looks for on `window`. */
|
||||
const val NAME = "ReaderAndroid"
|
||||
|
||||
/**
|
||||
* Bump when a method is added that the client cannot work without, and
|
||||
* raise MIN_SHELL_VERSION in `web/src/core/android-platform.ts` to match.
|
||||
*/
|
||||
const val SHELL_VERSION = 1
|
||||
|
||||
/**
|
||||
* Callback names must be plain identifiers.
|
||||
*
|
||||
* The name is interpolated into an `evaluateJavascript` string, so an
|
||||
* unrestricted value would let the web side run arbitrary script in its own
|
||||
* context. The context is the client's own, so the risk is low, but a
|
||||
* bridge that will interpolate a string should validate it rather than
|
||||
* assume.
|
||||
*/
|
||||
private val SAFE_IDENTIFIER = Regex("^[A-Za-z_$][A-Za-z0-9_$]{0,64}$")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package cool.cnb.reader.web
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import cool.cnb.reader.BuildConfig
|
||||
import cool.cnb.reader.bridge.ReaderBridge
|
||||
|
||||
/**
|
||||
* Configures the WebView and serves the bundled client to it.
|
||||
*
|
||||
* The interesting decision here is the **origin**.
|
||||
*
|
||||
* The obvious way to load a bundled web app is `file:///android_asset/...`. That
|
||||
* does not work for this product: `fetch()` from a `file://` origin is treated as
|
||||
* an opaque cross-origin request, so every call to the reader server would be
|
||||
* blocked — and the usual workaround is to disable web security, which turns a
|
||||
* WebView into an open door.
|
||||
*
|
||||
* `WebViewAssetLoader` serves the same files from `https://appassets.androidplatform.net/`,
|
||||
* a proper secure origin. The client's fetches then go out as ordinary CORS
|
||||
* requests, which is exactly what the server's CORS policy is written for, and
|
||||
* `isSecureContext` is true so IndexedDB and other APIs behave the same as they
|
||||
* do in a browser. That one choice is what lets a single web bundle run unchanged
|
||||
* in both hosts.
|
||||
*/
|
||||
class WebHost(
|
||||
private val context: Context,
|
||||
private val webView: WebView,
|
||||
) {
|
||||
|
||||
private val assetLoader = WebViewAssetLoader.Builder()
|
||||
.setDomain(ASSET_HOST)
|
||||
.addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(context))
|
||||
.build()
|
||||
|
||||
fun install(bridge: ReaderBridge) {
|
||||
configure()
|
||||
webView.addJavascriptInterface(bridge, ReaderBridge.NAME)
|
||||
webView.webViewClient = AssetClient()
|
||||
webView.webChromeClient = ChromeClient()
|
||||
webView.loadUrl(START_URL)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun configure() {
|
||||
val settings = webView.settings
|
||||
|
||||
// The client is our own bundle served from a same-origin asset loader, not
|
||||
// an arbitrary page from the internet. JavaScript is the renderer here, so
|
||||
// enabling it is not a decision that has an alternative.
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.databaseEnabled = true
|
||||
|
||||
// A book is laid out as a document with a realistic viewport. Without
|
||||
// these two the WebView renders at a 980px desktop width and the reader
|
||||
// gets a zoomed-out page of unreadable text.
|
||||
settings.useWideViewPort = true
|
||||
settings.loadWithOverviewMode = true
|
||||
// The web layer already handles small screens in CSS, so the WebView must
|
||||
// not add its own zoom on top; the reader has an explicit text-size control
|
||||
// instead, which is the one that respects the book's typography.
|
||||
settings.setSupportZoom(false)
|
||||
settings.builtInZoomControls = false
|
||||
settings.textZoom = 100
|
||||
|
||||
// The bundled client is https:// (asset loader) and the user's server is
|
||||
// usually http:// on the LAN. Blocking that combination would make the app
|
||||
// unable to reach the very server it exists for. The manifest has the full
|
||||
// reasoning for why cleartext is the right trade for this product.
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
|
||||
// Let the HTTP cache do its job: book bodies are immutable per hash and
|
||||
// already carry an ETag.
|
||||
settings.cacheMode = WebSettings.LOAD_DEFAULT
|
||||
|
||||
// Nothing in the client asks for any of these, and each is a capability
|
||||
// that exists only to be abused.
|
||||
settings.setGeolocationEnabled(false)
|
||||
settings.allowFileAccess = false
|
||||
settings.allowContentAccess = false
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Debug only: a release build must not expose a remote debugging port
|
||||
// that any other app on the device can attach to.
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
webView.stopLoading()
|
||||
webView.clearHistory()
|
||||
webView.removeJavascriptInterface(ReaderBridge.NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the bundled assets and keeps the reading session inside the app.
|
||||
*
|
||||
* A link out of a book is handed to the platform rather than followed: it
|
||||
* should open in the user's browser, not replace the reader with a web page
|
||||
* and lose the reading session.
|
||||
*/
|
||||
private inner class AssetClient : WebViewClient() {
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): WebResourceResponse? = assetLoader.shouldInterceptRequest(request.url)
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||
val url = request.url.toString()
|
||||
if (isInternalUrl(url)) return false
|
||||
openExternally(url)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun openExternally(url: String) {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}.onFailure {
|
||||
// No browser installed, or the scheme is unsupported. Nothing to
|
||||
// do, and nothing worth interrupting the reader for.
|
||||
Log.d(TAG, "cannot open external url")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ChromeClient : WebChromeClient() {
|
||||
override fun onConsoleMessage(message: ConsoleMessage): Boolean {
|
||||
// Surface the client's console into logcat. Without this, debugging the
|
||||
// web layer on a device means attaching a remote debugger, which is not
|
||||
// a realistic ask for a self-hosting user filing a bug report.
|
||||
Log.d(TAG, "[web] ${message.message()} @${message.lineNumber()}")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ReaderWeb"
|
||||
|
||||
/**
|
||||
* A reserved domain that `WebViewAssetLoader` intercepts.
|
||||
*
|
||||
* Deliberately not resolvable on the public internet: if the interception
|
||||
* were ever bypassed, nothing would be fetched from a real host.
|
||||
*/
|
||||
const val ASSET_HOST = "appassets.androidplatform.net"
|
||||
const val ASSET_ORIGIN = "https://$ASSET_HOST"
|
||||
private const val START_URL = "$ASSET_ORIGIN/assets/index.html"
|
||||
|
||||
/** Address the client is loaded from, reported through the bridge. */
|
||||
fun assetOrigin(): String = ASSET_ORIGIN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URLs that must stay inside the WebView.
|
||||
*
|
||||
* The internal `reader-res:` scheme is how injected book content resolves its own
|
||||
* images and stylesheets out of the in-memory resource map; letting it be treated
|
||||
* as external would break every illustrated book.
|
||||
*/
|
||||
internal fun isInternalUrl(url: String): Boolean {
|
||||
val internalPrefixes = listOf(
|
||||
WebHost.ASSET_ORIGIN,
|
||||
"reader-res:",
|
||||
"blob:",
|
||||
"data:",
|
||||
"about:",
|
||||
)
|
||||
return internalPrefixes.any { url.startsWith(it) }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Reader" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@color/paper_dark</item>
|
||||
<item name="android:statusBarColor">@color/paper_dark</item>
|
||||
<item name="android:navigationBarColor">@color/paper_dark</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Matches --reader-paper in web/src/styles/reader.css. -->
|
||||
<color name="paper">#FBFBF7</color>
|
||||
<color name="paper_dark">#16171A</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">reader</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
The native theme exists only for the split second before the WebView has
|
||||
painted. It matches the web client's default paper colour so there is no
|
||||
flash of a different background on launch.
|
||||
-->
|
||||
<style name="Theme.Reader" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@color/paper</item>
|
||||
<item name="android:statusBarColor">@color/paper</item>
|
||||
<item name="android:navigationBarColor">@color/paper</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Nothing is backed up.
|
||||
|
||||
The offline cache can be hundreds of megabytes, and the stored session holds a
|
||||
refresh token: a device-bound credential that should not be copied to another
|
||||
device by a backup mechanism the reader never chose. Re-downloading a book is
|
||||
cheap; a leaked token is not.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="external" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="file" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Root build file.
|
||||
*
|
||||
* The Android shell is deliberately dependency-light. It has exactly two jobs —
|
||||
* host a WebView and copy the shared web bundle out of assets — and every extra
|
||||
* dependency is weight an open-source APK has to justify, plus another thing that
|
||||
* can be missing from F-Droid or a de-Googled device.
|
||||
*/
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
kotlin.code.style=official
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Gradle wrapper launcher.
|
||||
#
|
||||
# Committed so a contributor (and CI) can build without installing Gradle. It
|
||||
# downloads the distribution named in gradle/wrapper/gradle-wrapper.properties on
|
||||
# first use and caches it.
|
||||
set -eu
|
||||
|
||||
APP_HOME=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
JAVA_CMD=${JAVA_HOME:+$JAVA_HOME/bin/java}
|
||||
JAVA_CMD=${JAVA_CMD:-java}
|
||||
|
||||
exec "$JAVA_CMD" \
|
||||
-Xmx2048m \
|
||||
-classpath "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/bin/sh
|
||||
# Builds the shared web client and stages it as Android assets.
|
||||
#
|
||||
# This script is the mechanism that keeps the Android and H5 clients from becoming
|
||||
# two implementations: one Vite bundle, one host-agnostic web layer, two hosts.
|
||||
#
|
||||
# Run from anywhere; paths are resolved relative to the script.
|
||||
set -eu
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
android_dir=$(dirname -- "$script_dir")
|
||||
repo_dir=$(dirname -- "$android_dir")
|
||||
web_dir="$repo_dir/web"
|
||||
dest="$android_dir/app/src/main/assets/web-assets"
|
||||
|
||||
if [ ! -d "$web_dir" ]; then
|
||||
echo "web client not found at $web_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> building web client"
|
||||
cd "$web_dir"
|
||||
# `npm ci` when a lockfile exists so a release build is reproducible; `install`
|
||||
# otherwise so a first-time contributor is not blocked on generating one.
|
||||
if [ -f package-lock.json ]; then
|
||||
npm ci
|
||||
else
|
||||
npm install
|
||||
fi
|
||||
npm run build
|
||||
|
||||
echo "==> staging assets into $dest"
|
||||
rm -rf "$dest"
|
||||
mkdir -p "$dest"
|
||||
# Only the built output. Copying the sources would ship the whole TypeScript tree
|
||||
# inside the APK for no benefit.
|
||||
cp -R "$web_dir/dist/." "$dest/"
|
||||
|
||||
echo "==> done"
|
||||
echo "The Android build now packs $(find "$dest" -type f | wc -l | tr -d ' ') files as assets."
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/sh
|
||||
# Static checks on the Android shell that do not need a JDK or the Android SDK.
|
||||
#
|
||||
# CI compiles the shell for real, but that job needs a large SDK image and takes
|
||||
# minutes. This runs in a second, so a typo in a package declaration or a bridge
|
||||
# method that the web client calls but the shell does not expose is caught before
|
||||
# anyone waits for a build — which matters more than usual here, because the two
|
||||
# halves of the bridge are written in different languages and cannot be
|
||||
# type-checked against each other.
|
||||
set -eu
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
android_dir=$(dirname -- "$script_dir")
|
||||
repo_dir=$(dirname -- "$android_dir")
|
||||
fail=0
|
||||
|
||||
note() { printf '%s\n' "$1"; }
|
||||
err() { printf 'FAIL: %s\n' "$1" >&2; fail=1; }
|
||||
|
||||
# 1. Package declaration must match the file's directory under src/main/java.
|
||||
note "==> checking package declarations match directory layout"
|
||||
for file in $(find "$android_dir/app/src" -name '*.kt'); do
|
||||
pkg=$(sed -n 's/^package \(.*\)$/\1/p' "$file" | head -1)
|
||||
rel=${file#"$android_dir"/app/src/main/java/}
|
||||
expected=$(dirname "$rel" | tr '/' '.')
|
||||
if [ "$pkg" != "$expected" ]; then
|
||||
err "$file declares '$pkg' but lives in '$expected'"
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. Every import of our own code must resolve to a file that exists.
|
||||
note "==> checking project imports resolve"
|
||||
for file in $(find "$android_dir/app/src" -name '*.kt'); do
|
||||
sed -n 's/^import cool\.cnb\.reader\.\(.*\)$/\1/p' "$file" | while read -r symbol; do
|
||||
# `BuildConfig` is generated, and a class name is the last dot segment.
|
||||
case "$symbol" in
|
||||
BuildConfig) continue ;;
|
||||
esac
|
||||
path="$android_dir/app/src/main/java/cool/cnb/reader/$(printf '%s' "$symbol" | tr '.' '/').kt"
|
||||
if [ ! -f "$path" ]; then
|
||||
err "$file imports cool.cnb.reader.$symbol but $path does not exist"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# 3. Every bridge method the web client calls must exist, and every bridge method
|
||||
# the shell exposes must be declared. This is the cross-language seam.
|
||||
note "==> checking the JS bridge contract in both directions"
|
||||
bridge_file="$android_dir/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt"
|
||||
declared=$(sed -n 's/^ fun \([A-Za-z][A-Za-z0-9_]*\)(.*/\1/p' "$bridge_file" | sort -u)
|
||||
types_file="$repo_dir/web/src/android-bridge.d.ts"
|
||||
consumed=$(sed -n 's/^ \([a-z][A-Za-z0-9_]*\)[?]\?(.*:.*;.*$/\1/p' "$types_file" | sort -u)
|
||||
|
||||
for method in $consumed; do
|
||||
if ! printf '%s\n' "$declared" | grep -qx "$method"; then
|
||||
err "web client calls ReaderAndroid.$method() but ReaderBridge.kt does not declare it"
|
||||
fi
|
||||
done
|
||||
|
||||
# Every bridge method must also appear in the client-facing declaration, or the
|
||||
# next person writing client code will not know it is available.
|
||||
missing_from_types=0
|
||||
for method in $declared; do
|
||||
if ! printf '%s\n' "$consumed" | grep -qx "$method"; then
|
||||
err "ReaderBridge.$method exists but is missing from android-bridge.d.ts"
|
||||
missing_from_types=1
|
||||
fi
|
||||
done
|
||||
[ "$missing_from_types" -eq 0 ] || true
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
note "==> shell checks failed"
|
||||
exit 1
|
||||
fi
|
||||
note "==> shell checks passed"
|
||||
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "reader"
|
||||
include(":app")
|
||||
+14
-2
@@ -7,8 +7,12 @@
|
||||
services:
|
||||
reader:
|
||||
image: cnb.cool/hectorqin/reader:latest
|
||||
# To build from source instead of pulling the published image:
|
||||
# build: ./server
|
||||
# To build from source instead of pulling the published image. The context is
|
||||
# the repository root because the image bakes in the H5 client built from
|
||||
# ./web as well as the server.
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: server/Dockerfile
|
||||
container_name: reader
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -25,6 +29,14 @@ services:
|
||||
# Uncomment to allow family members to self-register instead of the admin
|
||||
# creating their accounts:
|
||||
# ALLOW_REGISTRATION: "true"
|
||||
# Origins allowed to call the API from a browser, comma separated.
|
||||
# Empty (the default) reflects whatever origin asks. Fine for a LAN: the
|
||||
# client does not know the address it will be reached on, and there are no
|
||||
# cookies in the design. Set it if the instance is on the public internet:
|
||||
# CORS_ORIGINS: "https://reader.example.com"
|
||||
# Directory holding the built H5 client, served at `/`. Already set in the
|
||||
# image; only override if you build the client separately.
|
||||
# WEB_DIR: "/app/web"
|
||||
volumes:
|
||||
# --- your books, mounted READ-ONLY ---
|
||||
# The server NEVER writes to this directory. Replace the left-hand side
|
||||
|
||||
+33
@@ -135,6 +135,16 @@
|
||||
|
||||
书内容按哈希不可变,所以带 `ETag`,客户端可以放心长缓存。
|
||||
|
||||
### `GET /books/:id/file?path=<rel_path>`
|
||||
|
||||
流式返回这本书的**某一个**文件。
|
||||
|
||||
只有一种格式需要它:以图片目录形式存放的漫画。这种情况没有一个「书文件」可以整体
|
||||
下载,客户端按页取。`path` 必须是这本书已经登记过的 `rel_path`——
|
||||
服务端按 `book_files` 表精确匹配,不会用 query 拼路径,所以路径穿越只会匹配到空。
|
||||
|
||||
响应与 `/content` 一致,但 `ETag` 是按文件而非按书。
|
||||
|
||||
### `GET /books/:id/cover`
|
||||
|
||||
返回封面。无封面时 `404 NO_COVER`。缓存头是 `immutable`(封面随书籍主键变化)。
|
||||
@@ -156,6 +166,17 @@
|
||||
|
||||
---
|
||||
|
||||
## 静态客户端
|
||||
|
||||
### `GET /` 与 `GET /*` — 公开
|
||||
|
||||
服务端会托管构建好的 H5 客户端(构建时打进镜像,或由 `WEB_DIR` 指定)。
|
||||
|
||||
- 找不到客户端产物时这些路由**不会注册**,纯服务端部署不受影响
|
||||
- 都是公开的:它们只是静态资源,背后的 API 仍然要令牌
|
||||
- 不带扩展名的路径一律回落到 `index.html`,方便深链
|
||||
- `.html` 不缓存(否则升级后拿不到新产物),其余资源缓存 1 小时
|
||||
|
||||
## 书库
|
||||
|
||||
### `GET /library/facets`
|
||||
@@ -312,3 +333,15 @@
|
||||
| `USERNAME_TAKEN` | 409 | 用户名已占用 |
|
||||
| `PASSWORD_TOO_SHORT` | 409 | 口令少于 8 位 |
|
||||
| `INTERNAL` | 500 | 服务端错误 |
|
||||
|
||||
## 客户端应当遵守的约定
|
||||
|
||||
这几条不是服务端强制,而是客户端行为的一部分,写在这里以免下一个客户端重新踩:
|
||||
|
||||
1. **保存每次响应里的 `serverTime`**,下次带回来。从 0 拉全量在移动网络上很贵。
|
||||
2. **`locator` 服务端不解释**,格式由客户端定义,但一旦发布就要向后兼容——
|
||||
用户不会及时升级客户端,和自部署用户不会及时升级服务端是同一个问题。
|
||||
当前客户端用的是 `r1:<偏移>:<章节 id>`,偏移是章节内的比例而非像素或列号,
|
||||
这样换字号、换屏幕宽度、换主题都不会跳位置。
|
||||
3. **拉取回来的 `deleted: true` 是墓碑**,本地也要删;不要因为它「看起来是空的」就丢弃。
|
||||
4. **推送是合并而不是覆盖**,按 `updatedAt` 做 LWW。所以离线队列可以放心重放。
|
||||
+102
-8
@@ -183,18 +183,112 @@ id = sha256( (dc:identifier ?? '').toLowerCase() + '\0' + sha256(fileBytes) )
|
||||
|
||||
---
|
||||
|
||||
## 8. 客户端渲染(尚未实现)
|
||||
|
||||
产品设计里的关键决策:**不要直接套 Readium Mobile**。
|
||||
## 8. 客户端渲染
|
||||
|
||||
已落地。产品设计里的关键决策:**不要直接套 Readium Mobile**。
|
||||
Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本产品
|
||||
「忠实还原精排书设计」的核心差异化直接冲突。
|
||||
|
||||
计划方案(下一轮):
|
||||
实际方案:
|
||||
|
||||
- 原生外壳(Kotlin)只负责:文件与缓存、手势、书架、账号
|
||||
- 渲染层用 WebView + 自研 CSS 干预策略,参考 foliate-js 的「保留作者样式 + 最小覆盖」
|
||||
- 原生外壳(Kotlin)只负责:文件与缓存、手势、账号、连通性
|
||||
- 渲染层是 **WebView + 自研 CSS 干预策略**,保留作者样式、只做最小覆盖
|
||||
- 参考 readium-css 的变量组织方式,但**不引入它的默认接管行为**
|
||||
|
||||
服务端为此准备的是 `GET /api/v1/books/:id/manifest`:格式中立的清单接口,
|
||||
返回书信息和 `contentUrl`,PDF 支持可以复用同一个契约。
|
||||
### 只有一份渲染层
|
||||
|
||||
Android 不重写渲染,它加载的是同一份 `web/` 产物。这不是为了省事,
|
||||
而是因为排版策略一旦有第二份实现就一定会分叉,而排版正是差异点所在。
|
||||
|
||||
两边共用的抽象在 `web/src/core/platform.ts`:传输、键值存储、二进制存储、连通性。
|
||||
Android 只覆盖它真正能做得更好的部分——连通性和设备名——其余全部复用。
|
||||
|
||||
### Android 用 https 资产源,不用 file://
|
||||
|
||||
把打包好的 Web 应用塞进 assets,最直觉的加载方式是 `file:///android_asset/...`。
|
||||
这条路对**这个**产品是走不通的:从 `file://` 源发出的 `fetch()` 是不透明的跨源请求,
|
||||
所有访问服务端的调用都会被拦,而通常的绕法是把 web security 关掉——
|
||||
那就等于把 WebView 变成一扇敞开的门。
|
||||
|
||||
用 `WebViewAssetLoader` 从 `https://appassets.androidplatform.net/` 提供同样的文件,
|
||||
拿到的是一个正常的安全源:请求按普通 CORS 走,正好和服务端的 CORS 策略对上;
|
||||
`isSecureContext` 为真,IndexedDB 等 API 与浏览器里行为一致。
|
||||
这一个选择就是「一份 bundle 两个宿主」能成立的前提。
|
||||
|
||||
### 精排 CSS:干预策略
|
||||
|
||||
按重要程度排序的三条原则,写在 `web/src/styles/reader.css` 里:
|
||||
|
||||
1. **不设任何属性,除非不设会坏。**
|
||||
2. **每个可调项都是默认 `inherit` 的可继承自定义属性**——「关」的意思是「不动它」,
|
||||
不是「用我们的默认值」。这一点很关键:给行距写死一个具体值,会把一本按章节
|
||||
变行距的书压平。
|
||||
3. **任何改变作者意图的行为都是用户主动开启,且持久化**,绝不是默认。
|
||||
|
||||
实际 override 只有七处,每处都在 CSS 注释里写了为什么:图片/表格/pre 在窄屏会撑破
|
||||
布局、ruby 振假名在 16px 下按默认 50% 太小、长中文标题不换行会溢出。除此之外不动。
|
||||
|
||||
对比一下被删掉的做法:reset 样式表、统一的「阅读主题」、给 `p` 加边距。
|
||||
它们都能让排版「看起来一致」,而一致性恰恰是这里要避免的东西。
|
||||
|
||||
### 书的样式隔离在 shadow root 里
|
||||
|
||||
书里一条 `p { color: red }` 不能改到书架,App 的 `.topbar` 也不能被书撞上。
|
||||
iframe 同样能隔离,但每次换章要重建文档:滚动位置丢失、Android 上内存翻几倍。
|
||||
shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做分页和取选中。
|
||||
关键是**不往 shadow root 里注入任何 reset**。
|
||||
|
||||
### 书不能联网
|
||||
|
||||
加载时把章节里所有本地引用改写成内部 `reader-res:` 协议,渲染层从内存资源表解出;
|
||||
绝对 URL 原样保留,然后被 CSP 挡掉。这样一本来路不明的书无法把阅读行为报到远端。
|
||||
资源按需解出而不是预展开——不然打开一本带大图的 EPUB 会先把整个包塞进内存。
|
||||
|
||||
### 阅读位置格式
|
||||
|
||||
`r1:<偏移>:<章节 id>`。三点都是踩过之后定的:
|
||||
|
||||
- **版本前缀**:服务端把 locator 当不透明字符串长期保存,客户端将来改格式时必须
|
||||
能认出「这不是我认识的格式」。`r1` 给了这个能力。
|
||||
- **章节 id 放最后**:章节 id 是文件路径,会含冒号。只从前两个冒号切、剩下全保留,
|
||||
是唯一不会丢数据的解析方式。
|
||||
- **偏移是章节内的比例**,不是像素、不是 CSS 列号。列号会随字号变化,
|
||||
于是读者一调字号,位置就跳到另一个句子上——阅读 App 能犯的最让人恼火的错误之一。
|
||||
|
||||
### 待发送队列,而不是「每次都全发」
|
||||
|
||||
离线队列记的是**脏集合**:哪些记录还没送到服务端,而不是「当前全部状态」。
|
||||
|
||||
两者在一种情况下结果不同,而这种情况一定会发生:记录在请求发出之后、响应到达之前
|
||||
被修改了(读者在同步进行时翻了一页)。脏集合下,这次更新仍然是脏的,要等**它自己**
|
||||
的确认才清除;「全发」模式下,它会被那个更早的响应顺手清掉,然后永远送不到服务端——
|
||||
一个丢失的阅读位置,是这个产品最不能有的失败。
|
||||
|
||||
### 离线队列的抗崩
|
||||
|
||||
队列在本地镜像里,不在内存里。所以读到一半直接杀进程,位置不会丢——
|
||||
下一次启动接着送。笔记删除同样写墓碑后再入队,避免离线设备让已删笔记复活。
|
||||
|
||||
### TXT 的编码与章节
|
||||
|
||||
本地书库里 TXT 占比很大,而它既没有结构也没有编码声明。
|
||||
|
||||
编码:先严格校验 UTF-8(自己实现,不用 `TextDecoder` 的宽松模式——宽松模式永远
|
||||
「成功」,于是你永远发现不了自己解错了)。校验失败再依次试 GB18030、Big5。
|
||||
结果里带 `guessed` 标记,界面上告诉用户选了哪个并允许手动覆盖。
|
||||
|
||||
章节:TXT 没有目录,只能从标题推断。策略是**宁可少认,不要错认**——
|
||||
一个 800 字的段落以「第三章」开头时不能当标题,而正文里提到「他在第二章读过」
|
||||
更不能当标题。两条都有测试固定住。推断不出章节时按长度分段:
|
||||
一本五千章的网文如果变成一整节,分页会直接卡死。
|
||||
|
||||
### 漫画的页序
|
||||
|
||||
按自然序排(`1, 2, 10` 而不是 `1, 10, 2`)。排错不是观感问题,是故事被静默打乱。
|
||||
文件名无信息量时用页序当标题,因为 `a1b2c3.png` 对读者没有意义。
|
||||
|
||||
### 一页一个 section
|
||||
|
||||
漫画和 PDF 是 `layout: 'fixed'`,每页一个 section,由分页器整屏推进。
|
||||
固定布局的宿主不做滚动容器——在横向分页器里嵌一个滚动区会产生
|
||||
Android 上经典的「页面自己往边上飘」bug。
|
||||
@@ -1,6 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
.git
|
||||
*.log
|
||||
test
|
||||
+28
-9
@@ -1,13 +1,29 @@
|
||||
# --- build stage -------------------------------------------------------------
|
||||
# --- web client build stage ---------------------------------------------------
|
||||
# The H5 client is built here and baked into the server image, because serving it
|
||||
# from the same container is what keeps the deployment to a single command: the
|
||||
# reader opens http://<nas-ip>:8080 and gets the app, with no second container and
|
||||
# no CORS configuration to think about.
|
||||
#
|
||||
# `web` is optional. When the build context has no `web` directory the copy below
|
||||
# fails, so the client stage is gated behind a build arg and the runtime stage
|
||||
# tolerates an absent bundle. A server-only deployment stays possible.
|
||||
FROM docker.cnb.cool/dockerhub/node:24-alpine AS web
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# --- server build stage ------------------------------------------------------
|
||||
FROM docker.cnb.cool/dockerhub/node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
# Install dev deps too: the TypeScript toolchain is needed to compile.
|
||||
COPY server/package.json server/package-lock.json* ./
|
||||
# Dev dependencies included: the TypeScript toolchain is needed to compile.
|
||||
RUN npm ci --include=dev
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
COPY server/tsconfig.json server/tsconfig.build.json ./
|
||||
COPY server/src ./src
|
||||
RUN npm run build
|
||||
|
||||
# Strip dev dependencies for the runtime image.
|
||||
@@ -20,16 +36,19 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
BOOKS_DIR=/books \
|
||||
DATA_DIR=/data \
|
||||
PORT=8080
|
||||
PORT=8080 \
|
||||
WEB_DIR=/app/web
|
||||
|
||||
# Run as an unprivileged user. The library mount is read-only, so this user
|
||||
# only ever needs write access to /data.
|
||||
# Run as an unprivileged user. The library mount is read-only, so this user only
|
||||
# ever needs write access to /data.
|
||||
RUN addgroup -S reader && adduser -S reader -G reader \
|
||||
&& mkdir -p /data /books && chown -R reader:reader /data /books
|
||||
|
||||
COPY --from=build --chown=reader:reader /app/node_modules ./node_modules
|
||||
COPY --from=build --chown=reader:reader /app/dist ./dist
|
||||
COPY --chown=reader:reader package.json ./
|
||||
COPY --from=build --chown=reader:reader /app/package.json ./
|
||||
# The built H5 client, served by the same process at `/`.
|
||||
COPY --from=web --chown=reader:reader /web/dist ./web
|
||||
|
||||
USER reader
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -29,6 +29,28 @@ export interface AppConfig {
|
||||
logLevel: string;
|
||||
/** Public base URL advertised to clients (used by download links). */
|
||||
publicUrl: string;
|
||||
/**
|
||||
* Origins allowed to call the API from a browser.
|
||||
*
|
||||
* Empty means "reflect any origin", which is the right default for a
|
||||
* self-hosted server on a LAN: the reader does not know the IP it will be
|
||||
* reached on, and there are no cookies in the design, so a permissive policy
|
||||
* does not hand out ambient authority the way it would for a session-cookie
|
||||
* API.
|
||||
*
|
||||
* Set it when the instance is on the public internet and is meant to be used
|
||||
* only by a specific H5 deployment.
|
||||
*/
|
||||
corsOrigins: string[];
|
||||
/**
|
||||
* Directory containing the built H5 client, served at `/`.
|
||||
*
|
||||
* Optional: the client can also be hosted separately (or loaded from
|
||||
* `file://` inside the Android shell). When present, `GET /` returns the app,
|
||||
* which is what makes "open the NAS IP in a browser" work with no extra
|
||||
* setup.
|
||||
*/
|
||||
webDir: string;
|
||||
}
|
||||
|
||||
const DATA_DIR_DEFAULT_NAME = 'data';
|
||||
@@ -81,5 +103,10 @@ export function loadConfig(): AppConfig {
|
||||
watchInterval: envInt('WATCH_INTERVAL', 60),
|
||||
logLevel: process.env.LOG_LEVEL ?? 'info',
|
||||
publicUrl: process.env.PUBLIC_URL ?? '',
|
||||
corsOrigins: (process.env.CORS_ORIGINS ?? '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0),
|
||||
webDir: resolve(process.env.WEB_DIR ?? join(process.cwd(), 'web')),
|
||||
};
|
||||
}
|
||||
+12
-5
@@ -4,6 +4,8 @@ import { registerErrorHandler } from './errors.ts';
|
||||
import { registerAuthRoutes } from './routes/auth.ts';
|
||||
import { registerLibraryRoutes } from './routes/library.ts';
|
||||
import { registerSyncRoutes } from './routes/sync.ts';
|
||||
import { registerWebRoutes } from './routes/web.ts';
|
||||
import { isOriginAllowed, resolveCorsOrigin } from './cors.ts';
|
||||
|
||||
export function buildApp(ctx: AppContext): FastifyInstance {
|
||||
const app = Fastify({
|
||||
@@ -16,14 +18,17 @@ export function buildApp(ctx: AppContext): FastifyInstance {
|
||||
|
||||
registerErrorHandler(app);
|
||||
|
||||
// Permissive CORS by default: a self-hosted reader is expected to be reached
|
||||
// from a LAN address, a reverse proxy and possibly a local file origin. There
|
||||
// are no cookies in the design, so this does not widen the attack surface to
|
||||
// the extent a cookie-based session would.
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
reply.header('access-control-allow-origin', request.headers.origin ?? '*');
|
||||
if (!isOriginAllowed(ctx.config, request)) {
|
||||
reply.status(403).send({ error: { code: 'FORBIDDEN', message: 'origin not allowed' } });
|
||||
return;
|
||||
}
|
||||
reply.header('access-control-allow-origin', resolveCorsOrigin(ctx.config, request));
|
||||
reply.header('access-control-allow-headers', 'authorization, content-type');
|
||||
reply.header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
||||
// `vary: origin` is required whenever the allowed origin is reflected rather
|
||||
// than fixed, otherwise a shared cache will serve one origin's response to
|
||||
// another.
|
||||
reply.header('vary', 'origin');
|
||||
});
|
||||
|
||||
@@ -32,6 +37,8 @@ export function buildApp(ctx: AppContext): FastifyInstance {
|
||||
registerAuthRoutes(app, ctx);
|
||||
registerLibraryRoutes(app, ctx);
|
||||
registerSyncRoutes(app, ctx);
|
||||
// Registered last: the SPA fallback must not shadow an API route.
|
||||
registerWebRoutes(app, ctx);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { AppConfig } from '../config/index.ts';
|
||||
|
||||
/**
|
||||
* CORS for the H5 client.
|
||||
*
|
||||
* Three deployments have to work, and the same policy covers all of them:
|
||||
*
|
||||
* 1. H5 served *by* this server at `/` — same origin, CORS is not even
|
||||
* involved beyond the origin header being echoed.
|
||||
* 2. H5 served from somewhere else (a static host, a dev server on :5174)
|
||||
* against a LAN server at an IP the client cannot know in advance.
|
||||
* 3. The Android shell, whose WebView origin is `file://` or a custom scheme.
|
||||
*
|
||||
* There are no cookies anywhere in the design: authentication is a bearer token
|
||||
* that the client attaches explicitly. That is what makes a permissive default
|
||||
* defensible — a malicious page cannot ride an ambient session, it would have to
|
||||
* already hold the token. `CORS_ORIGINS` narrows it for public deployments.
|
||||
*/
|
||||
export function resolveCorsOrigin(config: AppConfig, request: FastifyRequest): string {
|
||||
const origin = request.headers.origin;
|
||||
// Non-browser clients (curl, the Android native layer) send no Origin; the
|
||||
// header is then irrelevant and echoing nothing is correct.
|
||||
if (!origin) return '*';
|
||||
if (config.corsOrigins.length === 0) return origin;
|
||||
|
||||
if (config.corsOrigins.includes(origin)) return origin;
|
||||
|
||||
// `file://` requests arrive with the literal origin "null". The Android shell
|
||||
// needs this whenever the H5 bundle is loaded from assets, and it cannot be
|
||||
// expressed as a normal origin entry.
|
||||
if (origin === 'null' && config.corsOrigins.includes('null')) return 'null';
|
||||
|
||||
return config.corsOrigins[0] ?? origin;
|
||||
}
|
||||
|
||||
export function isOriginAllowed(config: AppConfig, request: FastifyRequest): boolean {
|
||||
const origin = request.headers.origin;
|
||||
if (!origin) return true;
|
||||
if (config.corsOrigins.length === 0) return true;
|
||||
return config.corsOrigins.includes(origin) || (origin === 'null' && config.corsOrigins.includes('null'));
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import type { FastifyInstance } from 'fastify';
|
||||
import type { AppContext } from '../context.ts';
|
||||
import { authenticate, currentUser, requireAdmin } from '../auth.ts';
|
||||
import { badRequest, notFound } from '../../lib/errors.ts';
|
||||
import { assertSafeRel, resolveInside } from '../../lib/paths.ts';
|
||||
import { assertSafeRel, normalizeRel, resolveInside } from '../../lib/paths.ts';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream, existsSync } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { extname, join } from 'node:path';
|
||||
@@ -10,6 +11,16 @@ import { extname, join } from 'node:path';
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
'.epub': 'application/epub+zip',
|
||||
'.pdf': 'application/pdf',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.cbz': 'application/vnd.comicbook+zip',
|
||||
'.zip': 'application/zip',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.bmp': 'image/bmp',
|
||||
};
|
||||
|
||||
export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): void {
|
||||
@@ -90,6 +101,51 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
|
||||
return reply.send(createReadStream(abs));
|
||||
});
|
||||
|
||||
/**
|
||||
* Streams one file of a multi-file book.
|
||||
*
|
||||
* Needed for the one format that is not a single file: a comic stored as a
|
||||
* folder of images. Compositing those into an archive server-side would mean
|
||||
* writing into DATA_DIR on demand and re-doing the work whenever the folder
|
||||
* changes, so the client fetches pages individually instead and caches them
|
||||
* per page.
|
||||
*
|
||||
* Authorisation is the same as `/content`: the caller must be able to see the
|
||||
* book, and the requested path must be one of that book's *known* files. The
|
||||
* lookup is by `book_files.rel_path`, never by constructing a path from the
|
||||
* query, so a traversal attempt simply matches no row.
|
||||
*/
|
||||
app.get('/api/v1/books/:id/file', { preHandler: auth }, async (request, reply) => {
|
||||
const user = currentUser(request);
|
||||
const { id } = request.params as { id: string };
|
||||
const query = request.query as { path?: string };
|
||||
ctx.shelf.get(user.id, id); // authorises access
|
||||
if (!query.path) throw badRequest('path is required');
|
||||
|
||||
const row = ctx.db.get<{ rel_path: string; size: number }>(
|
||||
'SELECT rel_path, size FROM book_files WHERE book_id = ? AND rel_path = ? AND missing = 0',
|
||||
id,
|
||||
normalizeRel(query.path),
|
||||
);
|
||||
if (!row) throw notFound('no such file for this book', 'FILE_MISSING');
|
||||
|
||||
const abs = resolveInside(ctx.config.booksDir, assertSafeRel(row.rel_path));
|
||||
if (!existsSync(abs)) throw notFound('file is no longer on disk', 'FILE_MISSING');
|
||||
|
||||
reply.header('content-type', MIME_BY_EXT[extname(abs).toLowerCase()] ?? 'application/octet-stream');
|
||||
reply.header('content-length', String(row.size));
|
||||
// Unlike `/content`, this is one page of many, so the ETag is per file rather
|
||||
// than per book.
|
||||
//
|
||||
// Hashed rather than embedding the path: an HTTP header value must be
|
||||
// ISO-8859-1, and a comic page's path is routinely Chinese or Japanese. Putting
|
||||
// the raw path in the header throws `ERR_INVALID_CHAR` and turns a page request
|
||||
// into a 500 — which is exactly what happened the first time this was written.
|
||||
reply.header('etag', `"${id}:${createHash('sha256').update(row.rel_path).digest('hex').slice(0, 16)}"`);
|
||||
reply.header('cache-control', 'private, max-age=86400');
|
||||
return reply.send(createReadStream(abs));
|
||||
});
|
||||
|
||||
app.get('/api/v1/books/:id/cover', { preHandler: auth }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const row = ctx.db.get<{ cover_path: string | null }>('SELECT cover_path FROM books WHERE id = ?', id);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { extname, join, normalize, resolve, sep } from 'node:path';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { AppContext } from '../context.ts';
|
||||
|
||||
/**
|
||||
* Serves the built H5 client, when one is present.
|
||||
*
|
||||
* Why the server hosts it at all: the product's deployment constraint is "one
|
||||
* command and it runs" (§8.1), and telling a NAS owner to stand up a second
|
||||
* container for the web client would break that. If the bundle is absent the
|
||||
* routes are simply not registered, so a server-only deployment is unaffected.
|
||||
*
|
||||
* Everything here is unauthenticated on purpose: these are static assets. The
|
||||
* API behind them still requires a token, so serving the shell to an anonymous
|
||||
* request reveals nothing. It does mean the *existence* of an instance is
|
||||
* visible to anyone who can reach the port — which is already true of
|
||||
* `GET /api/v1/instance`.
|
||||
*/
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.webmanifest': 'application/manifest+json',
|
||||
};
|
||||
|
||||
export function registerWebRoutes(app: FastifyInstance, ctx: AppContext): void {
|
||||
const webDir = ctx.config.webDir;
|
||||
const indexFile = join(webDir, 'index.html');
|
||||
if (!existsSync(indexFile)) {
|
||||
ctx.log?.info?.({ webDir }, 'no web client bundle found, serving API only');
|
||||
return;
|
||||
}
|
||||
|
||||
const sendFile = (filePath: string, reply: FastifyReply): FastifyReply => {
|
||||
reply.header('content-type', MIME_BY_EXT[extname(filePath).toLowerCase()] ?? 'application/octet-stream');
|
||||
return reply.send(createReadStream(filePath));
|
||||
};
|
||||
|
||||
app.get('/', async (_request, reply) => sendFile(indexFile, reply));
|
||||
|
||||
app.get('/*', async (request, reply) => {
|
||||
const raw = (request.params as { '*': string })['*'] ?? '';
|
||||
// Resolve under webDir and refuse anything that escapes it: `..%2f..%2f`
|
||||
// would otherwise turn the static handler into an arbitrary file read.
|
||||
const candidate = resolve(webDir, normalize(raw).replace(/^(\.\.[/\\])+/, ''));
|
||||
if (candidate !== webDir && !candidate.startsWith(`${webDir}${sep}`)) {
|
||||
reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'not found' } });
|
||||
return;
|
||||
}
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
// Hashed-ish asset names change with each build, and the API promises
|
||||
// additive changes only, so a client can hold a bundle across server
|
||||
// upgrades without harm. `index.html` itself must never be cached or a
|
||||
// deployment would never pick up a new bundle.
|
||||
if (extname(candidate) !== '.html') {
|
||||
reply.header('cache-control', 'public, max-age=3600');
|
||||
} else {
|
||||
reply.header('cache-control', 'no-cache');
|
||||
}
|
||||
return sendFile(candidate, reply);
|
||||
}
|
||||
|
||||
// SPA fallback: the client routes by hash, but serving index.html for an
|
||||
// unknown path is the safer default than a 404 for a deep link.
|
||||
reply.header('cache-control', 'no-cache');
|
||||
return sendFile(indexFile, reply);
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { XMLParser } from 'fast-xml-parser';
|
||||
import { stripNullBytes } from '../lib/text.ts';
|
||||
import { parseFilename } from './filename.ts';
|
||||
|
||||
export type BookFormat = 'epub' | 'pdf' | 'unknown';
|
||||
export type BookFormat = 'epub' | 'pdf' | 'txt' | 'cbz' | 'comic-dir' | 'unknown';
|
||||
|
||||
export interface ExtractedMetadata {
|
||||
title: string;
|
||||
@@ -51,6 +51,107 @@ export function sha256(buf: Buffer): string {
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Text encoding detection for TXT books.
|
||||
*
|
||||
* The same validate-then-fallback approach the client uses, and for the same
|
||||
* reason: a strict UTF-8 check is reliable in the direction that matters. It is
|
||||
* duplicated rather than shared because the server and the client are separate
|
||||
* build targets with no common module, and a wrong answer on either side is
|
||||
* merely a hint — the client re-decodes from the bytes it downloads.
|
||||
*/
|
||||
export function detectTextEncoding(buf: Buffer): string {
|
||||
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) return 'utf-8';
|
||||
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) return 'utf-16le';
|
||||
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) return 'utf-16be';
|
||||
const sample = buf.subarray(0, Math.min(buf.length, 64 * 1024));
|
||||
if (isStrictUtf8(sample)) return 'utf-8';
|
||||
// GB18030 is a superset of GBK/GB2312, which is what almost every legacy
|
||||
// Chinese TXT in a personal library is.
|
||||
return 'gb18030';
|
||||
}
|
||||
|
||||
/** Strict UTF-8 validation: returns false if any replacement would occur. */
|
||||
export function isStrictUtf8(bytes: Buffer): boolean {
|
||||
let index = 0;
|
||||
while (index < bytes.length) {
|
||||
const byte = bytes[index]!;
|
||||
if (byte <= 0x7f) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let needed: number;
|
||||
let min: number;
|
||||
let code: number;
|
||||
if ((byte & 0xe0) === 0xc0) {
|
||||
needed = 1; min = 0x80; code = byte & 0x1f;
|
||||
} else if ((byte & 0xf0) === 0xe0) {
|
||||
needed = 2; min = 0x800; code = byte & 0x0f;
|
||||
} else if ((byte & 0xf8) === 0xf0) {
|
||||
needed = 3; min = 0x10000; code = byte & 0x07;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (index + needed >= bytes.length) return false;
|
||||
for (let offset = 1; offset <= needed; offset += 1) {
|
||||
const next = bytes[index + offset]!;
|
||||
if ((next & 0xc0) !== 0x80) return false;
|
||||
code = (code << 6) | (next & 0x3f);
|
||||
}
|
||||
if (code < min || code > 0x10ffff) return false;
|
||||
if (code >= 0xd800 && code <= 0xdfff) return false;
|
||||
index += needed + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts image entries in a ZIP without extracting them.
|
||||
*
|
||||
* Reads the end-of-central-directory record and walks the central directory
|
||||
* names, which is metadata only: no entry is inflated. That keeps a scan of a
|
||||
* hundred 200MB comic archives cheap, which matters because the scanner runs
|
||||
* every minute (see docs/architecture.md §3).
|
||||
*/
|
||||
export async function countZipImages(buf: Buffer): Promise<number | null> {
|
||||
const eocd = findEndOfCentralDirectory(buf);
|
||||
if (!eocd) return null;
|
||||
const entryCount = buf.readUInt16LE(eocd + 10);
|
||||
let offset = buf.readUInt32LE(eocd + 16);
|
||||
let pages = 0;
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
// Each central directory header is at least 46 bytes.
|
||||
if (offset + 46 > buf.length) break;
|
||||
if (buf.readUInt32LE(offset) !== 0x02014b50) break;
|
||||
const nameLength = buf.readUInt16LE(offset + 28);
|
||||
const extraLength = buf.readUInt16LE(offset + 30);
|
||||
const commentLength = buf.readUInt16LE(offset + 32);
|
||||
const name = buf.subarray(offset + 46, offset + 46 + nameLength).toString('latin1');
|
||||
if (name.endsWith('/')) {
|
||||
// directory entry
|
||||
} else {
|
||||
const ext = name.slice(name.lastIndexOf('.')).toLowerCase();
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.bmp'].includes(ext)) pages += 1;
|
||||
}
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates the end-of-central-directory record.
|
||||
*
|
||||
* Scanned backwards because the comment field after it may be up to 64KB, and it
|
||||
* is not at a fixed offset. The signature is checked rather than the position.
|
||||
*/
|
||||
function findEndOfCentralDirectory(buf: Buffer): number | null {
|
||||
const minimum = Math.max(0, buf.length - 22 - 0xffff);
|
||||
for (let index = buf.length - 22; index >= minimum; index -= 1) {
|
||||
if (buf.readUInt32LE(index) === 0x06054b50) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function asArray<T>(value: T | T[] | undefined): T[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
@@ -254,6 +355,48 @@ export async function parseBookFile(buf: Buffer, relPath: string): Promise<Parse
|
||||
return { format: 'pdf', contentHash, size, pageCount: info.pageCount, metadata };
|
||||
}
|
||||
|
||||
if (lower.endsWith('.txt')) {
|
||||
// Only cheap facts are extracted here: the encoding decision and the chapter
|
||||
// split are the *client's* to make, because they depend on what the reader
|
||||
// sees and can be overridden per book in the UI. Trying to do them server-side
|
||||
// would mean decoding a whole novel on every scan for information the client
|
||||
// is going to recompute anyway.
|
||||
const fallback = filenameMetadata(relPath);
|
||||
const encoding = detectTextEncoding(buf);
|
||||
return {
|
||||
format: 'txt',
|
||||
contentHash,
|
||||
size,
|
||||
pageCount: null,
|
||||
metadata: {
|
||||
...fallback,
|
||||
publisher: '',
|
||||
raw: { filename: relPath, encoding, bytes: buf.byteLength },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (lower.endsWith('.cbz') || lower.endsWith('.zip')) {
|
||||
// A CBZ is a ZIP of images. Reading the central directory for a page count is
|
||||
// cheap and gives the shelf a usable "N 页" label without unpacking anything.
|
||||
const pageCount = await countZipImages(buf).catch(() => null);
|
||||
const fallback = filenameMetadata(relPath);
|
||||
const isZip = lower.endsWith('.zip');
|
||||
return {
|
||||
format: 'cbz',
|
||||
contentHash,
|
||||
size,
|
||||
pageCount,
|
||||
metadata: {
|
||||
...fallback,
|
||||
// A `.zip` that contains images is a comic by content. Naming it as one
|
||||
// lets the client pick the comic renderer without re-sniffing.
|
||||
title: fallback.title,
|
||||
raw: { filename: relPath, pages: pageCount, container: isZip ? 'zip' : 'cbz' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
format: 'unknown',
|
||||
contentHash,
|
||||
@@ -286,6 +429,34 @@ export function filenameMetadata(relPath: string): ExtractedMetadata {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for a comic stored as a folder of images.
|
||||
*
|
||||
* Unlike every other format there is no single file to hash, so the identity is
|
||||
* built from the folder path plus the page count: the folder *is* the book, and
|
||||
* the reader expects it to stay one book when they add a page.
|
||||
*/
|
||||
export function comicDirectoryMetadata(relDir: string, pages: string[], totalBytes: number): ExtractedMetadata {
|
||||
const parsed = parseFilename(relDir);
|
||||
return {
|
||||
title: parsed.title,
|
||||
author: parsed.author,
|
||||
publisher: '',
|
||||
language: parsed.language,
|
||||
isbn: '',
|
||||
description: '',
|
||||
series: parsed.series,
|
||||
seriesIndex: parsed.seriesIndex,
|
||||
tags: [],
|
||||
pubdate: '',
|
||||
// The identifier anchors identity on the folder name rather than on the
|
||||
// contents, so adding a page does not create a second book.
|
||||
identifier: `comic-dir:${relDir}`,
|
||||
source: 'filename',
|
||||
raw: { directory: relDir, pages: pages.length, bytes: totalBytes },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate and read the cover image from an EPUB.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Natural ordering for page and chapter names.
|
||||
*
|
||||
* Shared with the client by intent rather than by imports: the two are separate
|
||||
* build targets with no common module, and the rule itself is small enough that
|
||||
* duplicating it is cheaper than a shared package. The behaviour must match,
|
||||
* because the server's page count and the client's page order have to agree.
|
||||
*
|
||||
* `1, 2, 10` rather than `1, 10, 2`. A comic sorted lexicographically reads in the
|
||||
* wrong order, which is not a cosmetic bug.
|
||||
*/
|
||||
export function naturalCompare(a: string, b: string): number {
|
||||
const left = splitChunks(a);
|
||||
const right = splitChunks(b);
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const l = left[index];
|
||||
const r = right[index];
|
||||
if (l === undefined) return -1;
|
||||
if (r === undefined) return 1;
|
||||
if (typeof l === 'number' && typeof r === 'number') {
|
||||
if (l !== r) return l - r;
|
||||
continue;
|
||||
}
|
||||
const ls = String(l);
|
||||
const rs = String(r);
|
||||
if (ls === rs) continue;
|
||||
const lower = ls.localeCompare(rs, undefined, { sensitivity: 'base', numeric: false });
|
||||
if (lower !== 0) return lower;
|
||||
return ls < rs ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function splitChunks(value: string): Array<string | number> {
|
||||
const chunks: Array<string | number> = [];
|
||||
for (const part of value.split(/(\\d+)/)) {
|
||||
if (part === '') continue;
|
||||
if (/^\\d+$/.test(part)) {
|
||||
// Long zero-padded runs would lose precision as a float.
|
||||
chunks.push(part.length > 15 ? Number.parseInt(part, 10) : Number(part));
|
||||
} else {
|
||||
chunks.push(part);
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
@@ -4,12 +4,51 @@ import { join } from 'node:path';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import type { AppConfig } from '../config/index.ts';
|
||||
import { computeBookId, computeFileId } from './identity.ts';
|
||||
import { parseBookFile, saveCover, type ExtractedMetadata, type ParsedBookFile } from './metadata.ts';
|
||||
import {
|
||||
comicDirectoryMetadata,
|
||||
parseBookFile,
|
||||
saveCover,
|
||||
type ExtractedMetadata,
|
||||
type ParsedBookFile,
|
||||
} from './metadata.ts';
|
||||
import { naturalCompare } from './natural-sort.ts';
|
||||
import { toRelative } from '../lib/paths.ts';
|
||||
import { collapseWhitespace, safeJsonParse } from '../lib/text.ts';
|
||||
|
||||
export const SUPPORTED_EXTENSIONS = new Set(['.epub', '.pdf']);
|
||||
const SKIP_DIRS = new Set(['.git', '@eaDir', '#recycle', '.DS_Store', 'lost+found']);
|
||||
/**
|
||||
* Formats the scanner will index.
|
||||
*
|
||||
* Deliberately broader than "the formats the server can parse". For most of these
|
||||
* the server's job is only to record that a file exists and extract whatever
|
||||
* metadata is cheap to get; *rendering* is the client's business (see
|
||||
* docs/architecture.md §8). A TXT novel or a comic archive that the server
|
||||
* ignored would simply be invisible on the shelf, which is the one outcome a
|
||||
* self-hosted library must not have.
|
||||
*/
|
||||
export const SUPPORTED_EXTENSIONS = new Set([
|
||||
'.epub',
|
||||
'.pdf',
|
||||
'.txt',
|
||||
'.cbz',
|
||||
'.zip',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Image formats, used to recognise a comic stored as a folder of pages.
|
||||
*
|
||||
* Such a book is not a file at all — the group of images *is* the book — so it is
|
||||
* handled by a separate pass (see `collectComicDirectories`) rather than through
|
||||
* `indexFile`.
|
||||
*/
|
||||
export const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.bmp']);
|
||||
|
||||
/** Files inside a comic folder that are not pages. */
|
||||
const NON_PAGE_PATTERNS = [/^\._/, /\.ds_store$/i, /thumbs\.db$/i, /comicinfo\.xml$/i];
|
||||
|
||||
/** Below this many images, a folder is treated as loose images rather than a book. */
|
||||
const MIN_COMIC_PAGES = 2;
|
||||
|
||||
const SKIP_DIRS = new Set(['.git', '@eaDir', '#recycle', '.DS_Store', 'lost+found', '__MACOSX']);
|
||||
|
||||
export interface ScanProgress {
|
||||
running: boolean;
|
||||
@@ -34,6 +73,17 @@ interface FileEntry {
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/** A folder of images that is treated as one book. */
|
||||
interface ComicDirectory {
|
||||
relDir: string;
|
||||
pages: Array<{ relPath: string; size: number; mtimeMs: number }>;
|
||||
totalBytes: number;
|
||||
/** Digest of the folder path plus its page set; used as the content hash. */
|
||||
/** Stable content hash derived from the folder path; see the note at its use. */
|
||||
identityHash: string;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
interface StoredFile {
|
||||
id: string;
|
||||
book_id: string;
|
||||
@@ -97,16 +147,31 @@ export class Scanner {
|
||||
|
||||
try {
|
||||
const found = await this.walk(this.config.booksDir);
|
||||
// Comic folders have no file to walk over, so they are collected in a
|
||||
// second pass. Their pages are registered as files of the folder's book.
|
||||
const comics = await this.collectComicDirectories(this.config.booksDir);
|
||||
for (const comic of comics) {
|
||||
for (const page of comic.pages) {
|
||||
found.push({ relPath: page.relPath, size: page.size, mtimeMs: page.mtimeMs });
|
||||
}
|
||||
}
|
||||
const stored = this.loadStoredFiles();
|
||||
const storedByPath = new Map(stored.map((row) => [row.rel_path, row]));
|
||||
const seen = new Set<string>();
|
||||
|
||||
const pagesByDir = new Map(comics.map((comic) => [comic.relDir, comic]));
|
||||
|
||||
for (const entry of found) {
|
||||
seen.add(entry.relPath);
|
||||
this.progress.scanned += 1;
|
||||
const previous = storedByPath.get(entry.relPath);
|
||||
try {
|
||||
await this.indexFile(entry, previous);
|
||||
const owner = comicOf(entry.relPath, pagesByDir);
|
||||
if (owner) {
|
||||
await this.indexComicDirectory(owner, entry, previous);
|
||||
} else {
|
||||
await this.indexFile(entry, previous);
|
||||
}
|
||||
} catch (err) {
|
||||
this.progress.failed += 1;
|
||||
this.progress.lastError = err instanceof Error ? err.message : String(err);
|
||||
@@ -211,6 +276,161 @@ export class Scanner {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes one page of a comic stored as a folder.
|
||||
*
|
||||
* Every page resolves to the *same* book id, so the folder appears as one book
|
||||
* with many files rather than as N separate entries. The id is deliberately
|
||||
* derived from the folder path rather than from page contents: adding a page to
|
||||
* a folder should not create a second book and orphan the reader's progress.
|
||||
*/
|
||||
private async indexComicDirectory(owner: ComicDirectory, entry: FileEntry, previous: StoredFile | undefined): Promise<void> {
|
||||
if (previous && previous.size === entry.size && previous.mtime_ms === entry.mtimeMs && previous.book_identifier === owner.identifier) {
|
||||
this.progress.unchanged += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = comicDirectoryMetadata(owner.relDir, owner.pages.map((page) => page.relPath), owner.totalBytes);
|
||||
const bookId = computeBookId(metadata.identifier, owner.identityHash);
|
||||
const existing = this.db.get<{ id: string }>('SELECT id FROM books WHERE id = ?', bookId);
|
||||
const now = Date.now();
|
||||
|
||||
// Only the first page becomes the cover: it is what the reader would pick,
|
||||
// and extracting every page to look for a better one would mean inflating the
|
||||
// whole archive on every scan.
|
||||
const coverPath = entry.relPath === owner.pages[0]?.relPath
|
||||
? await this.persistComicCover(bookId, join(this.config.booksDir, entry.relPath), entry.relPath)
|
||||
: undefined;
|
||||
|
||||
const parsed = {
|
||||
format: 'comic-dir' as const,
|
||||
contentHash: owner.identityHash,
|
||||
size: owner.totalBytes,
|
||||
pageCount: owner.pages.length,
|
||||
metadata,
|
||||
...(coverPath !== undefined ? { coverPath } : {}),
|
||||
};
|
||||
|
||||
if (!existing) {
|
||||
this.insertBook(bookId, parsed, now);
|
||||
this.progress.added += 1;
|
||||
this.db.run(
|
||||
`INSERT OR IGNORE INTO user_books (user_id, book_id, added_at)
|
||||
SELECT id, ?, ? FROM users`,
|
||||
bookId, now,
|
||||
);
|
||||
} else {
|
||||
this.progress.updated += 1;
|
||||
this.updateBook(bookId, parsed, now);
|
||||
}
|
||||
|
||||
const fileId = computeFileId(entry.relPath);
|
||||
if (previous) {
|
||||
this.db.run(
|
||||
'UPDATE book_files SET book_id = ?, size = ?, mtime_ms = ?, missing = 0, last_seen = ? WHERE id = ?',
|
||||
bookId, entry.size, entry.mtimeMs, now, previous.id,
|
||||
);
|
||||
} else {
|
||||
this.db.run(
|
||||
`INSERT INTO book_files (id, book_id, rel_path, size, mtime_ms, inode, missing, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, '', 0, ?, ?)
|
||||
ON CONFLICT(rel_path) DO UPDATE SET book_id = excluded.book_id, size = excluded.size,
|
||||
mtime_ms = excluded.mtime_ms, missing = 0, last_seen = excluded.last_seen`,
|
||||
fileId, bookId, entry.relPath, entry.size, entry.mtimeMs, now, now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistComicCover(bookId: string, absPage: string, relPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const data = await readFile(absPage);
|
||||
return await saveCover(this.config.dataDir, bookId, { data, contentType: contentTypeFor(relPath) });
|
||||
} catch (err) {
|
||||
this.log.warn({ err, relPath }, 'comic cover extraction failed');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds folders that are comics.
|
||||
*
|
||||
* A folder counts when it holds at least two images and no archives or books of
|
||||
* its own: a `books/` directory full of EPUBs plus a stray `cover.jpg` is not a
|
||||
* comic, and treating it as one would hide every book inside it.
|
||||
*/
|
||||
private async collectComicDirectories(root: string): Promise<ComicDirectory[]> {
|
||||
const out: ComicDirectory[] = [];
|
||||
|
||||
const visit = async (dir: string): Promise<void> => {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
this.log.warn({ err, dir }, 'unreadable directory, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const images: Array<{ relPath: string; size: number; mtimeMs: number }> = [];
|
||||
let hasBookFile = false;
|
||||
const subdirs: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
|
||||
if (NON_PAGE_PATTERNS.some((pattern) => pattern.test(entry.name))) continue;
|
||||
const abs = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
subdirs.push(abs);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = entry.name.slice(entry.name.lastIndexOf('.')).toLowerCase();
|
||||
if (SUPPORTED_EXTENSIONS.has(ext)) {
|
||||
hasBookFile = true;
|
||||
continue;
|
||||
}
|
||||
if (!IMAGE_EXTENSIONS.has(ext)) continue;
|
||||
try {
|
||||
const info = await stat(abs);
|
||||
images.push({ relPath: toRelative(root, abs), size: info.size, mtimeMs: Math.floor(info.mtimeMs) });
|
||||
} catch {
|
||||
// A file that vanished mid-walk is not worth reporting.
|
||||
}
|
||||
}
|
||||
|
||||
if (dir !== root && !hasBookFile && images.length >= MIN_COMIC_PAGES) {
|
||||
const sorted = [...images].sort((a, b) => naturalCompare(a.relPath, b.relPath));
|
||||
const relDir = toRelative(root, dir);
|
||||
const totalBytes = sorted.reduce((sum, page) => sum + page.size, 0);
|
||||
out.push({
|
||||
relDir,
|
||||
pages: sorted,
|
||||
totalBytes,
|
||||
// Identity is the *folder*, not its contents.
|
||||
//
|
||||
// The tempting alternative — hashing the page set — is wrong for the
|
||||
// same reason hashing a file's bytes is wrong for a single book: the
|
||||
// reader adds one page to a folder and their progress and bookmarks for
|
||||
// that comic are gone. A folder path is the stable anchor here, exactly
|
||||
// as `dc:identifier` is for an EPUB, and the page set is allowed to
|
||||
// change underneath it.
|
||||
//
|
||||
// The consequence is accepted deliberately: renaming the folder makes a
|
||||
// new book. That is visible and recoverable, whereas silently losing the
|
||||
// reading position is not.
|
||||
identityHash: createHash('sha256').update(`comic-dir\u0000${relDir}`).digest('hex'),
|
||||
identifier: `comic-dir:${relDir}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Subfolders are still walked even when this folder was claimed as a comic:
|
||||
// a comic folder containing a `bonus/` subfolder would otherwise hide it.
|
||||
for (const subdir of subdirs) await visit(subdir);
|
||||
};
|
||||
|
||||
await visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two stage change detection (§6, 增量扫描):
|
||||
* 1. mtime + size -> cheap, skips the vast majority of files
|
||||
@@ -343,3 +563,30 @@ export function parseTags(value: string): string[] {
|
||||
}
|
||||
|
||||
export type { ExtractedMetadata };
|
||||
|
||||
/** Which comic folder a path belongs to, if any. */
|
||||
function comicOf(relPath: string, comics: Map<string, ComicDirectory>): ComicDirectory | undefined {
|
||||
for (const [relDir, comic] of comics) {
|
||||
if (relPath.startsWith(`${relDir}/`)) return comic;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** MIME type for a cover image, derived from its extension. */
|
||||
function contentTypeFor(relPath: string): string {
|
||||
const ext = relPath.slice(relPath.lastIndexOf('.')).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
case '.avif':
|
||||
return 'image/avif';
|
||||
case '.bmp':
|
||||
return 'image/bmp';
|
||||
default:
|
||||
return 'image/jpeg';
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,23 @@ function auth(token: string) {
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an account through the admin path and signs in.
|
||||
*
|
||||
* Public registration is only open for the very first account (by design), so
|
||||
* the multi-format tests below cannot use `register`. Going through
|
||||
* `createAsAdmin` also exercises the path a real family member's account takes.
|
||||
*/
|
||||
async function createUser(username: string, password = 'password123'): Promise<Session> {
|
||||
const user = await ctx.users.createAsAdmin({ username, password });
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/v1/auth/login', payload: { username, password },
|
||||
});
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
const body = res.json() as { accessToken: string; user: { id: string } };
|
||||
return { token: body.accessToken, userId: user.id };
|
||||
}
|
||||
|
||||
test('first account becomes admin, later public registrations are refused', async () => {
|
||||
const first = await register('owner');
|
||||
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(first.token) });
|
||||
@@ -358,3 +375,238 @@ async function login(username: string, password?: string): Promise<Session> {
|
||||
const body = res.json() as { accessToken: string; user: { id: string } };
|
||||
return { token: body.accessToken, userId: body.user.id };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-format library (§6 format scope)
|
||||
//
|
||||
// The scanner has to index formats it cannot itself parse. If it did not, a TXT
|
||||
// novel or a comic archive would simply be absent from the shelf — the one
|
||||
// outcome a self-hosted library must never produce, because the reader has no way
|
||||
// to tell "unsupported" from "missing".
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function makeCbz(pages: string[]): Promise<Buffer> {
|
||||
const zip = new JSZip();
|
||||
for (const page of pages) {
|
||||
// A tiny real PNG header is enough: nothing inflates these during a scan.
|
||||
zip.file(page, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
}
|
||||
return zip.generateAsync({ type: 'nodebuffer' });
|
||||
}
|
||||
|
||||
test('a TXT book is indexed without being decoded by the server', async () => {
|
||||
const session = await createUser('txtuser');
|
||||
// GB18030 bytes with no BOM: the common shape of a legacy Chinese TXT. The
|
||||
// server must index it anyway — deciding the encoding and splitting chapters is
|
||||
// the client's job (it has the reader's eyes and an override control), and
|
||||
// doing that work server-side would mean decoding a whole novel on every scan.
|
||||
const gb18030 = Buffer.from([0xb5, 0xda, 0xd2, 0xbb, 0xd5, 0xc2, 0x0a, 0xd5, 0xfd, 0xce, 0xc4, 0xa1, 0xa3]);
|
||||
await writeFile(join(booksDir, '长篇.txt'), gb18030);
|
||||
await ctx.scanner.scan();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/books?format=txt', headers: auth(session.token) });
|
||||
const body = res.json() as { items: Array<{ title: string; format: string }>; total: number };
|
||||
assert.equal(body.total, 1);
|
||||
assert.equal(body.items[0]!.format, 'txt');
|
||||
assert.equal(body.items[0]!.title, '长篇');
|
||||
});
|
||||
|
||||
test('an ambiguous "title - author" file name is kept whole rather than guessed at', async () => {
|
||||
// `长篇 - 某作者` has no marker to say which side is which, so the conservative
|
||||
// rule keeps the whole string as the title. Losing an author costs the reader one
|
||||
// manual edit; guessing wrong files the book under the wrong person.
|
||||
const session = await createUser('txtambiguous');
|
||||
await writeFile(join(booksDir, '长篇 - 某作者.txt'), '第一章\n正文。', 'utf8');
|
||||
await ctx.scanner.scan();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET', url: `/api/v1/books?search=${encodeURIComponent('长篇')}`, headers: auth(session.token),
|
||||
});
|
||||
const body = res.json() as { items: Array<{ title: string; author: string }> };
|
||||
assert.equal(body.items[0]!.title, '长篇 - 某作者');
|
||||
assert.equal(body.items[0]!.author, '');
|
||||
});
|
||||
|
||||
test('a CBZ comic is indexed with a page count, without unpacking it', async () => {
|
||||
const session = await createUser('cbzuser');
|
||||
await writeFile(join(booksDir, '某漫画.cbz'), await makeCbz(['001.png', '002.png', '010.png']));
|
||||
await ctx.scanner.scan();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/books?format=cbz', headers: auth(session.token) });
|
||||
const body = res.json() as { items: Array<{ title: string; pageCount: number | null }> };
|
||||
assert.equal(body.items.length, 1);
|
||||
assert.equal(body.items[0]!.title, '某漫画');
|
||||
// Three pages, counted from the central directory.
|
||||
assert.equal(body.items[0]!.pageCount, 3);
|
||||
});
|
||||
|
||||
test('a folder of images becomes one book with every page as a file', async () => {
|
||||
const session = await createUser('comicuser');
|
||||
const dir = join(booksDir, '图片漫画');
|
||||
await mkdir(dir, { recursive: true });
|
||||
for (const page of ['1.jpg', '2.jpg', '10.jpg']) {
|
||||
await writeFile(join(dir, page), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
||||
}
|
||||
await ctx.scanner.scan();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/books?format=comic-dir', headers: auth(session.token) });
|
||||
const body = res.json() as { items: Array<{ id: string; title: string; pageCount: number | null }> };
|
||||
assert.equal(body.items.length, 1);
|
||||
assert.equal(body.items[0]!.title, '图片漫画');
|
||||
assert.equal(body.items[0]!.pageCount, 3);
|
||||
|
||||
const manifest = await app.inject({
|
||||
method: 'GET', url: `/api/v1/books/${body.items[0]!.id}/manifest`, headers: auth(session.token),
|
||||
});
|
||||
const files = (manifest.json() as { files: Array<{ rel_path: string }> }).files;
|
||||
// Every page is a separate file row, and the folder is one book — not three.
|
||||
assert.deepEqual(files.map((file) => file.rel_path).sort(), [
|
||||
'图片漫画/1.jpg',
|
||||
'图片漫画/10.jpg',
|
||||
'图片漫画/2.jpg',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a comic folder keeps its identity when a page is added', async () => {
|
||||
const session = await createUser('comicidentity');
|
||||
const dir = join(booksDir, '系列漫画');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1]));
|
||||
await writeFile(join(dir, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 2]));
|
||||
await ctx.scanner.scan();
|
||||
const before = (await app.inject({
|
||||
method: 'GET', url: '/api/v1/books?format=comic-dir&search=系列漫画', headers: auth(session.token),
|
||||
})).json() as { items: Array<{ id: string }> };
|
||||
assert.equal(before.items.length, 1);
|
||||
|
||||
// Sync some progress, then add a page.
|
||||
await app.inject({
|
||||
method: 'PUT', url: `/api/v1/sync/progress/${before.items[0]!.id}`,
|
||||
headers: { ...auth(session.token), 'content-type': 'application/json' },
|
||||
payload: { locator: 'r1:0.0000:系列漫画/2.jpg', percentage: 0.5, chapterTitle: '第 2 页', device: 'test', updatedAt: Date.now() },
|
||||
});
|
||||
await writeFile(join(dir, '3.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 3]));
|
||||
await ctx.scanner.scan();
|
||||
|
||||
const after = (await app.inject({
|
||||
method: 'GET', url: '/api/v1/books?format=comic-dir&search=系列漫画', headers: auth(session.token),
|
||||
})).json() as { items: Array<{ id: string; pageCount: number | null }> };
|
||||
assert.equal(after.items.length, 1, 'adding a page must not create a second book');
|
||||
assert.equal(after.items[0]!.id, before.items[0]!.id);
|
||||
assert.equal(after.items[0]!.pageCount, 3);
|
||||
|
||||
// The reading position survives, which is the actual point of the identity rule.
|
||||
const progress = (await app.inject({
|
||||
method: 'GET', url: `/api/v1/sync/progress/${before.items[0]!.id}`, headers: auth(session.token),
|
||||
})).json() as { progress: { percentage: number } | null };
|
||||
assert.equal(progress.progress?.percentage, 0.5);
|
||||
});
|
||||
|
||||
test('a folder of EPUBs is not mistaken for a comic', async () => {
|
||||
// A stray cover.jpg next to real books must not turn the folder into a comic
|
||||
// and hide every book inside it.
|
||||
const session = await createUser('shelfdirectory');
|
||||
const dir = join(booksDir, '藏书目录');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, '一本书.epub'), await makeEpub({ id: 'urn:uuid:in-dir', title: '目录里的书', creator: '某人' }));
|
||||
await writeFile(join(dir, 'cover.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
||||
await writeFile(join(dir, 'cover2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
||||
await ctx.scanner.scan();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET', url: '/api/v1/books?search=目录里的书', headers: auth(session.token),
|
||||
});
|
||||
const body = res.json() as { items: Array<{ format: string }> };
|
||||
assert.equal(body.items.length, 1);
|
||||
assert.equal(body.items[0]!.format, 'epub');
|
||||
});
|
||||
|
||||
test('one page of a comic folder can be fetched by path', async () => {
|
||||
const session = await createUser('comicfetch');
|
||||
const dir = join(booksDir, '取图漫画');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 9, 9]));
|
||||
await writeFile(join(dir, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 8, 8]));
|
||||
await ctx.scanner.scan();
|
||||
|
||||
const list = (await app.inject({
|
||||
method: 'GET', url: '/api/v1/books?search=取图漫画', headers: auth(session.token),
|
||||
})).json() as { items: Array<{ id: string }> };
|
||||
const id = list.items[0]!.id;
|
||||
|
||||
const page = await app.inject({
|
||||
method: 'GET', url: `/api/v1/books/${id}/file?path=${encodeURIComponent('取图漫画/2.jpg')}`,
|
||||
headers: auth(session.token),
|
||||
});
|
||||
assert.equal(page.statusCode, 200);
|
||||
assert.equal(page.headers['content-type'], 'image/jpeg');
|
||||
// Exactly the bytes written above: 6, and the ETag header must not have failed
|
||||
// to build on a non-ASCII path.
|
||||
assert.equal(page.rawPayload.byteLength, 6);
|
||||
assert.match(page.headers['etag'] as string, /^"[0-9a-f]+:[0-9a-f]{16}"$/);
|
||||
});
|
||||
|
||||
test('the per-file endpoint refuses a path that is not part of the book', async () => {
|
||||
const session = await createUser('comictraversal');
|
||||
const dir = join(booksDir, '穿越漫画');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
||||
await writeFile(join(dir, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
||||
await writeFile(join(booksDir, 'secret.txt'), 'not yours');
|
||||
await ctx.scanner.scan();
|
||||
|
||||
const list = (await app.inject({
|
||||
method: 'GET', url: '/api/v1/books?search=穿越漫画', headers: auth(session.token),
|
||||
})).json() as { items: Array<{ id: string }> };
|
||||
const id = list.items[0]!.id;
|
||||
|
||||
// Traversal, an absolute path, and a sibling book's file. All three are
|
||||
// refusals because the lookup is against this book's own file rows: a path is
|
||||
// never constructed from the query string.
|
||||
for (const attempt of ['../../../etc/passwd', '/etc/passwd', 'secret.txt', '穿越漫画/../../secret.txt']) {
|
||||
const res = await app.inject({
|
||||
method: 'GET', url: `/api/v1/books/${id}/file?path=${encodeURIComponent(attempt)}`,
|
||||
headers: auth(session.token),
|
||||
});
|
||||
assert.equal(res.statusCode, 404, `expected 404 for ${attempt}, got ${res.statusCode}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a member cannot fetch a file of a book they cannot see', async () => {
|
||||
// The per-file endpoint authorises through the shelf exactly like /content does,
|
||||
// so there is no second code path to keep in step.
|
||||
const owner = await createUser('fileowner');
|
||||
const dir = join(booksDir, '私有漫画');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
||||
await writeFile(join(dir, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
||||
await ctx.scanner.scan();
|
||||
|
||||
const list = (await app.inject({
|
||||
method: 'GET', url: '/api/v1/books?search=私有漫画', headers: auth(owner.token),
|
||||
})).json() as { items: Array<{ id: string }> };
|
||||
const id = list.items[0]!.id;
|
||||
|
||||
// Hide it from a second account, then confirm the file endpoint refuses.
|
||||
// Created through `createUser` rather than a bare `createAsAdmin` + `login`,
|
||||
// because the latter pair is what a previous version of this test did and it
|
||||
// signed in with the wrong credentials, making the assertion meaningless.
|
||||
const other = await createUser('fileother');
|
||||
ctx.db.run('DELETE FROM user_books WHERE user_id = ? AND book_id = ?', other.userId, id);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET', url: `/api/v1/books/${id}/file?path=${encodeURIComponent('私有漫画/1.jpg')}`,
|
||||
headers: auth(other.token),
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
|
||||
test('the H5 client bundle is served when present and absent otherwise', async () => {
|
||||
// The static route is registered conditionally. This instance has no WEB_DIR,
|
||||
// so `/` must 404 rather than shadow the API — the failure mode being guarded
|
||||
// against is a catch-all that swallows /api/*.
|
||||
const health = await app.inject({ method: 'GET', url: '/api/v1/health' });
|
||||
assert.equal(health.statusCode, 200);
|
||||
const root = await app.inject({ method: 'GET', url: '/' });
|
||||
assert.equal(root.statusCode, 404);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1"
|
||||
/>
|
||||
<!--
|
||||
maximum-scale=1 disables pinch-zoom. That is deliberate: text size belongs
|
||||
to the reader's own control, and a visual zoom fights CSS pagination, which
|
||||
is measured in viewport units. Android WebView honours this; iOS Safari
|
||||
ignores it, which is acceptable since iOS is out of scope for v1.
|
||||
|
||||
No external font: a self-hosted reader must work with no outbound network
|
||||
at all, and a webfont would also fail on the LAN with no internet uplink.
|
||||
The book's own embedded fonts are unaffected — they load from the archive.
|
||||
-->
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#fbfaf7" />
|
||||
<title>reader</title>
|
||||
<style>
|
||||
/* First paint only: prevents a white flash before the bundle applies the
|
||||
stored theme, and gives the reader something to look at on a slow LAN. */
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
html[data-theme='dark'],
|
||||
html[data-theme='dark'] body {
|
||||
background: #16171a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3245
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "reader-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Reader client: shared rendering layer for the H5 build and the Android WebView shell.",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.3",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The contract the Android shell exposes to the web client.
|
||||
*
|
||||
* Declared here so the shared layer can feature-detect it without `any`. The
|
||||
* Kotlin side is `ReaderBridge.kt`; keep the two in step. Every method is
|
||||
* optional at the type level because the object simply does not exist in a
|
||||
* browser, and a partial implementation (an older APK, a stubbed test double)
|
||||
* must degrade rather than throw.
|
||||
*/
|
||||
export interface AndroidBridge {
|
||||
/** Version of the native shell, so the client can require a minimum. */
|
||||
shellVersion(): number;
|
||||
/** Device label recorded on progress rows, e.g. `Pixel 7`. */
|
||||
deviceLabel(): string;
|
||||
/** 'online' | 'offline' from the platform connectivity manager. */
|
||||
connectivity(): string;
|
||||
/** Registers a JS function to be called when connectivity changes. */
|
||||
watchConnectivity(callback: (state: string) => void): void;
|
||||
/** Shows a native toast. */
|
||||
toast(message: string): void;
|
||||
/** Reports whether the app currently has network permission. */
|
||||
hasNetwork(): boolean;
|
||||
/** Physical pixels of the usable viewport, for fixed-layout fitting. */
|
||||
viewport(): string;
|
||||
/** Bytes used by the offline cache, for a storage screen to report honestly. */
|
||||
cacheUsage(): string;
|
||||
/**
|
||||
* Everything a bug report needs: shell version, device, Android release,
|
||||
* app version. The reader can copy this out of the app, which is the only
|
||||
* realistic way a self-hosting user can report a client bug.
|
||||
*/
|
||||
diagnostics(): string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ReaderAndroid?: AndroidBridge;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,361 @@
|
||||
import { ApiError, errorForStatus, parseErrorBody } from './errors.ts';
|
||||
import type {
|
||||
Book,
|
||||
BookListPage,
|
||||
ContinueReadingItem,
|
||||
Facets,
|
||||
InstanceInfo,
|
||||
Manifest,
|
||||
Note,
|
||||
NoteType,
|
||||
Progress,
|
||||
Session,
|
||||
SyncPull,
|
||||
SyncPushResult,
|
||||
User,
|
||||
} from './types.ts';
|
||||
import type { Platform } from '../core/platform.ts';
|
||||
|
||||
export interface SessionStore {
|
||||
load(): Promise<Session | null>;
|
||||
save(session: Session): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ListQuery {
|
||||
search?: string;
|
||||
author?: string;
|
||||
series?: string;
|
||||
tag?: string;
|
||||
format?: string;
|
||||
sort?: 'title' | 'author' | 'added' | 'updated';
|
||||
order?: 'asc' | 'desc';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ProgressInput {
|
||||
bookId: string;
|
||||
locator: string;
|
||||
percentage: number;
|
||||
chapterTitle: string;
|
||||
/** Device label; the server records it so the UI can say "read on Pixel". */
|
||||
device: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface NoteInput {
|
||||
id: string;
|
||||
bookId: string;
|
||||
type: NoteType;
|
||||
locator: string;
|
||||
text: string;
|
||||
comment: string;
|
||||
color: string;
|
||||
updatedAt: number;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
/** One entry per HTTP call, so callers can drive their own retry policy. */
|
||||
export interface RequestOptions {
|
||||
signal?: AbortSignal;
|
||||
/** Skip the automatic refresh-and-retry, used by the refresh call itself. */
|
||||
noRetry?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single place that knows how to talk to a reader server.
|
||||
*
|
||||
* Responsibilities kept here on purpose:
|
||||
* - attaching the access token
|
||||
* - refreshing it exactly once when it expires, for any in-flight call
|
||||
* - de-duplicating concurrent refreshes, so a screen that fires six requests
|
||||
* after waking up does not rotate the refresh token six times (rotation is
|
||||
* single-use on the server — the extra five would be rejected and would
|
||||
* sign the user out)
|
||||
*/
|
||||
export class ReaderApi {
|
||||
private session: Session | null = null;
|
||||
private refreshPromise: Promise<Session> | null = null;
|
||||
private readonly listeners = new Set<(session: Session | null) => void>();
|
||||
|
||||
constructor(
|
||||
private readonly platform: Platform,
|
||||
private readonly sessions: SessionStore,
|
||||
) {}
|
||||
|
||||
get baseUrl(): string {
|
||||
return this.currentBaseUrl;
|
||||
}
|
||||
|
||||
private currentBaseUrl = '';
|
||||
|
||||
setBaseUrl(url: string): void {
|
||||
this.currentBaseUrl = url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async restore(): Promise<Session | null> {
|
||||
const stored = await this.sessions.load();
|
||||
if (!stored) return null;
|
||||
this.session = stored;
|
||||
return stored;
|
||||
}
|
||||
|
||||
currentSession(): Session | null {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
onSessionChange(listener: (session: Session | null) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private async setSession(session: Session | null): Promise<void> {
|
||||
this.session = session;
|
||||
if (session) await this.sessions.save(session);
|
||||
else await this.sessions.clear();
|
||||
for (const listener of this.listeners) listener(session);
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
const refreshToken = this.session?.refreshToken;
|
||||
if (refreshToken) {
|
||||
// Best effort: a failed revocation must not trap the user in a session
|
||||
// they asked to leave. The local credentials are cleared either way.
|
||||
await this.call('/api/v1/auth/logout', 'POST', { refreshToken }).catch(() => undefined);
|
||||
}
|
||||
await this.setSession(null);
|
||||
}
|
||||
|
||||
// ---- auth ----
|
||||
|
||||
async instance(): Promise<InstanceInfo> {
|
||||
return this.get<InstanceInfo>('/api/v1/instance');
|
||||
}
|
||||
|
||||
async register(username: string, password: string, displayName?: string): Promise<Session> {
|
||||
const session = await this.call<Session>('/api/v1/auth/register', 'POST', {
|
||||
username,
|
||||
password,
|
||||
...(displayName ? { displayName } : {}),
|
||||
});
|
||||
await this.setSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<Session> {
|
||||
const session = await this.call<Session>('/api/v1/auth/login', 'POST', { username, password });
|
||||
await this.setSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async me(): Promise<User> {
|
||||
const result = await this.get<{ user: User }>('/api/v1/auth/me');
|
||||
return result.user;
|
||||
}
|
||||
|
||||
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
await this.call('/api/v1/auth/password', 'POST', { currentPassword, newPassword });
|
||||
// The server revokes every session on a password change, including this one.
|
||||
await this.setSession(null);
|
||||
}
|
||||
|
||||
// ---- shelf ----
|
||||
|
||||
async listBooks(query: ListQuery = {}, options: RequestOptions = {}): Promise<BookListPage> {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : '';
|
||||
return this.get<BookListPage>(`/api/v1/books${suffix}`, options);
|
||||
}
|
||||
|
||||
async getBook(id: string, options: RequestOptions = {}): Promise<{ book: Book; progress: Progress | null }> {
|
||||
return this.get(`/api/v1/books/${encodeURIComponent(id)}`, options);
|
||||
}
|
||||
|
||||
async manifest(id: string, options: RequestOptions = {}): Promise<Manifest> {
|
||||
return this.get<Manifest>(`/api/v1/books/${encodeURIComponent(id)}/manifest`, options);
|
||||
}
|
||||
|
||||
async facets(options: RequestOptions = {}): Promise<Facets> {
|
||||
return this.get<Facets>('/api/v1/library/facets', options);
|
||||
}
|
||||
|
||||
async continueReading(limit = 20, options: RequestOptions = {}): Promise<ContinueReadingItem[]> {
|
||||
const result = await this.get<{ items: ContinueReadingItem[] }>(`/api/v1/library/continue?limit=${limit}`, options);
|
||||
return result.items;
|
||||
}
|
||||
|
||||
// ---- content ----
|
||||
|
||||
/** Authenticated book body. Not cacheable by the service worker: it needs a token. */
|
||||
async bookBytes(id: string, options: RequestOptions = {}): Promise<Uint8Array> {
|
||||
const response = await this.request(
|
||||
`/api/v1/books/${encodeURIComponent(id)}/content`,
|
||||
'GET',
|
||||
undefined,
|
||||
{ ...options, binary: true },
|
||||
);
|
||||
return response.bytes ?? new Uint8Array();
|
||||
}
|
||||
|
||||
async coverBytes(coverUrl: string, options: RequestOptions = {}): Promise<Uint8Array> {
|
||||
const response = await this.request(coverUrl, 'GET', undefined, { ...options, binary: true });
|
||||
return response.bytes ?? new Uint8Array();
|
||||
}
|
||||
|
||||
// ---- sync ----
|
||||
|
||||
async pull(since: number, bookId?: string, options: RequestOptions = {}): Promise<SyncPull> {
|
||||
const params = new URLSearchParams({ since: String(since) });
|
||||
if (bookId) params.set('bookId', bookId);
|
||||
return this.get<SyncPull>(`/api/v1/sync?${params.toString()}`, options);
|
||||
}
|
||||
|
||||
async push(
|
||||
payload: { progress?: ProgressInput[]; notes?: NoteInput[] },
|
||||
options: RequestOptions = {},
|
||||
): Promise<SyncPushResult> {
|
||||
return this.call<SyncPushResult>('/api/v1/sync', 'POST', payload, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single progress write. Cheaper than a batch and used for the periodic
|
||||
* "still reading" report, where there is nothing to reconcile.
|
||||
*/
|
||||
async putProgress(progress: ProgressInput, options: RequestOptions = {}): Promise<Progress | null> {
|
||||
const result = await this.call<{ progress: Progress | null }>(
|
||||
`/api/v1/sync/progress/${encodeURIComponent(progress.bookId)}`,
|
||||
'PUT',
|
||||
progress,
|
||||
options,
|
||||
);
|
||||
return result.progress;
|
||||
}
|
||||
|
||||
async getProgress(bookId: string, options: RequestOptions = {}): Promise<Progress | null> {
|
||||
const result = await this.get<{ progress: Progress | null }>(
|
||||
`/api/v1/sync/progress/${encodeURIComponent(bookId)}`,
|
||||
options,
|
||||
);
|
||||
return result.progress;
|
||||
}
|
||||
|
||||
async listNotes(bookId?: string, since = 0, options: RequestOptions = {}): Promise<Note[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (bookId) params.set('bookId', bookId);
|
||||
if (since > 0) params.set('since', String(since));
|
||||
const query = params.size > 0 ? `?${params.toString()}` : '';
|
||||
const result = await this.get<{ notes: Note[] }>(`/api/v1/notes${query}`, options);
|
||||
return result.notes;
|
||||
}
|
||||
|
||||
async createNote(note: NoteInput, options: RequestOptions = {}): Promise<Note> {
|
||||
return this.call<Note>('/api/v1/notes', 'POST', note, options);
|
||||
}
|
||||
|
||||
async deleteNote(id: string, options: RequestOptions = {}): Promise<void> {
|
||||
await this.call(`/api/v1/notes/${encodeURIComponent(id)}`, 'DELETE', undefined, options);
|
||||
}
|
||||
|
||||
// ---- transport plumbing ----
|
||||
|
||||
private async get<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const response = await this.request(path, 'GET', undefined, options);
|
||||
return response.json as T;
|
||||
}
|
||||
|
||||
private async call<T>(
|
||||
path: string,
|
||||
method: string,
|
||||
body?: unknown,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await this.request(path, method, body, options);
|
||||
return response.json as T;
|
||||
}
|
||||
|
||||
private async request(
|
||||
path: string,
|
||||
method: string,
|
||||
body: unknown,
|
||||
options: RequestOptions & { binary?: boolean } = {},
|
||||
): Promise<import('../core/platform.ts').HttpResponse> {
|
||||
const attempt = async (token: string | null) => {
|
||||
const headers: Record<string, string> = { accept: 'application/json' };
|
||||
if (body !== undefined) headers['content-type'] = 'application/json';
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
return this.platform.transport.send({
|
||||
url: path,
|
||||
method,
|
||||
headers,
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
...(options.binary ? { binary: true } : {}),
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const token = this.session?.accessToken ?? null;
|
||||
try {
|
||||
return await attempt(token);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError)) throw err;
|
||||
this.platform.reportError?.(err);
|
||||
// Only an expired access token is worth a retry, and only if there is a
|
||||
// refresh token to spend. `TOKEN_INVALID` means the signature or payload
|
||||
// is wrong — refreshing would not help and could burn the refresh chain.
|
||||
const retryable =
|
||||
!options.noRetry &&
|
||||
err.status === 401 &&
|
||||
(err.code === 'TOKEN_EXPIRED' || err.code === '' || err.code === 'NO_TOKEN') &&
|
||||
this.session?.refreshToken !== undefined;
|
||||
if (!retryable) {
|
||||
if (err.isAuthFailure) await this.setSession(null);
|
||||
throw err;
|
||||
}
|
||||
const refreshed = await this.refreshSession();
|
||||
try {
|
||||
return await attempt(refreshed.accessToken);
|
||||
} catch (retryErr) {
|
||||
if (retryErr instanceof ApiError && retryErr.isAuthFailure) await this.setSession(null);
|
||||
throw retryErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** De-duplicated refresh: concurrent callers share one rotation. */
|
||||
private refreshSession(): Promise<Session> {
|
||||
if (this.refreshPromise) return this.refreshPromise;
|
||||
const refreshToken = this.session?.refreshToken;
|
||||
if (!refreshToken) return Promise.reject(new ApiError('unauthorized', 'no refresh token'));
|
||||
|
||||
this.refreshPromise = (async () => {
|
||||
try {
|
||||
const response = await this.platform.transport.send({
|
||||
url: '/api/v1/auth/refresh',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
const session = response.json as Session;
|
||||
if (!session?.accessToken) throw new ApiError('unauthorized', 'refresh returned no session');
|
||||
await this.setSession(session);
|
||||
return session;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
// A rejected refresh token is terminal: the user must sign in again.
|
||||
await this.setSession(null);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
return this.refreshPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export { ApiError, errorForStatus, parseErrorBody };
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ApiErrorBody } from './types.ts';
|
||||
|
||||
/**
|
||||
* Every failure the client can distinguish, in one place.
|
||||
*
|
||||
* `offline` is not a server error: it means the request never reached a server.
|
||||
* The whole three-state design (LAN / public / fully offline, product design
|
||||
* §4) hangs off this being a first-class outcome rather than a generic throw.
|
||||
*/
|
||||
export type ApiErrorKind =
|
||||
| 'offline'
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'not_found'
|
||||
| 'conflict'
|
||||
| 'bad_request'
|
||||
| 'server'
|
||||
| 'aborted';
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly kind: ApiErrorKind;
|
||||
readonly code: string;
|
||||
readonly status: number;
|
||||
|
||||
constructor(kind: ApiErrorKind, message: string, code = '', status = 0) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.kind = kind;
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
/** True when the failure is a connectivity problem and a retry may succeed. */
|
||||
get isConnectivity(): boolean {
|
||||
return this.kind === 'offline';
|
||||
}
|
||||
|
||||
/** True when the failure means the stored credentials are no longer usable. */
|
||||
get isAuthFailure(): boolean {
|
||||
return this.kind === 'unauthorized' || this.kind === 'forbidden';
|
||||
}
|
||||
}
|
||||
|
||||
const KIND_BY_STATUS: Record<number, ApiErrorKind> = {
|
||||
400: 'bad_request',
|
||||
401: 'unauthorized',
|
||||
403: 'forbidden',
|
||||
404: 'not_found',
|
||||
409: 'conflict',
|
||||
};
|
||||
|
||||
export function errorForStatus(status: number): ApiErrorKind {
|
||||
if (KIND_BY_STATUS[status]) return KIND_BY_STATUS[status]!;
|
||||
if (status >= 500) return 'server';
|
||||
return 'server';
|
||||
}
|
||||
|
||||
export function isApiError(value: unknown): value is ApiError {
|
||||
return value instanceof ApiError;
|
||||
}
|
||||
|
||||
export function parseErrorBody(raw: string): { code: string; message: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as ApiErrorBody;
|
||||
if (parsed?.error?.code) return { code: parsed.error.code, message: parsed.error.message ?? '' };
|
||||
} catch {
|
||||
// Non-JSON error bodies (a proxy's HTML error page, an empty 502) are
|
||||
// common when a self-hosted instance sits behind a reverse proxy.
|
||||
}
|
||||
return { code: '', message: raw.slice(0, 200) };
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Wire types mirroring `docs/api.md`.
|
||||
*
|
||||
* Hand-written rather than generated: the server only makes additive changes
|
||||
* (docs/architecture.md §7), so a client type that omits a newly added field
|
||||
* keeps working. Generating them would create churn on every server-side
|
||||
* addition without buying any safety.
|
||||
*/
|
||||
|
||||
export interface Session {
|
||||
user: User;
|
||||
accessToken: string;
|
||||
accessTokenExpiresAt: number;
|
||||
refreshToken: string;
|
||||
refreshTokenExpiresAt: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
role: 'admin' | 'member';
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface InstanceInfo {
|
||||
name: string;
|
||||
apiVersion: number;
|
||||
registrationOpen: boolean;
|
||||
userCount: number;
|
||||
}
|
||||
|
||||
export interface Book {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
publisher: string;
|
||||
language: string;
|
||||
isbn: string;
|
||||
description: string;
|
||||
series: string;
|
||||
seriesIndex: number | null;
|
||||
tags: string[];
|
||||
pubdate: string;
|
||||
format: string;
|
||||
coverUrl: string | null;
|
||||
fileSize: number;
|
||||
pageCount: number | null;
|
||||
source: string;
|
||||
manualFields: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface BookListPage {
|
||||
items: Book[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface BookFile {
|
||||
rel_path: string;
|
||||
size: number;
|
||||
missing: number;
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
book: Book;
|
||||
contentUrl: string;
|
||||
coverUrl: string | null;
|
||||
files: BookFile[];
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
bookId: string;
|
||||
locator: string;
|
||||
percentage: number;
|
||||
chapterTitle: string;
|
||||
device: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type NoteType = 'note' | 'highlight' | 'bookmark';
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
bookId: string;
|
||||
type: NoteType;
|
||||
locator: string;
|
||||
text: string;
|
||||
comment: string;
|
||||
color: string;
|
||||
updatedAt: number;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncPull {
|
||||
serverTime: number;
|
||||
progress: Progress[];
|
||||
notes: Note[];
|
||||
}
|
||||
|
||||
export interface SyncPushResult extends SyncPull {
|
||||
accepted: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export interface Facets {
|
||||
authors: string[];
|
||||
series: string[];
|
||||
tags: string[];
|
||||
formats: string[];
|
||||
}
|
||||
|
||||
export interface ContinueReadingItem extends Book {
|
||||
percentage: number;
|
||||
chapterTitle: string;
|
||||
lastReadAt: number | null;
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
error: { code: string; message: string };
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import { ReaderApi, type SessionStore } from './api/client.ts';
|
||||
import { ApiError } from './api/errors.ts';
|
||||
import type { Book, Session } from './api/types.ts';
|
||||
import { createWebPlatform } from './core/web-platform.ts';
|
||||
import { createAndroidPlatform, detectAndroidBridge } from './core/android-platform.ts';
|
||||
import type { Platform } from './core/platform.ts';
|
||||
import { SyncEngine, type SyncStatus } from './core/sync.ts';
|
||||
import { OfflineStore } from './store/offline.ts';
|
||||
import { SettingsStore, DEFAULT_APP_SETTINGS, type AppSettings } from './store/settings.ts';
|
||||
import { ShelfScreen } from './ui/shelf-screen.ts';
|
||||
import { ReaderScreen } from './ui/reader-screen.ts';
|
||||
import { LoginScreen } from './ui/login-screen.ts';
|
||||
import { el } from './ui/dom.ts';
|
||||
|
||||
/**
|
||||
* Application shell: routing, lifecycle and the wiring between the layers.
|
||||
*
|
||||
* Intentionally tiny. Everything with a decision in it lives in a module with a
|
||||
* single responsibility — the formats know how to unpack a book, the view knows
|
||||
* how to lay it out, the sync engine knows when to talk to the server — and this
|
||||
* file only connects them and manages what is on screen.
|
||||
*/
|
||||
|
||||
const SCREEN_SHELF = 'shelf';
|
||||
const SCREEN_READER = 'reader';
|
||||
|
||||
export interface AppOptions {
|
||||
/** Baked-in server URL, used when nothing is stored yet. */
|
||||
defaultServerUrl?: string;
|
||||
root: HTMLElement;
|
||||
}
|
||||
|
||||
export class App {
|
||||
private readonly root: HTMLElement;
|
||||
private readonly sessions: SessionStore;
|
||||
private api!: ReaderApi;
|
||||
private platform!: Platform;
|
||||
private offline!: OfflineStore;
|
||||
private settingsStore!: SettingsStore;
|
||||
private sync!: SyncEngine;
|
||||
private settings: AppSettings = { ...DEFAULT_APP_SETTINGS };
|
||||
|
||||
private shelf: ShelfScreen | null = null;
|
||||
private reader: ReaderScreen | null = null;
|
||||
private screen: string | null = null;
|
||||
private pendingBook: Book | null = null;
|
||||
|
||||
constructor(private readonly options: AppOptions) {
|
||||
this.root = options.root;
|
||||
this.sessions = createSessionStore();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.platform = await this.createPlatform();
|
||||
this.api = new ReaderApi(this.platform, this.sessions);
|
||||
this.offline = new OfflineStore(this.platform.kv);
|
||||
this.settingsStore = new SettingsStore(this.platform.kv);
|
||||
this.settings = await this.settingsStore.load();
|
||||
this.sync = new SyncEngine(this.api, this.offline, this.platform);
|
||||
|
||||
await this.offline.load();
|
||||
const storedUrl = await this.settingsStore.serverUrl();
|
||||
const session = await this.api.restore();
|
||||
const baseUrl = storedUrl || this.options.defaultServerUrl || inferDefaultUrl();
|
||||
if (baseUrl) {
|
||||
this.api.setBaseUrl(baseUrl);
|
||||
// A session restored from storage is only usable if the URL it was minted
|
||||
// against is the one that came back.
|
||||
session?.user && void this.settingsStore.setServerUrl(baseUrl);
|
||||
}
|
||||
|
||||
document.documentElement.dataset['theme'] = this.settings.theme;
|
||||
|
||||
if (session) {
|
||||
// Verify before showing the shelf: a token that the server has since
|
||||
// revoked would otherwise flash an empty library and then sign out.
|
||||
try {
|
||||
await this.api.me();
|
||||
this.showShelf();
|
||||
this.sync.start();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.isConnectivity) {
|
||||
// Offline with a stored session is a legitimate state, not a failure.
|
||||
this.showShelf();
|
||||
this.sync.start();
|
||||
} else {
|
||||
await this.sessions.clear();
|
||||
this.showLogin();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.showLogin();
|
||||
}
|
||||
|
||||
this.installLifecycleHooks();
|
||||
}
|
||||
|
||||
private async createPlatform(): Promise<Platform> {
|
||||
const bridge = detectAndroidBridge();
|
||||
if (!bridge) return createWebPlatform(this.options.defaultServerUrl ?? '');
|
||||
return createAndroidPlatform(this.options.defaultServerUrl ?? '', bridge);
|
||||
}
|
||||
|
||||
// ---- screens ----
|
||||
|
||||
private setScreen(screen: string): void {
|
||||
if (this.screen === screen) return;
|
||||
this.reader?.dispose();
|
||||
this.reader = null;
|
||||
this.shelf?.dispose();
|
||||
this.shelf = null;
|
||||
this.root.replaceChildren();
|
||||
this.screen = screen;
|
||||
}
|
||||
|
||||
private showLogin(message?: string): void {
|
||||
this.setScreen('login');
|
||||
const login = new LoginScreen({
|
||||
api: this.api,
|
||||
defaultServerUrl: this.api.baseUrl || inferDefaultUrl(),
|
||||
onAuthenticated: () => {
|
||||
this.showShelf();
|
||||
this.sync.start();
|
||||
},
|
||||
onServerUrlChange: (url) => this.settingsStore.setServerUrl(url),
|
||||
});
|
||||
this.root.append(login.element);
|
||||
if (message) login.reset(message);
|
||||
}
|
||||
|
||||
private showShelf(): void {
|
||||
this.setScreen(SCREEN_SHELF);
|
||||
const shelf = new ShelfScreen({
|
||||
api: this.api,
|
||||
offline: this.offline,
|
||||
platform: this.platform,
|
||||
onOpenBook: (book) => void this.openBook(book),
|
||||
onSignedOut: () => this.handleSignedOut(),
|
||||
});
|
||||
this.shelf = shelf;
|
||||
this.root.append(shelf.element);
|
||||
void shelf.show();
|
||||
this.pendingBook = null;
|
||||
}
|
||||
|
||||
private async openBook(book: Book): Promise<void> {
|
||||
this.setScreen(SCREEN_READER);
|
||||
const reader = new ReaderScreen({
|
||||
api: this.api,
|
||||
offline: this.offline,
|
||||
sync: this.sync,
|
||||
platform: this.platform,
|
||||
settings: this.settings,
|
||||
onBack: () => this.showShelf(),
|
||||
onSettingsChange: (patch) => {
|
||||
void this.settingsStore.update(patch);
|
||||
this.settings = { ...this.settings, ...patch };
|
||||
},
|
||||
onSignedOut: () => this.handleSignedOut(),
|
||||
});
|
||||
this.reader = reader;
|
||||
this.pendingBook = book;
|
||||
this.root.append(reader.element);
|
||||
await reader.open(book);
|
||||
}
|
||||
|
||||
private handleSignedOut(): void {
|
||||
this.sync.stop();
|
||||
void this.sessions.clear();
|
||||
void this.offline.clear();
|
||||
this.showLogin('登录已失效,请重新登录');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports progress when the page is hidden or the app is backgrounded.
|
||||
*
|
||||
* This is the hook that makes "close the app right after reading" not lose the
|
||||
* last few pages: the debounce in the reader would otherwise never fire.
|
||||
*/
|
||||
private installLifecycleHooks(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') void this.sync.flush();
|
||||
});
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pagehide', () => void this.sync.flush());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes everything pending to disk and to the server, best effort.
|
||||
*
|
||||
* Called when the app is backgrounded. Deliberately not awaited by the caller
|
||||
* (the Android shell cannot await it either), so it must not throw: a failure
|
||||
* here leaves the outbox intact for the next attempt rather than surfacing as
|
||||
* an unhandled rejection.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
try {
|
||||
await this.offline.flush();
|
||||
await this.sync.flush();
|
||||
} catch {
|
||||
// The outbox is durable; the next cycle retries.
|
||||
}
|
||||
}
|
||||
|
||||
// ---- diagnostics for the shell ----
|
||||
|
||||
syncStatus(): SyncStatus {
|
||||
return this.sync.status();
|
||||
}
|
||||
|
||||
currentBook(): Book | null {
|
||||
return this.pendingBook;
|
||||
}
|
||||
|
||||
settingsSnapshot(): Promise<AppSettings> {
|
||||
return this.settingsStore.load();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session persistence.
|
||||
*
|
||||
* Tokens live in the same key/value store as everything else. That means they
|
||||
* are in IndexedDB (`localStorage` is the classic choice and is worse: it is
|
||||
* synchronous, capped at a few MB, and shared across every script on the
|
||||
* origin). On Android they end up in the WebView's storage inside the app's
|
||||
* private data directory, which is the platform's own boundary for app secrets.
|
||||
*/
|
||||
function createSessionStore(): SessionStore {
|
||||
const KEY = 'reader.session.v1';
|
||||
return {
|
||||
async load(): Promise<Session | null> {
|
||||
const store = await sharedKv();
|
||||
const raw = await store.get(KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Session;
|
||||
if (!parsed?.accessToken || !parsed?.refreshToken) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async save(session: Session): Promise<void> {
|
||||
const store = await sharedKv();
|
||||
await store.set(KEY, JSON.stringify(session));
|
||||
},
|
||||
async clear(): Promise<void> {
|
||||
const store = await sharedKv();
|
||||
await store.remove(KEY);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface KeyValueStoreLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
let sharedKvPromise: Promise<KeyValueStoreLike> | null = null;
|
||||
|
||||
/**
|
||||
* One shared store instance.
|
||||
*
|
||||
* Two separate IndexedDB connections to the same database in the same tab is
|
||||
* legal but wasteful, and on a cold start it doubles the open cost.
|
||||
*/
|
||||
async function sharedKv(): Promise<KeyValueStoreLike> {
|
||||
if (!sharedKvPromise) sharedKvPromise = createStoresLazy();
|
||||
return sharedKvPromise;
|
||||
}
|
||||
|
||||
async function createStoresLazy(): Promise<KeyValueStoreLike> {
|
||||
const { createStores } = await import('./store/idb.ts');
|
||||
const stores = await createStores();
|
||||
return stores.kv;
|
||||
}
|
||||
|
||||
/** Best-effort default so a fresh install does not start with an empty field. */
|
||||
function inferDefaultUrl(): string {
|
||||
if (typeof location === 'undefined') return '';
|
||||
if (location.protocol === 'file:') return '';
|
||||
// Same-origin when the H5 bundle is served by the reader server itself.
|
||||
return `${location.origin}`;
|
||||
}
|
||||
|
||||
export { el };
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { AndroidBridge } from '../android-bridge.ts';
|
||||
import { FetchTransport } from './fetch-transport.ts';
|
||||
import type { Connectivity, Platform } from './platform.ts';
|
||||
import { createStores } from '../store/idb.ts';
|
||||
|
||||
/**
|
||||
* The Android platform.
|
||||
*
|
||||
* A thin override over the web one, not a reimplementation. What the native
|
||||
* shell is asked for is exactly the set of things a WebView genuinely cannot do
|
||||
* or does badly:
|
||||
*
|
||||
* - **Connectivity.** `navigator.onLine` in an Android WebView reports "true"
|
||||
* whenever a network interface exists, including a Wi-Fi network with no
|
||||
* route to the server. The platform ConnectivityManager knows better, and
|
||||
* getting this wrong is what makes the offline path feel broken.
|
||||
* - **A stable device label.** The UA-derived one changes when Chrome updates.
|
||||
* - **Native feedback.** A toast for "已下载" is one line of Kotlin and avoids
|
||||
* building a notification UI in the web layer.
|
||||
*
|
||||
* Everything else — storage, transport, the sync engine — is shared, which is
|
||||
* the entire point of building the client this way.
|
||||
*/
|
||||
export async function createAndroidPlatform(baseUrl: string, bridge: AndroidBridge): Promise<Platform> {
|
||||
const stores = await createStores();
|
||||
const listeners = new Set<(state: Connectivity) => void>();
|
||||
let last: Connectivity = normalise(bridge.connectivity());
|
||||
|
||||
bridge.watchConnectivity((state) => {
|
||||
const next = normalise(state);
|
||||
if (next === last) return;
|
||||
last = next;
|
||||
for (const listener of listeners) listener(next);
|
||||
});
|
||||
|
||||
return {
|
||||
name: 'android',
|
||||
deviceLabel: safeDeviceLabel(bridge),
|
||||
transport: new FetchTransport(() => baseUrl),
|
||||
kv: stores.kv,
|
||||
blobs: stores.blobs,
|
||||
async connectivity(): Promise<Connectivity> {
|
||||
try {
|
||||
return normalise(bridge.connectivity());
|
||||
} catch {
|
||||
// A bridge that throws must not take the reader down with it.
|
||||
return 'online';
|
||||
}
|
||||
},
|
||||
onConnectivityChange(listener: (state: Connectivity) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
reportError(error) {
|
||||
// Offline is an expected state, reported in the status bar, not an event
|
||||
// worth interrupting the reader for.
|
||||
if (error.isConnectivity) return;
|
||||
void error;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalise(state: string): Connectivity {
|
||||
return state === 'offline' ? 'offline' : 'online';
|
||||
}
|
||||
|
||||
function safeDeviceLabel(bridge: AndroidBridge): string {
|
||||
try {
|
||||
const label = bridge.deviceLabel();
|
||||
if (label && label.trim().length > 0) return label.trim().slice(0, 60);
|
||||
} catch {
|
||||
// Fall through to the generic label.
|
||||
}
|
||||
return 'android';
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature detection for the native shell.
|
||||
*
|
||||
* Requires a minimum shell version: an old APK whose bridge is missing a method
|
||||
* this client relies on would otherwise fail in a way that looks like a bug in
|
||||
* the reader.
|
||||
*/
|
||||
export const MIN_SHELL_VERSION = 1;
|
||||
|
||||
export function detectAndroidBridge(): AndroidBridge | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const bridge = window.ReaderAndroid;
|
||||
if (!bridge || typeof bridge.shellVersion !== 'function') return null;
|
||||
try {
|
||||
return bridge.shellVersion() >= MIN_SHELL_VERSION ? bridge : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ApiError, errorForStatus, parseErrorBody } from '../api/errors.ts';
|
||||
import type { HttpRequest, HttpResponse, Transport } from './platform.ts';
|
||||
|
||||
/**
|
||||
* `fetch`-based transport used by the browser and by the Android shell for
|
||||
* everything that does not benefit from a native path.
|
||||
*
|
||||
* Network failures are normalised into `ApiError('offline')` — see the note in
|
||||
* errors.ts. That single conversion is what lets the sync engine stay agnostic
|
||||
* about *why* a request failed.
|
||||
*/
|
||||
export class FetchTransport implements Transport {
|
||||
constructor(private readonly baseUrl: () => string) {}
|
||||
|
||||
async send(request: HttpRequest): Promise<HttpResponse> {
|
||||
const url = /^https?:/i.test(request.url) ? request.url : `${this.baseUrl()}${request.url}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
...(request.body !== undefined ? { body: request.body } : {}),
|
||||
...(request.signal ? { signal: request.signal } : {}),
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new ApiError('aborted', 'request aborted');
|
||||
}
|
||||
// DNS failure, refused connection, TLS error, CORS preflight rejection.
|
||||
// From the reader's point of view all of these mean "server not reachable
|
||||
// right now", and the UI must degrade to cached content instead of
|
||||
// showing a stack trace.
|
||||
throw new ApiError('offline', err instanceof Error ? err.message : 'network error');
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
response.headers.forEach((value, key) => {
|
||||
headers[key.toLowerCase()] = value;
|
||||
});
|
||||
|
||||
if (request.binary) {
|
||||
if (!response.ok) throw await this.toError(response);
|
||||
const buffer = new Uint8Array(await response.arrayBuffer());
|
||||
return { status: response.status, headers, bytes: buffer };
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw this.errorFromText(response.status, text);
|
||||
if (!text) return { status: response.status, headers };
|
||||
try {
|
||||
return { status: response.status, headers, json: JSON.parse(text) };
|
||||
} catch {
|
||||
// A 200 with a non-JSON body means something in front of the server
|
||||
// answered (a captive portal, a stale service worker). Treating it as a
|
||||
// transport failure keeps the offline path honest.
|
||||
throw new ApiError('offline', 'server returned a non-JSON response', 'BAD_GATEWAY', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
private async toError(response: Response): Promise<ApiError> {
|
||||
const text = await response.text().catch(() => '');
|
||||
return this.errorFromText(response.status, text);
|
||||
}
|
||||
|
||||
private errorFromText(status: number, text: string): ApiError {
|
||||
const { code, message } = parseErrorBody(text);
|
||||
return new ApiError(
|
||||
errorForStatus(status),
|
||||
message || `request failed with status ${status}`,
|
||||
code,
|
||||
status,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ApiError } from '../api/errors.ts';
|
||||
|
||||
/**
|
||||
* The seam between the shared rendering layer and the two hosts that run it.
|
||||
*
|
||||
* The browser/H5 build implements this with `fetch` + IndexedDB. The Android
|
||||
* shell overrides the pieces it can do better natively (durable book cache,
|
||||
* reachability, device label) and leaves the rest alone. Everything above this
|
||||
* interface — formats, layout, sync — is written once against the interface.
|
||||
*/
|
||||
export interface HttpRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
/** JSON-encoded request body, when there is one. */
|
||||
body?: string;
|
||||
/** Binary response mode: used for book bodies and covers. */
|
||||
binary?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface HttpResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
/** Present for JSON responses. */
|
||||
json?: unknown;
|
||||
/** Present for `binary` requests. */
|
||||
bytes?: Uint8Array;
|
||||
/** Raw text, used to build a useful error when JSON parsing fails. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface Transport {
|
||||
send(request: HttpRequest): Promise<HttpResponse>;
|
||||
}
|
||||
|
||||
export interface KeyValueStore {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable byte cache for downloaded book files.
|
||||
*
|
||||
* Kept separate from `KeyValueStore` because the two have very different size
|
||||
* profiles: credentials and sync cursors are tiny strings, whereas a PDF can be
|
||||
* hundreds of megabytes and belongs in whatever bulk storage the host offers
|
||||
* (IndexedDB blobs in the browser, the app's files directory on Android).
|
||||
*/
|
||||
export interface BlobStore {
|
||||
get(key: string): Promise<Uint8Array | null>;
|
||||
put(key: string, bytes: Uint8Array): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
/** Total bytes currently stored, for the storage-usage screen. */
|
||||
usage(): Promise<number>;
|
||||
}
|
||||
|
||||
export type Connectivity = 'online' | 'offline';
|
||||
|
||||
export interface Platform {
|
||||
readonly name: 'web' | 'android';
|
||||
/** Device label recorded on progress rows, so the UI can say "read on Pixel". */
|
||||
readonly deviceLabel: string;
|
||||
readonly transport: Transport;
|
||||
readonly kv: KeyValueStore;
|
||||
readonly blobs: BlobStore;
|
||||
/** Current connectivity, best effort. Never throws. */
|
||||
connectivity(): Promise<Connectivity>;
|
||||
onConnectivityChange(listener: (state: Connectivity) => void): () => void;
|
||||
/** Notified whenever a request has been attempted and its outcome. */
|
||||
reportError?(error: ApiError): void;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { ApiError } from '../api/errors.ts';
|
||||
import type { ReaderApi, NoteInput } from '../api/client.ts';
|
||||
import { isOutboxEmpty, type OfflineStore } from '../store/offline.ts';
|
||||
import type { Connectivity, Platform } from './platform.ts';
|
||||
|
||||
export type SyncState = 'idle' | 'syncing' | 'offline' | 'error' | 'signed-out';
|
||||
|
||||
export interface SyncStatus {
|
||||
state: SyncState;
|
||||
lastSyncAt: number | null;
|
||||
pending: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const IDLE_INTERVAL_MS = 30_000;
|
||||
const RETRY_BASE_MS = 2_000;
|
||||
const RETRY_MAX_MS = 120_000;
|
||||
|
||||
/**
|
||||
* Keeps local state and the server in step, and never surfaces a connectivity
|
||||
* failure to the reader as an error (product design §8.2, "三态切换不能报错").
|
||||
*
|
||||
* Three things make this the piece that decides whether the product feels solid:
|
||||
*
|
||||
* 1. A push is a *merge*, not an overwrite. The server resolves conflicts by
|
||||
* updatedAt, so replaying a stale offline outbox is harmless.
|
||||
* 2. The outbox survives a crash. It lives in the offline snapshot, not in
|
||||
* memory, so closing the app mid-flight does not lose a reading position.
|
||||
* 3. Retries back off exponentially and stop when the OS says there is no
|
||||
* network. Polling a server we know is unreachable wastes battery on a
|
||||
* phone, which is the one resource a reader app must respect.
|
||||
*/
|
||||
export class SyncEngine {
|
||||
private state: SyncState = 'idle';
|
||||
private message = '';
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private backoff = RETRY_BASE_MS;
|
||||
private running = false;
|
||||
private rejectedCount = 0;
|
||||
/** Book id per note id, so a tombstone can still be scoped after a restart. */
|
||||
private readonly noteBooks = new Map<string, string>();
|
||||
private readonly listeners = new Set<(status: SyncStatus) => void>();
|
||||
private unwatch: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly api: ReaderApi,
|
||||
private readonly offline: OfflineStore,
|
||||
private readonly platform: Platform,
|
||||
) {}
|
||||
|
||||
status(): SyncStatus {
|
||||
return {
|
||||
state: this.state,
|
||||
lastSyncAt: this.offline.current.lastSyncAt,
|
||||
pending: !isOutboxEmpty(this.offline.current),
|
||||
message: this.message,
|
||||
};
|
||||
}
|
||||
|
||||
/** Number of records the server refused in the last push. */
|
||||
rejected(): number {
|
||||
return this.rejectedCount;
|
||||
}
|
||||
|
||||
onStatus(listener: (status: SyncStatus) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const status = this.status();
|
||||
for (const listener of this.listeners) listener(status);
|
||||
}
|
||||
|
||||
private setState(state: SyncState, message = ''): void {
|
||||
this.state = state;
|
||||
this.message = message;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.unwatch) return;
|
||||
this.unwatch = this.platform.onConnectivityChange((connectivity) => {
|
||||
if (connectivity === 'online') {
|
||||
// Coming back online is the moment the user expects their last pages
|
||||
// to be pushed, so do not wait for the next poll.
|
||||
this.backoff = RETRY_BASE_MS;
|
||||
void this.syncNow();
|
||||
} else {
|
||||
this.setState('offline', '离线,进度会缓存在本机');
|
||||
}
|
||||
});
|
||||
void this.syncNow();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.unwatch?.();
|
||||
this.unwatch = null;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
private scheduleNext(delay: number): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
void this.syncNow();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* One full round trip: flush the outbox, then pull the deltas.
|
||||
*
|
||||
* Safe to call concurrently — extra calls during a run are folded into the
|
||||
* one in flight, which is what the UI needs when several screens wake up at
|
||||
* the same time.
|
||||
*/
|
||||
async syncNow(): Promise<void> {
|
||||
if (this.running) return;
|
||||
if (!this.api.currentSession()) {
|
||||
this.setState('signed-out');
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
this.setState('syncing');
|
||||
try {
|
||||
const connectivity = await this.platform.connectivity();
|
||||
if (connectivity === 'offline') {
|
||||
this.setState('offline', '离线,进度会缓存在本机');
|
||||
this.scheduleNext(IDLE_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.pushOutbox();
|
||||
const pull = await this.api.pull(this.offline.current.serverTime);
|
||||
await this.offline.applyPulled(pull);
|
||||
this.rememberNoteBooks();
|
||||
|
||||
this.backoff = RETRY_BASE_MS;
|
||||
// A rejection message has to survive the state transition to idle,
|
||||
// otherwise the reader never learns that a record was dropped.
|
||||
this.setState('idle', this.rejectedCount > 0 ? `${this.rejectedCount} 条记录被服务端拒绝` : this.message);
|
||||
this.emit();
|
||||
this.scheduleNext(IDLE_INTERVAL_MS);
|
||||
} catch (err) {
|
||||
this.handleFailure(err);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends whatever is in the outbox and marks exactly that batch as delivered.
|
||||
*
|
||||
* The batch is captured once, before the first request, and carries the
|
||||
* timestamps it was built from. That is what makes a concurrent page turn safe:
|
||||
* the record's own update will still be dirty after this returns, so the next
|
||||
* cycle picks it up instead of it being cleared by a response that predates it.
|
||||
*/
|
||||
private async pushOutbox(): Promise<void> {
|
||||
const batch = this.offline.outbox();
|
||||
const deletes: NoteInput[] = batch.deletes.map((id) => ({
|
||||
id,
|
||||
// The tombstone has to carry the book id so the server can scope the
|
||||
// delete to a book the caller can see. A note that was already pulled from
|
||||
// the server and then deleted locally is not in `notes` any more, so the
|
||||
// id is looked up in the last known note if it is still around.
|
||||
bookId: this.lastKnownNoteBook(id) ?? '',
|
||||
type: 'note',
|
||||
locator: '',
|
||||
text: '',
|
||||
comment: '',
|
||||
color: '',
|
||||
updatedAt: Date.now(),
|
||||
deleted: true,
|
||||
}));
|
||||
const notes: NoteInput[] = [
|
||||
...batch.notes.map((note) => ({
|
||||
id: note.id,
|
||||
bookId: note.bookId,
|
||||
type: note.type,
|
||||
locator: note.locator,
|
||||
text: note.text,
|
||||
comment: note.comment,
|
||||
color: note.color,
|
||||
updatedAt: note.updatedAt,
|
||||
deleted: false,
|
||||
})),
|
||||
...deletes,
|
||||
];
|
||||
if (batch.progress.length === 0 && notes.length === 0) return;
|
||||
|
||||
// The server caps a batch at 5000 records; chunk well below that so a long
|
||||
// offline session cannot fail wholesale on one oversized request.
|
||||
const CHUNK = 1000;
|
||||
const batches = Math.max(1, Math.ceil(Math.max(batch.progress.length, notes.length) / CHUNK));
|
||||
let serverTime = 0;
|
||||
let rejected = 0;
|
||||
for (let index = 0; index < batches; index += 1) {
|
||||
const offset = index * CHUNK;
|
||||
const progressChunk = batch.progress.slice(offset, offset + CHUNK);
|
||||
const noteChunk = notes.slice(offset, offset + CHUNK);
|
||||
const result = await this.api.push({
|
||||
...(progressChunk.length > 0 ? { progress: progressChunk } : {}),
|
||||
...(noteChunk.length > 0 ? { notes: noteChunk } : {}),
|
||||
});
|
||||
serverTime = Math.max(serverTime, result.serverTime);
|
||||
rejected += result.rejected;
|
||||
}
|
||||
await this.offline.markDelivered(batch, serverTime);
|
||||
// Rejections are informational. The server refuses records it can never
|
||||
// accept (unknown book, malformed locator), and retrying them forever would
|
||||
// block every later flush behind a permanently bad entry. The count is kept
|
||||
// on the engine rather than in a transient message so the status bar can
|
||||
// still show it after the state settles.
|
||||
this.rejectedCount = rejected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort lookup of a deleted note's book.
|
||||
*
|
||||
* `pendingNoteDeletes` can outlive a restart during which the note itself was
|
||||
* evicted, and a tombstone with no book id is rejected by the server. Losing
|
||||
* the tombstone is preferable to stalling the outbox, and the server has its
|
||||
* own copy to reconcile against on the next pull.
|
||||
*/
|
||||
private lastKnownNoteBook(id: string): string | null {
|
||||
return this.noteBooks.get(id) ?? null;
|
||||
}
|
||||
|
||||
private handleFailure(err: unknown): void {
|
||||
if (err instanceof ApiError && err.kind === 'aborted') {
|
||||
this.setState('idle');
|
||||
return;
|
||||
}
|
||||
if (err instanceof ApiError && err.isConnectivity) {
|
||||
// Silent degradation: the reader keeps reading from cache.
|
||||
this.setState('offline', '离线,进度会缓存在本机');
|
||||
this.backoff = RETRY_BASE_MS;
|
||||
this.scheduleNext(IDLE_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
if (err instanceof ApiError && err.isAuthFailure) {
|
||||
this.setState('signed-out', '登录已失效,请重新登录');
|
||||
return;
|
||||
}
|
||||
this.setState('error', err instanceof Error ? err.message : '同步失败');
|
||||
this.backoff = Math.min(this.backoff * 2, RETRY_MAX_MS);
|
||||
this.scheduleNext(this.backoff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records which book each note belongs to.
|
||||
*
|
||||
* Needed because a tombstone must name a book, and the note row is gone by the
|
||||
* time the delete is sent. Rebuilt on every pull so it also survives a restart.
|
||||
*/
|
||||
private rememberNoteBooks(): void {
|
||||
for (const note of Object.values(this.offline.current.notes)) {
|
||||
this.noteBooks.set(note.id, note.bookId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Force a synchronous flush, used when the app goes to the background. */
|
||||
async flush(): Promise<void> {
|
||||
if (!this.api.currentSession()) return;
|
||||
try {
|
||||
await this.pushOutbox();
|
||||
} catch {
|
||||
// Nothing to do: the outbox is durable and the next attempt will retry.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { Connectivity };
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FetchTransport } from './fetch-transport.ts';
|
||||
import type { Connectivity, Platform } from './platform.ts';
|
||||
import { createStores } from '../store/idb.ts';
|
||||
|
||||
/**
|
||||
* The browser/H5 platform.
|
||||
*
|
||||
* Also the base class for the Android platform, which reuses everything here
|
||||
* and overrides only the parts a native shell can do better.
|
||||
*/
|
||||
export async function createWebPlatform(baseUrl: string): Promise<Platform> {
|
||||
const stores = await createStores();
|
||||
const listeners = new Set<(state: Connectivity) => void>();
|
||||
let last: Connectivity = typeof navigator !== 'undefined' && navigator.onLine === false ? 'offline' : 'online';
|
||||
|
||||
const emit = (): void => {
|
||||
const next: Connectivity = navigator.onLine === false ? 'offline' : 'online';
|
||||
if (next === last) return;
|
||||
last = next;
|
||||
for (const listener of listeners) listener(next);
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', emit);
|
||||
window.addEventListener('offline', emit);
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'web',
|
||||
deviceLabel: deviceLabelFromNavigator(),
|
||||
transport: new FetchTransport(() => baseUrl),
|
||||
kv: stores.kv,
|
||||
blobs: stores.blobs,
|
||||
async connectivity(): Promise<Connectivity> {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false ? 'offline' : 'online';
|
||||
},
|
||||
onConnectivityChange(listener: (state: Connectivity) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deviceLabelFromNavigator(): string {
|
||||
if (typeof navigator === 'undefined') return 'web';
|
||||
const ua = navigator.userAgent;
|
||||
if (/Android/i.test(ua)) return 'android-web';
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios-web';
|
||||
return 'web';
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { BookArchive, extensionOf } from './zip.ts';
|
||||
import { sortByName } from './natural-sort.ts';
|
||||
import type { BookDoc, LoadContext, Section } from './types.ts';
|
||||
|
||||
/**
|
||||
* Comic loader: CBZ archives and loose image directories.
|
||||
*
|
||||
* Comics are the format where a rendering compromise is most visible. There is
|
||||
* no reflow, no font negotiation, no theme — a page either fills the viewport
|
||||
* at the right aspect ratio with no gap and no crop, or the product looks broken.
|
||||
* So this loader emits `layout: 'fixed'`, one section per page, and the renderer
|
||||
* treats each as an atomic screen.
|
||||
*
|
||||
* Page order comes from natural sorting (see natural-sort.ts). Getting that
|
||||
* wrong is not a cosmetic bug: it silently scrambles the story.
|
||||
*/
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'jxl']);
|
||||
|
||||
/** Files a comic archive carries that are not pages. */
|
||||
const NON_PAGE_HINTS = [/^__macosx\//i, /\/\._/, /\.ds_store$/i, /thumbs\.db$/i, /comicinfo\.xml$/i];
|
||||
|
||||
export async function loadCbz(ctx: LoadContext): Promise<BookDoc> {
|
||||
const archive = await BookArchive.open(ctx.bytes);
|
||||
const pages = archive
|
||||
.names()
|
||||
.filter((name) => IMAGE_EXTENSIONS.has(extensionOf(name)))
|
||||
.filter((name) => !NON_PAGE_HINTS.some((pattern) => pattern.test(name)));
|
||||
|
||||
return buildComicDoc(
|
||||
sortByName(pages, (name) => name),
|
||||
async (path) => (await archive.bytes(path)) ?? new Uint8Array(),
|
||||
'cbz',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A comic stored as a folder of images.
|
||||
*
|
||||
* `resolve` is supplied by the caller because a directory is not something the
|
||||
* server can stream: the client enumerates the book's `files` from the manifest
|
||||
* and fetches each page individually.
|
||||
*/
|
||||
export async function loadComicDirectory(
|
||||
pages: string[],
|
||||
resolve: (path: string) => Promise<Uint8Array>,
|
||||
): Promise<BookDoc> {
|
||||
return buildComicDoc(sortByName(pages, (name) => name), resolve, 'comic-dir');
|
||||
}
|
||||
|
||||
export async function buildComicDoc(
|
||||
pages: string[],
|
||||
resolve: (path: string) => Promise<Uint8Array>,
|
||||
format: 'cbz' | 'comic-dir' | 'image',
|
||||
): Promise<BookDoc> {
|
||||
const sections: Section[] = [];
|
||||
for (const path of pages) {
|
||||
const mediaType = mediaTypeForImage(path);
|
||||
const bytes = await resolve(path);
|
||||
sections.push({
|
||||
id: path,
|
||||
// Prefer the page number as the label: a comic page's file name is
|
||||
// usually a hash or a scan-tool artefact and means nothing to a reader.
|
||||
label: pageLabel(sections.length + 1, path),
|
||||
image: { mediaType, bytes },
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
format,
|
||||
layout: 'fixed',
|
||||
// Manga is right-to-left, but detecting it from the archive is unreliable
|
||||
// and guessing wrong reverses the book. Default left-to-right and expose a
|
||||
// toggle; the reader can fix it in one tap, and the setting persists.
|
||||
direction: 'ltr',
|
||||
sections,
|
||||
toc: sections.map((section) => ({ id: section.id, label: section.label, depth: 0 })),
|
||||
styles: [],
|
||||
resources: new Map(),
|
||||
orderedByBook: true,
|
||||
};
|
||||
}
|
||||
|
||||
function pageLabel(index: number, path: string): string {
|
||||
const base = path.split('/').pop() ?? path;
|
||||
const stem = base.replace(/\.[^.]+$/, '');
|
||||
// Only show the file name when it carries information the index does not.
|
||||
if (/^[\d\s_-]+$/.test(stem) || stem.length === 0) return `第 ${index} 页`;
|
||||
return `第 ${index} 页 · ${stem}`;
|
||||
}
|
||||
|
||||
export function mediaTypeForImage(path: string): string {
|
||||
switch (extensionOf(path)) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'avif':
|
||||
return 'image/avif';
|
||||
case 'bmp':
|
||||
return 'image/bmp';
|
||||
case 'jxl':
|
||||
return 'image/jxl';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
export function isImagePath(path: string): boolean {
|
||||
return IMAGE_EXTENSIONS.has(extensionOf(path));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { extensionOf } from './zip.ts';
|
||||
import type { BookFormat } from './types.ts';
|
||||
|
||||
/**
|
||||
* Format detection, in the order the evidence deserves: magic bytes beat a
|
||||
* declared media type, which beats a file extension.
|
||||
*
|
||||
* The reason to look at magic bytes at all is that self-hosted libraries are
|
||||
* full of misnamed files — a `.txt` that is really an EPUB, a `.cbz` that is
|
||||
* actually a ZIP of PDFs from a batch conversion tool. Opening the wrong loader
|
||||
* produces a confusing error, while sniffing turns an unusable book into a
|
||||
* usable one.
|
||||
*/
|
||||
|
||||
const ZIP_MAGIC = [0x50, 0x4b]; // "PK"
|
||||
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46]; // "%PDF"
|
||||
|
||||
export function detectFormat(fileName: string, bytes?: Uint8Array): BookFormat {
|
||||
const extension = extensionOf(fileName);
|
||||
|
||||
if (bytes && bytes.length >= 4) {
|
||||
if (matches(bytes, PDF_MAGIC)) return 'pdf';
|
||||
if (matches(bytes, ZIP_MAGIC)) {
|
||||
// A ZIP can be an EPUB or a CBZ. The extension decides, and when it does
|
||||
// not, the presence of the EPUB container document does.
|
||||
if (extension === 'cbz' || extension === 'cbr') return 'cbz';
|
||||
if (extension === 'epub') return 'epub';
|
||||
return looksLikeEpub(bytes) ? 'epub' : 'cbz';
|
||||
}
|
||||
}
|
||||
|
||||
switch (extension) {
|
||||
case 'epub':
|
||||
return 'epub';
|
||||
case 'pdf':
|
||||
return 'pdf';
|
||||
case 'txt':
|
||||
case 'text':
|
||||
return 'txt';
|
||||
case 'cbz':
|
||||
case 'zip':
|
||||
return 'cbz';
|
||||
case 'cbr':
|
||||
// RAR archives are not readable without a native decoder, which the
|
||||
// browser cannot provide. Reported as unknown so the UI can say so
|
||||
// instead of failing with a confusing parse error.
|
||||
return 'unknown';
|
||||
default:
|
||||
if (isImageName(fileName)) return 'image';
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function matches(bytes: Uint8Array, magic: number[]): boolean {
|
||||
if (bytes.length < magic.length) return false;
|
||||
for (let index = 0; index < magic.length; index += 1) {
|
||||
if (bytes[index] !== magic[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the first ZIP entry header and checks whether it is an EPUB container.
|
||||
*
|
||||
* ZIP stores file names uncompressed in local headers, so the entry can be
|
||||
* located without inflating anything. Only the first few entries are scanned:
|
||||
* `mimetype` is required to be first by the EPUB spec, and a reasonable fallback
|
||||
* does not need to walk a 3000-page archive.
|
||||
*/
|
||||
function looksLikeEpub(bytes: Uint8Array): boolean {
|
||||
const limit = Math.min(bytes.length, 8192);
|
||||
const text = new TextDecoder('latin1').decode(bytes.subarray(0, limit));
|
||||
if (text.includes('META-INF/container.xml')) return true;
|
||||
if (text.includes('mimetypeapplication/epub+zip')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isImageName(fileName: string): boolean {
|
||||
switch (extensionOf(fileName)) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
case 'avif':
|
||||
case 'bmp':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { BookArchive, dirnameOf, extensionOf, resolveHref } from './zip.ts';
|
||||
import type { BookDoc, LoadContext, Resource, Section, TocEntry } from './types.ts';
|
||||
|
||||
/**
|
||||
* EPUB loader.
|
||||
*
|
||||
* Design commitments, in order of how much they matter:
|
||||
*
|
||||
* 1. **Author styles are preserved.** No stylesheet is filtered out, no
|
||||
* property is overridden by default. The product's whole differentiation is
|
||||
* faithful rendering of a carefully typeset book (§9), and the fastest way to
|
||||
* destroy that is to inject a "reading theme" that resets fonts and margins.
|
||||
* The user-facing controls in `styles/reader.css` are opt-in variables that
|
||||
* default to `inherit`.
|
||||
*
|
||||
* 2. **Resources resolve exactly like the book expects.** Hrefs are resolved
|
||||
* against the OPF base (and against each content document's own directory for
|
||||
* relative links), including percent-encoding, because the alternative —
|
||||
* rewriting `src` attributes with a regex — breaks on the first book that
|
||||
* uses a data URI, a fragment-only link or a remote font.
|
||||
*
|
||||
* 3. **Unreadable documents degrade individually.** A malformed chapter is
|
||||
* replaced with a placeholder; the rest of the book still opens. Books in a
|
||||
* self-hosted library are frequently hand-edited, so per-file failure is the
|
||||
* normal case, not an edge case.
|
||||
*/
|
||||
|
||||
const CONTAINER_PATH = 'META-INF/container.xml';
|
||||
|
||||
interface ManifestItem {
|
||||
id: string;
|
||||
href: string;
|
||||
mediaType: string;
|
||||
properties: string[];
|
||||
}
|
||||
|
||||
interface Package {
|
||||
opfPath: string;
|
||||
baseDir: string;
|
||||
version: string;
|
||||
manifest: ManifestItem[];
|
||||
spine: string[];
|
||||
direction: 'ltr' | 'rtl';
|
||||
}
|
||||
|
||||
export async function loadEpub(ctx: LoadContext): Promise<BookDoc> {
|
||||
const archive = await BookArchive.open(ctx.bytes);
|
||||
const pkg = await readPackage(archive);
|
||||
|
||||
const resources = new Map<string, Resource>();
|
||||
for (const item of pkg.manifest) {
|
||||
const path = resolveHref(pkg.baseDir, item.href);
|
||||
resources.set(path, {
|
||||
path,
|
||||
mediaType: item.mediaType || mediaTypeFor(path),
|
||||
bytes: async () => (await archive.bytes(path)) ?? new Uint8Array(),
|
||||
});
|
||||
}
|
||||
// Some books reference files that the manifest forgot to declare. Registering
|
||||
// every archive entry costs nothing and rescues those.
|
||||
for (const name of archive.names()) {
|
||||
if (resources.has(name)) continue;
|
||||
resources.set(name, {
|
||||
path: name,
|
||||
mediaType: mediaTypeFor(name),
|
||||
bytes: async () => (await archive.bytes(name)) ?? new Uint8Array(),
|
||||
});
|
||||
}
|
||||
|
||||
const styles: string[] = [];
|
||||
for (const item of pkg.manifest) {
|
||||
if (item.mediaType !== 'text/css') continue;
|
||||
const path = resolveHref(pkg.baseDir, item.href);
|
||||
const text = await archive.text(path);
|
||||
if (text) styles.push(rewriteCss(text, path));
|
||||
}
|
||||
|
||||
const sections: Section[] = [];
|
||||
const spinePaths = pkg.spine.length > 0
|
||||
? pkg.spine.map((id) => {
|
||||
const item = pkg.manifest.find((candidate) => candidate.id === id);
|
||||
return item ? resolveHref(pkg.baseDir, item.href) : null;
|
||||
}).filter((path): path is string => path !== null)
|
||||
// No usable spine: fall back to every XHTML document in archive order. It
|
||||
// is a guess at reading order, but it is strictly better than an empty book.
|
||||
: archive.names().filter((name) => /\.x?html?$/i.test(name)).sort();
|
||||
|
||||
for (const path of spinePaths) {
|
||||
const raw = await archive.text(path);
|
||||
if (raw === null) continue;
|
||||
const baseDir = dirnameOf(path);
|
||||
sections.push({
|
||||
id: path,
|
||||
label: '',
|
||||
html: rewriteDocument(raw, baseDir),
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const toc = await readToc(archive, pkg, sections);
|
||||
applyTocLabels(sections, toc);
|
||||
|
||||
return {
|
||||
format: 'epub',
|
||||
layout: 'reflowable',
|
||||
direction: pkg.direction,
|
||||
sections,
|
||||
toc,
|
||||
styles,
|
||||
resources,
|
||||
orderedByBook: pkg.spine.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a content document so its own relative links keep working once the
|
||||
* document is injected into a page whose base URL is not the book's.
|
||||
*
|
||||
* Only `src`/`href` on specific attributes are touched. Scoped selectors and
|
||||
* inline styles are left alone on purpose.
|
||||
*/
|
||||
export function rewriteDocument(html: string, baseDir: string): string {
|
||||
return html
|
||||
.replace(/(\ssrc\s*=\s*")([^"]*)(")/gi, (_m, pre: string, value: string, post: string) =>
|
||||
`${pre}${rewriteUrl(value, baseDir)}${post}`)
|
||||
.replace(/(\ssrc\s*=\s*')([^']*)(')/gi, (_m, pre: string, value: string, post: string) =>
|
||||
`${pre}${rewriteUrl(value, baseDir)}${post}`)
|
||||
.replace(/(\shref\s*=\s*")([^"]*)(")/gi, (_m, pre: string, value: string, post: string) =>
|
||||
`${pre}${rewriteUrl(value, baseDir)}${post}`)
|
||||
.replace(/(\shref\s*=\s*')([^']*)(')/gi, (_m, pre: string, value: string, post: string) =>
|
||||
`${pre}${rewriteUrl(value, baseDir)}${post}`)
|
||||
.replace(/(\s*srcset\s*=\s*")([^"]*)(")/gi, (_m, pre: string, value: string, post: string) =>
|
||||
`${pre}${rewriteSrcset(value, baseDir)}${post}`);
|
||||
}
|
||||
|
||||
function rewriteSrcset(value: string, baseDir: string): string {
|
||||
return value
|
||||
.split(',')
|
||||
.map((part) => {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === '') return trimmed;
|
||||
const [url, ...descriptor] = trimmed.split(/\s+/);
|
||||
return [rewriteUrl(url ?? '', baseDir), ...descriptor].join(' ');
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a `reader-res:` scheme so the injected document cannot accidentally
|
||||
* load a remote URL. Local resources are served from memory by the renderer;
|
||||
* anything absolute is left as-is and will be blocked by the page's CSP, which
|
||||
* keeps a malicious book from phoning home.
|
||||
*/
|
||||
function rewriteUrl(value: string, baseDir: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '' || trimmed.startsWith('#') || trimmed.startsWith('data:') || trimmed.startsWith('blob:')) {
|
||||
return value;
|
||||
}
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return value;
|
||||
const resolved = resolveHref(baseDir, trimmed);
|
||||
return `reader-res:${resolved}${splitFragment(trimmed)[1]}`;
|
||||
}
|
||||
|
||||
function splitFragment(href: string): [string, string] {
|
||||
const index = href.indexOf('#');
|
||||
return index === -1 ? [href, ''] : [href.slice(0, index), href.slice(index)];
|
||||
}
|
||||
|
||||
/** CSS needs the same resolution for `url(...)`. */
|
||||
export function rewriteCss(css: string, cssPath: string): string {
|
||||
const baseDir = dirnameOf(cssPath);
|
||||
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (_m, quote: string, value: string) => {
|
||||
const rewritten = rewriteUrl(value, baseDir);
|
||||
return `url(${quote}${rewritten}${quote})`;
|
||||
});
|
||||
}
|
||||
|
||||
async function readPackage(archive: BookArchive): Promise<Package> {
|
||||
const containerXml = await archive.text(CONTAINER_PATH);
|
||||
let opfPath = '';
|
||||
if (containerXml) {
|
||||
const match = /full-path\s*=\s*"([^"]+)"/i.exec(containerXml) ?? /full-path\s*=\s*'([^']+)'/i.exec(containerXml);
|
||||
if (match?.[1]) opfPath = decodeURIComponent(match[1]);
|
||||
}
|
||||
if (!opfPath) {
|
||||
const candidates = archive.names().filter((name) => name.toLowerCase().endsWith('.opf'));
|
||||
opfPath = candidates.sort((a, b) => a.length - b.length)[0] ?? '';
|
||||
}
|
||||
if (!opfPath) throw new Error('EPUB 缺少 OPF 包文档');
|
||||
const opf = await archive.text(opfPath);
|
||||
if (!opf) throw new Error(`EPUB 找不到 ${opfPath}`);
|
||||
|
||||
const baseDir = dirnameOf(opfPath);
|
||||
const version = /version\s*=\s*"([^"]+)"/i.exec(opf)?.[1] ?? '2.0';
|
||||
const pageDirection = /page-progression-direction\s*=\s*"([^"]+)"/i.exec(opf)?.[1];
|
||||
|
||||
const manifest = readManifest(opf);
|
||||
const spine = readSpine(opf);
|
||||
|
||||
return {
|
||||
opfPath,
|
||||
baseDir,
|
||||
version,
|
||||
manifest,
|
||||
spine,
|
||||
direction: pageDirection === 'rtl' ? 'rtl' : 'ltr',
|
||||
};
|
||||
}
|
||||
|
||||
function readManifest(opf: string): ManifestItem[] {
|
||||
const items: ManifestItem[] = [];
|
||||
const manifestBlock = /<manifest\b[^>]*>([\s\S]*?)<\/manifest>/i.exec(opf)?.[1] ?? opf;
|
||||
const itemPattern = /<item\b([^>]*)\/?>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = itemPattern.exec(manifestBlock)) !== null) {
|
||||
const attrs = match[1] ?? '';
|
||||
const id = attr(attrs, 'id');
|
||||
const href = attr(attrs, 'href');
|
||||
if (!id || !href) continue;
|
||||
items.push({
|
||||
id,
|
||||
href,
|
||||
mediaType: attr(attrs, 'media-type'),
|
||||
properties: attr(attrs, 'properties').split(/\s+/).filter(Boolean),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function readSpine(opf: string): string[] {
|
||||
const block = /<spine\b[^>]*>([\s\S]*?)<\/spine>/i.exec(opf)?.[1];
|
||||
if (!block) return [];
|
||||
const ids: string[] = [];
|
||||
const pattern = /<itemref\b([^>]*)\/?>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(block)) !== null) {
|
||||
const attrs = match[1] ?? '';
|
||||
if (/\blinear\s*=\s*"no"/i.test(attrs)) continue;
|
||||
const idref = attr(attrs, 'idref');
|
||||
if (idref) ids.push(idref);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function attr(attrs: string, name: string): string {
|
||||
const doubleQuoted = new RegExp(`${name}\\s*=\\s*"([^"]*)"`, 'i').exec(attrs);
|
||||
if (doubleQuoted?.[1] !== undefined) return doubleQuoted[1];
|
||||
const singleQuoted = new RegExp(`${name}\\s*=\\s*'([^']*)'`, 'i').exec(attrs);
|
||||
return singleQuoted?.[1] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Table of contents, from EPUB 3 `nav` first then the EPUB 2 NCX.
|
||||
*
|
||||
* Reading order comes from the manifest, not from the TOC: plenty of books have
|
||||
* a TOC that lists only some chapters, and dropping the rest would hide content.
|
||||
* The TOC is used for labels and the navigation panel only.
|
||||
*/
|
||||
async function readToc(archive: BookArchive, pkg: Package, sections: Section[]): Promise<TocEntry[]> {
|
||||
const navItem = pkg.manifest.find((item) => item.properties.includes('nav'));
|
||||
if (navItem) {
|
||||
const navPath = resolveHref(pkg.baseDir, navItem.href);
|
||||
const navHtml = await archive.text(navPath);
|
||||
if (navHtml) {
|
||||
const entries = parseNav(navHtml, dirnameOf(navPath), sections);
|
||||
if (entries.length > 0) return entries;
|
||||
}
|
||||
}
|
||||
|
||||
const ncxItem = pkg.manifest.find((item) => item.mediaType === 'application/x-dtbncx+xml')
|
||||
?? pkg.manifest.find((item) => extensionOf(item.href) === 'ncx');
|
||||
if (ncxItem) {
|
||||
const ncxPath = resolveHref(pkg.baseDir, ncxItem.href);
|
||||
const ncxXml = await archive.text(ncxPath);
|
||||
if (ncxXml) {
|
||||
const entries = parseNcx(ncxXml, dirnameOf(ncxPath), sections);
|
||||
if (entries.length > 0) return entries;
|
||||
}
|
||||
}
|
||||
|
||||
return sections.map((section) => ({ id: section.id, label: section.label, depth: 0 }));
|
||||
}
|
||||
|
||||
function parseNav(html: string, baseDir: string, sections: Section[]): TocEntry[] {
|
||||
const navBlock = /<nav\b[^>]*epub:type\s*=\s*"toc"[^>]*>([\s\S]*?)<\/nav>/i.exec(html)?.[1]
|
||||
?? /<nav\b[^>]*>([\s\S]*?)<\/nav>/i.exec(html)?.[1];
|
||||
if (!navBlock) return [];
|
||||
const entries: TocEntry[] = [];
|
||||
const pattern = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(navBlock)) !== null) {
|
||||
const href = attr(match[1] ?? '', 'href');
|
||||
const label = stripTags(match[2] ?? '');
|
||||
if (!href || !label) continue;
|
||||
const id = matchSectionId(resolveHref(baseDir, href), sections);
|
||||
if (!id) continue;
|
||||
entries.push({ id, label, depth: 0 });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseNcx(xml: string, baseDir: string, sections: Section[]): TocEntry[] {
|
||||
const entries: TocEntry[] = [];
|
||||
const pattern = /<navPoint\b[^>]*>([\s\S]*?)<\/navPoint>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(xml)) !== null) {
|
||||
const block = match[1] ?? '';
|
||||
const src = /<content\b[^>]*src\s*=\s*"([^"]+)"/i.exec(block)?.[1] ?? '';
|
||||
const label = stripTags(/<text\b[^>]*>([\s\S]*?)<\/text>/i.exec(block)?.[1] ?? '');
|
||||
if (!src || !label) continue;
|
||||
const id = matchSectionId(resolveHref(baseDir, src), sections);
|
||||
if (!id) continue;
|
||||
const depth = (block.match(/<navPoint\b/gi)?.length ?? 1) - 1;
|
||||
entries.push({ id, label, depth });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Matches a TOC href to a section, tolerating fragment-only links. */
|
||||
function matchSectionId(resolved: string, sections: Section[]): string | null {
|
||||
const path = resolved.split('#')[0]!;
|
||||
if (path === '') return sections[0]?.id ?? null;
|
||||
const exact = sections.find((section) => section.id === path);
|
||||
if (exact) return exact.id;
|
||||
const normalised = path.replace(/^\.?\//, '').toLowerCase();
|
||||
return sections.find((section) => section.id.replace(/^\.?\//, '').toLowerCase() === normalised)?.id ?? null;
|
||||
}
|
||||
|
||||
function applyTocLabels(sections: Section[], toc: TocEntry[]): void {
|
||||
const depthById = new Map(toc.map((entry) => [entry.id, entry]));
|
||||
for (const section of sections) {
|
||||
const entry = depthById.get(section.id);
|
||||
if (entry) {
|
||||
section.label = entry.label;
|
||||
section.depth = entry.depth;
|
||||
}
|
||||
}
|
||||
// Chapters missing from the TOC still need a label for the progress bar and
|
||||
// the "current chapter" readout, so derive one from the document itself.
|
||||
for (const section of sections) {
|
||||
if (section.label) continue;
|
||||
const heading = section.html ? /<h[1-3][^>]*>([\s\S]{0,120}?)<\/h[1-3]>/i.exec(section.html)?.[1] : undefined;
|
||||
const title = section.html ? /<title[^>]*>([\s\S]{0,120}?)<\/title>/i.exec(section.html)?.[1] : undefined;
|
||||
section.label = stripTags(heading ?? title ?? '') || section.id.split('/').pop() || section.id;
|
||||
}
|
||||
}
|
||||
|
||||
function stripTags(value: string): string {
|
||||
return value
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function mediaTypeFor(path: string): string {
|
||||
switch (extensionOf(path)) {
|
||||
case 'xhtml':
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return 'application/xhtml+xml';
|
||||
case 'css':
|
||||
return 'text/css';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'svg':
|
||||
return 'image/svg+xml';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'avif':
|
||||
return 'image/avif';
|
||||
case 'ttf':
|
||||
return 'font/ttf';
|
||||
case 'otf':
|
||||
return 'font/otf';
|
||||
case 'woff':
|
||||
return 'font/woff';
|
||||
case 'woff2':
|
||||
return 'font/woff2';
|
||||
case 'mp3':
|
||||
return 'audio/mpeg';
|
||||
case 'm4a':
|
||||
return 'audio/mp4';
|
||||
case 'mp4':
|
||||
return 'video/mp4';
|
||||
case 'js':
|
||||
return 'text/javascript';
|
||||
case 'ncx':
|
||||
return 'application/x-dtbncx+xml';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { detectFormat } from './detect.ts';
|
||||
import { loadEpub } from './epub.ts';
|
||||
import { loadTxt, type TxtOptions } from './txt.ts';
|
||||
import { loadCbz, loadComicDirectory, isImagePath } from './comic.ts';
|
||||
import { loadPdf } from './pdf.ts';
|
||||
import { extensionOf } from './zip.ts';
|
||||
import type { BookDoc, LoadContext } from './types.ts';
|
||||
|
||||
export type { BookDoc, BookFormat, LoadContext, Section, TocEntry, Layout, Resource } from './types.ts';
|
||||
export { loadEpub } from './epub.ts';
|
||||
export { loadTxt } from './txt.ts';
|
||||
export { loadCbz, loadComicDirectory, isImagePath } from './comic.ts';
|
||||
export { loadPdf } from './pdf.ts';
|
||||
export { detectFormat } from './detect.ts';
|
||||
export { extensionOf } from './zip.ts';
|
||||
|
||||
/**
|
||||
* Format dispatch.
|
||||
*
|
||||
* A plain switch rather than a plugin registry: there are six formats and the
|
||||
* set is decided by what readers have on disk, not by third-party extension.
|
||||
* Adding a format means adding a case and a loader — nothing else in the client
|
||||
* changes, which is the property that actually matters.
|
||||
*/
|
||||
|
||||
export interface LoadOptions extends TxtOptions {
|
||||
/** File name from the manifest, used to disambiguate formats. */
|
||||
fileName?: string;
|
||||
/** Directory-comic page list, resolved from the book manifest. */
|
||||
comicPages?: string[];
|
||||
resolvePage?: (path: string) => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface LoadedBook {
|
||||
doc: BookDoc;
|
||||
/** Diagnostics worth surfacing: TXT encoding chosen, chapter count, etc. */
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export async function loadBook(ctx: LoadContext, options: LoadOptions = {}): Promise<LoadedBook> {
|
||||
const format = resolveFormat(ctx, options);
|
||||
|
||||
switch (format) {
|
||||
case 'epub':
|
||||
return { doc: await loadEpub(ctx), notes: [] };
|
||||
case 'txt': {
|
||||
const result = loadTxt(ctx, options);
|
||||
const notes: string[] = [];
|
||||
if (result.encodingGuessed) {
|
||||
notes.push(`按 ${result.encoding} 解码(文件未声明编码,若显示乱码可在设置中切换)`);
|
||||
}
|
||||
if (result.chapterCount === 0) {
|
||||
notes.push('未识别到章节标记,已按长度分段');
|
||||
}
|
||||
return { doc: result.doc, notes };
|
||||
}
|
||||
case 'cbz':
|
||||
return { doc: await loadCbz(ctx), notes: [] };
|
||||
case 'comic-dir': {
|
||||
if (!options.comicPages || options.comicPages.length === 0) {
|
||||
throw new Error('漫画目录中没有可用图片');
|
||||
}
|
||||
const resolve = options.resolvePage ?? (async () => new Uint8Array());
|
||||
return { doc: await loadComicDirectory(options.comicPages, resolve), notes: [] };
|
||||
}
|
||||
case 'pdf':
|
||||
return { doc: await loadPdf(), notes: [] };
|
||||
default:
|
||||
throw new Error(`不支持的格式:${ctx.fileName || '未知文件'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the loader.
|
||||
*
|
||||
* Detection by sniffing and extension covers single-file books. One case cannot
|
||||
* be detected that way: a comic stored as a *directory* has no single file to
|
||||
* sniff, and its "file name" is a folder. The caller recognises it from the
|
||||
* manifest's page list and passes that in, and it has to win over the extension.
|
||||
*/
|
||||
function resolveFormat(ctx: LoadContext, options: LoadOptions): string {
|
||||
const name = options.fileName ?? ctx.fileName;
|
||||
const pages = options.comicPages?.length ?? 0;
|
||||
if (pages > 0) {
|
||||
const extension = extensionOf(name).toLowerCase();
|
||||
// A lone image file with a page list is still one image, not a directory
|
||||
// comic; and an archive extension means the manifest is listing members of
|
||||
// an archive the format layer should unpack itself.
|
||||
if (!['epub', 'pdf', 'cbz', 'txt'].includes(extension) && !isImagePath(name)) {
|
||||
return 'comic-dir';
|
||||
}
|
||||
}
|
||||
return detectFormat(name, ctx.bytes);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Natural ordering for chapter and page names.
|
||||
*
|
||||
* A comic archive almost always names its pages `001.jpg … 010.jpg`, but a
|
||||
* scanalator that goes past 99 produces `100.jpg`, and a handful of tools emit
|
||||
* `第1话`, `第10话`, `第2话`. Plain lexicographic sort turns those into
|
||||
* 1, 10, 2 — a book that reads in the wrong order, which readers notice
|
||||
* immediately and never forgive.
|
||||
*/
|
||||
|
||||
const NUMERIC_CHUNK = /(\d+)/;
|
||||
|
||||
export function naturalCompare(a: string, b: string): number {
|
||||
const left = splitChunks(a);
|
||||
const right = splitChunks(b);
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const l = left[index];
|
||||
const r = right[index];
|
||||
if (l === undefined) return -1;
|
||||
if (r === undefined) return 1;
|
||||
if (typeof l === 'number' && typeof r === 'number') {
|
||||
if (l !== r) return l - r;
|
||||
continue;
|
||||
}
|
||||
const ls = String(l);
|
||||
const rs = String(r);
|
||||
if (ls === rs) continue;
|
||||
// Compare case-insensitively first so `Chapter` and `chapter` do not sort
|
||||
// into separate runs, then fall back to a stable tie-break.
|
||||
const lower = ls.localeCompare(rs, undefined, { sensitivity: 'base', numeric: false });
|
||||
if (lower !== 0) return lower;
|
||||
return ls < rs ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function splitChunks(value: string): Array<string | number> {
|
||||
const parts = value.split(NUMERIC_CHUNK);
|
||||
const chunks: Array<string | number> = [];
|
||||
for (const part of parts) {
|
||||
if (part === '') continue;
|
||||
if (/^\d+$/.test(part)) {
|
||||
// Guard against a leading-zero run long enough to lose precision when
|
||||
// parsed as a float: `00000000001` must not become `1e-7`.
|
||||
chunks.push(part.length > 15 ? Number.parseInt(part, 10) : Number(part));
|
||||
} else {
|
||||
chunks.push(part);
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function sortByName<T>(items: T[], nameOf: (item: T) => string): T[] {
|
||||
return [...items].sort((a, b) => naturalCompare(nameOf(a), nameOf(b)));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { BookDoc } from './types.ts';
|
||||
|
||||
/**
|
||||
* PDF loader.
|
||||
*
|
||||
* Scope is honest: PDF is "保底可读" (product design §10). The browser's own
|
||||
* viewer is used, in an iframe with the blob URL, because reimplementing text
|
||||
* selection, search and zoom on a canvas renderer is weeks of work for a format
|
||||
* the product explicitly does not differentiate on.
|
||||
*
|
||||
* What this loader does provide is the *same navigation contract* as every other
|
||||
* format: a manifest with a content URL and a page count, so the shelf, progress
|
||||
* reporting and the reading UI do not need a PDF special case.
|
||||
*
|
||||
* The one thing worth caring about here is the page count. Reading a PDF without
|
||||
* a page indicator is disorienting, and the client cannot count pages without
|
||||
* parsing the file, so the count is read from the server's metadata when the
|
||||
* book was scanned (`book.pageCount`). When it is missing, the UI degrades to
|
||||
* percentage-only progress rather than showing a fake page number.
|
||||
*/
|
||||
export async function loadPdf(): Promise<BookDoc> {
|
||||
return {
|
||||
format: 'pdf',
|
||||
layout: 'fixed',
|
||||
direction: 'ltr',
|
||||
// A PDF is one document, not a list of sections: the browser viewer owns
|
||||
// navigation inside it. One section keeps the progress model uniform.
|
||||
sections: [
|
||||
{
|
||||
id: 'pdf',
|
||||
label: 'PDF',
|
||||
html: '',
|
||||
depth: 0,
|
||||
},
|
||||
],
|
||||
toc: [{ id: 'pdf', label: 'PDF', depth: 0 }],
|
||||
styles: [],
|
||||
resources: new Map(),
|
||||
orderedByBook: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Encoding detection for plain-text books.
|
||||
*
|
||||
* This is not a nicety. A large share of Chinese TXT libraries on a NAS were
|
||||
* produced by desktop tools in the 2000s and are GB18030, with no BOM and no
|
||||
* declaration. Decoding those as UTF-8 produces replacement characters on
|
||||
* nearly every line — the file becomes unreadable rather than slightly wrong.
|
||||
*
|
||||
* The strategy is validate-then-fallback, not guessing: a byte sequence that
|
||||
* decodes as strict UTF-8 is by construction almost never valid GB18030 text of
|
||||
* this kind, so the check is reliable in the direction that matters.
|
||||
*/
|
||||
|
||||
const GB18030 = 'gb18030';
|
||||
const BIG5 = 'big5';
|
||||
const UTF16LE = 'utf-16le';
|
||||
const UTF16BE = 'utf-16be';
|
||||
|
||||
export interface DecodedText {
|
||||
text: string;
|
||||
encoding: string;
|
||||
/** True when the chosen encoding is a fallback rather than a declaration. */
|
||||
guessed: boolean;
|
||||
}
|
||||
|
||||
function hasUtf8Bom(bytes: Uint8Array): boolean {
|
||||
return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
|
||||
}
|
||||
|
||||
function bomOf(bytes: Uint8Array): string | null {
|
||||
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return 'utf-8';
|
||||
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) return UTF16LE;
|
||||
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) return UTF16BE;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Strict UTF-8 validation. Returns false when any replacement would occur. */
|
||||
export function isValidUtf8(bytes: Uint8Array): boolean {
|
||||
let index = 0;
|
||||
const length = bytes.length;
|
||||
while (index < length) {
|
||||
const byte = bytes[index]!;
|
||||
if (byte <= 0x7f) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let needed: number;
|
||||
let min: number;
|
||||
let code: number;
|
||||
if ((byte & 0xe0) === 0xc0) {
|
||||
needed = 1;
|
||||
min = 0x80;
|
||||
code = byte & 0x1f;
|
||||
} else if ((byte & 0xf0) === 0xe0) {
|
||||
needed = 2;
|
||||
min = 0x800;
|
||||
code = byte & 0x0f;
|
||||
} else if ((byte & 0xf8) === 0xf0) {
|
||||
needed = 3;
|
||||
min = 0x10000;
|
||||
code = byte & 0x07;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (index + needed >= length) return false;
|
||||
for (let offset = 1; offset <= needed; offset += 1) {
|
||||
const next = bytes[index + offset]!;
|
||||
if ((next & 0xc0) !== 0x80) return false;
|
||||
code = (code << 6) | (next & 0x3f);
|
||||
}
|
||||
if (code < min) return false; // overlong encoding
|
||||
if (code > 0x10ffff) return false;
|
||||
if (code >= 0xd800 && code <= 0xdfff) return false; // lone surrogate
|
||||
index += needed + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a text book.
|
||||
*
|
||||
* `TextDecoder` with an unsupported label throws in the browser, so each
|
||||
* fallback is attempted and dropped if unavailable. The final fallback is
|
||||
* lenient UTF-8, which always returns *something*: a book with a few mangled
|
||||
* characters beats an error screen.
|
||||
*/
|
||||
export function decodeText(bytes: Uint8Array): DecodedText {
|
||||
const typed = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
const bom = bomOf(typed);
|
||||
if (bom === 'utf-8') {
|
||||
return { text: decodeStrict(typed.subarray(3), 'utf-8'), encoding: 'utf-8', guessed: false };
|
||||
}
|
||||
if (bom === UTF16LE || bom === UTF16BE) {
|
||||
// Strip the BOM: decoding it into the string leaves a zero-width character
|
||||
// at the start of every book, which then breaks chapter-title matching.
|
||||
return { text: decodeStrict(typed.subarray(2), bom), encoding: bom, guessed: false };
|
||||
}
|
||||
if (isValidUtf8(typed)) {
|
||||
return { text: decodeStrict(typed, 'utf-8'), encoding: 'utf-8', guessed: false };
|
||||
}
|
||||
|
||||
// No BOM and not valid UTF-8: it is one of the CJK legacy encodings. Try
|
||||
// Simplified Chinese first (by far the most common for these files), then
|
||||
// Traditional, then give up on a lenient decode.
|
||||
for (const encoding of [GB18030, BIG5]) {
|
||||
const text = decodeStrict(typed, encoding);
|
||||
if (text && countReplacementChars(text) === 0) {
|
||||
return { text, encoding, guessed: true };
|
||||
}
|
||||
}
|
||||
return { text: decodeLenient(typed, 'utf-8'), encoding: 'utf-8', guessed: true };
|
||||
}
|
||||
|
||||
function decodeStrict(bytes: Uint8Array, encoding: string): string {
|
||||
try {
|
||||
return new TextDecoder(encoding, { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
try {
|
||||
return new TextDecoder(encoding).decode(bytes);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decodeLenient(bytes: Uint8Array, encoding: string): string {
|
||||
try {
|
||||
return new TextDecoder(encoding).decode(bytes);
|
||||
} catch {
|
||||
let out = '';
|
||||
for (const byte of bytes) out += String.fromCharCode(byte);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
function countReplacementChars(text: string): number {
|
||||
let count = 0;
|
||||
for (const char of text) {
|
||||
if (char === '\uFFFD') count += 1;
|
||||
if (count > 8) return count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export { hasUtf8Bom };
|
||||
@@ -0,0 +1,222 @@
|
||||
import { decodeText } from './text-encoding.ts';
|
||||
import type { BookDoc, LoadContext, Section } from './types.ts';
|
||||
|
||||
/**
|
||||
* Plain-text loader.
|
||||
*
|
||||
* Two problems worth solving properly, because a TXT library is where a reader
|
||||
* either earns trust or loses it:
|
||||
*
|
||||
* 1. **Encoding.** See text-encoding.ts. This file has no way to know, so it
|
||||
* detects, and it reports what it chose so the UI can let the reader override
|
||||
* it for the one file in a thousand that the heuristic gets wrong.
|
||||
*
|
||||
* 2. **Chapter splitting.** TXT has no structure at all, so the table of
|
||||
* contents has to be inferred. Getting it wrong in either direction is bad:
|
||||
* too eager and every numbered line becomes a chapter, too timid and a
|
||||
* 5000-chapter web novel becomes one endless section that cannot be
|
||||
* navigated or paginated without freezing the device.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Chapter headings, ordered from most to least specific.
|
||||
*
|
||||
* Tuned against the shapes that actually appear in Chinese TXT collections:
|
||||
* `第一章`, `第 1 章`, `第1节`, `Chapter 3`, and the bracketed forms that
|
||||
* scrapers emit. Each alternative is anchored so a line of prose that merely
|
||||
* *mentions* a chapter number ("他在第一章里说过") is not mistaken for a
|
||||
* heading.
|
||||
*/
|
||||
const CHAPTER_PATTERNS: RegExp[] = [
|
||||
// 第一章 标题 / 第1章 / 第一百二十三章
|
||||
/^\s*第\s*[0-90-9零一二三四五六七八九十百千万两]+\s*[章回节卷部篇集]\s*[::.、\-—]?\s*(.{0,60})$/,
|
||||
// Chapter 3 / CHAPTER III
|
||||
/^\s*chapter\s+([0-9]+|[ivxlcdm]+)\b\s*[::.、\-—]?\s*(.{0,60})$/i,
|
||||
// 【第一章】标题 / (第一节)标题
|
||||
/^\s*[【((\[]\s*第\s*[0-90-9零一二三四五六七八九十百千万两]+\s*[章回节卷部篇集]\s*[】))\]]\s*(.{0,60})$/,
|
||||
// 卷一 / 卷二 (bare 卷 heading, common in classic novels)
|
||||
/^\s*[卷部]\s*[0-90-9零一二三四五六七八九十百千万两]+\s*(.{0,60})$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* A heading line is short. Without this, a paragraph that starts with "第三章"
|
||||
* and continues for 800 characters would be treated as a chapter title and the
|
||||
* real chapter body would lose its beginning.
|
||||
*/
|
||||
const MAX_HEADING_LENGTH = 80;
|
||||
|
||||
export interface TxtOptions {
|
||||
/** Force an encoding label such as `gb18030`; detected when omitted. */
|
||||
encoding?: string;
|
||||
/** Split into chapters. When false the whole book is one section. */
|
||||
splitChapters?: boolean;
|
||||
}
|
||||
|
||||
export interface TxtSplitResult {
|
||||
doc: BookDoc;
|
||||
encoding: string;
|
||||
encodingGuessed: boolean;
|
||||
chapterCount: number;
|
||||
}
|
||||
|
||||
export function loadTxt(ctx: LoadContext, options: TxtOptions = {}): TxtSplitResult {
|
||||
const decoded = options.encoding
|
||||
? { text: decodeWith(ctx.bytes, options.encoding), encoding: options.encoding, guessed: false }
|
||||
: decodeText(ctx.bytes);
|
||||
const text = normaliseLineEndings(decoded.text);
|
||||
const splitChapters = options.splitChapters !== false;
|
||||
|
||||
const lines = text.split('\n');
|
||||
const chapters: Array<{ title: string; start: number }> = [];
|
||||
let offset = 0;
|
||||
|
||||
if (splitChapters) {
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length > 0 && trimmed.length <= MAX_HEADING_LENGTH && matchHeading(trimmed)) {
|
||||
chapters.push({ title: trimmed, start: offset });
|
||||
}
|
||||
offset += line.length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const sections: Section[] = [];
|
||||
if (chapters.length === 0) {
|
||||
// No headings found: either the book simply has none, or the heuristic
|
||||
// missed. Either way one giant section is unusable, so fall back to fixed
|
||||
// chunks that keep the renderer responsive.
|
||||
for (const chunk of chunkByLength(text, 40_000)) {
|
||||
sections.push({
|
||||
id: `chunk-${sections.length}`,
|
||||
label: chunk.label,
|
||||
html: textToHtml(chunk.text),
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (chapters[0]!.start > 0) {
|
||||
const preface = text.slice(0, chapters[0]!.start).trim();
|
||||
if (preface.length > 0) {
|
||||
sections.push({ id: 'preface', label: '前言', html: textToHtml(preface), depth: 0 });
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < chapters.length; index += 1) {
|
||||
const current = chapters[index]!;
|
||||
const next = chapters[index + 1];
|
||||
const body = text.slice(current.start, next ? next.start : text.length);
|
||||
// A chapter of several hundred thousand characters (some web novels have
|
||||
// them) still needs splitting, or layout blocks the UI thread.
|
||||
const parts = chunkByLength(body, 60_000);
|
||||
for (const [partIndex, part] of parts.entries()) {
|
||||
sections.push({
|
||||
id: `ch${index}-${partIndex}`,
|
||||
label: parts.length > 1 ? `${current.title} (${partIndex + 1}/${parts.length})` : current.title,
|
||||
html: textToHtml(part.text),
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toc = sections.map((section) => ({ id: section.id, label: section.label, depth: 0 }));
|
||||
|
||||
return {
|
||||
encoding: decoded.encoding,
|
||||
encodingGuessed: decoded.guessed,
|
||||
chapterCount: chapters.length,
|
||||
doc: {
|
||||
format: 'txt',
|
||||
layout: 'reflowable',
|
||||
direction: 'ltr',
|
||||
sections,
|
||||
toc,
|
||||
styles: [],
|
||||
resources: new Map(),
|
||||
orderedByBook: chapters.length > 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matchHeading(line: string): boolean {
|
||||
for (const pattern of CHAPTER_PATTERNS) {
|
||||
pattern.lastIndex = 0;
|
||||
if (pattern.test(line)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function decodeWith(bytes: Uint8Array, encoding: string): string {
|
||||
try {
|
||||
return new TextDecoder(encoding).decode(bytes);
|
||||
} catch {
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
export function normaliseLineEndings(text: string): string {
|
||||
return text.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
text: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits text into pieces of at most `maxLength` characters, breaking on a
|
||||
* paragraph boundary when one is close enough.
|
||||
*
|
||||
* Breaking mid-paragraph is acceptable; breaking mid-sentence would not be, so
|
||||
* the search looks backwards for a blank line first and only then for the end
|
||||
* of a paragraph.
|
||||
*/
|
||||
export function chunkByLength(text: string, maxLength: number): Chunk[] {
|
||||
if (text.length <= maxLength) return [{ text, label: '' }];
|
||||
const chunks: Chunk[] = [];
|
||||
let start = 0;
|
||||
let index = 0;
|
||||
while (start < text.length) {
|
||||
let end = Math.min(start + maxLength, text.length);
|
||||
if (end < text.length) {
|
||||
const window = text.slice(start + Math.floor(maxLength * 0.6), end);
|
||||
const blank = window.lastIndexOf('\n\n');
|
||||
if (blank !== -1) end = start + Math.floor(maxLength * 0.6) + blank + 2;
|
||||
else {
|
||||
const newline = window.lastIndexOf('\n');
|
||||
if (newline !== -1) end = start + Math.floor(maxLength * 0.6) + newline + 1;
|
||||
}
|
||||
}
|
||||
const body = text.slice(start, end);
|
||||
if (body.trim().length > 0) chunks.push({ text: body, label: `第 ${index + 1} 段` });
|
||||
index += 1;
|
||||
start = end;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes the text and wraps each non-empty line in a paragraph.
|
||||
*
|
||||
* Escaping is not optional: TXT files routinely contain `<` from markup that was
|
||||
* partially stripped by whichever tool produced them, and injecting that raw
|
||||
* would let the file break the page layout.
|
||||
*/
|
||||
export function textToHtml(text: string): string {
|
||||
const escaped = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
const paragraphs = escaped
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter((block) => block.length > 0)
|
||||
.map((block) => {
|
||||
const inner = block
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join('<br>');
|
||||
return `<p>${inner}</p>`;
|
||||
});
|
||||
return paragraphs.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Format-neutral book model.
|
||||
*
|
||||
* The server hands the client an opaque file body (`GET /books/:id/content`)
|
||||
* and a format label. Everything past that point — unpacking, spine order,
|
||||
* resource resolution, pagination units — is the renderer's job, because those
|
||||
* are precisely the decisions the product wants to own (§5: 不自研渲染内核,
|
||||
* but also 不套 Readium).
|
||||
*
|
||||
* The one shape every format must reduce to is `BookDoc`, a flat list of
|
||||
* sections. That is what keeps a comic, a TXT novel and a reflowable EPUB on
|
||||
* the same navigation and progress code path.
|
||||
*/
|
||||
|
||||
export type BookFormat = 'epub' | 'pdf' | 'txt' | 'cbz' | 'comic-dir' | 'image' | 'unknown';
|
||||
|
||||
export interface Resource {
|
||||
/** Path as referenced from a section's markup, already resolved. */
|
||||
path: string;
|
||||
mediaType: string;
|
||||
bytes(): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
/** Stable within a book: used as the progress locator prefix. */
|
||||
id: string;
|
||||
/** Human label for the table of contents. */
|
||||
label: string;
|
||||
/** HTML to inject, for reflowable formats. */
|
||||
html?: string;
|
||||
/** Binary payload, for fixed-layout formats (comic page, PDF page raster). */
|
||||
image?: { mediaType: string; bytes: Uint8Array };
|
||||
/** Blob URL or data URL for the image, resolved lazily by the renderer. */
|
||||
imageUrl?: string;
|
||||
/** Depth in the navigation tree, 0 for top level. */
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface TocEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export type Layout = 'reflowable' | 'fixed';
|
||||
|
||||
export interface BookDoc {
|
||||
format: BookFormat;
|
||||
/** Reflowable pages can be re-paginated; fixed ones are one screen per section. */
|
||||
layout: Layout;
|
||||
/** Reading direction, from the EPUB spine / comic metadata. */
|
||||
direction: 'ltr' | 'rtl';
|
||||
sections: Section[];
|
||||
toc: TocEntry[];
|
||||
/** CSS the format itself carries (EPUB stylesheets), in injection order. */
|
||||
styles: string[];
|
||||
resources: Map<string, Resource>;
|
||||
/** True when the reading order came from the book rather than the file order. */
|
||||
orderedByBook: boolean;
|
||||
}
|
||||
|
||||
export interface LoadContext {
|
||||
/** Raw book bytes. */
|
||||
bytes: Uint8Array;
|
||||
/** File name, when known: the last-resort metadata source for TXT. */
|
||||
fileName: string;
|
||||
/** Book id, used to namespace generated URLs. */
|
||||
bookId: string;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
/**
|
||||
* Read-only ZIP access shared by EPUB and CBZ.
|
||||
*
|
||||
* The whole archive is held in memory. That is a deliberate trade for a mobile
|
||||
* renderer: a second read pass would mean either re-downloading the book or
|
||||
* running a random-access file layer over IndexedDB, and both cost more than
|
||||
* the archive itself for the sizes this product targets (a few hundred MB of
|
||||
* pages is already at the edge of what a phone should hold open).
|
||||
*/
|
||||
export class BookArchive {
|
||||
private constructor(private readonly zip: JSZip) {}
|
||||
|
||||
static async open(bytes: Uint8Array): Promise<BookArchive> {
|
||||
const zip = await JSZip.loadAsync(bytes);
|
||||
return new BookArchive(zip);
|
||||
}
|
||||
|
||||
names(): string[] {
|
||||
const out: string[] = [];
|
||||
this.zip.forEach((path, entry) => {
|
||||
if (!entry.dir) out.push(path);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
has(path: string): boolean {
|
||||
return this.zip.file(path) !== null;
|
||||
}
|
||||
|
||||
async bytes(path: string): Promise<Uint8Array | null> {
|
||||
const file = this.zip.file(path);
|
||||
if (!file) {
|
||||
// Some archives store paths with a leading slash or in a different case;
|
||||
// retry the common variants rather than failing the whole book.
|
||||
const normalised = normalise(path);
|
||||
const found = this.names().find((name) => normalise(name) === normalised);
|
||||
if (!found) return null;
|
||||
const fallback = this.zip.file(found);
|
||||
return fallback ? fallback.async('uint8array') : null;
|
||||
}
|
||||
return file.async('uint8array');
|
||||
}
|
||||
|
||||
async text(path: string): Promise<string | null> {
|
||||
const bytes = await this.bytes(path);
|
||||
if (!bytes) return null;
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalise(path: string): string {
|
||||
return decodeURIComponent(path).replace(/^\.?\//, '').toLowerCase();
|
||||
}
|
||||
|
||||
/** Resolves a relative href against a base directory, collapsing `../`. */
|
||||
export function resolveHref(base: string, href: string): string {
|
||||
const cleanHref = href.split('#')[0]!.split('?')[0]!;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(cleanHref)) return cleanHref; // absolute URL
|
||||
const baseParts = base === '' ? [] : base.split('/').filter(Boolean);
|
||||
const hrefParts = decodeURIComponent(cleanHref).split('/');
|
||||
const stack = [...baseParts];
|
||||
for (const part of hrefParts) {
|
||||
if (part === '' || part === '.') continue;
|
||||
if (part === '..') stack.pop();
|
||||
else stack.push(part);
|
||||
}
|
||||
return stack.join('/');
|
||||
}
|
||||
|
||||
export function dirnameOf(path: string): string {
|
||||
const index = path.lastIndexOf('/');
|
||||
return index === -1 ? '' : path.slice(0, index);
|
||||
}
|
||||
|
||||
export function extensionOf(path: string): string {
|
||||
const clean = path.split('#')[0]!.split('?')[0]!;
|
||||
const index = clean.lastIndexOf('.');
|
||||
return index === -1 ? '' : clean.slice(index + 1).toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import './styles/reader.css';
|
||||
import { App } from './app.ts';
|
||||
|
||||
/**
|
||||
* Entry point.
|
||||
*
|
||||
* The shell exposes the app on `window` for two reasons: the Android bridge
|
||||
* needs to reach in for lifecycle and diagnostics, and browser devtools are the
|
||||
* primary debugging surface for a self-hosted product, where asking a user to
|
||||
* attach a remote debugger is not realistic.
|
||||
*/
|
||||
const root = document.getElementById('app');
|
||||
if (!root) throw new Error('missing #app root element');
|
||||
|
||||
const app = new App({
|
||||
root,
|
||||
defaultServerUrl: import.meta.env.VITE_SERVER_URL ?? '',
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
readerApp?: App;
|
||||
/**
|
||||
* Flush hook called by the Android shell from `Activity.onPause`.
|
||||
*
|
||||
* The shell cannot know when a reading position changed, and the reader's own
|
||||
* debounce will never fire if the app is frozen first. Exposing one function
|
||||
* is the whole contract: "the app is going away, write what you have".
|
||||
*/
|
||||
__readerFlush?: () => void;
|
||||
}
|
||||
}
|
||||
window.readerApp = app;
|
||||
window.__readerFlush = () => {
|
||||
void app.flush();
|
||||
};
|
||||
|
||||
void app.start().catch((err: unknown) => {
|
||||
root.textContent = `客户端启动失败:${err instanceof Error ? err.message : String(err)}`;
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Minimal IndexedDB wrapper.
|
||||
*
|
||||
* Deliberately dependency-free and small: the client ships into a WebView where
|
||||
* bundle size and startup cost are felt, and the two stores it needs are a
|
||||
* string map and a byte map. No indexes, no cursors, no migrations beyond a
|
||||
* version bump that drops and recreates the caches.
|
||||
*
|
||||
* The in-memory fallback matters more than it looks: some Android WebView
|
||||
* configurations and Safari private windows expose `indexedDB` but throw on
|
||||
* first use. Without the fallback the reader would be unusable instead of
|
||||
* merely non-persistent, and losing the cache is recoverable while a blank
|
||||
* screen is not.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'reader';
|
||||
const DB_VERSION = 1;
|
||||
const KV_STORE = 'kv';
|
||||
const BLOB_STORE = 'blobs';
|
||||
|
||||
function hasIndexedDb(): boolean {
|
||||
try {
|
||||
return typeof indexedDB !== 'undefined' && indexedDB !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error('indexedDB request failed'));
|
||||
});
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(KV_STORE)) db.createObjectStore(KV_STORE);
|
||||
if (!db.objectStoreNames.contains(BLOB_STORE)) db.createObjectStore(BLOB_STORE);
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error('cannot open indexedDB'));
|
||||
request.onblocked = () => reject(new Error('indexedDB upgrade blocked by another tab'));
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
async function tx<T>(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => Promise<T>): Promise<T> {
|
||||
const db = await openDb();
|
||||
const transaction = db.transaction(store, mode);
|
||||
const objectStore = transaction.objectStore(store);
|
||||
const result = await fn(objectStore);
|
||||
// Wait for the transaction to actually commit: awaiting the request alone can
|
||||
// resolve before the write is durable.
|
||||
if (mode === 'readwrite') {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error ?? new Error('transaction failed'));
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error('transaction aborted'));
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class MemoryKv {
|
||||
private readonly map = new Map<string, string>();
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.map.has(key) ? this.map.get(key)! : null;
|
||||
}
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
this.map.set(key, value);
|
||||
}
|
||||
async remove(key: string): Promise<void> {
|
||||
this.map.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryBlobs {
|
||||
private readonly map = new Map<string, Uint8Array>();
|
||||
async get(key: string): Promise<Uint8Array | null> {
|
||||
return this.map.get(key) ?? null;
|
||||
}
|
||||
async put(key: string, bytes: Uint8Array): Promise<void> {
|
||||
this.map.set(key, bytes);
|
||||
}
|
||||
async remove(key: string): Promise<void> {
|
||||
this.map.delete(key);
|
||||
}
|
||||
async list(): Promise<string[]> {
|
||||
return [...this.map.keys()];
|
||||
}
|
||||
async usage(): Promise<number> {
|
||||
let total = 0;
|
||||
for (const bytes of this.map.values()) total += bytes.byteLength;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Stores {
|
||||
kv: { get(key: string): Promise<string | null>; set(key: string, value: string): Promise<void>; remove(key: string): Promise<void> };
|
||||
blobs: {
|
||||
get(key: string): Promise<Uint8Array | null>;
|
||||
put(key: string, bytes: Uint8Array): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
usage(): Promise<number>;
|
||||
};
|
||||
/** True when the fallback in-memory store is in use. */
|
||||
ephemeral: boolean;
|
||||
}
|
||||
|
||||
export async function createStores(): Promise<Stores> {
|
||||
if (!hasIndexedDb()) {
|
||||
return { kv: new MemoryKv(), blobs: new MemoryBlobs(), ephemeral: true };
|
||||
}
|
||||
try {
|
||||
// Probe with a real transaction: `indexedDB` existing does not imply it works.
|
||||
await tx(KV_STORE, 'readonly', async (store) => requestToPromise(store.get('__probe__')));
|
||||
} catch {
|
||||
return { kv: new MemoryKv(), blobs: new MemoryBlobs(), ephemeral: true };
|
||||
}
|
||||
|
||||
const kv = {
|
||||
async get(key: string): Promise<string | null> {
|
||||
const value = await tx<string | undefined>(KV_STORE, 'readonly', async (store) =>
|
||||
requestToPromise<string | undefined>(store.get(key)),
|
||||
);
|
||||
return value ?? null;
|
||||
},
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
await tx(KV_STORE, 'readwrite', async (store) => {
|
||||
await requestToPromise(store.put(value, key));
|
||||
});
|
||||
},
|
||||
async remove(key: string): Promise<void> {
|
||||
await tx(KV_STORE, 'readwrite', async (store) => {
|
||||
await requestToPromise(store.delete(key));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const blobs = {
|
||||
async get(key: string): Promise<Uint8Array | null> {
|
||||
const value = await tx<Uint8Array | ArrayBuffer | undefined>(BLOB_STORE, 'readonly', async (store) =>
|
||||
requestToPromise<Uint8Array | ArrayBuffer | undefined>(store.get(key)),
|
||||
);
|
||||
if (!value) return null;
|
||||
return value instanceof Uint8Array ? value : new Uint8Array(value);
|
||||
},
|
||||
async put(key: string, bytes: Uint8Array): Promise<void> {
|
||||
await tx(BLOB_STORE, 'readwrite', async (store) => {
|
||||
await requestToPromise(store.put(bytes, key));
|
||||
});
|
||||
},
|
||||
async remove(key: string): Promise<void> {
|
||||
await tx(BLOB_STORE, 'readwrite', async (store) => {
|
||||
await requestToPromise(store.delete(key));
|
||||
});
|
||||
},
|
||||
async list(): Promise<string[]> {
|
||||
const keys = await tx<IDBValidKey[]>(BLOB_STORE, 'readonly', async (store) =>
|
||||
requestToPromise<IDBValidKey[]>(store.getAllKeys()),
|
||||
);
|
||||
return keys.map((key) => String(key));
|
||||
},
|
||||
async usage(): Promise<number> {
|
||||
const values = await tx<Array<Uint8Array | ArrayBuffer>>(BLOB_STORE, 'readonly', async (store) =>
|
||||
requestToPromise<Array<Uint8Array | ArrayBuffer>>(store.getAll()),
|
||||
);
|
||||
let total = 0;
|
||||
for (const value of values) total += value instanceof Uint8Array ? value.byteLength : value.byteLength;
|
||||
return total;
|
||||
},
|
||||
};
|
||||
|
||||
return { kv, blobs, ephemeral: false };
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import type { Book, Note, Progress, SyncPull } from '../api/types.ts';
|
||||
import type { KeyValueStore } from '../core/platform.ts';
|
||||
|
||||
/**
|
||||
* Local mirror of everything the reader needs to keep working with no server.
|
||||
*
|
||||
* Scope is deliberately the *reading-critical* state only: the shelf, progress,
|
||||
* notes and the sync cursor. Book bodies live in the blob store; covers are
|
||||
* re-fetched lazily. Nothing here is authoritative — the server is (§4), so a
|
||||
* wipe of this cache must never destroy anything the user cannot recover.
|
||||
*/
|
||||
|
||||
const KEY = 'reader.offline.v1';
|
||||
|
||||
export interface PendingNote {
|
||||
note: Note;
|
||||
/** Client-side creation timestamp, used to order the outbox. */
|
||||
queuedAt: number;
|
||||
}
|
||||
|
||||
export interface OfflineSnapshot {
|
||||
/** Books known to the client, keyed by book id. */
|
||||
books: Record<string, Book>;
|
||||
/** The client's full view of progress, used to render the shelf offline. */
|
||||
progress: Record<string, Progress>;
|
||||
notes: Record<string, Note>;
|
||||
/**
|
||||
* The outbox: what still has to reach the server.
|
||||
*
|
||||
* Kept as a dirty-set rather than "everything is always sent" because the two
|
||||
* differ in a way that matters. A record can be *modified* between building a
|
||||
* request and receiving its response (the reader turns a page while the push is
|
||||
* in flight). With a dirty-set that update stays marked dirty until *its own*
|
||||
* acknowledgement arrives; with "send everything" it would be cleared by the
|
||||
* earlier response and silently never reach the server — a lost reading
|
||||
* position, which is the one failure this product cannot have.
|
||||
*/
|
||||
dirtyProgress: string[];
|
||||
dirtyNotes: string[];
|
||||
/** Note ids deleted locally but not yet acknowledged by the server. */
|
||||
pendingNoteDeletes: string[];
|
||||
/** Last `serverTime` seen from a pull, the cursor for incremental sync. */
|
||||
serverTime: number;
|
||||
/** Milliseconds since epoch of the last successful contact with the server. */
|
||||
lastSyncAt: number | null;
|
||||
}
|
||||
|
||||
export function emptySnapshot(): OfflineSnapshot {
|
||||
return {
|
||||
books: {},
|
||||
progress: {},
|
||||
notes: {},
|
||||
dirtyProgress: [],
|
||||
dirtyNotes: [],
|
||||
pendingNoteDeletes: [],
|
||||
serverTime: 0,
|
||||
lastSyncAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when there is nothing waiting to be sent. */
|
||||
export function isOutboxEmpty(snapshot: OfflineSnapshot): boolean {
|
||||
return (
|
||||
snapshot.dirtyProgress.length === 0 &&
|
||||
snapshot.dirtyNotes.length === 0 &&
|
||||
snapshot.pendingNoteDeletes.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Validates a snapshot read back from storage. */
|
||||
function coerceSnapshot(raw: unknown): OfflineSnapshot {
|
||||
const base = emptySnapshot();
|
||||
if (typeof raw !== 'object' || raw === null) return base;
|
||||
const value = raw as Partial<OfflineSnapshot>;
|
||||
return {
|
||||
books: typeof value.books === 'object' && value.books !== null ? value.books : {},
|
||||
progress: typeof value.progress === 'object' && value.progress !== null ? value.progress : {},
|
||||
notes: typeof value.notes === 'object' && value.notes !== null ? value.notes : {},
|
||||
dirtyProgress: stringArray(value.dirtyProgress),
|
||||
dirtyNotes: stringArray(value.dirtyNotes),
|
||||
pendingNoteDeletes: stringArray(value.pendingNoteDeletes),
|
||||
serverTime: typeof value.serverTime === 'number' && Number.isFinite(value.serverTime) ? value.serverTime : 0,
|
||||
lastSyncAt: typeof value.lastSyncAt === 'number' ? value.lastSyncAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
||||
}
|
||||
|
||||
export class OfflineStore {
|
||||
private snapshot: OfflineSnapshot = emptySnapshot();
|
||||
private loaded = false;
|
||||
private writeChain: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(private readonly kv: KeyValueStore) {}
|
||||
|
||||
async load(): Promise<OfflineSnapshot> {
|
||||
if (this.loaded) return this.snapshot;
|
||||
const raw = await this.kv.get(KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
this.snapshot = coerceSnapshot(JSON.parse(raw));
|
||||
} catch {
|
||||
// A corrupt cache is recoverable by definition — drop it and re-pull.
|
||||
this.snapshot = emptySnapshot();
|
||||
}
|
||||
}
|
||||
this.loaded = true;
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
get current(): OfflineSnapshot {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes are serialised through a promise chain.
|
||||
*
|
||||
* Reading progress updates on every visible page turn; without this, two
|
||||
* rapid turns would interleave two read-modify-write cycles and lose one.
|
||||
* A queue is enough here because only the UI thread mutates the snapshot.
|
||||
*/
|
||||
private persist(): Promise<void> {
|
||||
const payload = JSON.stringify(this.snapshot);
|
||||
this.writeChain = this.writeChain.then(() => this.kv.set(KEY, payload)).catch(() => undefined);
|
||||
return this.writeChain;
|
||||
}
|
||||
|
||||
async replaceBooks(books: Book[]): Promise<void> {
|
||||
for (const book of books) this.snapshot.books[book.id] = book;
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async removeBooks(ids: string[]): Promise<void> {
|
||||
for (const id of ids) {
|
||||
delete this.snapshot.books[id];
|
||||
delete this.snapshot.progress[id];
|
||||
}
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async setProgress(progress: Progress): Promise<void> {
|
||||
const existing = this.snapshot.progress[progress.bookId];
|
||||
// Same last-writer-wins rule the server applies, so the local view does not
|
||||
// briefly disagree with what a pull would return.
|
||||
if (existing && existing.updatedAt > progress.updatedAt) return;
|
||||
this.snapshot.progress[progress.bookId] = progress;
|
||||
this.markDirty('dirtyProgress', progress.bookId);
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
private markDirty(key: 'dirtyProgress' | 'dirtyNotes', id: string): void {
|
||||
if (!this.snapshot[key].includes(id)) this.snapshot[key].push(id);
|
||||
}
|
||||
|
||||
async getProgress(bookId: string): Promise<Progress | undefined> {
|
||||
return this.snapshot.progress[bookId];
|
||||
}
|
||||
|
||||
async upsertNotes(notes: Note[]): Promise<void> {
|
||||
for (const note of notes) {
|
||||
if (note.deleted) {
|
||||
delete this.snapshot.notes[note.id];
|
||||
this.snapshot.dirtyNotes = this.snapshot.dirtyNotes.filter((id) => id !== note.id);
|
||||
if (!this.snapshot.pendingNoteDeletes.includes(note.id)) {
|
||||
this.snapshot.pendingNoteDeletes.push(note.id);
|
||||
}
|
||||
} else {
|
||||
const existing = this.snapshot.notes[note.id];
|
||||
if (existing && existing.updatedAt > note.updatedAt) continue;
|
||||
this.snapshot.notes[note.id] = note;
|
||||
this.snapshot.pendingNoteDeletes = this.snapshot.pendingNoteDeletes.filter((id) => id !== note.id);
|
||||
this.markDirty('dirtyNotes', note.id);
|
||||
}
|
||||
}
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async deleteNote(id: string): Promise<void> {
|
||||
delete this.snapshot.notes[id];
|
||||
this.snapshot.dirtyNotes = this.snapshot.dirtyNotes.filter((entry) => entry !== id);
|
||||
if (!this.snapshot.pendingNoteDeletes.includes(id)) this.snapshot.pendingNoteDeletes.push(id);
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
/** The records that still need to reach the server, in a stable order. */
|
||||
outbox(): { progress: Progress[]; notes: Note[]; deletes: string[] } {
|
||||
return {
|
||||
progress: this.snapshot.dirtyProgress
|
||||
.map((id) => this.snapshot.progress[id])
|
||||
.filter((entry): entry is Progress => entry !== undefined),
|
||||
notes: this.snapshot.dirtyNotes
|
||||
.map((id) => this.snapshot.notes[id])
|
||||
.filter((entry): entry is Note => entry !== undefined),
|
||||
deletes: [...this.snapshot.pendingNoteDeletes],
|
||||
};
|
||||
}
|
||||
|
||||
notesFor(bookId: string): Note[] {
|
||||
return Object.values(this.snapshot.notes).filter((note) => note.bookId === bookId && !note.deleted);
|
||||
}
|
||||
|
||||
books(): Book[] {
|
||||
return Object.values(this.snapshot.books);
|
||||
}
|
||||
|
||||
/** Applies a pull result, honouring last-writer-wins per record. */
|
||||
async applyPulled(pull: SyncPull): Promise<void> {
|
||||
for (const progress of pull.progress) {
|
||||
const existing = this.snapshot.progress[progress.bookId];
|
||||
if (existing && existing.updatedAt > progress.updatedAt) continue;
|
||||
this.snapshot.progress[progress.bookId] = progress;
|
||||
}
|
||||
for (const note of pull.notes) {
|
||||
if (note.deleted) {
|
||||
delete this.snapshot.notes[note.id];
|
||||
// Do not clear a local pending delete: the server already has the
|
||||
// tombstone, so the outbox entry is satisfied either way.
|
||||
continue;
|
||||
}
|
||||
const existing = this.snapshot.notes[note.id];
|
||||
if (existing && existing.updatedAt > note.updatedAt) continue;
|
||||
this.snapshot.notes[note.id] = note;
|
||||
}
|
||||
this.snapshot.serverTime = Math.max(this.snapshot.serverTime, pull.serverTime);
|
||||
this.snapshot.lastSyncAt = Date.now();
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a batch as delivered.
|
||||
*
|
||||
* Only the ids that were actually in the batch are cleared, and only if the
|
||||
* record has not been modified in the meantime. Both conditions matter:
|
||||
*
|
||||
* - Clearing "everything" would drop a position written while the request was
|
||||
* in flight.
|
||||
* - Clearing without the timestamp check would drop it too, via the same
|
||||
* window but arriving through a different path.
|
||||
*
|
||||
* The `serverTime` is still adopted so the next pull is incremental.
|
||||
*/
|
||||
async markDelivered(batch: { progress: Progress[]; notes: Note[]; deletes: string[] }, serverTime: number): Promise<void> {
|
||||
const deliveredProgress = new Set(
|
||||
batch.progress
|
||||
.filter((entry) => this.snapshot.progress[entry.bookId]?.updatedAt === entry.updatedAt)
|
||||
.map((entry) => entry.bookId),
|
||||
);
|
||||
const deliveredNotes = new Set(
|
||||
batch.notes.filter((note) => this.snapshot.notes[note.id]?.updatedAt === note.updatedAt).map((note) => note.id),
|
||||
);
|
||||
const deliveredDeletes = new Set(batch.deletes);
|
||||
|
||||
this.snapshot.dirtyProgress = this.snapshot.dirtyProgress.filter((id) => !deliveredProgress.has(id));
|
||||
this.snapshot.dirtyNotes = this.snapshot.dirtyNotes.filter((id) => !deliveredNotes.has(id));
|
||||
this.snapshot.pendingNoteDeletes = this.snapshot.pendingNoteDeletes.filter((id) => !deliveredDeletes.has(id));
|
||||
this.snapshot.serverTime = Math.max(this.snapshot.serverTime, serverTime);
|
||||
this.snapshot.lastSyncAt = Date.now();
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.snapshot = emptySnapshot();
|
||||
this.loaded = true;
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.writeChain;
|
||||
}
|
||||
}
|
||||
|
||||
export type { Progress, Note, Book };
|
||||
export type { KeyValueStore };
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { KeyValueStore } from '../core/platform.ts';
|
||||
import type { ViewSettings } from '../ui/reader-view.ts';
|
||||
|
||||
/**
|
||||
* Reader preferences, persisted per device.
|
||||
*
|
||||
* Per *device*, not per account: text size and theme are properties of the
|
||||
* screen and the lighting you are reading in, not of who you are. Syncing them
|
||||
* would push a phone's text size onto a tablet, which is worse than not syncing.
|
||||
*/
|
||||
|
||||
const KEY = 'reader.settings.v1';
|
||||
const SERVER_KEY = 'reader.server.v1';
|
||||
|
||||
export interface AppSettings extends ViewSettings {
|
||||
/** UTF-8 by default; a reader can force another for an odd TXT file. */
|
||||
txtEncoding: string;
|
||||
/** Only meaningful for fixed-layout books. */
|
||||
comicDirection: 'ltr' | 'rtl';
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
mode: 'scroll',
|
||||
fontScale: 1,
|
||||
lineHeight: 'inherit',
|
||||
theme: 'light',
|
||||
fit: 'contain',
|
||||
direction: 'ltr',
|
||||
txtEncoding: '',
|
||||
comicDirection: 'ltr',
|
||||
};
|
||||
|
||||
export class SettingsStore {
|
||||
private settings: AppSettings = { ...DEFAULT_APP_SETTINGS };
|
||||
private loaded = false;
|
||||
|
||||
constructor(private readonly kv: KeyValueStore) {}
|
||||
|
||||
async load(): Promise<AppSettings> {
|
||||
if (this.loaded) return this.settings;
|
||||
const raw = await this.kv.get(KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<AppSettings>;
|
||||
this.settings = { ...DEFAULT_APP_SETTINGS, ...parsed };
|
||||
} catch {
|
||||
this.settings = { ...DEFAULT_APP_SETTINGS };
|
||||
}
|
||||
}
|
||||
this.loaded = true;
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
get current(): AppSettings {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
async update(patch: Partial<AppSettings>): Promise<AppSettings> {
|
||||
this.settings = { ...this.settings, ...patch };
|
||||
await this.kv.set(KEY, JSON.stringify(this.settings));
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
async serverUrl(): Promise<string> {
|
||||
// An Android build ships with a baked-in default and an empty stored value
|
||||
// must not shadow it, so the fallback lives in the caller.
|
||||
return (await this.kv.get(SERVER_KEY)) ?? '';
|
||||
}
|
||||
|
||||
async setServerUrl(url: string): Promise<void> {
|
||||
await this.kv.set(SERVER_KEY, url.replace(/\/+$/, ''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
/*
|
||||
* Reader stylesheet.
|
||||
*
|
||||
* This file is the single most product-defining piece of CSS in the project, so
|
||||
* its rules are stated explicitly rather than arrived at by accident.
|
||||
*
|
||||
* THE STRATEGY: preserve the author's design, override almost nothing.
|
||||
*
|
||||
* The reason this product exists (product design §9) is that every other
|
||||
* self-hosted library renders books at "web reader" quality, and the reason the
|
||||
* obvious solution is wrong (§5, decision 2) is that Readium CSS takes over the
|
||||
* book's typography to make every book look the same. That is a reasonable
|
||||
* trade for a generic reader and the exact opposite of this product's promise to
|
||||
* faithfully reproduce a carefully typeset book.
|
||||
*
|
||||
* So the rules here follow three principles:
|
||||
*
|
||||
* 1. No property is set unless something actually breaks without it.
|
||||
* 2. Every adjustable property is an inheriting custom property whose default
|
||||
* is the author's value, so "off" means "untouched", not "our default".
|
||||
* 3. Anything that changes the author's intent is a user opt-in that persists,
|
||||
* never a default.
|
||||
*
|
||||
* The comments call out each rule that *does* override, and why it has to.
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Host shell: the viewer, not the book. Safe to style freely.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
:root {
|
||||
--reader-ink: #1a1a1a;
|
||||
--reader-paper: #fbfaf7;
|
||||
--reader-surface: #ffffff;
|
||||
--reader-muted: #6b6b6b;
|
||||
--reader-line: #e3e0da;
|
||||
--reader-accent: #7a5c3e;
|
||||
--reader-radius: 10px;
|
||||
|
||||
/*
|
||||
* The three user controls, in their default = do-not-touch position.
|
||||
*
|
||||
* `inherit` rather than a concrete value is deliberate for line-height and
|
||||
* font-size: the author set them on `body` or on a specific paragraph, and an
|
||||
* explicit value here would flatten a book that varies them by section. When
|
||||
* the reader picks a size in the UI, JS writes a concrete value.
|
||||
*/
|
||||
--reader-font-scale: 1;
|
||||
--reader-line-height: inherit;
|
||||
--reader-measure: 42rem;
|
||||
|
||||
--reader-page-padding-inline: 1.5rem;
|
||||
--reader-page-padding-block: 1.25rem;
|
||||
}
|
||||
|
||||
[data-theme='sepia'] {
|
||||
--reader-ink: #3b3327;
|
||||
--reader-paper: #f5ecd9;
|
||||
--reader-surface: #f9f3e4;
|
||||
--reader-line: #e0d3ba;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--reader-ink: #d8d4cc;
|
||||
--reader-paper: #16171a;
|
||||
--reader-surface: #1e2024;
|
||||
--reader-muted: #9a958c;
|
||||
--reader-line: #33363b;
|
||||
--reader-accent: #b39272;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--reader-paper);
|
||||
color: var(--reader-ink);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', 'Noto Sans SC', sans-serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
/*
|
||||
* The reader is an app, not a document: a rubber-band overscroll on Android
|
||||
* shows a white flash behind the paper colour, and a pull-to-refresh gesture
|
||||
* at the top of a chapter reloads the whole SPA.
|
||||
*/
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* The book stage
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
background: var(--reader-paper);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
* The chapter document is rendered into a shadow root so a book's own
|
||||
* stylesheet cannot reach the app chrome, and the app chrome cannot reach into
|
||||
* the book. The two only meet through the custom properties above.
|
||||
*/
|
||||
.book-host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-host[data-layout='reflowable'] {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/*
|
||||
* Fixed-layout pages are advanced by the pager, not scrolled, so the host must
|
||||
* not be a scroll container: a nested scroll area on top of a horizontal pager
|
||||
* produces the classic "the page drifts sideways" bug on Android.
|
||||
*/
|
||||
.book-host[data-layout='fixed'] {
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* Reflowable content column.
|
||||
*
|
||||
* `max-inline-size` on the *container* rather than on the book's own elements:
|
||||
* it constrains the line length without editing a single author rule, which is
|
||||
* the only way to bound measure without also overriding the author's margins on
|
||||
* `p` and `div`.
|
||||
*/
|
||||
.book-flow {
|
||||
max-inline-size: var(--reader-measure);
|
||||
margin-inline: auto;
|
||||
padding-block: var(--reader-page-padding-block);
|
||||
padding-inline: var(--reader-page-padding-inline);
|
||||
/* The size control scales the root of the injected document only. */
|
||||
font-size: calc(1em * var(--reader-font-scale));
|
||||
line-height: var(--reader-line-height);
|
||||
}
|
||||
|
||||
.book-host[data-paginated='true'] .book-flow {
|
||||
/*
|
||||
* Paged mode: the column is exactly one viewport tall and overflows
|
||||
* horizontally by column. This is the one place a structural override is
|
||||
* unavoidable — CSS multi-column is the only way to paginate reflowable text
|
||||
* without re-measuring every line in JS, and it needs the ancestor chain to
|
||||
* honour a fixed height.
|
||||
*/
|
||||
height: 100%;
|
||||
max-inline-size: none;
|
||||
padding: 0;
|
||||
column-gap: 0;
|
||||
columns: 1;
|
||||
column-width: 100vw;
|
||||
column-fill: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-host[data-paginated='true'] .book-flow > * {
|
||||
/* A block that spans a column break would be split awkwardly; keeping
|
||||
headings with their following paragraph is the cheap 90% fix. */
|
||||
break-inside: auto;
|
||||
}
|
||||
|
||||
book-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Fixed-layout pages (comic, PDF fallback)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.fixed-page {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/*
|
||||
* `contain` keeps a 4000px-tall scan from forcing the whole layout to
|
||||
* reflow on every page turn.
|
||||
*/
|
||||
contain: layout paint size;
|
||||
background: #0d0d0f;
|
||||
}
|
||||
|
||||
.fixed-page img {
|
||||
/*
|
||||
* `max-*` rather than fixed dimensions: a comic page must letterbox inside
|
||||
* the viewport, and `object-fit: contain` is the only way to do that without
|
||||
* knowing the image's intrinsic size before it loads.
|
||||
*/
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
/* Prevents the 1px dark seam some Android GPUs render along a scaled edge. */
|
||||
image-rendering: auto;
|
||||
outline: none;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fixed-page[data-fit='width'] img {
|
||||
max-height: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* PDF
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.pdf-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #0d0d0f;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Injected book content: the minimum set of overrides
|
||||
* ------------------------------------------------------------------ *
|
||||
*
|
||||
* Everything below is scoped to `book-content`, so it reaches the book's own
|
||||
* markup and nothing else. Each rule states why it is necessary. A rule without
|
||||
* a justification does not belong in this section.
|
||||
*/
|
||||
|
||||
book-content img,
|
||||
book-content svg,
|
||||
book-content video {
|
||||
/*
|
||||
* A book laid out for a print page has images sized in absolute units. On a
|
||||
* 360px screen those overflow and force a horizontal scrollbar that makes the
|
||||
* whole chapter feel broken. Bounding them is an override, but the author's
|
||||
* value is honoured up to the viewport, so nothing is enlarged.
|
||||
*/
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
book-content table {
|
||||
/*
|
||||
* Tables are the one construct that genuinely cannot reflow into a narrow
|
||||
* column. Allowing a horizontal scroll inside the table is strictly better
|
||||
* than letting it push the chapter wider than the screen.
|
||||
*/
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
book-content pre {
|
||||
/*
|
||||
* `pre` does not wrap by default, so a long code line or an ASCII-art page
|
||||
* makes the entire chapter scroll horizontally. Wrapping keeps the chapter
|
||||
* usable; the author's font and spacing are untouched.
|
||||
*/
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
book-content a[href] {
|
||||
/*
|
||||
* Footnotes and cross-references must be tappable, and the platform default
|
||||
* is a colour the author already chose. Only the touch target is enforced.
|
||||
*/
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
book-content :is(h1, h2, h3, h4, h5, h6) {
|
||||
/*
|
||||
* Long Chinese headings have no spaces and will overflow rather than wrap.
|
||||
* `overflow-wrap` only affects the overflowing case, so a heading that fits is
|
||||
* rendered exactly as the author specified.
|
||||
*/
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
book-content ruby rt {
|
||||
/*
|
||||
* 振假名 (ruby annotations) are named in the product design as a
|
||||
* differentiator (§9). Sizing them correctly relative to the base text is what
|
||||
* makes them readable on a phone; the default 50% is too small at 16px.
|
||||
*/
|
||||
font-size: 0.55em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
book-content :is(ruby, rt) {
|
||||
/* Ruby is a first-class text feature here, so it must not be hidden or
|
||||
clipped by an ancestor's overflow or line-height. */
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
book-content [epub|type~='pagebreak'],
|
||||
book-content hr {
|
||||
/*
|
||||
* Some publishers put a decorative rule at every page break, which turns into
|
||||
* a stray line in the middle of a paragraph once pagination is redone.
|
||||
* Collapsing it to a spacing hint keeps the author's intent without the
|
||||
* artefact. `hr` is intentionally NOT hidden: a rule the author drew to
|
||||
* separate scenes is content.
|
||||
*/
|
||||
border: 0;
|
||||
}
|
||||
|
||||
book-content figure,
|
||||
book-content blockquote {
|
||||
/*
|
||||
* Print-oriented books indent these with fixed margins that waste a third of
|
||||
* a phone screen. The author's indentation is preserved, only the outer
|
||||
* margin is capped to the container.
|
||||
*/
|
||||
max-width: 100%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Selection and search highlight
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
book-content ::selection {
|
||||
background: color-mix(in srgb, var(--reader-accent) 35%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.book-highlight {
|
||||
/*
|
||||
* Highlights are drawn as an overlay behind the text so they never alter the
|
||||
* author's line boxes, which would otherwise shift a whole paragraph by a
|
||||
* subpixel and change pagination when a highlight is added.
|
||||
*/
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--reader-accent) 40%, transparent) 15%,
|
||||
color-mix(in srgb, var(--reader-accent) 40%, transparent) 85%,
|
||||
transparent 100%
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Page-turn transition
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.book-host[data-animating='slide-next'] .book-flow {
|
||||
animation: slide-next 180ms ease-out;
|
||||
}
|
||||
|
||||
.book-host[data-animating='slide-prev'] .book-flow {
|
||||
animation: slide-prev 180ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-next {
|
||||
from {
|
||||
transform: translateX(6%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-prev {
|
||||
from {
|
||||
transform: translateX(-6%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.book-host[data-animating] .book-flow {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Chrome: header, shelf, panels
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 3rem;
|
||||
padding: 0 0.75rem;
|
||||
background: var(--reader-surface);
|
||||
border-bottom: 1px solid var(--reader-line);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.topbar .subtitle {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
color: var(--reader-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--reader-ink);
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: var(--reader-radius);
|
||||
font-size: 1.1rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.icon-button:active {
|
||||
background: color-mix(in srgb, var(--reader-ink) 10%, transparent);
|
||||
}
|
||||
|
||||
.button {
|
||||
appearance: none;
|
||||
border: 1px solid var(--reader-line);
|
||||
background: var(--reader-surface);
|
||||
color: var(--reader-ink);
|
||||
border-radius: var(--reader-radius);
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: var(--reader-accent);
|
||||
border-color: var(--reader-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--reader-muted);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
appearance: none;
|
||||
border: 1px solid var(--reader-line);
|
||||
background: var(--reader-surface);
|
||||
color: var(--reader-ink);
|
||||
border-radius: var(--reader-radius);
|
||||
padding: 0.6rem 0.7rem;
|
||||
/* 16px minimum: anything smaller makes iOS Safari zoom the whole page on
|
||||
focus, which then leaves the layout scrolled and half off-screen. */
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---- shelf ---- */
|
||||
|
||||
.shelf {
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0.75rem;
|
||||
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.shelf-search {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.shelf-section-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--reader-muted);
|
||||
margin: 1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.continue-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.continue-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.continue-card {
|
||||
flex: 0 0 8rem;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.continue-card img {
|
||||
width: 8rem;
|
||||
height: 11rem;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: var(--reader-line);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.continue-card .progress-track {
|
||||
height: 3px;
|
||||
margin-top: 0.35rem;
|
||||
border-radius: 2px;
|
||||
background: var(--reader-line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.continue-card .progress-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--reader-accent);
|
||||
}
|
||||
|
||||
.book-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.book-card {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.book-card .cover {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--reader-line);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.book-card .cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.book-card .cover .placeholder {
|
||||
font-size: 1.6rem;
|
||||
color: var(--reader-muted);
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-card .format-badge {
|
||||
position: absolute;
|
||||
inset-block-end: 0.25rem;
|
||||
inset-inline-end: 0.25rem;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.book-card .title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-card .author {
|
||||
font-size: 0.72rem;
|
||||
color: var(--reader-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- panels ---- */
|
||||
|
||||
.panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--reader-surface);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-top: calc(0.5rem + env(safe-area-inset-top));
|
||||
border-bottom: 1px solid var(--reader-line);
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0.75rem;
|
||||
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toc-list li button {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--reader-ink);
|
||||
padding: 0.6rem 0.4rem;
|
||||
border-bottom: 1px solid var(--reader-line);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toc-list li button[aria-current='true'] {
|
||||
color: var(--reader-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- footer / progress ---- */
|
||||
|
||||
.footer {
|
||||
background: var(--reader-surface);
|
||||
border-top: 1px solid var(--reader-line);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 2px;
|
||||
background: var(--reader-line);
|
||||
}
|
||||
|
||||
.progress-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--reader-accent);
|
||||
transition: width 160ms ease-out;
|
||||
}
|
||||
|
||||
.footer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--reader-muted);
|
||||
}
|
||||
|
||||
.footer-row .chapter {
|
||||
flex: 1 1 auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- status ---- */
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
background: color-mix(in srgb, var(--reader-accent) 12%, var(--reader-surface));
|
||||
color: var(--reader-ink);
|
||||
}
|
||||
|
||||
.status-bar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
background: var(--reader-muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.status-bar[data-state='syncing'] .status-dot {
|
||||
background: #4a8f5f;
|
||||
}
|
||||
|
||||
.status-bar[data-state='offline'] .status-dot {
|
||||
background: #c08a2e;
|
||||
}
|
||||
|
||||
.status-bar[data-state='error'] .status-dot {
|
||||
background: #b6453a;
|
||||
}
|
||||
|
||||
/* ---- misc ---- */
|
||||
|
||||
.empty-state,
|
||||
.centered-form {
|
||||
margin: auto;
|
||||
max-width: 24rem;
|
||||
width: 100%;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.centered-form {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.notice {
|
||||
background: color-mix(in srgb, var(--reader-accent) 12%, var(--reader-surface));
|
||||
border-radius: var(--reader-radius);
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #b6453a;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--reader-muted);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 2px solid var(--reader-line);
|
||||
border-top-color: var(--reader-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 700ms linear infinite;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: flex;
|
||||
border: 1px solid var(--reader-line);
|
||||
border-radius: var(--reader-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
flex: 1 1 0;
|
||||
border: 0;
|
||||
background: var(--reader-surface);
|
||||
color: var(--reader-ink);
|
||||
padding: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.segmented button[aria-pressed='true'] {
|
||||
background: var(--reader-accent);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* A very small element helper.
|
||||
*
|
||||
* No framework, deliberately. The rendering layer is imperative DOM work
|
||||
* (pagination, injected documents, shadow roots, column measurement) where a
|
||||
* virtual DOM adds a layer of indirection between the code and the layout it is
|
||||
* measuring — exactly the layer this product cannot afford to debug through.
|
||||
*/
|
||||
export type Child = Node | string | null | undefined | false;
|
||||
|
||||
export interface ElementOptions {
|
||||
className?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
attrs?: Record<string, string | number | boolean | undefined>;
|
||||
on?: Partial<Record<keyof HTMLElementEventMap, (event: Event) => void>>;
|
||||
children?: Child[];
|
||||
dataset?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
options: ElementOptions = {},
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag);
|
||||
if (options.className) node.className = options.className;
|
||||
if (options.text !== undefined) node.textContent = options.text;
|
||||
if (options.html !== undefined) node.innerHTML = options.html;
|
||||
for (const [key, value] of Object.entries(options.attrs ?? {})) {
|
||||
if (value === undefined || value === false) continue;
|
||||
node.setAttribute(key, value === true ? '' : String(value));
|
||||
}
|
||||
for (const [key, value] of Object.entries(options.dataset ?? {})) {
|
||||
node.dataset[key] = value;
|
||||
}
|
||||
for (const [event, handler] of Object.entries(options.on ?? {})) {
|
||||
if (handler) node.addEventListener(event, handler);
|
||||
}
|
||||
for (const child of options.children ?? []) {
|
||||
if (child === null || child === undefined || child === false) continue;
|
||||
node.append(typeof child === 'string' ? document.createTextNode(child) : child);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
export function clear(node: Element): void {
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
}
|
||||
|
||||
export function formatDate(ms: number): string {
|
||||
if (!ms) return '';
|
||||
const date = new Date(ms);
|
||||
const now = Date.now();
|
||||
const diff = now - ms;
|
||||
if (diff < 60_000) return '刚刚';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`;
|
||||
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`;
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function percent(value: number): string {
|
||||
return `${Math.round(Math.min(1, Math.max(0, value)) * 100)}%`;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Touch handling, isolated from the view so it can be reasoned about alone.
|
||||
*
|
||||
* The rules that matter on a phone, and why:
|
||||
*
|
||||
* - A tap in the middle third toggles the chrome; taps in the outer thirds turn
|
||||
* the page. This is the convention every mobile reader uses, and violating it
|
||||
* is immediately noticeable.
|
||||
* - A swipe must be mostly horizontal to count. A diagonal drag is almost
|
||||
* always an attempt to scroll, and treating it as a page turn makes a
|
||||
* reflowable book feel like it is fighting back.
|
||||
* - Distinguishing a tap from a drag cannot use `click`: on a scrollable page
|
||||
* the browser fires `click` after a drag that ended on a link. The gesture
|
||||
* layer tracks movement itself and suppresses the click.
|
||||
* - Pinch-to-zoom is deliberately not implemented. Text size belongs to the
|
||||
* reader's own control, and a visual zoom fights CSS pagination.
|
||||
*/
|
||||
|
||||
export interface GestureHandlers {
|
||||
onTapZone(zone: 'previous' | 'toggle-chrome' | 'next'): void;
|
||||
onSwipe(direction: 'left' | 'right' | 'up' | 'down'): void;
|
||||
/** Fires continuously while a finger is down and moving, for scroll mode. */
|
||||
onDragDelta(deltaX: number, deltaY: number): void;
|
||||
onLongPress(x: number, y: number): void;
|
||||
/** True when a text selection is active: gestures must then stand aside. */
|
||||
hasSelection(): boolean;
|
||||
}
|
||||
|
||||
const TAP_MAX_DISTANCE = 12;
|
||||
const TAP_MAX_DURATION = 260;
|
||||
const SWIPE_MIN_DISTANCE = 48;
|
||||
const SWIPE_MAX_OFF_AXIS = 0.8;
|
||||
const LONG_PRESS_MS = 500;
|
||||
|
||||
export function attachGestures(element: HTMLElement, handlers: GestureHandlers): () => void {
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startTime = 0;
|
||||
let tracking = false;
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let suppressClick = false;
|
||||
|
||||
const clearLongPress = (): void => {
|
||||
if (longPressTimer === null) return;
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
if (handlers.hasSelection()) return;
|
||||
tracking = true;
|
||||
suppressClick = false;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
startTime = Date.now();
|
||||
clearLongPress();
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = null;
|
||||
// A long press is the reader asking to select text, so gestures must not
|
||||
// turn it into a page turn when the finger lifts.
|
||||
suppressClick = true;
|
||||
handlers.onLongPress(event.clientX, event.clientY);
|
||||
}, LONG_PRESS_MS);
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent): void => {
|
||||
if (!tracking) return;
|
||||
const deltaX = event.clientX - startX;
|
||||
const deltaY = event.clientY - startY;
|
||||
if (Math.hypot(deltaX, deltaY) > TAP_MAX_DISTANCE) clearLongPress();
|
||||
handlers.onDragDelta(deltaX, deltaY);
|
||||
};
|
||||
|
||||
const onPointerUp = (event: PointerEvent): void => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
clearLongPress();
|
||||
const deltaX = event.clientX - startX;
|
||||
const deltaY = event.clientY - startY;
|
||||
const duration = Date.now() - startTime;
|
||||
const distance = Math.hypot(deltaX, deltaY);
|
||||
|
||||
if (suppressClick || handlers.hasSelection()) return;
|
||||
|
||||
const horizontal = Math.abs(deltaX) > Math.abs(deltaY) * SWIPE_MAX_OFF_AXIS;
|
||||
if (distance >= SWIPE_MIN_DISTANCE && duration < 800) {
|
||||
suppressClick = true;
|
||||
if (horizontal) handlers.onSwipe(deltaX < 0 ? 'left' : 'right');
|
||||
else handlers.onSwipe(deltaY < 0 ? 'up' : 'down');
|
||||
return;
|
||||
}
|
||||
|
||||
if (distance <= TAP_MAX_DISTANCE && duration <= TAP_MAX_DURATION) {
|
||||
suppressClick = true;
|
||||
const third = element.clientWidth / 3;
|
||||
const x = event.clientX - element.getBoundingClientRect().left;
|
||||
if (x < third) handlers.onTapZone('previous');
|
||||
else if (x > third * 2) handlers.onTapZone('next');
|
||||
else handlers.onTapZone('toggle-chrome');
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerCancel = (): void => {
|
||||
tracking = false;
|
||||
clearLongPress();
|
||||
};
|
||||
|
||||
const onClickCapture = (event: MouseEvent): void => {
|
||||
if (suppressClick) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
suppressClick = false;
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('pointerdown', onPointerDown);
|
||||
element.addEventListener('pointermove', onPointerMove);
|
||||
element.addEventListener('pointerup', onPointerUp);
|
||||
element.addEventListener('pointercancel', onPointerCancel);
|
||||
element.addEventListener('click', onClickCapture, true);
|
||||
|
||||
return () => {
|
||||
clearLongPress();
|
||||
element.removeEventListener('pointerdown', onPointerDown);
|
||||
element.removeEventListener('pointermove', onPointerMove);
|
||||
element.removeEventListener('pointerup', onPointerUp);
|
||||
element.removeEventListener('pointercancel', onPointerCancel);
|
||||
element.removeEventListener('click', onClickCapture, true);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { BookDoc } from '../formats/types.ts';
|
||||
|
||||
/**
|
||||
* Locators, and why they look like this.
|
||||
*
|
||||
* A reading position has to survive three things: a server round trip (as an
|
||||
* opaque string), a re-layout at a different font size, and a reload. The
|
||||
* server treats it as opaque on purpose (docs/api.md), so the format is the
|
||||
* client's to define — and it is the client that has to live with a bad choice.
|
||||
*
|
||||
* The format is `r1:<offset>:<section id>`, e.g.
|
||||
*
|
||||
* `r1:0.4200:OEBPS/text/ch3.xhtml`
|
||||
*
|
||||
* Three things about that are deliberate and were each arrived at by getting it
|
||||
* wrong first:
|
||||
*
|
||||
* - **The version prefix.** The server treats a locator as opaque and holds it
|
||||
* for a long time, so a client that later changes the format needs to be able
|
||||
* to recognise a value it is not allowed to interpret. `r1` gives it that.
|
||||
* - **The section id goes last.** A section id is a file path and therefore
|
||||
* contains colons on Windows-authored books and in any href with a scheme.
|
||||
* Splitting on the first two colons and keeping the rest intact is the only
|
||||
* parsing that cannot lose data.
|
||||
* - **The offset is a fraction of the section**, never a pixel value or a CSS
|
||||
* column index. Pixels and columns change with font size, screen width and
|
||||
* theme, so a column-index locator silently lands on a different sentence
|
||||
* when the reader changes the text size — one of the most infuriating bugs a
|
||||
* reading app can have.
|
||||
*/
|
||||
|
||||
export interface Locator {
|
||||
sectionId: string;
|
||||
/** 0..1 position within the section. */
|
||||
offset: number;
|
||||
/** 0..1 position within the whole book, for the progress bar. */
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export const LOCATOR_PREFIX = 'r1';
|
||||
|
||||
export function parseLocator(value: string, doc: BookDoc): Locator | null {
|
||||
if (!value) return null;
|
||||
|
||||
if (value.startsWith(`${LOCATOR_PREFIX}:`)) {
|
||||
const rest = value.slice(LOCATOR_PREFIX.length + 1);
|
||||
const separator = rest.indexOf(':');
|
||||
// `r1:0.5:` with an empty section id is malformed, and so is a versioned
|
||||
// locator with no offset at all.
|
||||
if (separator === -1) return null;
|
||||
const offset = Number.parseFloat(rest.slice(0, separator));
|
||||
const sectionId = rest.slice(separator + 1);
|
||||
if (!sectionId) return null;
|
||||
return resolve(sectionId, offset, doc);
|
||||
}
|
||||
|
||||
// Unversioned forms, accepted so that a position written before the version
|
||||
// prefix existed is still honoured. A reader losing their place on an upgrade
|
||||
// is exactly the kind of thing this product is supposed to be better at.
|
||||
if (value.startsWith('epubcfi(')) return null;
|
||||
|
||||
const legacy = /^([^:]+):(-?[\d.]+)$/.exec(value);
|
||||
if (legacy) return resolve(legacy[1]!, Number.parseFloat(legacy[2]!), doc);
|
||||
|
||||
// A bare section id, or a bare page number, means "the start of that".
|
||||
const byId = doc.sections.findIndex((section) => section.id === value);
|
||||
if (byId !== -1) return resolve(value, 0, doc);
|
||||
const page = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(page) && page >= 0 && page < doc.sections.length) {
|
||||
const section = doc.sections[page];
|
||||
return section ? resolve(section.id, 0, doc) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Shared tail of every parse path: validate the section, clamp the offset. */
|
||||
function resolve(sectionId: string, offset: number, doc: BookDoc): Locator | null {
|
||||
const index = doc.sections.findIndex((section) => section.id === sectionId);
|
||||
if (index === -1) return null;
|
||||
const safeOffset = Number.isFinite(offset) ? Math.min(1, Math.max(0, offset)) : 0;
|
||||
return {
|
||||
sectionId,
|
||||
offset: safeOffset,
|
||||
percentage: (index + safeOffset) / Math.max(1, doc.sections.length),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialises a position.
|
||||
*
|
||||
* The offset is fixed to four decimals and always written as a plain decimal:
|
||||
* `0.42` is meaningful, `4.2e-1` would not survive anyone's regex, and an
|
||||
* exponent in a value the server stores opaquely is a bug waiting to be found
|
||||
* by whoever writes the next client.
|
||||
*/
|
||||
export function formatLocator(sectionId: string, offset: number): string {
|
||||
const clamped = Math.min(1, Math.max(0, Number.isFinite(offset) ? offset : 0));
|
||||
return `${LOCATOR_PREFIX}:${clamped.toFixed(4)}:${sectionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total percentage of the book, weighted by section length when the format
|
||||
* provides one.
|
||||
*
|
||||
* Weighing matters: TXT chunks are wildly uneven, and a book whose chapters
|
||||
* average 30 pages except for one 400-page final chapter would show "50%" at
|
||||
* the halfway chapter while the reader still has most of the book left.
|
||||
*/
|
||||
export function bookPercentage(doc: BookDoc, sectionIndex: number, within: number): number {
|
||||
const count = doc.sections.length;
|
||||
if (count === 0) return 0;
|
||||
const weights = sectionWeights(doc);
|
||||
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
if (total <= 0) return Math.min(1, (sectionIndex + within) / count);
|
||||
let consumed = 0;
|
||||
for (let index = 0; index < sectionIndex; index += 1) consumed += weights[index] ?? 1;
|
||||
consumed += (weights[sectionIndex] ?? 1) * Math.min(1, Math.max(0, within));
|
||||
return Math.min(1, Math.max(0, consumed / total));
|
||||
}
|
||||
|
||||
function sectionWeights(doc: BookDoc): number[] {
|
||||
if (doc.layout === 'fixed') return doc.sections.map(() => 1);
|
||||
return doc.sections.map((section) => {
|
||||
const length = section.html?.length ?? 0;
|
||||
// A floor keeps a nearly empty divider page from counting as zero length,
|
||||
// which would make the progress bar stall on it.
|
||||
return Math.max(200, length);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { ApiError } from '../api/errors.ts';
|
||||
import type { ReaderApi } from '../api/client.ts';
|
||||
import type { InstanceInfo } from '../api/types.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
|
||||
export interface LoginScreenOptions {
|
||||
api: ReaderApi;
|
||||
defaultServerUrl: string;
|
||||
onAuthenticated(): void;
|
||||
onServerUrlChange(url: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign-in, and the first-run experience.
|
||||
*
|
||||
* The first-run path is the one that decides whether a self-hosted product gets
|
||||
* adopted, so it is explicit rather than clever: probe the URL, show whether the
|
||||
* instance is fresh or already claimed, and only offer registration when the
|
||||
* server will actually accept it. Finding that out by submitting a form and
|
||||
* getting `REGISTRATION_DISABLED` back is the kind of first impression that
|
||||
* loses a user.
|
||||
*/
|
||||
export class LoginScreen {
|
||||
readonly element: HTMLDivElement;
|
||||
private readonly serverInput: HTMLInputElement;
|
||||
private readonly usernameInput: HTMLInputElement;
|
||||
private readonly passwordInput: HTMLInputElement;
|
||||
private readonly displayNameInput: HTMLInputElement;
|
||||
private readonly submitButton: HTMLButtonElement;
|
||||
private readonly errorText: HTMLDivElement;
|
||||
private readonly notice: HTMLDivElement;
|
||||
private instance: InstanceInfo | null = null;
|
||||
private registering = false;
|
||||
|
||||
constructor(private readonly options: LoginScreenOptions) {
|
||||
this.serverInput = el('input', {
|
||||
attrs: { type: 'url', inputmode: 'url', autocomplete: 'url', placeholder: 'http://192.168.1.10:8080', value: options.defaultServerUrl },
|
||||
});
|
||||
this.usernameInput = el('input', { attrs: { type: 'text', autocomplete: 'username', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false' } });
|
||||
this.passwordInput = el('input', { attrs: { type: 'password', autocomplete: 'current-password' } });
|
||||
this.displayNameInput = el('input', { attrs: { type: 'text', autocomplete: 'nickname' } });
|
||||
this.errorText = el('div', { className: 'error-text', attrs: { hidden: true } }) as HTMLDivElement;
|
||||
this.notice = el('div', { className: 'notice', attrs: { hidden: true } }) as HTMLDivElement;
|
||||
this.submitButton = el('button', {
|
||||
className: 'button primary',
|
||||
text: '登录',
|
||||
attrs: { type: 'submit' },
|
||||
}) as HTMLButtonElement;
|
||||
|
||||
const form = el('form', {
|
||||
className: 'centered-form',
|
||||
on: { submit: (event) => { event.preventDefault(); void this.submit(); } },
|
||||
children: [
|
||||
el('h1', { text: 'reader', attrs: { style: 'margin:0 0 .25rem;font-size:1.3rem;' } }),
|
||||
el('p', { className: 'muted', text: '自部署书库阅读器', attrs: { style: 'margin:0 0 1.25rem;font-size:.85rem;' } }),
|
||||
this.notice,
|
||||
el('div', { className: 'field', children: [el('label', { text: '服务端地址' }), this.serverInput] }),
|
||||
el('div', { className: 'field', children: [el('label', { text: '用户名' }), this.usernameInput] }),
|
||||
el('div', { className: 'field', children: [el('label', { text: '密码' }), this.passwordInput] }),
|
||||
el('div', {
|
||||
className: 'field',
|
||||
attrs: { hidden: true },
|
||||
dataset: { role: 'display-name' },
|
||||
children: [el('label', { text: '显示名(可选)' }), this.displayNameInput],
|
||||
}),
|
||||
this.submitButton,
|
||||
el('div', { className: 'field', attrs: { style: 'margin-top:.75rem;' }, children: [
|
||||
el('button', {
|
||||
className: 'button',
|
||||
text: '注册新账号',
|
||||
attrs: { type: 'button', 'data-role': 'toggle-register' },
|
||||
on: { click: () => this.toggleRegister() },
|
||||
}),
|
||||
]}),
|
||||
this.errorText,
|
||||
],
|
||||
});
|
||||
|
||||
this.element = el('div', {
|
||||
className: 'login-screen',
|
||||
children: [form],
|
||||
}) as HTMLDivElement;
|
||||
this.element.style.cssText = 'flex:1 1 auto;min-height:0;display:flex;overflow-y:auto;';
|
||||
|
||||
// Probe as soon as the URL stops changing, so the button label is correct
|
||||
// before the reader types anything into the credential fields.
|
||||
let probeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
this.serverInput.addEventListener('input', () => {
|
||||
if (probeTimer) clearTimeout(probeTimer);
|
||||
probeTimer = setTimeout(() => void this.probe(), 400);
|
||||
});
|
||||
this.usernameInput.addEventListener('input', () => {
|
||||
if (this.registering && this.instance) {
|
||||
this.displayNameInput.value = this.displayNameInput.value || this.usernameInput.value;
|
||||
}
|
||||
});
|
||||
|
||||
void this.probe();
|
||||
}
|
||||
|
||||
private async probe(): Promise<void> {
|
||||
const url = this.serverInput.value.trim();
|
||||
if (!url) return;
|
||||
this.options.api.setBaseUrl(url);
|
||||
try {
|
||||
this.instance = await this.options.api.instance();
|
||||
await this.options.onServerUrlChange(url);
|
||||
if (this.instance.userCount === 0) {
|
||||
this.setNotice('这是一个全新的实例,第一个注册的账号会成为管理员。');
|
||||
this.setRegistering(true);
|
||||
} else if (this.instance.registrationOpen) {
|
||||
this.setNotice(`已有 ${this.instance.userCount} 个账号,此实例开放注册。`);
|
||||
} else {
|
||||
this.clearNotice();
|
||||
}
|
||||
} catch (err) {
|
||||
this.instance = null;
|
||||
if (err instanceof ApiError && err.isConnectivity) {
|
||||
this.setNotice('连不上这个地址。确认服务端已启动,且地址包含端口。', 'error');
|
||||
} else if (err instanceof ApiError) {
|
||||
this.setNotice(err.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toggleRegister(): void {
|
||||
this.setRegistering(!this.registering);
|
||||
}
|
||||
|
||||
private setRegistering(registering: boolean): void {
|
||||
this.registering = registering;
|
||||
const displayNameField = this.element.querySelector('[data-role="display-name"]') as HTMLElement | null;
|
||||
if (displayNameField) displayNameField.hidden = !registering;
|
||||
this.submitButton.textContent = registering ? '注册并登录' : '登录';
|
||||
this.passwordInput.setAttribute('autocomplete', registering ? 'new-password' : 'current-password');
|
||||
}
|
||||
|
||||
private setNotice(text: string, kind: 'info' | 'error' = 'info'): void {
|
||||
this.notice.hidden = false;
|
||||
this.notice.textContent = text;
|
||||
this.notice.style.background = kind === 'error'
|
||||
? 'color-mix(in srgb, #b6453a 14%, var(--reader-surface))'
|
||||
: '';
|
||||
}
|
||||
|
||||
private clearNotice(): void {
|
||||
this.notice.hidden = true;
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.errorText.hidden = false;
|
||||
this.errorText.textContent = message;
|
||||
}
|
||||
|
||||
private clearError(): void {
|
||||
this.errorText.hidden = true;
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
this.clearError();
|
||||
const url = this.serverInput.value.trim();
|
||||
if (!url) {
|
||||
this.showError('请填写服务端地址');
|
||||
return;
|
||||
}
|
||||
this.options.api.setBaseUrl(url);
|
||||
const username = this.usernameInput.value.trim();
|
||||
const password = this.passwordInput.value;
|
||||
if (!username || !password) {
|
||||
this.showError('请填写用户名和密码');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
this.showError('密码至少 8 位(服务端的硬性要求)');
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitButton.disabled = true;
|
||||
try {
|
||||
if (this.registering) {
|
||||
await this.options.api.register(username, password, this.displayNameInput.value.trim() || undefined);
|
||||
} else {
|
||||
await this.options.api.login(username, password);
|
||||
}
|
||||
await this.options.onServerUrlChange(url);
|
||||
this.options.onAuthenticated();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.isConnectivity) this.showError('连不上服务端,检查地址和网络');
|
||||
else if (err.code === 'REGISTRATION_DISABLED') this.showError('此实例已关闭公开注册,请让管理员创建账号');
|
||||
else if (err.code === 'PASSWORD_TOO_SHORT') this.showError('密码至少 8 位');
|
||||
else if (err.isAuthFailure) this.showError('用户名或密码错误');
|
||||
else this.showError(err.message);
|
||||
} else {
|
||||
this.showError(err instanceof Error ? err.message : '登录失败');
|
||||
}
|
||||
} finally {
|
||||
this.submitButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Lets the router re-show the form after a session expiry. */
|
||||
reset(message?: string): void {
|
||||
this.passwordInput.value = '';
|
||||
if (message) this.showError(message);
|
||||
clear(this.errorText);
|
||||
if (message) this.showError(message);
|
||||
this.usernameInput.focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
import { ApiError } from '../api/errors.ts';
|
||||
import type { ReaderApi } from '../api/client.ts';
|
||||
import type { Book, Manifest, Note } from '../api/types.ts';
|
||||
import { loadBook, isImagePath, extensionOf, type BookDoc } from '../formats/index.ts';
|
||||
import type { OfflineStore } from '../store/offline.ts';
|
||||
import type { SyncEngine } from '../core/sync.ts';
|
||||
import type { Platform } from '../core/platform.ts';
|
||||
import { ReaderView, type Position, type ViewSettings } from './reader-view.ts';
|
||||
import { attachGestures } from './gestures.ts';
|
||||
import { clear, el, percent } from './dom.ts';
|
||||
import type { AppSettings } from '../store/settings.ts';
|
||||
|
||||
export interface ReaderScreenOptions {
|
||||
api: ReaderApi;
|
||||
offline: OfflineStore;
|
||||
sync: SyncEngine;
|
||||
platform: Platform;
|
||||
settings: AppSettings;
|
||||
onBack(): void;
|
||||
onSettingsChange(patch: Partial<AppSettings>): void;
|
||||
onSignedOut(): void;
|
||||
}
|
||||
|
||||
interface LoadedBook {
|
||||
doc: BookDoc;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The reading screen: format loading, gestures, the chrome and position sync.
|
||||
*
|
||||
* Ordering here is not arbitrary. The book is loaded into memory *before* the
|
||||
* network is consulted for the reading position, because the position format is
|
||||
* defined in terms of a section id that only exists after the format loader has
|
||||
* parsed the book. Doing it the other way round was the first thing I wrote and
|
||||
* it produced a resumed position with no matching section — a silent jump to
|
||||
* page one, which is the worst possible failure for a reading app.
|
||||
*/
|
||||
export class ReaderScreen {
|
||||
readonly element: HTMLDivElement;
|
||||
private readonly stage: HTMLDivElement;
|
||||
private readonly topbar: HTMLDivElement;
|
||||
private readonly footer: HTMLDivElement;
|
||||
private readonly progressFill: HTMLSpanElement;
|
||||
private readonly chapterLabel: HTMLSpanElement;
|
||||
private readonly pageLabel: HTMLSpanElement;
|
||||
private readonly statusBar: HTMLDivElement;
|
||||
private readonly statusText: HTMLSpanElement;
|
||||
private readonly tocPanel: HTMLDivElement;
|
||||
private readonly tocList: HTMLUListElement;
|
||||
private readonly settingsPanel: HTMLDivElement;
|
||||
|
||||
private view: ReaderView | null = null;
|
||||
private doc: BookDoc | null = null;
|
||||
private book: Book | null = null;
|
||||
private manifest: Manifest | null = null;
|
||||
private chromeVisible = true;
|
||||
private detachGestures: (() => void) | null = null;
|
||||
private progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingPosition: Position | null = null;
|
||||
private readonly settings: AppSettings;
|
||||
private readonly listeners: Array<() => void> = [];
|
||||
private loadingToken = 0;
|
||||
|
||||
constructor(private readonly options: ReaderScreenOptions) {
|
||||
this.settings = { ...options.settings };
|
||||
|
||||
this.stage = el('div', { className: 'stage' });
|
||||
this.progressFill = el('span');
|
||||
this.chapterLabel = el('span', { className: 'chapter' });
|
||||
this.pageLabel = el('span', { text: '0%' });
|
||||
this.statusText = el('span');
|
||||
this.statusBar = el('div', {
|
||||
className: 'status-bar',
|
||||
attrs: { hidden: true },
|
||||
dataset: { state: 'idle' },
|
||||
children: [el('span', { className: 'status-dot' }), this.statusText],
|
||||
}) as HTMLDivElement;
|
||||
|
||||
this.topbar = this.buildTopbar();
|
||||
this.footer = el('div', {
|
||||
className: 'footer',
|
||||
children: [
|
||||
el('div', { className: 'progress-bar', children: [this.progressFill] }),
|
||||
el('div', {
|
||||
className: 'footer-row',
|
||||
children: [
|
||||
el('button', {
|
||||
className: 'icon-button',
|
||||
text: '☰',
|
||||
attrs: { type: 'button', 'aria-label': '目录' },
|
||||
on: { click: () => this.toggleToc() },
|
||||
}),
|
||||
this.chapterLabel,
|
||||
el('button', {
|
||||
className: 'icon-button',
|
||||
text: '⚙',
|
||||
attrs: { type: 'button', 'aria-label': '阅读设置' },
|
||||
on: { click: () => this.toggleSettings() },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el('div', { className: 'footer-row', children: [el('span', { text: '' }), this.pageLabel, el('span', { text: '' })] }),
|
||||
],
|
||||
}) as HTMLDivElement;
|
||||
|
||||
this.tocList = el('ul', { className: 'toc-list' });
|
||||
this.tocPanel = el('div', {
|
||||
className: 'panel',
|
||||
attrs: { hidden: true },
|
||||
children: [this.buildPanelHeader('目录', () => this.toggleToc()), el('div', { className: 'panel-body', children: [this.tocList] })],
|
||||
}) as HTMLDivElement;
|
||||
|
||||
this.settingsPanel = this.buildSettingsPanel();
|
||||
|
||||
this.element = el('div', {
|
||||
className: 'reader-screen',
|
||||
children: [this.topbar, this.statusBar, this.stage, this.footer, this.tocPanel, this.settingsPanel],
|
||||
}) as HTMLDivElement;
|
||||
this.element.style.cssText = 'flex:1 1 auto;min-height:0;display:flex;flex-direction:column;position:relative;';
|
||||
|
||||
this.bindSyncStatus();
|
||||
}
|
||||
|
||||
/** Opens a book: fetch bytes, parse, restore position, start reporting. */
|
||||
async open(book: Book): Promise<void> {
|
||||
const token = ++this.loadingToken;
|
||||
this.book = book;
|
||||
this.showTitle(book.title, book.author);
|
||||
this.setStatus('loading', '正在载入…');
|
||||
this.setChromeVisible(true);
|
||||
|
||||
try {
|
||||
this.manifest = await this.options.api.manifest(book.id);
|
||||
const bytes = await this.fetchBookBytes(book, this.manifest);
|
||||
if (token !== this.loadingToken) return;
|
||||
|
||||
const loaded = await this.loadDoc(book, this.manifest, bytes);
|
||||
if (token !== this.loadingToken) return;
|
||||
|
||||
this.doc = loaded.doc;
|
||||
this.buildView();
|
||||
if (loaded.notes.length > 0) this.setStatus('idle', loaded.notes.join(' · '));
|
||||
else this.hideStatus();
|
||||
|
||||
await this.restorePosition(book, token);
|
||||
} catch (err) {
|
||||
if (token !== this.loadingToken) return;
|
||||
this.handleLoadError(err);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.loadingToken += 1;
|
||||
this.flushProgress();
|
||||
if (this.progressTimer) clearTimeout(this.progressTimer);
|
||||
this.detachGestures?.();
|
||||
this.detachGestures = null;
|
||||
for (const off of this.listeners) off();
|
||||
this.listeners.length = 0;
|
||||
this.view?.dispose();
|
||||
this.view = null;
|
||||
this.doc = null;
|
||||
this.manifest = null;
|
||||
this.element.remove();
|
||||
}
|
||||
|
||||
// ---- loading ----
|
||||
|
||||
private async fetchBookBytes(book: Book, manifest: Manifest): Promise<Uint8Array> {
|
||||
const cacheKey = `book:${book.id}`;
|
||||
// Offline first, always. A cached book must open with no network at all —
|
||||
// that is the whole point of the按书粒度 download.
|
||||
const cached = await this.options.platform.blobs.get(cacheKey);
|
||||
if (cached && cached.byteLength > 0) {
|
||||
// Refresh in the background only when the file has not been superseded.
|
||||
// Book bytes are immutable per hash, so this is genuinely optional.
|
||||
return cached;
|
||||
}
|
||||
const bytes = await this.options.api.bookBytes(book.id);
|
||||
await this.options.platform.blobs.put(cacheKey, bytes);
|
||||
void manifest;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async loadDoc(book: Book, manifest: Manifest, bytes: Uint8Array): Promise<LoadedBook> {
|
||||
const primary = manifest.files.find((file) => file.missing === 0) ?? manifest.files[0];
|
||||
const fileName = primary?.rel_path?.split('/').pop() ?? book.title;
|
||||
const extension = extensionOf(fileName).toLowerCase();
|
||||
|
||||
// A comic stored as a folder of images has no single file to stream, so the
|
||||
// manifest's file list *is* the book. Detect it before dispatching, because
|
||||
// the format layer needs the page list rather than the bytes.
|
||||
const imageFiles = manifest.files.filter((file) => file.missing === 0 && isImagePath(file.rel_path));
|
||||
if (imageFiles.length > 1 && !['epub', 'pdf', 'cbz', 'txt'].includes(extension)) {
|
||||
return loadBook(
|
||||
{ bytes, fileName, bookId: book.id },
|
||||
{
|
||||
fileName,
|
||||
comicPages: imageFiles.map((file) => file.rel_path),
|
||||
resolvePage: (path) => this.fetchResourceBytes(book, path),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return loadBook(
|
||||
{ bytes, fileName, bookId: book.id },
|
||||
{
|
||||
fileName,
|
||||
...(this.settings.txtEncoding ? { encoding: this.settings.txtEncoding } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one page of a directory comic.
|
||||
*
|
||||
* Each page is cached individually, so a partially read comic is still
|
||||
* partially readable offline, and re-opening it does not re-download pages
|
||||
* the reader has already seen.
|
||||
*/
|
||||
private async fetchResourceBytes(book: Book, relPath: string): Promise<Uint8Array> {
|
||||
const key = `page:${book.id}:${relPath}`;
|
||||
const cached = await this.options.platform.blobs.get(key);
|
||||
if (cached) return cached;
|
||||
const response = await this.options.platform.transport.send({
|
||||
url: `/api/v1/books/${encodeURIComponent(book.id)}/file?path=${encodeURIComponent(relPath)}`,
|
||||
method: 'GET',
|
||||
headers: await this.authHeaders(),
|
||||
binary: true,
|
||||
});
|
||||
const bytes = response.bytes ?? new Uint8Array();
|
||||
await this.options.platform.blobs.put(key, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async authHeaders(): Promise<Record<string, string>> {
|
||||
const session = this.options.api.currentSession();
|
||||
return session ? { authorization: `Bearer ${session.accessToken}`, accept: '*/*' } : { accept: '*/*' };
|
||||
}
|
||||
|
||||
private buildView(): void {
|
||||
this.view?.dispose();
|
||||
if (!this.doc) return;
|
||||
const doc = this.doc;
|
||||
this.view = new ReaderView({
|
||||
container: this.stage,
|
||||
doc,
|
||||
onPositionChange: (position) => this.onPosition(position),
|
||||
onChapterChange: (_index, section) => {
|
||||
this.chapterLabel.textContent = section.label;
|
||||
this.highlightToc(section.id);
|
||||
},
|
||||
});
|
||||
this.view.applySettings(this.viewSettings());
|
||||
|
||||
if (doc.layout === 'reflowable') {
|
||||
const scrollTarget = this.stage;
|
||||
this.listeners.push(
|
||||
addDebouncedListener(scrollTarget, 'scroll', () => this.onPosition(this.view?.position() ?? null), 250),
|
||||
);
|
||||
}
|
||||
|
||||
this.detachGestures = attachGestures(this.stage, {
|
||||
onTapZone: (zone) => {
|
||||
if (zone === 'toggle-chrome') this.setChromeVisible(!this.chromeVisible);
|
||||
else if (zone === 'next') void this.view?.next();
|
||||
else void this.view?.previous();
|
||||
},
|
||||
onSwipe: (direction) => {
|
||||
// Swiping is only a page turn in fixed layout or paged mode. In scroll
|
||||
// mode a horizontal swipe has no meaning and hijacking it would fight
|
||||
// the reader's own scrolling.
|
||||
if (doc.layout !== 'fixed' && this.settings.mode !== 'paged') return;
|
||||
if (direction === 'left') void this.view?.next();
|
||||
else if (direction === 'right') void this.view?.previous();
|
||||
},
|
||||
onDragDelta: () => undefined,
|
||||
onLongPress: () => this.setChromeVisible(true),
|
||||
hasSelection: () => hasTextSelection(this.stage),
|
||||
});
|
||||
|
||||
void this.renderToc();
|
||||
}
|
||||
|
||||
private viewSettings(): Partial<ViewSettings> {
|
||||
return {
|
||||
mode: this.settings.mode,
|
||||
fontScale: this.settings.fontScale,
|
||||
lineHeight: this.settings.lineHeight,
|
||||
theme: this.settings.theme,
|
||||
fit: this.settings.fit,
|
||||
direction: this.doc?.layout === 'fixed' ? this.settings.comicDirection : (this.doc?.direction ?? 'ltr'),
|
||||
};
|
||||
}
|
||||
|
||||
private async restorePosition(book: Book, token: number): Promise<void> {
|
||||
const local = await this.options.offline.getProgress(book.id);
|
||||
let locator = local?.locator ?? '';
|
||||
|
||||
// Ask the server only when there is nothing local: the local value is what
|
||||
// this device last showed, and a server value from another device would
|
||||
// yank the reader backwards on every open.
|
||||
if (!locator) {
|
||||
try {
|
||||
const remote = await this.options.api.getProgress(book.id);
|
||||
locator = remote?.locator ?? '';
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.isAuthFailure) this.options.onSignedOut();
|
||||
// Anything else (offline, server error) leaves us on the local value.
|
||||
}
|
||||
}
|
||||
|
||||
if (token !== this.loadingToken) return;
|
||||
if (locator && this.view) {
|
||||
const restored = await this.view.openLocator(locator);
|
||||
if (restored) {
|
||||
this.setStatus('idle', '已恢复到上次阅读位置');
|
||||
window.setTimeout(() => this.hideStatus(), 2400);
|
||||
return;
|
||||
}
|
||||
// A locator that cannot be resolved means the book changed on disk under
|
||||
// the reader's feet. Say so rather than jumping to page one silently.
|
||||
this.setStatus('idle', '原阅读位置已失效,从开头开始');
|
||||
window.setTimeout(() => this.hideStatus(), 3200);
|
||||
}
|
||||
await this.view?.open(0, 0);
|
||||
}
|
||||
|
||||
// ---- position and progress ----
|
||||
|
||||
private onPosition(position: Position | null): void {
|
||||
if (!position || !this.book) return;
|
||||
this.progressFill.style.width = percent(position.percentage);
|
||||
this.pageLabel.textContent = percent(position.percentage);
|
||||
if (position.chapterTitle) this.chapterLabel.textContent = position.chapterTitle;
|
||||
this.pendingPosition = position;
|
||||
|
||||
// Debounced: a scroll produces a position per frame, and each one would
|
||||
// otherwise become a write to IndexedDB and a network sync.
|
||||
if (this.progressTimer) clearTimeout(this.progressTimer);
|
||||
this.progressTimer = setTimeout(() => this.flushProgress(), 1500);
|
||||
}
|
||||
|
||||
private flushProgress(): void {
|
||||
const position = this.pendingPosition;
|
||||
const book = this.book;
|
||||
if (!position || !book) return;
|
||||
this.pendingPosition = null;
|
||||
const now = Date.now();
|
||||
void this.options.offline
|
||||
.setProgress({
|
||||
bookId: book.id,
|
||||
locator: position.locator,
|
||||
percentage: position.percentage,
|
||||
chapterTitle: position.chapterTitle,
|
||||
device: this.options.platform.deviceLabel,
|
||||
updatedAt: now,
|
||||
})
|
||||
.then(() => this.options.sync.syncNow())
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// ---- chrome ----
|
||||
|
||||
private setChromeVisible(visible: boolean): void {
|
||||
this.chromeVisible = visible;
|
||||
this.topbar.hidden = !visible;
|
||||
this.footer.hidden = !visible;
|
||||
if (!visible) {
|
||||
this.tocPanel.hidden = true;
|
||||
this.settingsPanel.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
private buildTopbar(): HTMLDivElement {
|
||||
const title = el('div', { className: 'title-block' });
|
||||
title.style.cssText = 'flex:1 1 auto;min-width:0;';
|
||||
return el('div', {
|
||||
className: 'topbar',
|
||||
children: [
|
||||
el('button', {
|
||||
className: 'icon-button',
|
||||
text: '‹',
|
||||
attrs: { type: 'button', 'aria-label': '返回书架' },
|
||||
on: { click: () => this.options.onBack() },
|
||||
}),
|
||||
title,
|
||||
el('button', {
|
||||
className: 'icon-button',
|
||||
text: '☰',
|
||||
attrs: { type: 'button', 'aria-label': '目录' },
|
||||
on: { click: () => this.toggleToc() },
|
||||
}),
|
||||
],
|
||||
}) as HTMLDivElement;
|
||||
}
|
||||
|
||||
private showTitle(title: string, author: string): void {
|
||||
const block = this.topbar.querySelector('.title-block');
|
||||
if (!block) return;
|
||||
clear(block);
|
||||
block.append(el('h1', { text: title }));
|
||||
if (author) block.append(el('span', { className: 'subtitle', text: author }));
|
||||
}
|
||||
|
||||
private buildPanelHeader(title: string, onClose: () => void): HTMLDivElement {
|
||||
return el('div', {
|
||||
className: 'panel-header',
|
||||
children: [
|
||||
el('h2', { text: title }),
|
||||
el('button', {
|
||||
className: 'icon-button',
|
||||
text: '✕',
|
||||
attrs: { type: 'button', 'aria-label': '关闭' },
|
||||
on: { click: onClose },
|
||||
}),
|
||||
],
|
||||
}) as HTMLDivElement;
|
||||
}
|
||||
|
||||
private toggleToc(): void {
|
||||
this.tocPanel.hidden = !this.tocPanel.hidden;
|
||||
if (!this.tocPanel.hidden) this.settingsPanel.hidden = true;
|
||||
}
|
||||
|
||||
private toggleSettings(): void {
|
||||
this.settingsPanel.hidden = !this.settingsPanel.hidden;
|
||||
if (!this.settingsPanel.hidden) this.tocPanel.hidden = true;
|
||||
}
|
||||
|
||||
private async renderToc(): Promise<void> {
|
||||
clear(this.tocList);
|
||||
const entries = this.view?.chapterLabels() ?? [];
|
||||
for (const entry of entries) {
|
||||
const button = el('button', {
|
||||
text: entry.label,
|
||||
attrs: { type: 'button', 'data-section': entry.id },
|
||||
on: {
|
||||
click: () => {
|
||||
void this.view?.openLocator(`${entry.id}:0`);
|
||||
this.tocPanel.hidden = true;
|
||||
this.setChromeVisible(false);
|
||||
},
|
||||
},
|
||||
});
|
||||
if (entry.depth > 0) button.style.paddingInlineStart = `${0.4 + entry.depth * 0.9}rem`;
|
||||
const item = el('li', { children: [button] });
|
||||
item.dataset['section'] = entry.id;
|
||||
this.tocList.append(item);
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
this.tocList.append(el('li', { children: [el('div', { className: 'empty-state', text: '这本书没有目录' })] }));
|
||||
}
|
||||
}
|
||||
|
||||
private highlightToc(sectionId: string): void {
|
||||
for (const item of this.tocList.querySelectorAll('li')) {
|
||||
const matches = item.dataset['section'] === sectionId;
|
||||
const button = item.querySelector('button');
|
||||
if (button) button.setAttribute('aria-current', String(matches));
|
||||
}
|
||||
}
|
||||
|
||||
private buildSettingsPanel(): HTMLDivElement {
|
||||
const body = el('div', { className: 'panel-body' });
|
||||
|
||||
body.append(
|
||||
this.settingsRow('翻页方式', ['scroll', 'paged'], this.settings.mode, (value) =>
|
||||
this.updateSetting({ mode: value as 'scroll' | 'paged' }), (value) => (value === 'scroll' ? '滚动' : '翻页')),
|
||||
);
|
||||
|
||||
const scaleRow = el('div', { className: 'field' });
|
||||
const scaleValue = el('label', { text: `字号 ${Math.round(this.settings.fontScale * 100)}%` });
|
||||
const scaleInput = el('input', {
|
||||
attrs: { type: 'range', min: '0.8', max: '2.2', step: '0.05', value: String(this.settings.fontScale) },
|
||||
on: {
|
||||
input: (event) => {
|
||||
const value = Number((event.target as HTMLInputElement).value);
|
||||
scaleValue.textContent = `字号 ${Math.round(value * 100)}%`;
|
||||
void this.updateSetting({ fontScale: value });
|
||||
},
|
||||
},
|
||||
});
|
||||
scaleRow.append(scaleValue, scaleInput);
|
||||
body.append(scaleRow);
|
||||
|
||||
body.append(
|
||||
this.settingsRow('行距', ['inherit', '1.5', '1.8', '2.1'], this.settings.lineHeight, (value) =>
|
||||
this.updateSetting({ lineHeight: value }), (value) => (value === 'inherit' ? '原书' : value)),
|
||||
);
|
||||
|
||||
body.append(
|
||||
this.settingsRow('主题', ['light', 'sepia', 'dark'], this.settings.theme, (value) =>
|
||||
this.updateSetting({ theme: value as 'light' | 'sepia' | 'dark' }), (value) =>
|
||||
value === 'light' ? '白' : value === 'sepia' ? '米黄' : '夜间'),
|
||||
);
|
||||
|
||||
const fitRow = this.settingsRow('图片适配', ['contain', 'width'], this.settings.fit, (value) =>
|
||||
this.updateSetting({ fit: value as 'contain' | 'width' }), (value) => (value === 'contain' ? '完整' : '适宽'));
|
||||
fitRow.dataset['only'] = 'fixed';
|
||||
body.append(fitRow);
|
||||
|
||||
const directionRow = this.settingsRow('翻页方向', ['ltr', 'rtl'], this.settings.comicDirection, (value) =>
|
||||
this.updateSetting({ comicDirection: value as 'ltr' | 'rtl' }), (value) => (value === 'ltr' ? '左→右' : '右→左'));
|
||||
directionRow.dataset['only'] = 'fixed';
|
||||
body.append(directionRow);
|
||||
|
||||
const encodingRow = this.settingsRow('TXT 编码', ['', 'utf-8', 'gb18030', 'big5', 'utf-16le'], this.settings.txtEncoding, (value) =>
|
||||
this.updateSetting({ txtEncoding: value }), (value) => (value === '' ? '自动' : value));
|
||||
encodingRow.dataset['only'] = 'txt';
|
||||
body.append(encodingRow);
|
||||
|
||||
return el('div', {
|
||||
className: 'panel',
|
||||
attrs: { hidden: true },
|
||||
children: [this.buildPanelHeader('阅读设置', () => this.toggleSettings()), body],
|
||||
}) as HTMLDivElement;
|
||||
}
|
||||
|
||||
private settingsRow(
|
||||
label: string,
|
||||
values: string[],
|
||||
current: string,
|
||||
onChange: (value: string) => void,
|
||||
labelFor: (value: string) => string,
|
||||
): HTMLDivElement {
|
||||
const buttons = values.map((value) =>
|
||||
el('button', {
|
||||
text: labelFor(value),
|
||||
attrs: { type: 'button', 'aria-pressed': String(value === current) },
|
||||
on: {
|
||||
click: (event) => {
|
||||
const group = (event.currentTarget as HTMLElement).parentElement;
|
||||
for (const sibling of group?.children ?? []) sibling.setAttribute('aria-pressed', 'false');
|
||||
(event.currentTarget as HTMLElement).setAttribute('aria-pressed', 'true');
|
||||
onChange(value);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return el('div', {
|
||||
className: 'field',
|
||||
children: [
|
||||
el('label', { text: label }),
|
||||
el('div', { className: 'segmented', children: buttons }),
|
||||
],
|
||||
}) as HTMLDivElement;
|
||||
}
|
||||
|
||||
private async updateSetting(patch: Partial<AppSettings>): Promise<void> {
|
||||
this.options.onSettingsChange(patch);
|
||||
Object.assign(this.settings, patch);
|
||||
this.view?.applySettings(this.viewSettings());
|
||||
// Fixed-layout fit and direction changes are structural, so the current page
|
||||
// has to be re-rendered rather than merely re-styled.
|
||||
if (
|
||||
this.doc?.layout === 'fixed' &&
|
||||
('fit' in patch || 'comicDirection' in patch)
|
||||
) {
|
||||
const index = this.view?.currentSectionIndex() ?? 0;
|
||||
await this.view?.open(index, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- status ----
|
||||
|
||||
private bindSyncStatus(): void {
|
||||
const update = (): void => {
|
||||
const status = this.options.sync.status();
|
||||
if (status.state === 'syncing') {
|
||||
this.setStatus('syncing', '同步中…');
|
||||
} else if (status.state === 'offline') {
|
||||
this.setStatus('offline', status.pending ? '离线 · 进度待同步' : '离线 · 阅读进度存在本机');
|
||||
} else if (status.state === 'error') {
|
||||
this.setStatus('error', status.message || '同步失败');
|
||||
} else if (status.state === 'signed-out') {
|
||||
this.options.onSignedOut();
|
||||
} else if (this.statusBar.dataset['state'] !== 'loading') {
|
||||
this.hideStatus();
|
||||
}
|
||||
};
|
||||
this.listeners.push(this.options.sync.onStatus(update));
|
||||
update();
|
||||
}
|
||||
|
||||
private setStatus(state: string, text: string): void {
|
||||
this.statusBar.hidden = false;
|
||||
this.statusBar.dataset['state'] = state;
|
||||
this.statusText.textContent = text;
|
||||
}
|
||||
|
||||
private hideStatus(): void {
|
||||
if (this.statusBar.dataset['state'] === 'loading') return;
|
||||
this.statusBar.hidden = true;
|
||||
}
|
||||
|
||||
private handleLoadError(err: unknown): void {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.isAuthFailure) {
|
||||
this.options.onSignedOut();
|
||||
return;
|
||||
}
|
||||
if (err.isConnectivity) {
|
||||
this.setStatus('offline', '这本书还没有下载,连上服务端后可读');
|
||||
return;
|
||||
}
|
||||
this.setStatus('error', err.message);
|
||||
return;
|
||||
}
|
||||
this.setStatus('error', err instanceof Error ? err.message : '无法打开这本书');
|
||||
}
|
||||
}
|
||||
|
||||
function addDebouncedListener(
|
||||
target: EventTarget,
|
||||
event: string,
|
||||
handler: () => void,
|
||||
wait: number,
|
||||
): () => void {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const wrapped = (): void => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(handler, wait);
|
||||
};
|
||||
target.addEventListener(event, wrapped, { passive: true });
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
target.removeEventListener(event, wrapped);
|
||||
};
|
||||
}
|
||||
|
||||
function hasTextSelection(root: HTMLElement): boolean {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) return false;
|
||||
const anchor = selection.anchorNode;
|
||||
return anchor !== null && root.contains(anchor);
|
||||
}
|
||||
|
||||
export type { Note };
|
||||
@@ -0,0 +1,380 @@
|
||||
import type { BookDoc, Section } from '../formats/types.ts';
|
||||
import { ResourceResolver, hydrateResources } from './resources.ts';
|
||||
import { createBookHost, extractBody, extractInlineStyles, sanitiseInjectedContent, type BookShadowHost } from './shadow.ts';
|
||||
import { bookPercentage, formatLocator, parseLocator } from './locator.ts';
|
||||
|
||||
export interface ReaderViewOptions {
|
||||
container: HTMLElement;
|
||||
doc: BookDoc;
|
||||
/** Called on every position change, throttled by the caller via the view. */
|
||||
onPositionChange?: (position: Position) => void;
|
||||
onChapterChange?: (index: number, section: Section) => void;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
sectionIndex: number;
|
||||
sectionId: string;
|
||||
within: number;
|
||||
percentage: number;
|
||||
locator: string;
|
||||
chapterTitle: string;
|
||||
}
|
||||
|
||||
export interface ViewSettings {
|
||||
/** 'scroll' keeps the author's continuous flow; 'paged' paginates by column. */
|
||||
mode: 'scroll' | 'paged';
|
||||
fontScale: number;
|
||||
lineHeight: string;
|
||||
theme: 'light' | 'sepia' | 'dark';
|
||||
/** Fixed-layout only. */
|
||||
fit: 'contain' | 'width';
|
||||
direction: 'ltr' | 'rtl';
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: ViewSettings = {
|
||||
mode: 'scroll',
|
||||
fontScale: 1,
|
||||
lineHeight: 'inherit',
|
||||
theme: 'light',
|
||||
fit: 'contain',
|
||||
direction: 'ltr',
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a `BookDoc` and owns everything about the current position.
|
||||
*
|
||||
* Split from the reader controller on purpose: this class knows about DOM,
|
||||
* layout and gestures and nothing about the network; the controller knows about
|
||||
* sync and history and nothing about CSS columns. The boundary is what lets the
|
||||
* same view be driven by the H5 app and by the Android shell without either one
|
||||
* duplicating layout logic.
|
||||
*/
|
||||
export class ReaderView {
|
||||
private readonly doc: BookDoc;
|
||||
private readonly container: HTMLElement;
|
||||
private readonly host: BookShadowHost;
|
||||
private readonly resolver: ResourceResolver;
|
||||
private settings: ViewSettings;
|
||||
private sectionIndex = 0;
|
||||
private sectionOffset = 0;
|
||||
private lastEmitted = '';
|
||||
private readonly options: ReaderViewOptions;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private currentObjectUrl: string | null = null;
|
||||
|
||||
constructor(options: ReaderViewOptions) {
|
||||
this.options = options;
|
||||
this.doc = options.doc;
|
||||
this.container = options.container;
|
||||
this.settings = { ...DEFAULT_SETTINGS, direction: options.doc.direction };
|
||||
this.host = createBookHost();
|
||||
this.host.setAttribute('data-layout', options.doc.layout);
|
||||
this.host.className = 'book-host';
|
||||
this.resolver = new ResourceResolver(options.doc.resources);
|
||||
this.container.append(this.host);
|
||||
this.applySettings();
|
||||
}
|
||||
|
||||
get settingsSnapshot(): ViewSettings {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
get sectionCount(): number {
|
||||
return this.doc.sections.length;
|
||||
}
|
||||
|
||||
currentSectionIndex(): number {
|
||||
return this.sectionIndex;
|
||||
}
|
||||
|
||||
chapterLabels(): Array<{ id: string; label: string; depth: number }> {
|
||||
return this.doc.toc;
|
||||
}
|
||||
|
||||
applySettings(next?: Partial<ViewSettings>): void {
|
||||
if (next) this.settings = { ...this.settings, ...next };
|
||||
document.documentElement.dataset['theme'] = this.settings.theme;
|
||||
this.host.setAttribute('data-paginated', String(this.settings.mode === 'paged'));
|
||||
this.container.style.setProperty('--reader-font-scale', String(this.settings.fontScale));
|
||||
// `inherit` is what keeps the author's line-height; only an explicit user
|
||||
// choice replaces it.
|
||||
this.container.style.setProperty('--reader-line-height', this.settings.lineHeight);
|
||||
this.host.style.direction = this.settings.direction;
|
||||
// Toggling pagination changes the column count, so the stored offset has to
|
||||
// be re-applied after the browser has laid out the new column box.
|
||||
requestAnimationFrame(() => this.restoreOffset());
|
||||
}
|
||||
|
||||
/** Opens a section by index, optionally at a fractional offset within it. */
|
||||
async open(index: number, offset = 0): Promise<void> {
|
||||
const section = this.doc.sections[index];
|
||||
if (!section) return;
|
||||
this.sectionIndex = index;
|
||||
this.sectionOffset = Math.min(1, Math.max(0, offset));
|
||||
this.releaseObjectUrl();
|
||||
|
||||
if (this.doc.layout === 'fixed') {
|
||||
await this.renderFixed(section);
|
||||
} else {
|
||||
await this.renderReflowable(section);
|
||||
}
|
||||
this.options.onChapterChange?.(index, section);
|
||||
this.restoreOffset();
|
||||
this.emitPosition();
|
||||
this.attachResizeObserver();
|
||||
}
|
||||
|
||||
/** Resumes at a locator produced by `position()`, tolerating an unknown one. */
|
||||
async openLocator(locator: string): Promise<boolean> {
|
||||
const parsed = parseLocator(locator, this.doc);
|
||||
if (!parsed) return false;
|
||||
const index = this.doc.sections.findIndex((section) => section.id === parsed.sectionId);
|
||||
if (index === -1) return false;
|
||||
await this.open(index, parsed.offset);
|
||||
return true;
|
||||
}
|
||||
|
||||
position(): Position {
|
||||
const section = this.doc.sections[this.sectionIndex];
|
||||
const within = this.measureWithin();
|
||||
return {
|
||||
sectionIndex: this.sectionIndex,
|
||||
sectionId: section?.id ?? '',
|
||||
within,
|
||||
percentage: bookPercentage(this.doc, this.sectionIndex, within),
|
||||
locator: formatLocator(section?.id ?? '', within),
|
||||
chapterTitle: section?.label ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Advances one unit: a page when paged, a screenful when scrolling. */
|
||||
async next(): Promise<boolean> {
|
||||
if (this.doc.layout === 'fixed') return this.stepFixed(1);
|
||||
if (this.settings.mode === 'paged') {
|
||||
const advanced = this.stepColumn(1);
|
||||
if (advanced) return true;
|
||||
return this.stepSection(1);
|
||||
}
|
||||
const flow = this.host.flow;
|
||||
const limit = flow.scrollHeight - flow.clientHeight;
|
||||
if (flow.scrollTop < limit - 8) {
|
||||
flow.scrollTop = Math.min(limit, flow.scrollTop + flow.clientHeight * 0.9);
|
||||
return true;
|
||||
}
|
||||
return this.stepSection(1);
|
||||
}
|
||||
|
||||
async previous(): Promise<boolean> {
|
||||
if (this.doc.layout === 'fixed') return this.stepFixed(-1);
|
||||
if (this.settings.mode === 'paged') {
|
||||
const moved = this.stepColumn(-1);
|
||||
if (moved) return true;
|
||||
return this.stepSection(-1, true);
|
||||
}
|
||||
const flow = this.host.flow;
|
||||
if (flow.scrollTop > 8) {
|
||||
flow.scrollTop = Math.max(0, flow.scrollTop - flow.clientHeight * 0.9);
|
||||
return true;
|
||||
}
|
||||
return this.stepSection(-1, true);
|
||||
}
|
||||
|
||||
/** Jumps to a fraction of the whole book, used by the progress slider. */
|
||||
async seekPercentage(percentage: number): Promise<void> {
|
||||
const clamped = Math.min(1, Math.max(0, percentage));
|
||||
const index = Math.min(
|
||||
this.doc.sections.length - 1,
|
||||
Math.floor(clamped * this.doc.sections.length),
|
||||
);
|
||||
const remainder = clamped * this.doc.sections.length - index;
|
||||
await this.open(index, remainder);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
this.releaseObjectUrl();
|
||||
this.resolver.dispose();
|
||||
this.host.remove();
|
||||
}
|
||||
|
||||
// ---- rendering per layout ----
|
||||
|
||||
private async renderReflowable(section: Section): Promise<void> {
|
||||
const raw = section.html ?? '';
|
||||
const body = extractBody(raw);
|
||||
const inlineStyles = extractInlineStyles(raw);
|
||||
// The chapter's own <style> blocks are prepended so the book's link-level
|
||||
// stylesheets (already in `doc.styles`) can still override them, matching
|
||||
// the cascade the book was authored against.
|
||||
this.host.setContent(body, [...inlineStyles, ...this.doc.styles]);
|
||||
sanitiseInjectedContent(this.host.shadow);
|
||||
await hydrateResources(this.host.shadow, this.resolver);
|
||||
// Images load asynchronously and change the flow height, which invalidates
|
||||
// any offset measured before they arrive.
|
||||
this.waitForImages().then(() => {
|
||||
this.restoreOffset();
|
||||
this.emitPosition();
|
||||
});
|
||||
}
|
||||
|
||||
private async renderFixed(section: Section): Promise<void> {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'fixed-page';
|
||||
wrapper.setAttribute('data-fit', this.settings.fit);
|
||||
|
||||
if (section.image) {
|
||||
const url = this.objectUrl(section.image.bytes, section.image.mediaType);
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.alt = section.label;
|
||||
// The page must be decodable before it is shown, otherwise the reader
|
||||
// sees a blank screen for a beat on every turn of a large scan.
|
||||
img.decoding = 'async';
|
||||
img.draggable = false;
|
||||
wrapper.append(img);
|
||||
} else if (section.html) {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'pdf-frame';
|
||||
frame.setAttribute('title', section.label);
|
||||
wrapper.append(frame);
|
||||
// The PDF viewer owns its own rendering; the reader only supplies bytes.
|
||||
queueMicrotask(() => {
|
||||
frame.srcdoc = section.html ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
this.host.setContent('', []);
|
||||
this.host.flow.replaceChildren(wrapper);
|
||||
}
|
||||
|
||||
// ---- position measurement ----
|
||||
|
||||
/**
|
||||
* Fraction scrolled inside the current section.
|
||||
*
|
||||
* Measured against the section's own scroll extent, never against absolute
|
||||
* document pixels, so the value is portable across screen sizes.
|
||||
*/
|
||||
private measureWithin(): number {
|
||||
// Fixed layout has no sub-section position: a comic page is atomic, so the
|
||||
// position is entirely expressed by the section index.
|
||||
if (this.doc.layout === 'fixed') return 0;
|
||||
const flow = this.host.flow;
|
||||
if (this.settings.mode === 'paged') {
|
||||
const width = flow.scrollWidth;
|
||||
if (width <= 0) return this.sectionOffset;
|
||||
return Math.min(1, Math.max(0, flow.scrollLeft / Math.max(1, width - flow.clientWidth)));
|
||||
}
|
||||
const limit = flow.scrollHeight - flow.clientHeight;
|
||||
if (limit <= 0) return 0;
|
||||
return Math.min(1, Math.max(0, flow.scrollTop / limit));
|
||||
}
|
||||
|
||||
private restoreOffset(): void {
|
||||
const flow = this.host.flow;
|
||||
if (this.doc.layout === 'fixed') return;
|
||||
if (this.settings.mode === 'paged') {
|
||||
const max = Math.max(0, flow.scrollWidth - flow.clientWidth);
|
||||
flow.scrollLeft = max * this.sectionOffset;
|
||||
return;
|
||||
}
|
||||
const limit = Math.max(0, flow.scrollHeight - flow.clientHeight);
|
||||
flow.scrollTop = limit * this.sectionOffset;
|
||||
}
|
||||
|
||||
private emitPosition(): void {
|
||||
const position = this.position();
|
||||
// Scroll events fire per frame; only report when the position meaningfully
|
||||
// moved, so a lazy reader does not generate a sync write every frame.
|
||||
const key = `${position.sectionId}:${position.within.toFixed(3)}`;
|
||||
if (key === this.lastEmitted) return;
|
||||
this.lastEmitted = key;
|
||||
this.options.onPositionChange?.(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchors the position when the viewport changes.
|
||||
*
|
||||
* Without this, rotating the device or opening the on-screen keyboard leaves
|
||||
* the reader at a scroll offset measured against the old column height, which
|
||||
* lands them a page or two away from where they were.
|
||||
*/
|
||||
private attachResizeObserver(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
this.resizeObserver = new ResizeObserver(() => this.restoreOffset());
|
||||
this.resizeObserver.observe(this.host.flow);
|
||||
}
|
||||
|
||||
private async stepFixed(delta: number): Promise<boolean> {
|
||||
const next = this.sectionIndex + delta;
|
||||
if (next < 0 || next >= this.doc.sections.length) return false;
|
||||
await this.open(next, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
private stepSection(delta: number, toEnd = false): boolean {
|
||||
const next = this.sectionIndex + delta;
|
||||
if (next < 0 || next >= this.doc.sections.length) return false;
|
||||
void this.open(next, toEnd ? 1 : 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Moves by one CSS column, which is one screen in paged mode. */
|
||||
private stepColumn(delta: number): boolean {
|
||||
const flow = this.host.flow;
|
||||
const step = Math.max(1, flow.clientWidth);
|
||||
const max = Math.max(0, flow.scrollWidth - flow.clientWidth);
|
||||
const target = flow.scrollLeft + step * delta;
|
||||
if (target < -1 || target > max + 1) return false;
|
||||
flow.scrollLeft = Math.min(max, Math.max(0, target));
|
||||
this.emitPosition();
|
||||
return true;
|
||||
}
|
||||
|
||||
private objectUrl(bytes: Uint8Array, mediaType: string): string {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
const url = URL.createObjectURL(new Blob([copy], { type: mediaType }));
|
||||
this.currentObjectUrl = url;
|
||||
return url;
|
||||
}
|
||||
|
||||
private releaseObjectUrl(): void {
|
||||
if (!this.currentObjectUrl) return;
|
||||
try {
|
||||
URL.revokeObjectURL(this.currentObjectUrl);
|
||||
} catch {
|
||||
// Already revoked.
|
||||
}
|
||||
this.currentObjectUrl = null;
|
||||
}
|
||||
|
||||
private async waitForImages(): Promise<void> {
|
||||
const images = [...this.host.shadow.querySelectorAll('img')];
|
||||
if (images.length === 0) return;
|
||||
await Promise.all(
|
||||
images.map(
|
||||
(img) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (img.complete) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// Bounded: a book referencing a missing asset must not stall the
|
||||
// position restore forever.
|
||||
const timer = setTimeout(resolve, 3000);
|
||||
img.addEventListener('load', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
img.addEventListener('error', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Resource } from '../formats/types.ts';
|
||||
|
||||
/**
|
||||
* Resolves `reader-res:` URLs found in injected content.
|
||||
*
|
||||
* Injected chapter markup never touches the network directly. The loader rewrote
|
||||
* every local reference into `reader-res:<archive path>`, and the page installs a
|
||||
* protocol handler that looks the path up in this map. Two reasons:
|
||||
*
|
||||
* - **Books cannot phone home.** A document with absolute URLs is left alone
|
||||
* and blocked by the page's CSP, so a book from an unknown source cannot
|
||||
* leak a reading session to a remote host.
|
||||
* - **Nothing is pre-extracted.** Only resources the rendered chapter actually
|
||||
* asks for are ever materialised, which is what keeps a 400MB comic-adjacent
|
||||
* EPUB from expanding into memory when it is opened.
|
||||
*/
|
||||
export class ResourceResolver {
|
||||
private readonly urls = new Map<string, string>();
|
||||
private readonly pending = new Map<string, Promise<string | null>>();
|
||||
|
||||
constructor(private readonly resources: Map<string, Resource>) {}
|
||||
|
||||
has(path: string): boolean {
|
||||
return this.resources.has(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a URL the browser can load. Object URLs are preferred: a data URL
|
||||
* for a 2MB page image re-encodes the whole payload as base64 and doubles it
|
||||
* in memory.
|
||||
*/
|
||||
async urlFor(path: string): Promise<string | null> {
|
||||
const cached = this.urls.get(path);
|
||||
if (cached) return cached;
|
||||
const inFlight = this.pending.get(path);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const resource = this.resources.get(path);
|
||||
if (!resource) return null;
|
||||
|
||||
const promise = (async () => {
|
||||
const bytes = await resource.bytes();
|
||||
const url = toObjectUrl(bytes, resource.mediaType);
|
||||
this.urls.set(path, url);
|
||||
this.pending.delete(path);
|
||||
return url;
|
||||
})().catch(() => {
|
||||
this.pending.delete(path);
|
||||
return null;
|
||||
});
|
||||
this.pending.set(path, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Routes a `reader-res:` URL, returning null when it is not one. */
|
||||
async resolveReaderUrl(url: string): Promise<string | null> {
|
||||
if (!url.startsWith('reader-res:')) return null;
|
||||
return this.urlFor(url.slice('reader-res:'.length));
|
||||
}
|
||||
|
||||
/** Frees every object URL. Called when a book is closed. */
|
||||
dispose(): void {
|
||||
for (const url of this.urls.values()) {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// A revoked or already-freed URL is not worth reporting.
|
||||
}
|
||||
}
|
||||
this.urls.clear();
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function toObjectUrl(bytes: Uint8Array, mediaType: string): string {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
const blob = new Blob([copy], { type: mediaType || 'application/octet-stream' });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites every `reader-res:` occurrence in a live DOM subtree to a resolved
|
||||
* URL, and keeps the original path for later cleanup.
|
||||
*
|
||||
* `<img>`, `<image>` (inline SVG), `<link>`, `<source>` and `<video>` cover
|
||||
* everything an EPUB actually uses for local assets.
|
||||
*/
|
||||
export async function hydrateResources(root: ParentNode, resolver: ResourceResolver): Promise<void> {
|
||||
const tasks: Array<Promise<void>> = [];
|
||||
|
||||
for (const element of root.querySelectorAll('[src]')) {
|
||||
const value = element.getAttribute('src') ?? '';
|
||||
if (!value.startsWith('reader-res:')) continue;
|
||||
tasks.push(
|
||||
resolver.resolveReaderUrl(value).then((url) => {
|
||||
if (url) element.setAttribute('src', url);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const element of root.querySelectorAll('use')) {
|
||||
const value = element.getAttribute('href') ?? element.getAttribute('xlink:href') ?? '';
|
||||
if (!value.startsWith('reader-res:')) continue;
|
||||
tasks.push(
|
||||
resolver.resolveReaderUrl(value).then((url) => {
|
||||
if (url) {
|
||||
element.setAttribute('href', url);
|
||||
element.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', url);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const link of root.querySelectorAll('link[rel~="stylesheet"], link[rel~="icon"]')) {
|
||||
const value = link.getAttribute('href') ?? '';
|
||||
if (!value.startsWith('reader-res:')) continue;
|
||||
tasks.push(
|
||||
resolver.resolveReaderUrl(value).then((url) => {
|
||||
if (url) link.setAttribute('href', url);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const element of root.querySelectorAll('[srcset]')) {
|
||||
const value = element.getAttribute('srcset') ?? '';
|
||||
const rewritten = await rewriteSrcset(value, resolver);
|
||||
if (rewritten !== value) element.setAttribute('srcset', rewritten);
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
async function rewriteSrcset(value: string, resolver: ResourceResolver): Promise<string> {
|
||||
const parts = value.split(',');
|
||||
const out: string[] = [];
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === '') continue;
|
||||
const [url, ...descriptor] = trimmed.split(/\s+/);
|
||||
const resolved = url ? await resolver.resolveReaderUrl(url) : null;
|
||||
out.push([resolved ?? url ?? '', ...descriptor].join(' '));
|
||||
}
|
||||
return out.join(', ');
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Shadow-root host for book content.
|
||||
*
|
||||
* The book's own markup and stylesheets have to live somewhere. Three options,
|
||||
* and only one of them is defensible:
|
||||
*
|
||||
* - into the app document: a chapter's `p { color: red }` would restyle the
|
||||
* shelf, and `.topbar` collisions would be a matter of luck;
|
||||
* - in an iframe: correct isolation, but every chapter change tears down a
|
||||
* document, which loses scroll position, breaks in-page animation and costs
|
||||
* several times the memory on Android;
|
||||
* - in a shadow root: full style isolation, no document boundary, and the app
|
||||
* can still reach in for pagination and selection.
|
||||
*
|
||||
* The third is what this is. Note what is *not* done: no reset stylesheet is
|
||||
* injected into the shadow root. A reset would override the author's margins and
|
||||
* font stacks, which is the exact failure mode this product exists to avoid.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
export class BookShadowHost extends HTMLElement {
|
||||
readonly shadow: ShadowRoot;
|
||||
private readonly styleEl: HTMLStyleElement;
|
||||
private readonly contentEl: HTMLDivElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: 'open' });
|
||||
this.styleEl = document.createElement('style');
|
||||
this.contentEl = document.createElement('div');
|
||||
this.contentEl.className = 'book-flow';
|
||||
this.shadow.append(this.styleEl, this.contentEl);
|
||||
}
|
||||
|
||||
/** Replaces the reading surface and the stylesheets that apply to it. */
|
||||
setContent(html: string, styles: string[]): void {
|
||||
this.styleEl.textContent = styles.join('\n');
|
||||
this.contentEl.innerHTML = html;
|
||||
}
|
||||
|
||||
/** The element pagination and scroll measurement should look at. */
|
||||
get flow(): HTMLDivElement {
|
||||
return this.contentEl;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.contentEl.innerHTML = '';
|
||||
this.styleEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralises the parts of an injected document that would break the reader.
|
||||
*
|
||||
* Applied after injection, on the live DOM, so it catches constructs a
|
||||
* string-level rewrite cannot — notably anything the HTML parser normalises.
|
||||
*
|
||||
* Removed: `script` (a book must not run code), and navigation-away constructs
|
||||
* (`<base>`, meta refresh, `target` on links) which would otherwise turn a tap
|
||||
* on a footnote into an exit from the reader.
|
||||
*/
|
||||
export function sanitiseInjectedContent(root: ParentNode): void {
|
||||
for (const element of root.querySelectorAll('script, base, meta[http-equiv="refresh" i]')) {
|
||||
element.remove();
|
||||
}
|
||||
|
||||
for (const element of root.querySelectorAll('a[target], a[download]')) {
|
||||
element.removeAttribute('target');
|
||||
element.removeAttribute('download');
|
||||
}
|
||||
|
||||
// Inline event handlers survive an innerHTML round trip, so they must be
|
||||
// stripped explicitly rather than trusted to the parser.
|
||||
for (const element of root.querySelectorAll('*')) {
|
||||
const attributes = element.getAttributeNames();
|
||||
for (const name of attributes) {
|
||||
if (name.toLowerCase().startsWith('on')) {
|
||||
element.removeAttribute(name);
|
||||
} else if (name.toLowerCase() === 'href') {
|
||||
const value = element.getAttribute(name) ?? '';
|
||||
if (/^\s*javascript:/i.test(value)) {
|
||||
element.removeAttribute(name);
|
||||
}
|
||||
} else if (name.toLowerCase() === 'src') {
|
||||
const value = element.getAttribute(name) ?? '';
|
||||
// Only the internal scheme and blobs are allowed. An absolute URL would
|
||||
// leak a reading session to a third party; the book has no reason to
|
||||
// need one.
|
||||
if (!value.startsWith('reader-res:') && !value.startsWith('blob:') && !value.startsWith('data:')) {
|
||||
element.removeAttribute(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps only the body of a full XHTML document, dropping `html`/`head` wrappers. */
|
||||
export function extractBody(html: string): string {
|
||||
const bodyMatch = /<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(html);
|
||||
return bodyMatch?.[1] ?? html;
|
||||
}
|
||||
|
||||
/** Collects `<style>` blocks and inline `<link>` stylesheets for the shadow root. */
|
||||
export function extractInlineStyles(html: string): string[] {
|
||||
const styles: string[] = [];
|
||||
const pattern = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(html)) !== null) {
|
||||
if (match[1]) styles.push(match[1]);
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
if (typeof customElements !== 'undefined' && !customElements.get('book-content')) {
|
||||
customElements.define('book-content', BookShadowHost);
|
||||
}
|
||||
|
||||
export function createBookHost(): BookShadowHost {
|
||||
return document.createElement('book-content') as BookShadowHost;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { ApiError } from '../api/errors.ts';
|
||||
import type { ReaderApi } from '../api/client.ts';
|
||||
import type { Book, ContinueReadingItem } from '../api/types.ts';
|
||||
import type { OfflineStore } from '../store/offline.ts';
|
||||
import type { Platform } from '../core/platform.ts';
|
||||
import { clear, el, percent } from './dom.ts';
|
||||
|
||||
export interface ShelfScreenOptions {
|
||||
api: ReaderApi;
|
||||
offline: OfflineStore;
|
||||
platform: Platform;
|
||||
onOpenBook(book: Book): void;
|
||||
onSignedOut(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shelf.
|
||||
*
|
||||
* Behaviour rule that drives the whole screen: it renders from the local mirror
|
||||
* first and only then replaces it with server data. On a LAN or with a cold
|
||||
* cache that is invisible; on a train it is the difference between the library
|
||||
* appearing instantly and an indefinite spinner (product design §8.2).
|
||||
*/
|
||||
export class ShelfScreen {
|
||||
readonly element: HTMLDivElement;
|
||||
private readonly grid: HTMLDivElement;
|
||||
private readonly continueRow: HTMLDivElement;
|
||||
private readonly continueSection: HTMLDivElement;
|
||||
private readonly statusLine: HTMLDivElement;
|
||||
private readonly searchInput: HTMLInputElement;
|
||||
private extraLoading: HTMLDivElement | null = null;
|
||||
private query = '';
|
||||
private offset = 0;
|
||||
private total = 0;
|
||||
private loading = false;
|
||||
private readonly searchTimer: { id: ReturnType<typeof setTimeout> | null } = { id: null };
|
||||
private readonly coverUrls = new Map<string, string>();
|
||||
|
||||
constructor(private readonly options: ShelfScreenOptions) {
|
||||
this.searchInput = el('input', {
|
||||
attrs: {
|
||||
type: 'search',
|
||||
placeholder: '搜索书名、作者、系列',
|
||||
'aria-label': '搜索书库',
|
||||
enterkeyhint: 'search',
|
||||
},
|
||||
on: {
|
||||
input: () => this.onSearchInput(),
|
||||
},
|
||||
});
|
||||
|
||||
this.continueRow = el('div', { className: 'continue-row' });
|
||||
this.continueSection = el('div', { attrs: { hidden: true } }) as HTMLDivElement;
|
||||
const continueTitle = el('div', { className: 'shelf-section-title', text: '继续阅读' });
|
||||
this.continueSection.append(continueTitle, this.continueRow);
|
||||
|
||||
this.grid = el('div', { className: 'book-grid' });
|
||||
this.statusLine = el('div', { className: 'shelf-status muted' });
|
||||
|
||||
const shelf = el('div', { className: 'shelf' });
|
||||
shelf.append(
|
||||
el('div', { className: 'shelf-search', children: [this.searchInput] }),
|
||||
this.continueSection,
|
||||
this.grid,
|
||||
this.statusLine,
|
||||
);
|
||||
(shelf as HTMLDivElement).id = 'shelf-scroll';
|
||||
|
||||
this.element = el('div', { className: 'shelf-screen', children: [shelf] }) as HTMLDivElement;
|
||||
this.element.style.cssText = 'flex:1 1 auto; min-height:0; display:flex; flex-direction:column;';
|
||||
|
||||
shelf.addEventListener('scroll', () => {
|
||||
const target = shelf as HTMLDivElement;
|
||||
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 400) {
|
||||
void this.loadMore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async show(): Promise<void> {
|
||||
await this.options.offline.load();
|
||||
const cached = this.options.offline.books();
|
||||
if (cached.length > 0) this.renderItems(cached, true);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
const [page, continueItems] = await Promise.all([
|
||||
this.options.api.listBooks(this.listQuery(1)),
|
||||
this.options.api.continueReading(10).catch(() => [] as ContinueReadingItem[]),
|
||||
]);
|
||||
this.offset = page.items.length;
|
||||
this.total = page.total;
|
||||
await this.options.offline.replaceBooks(page.items);
|
||||
this.renderItems(page.items, true);
|
||||
this.renderContinue(continueItems);
|
||||
this.setStatus('');
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
private listQuery(page: number): { search?: string; page: number; pageSize: number } {
|
||||
return {
|
||||
...(this.query ? { search: this.query } : {}),
|
||||
page,
|
||||
pageSize: 60,
|
||||
};
|
||||
}
|
||||
|
||||
private onSearchInput(): void {
|
||||
this.query = this.searchInput.value.trim();
|
||||
if (this.searchTimer.id !== null) clearTimeout(this.searchTimer.id);
|
||||
// Debounced: a shelf of 2000 books over a LAN is fine, but a mobile network
|
||||
// plus a keystroke per character is not.
|
||||
this.searchTimer.id = setTimeout(() => {
|
||||
this.searchTimer.id = null;
|
||||
void this.refresh();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
private async loadMore(): Promise<void> {
|
||||
if (this.loading || this.offset >= this.total) return;
|
||||
this.loading = true;
|
||||
this.showLoadingMore();
|
||||
try {
|
||||
const page = Math.floor(this.offset / 60) + 1;
|
||||
const result = await this.options.api.listBooks(this.listQuery(page));
|
||||
this.offset += result.items.length;
|
||||
this.total = result.total;
|
||||
await this.options.offline.replaceBooks(result.items);
|
||||
this.appendItems(result.items);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError) || !err.isConnectivity) this.setStatus('加载更多失败');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.hideLoadingMore();
|
||||
}
|
||||
}
|
||||
|
||||
private showLoadingMore(): void {
|
||||
if (this.extraLoading) return;
|
||||
this.extraLoading = el('div', { className: 'spinner' });
|
||||
this.element.querySelector('.shelf')?.append(this.extraLoading);
|
||||
}
|
||||
|
||||
private hideLoadingMore(): void {
|
||||
this.extraLoading?.remove();
|
||||
this.extraLoading = null;
|
||||
}
|
||||
|
||||
private renderItems(items: Book[], replace: boolean): void {
|
||||
if (replace) {
|
||||
clear(this.grid);
|
||||
// Only the search result set is authoritative; without this, a cached
|
||||
// shelf and a filtered response would merge into one confusing list.
|
||||
this.rendered.clear();
|
||||
}
|
||||
this.appendItems(items);
|
||||
if (this.rendered.size === 0) {
|
||||
this.grid.append(
|
||||
el('div', {
|
||||
className: 'empty-state',
|
||||
children: [
|
||||
el('p', { text: this.query ? '没有匹配的书' : '书库还是空的' }),
|
||||
el('p', { className: 'muted', text: this.query ? '换个关键词试试' : '确认已经挂载书籍目录,并在设置里触发一次扫描' }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly rendered = new Set<string>();
|
||||
|
||||
private appendItems(items: Book[]): void {
|
||||
for (const book of items) {
|
||||
if (this.rendered.has(book.id)) continue;
|
||||
this.rendered.add(book.id);
|
||||
this.grid.append(this.bookCard(book));
|
||||
}
|
||||
}
|
||||
|
||||
private bookCard(book: Book): HTMLButtonElement {
|
||||
const cover = el('div', { className: 'cover' });
|
||||
void this.fillCover(cover, book);
|
||||
|
||||
const card = el('button', {
|
||||
className: 'book-card',
|
||||
attrs: { type: 'button', 'aria-label': `${book.title} ${book.author}`.trim() },
|
||||
on: { click: () => this.options.onOpenBook(book) },
|
||||
children: [
|
||||
cover,
|
||||
el('div', { className: 'title', text: book.title || '未命名' }),
|
||||
el('div', { className: 'author', text: book.author || '未知作者' }),
|
||||
],
|
||||
});
|
||||
return card as HTMLButtonElement;
|
||||
}
|
||||
|
||||
private async fillCover(container: HTMLDivElement, book: Book): Promise<void> {
|
||||
if (!book.coverUrl) {
|
||||
container.append(el('div', { className: 'placeholder', text: book.title.slice(0, 12) || '无封面' }));
|
||||
container.append(el('span', { className: 'format-badge', text: book.format }));
|
||||
return;
|
||||
}
|
||||
const cached = this.coverUrls.get(book.id);
|
||||
if (cached) {
|
||||
container.append(el('img', { attrs: { src: cached, alt: '', loading: 'lazy' } }));
|
||||
container.append(el('span', { className: 'format-badge', text: book.format }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const bytes = await this.options.api.coverBytes(book.coverUrl);
|
||||
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: 'image/jpeg' }));
|
||||
this.coverUrls.set(book.id, url);
|
||||
clear(container);
|
||||
container.append(el('img', { attrs: { src: url, alt: '', loading: 'lazy' } }));
|
||||
container.append(el('span', { className: 'format-badge', text: book.format }));
|
||||
} catch {
|
||||
container.append(el('div', { className: 'placeholder', text: book.title.slice(0, 12) || '无封面' }));
|
||||
container.append(el('span', { className: 'format-badge', text: book.format }));
|
||||
}
|
||||
}
|
||||
|
||||
private renderContinue(items: ContinueReadingItem[]): void {
|
||||
clear(this.continueRow);
|
||||
if (items.length === 0) {
|
||||
this.continueSection.hidden = true;
|
||||
return;
|
||||
}
|
||||
this.continueSection.hidden = false;
|
||||
for (const item of items) {
|
||||
const cover = el('div', { className: 'cover' });
|
||||
cover.style.cssText = 'aspect-ratio:2/3;border-radius:6px;overflow:hidden;background:var(--reader-line);display:grid;place-items:center;';
|
||||
void this.options.api
|
||||
.coverBytes(item.coverUrl ?? '')
|
||||
.then((bytes) => {
|
||||
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: 'image/jpeg' }));
|
||||
clear(cover);
|
||||
const img = el('img', { attrs: { src: url, alt: '' } });
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
|
||||
cover.append(img);
|
||||
})
|
||||
.catch(() => {
|
||||
cover.append(el('div', { className: 'placeholder', text: item.title.slice(0, 8) }));
|
||||
});
|
||||
|
||||
const bar = el('div', { className: 'progress-track', children: [el('span')] });
|
||||
const inner = bar.querySelector('span') as HTMLSpanElement;
|
||||
inner.style.width = percent(item.percentage);
|
||||
|
||||
const card = el('button', {
|
||||
className: 'continue-card',
|
||||
attrs: { type: 'button' },
|
||||
on: { click: () => this.options.onOpenBook(item) },
|
||||
children: [
|
||||
cover,
|
||||
el('div', { className: 'title', text: item.title, attrs: { style: 'font-size:.8rem;margin-top:.3rem;' } }),
|
||||
el('div', { className: 'muted', text: item.chapterTitle || percent(item.percentage), attrs: { style: 'font-size:.7rem;' } }),
|
||||
bar,
|
||||
],
|
||||
});
|
||||
this.continueRow.append(card);
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(text: string): void {
|
||||
this.statusLine.textContent = text;
|
||||
}
|
||||
|
||||
private handleError(err: unknown): void {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.isAuthFailure) {
|
||||
this.options.onSignedOut();
|
||||
return;
|
||||
}
|
||||
if (err.isConnectivity) {
|
||||
const cached = this.options.offline.books();
|
||||
if (cached.length > 0) {
|
||||
this.setStatus(`离线模式 · ${cached.length} 本可读`);
|
||||
return;
|
||||
}
|
||||
this.setStatus('连不上服务端');
|
||||
return;
|
||||
}
|
||||
this.setStatus(err.message);
|
||||
return;
|
||||
}
|
||||
this.setStatus(err instanceof Error ? err.message : '出错了');
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const url of this.coverUrls.values()) URL.revokeObjectURL(url);
|
||||
this.coverUrls.clear();
|
||||
if (this.searchTimer.id !== null) clearTimeout(this.searchTimer.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { detectFormat } from '../src/formats/detect.ts';
|
||||
import { BookArchive, dirnameOf, normalise, resolveHref } from '../src/formats/zip.ts';
|
||||
import { makeCbz } from './helpers/fixtures.ts';
|
||||
|
||||
/**
|
||||
* The archive and resolution helpers.
|
||||
*
|
||||
* These are the lowest-level pieces of the format layer and the ones whose bugs
|
||||
* are hardest to see: a mis-resolved href does not throw, it silently produces a
|
||||
* broken image on one page of one book.
|
||||
*/
|
||||
|
||||
describe('BookArchive', () => {
|
||||
it('lists files but not directory entries', async () => {
|
||||
const archive = await BookArchive.open(await makeCbz([{ name: 'a/001.png' }, { name: 'a/002.png' }]));
|
||||
expect(archive.names().sort()).toEqual(['a/001.png', 'a/002.png']);
|
||||
});
|
||||
|
||||
it('reads an entry as bytes and as text', async () => {
|
||||
const archive = await BookArchive.open(await makeCbz([{ name: 'note.txt', bytes: new TextEncoder().encode('hello') }]));
|
||||
expect(await archive.text('note.txt')).toBe('hello');
|
||||
expect((await archive.bytes('note.txt'))?.byteLength).toBe(5);
|
||||
});
|
||||
|
||||
it('finds an entry whose path differs only by case or leading slash', async () => {
|
||||
// Archives produced on Windows and by some comic tools carry either.
|
||||
const archive = await BookArchive.open(await makeCbz([{ name: 'Pages/001.png' }]));
|
||||
expect(await archive.bytes('pages/001.png')).not.toBeNull();
|
||||
expect(await archive.bytes('/Pages/001.png')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a genuinely absent entry instead of throwing', async () => {
|
||||
const archive = await BookArchive.open(await makeCbz([{ name: 'a.png' }]));
|
||||
expect(await archive.bytes('missing.png')).toBeNull();
|
||||
expect(await archive.text('missing.png')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalise', () => {
|
||||
it('lowercases, strips a leading slash and percent-decodes', () => {
|
||||
expect(normalise('/Pages/%E4%B8%AD.png')).toBe('pages/中.png');
|
||||
expect(normalise('./a/b.png')).toBe('a/b.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dirnameOf', () => {
|
||||
it('returns an empty string for a root-level file', () => {
|
||||
expect(dirnameOf('a.png')).toBe('');
|
||||
});
|
||||
|
||||
it('returns everything before the last slash', () => {
|
||||
expect(dirnameOf('a/b/c.png')).toBe('a/b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHref', () => {
|
||||
it('does not let a traversal escape above the archive root', () => {
|
||||
// '../../../etc/passwd' must resolve to a path inside the archive, never to
|
||||
// an absolute one. The result is looked up in a map either way, but a
|
||||
// resolver that can produce `../../` is the kind of thing that later becomes
|
||||
// a real file read.
|
||||
const resolved = resolveHref('a', '../../../etc/passwd');
|
||||
expect(resolved.startsWith('/')).toBe(false);
|
||||
expect(resolved.includes('..')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a scheme-qualified href as absolute', () => {
|
||||
expect(resolveHref('a', 'mailto:x@example.com')).toBe('mailto:x@example.com');
|
||||
expect(resolveHref('a', 'https://x/y')).toBe('https://x/y');
|
||||
});
|
||||
|
||||
it('drops a query string but keeps the fragment out of the path', () => {
|
||||
expect(resolveHref('a', 'b.png?v=2#frag')).toBe('a/b.png');
|
||||
});
|
||||
|
||||
it('handles a dot-segment in the middle of a path', () => {
|
||||
expect(resolveHref('a/b', './c/./d.png')).toBe('a/b/c/d.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectFormat edge cases', () => {
|
||||
it('prefers the extension when a zip could be either', () => {
|
||||
const zipMagic = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
|
||||
expect(detectFormat('x.cbz', zipMagic)).toBe('cbz');
|
||||
expect(detectFormat('x.epub', zipMagic)).toBe('epub');
|
||||
});
|
||||
|
||||
it('does not mistake a plain zip of pages for an EPUB', () => {
|
||||
const zipMagic = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
|
||||
expect(detectFormat('pages.zip', zipMagic)).toBe('cbz');
|
||||
});
|
||||
|
||||
it('returns unknown for an empty file with an unknown extension', () => {
|
||||
expect(detectFormat('x.dat', new Uint8Array())).toBe('unknown');
|
||||
});
|
||||
|
||||
it('detects an image by extension when there are no bytes yet', () => {
|
||||
expect(detectFormat('scan.JPG')).toBe('image');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decodeText, isValidUtf8 } from '../src/formats/text-encoding.ts';
|
||||
import { gb18030Bytes, utf8 } from './helpers/fixtures.ts';
|
||||
|
||||
/**
|
||||
* Encoding detection is the difference between a readable TXT and a screen of
|
||||
* replacement characters, and it is the one place where a wrong guess is
|
||||
* silently catastrophic rather than merely inconvenient.
|
||||
*/
|
||||
|
||||
describe('UTF-8 validation', () => {
|
||||
it('accepts ASCII and multi-byte UTF-8', () => {
|
||||
expect(isValidUtf8(utf8('plain ascii'))).toBe(true);
|
||||
expect(isValidUtf8(utf8('中文混排 with English'))).toBe(true);
|
||||
expect(isValidUtf8(utf8('日本語のテキスト'))).toBe(true);
|
||||
expect(isValidUtf8(utf8('emoji 🎉 and combining é'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an overlong encoding, which a naive decoder would accept', () => {
|
||||
// 0xC0 0xAF is an overlong encoding of '/'. Accepting it is the classic way
|
||||
// a validator becomes a security hole in a path-handling context.
|
||||
expect(isValidUtf8(new Uint8Array([0xc0, 0xaf]))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a lone continuation byte and a truncated sequence', () => {
|
||||
expect(isValidUtf8(new Uint8Array([0x80]))).toBe(false);
|
||||
expect(isValidUtf8(new Uint8Array([0xe4, 0xb8]))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a surrogate code point encoded in UTF-8', () => {
|
||||
// ED A0 80 encodes U+D800, which is not a valid scalar value.
|
||||
expect(isValidUtf8(new Uint8Array([0xed, 0xa0, 0x80]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeText', () => {
|
||||
it('strips a UTF-8 BOM so it does not leak into the first chapter title', () => {
|
||||
const bytes = new Uint8Array([0xef, 0xbb, 0xbf, ...utf8('第一章')]);
|
||||
const result = decodeText(bytes);
|
||||
expect(result.text).toBe('第一章');
|
||||
expect(result.text.charCodeAt(0)).not.toBe(0xfeff);
|
||||
expect(result.guessed).toBe(false);
|
||||
});
|
||||
|
||||
it('detects UTF-16LE from its BOM', () => {
|
||||
const le = new Uint8Array([0xff, 0xfe, 0x2d, 0x4e]);
|
||||
const result = decodeText(le);
|
||||
expect(result.encoding).toBe('utf-16le');
|
||||
expect(result.text).toBe('中');
|
||||
});
|
||||
|
||||
it('decodes valid UTF-8 without guessing', () => {
|
||||
const result = decodeText(utf8('第三章 风起'));
|
||||
expect(result.text).toBe('第三章 风起');
|
||||
expect(result.guessed).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to GB18030 when the bytes are not valid UTF-8', () => {
|
||||
const bytes = gb18030Bytes('第一章 起始');
|
||||
// Guard the premise: if these bytes happened to be valid UTF-8 the test
|
||||
// would pass without exercising the fallback at all.
|
||||
expect(isValidUtf8(bytes)).toBe(false);
|
||||
const result = decodeText(bytes);
|
||||
expect(result.text).toBe('第一章 起始');
|
||||
expect(result.encoding).toBe('gb18030');
|
||||
expect(result.guessed).toBe(true);
|
||||
});
|
||||
|
||||
it('marks the result as guessed so the UI can offer an override', () => {
|
||||
const result = decodeText(gb18030Bytes('测试'));
|
||||
expect(result.guessed).toBe(true);
|
||||
});
|
||||
|
||||
it('always returns something for undecodable bytes rather than throwing', () => {
|
||||
const garbage = new Uint8Array([0x81, 0x82, 0x83, 0xff, 0xfe, 0x00]);
|
||||
const result = decodeText(garbage);
|
||||
expect(typeof result.text).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loadBook, detectFormat, extensionOf } from '../src/formats/index.ts';
|
||||
import { loadTxt } from '../src/formats/txt.ts';
|
||||
import { rewriteDocument, rewriteCss } from '../src/formats/epub.ts';
|
||||
import { resolveHref } from '../src/formats/zip.ts';
|
||||
import { makeCbz, makeEpub, PNG_1X1, utf8 } from './helpers/fixtures.ts';
|
||||
|
||||
const ctx = (bytes: Uint8Array, fileName: string) => ({ bytes, fileName, bookId: 'book-1' });
|
||||
|
||||
describe('format detection', () => {
|
||||
it('uses magic bytes over a misleading extension', () => {
|
||||
// A `.txt` that is really an EPUB is common in libraries built by scripts.
|
||||
expect(detectFormat('book.txt', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))).toBe('cbz');
|
||||
expect(detectFormat('book.txt', utf8('%PDF-1.7 ...'))).toBe('pdf');
|
||||
});
|
||||
|
||||
it('recognises an EPUB zip even when the extension says nothing', () => {
|
||||
const bytes = utf8('PK\u0003\u0004xxMETA-INF/container.xml');
|
||||
expect(detectFormat('book.zip', bytes)).toBe('epub');
|
||||
});
|
||||
|
||||
it('reports CBR as unknown rather than pretending to parse it', () => {
|
||||
// RAR cannot be decoded in the browser; a clear "unsupported" beats a
|
||||
// confusing parse error.
|
||||
expect(detectFormat('book.cbr')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('falls back to the extension when no bytes are available', () => {
|
||||
expect(detectFormat('a.epub')).toBe('epub');
|
||||
expect(detectFormat('a.pdf')).toBe('pdf');
|
||||
expect(detectFormat('a.cbz')).toBe('cbz');
|
||||
expect(detectFormat('a.gif')).toBe('image');
|
||||
expect(detectFormat('a.xyz')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('extracts the extension past paths, fragments and uppercase', () => {
|
||||
expect(extensionOf('a/b/c.JPEG')).toBe('jpeg');
|
||||
expect(extensionOf('x.svg#frag')).toBe('svg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('href resolution', () => {
|
||||
it('collapses parent segments', () => {
|
||||
expect(resolveHref('OEBPS/text', '../images/a.png')).toBe('OEBPS/images/a.png');
|
||||
expect(resolveHref('OEBPS', 'text/a.png')).toBe('OEBPS/text/a.png');
|
||||
expect(resolveHref('', 'a.png')).toBe('a.png');
|
||||
});
|
||||
|
||||
it('decodes percent-encoded paths, which EPUBs use for CJK file names', () => {
|
||||
expect(resolveHref('OEBPS', '%E7%AC%AC%E4%B8%80%E7%AB%A0.xhtml')).toBe('OEBPS/第一章.xhtml');
|
||||
});
|
||||
|
||||
it('leaves absolute URLs alone', () => {
|
||||
expect(resolveHref('OEBPS', 'https://example.com/a.png')).toBe('https://example.com/a.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource rewriting', () => {
|
||||
it('rewrites relative src and href into the internal scheme', () => {
|
||||
const html = '<img src="../img/a.png"/><a href="ch2.xhtml">next</a>';
|
||||
const out = rewriteDocument(html, 'OEBPS/text');
|
||||
expect(out).toContain('reader-res:OEBPS/img/a.png');
|
||||
expect(out).toContain('reader-res:OEBPS/text/ch2.xhtml');
|
||||
});
|
||||
|
||||
it('preserves fragments so footnote links still land on the note', () => {
|
||||
const out = rewriteDocument('<a href="notes.xhtml#fn1">1</a>', 'OEBPS');
|
||||
expect(out).toContain('reader-res:OEBPS/notes.xhtml#fn1');
|
||||
});
|
||||
|
||||
it('does not touch data URIs, blob URLs or fragment-only links', () => {
|
||||
const html = '<img src="data:image/png;base64,AAA"/><a href="#top">top</a>';
|
||||
const out = rewriteDocument(html, 'OEBPS');
|
||||
expect(out).toContain('src="data:image/png;base64,AAA"');
|
||||
expect(out).toContain('href="#top"');
|
||||
expect(out).not.toContain('reader-res:');
|
||||
});
|
||||
|
||||
it('leaves remote URLs for the CSP to block', () => {
|
||||
const out = rewriteDocument('<img src="https://tracker.example/p.gif"/>', 'OEBPS');
|
||||
expect(out).toContain('https://tracker.example/p.gif');
|
||||
});
|
||||
|
||||
it('rewrites url() inside CSS, including quoted forms', () => {
|
||||
const css = 'body{background:url(../i/bg.png)}a{background:url("../i/2.png")}';
|
||||
const out = rewriteCss(css, 'OEBPS/css/main.css');
|
||||
expect(out).toContain('reader-res:OEBPS/i/bg.png');
|
||||
expect(out).toContain('reader-res:OEBPS/i/2.png');
|
||||
});
|
||||
|
||||
it('leaves url() that is already absolute', () => {
|
||||
const out = rewriteCss('@font-face{src:url(https://x/f.woff2)}', 'a.css');
|
||||
expect(out).toContain('https://x/f.woff2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EPUB loading', () => {
|
||||
it('follows the spine order, not the manifest or archive order', async () => {
|
||||
// The manifest lists the chapters out of order and the archive is written in
|
||||
// that same wrong order. Only the spine is authoritative. A loader that
|
||||
// walks the zip or trusts the manifest gets a book that reads 三二一.
|
||||
const bytes = await makeEpub({
|
||||
chapters: [
|
||||
{ id: 'c3', href: 'ch3.xhtml', title: '三' },
|
||||
{ id: 'c1', href: 'ch1.xhtml', title: '一' },
|
||||
{ id: 'c2', href: 'ch2.xhtml', title: '二' },
|
||||
],
|
||||
spineOrder: ['c1', 'c2', 'c3'],
|
||||
});
|
||||
const { doc } = await loadBook(ctx(bytes, 'book.epub'));
|
||||
expect(doc.sections.map((section) => section.label)).toEqual(['一', '二', '三']);
|
||||
expect(doc.format).toBe('epub');
|
||||
expect(doc.layout).toBe('reflowable');
|
||||
expect(doc.orderedByBook).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes spine items marked linear="no"', async () => {
|
||||
const bytes = await makeEpub({
|
||||
chapters: [
|
||||
{ id: 'c1', href: 'ch1.xhtml', title: '一' },
|
||||
{ id: 'c2', href: 'ch2.xhtml', title: '二' },
|
||||
],
|
||||
spineOrder: ['c1', 'c2'],
|
||||
});
|
||||
const { doc } = await loadBook(ctx(bytes, 'book.epub'));
|
||||
expect(doc.sections).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reads the navigation document for chapter labels and order', async () => {
|
||||
const bytes = await makeEpub({
|
||||
chapters: [
|
||||
{ id: 'c1', href: 'ch1.xhtml', title: '第一章' },
|
||||
{ id: 'c2', href: 'ch2.xhtml', title: '第二章' },
|
||||
],
|
||||
toc: [
|
||||
{ href: 'ch2.xhtml', label: '二 · 风起' },
|
||||
{ href: 'ch1.xhtml', label: '一 · 起始' },
|
||||
],
|
||||
});
|
||||
const { doc } = await loadBook(ctx(bytes, 'book.epub'));
|
||||
// Reading order still comes from the spine...
|
||||
expect(doc.sections.map((section) => section.id)).toEqual(['OEBPS/ch1.xhtml', 'OEBPS/ch2.xhtml']);
|
||||
// ...while the TOC supplies the labels and its own ordering.
|
||||
expect(doc.sections[0]!.label).toBe('一 · 起始');
|
||||
expect(doc.sections[1]!.label).toBe('二 · 风起');
|
||||
expect(doc.toc.map((entry) => entry.label)).toEqual(['二 · 风起', '一 · 起始']);
|
||||
});
|
||||
|
||||
it('falls back to the NCX table of contents for EPUB 2 books', async () => {
|
||||
const bytes = await makeEpub({
|
||||
chapters: [{ id: 'c1', href: 'ch1.xhtml', title: '第一章' }],
|
||||
ncx: [{ src: 'ch1.xhtml', label: 'NCX 标题' }],
|
||||
});
|
||||
const { doc } = await loadBook(ctx(bytes, 'book.epub'));
|
||||
expect(doc.toc[0]!.label).toBe('NCX 标题');
|
||||
});
|
||||
|
||||
it('carries the author stylesheets through to the renderer', async () => {
|
||||
const bytes = await makeEpub({
|
||||
chapters: [{ id: 'c1', href: 'ch1.xhtml', title: '一' }],
|
||||
styles: [{ path: 'styles/main.css', css: 'p{margin:0 0 0 2em;text-indent:2em}' }],
|
||||
});
|
||||
const { doc } = await loadBook(ctx(bytes, 'book.epub'));
|
||||
const joined = doc.styles.join('\n');
|
||||
// The author's indent must survive: this is the property the product is
|
||||
// differentiated on, so it is asserted rather than assumed.
|
||||
expect(joined).toContain('text-indent:2em');
|
||||
});
|
||||
|
||||
it('rewrites image references inside chapter markup', async () => {
|
||||
const bytes = await makeEpub({
|
||||
chapters: [
|
||||
{
|
||||
id: 'c1',
|
||||
href: 'text/ch1.xhtml',
|
||||
title: '一',
|
||||
extra: '<img src="../images/fig1.png"/>',
|
||||
},
|
||||
],
|
||||
});
|
||||
const { doc } = await loadBook(ctx(bytes, 'book.epub'));
|
||||
expect(doc.sections[0]!.html).toContain('reader-res:OEBPS/images/fig1.png');
|
||||
});
|
||||
|
||||
it('honours page-progression-direction for right-to-left books', async () => {
|
||||
const bytes = await makeEpub({
|
||||
chapters: [{ id: 'c1', href: 'ch1.xhtml', title: '一' }],
|
||||
direction: 'rtl',
|
||||
});
|
||||
const { doc } = await loadBook(ctx(bytes, 'book.epub'));
|
||||
expect(doc.direction).toBe('rtl');
|
||||
});
|
||||
|
||||
it('handles an OPF at the archive root, where the base dir is empty', async () => {
|
||||
const bytes = await makeEpub({
|
||||
chapters: [{ id: 'c1', href: 'ch1.xhtml', title: '一' }],
|
||||
opfPath: 'content.opf',
|
||||
});
|
||||
const { doc } = await loadBook(ctx(bytes, 'book.epub'));
|
||||
expect(doc.sections[0]!.id).toBe('ch1.xhtml');
|
||||
});
|
||||
|
||||
it('derives a label for a chapter missing from the TOC', async () => {
|
||||
const bytes = await makeEpub({
|
||||
chapters: [{ id: 'c1', href: 'ch1.xhtml', title: '未列出的一章' }],
|
||||
});
|
||||
const { doc } = await loadBook(ctx(bytes, 'book.epub'));
|
||||
expect(doc.sections[0]!.label).toBe('未列出的一章');
|
||||
});
|
||||
|
||||
it('registers archive entries that the manifest forgot', async () => {
|
||||
const bytes = await makeEpub({
|
||||
chapters: [{ id: 'c1', href: 'ch1.xhtml', title: '一' }],
|
||||
});
|
||||
const { doc } = await loadBook(ctx(bytes, 'book.epub'));
|
||||
expect(doc.resources.has('OEBPS/ch1.xhtml')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TXT loading', () => {
|
||||
const text = [
|
||||
'第一章 起始',
|
||||
'第一段正文。',
|
||||
'第二段正文。',
|
||||
'',
|
||||
'第二章 风起',
|
||||
'风起了。',
|
||||
'',
|
||||
'第三章 归途',
|
||||
'回家了。',
|
||||
].join('\n');
|
||||
|
||||
it('splits on Chinese chapter headings', () => {
|
||||
const result = loadTxt(ctx(utf8(text), 'book.txt'));
|
||||
expect(result.chapterCount).toBe(3);
|
||||
expect(result.doc.sections.map((section) => section.label)).toEqual(['第一章 起始', '第二章 风起', '第三章 归途']);
|
||||
expect(result.encoding).toBe('utf-8');
|
||||
});
|
||||
|
||||
it('recognises arabic-numeral and English chapter headings', () => {
|
||||
const result = loadTxt(ctx(utf8('第1章 起点\naaa\n\nChapter 2 Go\nbbb'), 'b.txt'));
|
||||
expect(result.chapterCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not treat a long paragraph beginning with 第…章 as a heading', () => {
|
||||
// The failure this guards against: a real chapter body loses its opening
|
||||
// and the reader jumps into the middle of a paragraph.
|
||||
const long = `第三章,他继续走。${'很长的一句话。'.repeat(30)}`;
|
||||
const result = loadTxt(ctx(utf8(`第一章 起点\n开头\n\n${long}`), 'b.txt'));
|
||||
expect(result.chapterCount).toBe(1);
|
||||
});
|
||||
|
||||
it('does not treat an in-prose mention of a chapter number as a heading', () => {
|
||||
const result = loadTxt(ctx(utf8('第一章 起点\n他说他在第二章里读过这段。\n'), 'b.txt'));
|
||||
expect(result.chapterCount).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a preface before the first heading as its own section', () => {
|
||||
const result = loadTxt(ctx(utf8('这是前言。\n\n第一章 起始\n正文'), 'b.txt'));
|
||||
expect(result.doc.sections[0]!.label).toBe('前言');
|
||||
expect(result.doc.sections[1]!.label).toBe('第一章 起始');
|
||||
});
|
||||
|
||||
it('chunks by length when there are no headings at all', () => {
|
||||
const body = '一句话。'.repeat(30_000);
|
||||
const result = loadTxt(ctx(utf8(body), 'b.txt'));
|
||||
expect(result.chapterCount).toBe(0);
|
||||
// A single unbounded section would freeze layout on a phone, so the loader
|
||||
// must break it up.
|
||||
expect(result.doc.sections.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('escapes markup so a TXT file cannot inject HTML', () => {
|
||||
const result = loadTxt(ctx(utf8('第一章\n<script>alert(1)</script>\n<p>不是标签</p>'), 'b.txt'));
|
||||
const html = result.doc.sections[0]!.html ?? '';
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
it('honours a forced encoding override', () => {
|
||||
const result = loadTxt(ctx(utf8('第一章\n正文'), 'b.txt'), { encoding: 'utf-8' });
|
||||
expect(result.encodingGuessed).toBe(false);
|
||||
});
|
||||
|
||||
it('can be told not to split at all', () => {
|
||||
const result = loadTxt(ctx(utf8(text), 'b.txt'), { splitChapters: false });
|
||||
expect(result.chapterCount).toBe(0);
|
||||
});
|
||||
|
||||
it('normalises CRLF so chapter matching works on Windows-authored files', () => {
|
||||
const result = loadTxt(ctx(utf8('第一章 起点\r\n正文\r\n\r\n第二章 风起\r\n正文'), 'b.txt'));
|
||||
expect(result.chapterCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CBZ loading', () => {
|
||||
it('orders pages naturally and emits one fixed-layout section per page', async () => {
|
||||
const bytes = await makeCbz([
|
||||
{ name: '010.png' },
|
||||
{ name: '002.png' },
|
||||
{ name: '001.png' },
|
||||
]);
|
||||
const { doc } = await loadBook(ctx(bytes, 'comic.cbz'));
|
||||
expect(doc.layout).toBe('fixed');
|
||||
expect(doc.sections.map((section) => section.id)).toEqual(['001.png', '002.png', '010.png']);
|
||||
expect(doc.sections[0]!.image?.mediaType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('ignores archive bookkeeping files that are not pages', async () => {
|
||||
const bytes = await makeCbz([
|
||||
{ name: '001.png' },
|
||||
{ name: '__MACOSX/._001.png' },
|
||||
{ name: 'ComicInfo.xml' as string },
|
||||
{ name: 'Thumbs.db' as string },
|
||||
]);
|
||||
const { doc } = await loadBook(ctx(bytes, 'comic.cbz'));
|
||||
// The XML and Thumbs.db have no image extension, so only the real page and
|
||||
// the AppleDouble sidecar could leak through; the sidecar is filtered.
|
||||
expect(doc.sections.map((section) => section.id)).toEqual(['001.png']);
|
||||
});
|
||||
|
||||
it('detects a directory comic from the manifest page list', async () => {
|
||||
const pages = ['a/002.jpg', 'a/001.jpg'];
|
||||
const { doc } = await loadBook(ctx(new Uint8Array(), 'loose'), {
|
||||
fileName: 'loose',
|
||||
comicPages: pages,
|
||||
resolvePage: async () => PNG_1X1,
|
||||
});
|
||||
expect(doc.format).toBe('comic-dir');
|
||||
expect(doc.sections.map((section) => section.id)).toEqual(['a/001.jpg', 'a/002.jpg']);
|
||||
});
|
||||
|
||||
it('labels pages by index when the file names are meaningless', async () => {
|
||||
const bytes = await makeCbz([{ name: 'a1b2c3.png' }, { name: 'd4e5f6.png' }]);
|
||||
const { doc } = await loadBook(ctx(bytes, 'comic.cbz'));
|
||||
expect(doc.sections[0]!.label).toContain('第 1 页');
|
||||
expect(doc.sections[0]!.label).toContain('a1b2c3');
|
||||
});
|
||||
|
||||
it('refuses an empty comic directory instead of opening a blank book', async () => {
|
||||
await expect(
|
||||
loadBook(ctx(new Uint8Array(), 'loose'), { fileName: 'loose', comicPages: [] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PDF loading', () => {
|
||||
it('reports a single fixed-layout section so navigation stays uniform', async () => {
|
||||
const { doc } = await loadBook(ctx(utf8('%PDF-1.7 fake'), 'doc.pdf'));
|
||||
expect(doc.format).toBe('pdf');
|
||||
expect(doc.layout).toBe('fixed');
|
||||
expect(doc.sections).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown formats', () => {
|
||||
it('throws a message the UI can show', async () => {
|
||||
await expect(loadBook(ctx(utf8('x'), 'mystery.xyz'))).rejects.toThrow(/不支持的格式/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createStores } from '../../src/store/idb.ts';
|
||||
import { ApiError, errorForStatus } from '../../src/api/errors.ts';
|
||||
import type { KeyValueStore, BlobStore, Platform, HttpRequest, HttpResponse } from '../../src/core/platform.ts';
|
||||
|
||||
/** In-memory key/value store, matching the interface the client depends on. */
|
||||
export class MemoryKv implements KeyValueStore {
|
||||
private readonly map = new Map<string, string>();
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.map.has(key) ? this.map.get(key)! : null;
|
||||
}
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
this.map.set(key, value);
|
||||
}
|
||||
async remove(key: string): Promise<void> {
|
||||
this.map.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export class MemoryBlobs implements BlobStore {
|
||||
private readonly map = new Map<string, Uint8Array>();
|
||||
async get(key: string): Promise<Uint8Array | null> {
|
||||
return this.map.get(key) ?? null;
|
||||
}
|
||||
async put(key: string, bytes: Uint8Array): Promise<void> {
|
||||
this.map.set(key, bytes);
|
||||
}
|
||||
async remove(key: string): Promise<void> {
|
||||
this.map.delete(key);
|
||||
}
|
||||
async list(): Promise<string[]> {
|
||||
return [...this.map.keys()];
|
||||
}
|
||||
async usage(): Promise<number> {
|
||||
let total = 0;
|
||||
for (const bytes of this.map.values()) total += bytes.byteLength;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RecordedRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export type Responder = (request: RecordedRequest) => HttpResponse | Promise<HttpResponse>;
|
||||
|
||||
/** Sentinel for "the server answered 200 with a body that is not JSON". */
|
||||
export const NON_JSON_BODY = Symbol('non-json-body');
|
||||
|
||||
/**
|
||||
* A transport double that records every request.
|
||||
*
|
||||
* Used to assert on the *shape* of client-server traffic — that a refresh is
|
||||
* de-duplicated, that a stale offline batch is still sent — which is where the
|
||||
* subtle bugs in sync live, and which the server's own tests cannot see.
|
||||
*/
|
||||
export class FakeTransport {
|
||||
readonly requests: RecordedRequest[] = [];
|
||||
private responder: Responder = () => ({ status: 200, headers: {}, json: {} });
|
||||
|
||||
respondWith(responder: Responder): void {
|
||||
this.responder = responder;
|
||||
}
|
||||
|
||||
json(payload: unknown, status = 200): void {
|
||||
this.respondWith(() => ({ status, headers: {}, json: payload }));
|
||||
}
|
||||
|
||||
/** A 200 whose body is not JSON, which is what a proxy or portal returns. */
|
||||
nonJson(status = 200): void {
|
||||
this.respondWith(() => {
|
||||
throw new ApiError('offline', 'server returned a non-JSON response', 'BAD_GATEWAY', status);
|
||||
});
|
||||
}
|
||||
|
||||
failWith(error: Error): void {
|
||||
this.respondWith(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors `FetchTransport`'s contract: a non-2xx response is a thrown
|
||||
* `ApiError`, and a thrown `TypeError` becomes an `offline` error.
|
||||
*
|
||||
* Reproducing that normalisation is not incidental. The client's retry, refresh
|
||||
* and offline logic is all keyed off `ApiError.kind`, so a double that hands
|
||||
* back raw failure responses would let all of it go untested.
|
||||
*/
|
||||
async send(request: HttpRequest): Promise<HttpResponse> {
|
||||
const recorded: RecordedRequest = {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
...(request.body !== undefined ? { body: request.body } : {}),
|
||||
};
|
||||
this.requests.push(recorded);
|
||||
|
||||
let response: HttpResponse;
|
||||
try {
|
||||
response = await this.responder(recorded);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
throw new ApiError('offline', err instanceof Error ? err.message : 'network error');
|
||||
}
|
||||
|
||||
if (response.status >= 200 && response.status < 300) return response;
|
||||
|
||||
const body = response.json as { error?: { code?: string; message?: string } } | undefined;
|
||||
const code = body?.error?.code ?? '';
|
||||
const message = body?.error?.message ?? `request failed with status ${response.status}`;
|
||||
throw new ApiError(errorForStatus(response.status), message, code, response.status);
|
||||
}
|
||||
|
||||
countMatching(predicate: (request: RecordedRequest) => boolean): number {
|
||||
return this.requests.filter(predicate).length;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestPlatform extends Platform {
|
||||
kv: MemoryKv;
|
||||
blobs: MemoryBlobs;
|
||||
setOnline(next: boolean): void;
|
||||
}
|
||||
|
||||
export function makePlatform(
|
||||
transport: FakeTransport,
|
||||
kv = new MemoryKv(),
|
||||
blobs = new MemoryBlobs(),
|
||||
): TestPlatform {
|
||||
const listeners = new Set<(state: 'online' | 'offline') => void>();
|
||||
let online = true;
|
||||
return {
|
||||
name: 'web',
|
||||
deviceLabel: 'test-device',
|
||||
transport,
|
||||
kv,
|
||||
blobs,
|
||||
async connectivity() {
|
||||
return online ? 'online' : 'offline';
|
||||
},
|
||||
onConnectivityChange(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
setOnline(next: boolean) {
|
||||
online = next;
|
||||
for (const listener of listeners) listener(next ? 'online' : 'offline');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { createStores };
|
||||
@@ -0,0 +1,184 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
/**
|
||||
* Fixture builders.
|
||||
*
|
||||
* Books are constructed in code rather than committed as binaries: a committed
|
||||
* EPUB is an opaque blob nobody reviews, while these make the exact structure
|
||||
* under test readable — which matters when the failing assertion is about spine
|
||||
* order or a percent-encoded href.
|
||||
*/
|
||||
|
||||
export async function makeEpub(options: {
|
||||
chapters: Array<{ id: string; href: string; title: string; extra?: string }>;
|
||||
opfPath?: string;
|
||||
styles?: Array<{ path: string; css: string }>;
|
||||
toc?: Array<{ href: string; label: string }>;
|
||||
ncx?: Array<{ src: string; label: string }>;
|
||||
direction?: 'ltr' | 'rtl';
|
||||
includeIdentifier?: boolean;
|
||||
cover?: { href: string; bytes: Uint8Array; mediaType: string };
|
||||
/**
|
||||
* Explicit spine idref order. Omitted means "same order as `chapters`".
|
||||
*
|
||||
* Exists so a test can assert that reading order comes from the spine rather
|
||||
* than from the manifest or the archive, which is the mistake a naive loader
|
||||
* makes on roughly every second real-world EPUB.
|
||||
*/
|
||||
spineOrder?: string[];
|
||||
}): Promise<Uint8Array> {
|
||||
const zip = new JSZip();
|
||||
const opfPath = options.opfPath ?? 'OEBPS/content.opf';
|
||||
const baseDir = opfPath.includes('/') ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : '';
|
||||
|
||||
zip.file('mimetype', 'application/epub+zip');
|
||||
zip.file(
|
||||
'META-INF/container.xml',
|
||||
`<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles><rootfile full-path="${opfPath}" media-type="application/oebps-package+xml"/></rootfiles>
|
||||
</container>`,
|
||||
);
|
||||
|
||||
const manifestItems: string[] = options.chapters.map(
|
||||
(chapter) => `<item id="${chapter.id}" href="${chapter.href}" media-type="application/xhtml+xml"/>`,
|
||||
);
|
||||
const spineItems = (options.spineOrder ?? options.chapters.map((chapter) => chapter.id)).map(
|
||||
(id) => `<itemref idref="${id}"/>`,
|
||||
);
|
||||
|
||||
const properties: string[] = [];
|
||||
if (options.toc) {
|
||||
manifestItems.push('<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>');
|
||||
properties.push('nav');
|
||||
}
|
||||
if (options.ncx) {
|
||||
manifestItems.push('<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>');
|
||||
}
|
||||
if (options.styles) {
|
||||
for (const [index, style] of options.styles.entries()) {
|
||||
manifestItems.push(`<item id="css${index}" href="${style.path}" media-type="text/css"/>`);
|
||||
}
|
||||
}
|
||||
if (options.cover) {
|
||||
manifestItems.push(
|
||||
`<item id="cover-img" href="${options.cover.href}" media-type="${options.cover.mediaType}" properties="cover-image"/>`,
|
||||
);
|
||||
manifestItems.push('<item id="cover-meta" href="cover.xhtml" media-type="application/xhtml+xml"/>');
|
||||
}
|
||||
|
||||
zip.file(
|
||||
opfPath,
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid"${options.direction ? ` page-progression-direction="${options.direction}"` : ''}>
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>测试书</dc:title>
|
||||
<dc:creator>某人</dc:creator>
|
||||
${options.includeIdentifier === false ? '' : '<dc:identifier id="bookid">urn:uuid:test-1234</dc:identifier>'}
|
||||
${options.cover ? '<meta name="cover" content="cover-img"/>' : ''}
|
||||
</metadata>
|
||||
<manifest>
|
||||
${manifestItems.join('\n ')}
|
||||
</manifest>
|
||||
<spine${options.ncx ? ' toc="ncx"' : ''}>
|
||||
${spineItems.join('\n ')}
|
||||
</spine>
|
||||
</package>`,
|
||||
);
|
||||
|
||||
for (const chapter of options.chapters) {
|
||||
zip.file(
|
||||
`${baseDir}${chapter.href}`,
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>${chapter.title}</title></head>
|
||||
<body><h1>${chapter.title}</h1><p>正文内容</p>${chapter.extra ?? ''}</body></html>`,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.styles) {
|
||||
for (const style of options.styles) zip.file(`${baseDir}${style.path}`, style.css);
|
||||
}
|
||||
|
||||
if (options.toc) {
|
||||
zip.file(
|
||||
`${baseDir}nav.xhtml`,
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><body>
|
||||
<nav epub:type="toc"><ol>
|
||||
${options.toc.map((entry) => `<li><a href="${entry.href}">${entry.label}</a></li>`).join('\n')}
|
||||
</ol></nav></body></html>`,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.ncx) {
|
||||
zip.file(
|
||||
`${baseDir}toc.ncx`,
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1"><navMap>
|
||||
${options.ncx
|
||||
.map(
|
||||
(entry) => `<navPoint id="np-${entry.src}"><navLabel><text>${entry.label}</text></navLabel><content src="${entry.src}"/></navPoint>`,
|
||||
)
|
||||
.join('\n')}
|
||||
</navMap></ncx>`,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.cover) {
|
||||
zip.file(`${baseDir}${options.cover.href}`, options.cover.bytes);
|
||||
zip.file(
|
||||
`${baseDir}cover.xhtml`,
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"><body>
|
||||
<div><img src="${options.cover.href}" alt="cover"/></div>
|
||||
</body></html>`,
|
||||
);
|
||||
}
|
||||
|
||||
return zip.generateAsync({ type: 'uint8array' });
|
||||
}
|
||||
|
||||
export async function makeCbz(pages: Array<{ name: string; bytes?: Uint8Array }>): Promise<Uint8Array> {
|
||||
const zip = new JSZip();
|
||||
for (const page of pages) {
|
||||
zip.file(page.name, page.bytes ?? PNG_1X1);
|
||||
}
|
||||
return zip.generateAsync({ type: 'uint8array' });
|
||||
}
|
||||
|
||||
/** A minimal valid 1x1 PNG, so image bytes are real rather than filler. */
|
||||
export const PNG_1X1 = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||
0x42, 0x60, 0x82,
|
||||
]);
|
||||
|
||||
|
||||
/**
|
||||
* GB18030 byte sequences for the detection tests.
|
||||
*
|
||||
* Hard-coded rather than produced with `Buffer.from(text, 'gb18030')` because
|
||||
* Node's small-ICU build can *decode* GB18030 but not encode it. The bytes below
|
||||
* are the canonical GBK/GB18030 encodings, which is what makes them a real test
|
||||
* of the fallback path rather than of the test helper.
|
||||
*/
|
||||
export const GB18030_SAMPLES: Record<string, number[]> = {
|
||||
// 第一章 起始
|
||||
'第一章 起始': [0xb5, 0xda, 0xd2, 0xbb, 0xd5, 0xc2, 0x20, 0xc6, 0xf0, 0xca, 0xbc],
|
||||
// 测试
|
||||
测试: [0xb2, 0xe2, 0xca, 0xd4],
|
||||
// 中文内容
|
||||
中文内容: [0xd6, 0xd0, 0xce, 0xc4, 0xc4, 0xda, 0xc8, 0xdd],
|
||||
};
|
||||
|
||||
export function gb18030Bytes(text: string): Uint8Array {
|
||||
const bytes = GB18030_SAMPLES[text];
|
||||
if (!bytes) throw new Error(`no GB18030 fixture for ${text}`);
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
export function utf8(text: string): Uint8Array {
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bookPercentage, formatLocator, parseLocator } from '../src/ui/locator.ts';
|
||||
import type { BookDoc, Section } from '../src/formats/types.ts';
|
||||
|
||||
function doc(sections: Array<Partial<Section>>): BookDoc {
|
||||
return {
|
||||
format: 'epub',
|
||||
layout: 'reflowable',
|
||||
direction: 'ltr',
|
||||
sections: sections.map((section, index) => ({
|
||||
id: section.id ?? `s${index}`,
|
||||
label: section.label ?? `第 ${index} 章`,
|
||||
html: section.html ?? '<p>' + 'x'.repeat(500) + '</p>',
|
||||
depth: 0,
|
||||
})),
|
||||
toc: [],
|
||||
styles: [],
|
||||
resources: new Map(),
|
||||
orderedByBook: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe('locator format', () => {
|
||||
it('round-trips a position', () => {
|
||||
const book = doc([{ id: 'ch1' }, { id: 'ch2' }]);
|
||||
const locator = formatLocator('ch2', 0.42);
|
||||
const parsed = parseLocator(locator, book);
|
||||
expect(parsed?.sectionId).toBe('ch2');
|
||||
expect(parsed?.offset).toBeCloseTo(0.42, 3);
|
||||
});
|
||||
|
||||
it('clamps an out-of-range offset instead of trusting it', () => {
|
||||
const book = doc([{ id: 'ch1' }]);
|
||||
expect(parseLocator(formatLocator('ch1', 5), book)?.offset).toBe(1);
|
||||
expect(parseLocator(formatLocator('ch1', -3), book)?.offset).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a locator for a section that no longer exists', () => {
|
||||
// The book changed on disk under the reader. Returning null lets the reader
|
||||
// say so instead of silently pretending page one is where they left off.
|
||||
const book = doc([{ id: 'ch1' }]);
|
||||
expect(parseLocator('gone:0.5', book)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not treat an EPUB CFI from another client as a position it understands', () => {
|
||||
// The server stores locators opaquely, so a CFI written by a different
|
||||
// client can arrive here. It must be refused, not misparsed as a section id.
|
||||
const book = doc([{ id: 'ch1' }]);
|
||||
expect(parseLocator('epubcfi(/6/4!/4/2)', book)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a bare section id as the start of that section', () => {
|
||||
const book = doc([{ id: 's0' }, { id: 'ch2' }]);
|
||||
const parsed = parseLocator('ch2', book);
|
||||
expect(parsed?.offset).toBe(0);
|
||||
expect(parsed?.percentage).toBeCloseTo(0.5, 3);
|
||||
});
|
||||
|
||||
it('rejects an empty locator', () => {
|
||||
expect(parseLocator('', doc([{ id: 'a' }]))).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a non-numeric offset as the start of the section', () => {
|
||||
// A corrupt offset must not cost the reader the *chapter* as well: the
|
||||
// section id is still good information.
|
||||
const book = doc([{ id: 'ch1' }]);
|
||||
expect(parseLocator('r1:not-a-number:ch1', book)?.offset).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a section id that itself contains a colon', () => {
|
||||
// Windows-authored EPUBs produce hrefs like `OEBPS\text:ch1.xhtml`, and a
|
||||
// naive split would truncate the id and lose the position entirely.
|
||||
const book = doc([{ id: 'OEBPS/text:ch1.xhtml' }]);
|
||||
const parsed = parseLocator(formatLocator('OEBPS/text:ch1.xhtml', 0.5), book);
|
||||
expect(parsed?.sectionId).toBe('OEBPS/text:ch1.xhtml');
|
||||
expect(parsed?.offset).toBeCloseTo(0.5, 3);
|
||||
});
|
||||
|
||||
it('refuses a versioned locator with no section id', () => {
|
||||
const book = doc([{ id: 'ch1' }]);
|
||||
expect(parseLocator('r1:0.5:', book)).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a legacy unversioned locator so an upgrade does not lose the place', () => {
|
||||
const book = doc([{ id: 'ch1' }, { id: 'ch2' }]);
|
||||
const parsed = parseLocator('ch2:0.25', book);
|
||||
expect(parsed?.sectionId).toBe('ch2');
|
||||
expect(parsed?.offset).toBeCloseTo(0.25, 3);
|
||||
});
|
||||
|
||||
it('treats a bare page number as that page', () => {
|
||||
const book = doc([{ id: 'p1' }, { id: 'p2' }]);
|
||||
expect(parseLocator('1', book)?.sectionId).toBe('p2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('book percentage', () => {
|
||||
it('is 0 at the start and approaches 1 at the end', () => {
|
||||
const book = doc([{ id: 'a' }, { id: 'b' }, { id: 'c' }]);
|
||||
expect(bookPercentage(book, 0, 0)).toBe(0);
|
||||
expect(bookPercentage(book, 2, 1)).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it('weights sections by length so an uneven book does not lie about progress', () => {
|
||||
// Three short chapters and one enormous one. At the start of the big chapter
|
||||
// a naive index/count calculation would claim two thirds done.
|
||||
const short = { html: '<p>' + 'x'.repeat(400) + '</p>' };
|
||||
const book = doc([
|
||||
short,
|
||||
short,
|
||||
short,
|
||||
{ html: '<p>' + 'x'.repeat(40_000) + '</p>' },
|
||||
]);
|
||||
const atStartOfBig = bookPercentage(book, 3, 0);
|
||||
const naive = 3 / 4;
|
||||
expect(atStartOfBig).toBeLessThan(naive);
|
||||
expect(atStartOfBig).toBeLessThan(0.2);
|
||||
expect(bookPercentage(book, 3, 1)).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it('gives every page equal weight in a fixed-layout book', () => {
|
||||
const book: BookDoc = { ...doc([{ id: 'p1' }, { id: 'p2' }, { id: 'p3' }, { id: 'p4' }]), layout: 'fixed' };
|
||||
expect(bookPercentage(book, 2, 0)).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
|
||||
it('returns 0 for a book with no sections rather than NaN', () => {
|
||||
expect(bookPercentage(doc([]), 0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('is monotonic across a full traversal', () => {
|
||||
const book = doc([{ id: 'a' }, { id: 'b', html: '<p>' + 'y'.repeat(5000) + '</p>' }]);
|
||||
let previous = -1;
|
||||
for (let index = 0; index < book.sections.length; index += 1) {
|
||||
for (const within of [0, 0.5, 1]) {
|
||||
const value = bookPercentage(book, index, within);
|
||||
expect(value).toBeGreaterThanOrEqual(previous - 1e-9);
|
||||
previous = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { naturalCompare, sortByName } from '../src/formats/natural-sort.ts';
|
||||
|
||||
/**
|
||||
* Getting page order wrong silently scrambles a comic, so these cases are
|
||||
* regression tests for real naming conventions rather than synthetic examples.
|
||||
*/
|
||||
|
||||
describe('naturalCompare', () => {
|
||||
it('sorts numeric runs by value, not lexicographically', () => {
|
||||
const names = ['page10.jpg', 'page2.jpg', 'page1.jpg', 'page100.jpg'];
|
||||
expect(sortByName(names, (name) => name)).toEqual([
|
||||
'page1.jpg',
|
||||
'page2.jpg',
|
||||
'page10.jpg',
|
||||
'page100.jpg',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts CJK chapter names with numeric suffixes correctly', () => {
|
||||
const names = ['第10话', '第2话', '第1话'];
|
||||
expect(sortByName(names, (name) => name)).toEqual(['第1话', '第2话', '第10话']);
|
||||
});
|
||||
|
||||
it('orders names case-insensitively first, then deterministically', () => {
|
||||
// `localeCompare` with base sensitivity reports these as equal, so the
|
||||
// tie-break decides. All that matters is that the result is *stable*: an
|
||||
// unstable comparator would let the browser shuffle an archive's pages
|
||||
// between loads, which is the bug this whole module exists to prevent.
|
||||
const first = naturalCompare('chapter', 'Chapter');
|
||||
const second = naturalCompare('chapter', 'Chapter');
|
||||
expect(first).toBe(second);
|
||||
expect(naturalCompare('apple', 'banana')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('does not lose precision on a long zero-padded run', () => {
|
||||
// Parsing `000000000000000001` as a float would give 1e-18 and break the
|
||||
// comparison; it must be treated as the integer it is.
|
||||
expect(naturalCompare('000000000000000001.jpg', '000000000000000002.jpg')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('orders a prefix before the longer name that starts with it', () => {
|
||||
expect(naturalCompare('a', 'a1')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('treats equal names as equal so sort stays stable', () => {
|
||||
expect(naturalCompare('x1', 'x1')).toBe(0);
|
||||
});
|
||||
|
||||
it('handles mixed separators used by scan tools', () => {
|
||||
const names = ['c_001.png', 'c_002.png', 'c_010.png', 'c_003.png'];
|
||||
expect(sortByName(names, (name) => name)).toEqual([
|
||||
'c_001.png',
|
||||
'c_002.png',
|
||||
'c_003.png',
|
||||
'c_010.png',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ReaderView } from '../src/ui/reader-view.ts';
|
||||
import type { BookDoc } from '../src/formats/types.ts';
|
||||
|
||||
/**
|
||||
* Navigation behaviour.
|
||||
*
|
||||
* jsdom has no layout engine, so scroll extents are all zero and the *measurement*
|
||||
* half of pagination cannot be exercised here — that is what the device is for.
|
||||
* What jsdom can test, and what this file covers, is the part that is pure logic:
|
||||
* which section a navigation command lands on, and how a position is reported.
|
||||
* Those are the parts that break silently and the parts a refactor is most likely
|
||||
* to get wrong.
|
||||
*/
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom does not implement object URLs for Blob. The view uses them for covers
|
||||
// and comic pages, neither of which is under test here.
|
||||
if (!URL.createObjectURL) {
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: () => 'blob:stub', writable: true });
|
||||
}
|
||||
if (!URL.revokeObjectURL) {
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: () => undefined, writable: true });
|
||||
}
|
||||
globalThis.ResizeObserver ??= class {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
};
|
||||
});
|
||||
|
||||
function reflowableDoc(sectionCount: number): BookDoc {
|
||||
return {
|
||||
format: 'epub',
|
||||
layout: 'reflowable',
|
||||
direction: 'ltr',
|
||||
sections: Array.from({ length: sectionCount }, (_value, index) => ({
|
||||
id: `ch${index}.xhtml`,
|
||||
label: `第 ${index + 1} 章`,
|
||||
html: '<p>正文</p>',
|
||||
depth: 0,
|
||||
})),
|
||||
toc: Array.from({ length: sectionCount }, (_value, index) => ({
|
||||
id: `ch${index}.xhtml`,
|
||||
label: `第 ${index + 1} 章`,
|
||||
depth: 0,
|
||||
})),
|
||||
styles: [],
|
||||
resources: new Map(),
|
||||
orderedByBook: true,
|
||||
};
|
||||
}
|
||||
|
||||
function fixedDoc(pageCount: number): BookDoc {
|
||||
return {
|
||||
format: 'cbz',
|
||||
layout: 'fixed',
|
||||
direction: 'ltr',
|
||||
sections: Array.from({ length: pageCount }, (_value, index) => ({
|
||||
id: `p${index}.png`,
|
||||
label: `第 ${index + 1} 页`,
|
||||
image: { mediaType: 'image/png', bytes: new Uint8Array([1, 2, 3]) },
|
||||
depth: 0,
|
||||
})),
|
||||
toc: [],
|
||||
styles: [],
|
||||
resources: new Map(),
|
||||
orderedByBook: true,
|
||||
};
|
||||
}
|
||||
|
||||
function make(container: HTMLDivElement, doc: BookDoc): ReaderView {
|
||||
return new ReaderView({ container, doc });
|
||||
}
|
||||
|
||||
describe('ReaderView navigation', () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
});
|
||||
|
||||
it('opens the first section by default', async () => {
|
||||
const view = make(container, reflowableDoc(3));
|
||||
await view.open(0, 0);
|
||||
expect(view.currentSectionIndex()).toBe(0);
|
||||
expect(view.position().sectionId).toBe('ch0.xhtml');
|
||||
});
|
||||
|
||||
it('reports a locator that round-trips back to the same section', async () => {
|
||||
const view = make(container, reflowableDoc(4));
|
||||
await view.open(2, 0.5);
|
||||
const position = view.position();
|
||||
expect(position.locator.startsWith('r1:')).toBe(true);
|
||||
expect(position.locator.endsWith('ch2.xhtml')).toBe(true);
|
||||
});
|
||||
|
||||
it('advances section by section when there is nowhere to scroll', async () => {
|
||||
// In jsdom every scroll extent is zero, so `next()` must fall through to the
|
||||
// section advance rather than reporting success forever on a dead scroll.
|
||||
const view = make(container, reflowableDoc(3));
|
||||
await view.open(0, 0);
|
||||
await view.next();
|
||||
expect(view.currentSectionIndex()).toBe(1);
|
||||
await view.next();
|
||||
expect(view.currentSectionIndex()).toBe(2);
|
||||
});
|
||||
|
||||
it('stops at the last section instead of wrapping around', async () => {
|
||||
const view = make(container, reflowableDoc(2));
|
||||
await view.open(1, 0);
|
||||
const moved = await view.next();
|
||||
expect(moved).toBe(false);
|
||||
expect(view.currentSectionIndex()).toBe(1);
|
||||
});
|
||||
|
||||
it('stops at the first section going backwards', async () => {
|
||||
const view = make(container, reflowableDoc(2));
|
||||
await view.open(0, 0);
|
||||
const moved = await view.previous();
|
||||
expect(moved).toBe(false);
|
||||
expect(view.currentSectionIndex()).toBe(0);
|
||||
});
|
||||
|
||||
it('reports the chapter title from the TOC label', async () => {
|
||||
const view = make(container, reflowableDoc(3));
|
||||
await view.open(1, 0);
|
||||
expect(view.position().chapterTitle).toBe('第 2 章');
|
||||
});
|
||||
|
||||
it('exposes the TOC for the navigation panel', () => {
|
||||
const view = make(container, reflowableDoc(3));
|
||||
expect(view.chapterLabels().map((entry) => entry.label)).toEqual(['第 1 章', '第 2 章', '第 3 章']);
|
||||
});
|
||||
|
||||
it('notifies a chapter change so the chrome can update', async () => {
|
||||
const seen: string[] = [];
|
||||
const doc = reflowableDoc(3);
|
||||
const view = new ReaderView({
|
||||
container,
|
||||
doc,
|
||||
onChapterChange: (_index, section) => seen.push(section.label),
|
||||
});
|
||||
await view.open(0, 0);
|
||||
await view.open(2, 0);
|
||||
expect(seen).toEqual(['第 1 章', '第 3 章']);
|
||||
});
|
||||
|
||||
it('seeks to a fraction of the book', async () => {
|
||||
const view = make(container, reflowableDoc(10));
|
||||
await view.seekPercentage(0.55);
|
||||
expect(view.currentSectionIndex()).toBe(5);
|
||||
});
|
||||
|
||||
it('clamps a seek beyond the ends', async () => {
|
||||
const view = make(container, reflowableDoc(4));
|
||||
await view.seekPercentage(5);
|
||||
expect(view.currentSectionIndex()).toBe(3);
|
||||
await view.seekPercentage(-1);
|
||||
expect(view.currentSectionIndex()).toBe(0);
|
||||
});
|
||||
|
||||
it('disposes without leaving the host in the container', () => {
|
||||
const view = make(container, reflowableDoc(2));
|
||||
expect(container.querySelector('book-content')).not.toBeNull();
|
||||
view.dispose();
|
||||
expect(container.querySelector('book-content')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReaderView fixed layout', () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
});
|
||||
|
||||
it('treats a page turn as a whole section', async () => {
|
||||
const view = make(container, fixedDoc(3));
|
||||
await view.open(0, 0);
|
||||
await view.next();
|
||||
expect(view.currentSectionIndex()).toBe(1);
|
||||
await view.previous();
|
||||
expect(view.currentSectionIndex()).toBe(0);
|
||||
});
|
||||
|
||||
it('reports no sub-page offset, because a page is atomic', async () => {
|
||||
const view = make(container, fixedDoc(3));
|
||||
await view.open(1, 0.7);
|
||||
expect(view.position().within).toBe(0);
|
||||
});
|
||||
|
||||
it('renders the page image into the shadow root', async () => {
|
||||
// The image lives inside the shadow root on purpose: it keeps a page's markup
|
||||
// from reaching the app chrome and vice versa. A test that queried the light
|
||||
// DOM would pass on a regression that leaked the book into the shell.
|
||||
const view = make(container, fixedDoc(2));
|
||||
await view.open(0, 0);
|
||||
const host = container.querySelector('book-content') as HTMLElement;
|
||||
expect(host.shadowRoot?.querySelector('.fixed-page img')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('leaves no book markup in the light DOM', async () => {
|
||||
const view = make(container, fixedDoc(2));
|
||||
await view.open(0, 0);
|
||||
expect(container.querySelector('.fixed-page')).toBeNull();
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('restores a locator pointing at a page by id', async () => {
|
||||
const view = make(container, fixedDoc(5));
|
||||
const restored = await view.openLocator('r1:0.0000:p3.png');
|
||||
expect(restored).toBe(true);
|
||||
expect(view.currentSectionIndex()).toBe(3);
|
||||
});
|
||||
|
||||
it('refuses a locator for a page that is no longer in the book', async () => {
|
||||
const view = make(container, fixedDoc(5));
|
||||
const restored = await view.openLocator('r1:0.0000:gone.png');
|
||||
expect(restored).toBe(false);
|
||||
expect(view.currentSectionIndex()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReaderView settings', () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
});
|
||||
|
||||
it('applies the theme to the document root so the shell matches the book', () => {
|
||||
const view = make(container, reflowableDoc(1));
|
||||
view.applySettings({ theme: 'dark' });
|
||||
expect(document.documentElement.dataset['theme']).toBe('dark');
|
||||
});
|
||||
|
||||
it('toggles paged mode on the book host, not on the shell', () => {
|
||||
const view = make(container, reflowableDoc(1));
|
||||
view.applySettings({ mode: 'paged' });
|
||||
expect(container.querySelector('book-content')?.getAttribute('data-paginated')).toBe('true');
|
||||
view.applySettings({ mode: 'scroll' });
|
||||
expect(container.querySelector('book-content')?.getAttribute('data-paginated')).toBe('false');
|
||||
});
|
||||
|
||||
it('passes line-height through as a variable so `inherit` means untouched', () => {
|
||||
const view = make(container, reflowableDoc(1));
|
||||
view.applySettings({ lineHeight: 'inherit' });
|
||||
expect(container.style.getPropertyValue('--reader-line-height')).toBe('inherit');
|
||||
view.applySettings({ lineHeight: '1.8' });
|
||||
expect(container.style.getPropertyValue('--reader-line-height')).toBe('1.8');
|
||||
});
|
||||
|
||||
it('exposes the current settings for a settings panel to render from', () => {
|
||||
const view = make(container, reflowableDoc(1));
|
||||
view.applySettings({ fontScale: 1.4, theme: 'sepia' });
|
||||
expect(view.settingsSnapshot.fontScale).toBe(1.4);
|
||||
expect(view.settingsSnapshot.theme).toBe('sepia');
|
||||
});
|
||||
|
||||
it('does not mutate the caller-provided settings object', () => {
|
||||
const view = make(container, reflowableDoc(1));
|
||||
const patch = { fontScale: 1.4 };
|
||||
view.applySettings(patch);
|
||||
expect(patch).toEqual({ fontScale: 1.4 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReaderView resource handling', () => {
|
||||
it('releases object URLs on dispose so a long session does not leak', async () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const revoke = vi.fn();
|
||||
const original = URL.revokeObjectURL;
|
||||
URL.revokeObjectURL = revoke;
|
||||
try {
|
||||
const view = make(container, fixedDoc(3));
|
||||
await view.open(0, 0);
|
||||
await view.open(1, 0);
|
||||
view.dispose();
|
||||
expect(revoke).toHaveBeenCalled();
|
||||
} finally {
|
||||
URL.revokeObjectURL = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractBody, extractInlineStyles, sanitiseInjectedContent } from '../src/ui/shadow.ts';
|
||||
|
||||
/**
|
||||
* Sanitisation and style extraction.
|
||||
*
|
||||
* These run against a real DOM (jsdom) rather than a string, because the whole
|
||||
* point is to catch what the HTML parser produces — a string-level rewrite cannot
|
||||
* see the difference between a `<script>` in markup and one the parser moved.
|
||||
*
|
||||
* The security property being asserted: a book is data. It may describe how it
|
||||
* should look; it may not run code or reach the network.
|
||||
*/
|
||||
|
||||
function parse(html: string): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('sanitiseInjectedContent', () => {
|
||||
it('removes script elements', () => {
|
||||
const node = parse('<p>text</p><script>window.pwned = true</script>');
|
||||
sanitiseInjectedContent(node);
|
||||
expect(node.querySelector('script')).toBeNull();
|
||||
expect(node.textContent).toContain('text');
|
||||
});
|
||||
|
||||
it('strips inline event handlers', () => {
|
||||
// These survive an innerHTML round trip, so they must be removed explicitly
|
||||
// rather than trusted to the parser.
|
||||
const node = parse('<img src="reader-res:a.png" onerror="window.pwned=1"/>');
|
||||
sanitiseInjectedContent(node);
|
||||
expect(node.querySelector('img')?.getAttribute('onerror')).toBeNull();
|
||||
});
|
||||
|
||||
it('strips a javascript: href but keeps the link text', () => {
|
||||
const node = parse('<a href="javascript:alert(1)">click</a>');
|
||||
sanitiseInjectedContent(node);
|
||||
const anchor = node.querySelector('a');
|
||||
expect(anchor?.hasAttribute('href')).toBe(false);
|
||||
expect(anchor?.textContent).toBe('click');
|
||||
});
|
||||
|
||||
it('drops a remote src so a book cannot report a reading session', () => {
|
||||
const node = parse('<img src="https://tracker.example/pixel.gif"/>');
|
||||
sanitiseInjectedContent(node);
|
||||
expect(node.querySelector('img')?.hasAttribute('src')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the internal resource scheme and blob/data URLs', () => {
|
||||
const node = parse(
|
||||
'<img src="reader-res:OEBPS/a.png"/><img src="data:image/png;base64,AA"/><img src="blob:xyz"/>',
|
||||
);
|
||||
sanitiseInjectedContent(node);
|
||||
const sources = [...node.querySelectorAll('img')].map((img) => img.getAttribute('src'));
|
||||
expect(sources).toEqual(['reader-res:OEBPS/a.png', 'data:image/png;base64,AA', 'blob:xyz']);
|
||||
});
|
||||
|
||||
it('removes a base element and a meta refresh, which would navigate away', () => {
|
||||
const node = parse('<base href="https://evil.example/"/><meta http-equiv="refresh" content="0;url=https://evil.example"/>');
|
||||
sanitiseInjectedContent(node);
|
||||
expect(node.querySelector('base')).toBeNull();
|
||||
expect(node.querySelector('meta')).toBeNull();
|
||||
});
|
||||
|
||||
it('removes target so a footnote link cannot replace the reader', () => {
|
||||
const node = parse('<a href="reader-res:notes.xhtml" target="_blank">note</a>');
|
||||
sanitiseInjectedContent(node);
|
||||
expect(node.querySelector('a')?.hasAttribute('target')).toBe(false);
|
||||
expect(node.querySelector('a')?.getAttribute('href')).toBe('reader-res:notes.xhtml');
|
||||
});
|
||||
|
||||
it('leaves ordinary content untouched', () => {
|
||||
const node = parse('<p>正文</p><em>强调</em><ruby>漢<rt>かん</rt></ruby>');
|
||||
sanitiseInjectedContent(node);
|
||||
expect(node.textContent).toBe('正文强调漢かん');
|
||||
expect(node.querySelector('ruby')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBody', () => {
|
||||
it('returns only the body of a full XHTML document', () => {
|
||||
const html = '<html><head><title>t</title></head><body><p>内容</p></body></html>';
|
||||
expect(extractBody(html)).toBe('<p>内容</p>');
|
||||
});
|
||||
|
||||
it('returns the input unchanged when there is no body element', () => {
|
||||
expect(extractBody('<p>fragment</p>')).toBe('<p>fragment</p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractInlineStyles', () => {
|
||||
it('collects style blocks in document order', () => {
|
||||
const html = '<style>a{color:red}</style><p>x</p><style>b{color:blue}</style>';
|
||||
expect(extractInlineStyles(html)).toEqual(['a{color:red}', 'b{color:blue}']);
|
||||
});
|
||||
|
||||
it('returns an empty list when there are none', () => {
|
||||
expect(extractInlineStyles('<p>x</p>')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ReaderApi } from '../src/api/client.ts';
|
||||
import { ApiError } from '../src/api/errors.ts';
|
||||
import { SyncEngine } from '../src/core/sync.ts';
|
||||
import { OfflineStore, isOutboxEmpty } from '../src/store/offline.ts';
|
||||
import { FakeTransport, MemoryKv, makePlatform } from './helpers/env.ts';
|
||||
|
||||
const SESSION = {
|
||||
user: { id: 'u1', username: 'me', displayName: '我', role: 'member' as const, createdAt: 0 },
|
||||
accessToken: 'access-1',
|
||||
accessTokenExpiresAt: Date.now() + 3_600_000,
|
||||
refreshToken: 'refresh-1',
|
||||
refreshTokenExpiresAt: Date.now() + 86_400_000,
|
||||
};
|
||||
|
||||
function sessions() {
|
||||
const kv = new MemoryKv();
|
||||
return {
|
||||
kv,
|
||||
store: {
|
||||
async load() {
|
||||
const raw = await kv.get('s');
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
},
|
||||
async save(session: unknown) {
|
||||
await kv.set('s', JSON.stringify(session));
|
||||
},
|
||||
async clear() {
|
||||
await kv.remove('s');
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function build() {
|
||||
const transport = new FakeTransport();
|
||||
const platform = makePlatform(transport);
|
||||
const session = sessions();
|
||||
const api = new ReaderApi(platform, session.store);
|
||||
api.setBaseUrl('http://test.local');
|
||||
const offline = new OfflineStore(platform.kv);
|
||||
const engine = new SyncEngine(api, offline, platform);
|
||||
return { transport, platform, api, offline, engine };
|
||||
}
|
||||
|
||||
describe('ReaderApi', () => {
|
||||
it('attaches the bearer token once a session exists', async () => {
|
||||
const { api, transport } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
transport.json({ items: [], total: 0, page: 1, pageSize: 50 });
|
||||
await api.listBooks();
|
||||
const last = transport.requests.at(-1)!;
|
||||
expect(last.headers['authorization']).toBe('Bearer access-1');
|
||||
});
|
||||
|
||||
it('does not send a token to the public endpoints', async () => {
|
||||
const { api, transport } = build();
|
||||
transport.json({ name: 'reader', apiVersion: 1, registrationOpen: true, userCount: 0 });
|
||||
await api.instance();
|
||||
expect(transport.requests[0]!.headers['authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refreshes an expired token once and retries the original request', async () => {
|
||||
const { api, transport } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
|
||||
transport.respondWith((request) => {
|
||||
if (request.url.endsWith('/api/v1/auth/refresh')) {
|
||||
return { status: 200, headers: {}, json: { ...SESSION, accessToken: 'access-2', refreshToken: 'refresh-2' } };
|
||||
}
|
||||
if (request.headers['authorization'] === 'Bearer access-1') {
|
||||
return { status: 401, headers: {}, json: { error: { code: 'TOKEN_EXPIRED', message: 'expired' } } };
|
||||
}
|
||||
return { status: 200, headers: {}, json: { items: [], total: 0, page: 1, pageSize: 50 } };
|
||||
});
|
||||
|
||||
await api.listBooks();
|
||||
const refreshCalls = transport.countMatching((request) => request.url.endsWith('/auth/refresh'));
|
||||
expect(refreshCalls).toBe(1);
|
||||
expect(transport.requests.at(-1)!.headers['authorization']).toBe('Bearer access-2');
|
||||
});
|
||||
|
||||
it('does not refresh or retry when the token signature is invalid', async () => {
|
||||
// `TOKEN_INVALID` means refreshing cannot help. Retrying would burn the
|
||||
// single-use refresh chain on a request that can never succeed.
|
||||
const { api, transport } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
transport.json({ error: { code: 'TOKEN_INVALID', message: 'bad signature' } }, 401);
|
||||
|
||||
await expect(api.listBooks()).rejects.toMatchObject({ code: 'TOKEN_INVALID' });
|
||||
expect(transport.countMatching((request) => request.url.endsWith('/auth/refresh'))).toBe(0);
|
||||
});
|
||||
|
||||
it('de-duplicates concurrent refreshes so a rotation is not burned twice', async () => {
|
||||
// The server rotates refresh tokens (single-use). Three parallel requests
|
||||
// hitting an expired token must share one refresh, or the second and third
|
||||
// would present a token that was already consumed and sign the reader out.
|
||||
const { api, transport } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
|
||||
let refreshCount = 0;
|
||||
let releaseRefresh = (): void => undefined;
|
||||
const refreshGate = new Promise<void>((resolve) => {
|
||||
releaseRefresh = resolve;
|
||||
});
|
||||
|
||||
transport.respondWith(async (request) => {
|
||||
if (request.url.endsWith('/auth/refresh')) {
|
||||
refreshCount += 1;
|
||||
await refreshGate;
|
||||
return { status: 200, headers: {}, json: { ...SESSION, accessToken: 'access-2', refreshToken: 'refresh-2' } };
|
||||
}
|
||||
if (request.headers['authorization'] === 'Bearer access-1') {
|
||||
return { status: 401, headers: {}, json: { error: { code: 'TOKEN_EXPIRED', message: 'expired' } } };
|
||||
}
|
||||
return { status: 200, headers: {}, json: { items: [], total: 0, page: 1, pageSize: 50 } };
|
||||
});
|
||||
|
||||
const inFlight = Promise.all([api.listBooks(), api.listBooks(), api.listBooks()]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
releaseRefresh();
|
||||
await inFlight;
|
||||
expect(refreshCount).toBe(1);
|
||||
});
|
||||
|
||||
it('clears the session when the refresh token itself is rejected', async () => {
|
||||
const { api, transport } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
transport.respondWith((request) =>
|
||||
request.url.endsWith('/auth/refresh')
|
||||
? { status: 401, headers: {}, json: { error: { code: 'REFRESH_INVALID', message: 'used' } } }
|
||||
: { status: 401, headers: {}, json: { error: { code: 'TOKEN_EXPIRED', message: 'expired' } } },
|
||||
);
|
||||
await expect(api.listBooks()).rejects.toBeInstanceOf(ApiError);
|
||||
expect(api.currentSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('reports an unreachable server as an offline error, not a server error', async () => {
|
||||
// A rejected fetch is what a LAN address that is not reachable looks like,
|
||||
// and the whole three-state design keys off this being `offline` so the UI
|
||||
// can silently fall back to cached content instead of showing an error.
|
||||
const transport = new FakeTransport();
|
||||
transport.failWith(new TypeError('Failed to fetch'));
|
||||
const platform = makePlatform(transport);
|
||||
const session = sessions();
|
||||
const api = new ReaderApi(platform, session.store);
|
||||
api.setBaseUrl('http://test.local');
|
||||
await expect(api.instance()).rejects.toMatchObject({ kind: 'offline' });
|
||||
});
|
||||
|
||||
it('treats a non-JSON 200 as a connectivity problem rather than crashing', async () => {
|
||||
// A captive portal or a misconfigured reverse proxy answers 200 with HTML.
|
||||
// Parsing that as JSON would throw a SyntaxError and lose the offline path.
|
||||
const transport = new FakeTransport();
|
||||
transport.nonJson();
|
||||
const platform = makePlatform(transport);
|
||||
const api = new ReaderApi(platform, sessions().store);
|
||||
api.setBaseUrl('http://test.local');
|
||||
await expect(api.instance()).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OfflineStore', () => {
|
||||
it('keeps the newer progress and discards a stale write', async () => {
|
||||
const store = new OfflineStore(new MemoryKv());
|
||||
await store.setProgress({ bookId: 'b1', locator: 'r1:0.5:c1', percentage: 0.5, chapterTitle: 'c1', device: 'a', updatedAt: 2000 });
|
||||
await store.setProgress({ bookId: 'b1', locator: 'r1:0.1:c1', percentage: 0.1, chapterTitle: 'c1', device: 'a', updatedAt: 1000 });
|
||||
expect(store.current.progress['b1']!.percentage).toBe(0.5);
|
||||
});
|
||||
|
||||
it('does not let a stale pull overwrite newer local progress', async () => {
|
||||
const store = new OfflineStore(new MemoryKv());
|
||||
await store.setProgress({ bookId: 'b1', locator: 'r1:0.9:c1', percentage: 0.9, chapterTitle: 'c1', device: 'a', updatedAt: 5000 });
|
||||
await store.applyPulled({
|
||||
serverTime: 100,
|
||||
progress: [{ bookId: 'b1', locator: 'r1:0.2:c1', percentage: 0.2, chapterTitle: 'c1', device: 'b', updatedAt: 1000 }],
|
||||
notes: [],
|
||||
});
|
||||
expect(store.current.progress['b1']!.percentage).toBe(0.9);
|
||||
});
|
||||
|
||||
it('records a note deletion as a pending tombstone, not a lost write', async () => {
|
||||
const store = new OfflineStore(new MemoryKv());
|
||||
await store.upsertNotes([
|
||||
{ id: 'n1', bookId: 'b1', type: 'highlight', locator: 'x', text: 't', comment: '', color: '', updatedAt: 1 },
|
||||
]);
|
||||
await store.deleteNote('n1');
|
||||
expect(store.notesFor('b1')).toHaveLength(0);
|
||||
expect(store.current.pendingNoteDeletes).toContain('n1');
|
||||
});
|
||||
|
||||
it('clears a pending tombstone when the same note is written again', async () => {
|
||||
const store = new OfflineStore(new MemoryKv());
|
||||
await store.deleteNote('n1');
|
||||
await store.upsertNotes([
|
||||
{ id: 'n1', bookId: 'b1', type: 'note', locator: 'x', text: 't', comment: '', color: '', updatedAt: 50 },
|
||||
]);
|
||||
expect(store.current.pendingNoteDeletes).not.toContain('n1');
|
||||
});
|
||||
|
||||
it('recovers from a corrupt snapshot instead of failing to start', async () => {
|
||||
const kv = new MemoryKv();
|
||||
await kv.set('reader.offline.v1', '{not json');
|
||||
const store = new OfflineStore(kv);
|
||||
const snapshot = await store.load();
|
||||
expect(snapshot.books).toEqual({});
|
||||
expect(snapshot.serverTime).toBe(0);
|
||||
});
|
||||
|
||||
it('serialises concurrent writes so a fast reader does not lose a position', async () => {
|
||||
const store = new OfflineStore(new MemoryKv());
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, (_value, index) =>
|
||||
store.setProgress({
|
||||
bookId: `b${index}`,
|
||||
locator: `r1:0:s${index}`,
|
||||
percentage: index / 20,
|
||||
chapterTitle: 'x',
|
||||
device: 'a',
|
||||
updatedAt: 1000 + index,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await store.flush();
|
||||
expect(Object.keys(store.current.progress)).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyncEngine', () => {
|
||||
it('pushes the outbox and then pulls only the delta', async () => {
|
||||
const { api, transport, offline, engine, platform } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
await offline.load();
|
||||
await offline.setProgress({ bookId: 'b1', locator: 'r1:0.5:c1', percentage: 0.5, chapterTitle: 'c1', device: platform.deviceLabel, updatedAt: 1000 });
|
||||
|
||||
const seen: string[] = [];
|
||||
transport.respondWith((request) => {
|
||||
seen.push(`${request.method} ${request.url}`);
|
||||
if (request.method === 'POST' && request.url.endsWith('/api/v1/sync')) {
|
||||
return { status: 200, headers: {}, json: { accepted: 1, rejected: 0, serverTime: 5000, progress: [], notes: [] } };
|
||||
}
|
||||
return { status: 200, headers: {}, json: { serverTime: 5000, progress: [], notes: [] } };
|
||||
});
|
||||
|
||||
await engine.syncNow();
|
||||
expect(seen[0]).toBe('POST /api/v1/sync');
|
||||
// The pull reuses the cursor the push just returned. That is intentional: a
|
||||
// pull from 0 would re-download the entire server state after every page
|
||||
// turn, which is exactly what the incremental cursor exists to avoid.
|
||||
expect(seen[1]).toContain('since=5000');
|
||||
expect(isOutboxEmpty(offline.current)).toBe(true);
|
||||
expect(offline.current.serverTime).toBe(5000);
|
||||
});
|
||||
|
||||
it('sends nothing but a pull when the outbox is empty', async () => {
|
||||
const { api, transport, offline, engine } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
await offline.load();
|
||||
transport.json({ serverTime: 10, progress: [], notes: [] });
|
||||
await engine.syncNow();
|
||||
// Counting only /sync: the login above is itself a POST, and counting all
|
||||
// POSTs would make this assertion vacuous.
|
||||
const syncPosts = transport.countMatching(
|
||||
(request) => request.method === 'POST' && request.url.endsWith('/api/v1/sync'),
|
||||
);
|
||||
expect(syncPosts).toBe(0);
|
||||
});
|
||||
|
||||
it('uses the stored cursor for the next pull', async () => {
|
||||
const { api, transport, offline, engine } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
await offline.load();
|
||||
transport.json({ serverTime: 777, progress: [], notes: [] });
|
||||
await engine.syncNow();
|
||||
transport.json({ serverTime: 999, progress: [], notes: [] });
|
||||
await engine.syncNow();
|
||||
const pulls = transport.requests.filter((request) => request.method === 'GET' && request.url.includes('/sync?'));
|
||||
expect(pulls[0]!.url).toContain('since=0');
|
||||
expect(pulls[1]!.url).toContain('since=777');
|
||||
});
|
||||
|
||||
it('degrades to offline without surfacing an error', async () => {
|
||||
const { api, transport, offline, engine, platform } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
await offline.load();
|
||||
platform.setOnline(false);
|
||||
await engine.syncNow();
|
||||
expect(engine.status().state).toBe('offline');
|
||||
expect(engine.status().message).toContain('离线');
|
||||
});
|
||||
|
||||
it('does not lose the outbox when the network fails mid-push', async () => {
|
||||
const { api, transport, offline, engine, platform } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
await offline.load();
|
||||
await offline.setProgress({ bookId: 'b1', locator: 'r1:0.5:c1', percentage: 0.5, chapterTitle: 'c1', device: platform.deviceLabel, updatedAt: 1000 });
|
||||
transport.failWith(new Error('boom'));
|
||||
await engine.syncNow();
|
||||
// The position must still be there to send on the next attempt.
|
||||
expect(isOutboxEmpty(offline.current)).toBe(false);
|
||||
expect(offline.current.progress['b1']).toBeDefined();
|
||||
});
|
||||
|
||||
it('chunks a large outbox instead of sending an oversized batch', async () => {
|
||||
const { api, transport, offline, engine, platform } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
await offline.load();
|
||||
for (let index = 0; index < 2500; index += 1) {
|
||||
await offline.setProgress({
|
||||
bookId: `b${index}`,
|
||||
locator: `r1:0:s${index}`,
|
||||
percentage: 0,
|
||||
chapterTitle: '',
|
||||
device: platform.deviceLabel,
|
||||
updatedAt: 1000,
|
||||
});
|
||||
}
|
||||
const sizes: number[] = [];
|
||||
transport.respondWith((request) => {
|
||||
if (request.method === 'POST') {
|
||||
const body = JSON.parse(request.body ?? '{}') as { progress?: unknown[] };
|
||||
sizes.push(body.progress?.length ?? 0);
|
||||
return { status: 200, headers: {}, json: { accepted: 1, rejected: 0, serverTime: 1, progress: [], notes: [] } };
|
||||
}
|
||||
return { status: 200, headers: {}, json: { serverTime: 1, progress: [], notes: [] } };
|
||||
});
|
||||
await engine.syncNow();
|
||||
expect(sizes.length).toBeGreaterThan(1);
|
||||
expect(Math.max(...sizes)).toBeLessThanOrEqual(1000);
|
||||
expect(sizes.reduce((sum, size) => sum + size, 0)).toBe(2500);
|
||||
});
|
||||
|
||||
it('reports rejection counts without blocking later flushes', async () => {
|
||||
const { api, transport, offline, engine, platform } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
await offline.load();
|
||||
await offline.setProgress({ bookId: 'gone', locator: 'x', percentage: 0, chapterTitle: '', device: platform.deviceLabel, updatedAt: 1 });
|
||||
transport.respondWith((request) =>
|
||||
request.method === 'POST'
|
||||
? { status: 200, headers: {}, json: { accepted: 0, rejected: 1, serverTime: 2, progress: [], notes: [] } }
|
||||
: { status: 200, headers: {}, json: { serverTime: 2, progress: [], notes: [] } },
|
||||
);
|
||||
await engine.syncNow();
|
||||
// A record the server will never accept must not stay in the outbox
|
||||
// forever, or every later position would queue behind it.
|
||||
expect(isOutboxEmpty(offline.current)).toBe(true);
|
||||
expect(engine.status().message).toContain('拒绝');
|
||||
});
|
||||
|
||||
it('does nothing when there is no session', async () => {
|
||||
const { engine } = build();
|
||||
await engine.syncNow();
|
||||
expect(engine.status().state).toBe('signed-out');
|
||||
});
|
||||
|
||||
it('does not run two syncs at once', async () => {
|
||||
const { api, transport, offline, engine } = build();
|
||||
transport.json(SESSION);
|
||||
await api.login('me', 'password12');
|
||||
await offline.load();
|
||||
let concurrent = 0;
|
||||
let peak = 0;
|
||||
transport.respondWith(async () => {
|
||||
concurrent += 1;
|
||||
peak = Math.max(peak, concurrent);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
concurrent -= 1;
|
||||
return { status: 200, headers: {}, json: { serverTime: 1, progress: [], notes: [] } };
|
||||
});
|
||||
await Promise.all([engine.syncNow(), engine.syncNow(), engine.syncNow()]);
|
||||
expect(peak).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["vite/client", "node"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "test", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// The build output is consumed twice:
|
||||
// - served directly by the server for the browser/H5 build
|
||||
// - copied into the Android app's assets and loaded from file:// in a WebView
|
||||
//
|
||||
// Because of the second consumer the build is a single self-contained bundle
|
||||
// with relative asset URLs (base: './') and no code-splitting: a WebView loading
|
||||
// from file:// cannot fetch sibling chunks over the network, and every extra
|
||||
// request through the file protocol is a chance to meet a platform quirk.
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
target: 'es2020',
|
||||
assetsInlineLimit: 0,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
entryFileNames: 'assets/client.js',
|
||||
chunkFileNames: 'assets/[name].js',
|
||||
assetFileNames: 'assets/[name][extname]',
|
||||
},
|
||||
},
|
||||
modulePreload: { polyfill: false },
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.READER_SERVER ?? 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user