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 0000000..660449f Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..37646d9 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,17 @@ +#!/bin/sh +# Gradle wrapper launcher. +# +# Committed so a contributor (and CI) can build without installing Gradle. It +# downloads the distribution named in gradle/wrapper/gradle-wrapper.properties on +# first use and caches it. +set -eu + +APP_HOME=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +JAVA_CMD=${JAVA_HOME:+$JAVA_HOME/bin/java} +JAVA_CMD=${JAVA_CMD:-java} + +exec "$JAVA_CMD" \ + -Xmx2048m \ + -classpath "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" diff --git a/android/scripts/build-web-assets.sh b/android/scripts/build-web-assets.sh new file mode 100755 index 0000000..b03b181 --- /dev/null +++ b/android/scripts/build-web-assets.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# Builds the shared web client and stages it as Android assets. +# +# This script is the mechanism that keeps the Android and H5 clients from becoming +# two implementations: one Vite bundle, one host-agnostic web layer, two hosts. +# +# Run from anywhere; paths are resolved relative to the script. +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +android_dir=$(dirname -- "$script_dir") +repo_dir=$(dirname -- "$android_dir") +web_dir="$repo_dir/web" +dest="$android_dir/app/src/main/assets/web-assets" + +if [ ! -d "$web_dir" ]; then + echo "web client not found at $web_dir" >&2 + exit 1 +fi + +echo "==> building web client" +cd "$web_dir" +# `npm ci` when a lockfile exists so a release build is reproducible; `install` +# otherwise so a first-time contributor is not blocked on generating one. +if [ -f package-lock.json ]; then + npm ci +else + npm install +fi +npm run build + +echo "==> staging assets into $dest" +rm -rf "$dest" +mkdir -p "$dest" +# Only the built output. Copying the sources would ship the whole TypeScript tree +# inside the APK for no benefit. +cp -R "$web_dir/dist/." "$dest/" + +echo "==> done" +echo "The Android build now packs $(find "$dest" -type f | wc -l | tr -d ' ') files as assets." 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'], + }, +});