主题
数据卫生 / 资源身份 —— 核验与修复计划
物理仓库:
/Users/larry/reading-browser(RB 桌面端) 创建:2026-08-11 · 更新:2026-08-11(代码已实施 + 存量已清理) 状态:✅ 代码全部落地(T1-T6 + 实机验证暴露的追加 ⑤⑥,质量闸全绿); 存量数据清理 ✅ 已完成;第一轮实机验证 ✅ 4/4 通过; ⬜ 剩第二轮实机验证(追加 ⑤⑥,验收项见 §5.4)📌 as-built 见 §5.1 —— 含 3 条计划外发现, 其中「归一必须在命令入口做」与「测试数据形态假设错误」两条是后续同类改动的直接教训。 缘起:
~/Downloads/readbrowser-ux-backlog.md(外部 LLM 截图评审)的 P0 两条 本计划的前置动作:逐条核验「是活 bug 还是过时测试数据」,避免为脏数据引入不必要逻辑姊妹计划(同批评审派生,各自独立排期):
popup-and-entry-points-plan.md—— 查词弹窗打磨包module-state-preservation-plan.md—— 模块状态保持 + 回顾入口
0. TL;DR —— 核验结论
用 rb-debug MCP 直读本机 dev 库 + 通读相关代码路径,对报告 P0 的每条现象做了归因。
| # | 现象 | 裁定 | 依据 |
|---|---|---|---|
| B1 | 同一本 EPUB 反复打开 → N 条同名笔记本 | 🔴 活 bug | 开书次数与笔记条数 1:1 精确吻合;根因是恒不生效的 INSERT OR IGNORE |
| B2 | rb-cache://localhost/web/…html 当标题显示 | 🔴 活 bug(两层) | 生成页无 <title> + 渲染层无条件回落 URI,两处渲染点 |
| B3 | 幽灵笔记本「Web Notes」+ 空来源页 | 🔴 活 bug(核验时新发现) | 今天 08-11 07:36 / 08:05 各产生一条;add_word_page_link 缺空 URL 护栏 |
| S1 | 两条同名「粘贴文本」笔记本 | 🟢 脏数据 | 均建于 2026-08-06 02:57 / 05:49,早于当日 14:20 的去重修复 a5b0115 |
| S2 | 多条同名 EPUB 笔记本(Moby Dick / Testbook 等) | 🟢 脏数据,但由 B1 持续再生产 | 清了还会长出来 → 必须先修 B1 再清 |
| B4 | 英文「Pasted Text」与中文「粘贴文本」并存 | 🟡 活 bug(低频) | Rust 兜底硬编码英文,与前端 i18n 标题不匹配 |
净结论:需要写代码的是 B1 / B2 / B3 / B4 四条;S1 纯清数据、零代码。 报告归纳的「根源都是缺少稳定的笔记本身份标识」不成立——B1 是插入语句失效、 B3 是缺参数护栏、B2 是展示层兜底,三个互不相干的根因。不要去建"内容指纹去重" 那一层,那是对着一个不存在的根因做设计。
1. 核验方法与证据
1.1 B1 —— 开书次数 vs 笔记条数
resource_history(每本书的累计打开次数)对照 reading_notes(同名笔记条数):
| 书 | 打开次数 | 同名笔记条数 | 这些笔记的 页/词 |
|---|---|---|---|
| The City of God, Volume I | 13 + 1(RENAMED) = 14 | 14 | 全部 0 页 0 词 |
| EPUB3 Nav Only Testbook | 3 | 3 | 全部 0 页 0 词 |
| Moby Dick | 7(记录值) | 9 | 全部 0 页 0 词 |
前两行 1:1 精确吻合。Moby Dick 多出 2 条的原因也清楚:prepareEpub() 在 useNavigation.ts:195 被调用,而 logResourceOpen 在 195 行之后的 if (openOrEvict(...)) 分支内——打开被"标签已满"确认框取消时,笔记照建、历史不记。
代码侧的铁证(不依赖数据):
rust
// src-tauri/src/commands/epub/mod.rs:598
"INSERT OR IGNORE INTO reading_notes (id, user_id, title, ...) VALUES (?1, ...)"
// ?1 = uuid::Uuid::new_v4()reading_notes 的约束只有 id TEXT PRIMARY KEY(schema.sql:206-221,无UNIQUE(user_id, title))。主键是每次现生成的 uuid → 永远不冲突 → OR IGNORE 一次都没生效过。这不是概率问题,是必然:开一次书,多一条。
1.2 B1 附带发现 —— 这条笔记从来没被用过
真正接收生词的笔记本是另一条通路建的:save_word → resolve_note_for_source_url → auto_create_note_for_domain(notes.rs:152),它按文件名 stem 命名并写 domain_prefs 绑定。库里可对照:
The City of God, Volume I(×14,prepare_epub产)→ 0 页 0 词,无 domain_prefs 绑定The City of God, Volume I by Saint of Hippo Augustine (4505)(×1,auto_create产)→ 2 页 12 词,有绑定
即 prepare_epub 那条插入是 100% 孤儿,删掉不影响任何功能。 同时暴露一个质量问题:书名取的是文件名(含 Gutenberg 编号 (4505)),而 prepare_epub 手上明明有解析好的 title + author 却没用上。 (对比:reading_pages.name 是漂亮的「The City of God, Volume I - BOOK FIRST.」—— 元数据流到了页级,没流到笔记本级。)
1.3 B2 —— 两层都漏
写入侧:cache_protocol.rs 的 4 处生成页(236 / 318 / 351 / 489 行)<head> 里 只有 <style> / <meta charset>,没有 <title> → document.title 为空 → MainApp.tsx:303 的 logResourceOpen('web', url, title || undefined) 写入 NULL。
渲染侧:两处同款无条件兜底——
src/pages/HomePage.tsx:543:title={entry.title || entry.uri}src/components/HistoryPanel.tsx:163:同一行代码
副标题也漏:HistoryPanel.tsx:156 的 extractDomain(entry.uri) || entry.uri。
⚠️ 渲染侧的兜底对任何 title 缺失的资源都会漏内部 URL,不限于快照页。这层是硬护栏,必须有。
1.4 B3 —— 核验时新发现(今天刚产生)
库里有一条 title='Web Notes' 的笔记本(2026-08-11 07:36)+ 一条 source_ref=''、name='' 的 reading_pages,挂着两个词(repeatedly / potential)。
完整链路已定位:
词汇库「我的记忆」→ 点「回原文」
→ WordMemorySection.tsx:16 cacheUrl(cached_file_path)
→ openContent({kind:'url', url:'rb-cache://localhost/web/<hash>.html'}) ← 普通 content tab,不注入任何 metadata
→ 页面里双击查词
→ ipc.js:33-34 effectiveSourceUrl() 见 rb-cache 且无 review/epub metadata → console.warn + return ''
→ invoke('add_word_page_link', { sourceUrl: '' })
→ crud.rs:515 add_word_page_link —— **没有空 URL 护栏**
→ extract_domain('') = '' → auto_create_note_for_domain('')
→ 落 else 分支,chars.next()==None → 标题 "Web Notes" + 空 source_ref 的 reading_pages它的三个兄弟命令都有护栏,只有它没有:
| 命令 | 空 URL 护栏 |
|---|---|
save_word (crud.rs:301) | ✅ if !url.is_empty() |
save_words_batch (crud.rs:387) | ✅ |
save_page_annotation (annotations.rs:39) | ✅ 返 Err |
record_pronunciation (pronunciation.rs:257) | ✅ .filter(|u| !u.is_empty()) |
add_word_page_link (crud.rs:515) | ❌ 无 |
讽刺的是,走进这个坑的入口正是外部报告 §七 点名表扬的「我的记忆」。
1.5 S1 —— 粘贴文本是脏数据,不是 bug
两条「粘贴文本」建于 2026-08-06T02:57 与 05:49;去重修复 a5b0115 fix(notes): 禁止同名笔记本提交于 2026-08-06 14:20:33 +0800。早于修复。
现行代码只有一条真实插入路径 text_source/mod.rs:425,包在 ensure_notebook_by_title 里,先按 title COLLATE NOCASE AND deleted_at IS NULL 查已有再建。当前代码不会再产生重复。
同时纠正外部报告的一个误解:它建议「无标题内容自动生成可辨识标题(正文首行摘要+日期)」。 但设计上所有粘贴文档共用一个笔记本(notes.rs:304 的注释 + extract_domain 把 rb-cache://…/text/ 全归到常量 key "text"),单篇标题早已从正文首行剥出 (textNormalize.ts)。按报告改会变成"每粘一段多一个笔记本",是退化不是修复。
1.6 B4 —— 中英兜底不一致
notes.rs:172 在 Rust 侧硬编码英文 "Pasted Text";前端走 i18n (strings/locales/{zh,en}/panels/textEditor.ts::defaultNote = 粘贴文本 / Pasted Text)。 中文界面下这两个名字不相等 → 各建一个笔记本。
触发条件较窄(该文档的 reading_pages 行被删后再产生学习痕迹才走到这个兜底), 但库里那条 Pasted Text(3 词)就是它。
2. 修法设计
2.1 B1 —— EPUB 书笔记本身份归一
方案(D1 裁定):删掉 src-tauri/src/commands/epub/mod.rs:592-608 整个 block, 让 EPUB 与网页共用同一条「首次存词才建」通路。
rust
// 5. Auto-create reading note —— 已删除(2026-08-11)。
//
// 这段用 `INSERT OR IGNORE INTO reading_notes VALUES(<新 uuid>, …)`,而
// reading_notes 只有 `id TEXT PRIMARY KEY`、**没有** (user_id,title) 唯一约束
// (schema.sql:206-221,且已冻结不可加)——主键是每次现生成的 uuid,永远不冲突,
// `OR IGNORE` **一次都没生效过**:开一次书就多一条 0 词孤儿笔记
// (2026-08-11 实测:City of God 开 14 次 = 14 条,Testbook 3 次 = 3 条)。
//
// 删而不是"修成幂等"的理由:笔记本的创建**本来就有唯一 owner**——
// `save_word → resolve_note_for_source_url → auto_create_note_for_domain`
// (`notes.rs:152`,其 `.epub` 分支写 note_type='book' + domain_prefs 绑定)。
// `extract_domain` 对本地文件返回文件路径本身 = 「一本书就是一个 domain」。
// 这段是第二个 owner,且它建的行从未被任何通路使用过(0 页 0 词 0 绑定)。同时不做的事(重要):
- 不新增
create_book_note_bound之类的辅助函数——统一模型的意义就是不再有第二条通路 - 不在
prepare_epub里做任何笔记本相关的事 - 不加
UNIQUE(user_id, title)约束:schema.sql 已冻结(红线 #11), 且应用层已有去重(create_reading_note拒重名 /ensure_notebook_by_title按标题复用)。 零 migration。
2.2 B2 —— 内部 URL 永不作为标题
✅ as-built 修正:接入点是 3 处不是 2 处 —— 漏了
HomePage.tsx的 「继续阅读」(粘贴文档的source_ref天生就是rb-cache://…/text/…)。见 §5.1-①。
(a) 渲染侧硬护栏(必做):新增 src/lib/resourceLabel.ts:
ts
/** 历史/最近打开条目的展示标题。**任何情况下都不返回 rb-cache:// 内部 URL。** */
export function resourceDisplayTitle(entry: { title?: string|null; uri: string; resource_type: string }): string
/** 同上,副标题(来源)位。 */
export function resourceDisplaySource(entry): string回落链:title → 按 uri 形态派生友好标签(rb-cache://…/text/ → 「粘贴文本」; rb-cache://…/web/ → 「页面快照」;epub → 文件名;否则域名)→ 最后才是 uri (且 uri 是 rb-cache:// 时返回通用标签,绝不返回原串)。文案进 src/lib/strings/(禁 JSX 中文字面量)。
接入两处:HomePage.tsx:543 + HistoryPanel.tsx:156,163。
(b) 写入侧(顺手做):cache_protocol.rs 4 处生成页 <head> 各补一个 <title> (复用同函数内已算好的 title_text / hint)。同时改善标签页标题。
2.3 B3 —— 快照页查词不再产生幽灵笔记本
✅ as-built 修正:(b) 的最终实现不是在
resolve_note_for_source_url里反查, 而是抽notes::canonical_source_url在 5 个命令入口归一。理由见 §5.1-② —— 只在 note 解析处归一会让word_cloze_contexts.source_url与reading_pages.source_ref不同形,来源级删除永久失配。
(a) Rust 护栏(必做,3 行):crud.rs:515 add_word_page_link 开头补
rust
if source_url.trim().is_empty() {
return Err(CommandError::InvalidArgument("source_url is required".into()));
}与 save_page_annotation 完全同形。
(b) 让快照页真的能记回原文(推荐,但可拆):现在在快照页查词是功能残缺—— 词存进了生词本,却丢了来源。修法纯 Rust,不碰 content-script 时序:
ipc.js:34把rb-cache://…/web/那支的return ''改成return href(sentinel 页仍返'',判据复用isSentinelCacheUrl的__名字__命名约定);notes::resolve_note_for_source_url增加一支:URL 形如rb-cache://localhost/<rel>时,反查reading_pages WHERE cached_file_path = <rel> AND deleted_at IS NULL取其source_ref作为真实来源键。
映射表本来就在库里(快照就是从那行写出去的),不需要新表、新列、新 metadata 注入。
⚠️ 若只做 (a) 不做 (b):快照页查词会变成"存词成功但无来源", 弹窗的「已收录本句语境」提示需要相应降级——见 §5 验收 T3。
2.4 B4 —— 兜底不再冒充「粘贴文本」
notes.rs:172 的 ("Pasted Text", "webArticle") 改为归入既有常量 UNCATEGORIZED_TITLE(notes.rs:8 = "未归类",get_reading_notes 已给它置顶排序、 rename 已拒改名)。理由:这是"找不到归属"的兜底,本就该是「未归类」, 不该冒充一个语义明确的笔记本,更不该在中文界面里造英文名字。
3. 决策记录(2026-08-11 已定)
| # | 问题 | 裁定 | 说明 |
|---|---|---|---|
| D1 | EPUB 笔记本何时创建? | ✅ 统一到「首次存词才建」(删 epub/mod.rs 那段插入) | 用户裁定,且优于原建议。 理由见下方 §3.1 —— 这不是"改变行为",是去掉一个从未被使用的第二 owner,让 EPUB 与网页走完全同一条通路 |
| D2 | B3 做到哪一层? | ✅ a + b(护栏 + 快照 URL 反查真实来源) | 只做 a 会让「我的记忆 → 回原文 → 查词」丢来源 |
| D3 | 存量脏数据谁清? | ✅ 手工 SQL,已执行完毕 | 见 §4 执行记录 |
3.1 D1 为什么选「首次存词才建」(推翻本文档初版的建议)
初版建议「开书即建 + 用元数据命名」。用户提出反问:一本 EPUB 就该像一个网站(domain)一样, 存词时判断 note 是否存在即可,何必为它单开一条创建时机? 复核后确认这个判断更好—— 因为统一模型早就存在并且在跑,prepare_epub 那段插入才是多余的分叉:
| 事实 | 位置 |
|---|---|
extract_domain() 对本地文件返回文件路径本身(网页返回 host)——「一本书 = 一个 domain」在代码里本就是这么建模的 | notes.rs:294 |
auto_create_note_for_domain() 已有 .epub 分支(note_type='book' + 写 domain_prefs 绑定) | notes.rs:158 |
| 库里真正收词的那条笔记本(2 页 12 词、有 domain_prefs 绑定)正是这条统一通路建的 | 实测 |
prepare_epub 建的 14 条:0 页 0 词、无绑定、100% 孤儿 | 实测 |
复核过「懒建会不会打破什么」——不会:
- 封面:
save_note_cover走document.location.hostname+ favicon,是纯网页路径,书本来就不经过它; 且其注释明写「note 可能尚未存在 → 后端 skip」——懒建正是既有设计假设(tracking.js:175-180) - 「继续阅读」读
reading_pages、「最近打开」读resource_history,都与笔记本无关
接受的代价(明确记录,不藏):笔记本名仍取文件名 stem,即 The City of God, Volume I by Saint of Hippo Augustine (4505) 而非元数据 title + author。 这是观感问题、非 bug,且笔记本支持改名。要改善得把书名从 prepare_epub 传到 save_word ——那等于给统一模型重新加一条特殊通路,与本决策的初衷相反。先按统一模型落地,书名单独观察。
4. 存量数据清理 —— ✅ 2026-08-11 已执行
备份:
backups/lampio-20260811-1733.db(用sqlite3 .backup在线备份 ——cp对带 WAL 的活库不安全)。 执行时 dev app 在运行;WAL 模式下短语句写入安全,PRAGMA busy_timeout=10000+BEGIN IMMEDIATE兜底。
| 步骤 | 结果 |
|---|---|
Step 1 EPUB 孤儿笔记本(note_type='book' 且 0 活跃来源页) | 软删 29 条 |
Step 2 幽灵 Web Notes 子树 | word_page_links 2 / page_annotations 0 / reading_pages 1 / reading_notes 1 / domain_prefs(domain='')1 |
| 复验 | 0 行残留 ✅;repeatedly/potential 仍在生词本(只解绑来源)✅ |
43 条笔记本 → 13 条。 全部软删,墓碑随下轮 sync 传播到云端与 RVH。
⚠️ epub/mod.rs:592-608 未删之前,每开一次书仍会再长一条。
Step 3(两条「粘贴文本」,6页9词 / 6页3词)刻意未动:都有真实内容,走 Library UI 逐条确认更安全;属 2026-08-06 修复前的历史遗留,不影响新数据。
执行用的 SQL(存档,复现/回归用)
Step 0 — 先看清单(只读)
sql
SELECT rn.id, rn.title, rn.note_type, rn.created_at,
(SELECT COUNT(*) FROM reading_pages rp
WHERE rp.note_id = rn.id AND rp.deleted_at IS NULL) AS alive_pages
FROM reading_notes rn WHERE rn.deleted_at IS NULL
GROUP BY rn.id HAVING alive_pages = 0 ORDER BY rn.note_type, rn.title;Step 1 —— 限定 note_type='book' 是刻意的:手工建的空笔记本默认 'webArticle',这样写碰不到它们。
sql
UPDATE reading_notes
SET deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), synced_at = NULL
WHERE deleted_at IS NULL AND note_type = 'book'
AND NOT EXISTS (SELECT 1 FROM reading_pages rp
WHERE rp.note_id = reading_notes.id AND rp.deleted_at IS NULL);Step 2 —— 四级顺序照 notes.rs::soft_delete_note_tree(红线 #6a:禁靠 FK CASCADE)。 末尾那条 domain_prefs 不能漏:不清掉 domain='' 的规则行,下次空 URL 又会命中它把笔记本复活。
sql
BEGIN;
UPDATE word_page_links SET deleted_at=…, updated_at=…, synced_at=NULL
WHERE deleted_at IS NULL AND reading_page_id IN (
SELECT rp.id FROM reading_pages rp JOIN reading_notes rn ON rn.id=rp.note_id
WHERE rn.title='Web Notes' AND rn.deleted_at IS NULL);
-- page_annotations 同形
-- reading_pages WHERE note_id IN (SELECT id FROM reading_notes WHERE title='Web Notes' …)
-- reading_notes WHERE title='Web Notes' AND deleted_at IS NULL
UPDATE domain_prefs SET deleted_at=…, updated_at=…, synced_at=NULL
WHERE domain='' AND deleted_at IS NULL;
COMMIT;Step 4 — 复验(应返回 0 行)
sql
SELECT rn.title FROM reading_notes rn WHERE rn.deleted_at IS NULL
AND (rn.title='Web Notes'
OR (rn.note_type='book' AND NOT EXISTS
(SELECT 1 FROM reading_pages rp WHERE rp.note_id=rn.id AND rp.deleted_at IS NULL)));4.1 原清理指引(保留:代码修复后的回归验证步骤)
⚠️ 顺序重要:先修 B1 再清,否则 §1.1 的机制会继续再生产。 ⚠️ 下面全部是软删(红线 #6:同步表禁硬删,否则墓碑丢失 → 下轮 pull 复活)。 ⚠️ 执行前请先
backups/备份lampio.db。
Step 0 — 先看清单,确认再删(只读):
sql
SELECT rn.id, rn.title, rn.created_at,
(SELECT COUNT(*) FROM reading_pages rp WHERE rp.note_id=rn.id AND rp.deleted_at IS NULL) AS pages,
(SELECT COUNT(DISTINCT wpl.learning_entry_id) FROM word_page_links wpl
JOIN reading_pages rp2 ON rp2.id=wpl.reading_page_id AND rp2.deleted_at IS NULL
WHERE rp2.note_id=rn.id AND wpl.deleted_at IS NULL) AS words
FROM reading_notes rn WHERE rn.deleted_at IS NULL ORDER BY words, rn.title;Step 1 — 软删「0 页 0 词」的孤儿笔记本(B1/S2 产物,预计 ~26 条):
sql
UPDATE reading_notes
SET deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM reading_pages rp WHERE rp.note_id = reading_notes.id AND rp.deleted_at IS NULL);Step 2 — 幽灵「Web Notes」整棵子树(B3 产物):四级软删,word_page_links → reading_pages → reading_notes,模板照 notes.rs::soft_delete_note_tree (红线 #6a:禁靠 FK CASCADE)。两个词 repeatedly / potential 本身留在生词本, 只解绑来源。建议改走 UI:Library → 笔记本 → 删除,走的就是那条已验证的事务, 比手搓 SQL 安全。
Step 3 — 两条「粘贴文本」合并:同样建议走 UI——把 3 词那条里的来源页 逐条处理后删掉该笔记本;或直接保留(S1 是历史遗留,不影响新数据)。
Step 4 — 复验:Step 0 的查询重跑,确认无 0 页笔记本、无 Web Notes; 然后开同一本 EPUB 三次,确认笔记本仍只有 1 条(B1 回归验证)。
5. 任务分解
| ID | 任务 | 文件 | 量级 | 依赖 |
|---|---|---|---|---|
| T1 ✅ | 删 epub/mod.rs:592-608,留注释说明为何删(conn/user_id 仍被文件内其它函数使用,无 import 需清理) | commands/epub/mod.rs | XS | D1 ✅ |
| T2 ✅ | lib/resourceLabel.ts + i18n 4 key;接入 3 处(比计划多 1 处,见 §5.1-①) | lib/resourceLabel.ts、strings/{common.ts,locales/{en,zh}/common.ts}、HomePage.tsx、HistoryPanel.tsx | S | — |
| T2b ✅ | cache_protocol.rs 4 处生成页补 <title> | cache_protocol.rs | XS | — |
| T3 ✅ | add_word_page_link 空 URL 护栏(CommandError::InvalidArgument,与 save_page_annotation 同形) | vocabulary/crud.rs | XS | — |
| T3b ✅ | ipc.js 返回快照 href + 新增 notes::canonical_source_url,在 5 个命令入口归一(比计划多,见 §5.1-②) | content-script/core/ipc.js、notes.rs、crud.rs×2、annotations.rs、pronunciation.rs | M | D2 ✅ |
| T4 ✅ | notes.rs 的 "Pasted Text" 兜底改 UNCATEGORIZED_TITLE | commands/notes.rs | XS | — |
| T5 ✅ | resourceLabel.test.ts 21 例 + notes.rs::canonical_source_url_tests 9 例 | — | S | T2/T3b |
| T6 ✅ | 质量闸全绿(明细见 §5.1 末) | — | — | 全部 |
5.1 as-built(2026-08-11 实施记录)
① 内部 URL 泄漏点是三处,不是计划里写的两处
计划只列了 HomePage.tsx:543(Recently Opened)与 HistoryPanel.tsx:163。 实施时发现 HomePage.tsx:496「继续阅读」 有同款 title={entry.name || sourceRef} —— 而粘贴文档的 source_ref 天生就是 rb-cache://localhost/text/… (文件本身由 rb-cache 协议提供,不另存快照),name 一空就直接把内部地址当标题。
教训:找这类「回落到原始标识符」的模式,别按已知症状去数,要按模式全仓 grep (|| entry.uri / || sourceRef / || url 这一族)。
② 🔑 归一必须在命令入口做,不能只塞进 resolve_note_for_source_url
计划原文写的是「resolve_note_for_source_url 反查」。那样是错的: source_url 在同一个命令里会分别喂给两个消费者——
| 消费者 | 落到哪 |
|---|---|
insert_cloze_context | word_cloze_contexts.source_url |
ensure_source_for_url | reading_pages.source_ref |
只在 note 解析处归一,cloze 那支拿到的仍是 rb-cache:// 原串 → 两列不同形 → 来源级删除的 WHERE source_url = <source_ref>(soft_delete_note_tree / remove_reading_page)永久失配,语境永远清不掉。
故最终实现是:抽 pub(crate) fn canonical_source_url(conn, url) -> String, 在 5 个命令入口各归一一次并把结果用到底: save_word / save_words_batch / add_word_page_link / save_page_annotation / record_pronunciation。函数头注释已把这条纪律写死。
③ 🔑 测试数据形态假设错误 —— 查实测库才发现
第一版 canonical_source_url_tests 里我按 rb-cache URL 的路径段想当然地假设 EPUB 章节的 cached_file_path 形如 epub/{hash}/OEBPS/ch3.xhtml。实际不是:
source_ref: /Users/larry/Downloads/The City of God…epub#pgepubid00010
cached_file_path: web/28ce7a0843385148.html ← 章节**快照**,走 web/ 前缀28ce7a0843385148.html 正是当初在「最近打开」里泄漏出来的那个哈希 —— 幽灵 Web Notes 就是从这本 EPUB 的章节快照上查词产生的,整条链路到此闭合 (此前 §1.4 只推到「打开快照页 → 查词」,没定位到那是 EPUB 章节)。
测试已改用真实形态,并补了 epub_cache_urls_without_a_snapshot_row_pass_through: __RB_EPUB_META 尚未注入的时间窗里,rb-cache://…/epub/{hash}/… 反查不到 → 原样返回 → 下游落到 notes.rs 既有的 epub/ 兜底分支("EPUB Book")。 即:最坏情况也比改动前好(改动前返回空串 → 落 Web Notes 幽灵)。
教训(与 memory feedback_diagnostic_first 同源):写测试样例前先查一眼真实数据, 别从命名约定反推数据形态。
质量闸结果
| 项 | 结果 |
|---|---|
cargo check | ✅(唯一 warning epub/search.rs 未用 import 为既有,非本次引入) |
cargo test --lib | ✅ 160 passed(新增 9) |
vitest run | ✅ 192 passed(新增 21) |
tsc --noEmit | ✅ |
pnpm build / build:cs | ✅(2 条 warning 均既有) |
/arch-check H1-H6 | ✅ 6/6(扫到的命中全在注释/测试/既有代码,本次变更文件未上榜) |
| i18n 三件套 S19/S22/S23 | ✅ 全 0(新增 4 个 key 已配 en/zh 双语,locale 审计 0 suspect) |
/rb-code-review | ✅ 无新增 unwrap / any / console.log / format! 拼 SQL;新增 SQL 带 deleted_at IS NULL |
5.3 实机验证暴露的两条追加改动(2026-08-11,已实施)
第一轮实机验证 4 条主路径全部通过(库里复核证据见下),但截图暴露了两个 原计划未覆盖的问题,同批修掉:
验收复核(rb-debug 直查)
| 项 | 证据 | 结论 |
|---|---|---|
| ① EPUB 零新增笔记本 | Moby Dick 开 3 次 → 恰好 1 条笔记本,下挂 3 章节页 | ✅ |
| ② 最近打开无内部地址 | 6 行历史,3 行 title 为 NULL 但渲染成「页面快照」 | ✅ |
| ③ 不产生幽灵笔记本 | 笔记本仅 3 条、无 Web Notes;reading_pages 零行带 rb-cache://…/web/ 的 source_ref | ✅ |
| ④ 粘贴文档标题正常 | title = 「Trump secretly switched planes…」 | ✅ |
T3b 的强证据:
consciousness(11:41:16) 落在#pgepubid00016(CH11),而它前后 4 秒的词 分别落在 CH3、CH6 —— 这种跳章顺序正是「从我的记忆点回原文」的特征,而它们 全部落到正确的 EPUB 章节路径而非快照地址。反查生效。
📌 dev 模式的 library 在
temp/library/(db/helpers.rs:50的cfg!(debug_assertions)分支),不在 App Support。排查快照文件时别找错地方 (我第一次就找错了,误以为本地无文件)。
追加 ⑤ —— 「继续阅读」按书归并
问题:reading_pages 粒度是章,而 get_continue_reading 直接 ORDER BY last_opened_at DESC LIMIT 5 —— 5 个槽位被 2 本书吃光 (Moby Dick 3 条 + City of God 2 条),第三本书根本进不来,读得越多越糟。
修法(notes.rs::get_continue_reading):CTE + ROW_NUMBER() OVER (PARTITION BY 书路径), 每本只取最近读的那一章。三个配套判断:
- 章节名保留(
name= "Moby Dick - CHAPTER 11")—— 那正是"上次读到哪"的信息 saved_word_count改为全书累计去重词数(原先是当前章的,每条都显示"已存 1 词", 作为续读动机钩子太弱)LIMIT在归并之后生效
⚠️ 两个静默错误陷阱(回归测试 continue_reading_tests 6 例专门锁它们,含反向断言):
- 窗口函数仓里此前从未用过 —— 若 bundled SQLite 不支持会在 prepare 期炸;
- 全书词数子查询里的
source_ref必须带rp2.前缀。不带的话 SQLite 会把它 合法地绑到外层ranked上(相关子查询允许引用外层列)→ 计数静默退化成 "当前章的词数",不报错。
追加 ⑥ —— 「页面快照」标题 + 不再记入历史
根因(两层,都属真缺陷):
rust
// notes.rs::save_html_to_file(修复前)
let full_html = wrap_html_with_template(html, None);
// ^^^^ 模板第 1008 行明明支持 title,恒传 Nonewrap_html_with_template(article_html, title: Option<&str>) 一直有 title 参数, 调用方却从不传 → 每个快照都写成无 <title> 的文档(实测文件 <head> 里只有 meta + style)→ 打开时 document.title 为空 → log_resource_open 落 NULL → 「最近打开」只能显示通用兜底名「页面快照」,三条一模一样、彼此无法区分。 而 title 一直就在手边(page-snapshot.js 返回 {title: document.title, content}, 四个调用方也都持有 source_title),只是从没往下传。
修法 A(数据正确性):save_html_to_file 加 title: Option<&str> 入参, 5 个调用点各传自己的 source_title;save_page_content 无 title 入参 → 改从它本就查的 reading_pages 行取 name(那就是页标题)。
修法 B(产品语义):快照打开不再记入历史。新增 resourceLabel.ts::isSnapshotUrl(),MainApp.tsx 的 logResourceOpen 加这道门。 理由:打开快照 = 重看一个已在历史里的资源,不是打开了新东西;对 EPUB 更糟—— 快照按章存,于是从后门绕过了同一个 if 里 tab.type !== 'epub' 那条 「不逐章记历史」的既有决定(实测三章三条)。 text/ 粘贴文档不在此列:它没有别的表示形式,本身就是那个资源。
⚠️ 只门控 logResourceOpen,不要把 restoreDomainZoom 一起圈进条件 —— 缩放恢复与「算不算新资源」无关,圈进去会让快照页丢掉缩放态(初版就这么写错了,已改)。
⚠️ 存量的 6 个快照文件已写成无 title,修复只对新快照生效。
追加改动的验证
cargo test --lib 166 passed(新增 6)· vitest 197 passed(新增 5)· tsc ✓ · pnpm build ✓ · build:cs ✓ · i18n 三件套全 0
5.5 追加 ⑦ —— 🔴 无锚点目录的书「整本塌成一条来源」(2026-08-12 实测捞出)
用户现象:复习卡里 despair 的来源页标题是「Romance - I」,点开的缓存页却是第 IX 章。
实测数据——整本书只有一条 reading_pages,且 source_ref 没有 fragment:
name: Romance - I
source_ref: /Users/larry/Downloads/joseph-conrad-ford-madox-ford_romance.epub ← 无 #锚点
cached_file_path: web/a7d42bad4c39ccd4.html
挂着的词: attentively(01:08:13) / separation(01:08:19) / despair(01:08:22) ← 横跨 I 与 IX 两章根因(content-script/core/ipc.js::effectiveSourceUrl):
js
if (em.tocHref) {
var anchor = (em.tocHref.split('#')[1] || '');
return em.filePath + (anchor ? '#' + anchor : ''); // ← anchor 为空 → 返回纯路径
}这本书(Standard Ebooks 版)的目录条目指向整份 xhtml、不带锚点 (解包实测:src="text/titlepage.xhtml"、href="text/chapter-9.xhtml"), 于是 anchor 恒为空 → source_ref 退化成纯书路径 → 全书所有章节共用一条来源行。
四级连锁后果(每一级都不报错,全是静默的):
| # | 后果 | 机制 |
|---|---|---|
| 1 | 各章的词全挂到同一行 | ensure_source_for_url 按 source_ref 命中 |
| 2 | name 卡在第一次存词那章 | 命中已有行时只 touch 时间戳、不改 name |
| 3 | 快照互相覆盖 → 标题是第 I 章、内容却是第 IX 章 | 快照文件名 = hash(source_url) 相同;save_html_to_file 无条件写文件,而 DB 的 cached_file_path 是 first-write-wins |
| 4 | 「继续阅读」永远停在第一章;全书词数 / 来源页数失真 | 同 1 |
⚠️ backlog B4 记过这类书(「TOC 条目不带
#锚点的书 →source_ref退化成纯路径, 还原不出章节」),但只写了位置恢复这一条,远低估了影响面 —— 实际是整条 来源身份链塌掉。B4 条目已回补交叉引用。
修法(ipc.js,逻辑抽成纯函数 epubSourceRef(em) 以便单测): anchor 为空时回落到 spine 序号形式 #ch{N}。
选它而不是「用 xhtml 文件名当锚点」的理由:chapterIdx 就是 epubInfo.chapters 的下标, 而 epubStart.ts::hrefForAnchor 的 LEGACY_SPINE_ANCHOR(/^ch(\d+)$/)分支本来就 按这个下标取 chapters[N].href —— 消费端零改动即可往返。文件名方案则要同步改 hrefForAnchor 的反查逻辑(它现在按 fragmentOf(toc[i].href) === anchor 匹配, 而这类书所有 TOC href 的 fragment 都是空,永远匹配不上)。
新增不变式 + 13 个用例(src/lib/epubSourceRef.test.ts): EPUB 的 source_ref 恒带 fragment,且不同章必须产出不同的 ref。 覆盖 falsy 陷阱(chapterIdx === 0)、tocHref 以 # 结尾、锚点形态优先于序号形态等。
⚠️ 存量数据不会自愈:那条塌缩的 Romance - I 行(3 词 + 错章快照)留在库里。 要干净复测,把它从 Library 里删掉重存。
5.4 ⬜ 剩余:第二轮实机验证(需重启 pnpm tauri dev)
追加改动的验收项(⑤⑥ 已于 2026-08-12 实测,见勾选):
- ✅ 「继续阅读」同一本书只出一条(显示最近读的那一章),多本书各占一槽
- ✅ 「已存 N 词」显示全书累计,不是当前章
- 🔶 在网页上存词 → 从「我的记忆 → 回原文」打开快照 → 标签页/历史显示真实文章标题 (实测仍见「页面快照」——存量快照文件已写成无 title,修复只对新快照生效,属预期。 需在网页上重新存一个词产生新快照再验)
- ✅ 打开快照后「最近打开」不新增条目
⑦ 的验收项(本轮新增,未验):
- 打开一本目录不带锚点的书(
joseph-conrad-ford-madox-ford_romance.epub已确认是这类),在两个不同章节各存一个词 →reading_pages应出现两行、source_ref形如…romance.epub#ch3/#ch9 - 复习卡的来源标题与点开的缓存页章节一致(本轮 bug 的正面复现)
- 「继续阅读」显示的是最近读的那一章(不再永远卡在第一章)
⚠️ 验 5-7 前先把库里那条塌缩的 Romance - I 来源页删掉,否则新旧混着看不清。
5.2 第一轮实机验证清单(✅ 已通过,保留供回归)
Rust 改动需重新编译才生效。四条主路径:
- 连开同一本 EPUB 三次 →
reading_notes零新增(懒建;存词后才 +1) - 「我的记忆 → 回原文」打开快照页 → 「最近打开」列表不出现
rb-cache://字样 - 在该快照页双击查词 → 不产生
Web Notes;且来源正确指向原书路径带 #锚点 - 粘贴文档在「最近打开」显示「粘贴文本」而非内部地址 |
T7|存量数据清理✅ 2026-08-11 已完成(§4) | — | — | — |
跨端影响:零。不动 schema、不动 sync、不动 SM-2、不动预装库。 reading_notes / reading_pages / word_page_links 都是同步表,但本计划只改写入时机与取值, 不改列、不改 push/pull 逻辑,RVH 无需镜像。零 migration。
验收:
- 同一本 EPUB 连开 3 次 →
reading_notes只增 1 条,标题为元数据 title、author 非空; - 打开「我的记忆 → 回原文」的快照页 → 最近打开列表不出现
rb-cache://字样; - 在该快照页双击查词 → 不产生
Web Notes;(做 T3b 则)来源正确指向原始 http(s) URL; - Step 0 查询:无 0 页笔记本。
6. 明确不做(防止范围蔓延)
- ❌ 内容指纹 / 文件哈希去重:外部报告建议的这层是对着一个不存在的根因做设计。 真根因是失效的
INSERT OR IGNORE,身份键(文件路径)本来就是稳的。 - ❌ 粘贴文档"首行摘要+日期"当笔记本名:与"所有粘贴文档共用一个笔记本"的设计冲突(§1.5)。
- ❌
reading_notes加唯一约束 / 任何 migration:schema.sql 已冻结(红线 #11), 应用层去重已足够。 - ❌ EPUB 改名/移动导致换身份(外部报告未提,backlog B8 已立项):那是文件路径 作为身份键的固有局限,与本计划的三条 bug 无关,不在本轮范围。