主题
EPUB 可用性 Sprint 1 —— 交接稿
物理位置:
~/reading-browser/docs/plans/epub-usability-sprint1-handoff.md(RB 仓,Tauri/Rust/TS) 上游:roadmap 3-5 EPUB 长期阅读验收(2026-08-07 完成,commitd973d2a) 创建:2026-08-07(由验收会话代写,未动任何代码) 性质:纯 RB 修复任务。零 Supabase、零同步矩阵、零 RVH、预期零 migration。
0. 先读这两份,别重新调查
| 文件 | 里面有什么 |
|---|---|
~/reading-browser/docs/plans/epub-longform-acceptance-handoff.md §4 + §4.1 | 每条缺陷的实测证据(可复现形式)+ 代码锚点 + 已排除的错误假设 |
~/reading-browser/docs/plans/backlog.md §🔴 EPUB 长期阅读 9 条 | 9 条的立项条目(B1-B9),本 sprint 只做 B1-B4 |
证据已经很足,不需要再跑一遍验收。 直接进入修复。若某条的机制描述与你读到的代码对不上, 以代码为准并回头修正那两份文档(它们是活文档)。
1. 本 sprint 的范围:B1 + B2 + B3 + B4(部分)
选这 4 条打包,是因为它们共同构成「打开一本书之后能不能走下去」这一件事, 单修任何一条都不解决问题:
| 条目 | 一句话 | 为什么在同一个 sprint |
|---|---|---|
| B1 开书即死路 | 落封面 + 目录不自动开 + 无「下一章」 | 三条叠加 = 走不下去,本身就是复合缺陷 |
| B2 TOC 间歇性空白且不自愈 | tocItems effect 依赖只有 [activeTabId] | 目录是 EPUB 唯一导航入口,B1 修了它还空 = 白修 |
B3 source_ref fragment 从不剥离(一因三处) | 「继续阅读」→EPUB 死;复习「打开原书」死 | 进书的入口本身是断的 |
| B4(仅「恢复到章」) | addEpubTab 永远落 chapters[0] | 与 B3 纠缠:入口通了才谈得上恢复到哪 |
明确排除(不要顺手做)
- B4 的「章内滚动偏移持久化」 —— 需要新列 → 触发红线 #11(
schema.sql/v1 已冻结, 新列只进新编号迁移,下一个从 v32 开始)。这是独立立项,不在本 sprint。 本 sprint 的 B4 只做到「重开落回上次那一章」,用已有的reading_pages.source_ref里的锚点即可, 不需要任何 migration。 - B5-B9(锚点定位失准 / TOC 高亮丢失 / 全书搜索 / 改名丢划线 / 列表元数据)→ 各自独立会话。 B5+B6 是「导航精度」一组,B7-B9 是打磨。
2. 四条的修法建议(不是命令,你可以推翻,但推翻要说理由)
B3 最先做 —— 它是另外三条的地基,且必须三处一起改
reading_pages.source_ref 对 EPUB 存的是 /Users/…/book.epub#pgepubid00010。 Rust 侧早就处理了(commands/notes.rs:236 classify_source_type 的注释 「Strip fragment first so /book.epub#ch4 still matches .epub」; notes.rs:293 extract_domain 同样剥,好让 #ch0/#ch1 共用一条域名笔记规则)。 TS 侧没有任何同类 helper(全仓只有 SentenceWorkbench.tsx:67 一处手搓 split('#')[0])。
三处漏点:
| 位置 | 现在的后果 |
|---|---|
src/hooks/useNavigation.ts:110(经 src/pages/HomePage.tsx:482 传入) | 「继续阅读」→ EPUB 整条通路死 |
src/components/AddressBarReviewSegment.tsx:139 | 复习「打开原书」找不到已经开着的同一本书 |
src/components/AddressBarReviewSegment.tsx:145 | 也开不了新的,且 try 块未进入 → 静默无日志 |
建议:在 src/lib/url.ts(已存在)加一个 stripFragment(path),三处统一调用。 一次改完三处——只改一处会让另外两处变成更难发现的残缺态。
⚠️ 验证这条时有个陷阱:
MainApp.tsx:825-828的onOpenFile会先setActiveModule('read')再调openFileByPath,所以即使打开失败,屏幕也会切到阅读模块并显示本来就开着的那个标签页, 看上去像成功了。必须断言rb_state store=tabs:真打开会先关掉当前 tab、再产生一个新 id 的 tab, 且lastActiveAt会变。别信屏幕。
B2 次之 —— 一行依赖的问题,但会让 B1 的修复白费
src/MainApp.tsx:270-279:
js
useEffect(() => {
const tab = useTabsStore.getState().getActiveTab();
if (tab?.type === 'review-source') return; // ← 早退,不写 tocItems
if (tab?.type === 'epub' && tab.epubInfo?.toc?.length) { setTocItems(...) }
else { setTocItems([]); }
}, [activeTabId]); // ← 只认 id 变化openFileByPath 在 closeTab() 与 addEpubTab() 之间隔着 await prepareEpub(filePath) (真实异步边界,React 会在中间提交渲染)。中间那次提交若让 getActiveTab() 落在 review tab 上 走了早退分支,tocItems 就停在 [] 且永不自愈(它不认 tab 内容变化)。
建议:依赖补上 tab 的 epubInfo 身份(例如 [activeTabId, activeTab?.epubInfo]), 或在 addEpubTab 之后显式同步一次。注意早退那一支——它现在是"什么都不写", 可能需要改成"保持原值"或索性移到判断之后。
B1 —— 三条子改动,建议 ①+③,② 慎重
- 开书时自动打开目录:
openFileByPath成功后usePanelStore.getState().openLeftPanel('toc')。 最小、最直接。 addEpubTab跳过纯封面 spine 项(src/stores/useTabsStore.ts:325): ⚠️ 慎重——并非所有书的spine[0]都是封面。若做,判据要基于内容 (如「无文本节点 / 只有一个<svg><image>」)而不是文件名或索引。 本轮实测的 Gutenberg 书spine[0]=wrap0000.xhtml,body只有一个div.x-ebookmaker-cover > svg > image。- 章末补续读入口:
computeNextTocAnchor已经在算下一章锚点了(现在只喂给content-script/features/reader.js:504做正文切片,不驱动任何 UI)。EpubNavBar组件已被删除(MainApp.tsx:250注释尚存),要重新做一个落点。
B4(仅恢复到章)
useTabsStore.ts:325 addEpubTab 永远取 epubInfo.chapters[0]。 打开一本书时,可查 reading_pages 里该 source_ref(剥掉 fragment 后匹配文件路径) last_opened_at 最新的那一行,用它锚点里的章节作为初始章。
- 不需要 migration —— 数据已经在
source_ref里。 - 注意
MainApp.tsx:278让 epub 跳过logResourceOpen,所以reading_pages的行 只在存词/划线时才产生(经ensure_source_for_url)。没存过词的书没有行 → 优雅回落到第一章。 要不要顺带让 epub 章节导航也 touchlast_opened_at,是本条要做的一个判断(做了会让恢复更准, 但会让「继续阅读」列表更吵——见 B9 的粒度问题,别把 B9 一起做了)。
3. 工作方式(沿用验收会话的分工,已验证有效)
🔴 用户管「到达现场」,MCP 管「检查现场」。 需要人操作时写成一次做完的编号步骤交给用户, 等一句「好了」再批量探测。禁止 osascript 合成按键(本机对 dev app 不抵达,硬试会导致误诊)。
已实测固化的选择器 / 能力边界(细节见 docs/smoke-test-runbook.md §实跑校准记录 2026-08-07):
| 能做 ✅ | 怎么做 |
|---|---|
| 点主 webview 里的 React 按钮 | /debug/click(合成 click 会被 React 委托监听接住) |
| 进复习模块 | nav[aria-label] button:nth-of-type(3)(位置型,不吃 locale) |
| 点 TOC 条目 | [class*="space-y-0"] > *:nth-child(N)(先用 rb_dom 读 span 确认落点) |
| 切标签页 | [title*="<书名片段>"](同名多标签用 :first-of-type) |
| 数划线 | 内容 webview 里的 .rb-annotation(annotations.js:14 MARK_CLASS) |
| 做不到 ❌(别再试) | 原因 |
|---|---|
| 打开本地 .epub | macOS 原生文件面板,合成事件碰不到;地址栏也不接受本地路径(useNavigation.ts:78 一律按 URL 处理) |
| 双击存词 / 选区划线 | 需要真实 Selection 与 dblclick,/debug/click 只发单次 click |
探测配方(比 MCP 工具便宜一个量级):
bash
P=$(cat "$TMPDIR/rb-debug-port") # 注意是 $TMPDIR,不是 /tmp
curl -s --get "http://127.0.0.1:$P/debug/dom" \
--data-urlencode 'selector=.rb-annotation' --data-urlencode 'webview=content-5' \
| jq -c '{found:.result.found, n:.result.total_matches}'路由:/debug/{snapshot,snapshot/tab,logs,state,dom} (GET)、/debug/{click,type,scroll,db/query} (POST)。
/debug/logs返回{capacity, console:[{kind,level,msg,source,ts}]}—— 验证 B3 时这里是金矿 (失败会留[warn] [RB] Only EPUB files are supported: …#pgepubid00010)。/debug/db/query是只读的,写不了。rb_state读不到readingPrefsstore(未暴露);可读的有auth/library/language/panel/review/rss/stats/tabs/theme/translation/workspace。
⚠️ 测试数据卫生:auth.rs:196 save_session 每次 token 刷新都会跑 clear_learning_data_if_user_changed(WHERE user_id IS NULL OR user_id != <当前>,清 12 张表)。 这是设计内行为(红线 #5i),但意味着实测期间不要切账号,否则中途的刷新会吃掉你的证据。
4. 验收标准(改完怎么算过)
docs/smoke-test-runbook.md的 Flow 4 从头到尾走通(它已按 3-5 结论重写过,断言是真实的)。- 四条各自的最小验收:
- B1:打开一本没读过的书 → 能直接开始读(不需要先发现地址栏第 4 个图标);章末有去处。
- B2:连续开关同一本书 5 次,目录每次都有内容(这条是间歇性的,单次通过不算数)。
- B3:点「继续阅读」的 EPUB 条目 →
rb_state store=tabs出现新 id 的 tab(别信屏幕); 复习卡的 📂 在「书已开着」和「书没开」两种情况下都要验。 - B4:存过词的书重开落回那一章;没存过词的书优雅落第一章、不报错。
/build-check+/arch-check;若动了.tsx再跑/ui-check。- 若改了 content-script(B1 的 ③ 可能会):必须
pnpm build:cs(它是打包产物,tauri dev不跑打包器);grep 该 bundle 要grep -a。 - 改完回填:
backlog.md的 B1-B4 条目标 ✅ + 链 as-built;epub-longform-acceptance-handoff.md§4 对应行的结论更新(它是那 8 项的真相源)。
5. 环境/纪律速查
- 本仓 =
~/reading-browser。RVH(~/reading_vocab_helper)本任务零涉及;真需要碰 → 新会话。 - 红线 #11:
schema.sql/ v1 已冻结,新表新列只进新编号迁移,下一个从 v32 开始。 本 sprint 预期零 migration —— 若你发现"非加列不可",那是立项信号,先停下来说,别顺手加。 - 红线 #2 无
LOWER()(用COLLATE NOCASE)· #3 content-script 用__TAURI_INTERNALS__.invoke()· #8 组件内禁止 hex 字面量(用lib/design-tokens.ts)· JSX 中禁止中文字面量(走src/lib/strings/)。 - 禁止无 pathspec 的
git reset --hard/git checkout ./git stash push。git add只给具体文件路径,禁止-A、禁止裸目录。 - 动手前
git status+git worktree list;检查有无遗留的pnpm tauri dev孤儿进程 (两个 watcher 抢同一个target/会互相打断)。
6. As-built(2026-08-07 收口)
B1 / B2 / B3 全部闭环;B4 只做「恢复到章」(如 §1 计划)。零 migration,schema.sql 未动。
改了什么
| 文件 | 改动 |
|---|---|
src/lib/url.ts | 新增 stripFragment / fragmentOf(B3 的共用地基) |
src/lib/epubStart.ts | 新文件:resolveEpubStartHref —— 「这本书从哪开始读」的唯一决策处 |
src-tauri/src/commands/epub.rs | EpubInfo 加 start_index;新增 visible_text_len / first_content_spine_index(B1②);新增命令 get_epub_last_read_ref(B4) |
src-tauri/src/lib.rs | 注册新命令 |
src/lib/commands.ts | EpubInfo.start_index + getEpubLastReadRef |
src/stores/useTabsStore.ts | OpenPayload.epub 加 startHref;patchFor 按它算 url + spine 下标 |
src/hooks/useNavigation.ts | openFileByPath 先剥 fragment(B3)+ 解析起始章(B4)+ 成功后开目录(B1①) |
src/components/AddressBarReviewSegment.tsx | 剥 fragment(B3 两处)+ 起始章 + 补 setActiveModule('read') |
src/MainApp.tsx | TOC effect 依赖补 activeEpubInfo(B2);给 AddressBar 传 currentTocHref / onTocNavigate |
src/components/AddressBar.tsx | EPUB tab 上 ‹ › 改成上一章/下一章(B1③) |
src/lib/strings/{panels,locales/{en,zh}/panels}/addressBar.ts | 4 条章节导航文案 |
三件与交接稿预期不同的事(后来者别按原稿走)
B3 是四处不是三处。 前三处剥完 fragment,复习 📂 仍然「点了没反应」——
handleOpen从不切 workspace 模块。复习是独立 module,只设 active tab 用户看不见 (Library 的「回原文」早就显式处理了,vocab/WordMemorySection.tsx::openSource)。§2 里 B3 的验证配方已过期,按它断言会得到假阴性。 原文说「真打开会先关掉当前 tab、 再产生一个新 id 的 tab」——那是 2026-08-06 收口前的行为。现在
openContent见到空白 tab 会就地复用(useTabsStore.ts::canOpenInPlace),tab id 不变。 正确断言:tabs里出现type:'epub'且filePath= 该书路径的 tab。B2 不是「间歇性」,是确定性的。 原稿归因于
closeTab()/await prepareEpub()之间的竞态, 但那条路径 2026-08-06 已不存在。真机制就是上一条的推论:空白 tab 就地复用 →activeTabId不变 → 只依赖[activeTabId]的 effect 根本不触发。 判据:进 Read 模块自动建的那个空白 tab 还活着时开书,目录必空。
实测结论(全部经 /debug 探针断言,非看屏幕)
| 条目 | 证据 |
|---|---|
| B1① | 两次开书后 panel.leftPanel = "toc" |
| B1② | 磁盘直读:spine[0]=OEBPS/wrap0000.xhtml 可见字符 0,spine[1]=157,403 → 落 epubCurrentIdx:1 |
| B1③ | EPUB tab 上 ‹ › 的 aria-label 命中可用态(此前对 epub 恒 disabled) |
| B2 | 走通确定性失败路径(activeTabId 全程为 "2" 未变),目录 35 条 |
| B3① 继续阅读 | 传入带 #pgepubid00010 的 source_ref → 开书成功,filePath 落成干净路径,无 Only EPUB files are supported warn |
| B3② 书已开着 | activeTabId 切到既有 tab,未新建;补模块切换后画面跟着走 |
| B3③ 书没开 | 新建 tab(id 3),落点 #pgepubid00010,无 error |
| B4 命中 | 库里两行(08-07 #pgepubid00010 / 08-05 #pgepubid00021),取到新的那行 |
| B4 回落 | Moby Dick(无 reading_pages 行)→ epubCurrentIdx:1、url 不带 #(= 走 start_index 而非查库)、目录 146 条、无 error |
cargo test --lib 127 passed(含新增 epub::start_index_tests 5 条:SVG 封面 / head title 不计 / script·style 整块丢弃 / 真章节过线 / 中文按字符不按字节)· pnpm test 120 passed · cargo check + pnpm build 通过 · /arch-check H1-H6 零命中、S24=7 S25=0 S27=3 全持平基线 · /ui-check U1=2(永久豁免)U2=U3=0。未改 content-script,故无需 pnpm build:cs。
留下的东西
- B4 章内偏移:需新列 → 独立立项(红线 #11,下一个迁移从 v32 起)。
- TOC 不带锚点的书:
source_ref退化成纯路径、还原不出章节,只能落start_index。同上立项。 - 刻意没做:让 epub 章节导航 touch
last_opened_at。会让章级恢复更准,但改变「继续阅读」 排序行为 = B9 的粒度问题。 - 一条待观察的 warn(非本 sprint 引入,1/14 条日志):
[RB] effectiveSourceUrl: rb-cache:// without review/epub metadata—— 来自复习 webview 加载卡片缓存快照页时__RB_REVIEW_MODE尚未 eval 到位(content-script/index.js注明的既有时序竞态)。 本 sprint 不碰复习 webview 的导航与 meta 注入。要追的话它属于那族时序问题,不属于 B1-B9。