36 Commits
Author SHA1 Message Date
hectorqin eeb508f880 feat(media): add global and per-user controls with reading-only mode 2026-11-07 12:09:26 +00:00
hectorqinandClaude Opus 5.5 120827bad6 docs: bring every document in line with the code
Audit of README, docs/, android/README.md, android/contract.md and CLAUDE.md
against the current server, web and Android code: V3 navigation and settings
locations, Docker Hub release images, GitHub clone URL and CI, missing API
endpoints and error codes, architecture of lazy chunks/themes/sources, media
pages and routes, the Android bridge contract, and the new file-management and
member-upload permissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:03:45 +00:00
hectorqin 875fec84dd fix(media): sync prototype routes and review tooling 2026-10-08 09:51:42 +08:00
hectorqin dec8ec8c44 docs: document media features and remove remote source plugin guides 2026-09-28 10:32:52 +08:00
hectorqin 1d86efab34 feat(reader): add selection annotations and server-side book search 2026-09-24 16:03:35 +08:00
hectorqin 0b7f901db5 chore(server): change default listening port to 5888 2026-09-24 14:50:13 +08:00
hectorqin 72c4405435 chore: keep host repository plugin agnostic 2026-09-22 17:47:21 +08:00
hectorqin 5772014d3f docs: split bilingual readme and getting started guides 2026-09-22 17:12:49 +08:00
hectorqin 4af639d3b8 docs: document streamed source search and upload semantics 2026-09-22 16:54:18 +08:00
hectorqin be219e5090 docs: align library write model and deployment guidance 2026-09-22 16:49:06 +08:00
hectorqin 5ecf918551 feat(plugins): support independently installed npm providers 2026-09-21 10:29:17 +08:00
hectorqin 59faec336a merge: 同步远程阅读界面更新并保留书源配置样式 2026-09-21 09:39:39 +08:00
hectorqin c192fc2f12 feat(sources): add configuration pages and chapter updates 2026-09-20 23:59:12 +08:00
hectorqin 88f7a437f8 Merge branch 'main' into codex/source-plugins
解决 web/src/ui/reader-chrome.tsx 与 web/test/reader-chrome.test.ts 的冲突:
- 导入合并为 toolkit 的 IconButton/IconTextButton/SectionTitle 与 icon.tsx 的 Icon
- 目录面板保留章节出版物的「刷新目录」按钮
- 设置页保留 main 的「界面/阅读设置」分页与「浅绿」主题
- 测试中两边的用例均保留
2026-09-20 22:51:41 +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 d60d7e67f9 feat(reader): 配置四角阅读信息 2026-09-20 17:36:59 +08:00
hectorqin da1aa15174 fix(reader): 稳定阅读位置并重整手机阅读界面
固定工具栏显隐时的正文区域,保存最新阅读偏移并抑制亚像素量测抖动。重组底部四入口、半屏面板和章内滑杆,采用 Lucide SVG 图标并新增浅绿主题;补齐 PDF 内容传递。

验证:构建通过,498 项单元测试、29 项接口及分页测试、30 项 UI 检查通过。
2026-09-20 17:02:40 +08:00
hectorqin 558f44e206 feat(web,server): 书架默认按最近阅读排,书库拆成预览与文件两页 2026-09-19 14:17:55 +08:00
hectorqin 3c3e461a49 feat(web): 界面图标换成自带字体,补 UI/UX 规范并统一设计令牌
按钮里放的是 Unicode 字符——`☰` `⚙` `✕` `⟨` `⟩` `+` `🗂` `📁` `⮝`——
和 emoji。八个字形来自八个字族,光学重量各不相同;`🗂` `📁` `⏮` 在部分平台
被渲染成彩色的,而一个彩色 emoji 出现在单色工具栏里,是「没有设计过」最直接的
信号。不同平台的字体回退还不同,同一个界面在 Android 和桌面浏览器上长得不一样。
此外样式表里所有按钮都没有 `:focus-visible` 规则,键盘走一遍看不到焦点在哪。

所以这次做三件事:图标改成自带字体、把缺失的交互状态补齐、把散落的字面量收成
设计令牌,并把规范写成文档。

图标用字体而不是内联 SVG,理由是**一致性可以被强制**:字体把字形变成字符,它
继承 `color` 和 `font-size`,没有自己的几何,因此没有可以漂移的地方;而三十个
内联 SVG 组件里,颜色、尺寸、线宽每一项都是单个字形可以漂移的位置。字体是
bundle 里的一个 16KB 相对路径文件,`file://` 下能加载,与「一份产物两个宿主」
的既有约束一致。

