From 78bfe557c324083d709cf3fa1ff5108ab8dd3e55 Mon Sep 17 00:00:00 2001 From: CodeBuddy Date: Wed, 16 Sep 2026 14:49:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=A2=E6=88=B7=E7=AB=AF=20Android?= =?UTF-8?q?=20=E4=B8=8E=20H5=20=E5=8F=8C=E7=AB=AF=EF=BC=8C=E5=90=AB?= =?UTF-8?q?=E7=B2=BE=E6=8E=92=E6=B8=B2=E6=9F=93=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按产品设计 §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 --- .cnb.yml | 93 +- .dockerignore | 6 + .gitignore | 3 + README.md | 132 +- android/.gitignore | 16 + android/app/build.gradle.kts | 95 + android/app/proguard-rules.pro | 9 + android/app/src/main/AndroidManifest.xml | 57 + .../app/src/main/assets/web-assets/README.txt | 5 + .../main/java/cool/cnb/reader/MainActivity.kt | 126 + .../cnb/reader/bridge/ConnectivityMonitor.kt | 87 + .../cool/cnb/reader/bridge/ReaderBridge.kt | 205 ++ .../main/java/cool/cnb/reader/web/WebHost.kt | 185 + .../app/src/main/res/values-night/themes.xml | 9 + android/app/src/main/res/values/colors.xml | 6 + android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/themes.xml | 14 + .../main/res/xml/data_extraction_rules.xml | 24 + android/build.gradle.kts | 12 + android/gradle.properties | 8 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 72695 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 17 + android/scripts/build-web-assets.sh | 40 + android/scripts/check-shell.sh | 75 + android/settings.gradle.kts | 18 + docker-compose.yml | 16 +- docs/api.md | 33 + docs/architecture.md | 110 +- server/.dockerignore | 2 - server/Dockerfile | 37 +- server/src/config/index.ts | 27 + server/src/http/app.ts | 17 +- server/src/http/cors.ts | 42 + server/src/http/routes/library.ts | 58 +- server/src/http/routes/web.ts | 83 + server/src/indexer/metadata.ts | 173 +- server/src/indexer/natural-sort.ts | 47 + server/src/indexer/scanner.ts | 255 +- server/test/api.test.ts | 252 ++ web/index.html | 41 + web/package-lock.json | 3245 +++++++++++++++++ web/package.json | 25 + web/src/android-bridge.d.ts | 41 + web/src/api/client.ts | 361 ++ web/src/api/errors.ts | 71 + web/src/api/types.ts | 123 + web/src/app.ts | 290 ++ web/src/core/android-platform.ts | 95 + web/src/core/fetch-transport.ts | 76 + web/src/core/platform.ts | 74 + web/src/core/sync.ts | 275 ++ web/src/core/web-platform.ts | 50 + web/src/formats/comic.ts | 117 + web/src/formats/detect.ts | 91 + web/src/formats/epub.ts | 402 ++ web/src/formats/index.ts | 94 + web/src/formats/natural-sort.ts | 56 + web/src/formats/pdf.ts | 41 + web/src/formats/text-encoding.ts | 145 + web/src/formats/txt.ts | 222 ++ web/src/formats/types.ts | 69 + web/src/formats/zip.ts | 81 + web/src/main.ts | 40 + web/src/store/idb.ts | 183 + web/src/store/offline.ts | 275 ++ web/src/store/settings.ts | 73 + web/src/styles/reader.css | 858 +++++ web/src/ui/dom.ts | 76 + web/src/ui/gestures.ts | 131 + web/src/ui/locator.ts | 129 + web/src/ui/login-screen.ts | 210 ++ web/src/ui/reader-screen.ts | 640 ++++ web/src/ui/reader-view.ts | 380 ++ web/src/ui/resources.ts | 145 + web/src/ui/shadow.ts | 121 + web/src/ui/shelf-screen.ts | 298 ++ web/test/detect-and-resolve.test.ts | 101 + web/test/encoding.test.ts | 79 + web/test/formats.test.ts | 360 ++ web/test/helpers/env.ts | 155 + web/test/helpers/fixtures.ts | 184 + web/test/locator.test.ts | 141 + web/test/natural-sort.test.ts | 59 + web/test/reader-view.test.ts | 292 ++ web/test/shadow.test.ts | 103 + web/test/sync.test.ts | 385 ++ web/tsconfig.json | 21 + web/vite.config.ts | 41 + 89 files changed, 13909 insertions(+), 61 deletions(-) create mode 100644 .dockerignore create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/assets/web-assets/README.txt create mode 100644 android/app/src/main/java/cool/cnb/reader/MainActivity.kt create mode 100644 android/app/src/main/java/cool/cnb/reader/bridge/ConnectivityMonitor.kt create mode 100644 android/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt create mode 100644 android/app/src/main/java/cool/cnb/reader/web/WebHost.kt create mode 100644 android/app/src/main/res/values-night/themes.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100755 android/scripts/build-web-assets.sh create mode 100755 android/scripts/check-shell.sh create mode 100644 android/settings.gradle.kts create mode 100644 server/src/http/cors.ts create mode 100644 server/src/http/routes/web.ts create mode 100644 server/src/indexer/natural-sort.ts create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/android-bridge.d.ts create mode 100644 web/src/api/client.ts create mode 100644 web/src/api/errors.ts create mode 100644 web/src/api/types.ts create mode 100644 web/src/app.ts create mode 100644 web/src/core/android-platform.ts create mode 100644 web/src/core/fetch-transport.ts create mode 100644 web/src/core/platform.ts create mode 100644 web/src/core/sync.ts create mode 100644 web/src/core/web-platform.ts create mode 100644 web/src/formats/comic.ts create mode 100644 web/src/formats/detect.ts create mode 100644 web/src/formats/epub.ts create mode 100644 web/src/formats/index.ts create mode 100644 web/src/formats/natural-sort.ts create mode 100644 web/src/formats/pdf.ts create mode 100644 web/src/formats/text-encoding.ts create mode 100644 web/src/formats/txt.ts create mode 100644 web/src/formats/types.ts create mode 100644 web/src/formats/zip.ts create mode 100644 web/src/main.ts create mode 100644 web/src/store/idb.ts create mode 100644 web/src/store/offline.ts create mode 100644 web/src/store/settings.ts create mode 100644 web/src/styles/reader.css create mode 100644 web/src/ui/dom.ts create mode 100644 web/src/ui/gestures.ts create mode 100644 web/src/ui/locator.ts create mode 100644 web/src/ui/login-screen.ts create mode 100644 web/src/ui/reader-screen.ts create mode 100644 web/src/ui/reader-view.ts create mode 100644 web/src/ui/resources.ts create mode 100644 web/src/ui/shadow.ts create mode 100644 web/src/ui/shelf-screen.ts create mode 100644 web/test/detect-and-resolve.test.ts create mode 100644 web/test/encoding.test.ts create mode 100644 web/test/formats.test.ts create mode 100644 web/test/helpers/env.ts create mode 100644 web/test/helpers/fixtures.ts create mode 100644 web/test/locator.test.ts create mode 100644 web/test/natural-sort.test.ts create mode 100644 web/test/reader-view.test.ts create mode 100644 web/test/shadow.test.ts create mode 100644 web/test/sync.test.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.cnb.yml b/.cnb.yml index 8a22131..05b2ac6 100644 --- a/.cnb.yml +++ b/.cnb.yml @@ -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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..402f778 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +**/node_modules +**/dist +**/build +**/.gradle +data +.git diff --git a/.gitignore b/.gitignore index c91cd23..7df981c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ server/data/ .DS_Store .idea/ .vscode/ + +# web client build output (regenerated by `npm run build`) +web/dist/ diff --git a/README.md b/README.md index af7b4a5..25831b1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..16e1a3c --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..298d95e --- /dev/null +++ b/android/app/build.gradle.kts @@ -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") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..4a18cc5 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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* diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2351508 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/web-assets/README.txt b/android/app/src/main/assets/web-assets/README.txt new file mode 100644 index 0000000..2b8aa02 --- /dev/null +++ b/android/app/src/main/assets/web-assets/README.txt @@ -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. diff --git a/android/app/src/main/java/cool/cnb/reader/MainActivity.kt b/android/app/src/main/java/cool/cnb/reader/MainActivity.kt new file mode 100644 index 0000000..b814de3 --- /dev/null +++ b/android/app/src/main/java/cool/cnb/reader/MainActivity.kt @@ -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() + } +} diff --git a/android/app/src/main/java/cool/cnb/reader/bridge/ConnectivityMonitor.kt b/android/app/src/main/java/cool/cnb/reader/bridge/ConnectivityMonitor.kt new file mode 100644 index 0000000..af3e4c3 --- /dev/null +++ b/android/app/src/main/java/cool/cnb/reader/bridge/ConnectivityMonitor.kt @@ -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) + } +} diff --git a/android/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt b/android/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt new file mode 100644 index 0000000..9f4b72f --- /dev/null +++ b/android/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt @@ -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() + 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}$") + } +} diff --git a/android/app/src/main/java/cool/cnb/reader/web/WebHost.kt b/android/app/src/main/java/cool/cnb/reader/web/WebHost.kt new file mode 100644 index 0000000..5305a29 --- /dev/null +++ b/android/app/src/main/java/cool/cnb/reader/web/WebHost.kt @@ -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) } +} diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..d64198e --- /dev/null +++ b/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..fcb0d3d --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + + #FBFBF7 + #16171A + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7a3cc51 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + reader + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..8a9ff1e --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..3b73701 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..035feb3 --- /dev/null +++ b/android/build.gradle.kts @@ -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 +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..7dfa41d --- /dev/null +++ b/android/gradle.properties @@ -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 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..660449f2368301bedbe8a1484fe24841ad639f0f GIT binary patch literal 72695 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 + 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." diff --git a/android/scripts/check-shell.sh b/android/scripts/check-shell.sh new file mode 100755 index 0000000..059f8bd --- /dev/null +++ b/android/scripts/check-shell.sh @@ -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" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..a4bb497 --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml index 4004864..94d0906 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/api.md b/docs/api.md index cef043e..0a6a038 100644 --- a/docs/api.md +++ b/docs/api.md @@ -135,6 +135,16 @@ 书内容按哈希不可变,所以带 `ETag`,客户端可以放心长缓存。 +### `GET /books/:id/file?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。所以离线队列可以放心重放。 diff --git a/docs/architecture.md b/docs/architecture.md index 4ec4e95..2937c08 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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。 diff --git a/server/.dockerignore b/server/.dockerignore index 8d51cf1..88cb6cc 100644 --- a/server/.dockerignore +++ b/server/.dockerignore @@ -1,6 +1,4 @@ node_modules dist data -.git *.log -test diff --git a/server/Dockerfile b/server/Dockerfile index 288ee7b..f81903c 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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://: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 diff --git a/server/src/config/index.ts b/server/src/config/index.ts index 3006f10..5ed8fe5 100644 --- a/server/src/config/index.ts +++ b/server/src/config/index.ts @@ -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')), }; } diff --git a/server/src/http/app.ts b/server/src/http/app.ts index 37db4d0..2b950c2 100644 --- a/server/src/http/app.ts +++ b/server/src/http/app.ts @@ -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; } diff --git a/server/src/http/cors.ts b/server/src/http/cors.ts new file mode 100644 index 0000000..d09ec49 --- /dev/null +++ b/server/src/http/cors.ts @@ -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')); +} diff --git a/server/src/http/routes/library.ts b/server/src/http/routes/library.ts index 5f31b30..3d82a4f 100644 --- a/server/src/http/routes/library.ts +++ b/server/src/http/routes/library.ts @@ -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 = { '.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); diff --git a/server/src/http/routes/web.ts b/server/src/http/routes/web.ts new file mode 100644 index 0000000..4a4f48c --- /dev/null +++ b/server/src/http/routes/web.ts @@ -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 = { + '.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); + }); +} diff --git a/server/src/indexer/metadata.ts b/server/src/indexer/metadata.ts index e06a27d..504293b 100644 --- a/server/src/indexer/metadata.ts +++ b/server/src/indexer/metadata.ts @@ -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 { + 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(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 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. * diff --git a/server/src/indexer/natural-sort.ts b/server/src/indexer/natural-sort.ts new file mode 100644 index 0000000..1b1a76a4 --- /dev/null +++ b/server/src/indexer/natural-sort.ts @@ -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 { + const chunks: Array = []; + 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; +} diff --git a/server/src/indexer/scanner.ts b/server/src/indexer/scanner.ts index 3065291..9d65ad0 100644 --- a/server/src/indexer/scanner.ts +++ b/server/src/indexer/scanner.ts @@ -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(); + 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 { + 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 { + 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 { + const out: ComicDirectory[] = []; + + const visit = async (dir: string): Promise => { + 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): 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'; + } +} diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 428fa2e..a5ff270 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -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 { + 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 { 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 { + 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); +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..98354b6 --- /dev/null +++ b/web/index.html @@ -0,0 +1,41 @@ + + + + + + + + + reader + + + +

