Commit Graph
29 Commits
Author SHA1 Message Date
hectorqin c192fc2f12 feat(sources): add configuration pages and chapter updates 2026-09-20 23:59:12 +08:00
hectorqin a6fddab953 feat(reader): 接入插件章节阅读与目录刷新
接入远程章节出版物、稳定定位、服务端与设备端缓存及手动刷新;补充账号隔离、同步并发保护、API 和设计文档。
2026-09-20 18:40:09 +08:00
hectorqin 037bdd6880 feat(sources): 增加内置 local、OPDS 与进程插件系统 2026-09-20 17:51:12 +08:00
hectorqin 31b5459a47 fix(web,server): 下架按下架的书 id 定位,书库两半各归其位 2026-09-20 12:32:43 +08:00
hectorqin 558f44e206 feat(web,server): 书架默认按最近阅读排,书库拆成预览与文件两页 2026-09-19 14:17:55 +08:00
hectorqin f2701da703 fix(web,server): 继续阅读带上完整书对象,书库能上架,放大镜回到中线
三处 #40 后续:继续阅读卡片点了报「不在书架上」、书库的书加不回书架、
书架搜索框的放大镜高了一行。
2026-09-19 11:46:42 +08:00
hectorqin 69993d7b37 Merge remote-tracking branch 'origin/main' into auto/reader-paragraphs-layout-images-eb50 2026-09-19 09:13:54 +08:00
hectorqin 0551f21e4e fix(web,server): 段落不再合成一块,插图重新画出来,工具栏对齐 2026-09-19 09:09:12 +08:00
hectorqin d489c45cf3 Merge remote-tracking branch 'origin/main' into auto/shelf-library-pagination-d4fc 2026-09-19 08:49:21 +08:00
hectorqin c7d065bd52 feat(web): 书架与书库拆成两个可分页的屏幕 2026-09-19 08:45:37 +08:00
hectorqin b8fda56c34 fix(web,server): 底部滑杆量章节内页数,快捷列随工具栏收放,chapter-full 改名 2026-09-19 08:17:46 +08:00
hectorqin 1c2d07ba4c fix(web,server): 让服务端只交章节,并去掉成对的 sync 请求
服务端仍在做排版:`chapter-html:<n>` 自己断段、自己提升标题、自己吐
`<p>`。于是 reader 设置里的缩进和段间距只能作用于服务端已经渲染好的那一份,
流式那条路(`chapter:<n>`)拿不到段落,同一本书因为走哪条传输而看起来不一样。
客户端的 `retypePlainText` 于是把服务端断好的段落再拆一遍——同一个判断被两个
地方执行,两次的结果就会分歧。

`POST /api/v1/sync` 的响应本来就是合并后的状态(服务端在两个接口上返回同一份
`pull`),但客户端把它丢掉,然后又为同一份数据发一个 `GET`。读者每翻一页都发
一个 POST 加一个 GET,网络面板里成对出现。另外 `setState` 每次调用都广播,而
阅读页的状态监听会重绘整棵 chrome 树,一个同步循环通知三次。

现在服务端只把整章字符交出来(`text/plain`,`chapter-html:` 不截断),断段、
去段前空格、提升标题全部由 `web/src/formats/segments.ts` 一处决定;push 的返回
值直接喂给 `applyPulled`,空闲轮询才真的发 GET;`setState` 值未变就不广播。

「这一章是不是纯文本」改由 manifest 声明(item 的 `format: 'html'` →
`Section.plainText` → `isPlainText()`),不再靠 `.txt-body` 嗅探:服务端现在发
裸字符,嗅探对一章节全是纯文本的章节会答「不是」,读者会看到一本没有段落、
没有缩进、样式表也没挂上的小说。`.txt-body` 标记降级为旧服务端/旧缓存的兜底。

放弃的方案:保留服务端渲染、只在客户端覆盖样式。缩进和段间距是阅读偏好,服务端
渲染就等于把每次调整变成一次往返,而这个往返正是问题本身。也考虑过让
`chapter-html:` 直接返回服务端渲染的裸 `<p>`(不带 wrapper),但那仍然有两个
地方在判断「一段是什么」。