字形由 `web/tools/icons/` 下的脚本从描边路径编译:路径 → 折线 → 圆头矩形 →
精确布尔并(Clipper)→ TTF。先做过一版「栅格化 + 边界追踪」,失败了:笔画宽度
只有一个栅格单元,而逐单元的边界规则无法表达「边界在一格宽的图形上左右交替」,
结果是一个圆被追踪成 58 段碎片。精确布尔没有这种情形。

放弃的方案:内联 SVG(前面说的漂移问题)、把图标做成 React 组件并逐个手绘
(每个都是独立的几何,光学重量必然不一致)、从 CDN 引用图标字体(本产品必须
在无外网环境下工作)。

界面改动:
- `Icon` 组件 + 37 个字形,码位表由生成器写出,测试直接解析字体 `cmap` 核对
- `IconButton` 的 `icon` 是字形名而非任意 `children`,传错名字是编译错误;
  `label` 必填,字形始终 `aria-hidden`
- 补上全部按钮的 `:focus-visible`(本次之前完全缺失)
- 间距/字号/动效收成令牌;`prefers-reduced-motion` 通过折叠时长令牌实现,
  而不是给每条动画各加一个媒体查询
- 悬停包进 `@media (hover: hover) and (pointer: fine)`,避免触屏上按钮停在悬停态

验证:`web` 336 个测试通过(新增 9 个图标契约测试);`typecheck`、`build`、
`check-shell.sh`、`verify-web-assets.sh` 全过;字体已确认被打进 Android assets。
真浏览器(Chromium + 真实服务端)实测了书架、书库管理、阅读器三个屏幕的浅色与
深色主题,以及键盘 Tab 下的焦点环。产物 259.5KB → 259.5KB(gzip 81.4KB),
新增 16KB 字体、CSS 减到 24.4KB。

Ref: #29
2026-09-18 07:08:02 +08:00
hectorqin ecb2ef4806 fix(web): 修复阅读页章节跳转失灵,补齐上一章/下一章 2026-09-17 23:52:11 +08:00
cnb.dQC5J6ohAGA dbaac99b45 feat(web): 界面加路由,首页重做
首页(书架)此前是纯命令式拼出来的,而整个客户端没有任何路由:URL 从头到尾不变,
返回键、刷新、分享、收藏一概不成立。

## 路由

`ui/router.ts` 把 fragment 解析成 route,shell 渲染 router 报上来的那个:

  #/shelf             书架
  #/book/<id>         阅读器
  #/library/<路径>    书库里的目录

用 fragment 而不是路径,是因为同一份产物要被两个宿主加载,而两个都服务不了任意路径:
Android 从 appassets.androidplatform.net 加载(那里没有服务端),H5 由 reader 服务端
托管(没有客户端路由回落)。路径式路由会在 vite dev 里正常、在两个生产宿主里 404。

返回走应用自己的屏幕轨迹,不走 history.back():应用里的「返回」是「上一个屏幕」,
而浏览器历史里可能有登录表单或别的站点。轨迹为空时(新标签页里粘贴的链接)替换成父级,
而不是压入——否则那个链接会永远压在书架后面。

走进书库子目录用 replace:书库是一个带面包屑的屏幕,走目录是同一个屏幕改自己的参数。
压栈的话,读者走过四个文件夹再按返回会逐层退出来。

带着链接进来而尚未登录时,route 存在 App.pending 里等在登录页后面,登录后直接落到
目标屏幕,而不是落到一个他从未见过的书架。

Android 外壳的返回手势不需要改动:它看的是 WebView 的 canGoBack(),而路由现在真的
往 WebView 历史里写——之前那段注释描述的行为,从这次起才成立。

## 首页

- 先看到书,再看到控件:标题和封面在最上面,搜索在后,排序与数量贴在它们作用的那一节上。
  旧版把搜索框、数量、四个排序 chip 依次排在封面之前,三行控件才轮到第一本书。
- 排序栏是唯一吸顶的东西,它承载的是读者会滚回去改的两件事。
- 空状态给一条出路(打开书库管理 / 刷新 / 清除搜索),而不是一句「书库还是空的」。
- 冷启动画骨架屏,且按当前密度画——密度不对的骨架屏会让读者看到布局动两次。
- 封面缺图时把书名画成封面,而不是一个灰色方块。

