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:
CodeBuddy committed 2026-09-16 14:49:11 +08:00
1 parent 0417bcdf72
commit 78bfe557c3
89 files changed
+13909 -61

No files matched your search

+70 -23
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
**/node_modules
**/dist
**/build
**/.gradle
data
.git
+3
View File
@@ -17,3 +17,6 @@ server/data/
.DS_Store
.idea/
.vscode/
# web client build output (regenerated by `npm run build`)
web/dist/
+126 -6
View File
@@ -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
+16
View File
@@ -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
+95
View File
@@ -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")
}
+9
View File
@@ -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*
+57
View File
@@ -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>
+12
View File
@@ -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
}
+8
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+17
View File
@@ -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 \
"$@"
+40
View File
@@ -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."
+75
View File
@@ -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"
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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。
-2
View File
@@ -1,6 +1,4 @@
node_modules
dist
data
.git
*.log
test
+28 -9
View File
@@ -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
+27
View File
@@ -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
View File
@@ -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;
}
+42
View File
@@ -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'));
}
+57 -1
View File
@@ -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);
+83
View File
@@ -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);
});
}
+172 -1
View File
@@ -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.
*
+47
View File
@@ -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;
}
+251 -4
View File
@@ -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';
}
}
+252
View File
@@ -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);
});
+41
View File
@@ -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>
+3245
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -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"
}
}
+41
View File
@@ -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 {};
+361
View File
@@ -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 };
+71
View File
@@ -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) };
}
+123
View File
@@ -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
View File
@@ -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 };
+95
View File
@@ -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;
}
}
+76
View File
@@ -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,
);
}
}
+74
View File
@@ -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;
}
+275
View File
@@ -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 };
+50
View File
@@ -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';
}
+117
View File
@@ -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));
}
+91
View File
@@ -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;
}
}
+402
View File
@@ -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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/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';
}
}
+94
View File
@@ -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);
}
+56
View File
@@ -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)));
}
+41
View File
@@ -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,
};
}
+145
View File
@@ -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 };
+222
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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');
}
+69
View File
@@ -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;
}
+81
View File
@@ -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();
}
+40
View File
@@ -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)}`;
});
+183
View File
@@ -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 };
}
+275
View File
@@ -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 };
+73
View File
@@ -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(/\/+$/, ''));
}
}
+858
View File
@@ -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;
}
+76
View File
@@ -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)}%`;
}
+131
View File
@@ -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);
};
}
+129
View File
@@ -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);
});
}
+210
View File
@@ -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();
}
}
+640
View File
@@ -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 };
+380
View File
@@ -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 });
}),
),
);
}
}
+145
View File
@@ -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(', ');
}
+121
View File
@@ -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;
}
+298
View File
@@ -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);
}
}
+101
View File
@@ -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');
});
});
+79
View File
@@ -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');
});
});
+360
View File
@@ -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('&lt;script&gt;');
});
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(/不支持的格式/);
});
});
+155
View File
@@ -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 };
+184
View File
@@ -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);
}
+141
View File
@@ -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;
}
}
});
});
+59
View File
@@ -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',
]);
});
});
+292
View File
@@ -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;
}
});
});
+103
View File
@@ -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([]);
});
});
+385
View File
@@ -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);
});
});
+21
View File
@@ -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"]
}
+41
View File
@@ -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'],
},
});