Ref: #34
2026-09-18 23:39:55 +08:00
hectorqin 7b9c786c83 feat(web): 纯文本排版改在客户端,并让顶栏底栏一起收放
现状问题。TXT 的段落和缩进原本由服务端决定:`chapter-html:<n>` 把章节渲染成
带 `<p>` 的片段,客户端只是把它塞进 shadow root。于是三件事同时不对。一是缩进
设定只对服务端已经渲染好的那份生效,windowed 路径拿到的 `chapter:<n>` 是纯字符
没有段落,同一个读者同一本书会因为走哪条传输而长得不一样。二是抓取来的文件里
用来当缩进的段前空格被原样留下,和读者的缩进叠加,恰好在最需要这个设定的文件
上看起来像没生效。三是这些设定是每台设备各自的阅读偏好,烧进响应就等于把每次
调整变成一次往返。另一件事是顶栏和底栏可以各自隐藏,读者收起导航后底栏仍压着
每一页的最后两行。

方案依据。把「一段是什么」收敛到客户端的唯一定义(`formats/segments.ts`),
本地加载、windowed、流式三条路都走它,服务端的职责缩小为交出完整章节和在原文
上断段。`ReaderView` 在渲染前对纯文本章节再过一遍这个拆分(按块读回、拼回空行
再拆,所以服务端断得对不对不影响观感),缩进、段间距、去掉段前空格因此无需请求
即可作用于两条路径。顶栏与前底栏由 `.reader-screen` 上的一个 `data-chrome` 统一
开关,收起时两条栏一起归零高度并滑出、正文长满整屏,同时右侧浮出一列快捷按钮,
否则收起工具栏会变成「必须离开才能操作」的状态。

放弃方案。把服务端的 `chapter-html` 直接删掉:`href` 同时是阅读位置的标识,且
`format: 'html'` 字段已是既有契约,删掉会让旧客户端与旧位置一起失效,所以保留
作为传输,只去掉它承担的排版职责。让缩进靠服务端属性(`data-indent`)继续驱动:
那正是「设定是响应的属性」这一问题的根源。收起态用两个独立开关:写法上就能只藏
一半,而这恰是读者报的那个 bug。

改动点。新增 `web/src/formats/segments.ts`(分段、去段前空格、HTML/节点两种形
式、跨 chunk 的 carry 判定);`reader-view.ts` 增加 `retypePlainText` 并去掉旧
排版表里读服务端属性的规则;`render/text.ts` 按 `carry` 拼接跨块段落;服务端
`text-html.ts` 去掉 `indent` 选项、不再截断;`reader-chrome.tsx` 增加侧边快捷列
与设置面板顶部的「快捷」一行;`reader.css` 增加两条栏的收放与按钮列样式;图标
集新增 `text-indent` / `text-type` / `spacing`;`docs/ui.md` 补 §3.7 的边界说明
与 §3.8 沉浸式阅读。

验证方式。`cd web && npm test`(369 条)、`cd server && npm test`(205 条)全绿;
`npm run build` 通过;`npm run ui:review` 全绿,并新增「收起工具栏: 顶栏与底栏一
起走 / 都不|不可见 / 正文占满整屏 / 右侧仍有快捷按钮」四条尺寸断言(截图见
`docs/ui-review/03-reader-no-chrome.png`),评审夹具改用全角空格缩进,使「段前
空格被去掉」在截图上可见。

Ref: #34
2026-09-18 19:09:51 +08:00
hectorqin cdc95d8d27 fix(web,server): 阅读页可读性与面板、翻页、同步频率、事件层级
用户报的八条里,这一提交覆盖了除图标对齐之外的七条。每一条都先做成能复现的
测试或量出来的数字,再改。

## 2. txt 没有处理就渲染,没有默认样式,也无法调整

三层都缺,所以修了三层。

服务端新增 `chapter-html:<n>`,把纯文本渲染成带 p 的片段
(`indexer/formats/text-html.ts`)。空行断段;「上一行以句末标点结尾、下一行以
句子开头」也断段——抓取来的网文常常每行一段且没有空行,少了这条整本书是一块。
章节标题行提升成 h3,否则正文会变成「第二章 落雨雨来了。」。引用仍是
`chapter:<n>`(它同时是阅读位置的标识,改它会把现有读者打回第一章),新增
`format` 字段说明取哪一份;`chapter:` 的 256KB 截断也不再作用于这份渲染结果——
那个截断会把长章节切一半。

客户端新增 `TXT_STYLESHEET`,只在 `format === 'txt'` 时注入 shadow root,作用域
`.txt-body`,所以 EPUB 作者自己的样式一点不受影响。默认两字符首行缩进,用
`2em` 而不是 `2rem`:缩进要跟字号缩放。

阅读设置新增「正文」一节(段落缩进 / 段间距 / 编码),只在 TXT 上出现,前两项
是写在 stage 上的自定义属性,所以调整不需要重新注入章节。

## 3. 翻页异常,往前翻页还会来回跳