+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..21c8250 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3245 @@ +{ + "name": "reader-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "reader-client", + "version": "0.1.0", + "license": "AGPL-3.0-or-later", + "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" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..cc7a578 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/android-bridge.d.ts b/web/src/android-bridge.d.ts new file mode 100644 index 0000000..6cdf3d0 --- /dev/null +++ b/web/src/android-bridge.d.ts @@ -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 {}; diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..e23529b --- /dev/null +++ b/web/src/api/client.ts @@ -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; + save(session: Session): Promise; + clear(): Promise; +} + +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 | 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 { + 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 { + 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 { + 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 { + return this.get('/api/v1/instance'); + } + + async register(username: string, password: string, displayName?: string): Promise { + const session = await this.call('/api/v1/auth/register', 'POST', { + username, + password, + ...(displayName ? { displayName } : {}), + }); + await this.setSession(session); + return session; + } + + async login(username: string, password: string): Promise { + const session = await this.call('/api/v1/auth/login', 'POST', { username, password }); + await this.setSession(session); + return session; + } + + async me(): Promise { + const result = await this.get<{ user: User }>('/api/v1/auth/me'); + return result.user; + } + + async changePassword(currentPassword: string, newPassword: string): Promise { + 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 { + 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(`/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 { + return this.get(`/api/v1/books/${encodeURIComponent(id)}/manifest`, options); + } + + async facets(options: RequestOptions = {}): Promise { + return this.get('/api/v1/library/facets', options); + } + + async continueReading(limit = 20, options: RequestOptions = {}): Promise { + 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 { + 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 { + 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 { + const params = new URLSearchParams({ since: String(since) }); + if (bookId) params.set('bookId', bookId); + return this.get(`/api/v1/sync?${params.toString()}`, options); + } + + async push( + payload: { progress?: ProgressInput[]; notes?: NoteInput[] }, + options: RequestOptions = {}, + ): Promise { + return this.call('/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 { + 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 { + 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 { + 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 { + return this.call('/api/v1/notes', 'POST', note, options); + } + + async deleteNote(id: string, options: RequestOptions = {}): Promise { + await this.call(`/api/v1/notes/${encodeURIComponent(id)}`, 'DELETE', undefined, options); + } + + // ---- transport plumbing ---- + + private async get(path: string, options: RequestOptions = {}): Promise { + const response = await this.request(path, 'GET', undefined, options); + return response.json as T; + } + + private async call( + path: string, + method: string, + body?: unknown, + options: RequestOptions = {}, + ): Promise { + 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 { + const attempt = async (token: string | null) => { + const headers: Record = { 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 { + 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 }; diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts new file mode 100644 index 0000000..9eb5047 --- /dev/null +++ b/web/src/api/errors.ts @@ -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 = { + 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) }; +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..aeb4c91 --- /dev/null +++ b/web/src/api/types.ts @@ -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 }; +} diff --git a/web/src/app.ts b/web/src/app.ts new file mode 100644 index 0000000..87ac2d5 --- /dev/null +++ b/web/src/app.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const store = await sharedKv(); + await store.set(KEY, JSON.stringify(session)); + }, + async clear(): Promise { + const store = await sharedKv(); + await store.remove(KEY); + }, + }; +} + +interface KeyValueStoreLike { + get(key: string): Promise; + set(key: string, value: string): Promise; + remove(key: string): Promise; +} + +let sharedKvPromise: Promise | 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 { + if (!sharedKvPromise) sharedKvPromise = createStoresLazy(); + return sharedKvPromise; +} + +async function createStoresLazy(): Promise { + 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 }; diff --git a/web/src/core/android-platform.ts b/web/src/core/android-platform.ts new file mode 100644 index 0000000..9fc3ea9 --- /dev/null +++ b/web/src/core/android-platform.ts @@ -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 { + 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 { + 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; + } +} diff --git a/web/src/core/fetch-transport.ts b/web/src/core/fetch-transport.ts new file mode 100644 index 0000000..22f751a --- /dev/null +++ b/web/src/core/fetch-transport.ts @@ -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 { + 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 = {}; + 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 { + 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, + ); + } +} diff --git a/web/src/core/platform.ts b/web/src/core/platform.ts new file mode 100644 index 0000000..2085604 --- /dev/null +++ b/web/src/core/platform.ts @@ -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; + /** 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; + /** 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; +} + +export interface KeyValueStore { + get(key: string): Promise; + set(key: string, value: string): Promise; + remove(key: string): Promise; +} + +/** + * 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; + put(key: string, bytes: Uint8Array): Promise; + remove(key: string): Promise; + list(): Promise; + /** Total bytes currently stored, for the storage-usage screen. */ + usage(): Promise; +} + +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; + onConnectivityChange(listener: (state: Connectivity) => void): () => void; + /** Notified whenever a request has been attempted and its outcome. */ + reportError?(error: ApiError): void; +} diff --git a/web/src/core/sync.ts b/web/src/core/sync.ts new file mode 100644 index 0000000..e45c24c --- /dev/null +++ b/web/src/core/sync.ts @@ -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 | 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(); + 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 { + 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 { + 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 { + 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 }; diff --git a/web/src/core/web-platform.ts b/web/src/core/web-platform.ts new file mode 100644 index 0000000..493bae4 --- /dev/null +++ b/web/src/core/web-platform.ts @@ -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 { + 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 { + 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'; +} diff --git a/web/src/formats/comic.ts b/web/src/formats/comic.ts new file mode 100644 index 0000000..c86b8a2 --- /dev/null +++ b/web/src/formats/comic.ts @@ -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 { + 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, +): Promise { + return buildComicDoc(sortByName(pages, (name) => name), resolve, 'comic-dir'); +} + +export async function buildComicDoc( + pages: string[], + resolve: (path: string) => Promise, + format: 'cbz' | 'comic-dir' | 'image', +): Promise { + 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)); +} diff --git a/web/src/formats/detect.ts b/web/src/formats/detect.ts new file mode 100644 index 0000000..780830e --- /dev/null +++ b/web/src/formats/detect.ts @@ -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; + } +} diff --git a/web/src/formats/epub.ts b/web/src/formats/epub.ts new file mode 100644 index 0000000..735a2ff --- /dev/null +++ b/web/src/formats/epub.ts @@ -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 { + const archive = await BookArchive.open(ctx.bytes); + const pkg = await readPackage(archive); + + const resources = new Map(); + 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 { + 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 = /]*>([\s\S]*?)<\/manifest>/i.exec(opf)?.[1] ?? opf; + const itemPattern = /]*)\/?>/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 = /]*>([\s\S]*?)<\/spine>/i.exec(opf)?.[1]; + if (!block) return []; + const ids: string[] = []; + const pattern = /]*)\/?>/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 { + 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 = /]*epub:type\s*=\s*"toc"[^>]*>([\s\S]*?)<\/nav>/i.exec(html)?.[1] + ?? /]*>([\s\S]*?)<\/nav>/i.exec(html)?.[1]; + if (!navBlock) return []; + const entries: TocEntry[] = []; + const pattern = /]*)>([\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 = /]*>([\s\S]*?)<\/navPoint>/gi; + let match: RegExpExecArray | null; + while ((match = pattern.exec(xml)) !== null) { + const block = match[1] ?? ''; + const src = /]*src\s*=\s*"([^"]+)"/i.exec(block)?.[1] ?? ''; + const label = stripTags(/]*>([\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(/ 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 ? /]*>([\s\S]{0,120}?)<\/h[1-3]>/i.exec(section.html)?.[1] : undefined; + const title = section.html ? /]*>([\s\S]{0,120}?)<\/title>/i.exec(section.html)?.[1] : undefined; + section.label = stripTags(heading ?? title ?? '') || section.id.split('/').pop() || section.id; + } +} + +function stripTags(value: string): string { + return value + .replace(/<[^>]*>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/\s+/g, ' ') + .trim(); +} + +export function mediaTypeFor(path: string): string { + switch (extensionOf(path)) { + case 'xhtml': + case 'html': + case 'htm': + return 'application/xhtml+xml'; + case 'css': + return 'text/css'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'png': + return 'image/png'; + case 'gif': + return 'image/gif'; + case 'svg': + return 'image/svg+xml'; + case 'webp': + return 'image/webp'; + case 'avif': + return 'image/avif'; + case 'ttf': + return 'font/ttf'; + case 'otf': + return 'font/otf'; + case 'woff': + return 'font/woff'; + case 'woff2': + return 'font/woff2'; + case 'mp3': + return 'audio/mpeg'; + case 'm4a': + return 'audio/mp4'; + case 'mp4': + return 'video/mp4'; + case 'js': + return 'text/javascript'; + case 'ncx': + return 'application/x-dtbncx+xml'; + default: + return 'application/octet-stream'; + } +} diff --git a/web/src/formats/index.ts b/web/src/formats/index.ts new file mode 100644 index 0000000..e3d06f9 --- /dev/null +++ b/web/src/formats/index.ts @@ -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; +} + +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 { + 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); +} diff --git a/web/src/formats/natural-sort.ts b/web/src/formats/natural-sort.ts new file mode 100644 index 0000000..d9e6147 --- /dev/null +++ b/web/src/formats/natural-sort.ts @@ -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 { + const parts = value.split(NUMERIC_CHUNK); + const chunks: Array = []; + 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(items: T[], nameOf: (item: T) => string): T[] { + return [...items].sort((a, b) => naturalCompare(nameOf(a), nameOf(b))); +} diff --git a/web/src/formats/pdf.ts b/web/src/formats/pdf.ts new file mode 100644 index 0000000..bfdb056 --- /dev/null +++ b/web/src/formats/pdf.ts @@ -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 { + 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, + }; +} diff --git a/web/src/formats/text-encoding.ts b/web/src/formats/text-encoding.ts new file mode 100644 index 0000000..edd5170 --- /dev/null +++ b/web/src/formats/text-encoding.ts @@ -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 }; diff --git a/web/src/formats/txt.ts b/web/src/formats/txt.ts new file mode 100644 index 0000000..0829493 --- /dev/null +++ b/web/src/formats/txt.ts @@ -0,0 +1,222 @@ +import { decodeText } from './text-encoding.ts'; +import type { BookDoc, LoadContext, Section } from './types.ts'; + +/** + * Plain-text loader. + * + * Two problems worth solving properly, because a TXT library is where a reader + * either earns trust or loses it: + * + * 1. **Encoding.** See text-encoding.ts. This file has no way to know, so it + * detects, and it reports what it chose so the UI can let the reader override + * it for the one file in a thousand that the heuristic gets wrong. + * + * 2. **Chapter splitting.** TXT has no structure at all, so the table of + * contents has to be inferred. Getting it wrong in either direction is bad: + * too eager and every numbered line becomes a chapter, too timid and a + * 5000-chapter web novel becomes one endless section that cannot be + * navigated or paginated without freezing the device. + */ + +/** + * Chapter headings, ordered from most to least specific. + * + * Tuned against the shapes that actually appear in Chinese TXT collections: + * `第一章`, `第 1 章`, `第1节`, `Chapter 3`, and the bracketed forms that + * scrapers emit. Each alternative is anchored so a line of prose that merely + * *mentions* a chapter number ("他在第一章里说过") is not mistaken for a + * heading. + */ +const CHAPTER_PATTERNS: RegExp[] = [ + // 第一章 标题 / 第1章 / 第一百二十三章 + /^\s*第\s*[0-90-9零一二三四五六七八九十百千万两]+\s*[章回节卷部篇集]\s*[::.、\-—]?\s*(.{0,60})$/, + // Chapter 3 / CHAPTER III + /^\s*chapter\s+([0-9]+|[ivxlcdm]+)\b\s*[::.、\-—]?\s*(.{0,60})$/i, + // 【第一章】标题 / (第一节)标题 + /^\s*[【((\[]\s*第\s*[0-90-9零一二三四五六七八九十百千万两]+\s*[章回节卷部篇集]\s*[】))\]]\s*(.{0,60})$/, + // 卷一 / 卷二 (bare 卷 heading, common in classic novels) + /^\s*[卷部]\s*[0-90-9零一二三四五六七八九十百千万两]+\s*(.{0,60})$/, +]; + +/** + * A heading line is short. Without this, a paragraph that starts with "第三章" + * and continues for 800 characters would be treated as a chapter title and the + * real chapter body would lose its beginning. + */ +const MAX_HEADING_LENGTH = 80; + +export interface TxtOptions { + /** Force an encoding label such as `gb18030`; detected when omitted. */ + encoding?: string; + /** Split into chapters. When false the whole book is one section. */ + splitChapters?: boolean; +} + +export interface TxtSplitResult { + doc: BookDoc; + encoding: string; + encodingGuessed: boolean; + chapterCount: number; +} + +export function loadTxt(ctx: LoadContext, options: TxtOptions = {}): TxtSplitResult { + const decoded = options.encoding + ? { text: decodeWith(ctx.bytes, options.encoding), encoding: options.encoding, guessed: false } + : decodeText(ctx.bytes); + const text = normaliseLineEndings(decoded.text); + const splitChapters = options.splitChapters !== false; + + const lines = text.split('\n'); + const chapters: Array<{ title: string; start: number }> = []; + let offset = 0; + + if (splitChapters) { + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length > 0 && trimmed.length <= MAX_HEADING_LENGTH && matchHeading(trimmed)) { + chapters.push({ title: trimmed, start: offset }); + } + offset += line.length + 1; + } + } + + const sections: Section[] = []; + if (chapters.length === 0) { + // No headings found: either the book simply has none, or the heuristic + // missed. Either way one giant section is unusable, so fall back to fixed + // chunks that keep the renderer responsive. + for (const chunk of chunkByLength(text, 40_000)) { + sections.push({ + id: `chunk-${sections.length}`, + label: chunk.label, + html: textToHtml(chunk.text), + depth: 0, + }); + } + } else { + if (chapters[0]!.start > 0) { + const preface = text.slice(0, chapters[0]!.start).trim(); + if (preface.length > 0) { + sections.push({ id: 'preface', label: '前言', html: textToHtml(preface), depth: 0 }); + } + } + for (let index = 0; index < chapters.length; index += 1) { + const current = chapters[index]!; + const next = chapters[index + 1]; + const body = text.slice(current.start, next ? next.start : text.length); + // A chapter of several hundred thousand characters (some web novels have + // them) still needs splitting, or layout blocks the UI thread. + const parts = chunkByLength(body, 60_000); + for (const [partIndex, part] of parts.entries()) { + sections.push({ + id: `ch${index}-${partIndex}`, + label: parts.length > 1 ? `${current.title} (${partIndex + 1}/${parts.length})` : current.title, + html: textToHtml(part.text), + depth: 0, + }); + } + } + } + + const toc = sections.map((section) => ({ id: section.id, label: section.label, depth: 0 })); + + return { + encoding: decoded.encoding, + encodingGuessed: decoded.guessed, + chapterCount: chapters.length, + doc: { + format: 'txt', + layout: 'reflowable', + direction: 'ltr', + sections, + toc, + styles: [], + resources: new Map(), + orderedByBook: chapters.length > 0, + }, + }; +} + +function matchHeading(line: string): boolean { + for (const pattern of CHAPTER_PATTERNS) { + pattern.lastIndex = 0; + if (pattern.test(line)) return true; + } + return false; +} + +function decodeWith(bytes: Uint8Array, encoding: string): string { + try { + return new TextDecoder(encoding).decode(bytes); + } catch { + return new TextDecoder('utf-8').decode(bytes); + } +} + +export function normaliseLineEndings(text: string): string { + return text.replace(/\r\n?/g, '\n'); +} + +interface Chunk { + text: string; + label: string; +} + +/** + * Splits text into pieces of at most `maxLength` characters, breaking on a + * paragraph boundary when one is close enough. + * + * Breaking mid-paragraph is acceptable; breaking mid-sentence would not be, so + * the search looks backwards for a blank line first and only then for the end + * of a paragraph. + */ +export function chunkByLength(text: string, maxLength: number): Chunk[] { + if (text.length <= maxLength) return [{ text, label: '' }]; + const chunks: Chunk[] = []; + let start = 0; + let index = 0; + while (start < text.length) { + let end = Math.min(start + maxLength, text.length); + if (end < text.length) { + const window = text.slice(start + Math.floor(maxLength * 0.6), end); + const blank = window.lastIndexOf('\n\n'); + if (blank !== -1) end = start + Math.floor(maxLength * 0.6) + blank + 2; + else { + const newline = window.lastIndexOf('\n'); + if (newline !== -1) end = start + Math.floor(maxLength * 0.6) + newline + 1; + } + } + const body = text.slice(start, end); + if (body.trim().length > 0) chunks.push({ text: body, label: `第 ${index + 1} 段` }); + index += 1; + start = end; + } + return chunks; +} + +/** + * Escapes the text and wraps each non-empty line in a paragraph. + * + * Escaping is not optional: TXT files routinely contain `<` from markup that was + * partially stripped by whichever tool produced them, and injecting that raw + * would let the file break the page layout. + */ +export function textToHtml(text: string): string { + const escaped = text + .replace(/&/g, '&') + .replace(//g, '>'); + 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('
'); + return `

${inner}

`; + }); + return paragraphs.join('\n'); +} diff --git a/web/src/formats/types.ts b/web/src/formats/types.ts new file mode 100644 index 0000000..faef7d6 --- /dev/null +++ b/web/src/formats/types.ts @@ -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; +} + +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; + /** 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; +} diff --git a/web/src/formats/zip.ts b/web/src/formats/zip.ts new file mode 100644 index 0000000..4b9c5ac --- /dev/null +++ b/web/src/formats/zip.ts @@ -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 { + 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 { + 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 { + 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(); +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..ee92b4c --- /dev/null +++ b/web/src/main.ts @@ -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)}`; +}); diff --git a/web/src/store/idb.ts b/web/src/store/idb.ts new file mode 100644 index 0000000..b7bccb8 --- /dev/null +++ b/web/src/store/idb.ts @@ -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(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('indexedDB request failed')); + }); +} + +let dbPromise: Promise | null = null; + +function openDb(): Promise { + if (dbPromise) return dbPromise; + dbPromise = new Promise((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(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => Promise): Promise { + 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((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(); + async get(key: string): Promise { + return this.map.has(key) ? this.map.get(key)! : null; + } + async set(key: string, value: string): Promise { + this.map.set(key, value); + } + async remove(key: string): Promise { + this.map.delete(key); + } +} + +class MemoryBlobs { + private readonly map = new Map(); + async get(key: string): Promise { + return this.map.get(key) ?? null; + } + async put(key: string, bytes: Uint8Array): Promise { + this.map.set(key, bytes); + } + async remove(key: string): Promise { + this.map.delete(key); + } + async list(): Promise { + return [...this.map.keys()]; + } + async usage(): Promise { + let total = 0; + for (const bytes of this.map.values()) total += bytes.byteLength; + return total; + } +} + +export interface Stores { + kv: { get(key: string): Promise; set(key: string, value: string): Promise; remove(key: string): Promise }; + blobs: { + get(key: string): Promise; + put(key: string, bytes: Uint8Array): Promise; + remove(key: string): Promise; + list(): Promise; + usage(): Promise; + }; + /** True when the fallback in-memory store is in use. */ + ephemeral: boolean; +} + +export async function createStores(): Promise { + 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 { + const value = await tx(KV_STORE, 'readonly', async (store) => + requestToPromise(store.get(key)), + ); + return value ?? null; + }, + async set(key: string, value: string): Promise { + await tx(KV_STORE, 'readwrite', async (store) => { + await requestToPromise(store.put(value, key)); + }); + }, + async remove(key: string): Promise { + await tx(KV_STORE, 'readwrite', async (store) => { + await requestToPromise(store.delete(key)); + }); + }, + }; + + const blobs = { + async get(key: string): Promise { + const value = await tx(BLOB_STORE, 'readonly', async (store) => + requestToPromise(store.get(key)), + ); + if (!value) return null; + return value instanceof Uint8Array ? value : new Uint8Array(value); + }, + async put(key: string, bytes: Uint8Array): Promise { + await tx(BLOB_STORE, 'readwrite', async (store) => { + await requestToPromise(store.put(bytes, key)); + }); + }, + async remove(key: string): Promise { + await tx(BLOB_STORE, 'readwrite', async (store) => { + await requestToPromise(store.delete(key)); + }); + }, + async list(): Promise { + const keys = await tx(BLOB_STORE, 'readonly', async (store) => + requestToPromise(store.getAllKeys()), + ); + return keys.map((key) => String(key)); + }, + async usage(): Promise { + const values = await tx>(BLOB_STORE, 'readonly', async (store) => + requestToPromise>(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 }; +} diff --git a/web/src/store/offline.ts b/web/src/store/offline.ts new file mode 100644 index 0000000..f3d2049 --- /dev/null +++ b/web/src/store/offline.ts @@ -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; + /** The client's full view of progress, used to render the shelf offline. */ + progress: Record; + notes: Record; + /** + * 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; + 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 = Promise.resolve(); + + constructor(private readonly kv: KeyValueStore) {} + + async load(): Promise { + 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 { + 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 { + for (const book of books) this.snapshot.books[book.id] = book; + await this.persist(); + } + + async removeBooks(ids: string[]): Promise { + for (const id of ids) { + delete this.snapshot.books[id]; + delete this.snapshot.progress[id]; + } + await this.persist(); + } + + async setProgress(progress: Progress): Promise { + 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 { + return this.snapshot.progress[bookId]; + } + + async upsertNotes(notes: Note[]): Promise { + 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 { + 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 { + 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 { + 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 { + this.snapshot = emptySnapshot(); + this.loaded = true; + await this.persist(); + } + + async flush(): Promise { + await this.writeChain; + } +} + +export type { Progress, Note, Book }; +export type { KeyValueStore }; diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts new file mode 100644 index 0000000..afb9b5a --- /dev/null +++ b/web/src/store/settings.ts @@ -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 { + if (this.loaded) return this.settings; + const raw = await this.kv.get(KEY); + if (raw) { + try { + const parsed = JSON.parse(raw) as Partial; + 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): Promise { + this.settings = { ...this.settings, ...patch }; + await this.kv.set(KEY, JSON.stringify(this.settings)); + return this.settings; + } + + async serverUrl(): Promise { + // 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 { + await this.kv.set(SERVER_KEY, url.replace(/\/+$/, '')); + } +} diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css new file mode 100644 index 0000000..420a6e2 --- /dev/null +++ b/web/src/styles/reader.css @@ -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; +} diff --git a/web/src/ui/dom.ts b/web/src/ui/dom.ts new file mode 100644 index 0000000..75b7d8a --- /dev/null +++ b/web/src/ui/dom.ts @@ -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; + on?: Partial void>>; + children?: Child[]; + dataset?: Record; +} + +export function el( + 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)}%`; +} diff --git a/web/src/ui/gestures.ts b/web/src/ui/gestures.ts new file mode 100644 index 0000000..27367ef --- /dev/null +++ b/web/src/ui/gestures.ts @@ -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 | 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); + }; +} diff --git a/web/src/ui/locator.ts b/web/src/ui/locator.ts new file mode 100644 index 0000000..4af454a --- /dev/null +++ b/web/src/ui/locator.ts @@ -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::
`, 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); + }); +} diff --git a/web/src/ui/login-screen.ts b/web/src/ui/login-screen.ts new file mode 100644 index 0000000..2a3d8ef --- /dev/null +++ b/web/src/ui/login-screen.ts @@ -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; +} + +/** + * 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 | 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 { + 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 { + 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(); + } +} diff --git a/web/src/ui/reader-screen.ts b/web/src/ui/reader-screen.ts new file mode 100644 index 0000000..5ee1d2d --- /dev/null +++ b/web/src/ui/reader-screen.ts @@ -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): 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 | 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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): Promise { + 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 | 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 }; diff --git a/web/src/ui/reader-view.ts b/web/src/ui/reader-view.ts new file mode 100644 index 0000000..4c6d569 --- /dev/null +++ b/web/src/ui/reader-view.ts @@ -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): 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const raw = section.html ?? ''; + const body = extractBody(raw); + const inlineStyles = extractInlineStyles(raw); + // The chapter's own

x

'; + expect(extractInlineStyles(html)).toEqual(['a{color:red}', 'b{color:blue}']); + }); + + it('returns an empty list when there are none', () => { + expect(extractInlineStyles('

x

')).toEqual([]); + }); +}); diff --git a/web/test/sync.test.ts b/web/test/sync.test.ts new file mode 100644 index 0000000..bf55abd --- /dev/null +++ b/web/test/sync.test.ts @@ -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((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); + }); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..c5d19be --- /dev/null +++ b/web/tsconfig.json @@ -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"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..e6d519b --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from 'vitest/config'; + +// The build output is consumed twice: +// - served directly by the server for the browser/H5 build +// - copied into the Android app's assets and loaded from file:// in a WebView +// +// Because of the second consumer the build is a single self-contained bundle +// with relative asset URLs (base: './') and no code-splitting: a WebView loading +// from file:// cannot fetch sibling chunks over the network, and every extra +// request through the file protocol is a chance to meet a platform quirk. +export default defineConfig({ + base: './', + build: { + outDir: 'dist', + emptyOutDir: true, + target: 'es2020', + assetsInlineLimit: 0, + rollupOptions: { + output: { + inlineDynamicImports: true, + entryFileNames: 'assets/client.js', + chunkFileNames: 'assets/[name].js', + assetFileNames: 'assets/[name][extname]', + }, + }, + modulePreload: { polyfill: false }, + }, + server: { + port: 5174, + proxy: { + '/api': { + target: process.env.READER_SERVER ?? 'http://127.0.0.1:8080', + changeOrigin: true, + }, + }, + }, + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + }, +});