## 顺手修掉的 5 个真缺陷

1. mountUI 在 unmount 之后仍会渲染。屏幕是异步加载的,离开后那条迟到的响应会把
   整棵树画进一个刚被清空的容器;书架迟到的响应会把自己一屏封面画进书库管理器。
2. --safe-left/-right/-top/-bottom 从未在生效的样式表里定义过,只有一份没人 import 的
   死样式表定义过。未定义的自定义属性会让整个 calc() 失效并静默丢弃声明。
3. 封面在 flex 列里没有显式宽度,aspect-ratio 盒子因此收缩到内容宽度:只有书名的封面
   塌成 42px、有图的占满卡片,整排看起来参差不齐。
4. 阅读列在宽屏上被拉伸到 9 列 45px 的封面。现在内容列封顶 68rem,封面随断点变大。
5. 删掉 web/src/ui/styles.css——414 行从未被引用的旧样式,以及 .book-highlight 等
   已经没有任何代码产出的规则。

## 验证

- web 全量 284 项测试通过(新增 router 19 项 + 链接契约 4 项)。
- typecheck / build / check-shell.sh / build-web-assets.sh 均通过。
- 真浏览器实测:深链直开、刷新不丢、返回离开屏幕、登录后落回目标、书库走目录、
  空搜索、粘性工具栏、深浅色、390px 与 1440px 两档宽度。
- 体积:251KB → 252KB(gzip 79.1KB)。
2026-09-17 18:49:53 +08:00
CodeBuddy 586bec25b1 refactor(web): 界面层迁移到 Preact,排版层保持命令式
书架、登录、书库管理、以及阅读器的铬层(顶栏/页脚/目录/设置面板/朗读条)
从「构造函数建节点 + 实例字段 + 若干 render*()」改成组件树 + 单一状态。
阅读舞台——分页、注入文档、shadow root、量测列宽——仍然零框架。

为什么换:
- 同一份状态有多个副本。分段控件的选中态既在 settings 又在 aria-pressed 上,
  靠点击处理器手工同步;LoginScreen 的通知/错误/注册开关/按钮文案是四个节点
  被五个地方改,漏一处不报错。
- 重建与状态互相矛盾。朗读引擎行要在 HTTP 能力探测回来时重新生成,而整块
  面板重建会丢掉面板滚动位置,于是长出一套「只替换 [data-speech=group]」
  的定点重建;改成组件后探测只是改状态,diff 天然保住滚动位置。

边界(写在 ReaderScreen 里,不靠自觉):
- stage 由类持有并交给 ReaderView,只通过 ref 回调挂进组件树;组件决定它
  放在哪,内容一概不碰。mountUI() 是组件树与类式屏幕之间唯一的接缝,
  路由仍不认识框架,测试仍打在真实 DOM 上。

配套:
- toolkit.tsx:IconButton / Button / Segmented / SwitchRow / Scrim 这些原语
  各把 a11y 与状态细节收在一处,消掉两处手工 aria-pressed 同步。
- vendor/preact.ts:框架唯一入口,只 re-export preact 与 preact/hooks,
  无 preact/compat、无 react-dom、无额外 signals 依赖。
- tsconfig: jsx=react-jsx, jsxImportSource=preact。
- 修一处回归:书库管理的状态行改由 listing 推导,写入结果跨重载保留。

验证:web 全量 260 项测试通过(manager.test.ts 断言逻辑未改),
typecheck、build、android check-shell.sh、build-web-assets.sh 均通过。
产物 235KB → 245KB(gzip 71.2KB → 77.2KB)。
2026-09-17 17:52:18 +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
cnb.dQC5J6ohAGA 75718e9fb7 merge: 合并 main 并解决阅读界面与分窗读取的冲突 2026-09-16 23:11:10 +08:00
cnb.dQC5J6ohAGA 058bee57a5 feat(web): 丰富阅读界面设置并支持系统 TTS 朗读 2026-09-16 22:55:58 +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 02453c93d3 docs: 更新 README / 架构 / API 文档
记录本轮的四项决定与三个坑:
- 排版策略:出版方优先、变量层可被书拒绝、无 !important
- 渲染分工按内存而非速度划分
- 外壳用 Shadow DOM、书用 iframe 的完整理由
- column-fill 需要确定高度的实测结论
- 分窗与目录分离的原因
- 解析器版本机制与它解决的静默陈旧问题
- 子资源 query token 的白名单边界
2026-09-16 15:49:22 +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