`previous()` 用 `scrollTop > 8` 判断「还有没有地方可退」,`next()` 用
`scrollTop < limit - 8` 判断「还有没有地方可进」,然后两边都以 0.9 屏步进。
这三个数字互不相容:forward 落到的位置,back 不认为那是边界。读者看到的就是
来回跳。

改成按**页序号**算术:整屏步进,页边界由滚动**范围**(`scrollHeight -
clientHeight`)而不是内容高度推出。这一个区别是页数和可翻次数对不上的原因——
2.31 屏的一章,按内容高度算 3 页并请求 offset 0/723/1446/2169,而 2169 会被
夹到 1674,也就是第 2 个边界,于是第四次按下的位置计数器不认。

`stepSection` 改成 await:它在跨章时是 fire-and-forget,而 `turnPage` 在
`previous()` 返回后就播放动画——动画演的是读者刚离开的那一章。

## 4. sync 接口会不停的重复渲染,sync 接口调用太过频繁

两处。`onPosition` 每个位置调三次 `patch()`,而 `patch` 会重绘整棵 chrome
树,滚动时每帧三次;合并成一次。

`flushProgress` 无条件 `syncNow()`,于是 1.5 秒一轮 POST+GET,读者只要在读就
一直发。`SyncEngine.schedule()` 去抖并**保证尾次**——没有尾次的话,读者翻完
最后一页就关书,那一页永远发不出去。

## 5/6. 布局样式,以及目录/设置改半屏

面板从 `inset: 0` 改成底部半屏 + 遮罩 + 把手,上半屏正文仍然看得见:选章节、
调字号都是对下面那一页的调整,全屏让读者为了做调整而离开那一页。

状态行从 flex 列里的一行改成浮在正文上的药丸。横条每出现一次就把正文推下去
一行再收回来,也就是把读者正在读的那句挪走。`ui:review` 有一条断言盯着它。

页脚改成两行不同职责:控制行居中、读数是三个固定位置的等宽数字。目录行改成
「序号 + 标题」,当前章高亮一次而不是每行都划线;当前窗口到不了的行标记出来
而不是静静地没反应。

## 7. 点击页面的事件处理层次

一次触摸只属于一个目标,最里面那个赢。两个真缺陷:

- 必须用 `composedPath()` 而不是 `event.target`。书的正文在 shadow root 里,
  shadow 边界会**重定向**事件,所以 target 是 shadow host——用 target 写不出
  「段落」和「同一个 shadow 树里的按钮」的区别。
- **阅读面自己是个滚动容器**。滚动模式就是让 shadow host 滚起来,于是「可滚动
  的祖先拿走手势」这条规则让它吃掉了每一个手势:翻页层级完全正确,但永远
  到不了。这是「点击页面没反应」的真正原因。

另外,按到控件的触摸不再被吞掉 click——之前工具栏按钮可见、可点、没反应。

面板打开时它拥有点击:旁边露出的那一条正文不翻页。

## 8. 端到端测试要做 UI 评审

新增 `web/tools/ui-review/`:真实 Chromium(390×844、2x)加载**生产产物**,
对着替身 API 逐屏截图并测量。截图给人看(「只是丑」不是测试失败,但是缺陷),
尺寸给流水线断言(面板不遮住整页、留出 ≥120px 正文、有遮罩和把手、状态提示
不推动正文、控件 ≥28px 且有名字、没有横向溢出)。接进 `client-ui-review`
流水线,只在界面相关文件变化时触发。

这套工具第一次跑就发现了四个真缺陷:`api.toc()` 返回形状不符时 `openStaged`
用 TypeError 打死整个 open(现在回退到 manifest 自己的条目);状态行一旦进入
loading 就再也清不掉(`hideStatus` 的守卫挡住了唯一的出口);翻页步进的 0.9 屏;
以及上面那条阅读面吃掉手势。

## 测试

web 351 项、server 206 项全过。新增:`gestures.test.ts`(事件层级优先级)、
`reader-paging.test.ts`(页序号算术,含子像素取整与滚动范围)、
`mount.test.ts`(单次渲染与卸载后丢弃更新)、`text-html.test.ts`(分段、
转义、标题提升、不截断)。
2026-09-18 12:39:45 +08:00
cnb.dQC5J6ohAGA 780849c1b4 fix: 修复跨文件系统上传报 EXDEV 错误 2026-09-17 17:30:59 +08:00
cnb.dQC5J6ohAGA b7ffa42c80 feat(library): 支持上传书籍与批量管理 2026-09-17 16:50:47 +08:00
cnb.dQC5J6ohAGA 282e0426c2 feat(library): 新增书库管理页面 2026-09-17 12:54:52 +08:00
cnb.dQC5J6ohAGA 0f53088ffe fix(docker): 允许非 root 用户写入挂载的 /data
服务端以非特权用户 `reader` 运行,镜像里构建时做的 `chown /data` 在
`/data` 被 **挂载** 时完全失效:bind mount 的属主来自宿主机目录,宿主机上
`./data` 不存在时 Docker 会以 root:root 新建它,于是服务端写签名密钥时
直接以 `EACCES: permission denied, open '/data/token.secret'` 崩溃退出。
命名卷继承镜像的 chown,所以只有 bind mount 受影响,而 compose 与 README
推荐的正是 bind mount,等于默认路径就是坏的。

改动:

- 新增 `server/docker-entrypoint.sh`:以 root 启动,只在启动时把 /data
  这一层的属主改成 reader(不递归整棵树,避免大缓存目录每次重启都被
  chown),随后用 su-exec 降权到 reader 再 exec 服务端。服务端仍是 pid 1,
  main.ts 的 SIGTERM/SIGINT 优雅退出照常工作。若容器本来就不是 root
  启动(`--user`),脚本直接 exec 命令,不尝试改属主。
- `server/Dockerfile`:装 su-exec(约 10KB;alpine 的 busybox 没有等价的
  setpriv,util-linux 又不在基础镜像里),加 ENTRYPOINT。去掉 `USER reader`,
  因为降权改由入口脚本完成。
- `server/src/config/index.ts`:写密钥失败时,EACCES/EPERM/EROFS 不再只抛
  errno,而是报出「DATA_DIR 必须可写」并给出两种修法(改宿主机属主,
  或设 READER_TOKEN_SECRET 跳过写文件)。这是自部署最高频的坑,
  直接给出下一步能省掉一整轮排查。
- 新增 `server/test/config.test.ts`:覆盖密钥持久化与复用的稳定性、
  READER_TOKEN_SECRET 优先时不落盘、目录不可写时错误信息含修法、
  DATA_DIR 落在 BOOKS_DIR 内被拒。权限用例在非 root 下运行,
  以 root 跑测试时跳过。
- README 增加「故障排查」小节,写明成因与 4 步自查。

验证:`npm run typecheck` 通过;`npm test` 149 项 148 通过 1 跳过
(跳过项即需非 root 的权限用例,已用非 root 单独跑通);以非 root 手动
复现 EACCES 路径,确认错误信息与修法正确;入口脚本的 chown + 降权流程
手动模拟通过。
2026-09-17 10:16:11 +08:00
cnb.dQC5J6ohAGA 0370cd98e8 feat: HTTP TTS 与双端兼容朗读,书架体验与自定义设置
朗读(Closes #13 的朗读部分)

新增服务端 HTTP TTS 代理与客户端第二、第三个朗读引擎,三者在同一个
SpeechEngine 接口下可切换:

- 服务端 GET /api/v1/tts 代理 TTS_URL 指向的合成服务(edge-tts / Piper
  等兼容形态),只代理不内置合成模型;GET /api/v1/tts/voices 回答
  「这个实例能用什么」,未配置时返回 http:false 而不是 404。
  按句合成、磁盘缓存、上游返回非 audio/* 时明确报错(一个指到网页的
  TTS_URL 会以 200 返回 HTML,塞进 audio 里既没声音也没事件)。
  /tts 是精确的查询串令牌白名单项之一:<audio src> 无法带 Authorization。
- 客户端 HttpTtsEngine 用 <audio> 而不是 Web Audio,事件驱动推进队列。
- 客户端 NativeTtsEngine 走 Android 外壳新增的 speech 接口(shell v3):
  WebView 自带的合成器与系统的是两个引擎,手机系统装好的中文语音经常
  不在 getVoices() 里,这是「朗读没有中文声音」最常见的原因。
- speechAvailability 决定 auto 用哪个引擎:native → system → http。
- 原 TtsEngine 抽出 loadQueue/onChunk/onState 为公开字段以结构化满足接口。

书架

- 排序:最近更新 / 最近入库 / 书名 / 作者,中文按数字大小排。
  排序规则抽到 shelf-order.ts,离线路径与服务端用同一条规则。
- 密度、作者显隐、封面进度条;搜索一键清空 + 回车即搜;下拉刷新。
- 服务端 BookDto 增加 addedAt,否则客户端离线排序无法复现服务端结果。

自定义设置

- 朗读引擎可选(引擎多于一个时才显示该行);音调行只对有声调的引擎出现。
- 书架设置独立成 sheet,与阅读面板分开。

测试与文档

- server: tts.test.ts(10 例)。web: tts-http / speech / shelf-order
  新增 39 例,合计 245 passing。
- android/scripts/check-shell.sh 修正为同时校验嵌套的 speech 接口,
  之前它只能看到一个顶层接口。
- README / docs/api.md / docs/architecture.md 补齐三个引擎的取舍、
  环境变量与错误码。

Ref: #13
2026-09-16 23:29:41 +08:00
CodeBuddy b9b9c63605 merge: 把 main 合并进按窗口读取分支,并修正目录书页契约的两处缺陷
冲突只有两处文档(README 的测试计数与端点清单、docs/api.md 的 /manifest 说明),
两边各自都是对的,所以按并集处理:计数取本分支的实际值,端点清单保留 main 新增的
/items、/toc、/file、/library/formats。

合并后按契约把两边一起验了一遍,发现目录书这条链路上有两个会被静默吞掉的缺陷,
都是「服务端仍回 200、读者看到的是坏内容」那一类:

1. 「卷」的判据里有一句 `images === 0`。整卷系列里放一张封面扫描是常态,于是
   `第01卷.cbz` 与 `第02卷/` 并列、旁边再有张图时,整个系列会被判成「不是漫画」,
   而它也不是别的格式的书——书架上直接消失。判据改成按来源问:压缩包的页就在包里,
   一个压缩包本身就是一卷;卷子目录要往下看才知道有没有页。散图仍由 MIN_IMAGES 管,
   既不能否定同级存在的整卷,也不该在自己是唯一页源时被当成书。

2. 页的资源引用被 API 层按「列表下标」拼成了 `page:<index>`,而这个格式的引用形态是
   `page:<卷>:<页>`。「一个压缩包算一页」意味着一个卷在列表里的下标与它在卷里的位置
   无关,于是按 `page:1` 去问一卷时格式处理器解不出引用,异常回退到「当普通文件发」,
   客户端请求一页、拿到的是那个压缩包——状态码 200,内容不对。引用现在由同一次页面
   遍历给出(manifest 的 files[].ref),/file 原样回传,不再有第二处实现。

顺带修正三处文档与实际不符的地方:`.cbr`/`.rar` 并未被解析(原「不做」清单暗示只有它
不做)、嵌套深度措辞与实现(读到第 4 层,且内嵌压缩包不展开)、README 的测试计数。

server 137/137(typecheck + test + build),web 179 vitest + 29 node:test(typecheck + test + build)。
2026-09-16 22:49:45 +08:00
cnb.dQC5J6ohAGA c18664f1e3 feat(reader): 按窗口读取、真机可编译的 Android 壳,以及分章契约的收口
两件事:把 PR #8 里那四项合并进来(A 选项),然后把 issue 里一直没落地的
「一次只取一章」从服务端契约一路做到客户端。

## 一、PR #8 的四项(Android 外壳最值钱)

- Android 原生整页图渲染:NativePageView + 桥接 v2(renderPage/hidePage)。
  只画整页图,EPUB 永远留在 WebView;画不了就返回 false 回退,不出现空白页。
- 离线队列:脏集合语义固定为契约,补上「请求在途时位置被改写」的三条用例。
- TXT 编码探测:GB18030 与 Big5 都解一遍再按损坏证据评分选优。
  原来「先 GB18030 失败再 Big5」永远走不到第二步——两者字节范围重叠,
  同一串字节通常两边都能解出合法字符,繁体书会整本乱码。
- 格式嗅探:magic number 优先于扩展名,并明确拒绝非书籍的媒体容器。

## 二、Android 壳第一次真的编译通过

上一轮说「没有 JDK/SDK,只写了契约」,这一轮补上了环境并真的编译。
编译立刻暴露三处——都是只有编译才能发现的:

- colors.xml 的注释里写了 --reader-paper。XML 禁止注释中出现双连字符,
  AAPT 直接拒绝整个文件。Android 壳此前从未构建过,所以从未发现。
- NativePageView.kt 用了 Uint8Array。那是 JavaScript 类型,Kotlin 里不存在;
  桥接的另一侧是 ByteArray。
- AndroidManifest.xml 引用了 @mipmap/ic_launcher,而资源里没有这个文件。

三条都让 :app:assembleDebug 失败,也就是说这个 APK 此前根本产不出来。
现在是 BUILD SUCCESSFUL,产物 3.6MB。

## 三、分窗读取:从契约做到客户端

服务端:
- /manifest 省略 group 现在等价于 group=0。原来省略意味着「发全部条目」,
  于是一本 1200 章的书在它最需要快的那个请求(打开书)里收到 1200 条记录,
  等于分窗对不传参的客户端完全失效。要完整结构用 ?group=all。
- /toc 在没有 OPF 标题映射时回落到章节自身的第一个标题。手作 EPUB 大量
  没有标题映射,原来整本目录会读成「第 1 章 … 第 300 章」,等于没有目录。
- 扫描时的 spine 读取不再解析 NCX 与 nav:扫一遍全库本该只读一次包文档。
- 尾部 Range(bytes=-N)在超过 8MB 时退化为整包响应并撤回 Accept-Ranges。
  前向流无法跳到文件末尾,服务这种请求意味着为三个字节读完整个 400MB 漫画包。

客户端:
- 新增 formats/remote-zip.ts:走 HTTP Range 的只读 ZIP,让浏览器端也能不下整本书。
- 新增 formats/windowed.ts:分窗文档。分窗是传输边界,目录必须完整,两者分开
  —— 合在一起就是原来目录面板显示「第 1 章 – 第 40 章」的原因。
- reader-screen 接上 /toc,并且点击目录项会先取对应窗口再跳。

## 验证

server:  133/133(typecheck + test + build)
web:     179 vitest + 29 node:test(typecheck + test + build)
android: BUILD SUCCESSFUL,app-debug.apk 3.6MB
shell:   契约双向校验 + 渲染模式词表校验通过
2026-09-16 19:51:38 +08:00
cnb.dQC5J6ohAGA 5f1e29a3e5 fix(server,docs): 目录书页面清单成为契约,修正三处会让书静默消失的缺陷 2026-09-16 18:40:37 +08:00
cnb.dQC5J6ohAGA ca68a03e5d merge: 解决 auto/init-server-scaffold-81b3 与 main 的冲突
两个分支各自演进:一侧引入格式注册表、H5 客户端与 Android 外壳,另一侧
引入漫画目录书与逐页取图。冲突集中在扫描器、metadata、library 路由和 web
客户端,逐项按「两边能力都要保留」的原则合并。

合并后修复了 4 个被冲突掩盖的真实缺陷:

- scanner 同时保留了两套目录书实现,类型检查与测试均无法通过;改为统一
  走格式注册表,目录书在文件遍历之前发现,其内容不再重复入库。
- /manifest 的 files 对漫画目录书返回的是文件夹路径,客户端无法逐页取图;
  改为由 manifest 派生真实页路径。
- 单个文件端点只认 book_files 行,漫画目录书的每一页都 404;改为目录书
  额外认领其目录下的路径,并修正路径归一化使穿越尝试仍被拒绝。
- 目录书判定只看图片数量,会把夹着一本书的目录整体吞掉;改为注册表驱动
  的判定,含书籍文件的目录始终是书架。

同时修复了 web 端两套测试运行器互相无法执行对方用例的问题(vitest 与
node:test 混跑会静默跳过),以及 web 侧 5 处类型错误。

Ref: #9
2026-09-16 18:04:11 +08:00
CodeBuddy 5e8d14b460 fix(web): 用 iframe 承载章节文档,浏览器实测逼出来的四处修正
真机(Chromium,390x844)跑通整条链路后,之前"看起来对"的地方全都露馅了。
这一轮把渲染层按实测重做,四个都是必须修的:

1. **章节必须是真文档,不能是 shadow DOM 里的片段。**
   章节里的 <img src> 和 <link rel=stylesheet> 是浏览器自己去请求的,
   没法带 Authorization 头。把它们注入宿主文档的结果是每个插图、每个
   样式表都 401 —— 书渲染成无样式无图。iframe 是真浏览上下文,它的
   子资源请求是普通导航,URL 里带什么就发什么。所以:外壳用 shadow DOM
   (挡的是客户端样式往书里漏,这是它真正能挡的方向),书本身用 iframe。

2. **子资源的 token 只能走 query。**
   服务端新增 `queryTokenAllowed`,白名单式放行:仅 GET、仅
   /api/v1/books/*/assets|cover|content。触发扫描、写元数据这类端点一律
   不接受 URL 里的 token —— 那才是真正会泄到日志和 Referer 的地方。
   新增 5 个测试固定这条边界。

3. **`column-fill: auto` 需要确定高度,否则根本没有分页。**
   body 不设高度时它会撑到内容高度,整章排成一列,scrollWidth 等于
   clientWidth —— 翻页手势完全无效,一章只显示一页。修完 390px 宽的一章
   是 3732px / 9 页,翻页步长精确 422px(390 + 32 gutter)。
   同时去掉 scroll-snap:吸附目标是列边缘而不是我做出来的页边界,
   会导致 scrollLeft 落在两页之间、甚至一次跳两页。

4. **`pageWidth` 是上限不是实际宽度。**
   手机上 720px 的列比屏幕还宽,同样退化成"一章一页"。取
   min(读者设置, 实际宽度)。

另外发现并按格式分流修好的:PDF(kind=document)之前落进了图片翻页器,
读者看到的是一个破图;现在走 PdfReader。
封面 <img> 也漏了 token,一并改成 api.coverUrl()。

顺带把一个设计缺陷拆开了:目录不再从 manifest 的 groups 取。
groups 是传输分窗,它把目录显示成「第 1 章 – 第 40 章」——服务端的分页
边界漏进了 UI。新增 /books/:id/toc,按格式返回真正的目录
(epub 的 spine 标题、txt 的章节、漫画的卷),与分窗互不干扰。
2026-09-16 15:28:34 +08:00
CodeBuddy 0a17174fb8 fix(server): 让解析器修复能真正到达已入库的书
改 EPUB 读取时发现两个连在一起的坑,都会静默留疤:

1. `spineLength()` 用内存里的 zip 打开,但里面调用的 `readPackage` 又要求
   从文件读,于是每本 epub 的 page_count 都是 null 且不报错。
   `null` 同时也是"这个格式本来就报不出页数"的合法答案,下游无法区分。

2. 更麻烦的是:即使修好 1,已入库的书也修不回来。变更检测基于内容哈希,
   而解析器修复不改变任何字节,扫描恰好会跳过最需要重读的那些文件。
   于是坏结果永久留存——这正是这个 bug 在真实书库里的表现。

修法是给解析结果带上版本号:`book_files.parse_version` 记录是哪一版解析器
写下的,`PARSE_VERSION` 一旦提升就强制重解析一次。自部署用户不会按时升级,
所以版本得跟着数据行走,不能只活在进程里。

配套:schema 加一段可重复执行的增量迁移(SQLite 不支持 ADD COLUMN IF NOT
EXISTS,靠 duplicate column 错误判断已执行过),旧库能原地升级。

新增的日志出口 `lib/log.ts` 是因为扫描器和解析器跑在定时器里,没有请求
可以借 logger;静默正是 1 得以存在的原因。
2026-09-16 14:48:35 +08:00
CodeBuddy d0a475919d feat(web): H5 客户端,精排 epub 渲染 + 多格式阅读
用户的三个问题在这里落地:

1. 「H5 是一个完整的客户端」——同一份构建产物既由服务端托管(`/` 直接
   就是完整阅读器),也可被 Android 外壳打进 assets 复用,两种宿主只差
   一个 serverUrl。

2. 「纯 H5 时 epub 等资源是否需要 iframe 或 shadow DOM」——用 shadow DOM,
   但不是为了隔离出版方样式(那做不到也不该做:书必须能给自己排版)。
   它挡的是反方向:客户端自身的 CSS 不会漏进书里。iframe 只在 PDF 那一处
   用,因为 PDF 需要自己的浏览上下文,理由写在 render/container.ts 里。

3. 「Android 端 epub 用 H5 渲染,其他类型用原生」——按 kind 分流,
   capabilities() 明确声明:reflowable 走 WebView,paged/document 在
   Android 交回原生。理由写进 reader-view.ts:WebView 每页一个合成层,
   300MB 的画册在一台中端机上就是 OOM。

服务端同步配合的两处:
- 资源流式下发 + HTTP Range
- epub 按包内路径寻址、items/manifest 分窗

前端无框架:渲染热路径是命令式 DOM 操作,引框架只会把"最小覆盖 CSS"
的策略搞复杂;最终产物 35KB(gzip 10.7KB)。
2026-09-16 14:45:04 +08:00
CodeBuddy 80454c2491 perf(server): 按需读取书籍容器,章节/页流式下发
EPUB 与漫画此前每次 manifest / assets 请求都整包解压(EPUB 走 JSZip)。
一本 50MB 画册的每次翻章都会把整个压缩包读进内存;扫全库时更糟。

- EPUB 改走项目自研的 zip-reader,只读中央目录 + 按需解压条目
- 新增 ZipArchive.openStream():stored 直接读,deflate 走流式 inflate,
  单页取图不再需要整包驻留
- 资源响应支持流式下发,seekable 资源支持 HTTP Range(断点续传、
  PDF 跳页、漫画跳卷都靠它)
- epub 章节改为按包内路径寻址(xhtml:OEBPS/ch1.xhtml),不再用 spine 下标:
  分窗加载后下标在不同窗口里指向不同章节
- items/manifest 支持 ?group=N 分窗,group 自带 offset,客户端拿到单窗
  也能定位到全书位置
- /manifest 直接带回可寻址结构,打开一本书从两次往返降到一次
2026-09-16 14:40:28 +08:00
cnb.dQC5J6ohAGA 664141f0f1 feat: 支持 txt 与漫画等多种格式,格式层改为注册表
原先只认 .epub 和 .pdf,本地书库里占很大比重的 txt 长篇与漫画
(cbz、图片目录)会整片「消失」—— 用户能看到文件在 NAS 上,却在
书架里找不到,这是最让人困惑的一类问题。

格式列表硬编码在三处(扫描器白名单、parseBookFile 分支、content-type
映射表),加一种格式要同时改三处,漏掉任何一处就得到一个「扫到了但读不
出来」的安静 bug。这次把格式收进注册表,并补齐四种格式。

新增格式
- cbz / zip:按包内页序自然排序翻页,扫描时不解压到磁盘
- 漫画目录:一个目录一本书,子目录为卷,支持卷/页两级寻址
- txt:UTF-8 严格解码失败才回退 GB18030(UTF-16 BOM 也识别),
  按中文网文章节标题分章,正文可按章节或字节偏移读取
- 单张图片按单页读物入库
- 明确不支持 cbr/rar:需要非自由解压实现,与单容器约束冲突

格式层
- registry.ts 定义 handler 契约(parse/manifest/asset)与扩展名路由,
  扫描器与 HTTP 层只与它对话;扩展名白名单、能力声明、遍历过滤都自动派生
- zip-reader.ts 自研只读 ZIP,只用 node:zlib。JsZip 会把整包解压进内存,
  而漫画动辄几百 MB,首次扫描真实漫画库会 OOM
- natural-sort.ts 自研自然排序:page10 必须排在 page2 之后,
  否则整本漫画的阅读顺序是乱的;不用 localeCompare 是因为 ICU 行为
  在不同平台上不一致
- epub 章节返回前重写章节内的相对资源引用,否则 WebView 里图片/字体/
  样式全部 404;绝对 URL、data: URI 与文内锚点保持原样,脚注不会坏

修复
- 目录型读物认领后,其内部文件从索引中排除,否则同一部漫画会以
  「系列 + 每卷 + 每页」的形式重复出现
- 目录认领顺序改为从外往里:反过来的话一部漫画会变成 N 本独立卷
- 主键新增原地编辑规则:txt/漫画没有内嵌标识,追加一章会算出新 id
  并把读者进度留在旧记录上。现在同路径原地编辑算更新,移动/重命名
  仍走内容哈希规则,所以进度都保得住
- 损坏的 .cbz 仍然入库并记录失败原因,而不是静默从书架消失
- PDF 不再用正则猜页数(会匹配到无关的 /Count 并报出错误页数,
  让客户端分页错乱),改为诚实上报 null
- 清理已迁出的 parseBookFile / pdfInfo / SUPPORTED_EXTENSIONS

测试
- 新增 65 个用例(共 92 个):真实 zip 与真实文件,不用 mock;
  fixtures 由测试自己生成,不需要签入二进制
- 覆盖自然排序、编码探测、ZIP 边界(ZIP64/加密/超大条目)、
  目录认领、原地编辑、只读约束、各格式的读取端点
2026-09-16 14:31:15 +08:00
cnb.dQC5J6ohAGA 2233b295bb feat: 初始化自部署书库服务端
按产品设计 v0.2 落地服务端,覆盖第一版范围中的扫描、元数据、权限与同步。

核心决策:
- 书籍主键用 dc:identifier + 内容哈希,不用文件路径。
  重命名/移动不丢进度,副本自动合并,重新编码的文件保持独立。
- 库目录只读挂载,写操作全部落到 DATA_DIR。
  启动时断言 DATA_DIR 不在 BOOKS_DIR 内,路径解析统一走 resolveInside
  拦截逃逸,扫描不跟随符号链接。
- 元数据优先级:手动修改 > 内嵌 > provider > 文件名解析。
  手动修改独立成表,扫描永远碰不到,可单字段撤销。
- 中文文件名解析采取保守策略,无证据时不猜测作者方向。
- 离线合并用 LWW,笔记删除写墓碑,批次单事务。
- 无原生编译依赖:node:sqlite 与 scrypt 均为内置模块。

交付:
- Fastify + TypeScript 服务端,Docker 单容器
- 27 个自动化测试用例全部通过
- AGPL-3.0,README 做到一条命令跑起来
- CNB 流水线:typecheck / test / build
2026-09-16 14:07:25 +08:00