Skip to content

数据卫生 / 资源身份 —— 核验与修复计划

物理仓库:/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 还是过时测试数据」,避免为脏数据引入不必要逻辑

姊妹计划(同批评审派生,各自独立排期):


0. TL;DR —— 核验结论

rb-debug MCP 直读本机 dev 库 + 通读相关代码路径,对报告 P0 的每条现象做了归因。

#现象裁定依据
B1同一本 EPUB 反复打开 → N 条同名笔记本🔴 活 bug开书次数与笔记条数 1:1 精确吻合;根因是恒不生效的 INSERT OR IGNORE
B2rb-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 I13 + 1(RENAMED) = 1414全部 0 页 0 词
EPUB3 Nav Only Testbook33全部 0 页 0 词
Moby Dick7(记录值)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 KEYschema.sql:206-221UNIQUE(user_id, title))。主键是每次现生成的 uuid → 永远不冲突 → OR IGNORE 一次都没生效过。这不是概率问题,是必然:开一次书,多一条。

1.2 B1 附带发现 —— 这条笔记从来没被用过

真正接收生词的笔记本是另一条通路建的:save_wordresolve_note_for_source_urlauto_create_note_for_domainnotes.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:303logResourceOpen('web', url, title || undefined) 写入 NULL。

渲染侧:两处同款无条件兜底——

  • src/pages/HomePage.tsx:543title={entry.title || entry.uri}
  • src/components/HistoryPanel.tsx:163:同一行代码

副标题也漏:HistoryPanel.tsx:156extractDomain(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:5705: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_domainrb-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_url5 个命令入口归一。理由见 §5.1-② —— 只在 note 解析处归一会让 word_cloze_contexts.source_urlreading_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 时序

  1. ipc.js:34rb-cache://…/web/ 那支的 return '' 改成 return href (sentinel 页仍返 '',判据复用 isSentinelCacheUrl__名字__ 命名约定);
  2. 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_TITLEnotes.rs:8 = "未归类"get_reading_notes 已给它置顶排序、 rename 已拒改名)。理由:这是"找不到归属"的兜底,本就该是「未归类」, 不该冒充一个语义明确的笔记本,更不该在中文界面里造英文名字。


3. 决策记录(2026-08-11 已定)

#问题裁定说明
D1EPUB 笔记本何时创建?统一到「首次存词才建」(删 epub/mod.rs 那段插入)用户裁定,且优于原建议。 理由见下方 §3.1 —— 这不是"改变行为",是去掉一个从未被使用的第二 owner,让 EPUB 与网页走完全同一条通路
D2B3 做到哪一层?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_coverdocument.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_linksreading_pagesreading_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.rsXSD1 ✅
T2lib/resourceLabel.ts + i18n 4 key;接入 3 处(比计划多 1 处,见 §5.1-①)lib/resourceLabel.tsstrings/{common.ts,locales/{en,zh}/common.ts}HomePage.tsxHistoryPanel.tsxS
T2bcache_protocol.rs 4 处生成页补 <title>cache_protocol.rsXS
T3add_word_page_link 空 URL 护栏(CommandError::InvalidArgument,与 save_page_annotation 同形)vocabulary/crud.rsXS
T3bipc.js 返回快照 href + 新增 notes::canonical_source_url,在 5 个命令入口归一(比计划多,见 §5.1-②)content-script/core/ipc.jsnotes.rscrud.rs×2、annotations.rspronunciation.rsMD2 ✅
T4notes.rs"Pasted Text" 兜底改 UNCATEGORIZED_TITLEcommands/notes.rsXS
T5resourceLabel.test.ts 21 例 + notes.rs::canonical_source_url_tests 9 例ST2/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_contextword_cloze_contexts.source_url
ensure_source_for_urlreading_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 --lib160 passed(新增 9)
vitest run192 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 Notesreading_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:50cfg!(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 例专门锁它们,含反向断言):

  1. 窗口函数仓里此前从未用过 —— 若 bundled SQLite 不支持会在 prepare 期炸;
  2. 全书词数子查询里的 source_ref 必须带 rp2. 前缀。不带的话 SQLite 会把它 合法地绑到外层 ranked 上(相关子查询允许引用外层列)→ 计数静默退化成 "当前章的词数",不报错。

追加 ⑥ —— 「页面快照」标题 + 不再记入历史

根因(两层,都属真缺陷):

rust
// notes.rs::save_html_to_file(修复前)
let full_html = wrap_html_with_template(html, None);
//                                            ^^^^ 模板第 1008 行明明支持 title,恒传 None

wrap_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_filetitle: Option<&str> 入参, 5 个调用点各传自己的 source_titlesave_page_content 无 title 入参 → 改从它本就查的 reading_pages 行取 name(那就是页标题)。

修法 B(产品语义):快照打开不再记入历史。新增 resourceLabel.ts::isSnapshotUrl()MainApp.tsxlogResourceOpen 加这道门。 理由:打开快照 = 重看一个已在历史里的资源,不是打开了新东西;对 EPUB 更糟—— 快照按存,于是从后门绕过了同一个 iftab.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_urlsource_ref 命中
2name 卡在第一次存词那章命中已有行时只 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::hrefForAnchorLEGACY_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 实测,见勾选):

  1. ✅ 「继续阅读」同一本书只出一条(显示最近读的那一章),多本书各占一槽
  2. ✅ 「已存 N 词」显示全书累计,不是当前章
  3. 🔶 在网页上存词 → 从「我的记忆 → 回原文」打开快照 → 标签页/历史显示真实文章标题 (实测仍见「页面快照」——存量快照文件已写成无 title,修复只对新快照生效,属预期。 需在网页上重新存一个词产生新快照再验)
  4. ✅ 打开快照后「最近打开」不新增条目

⑦ 的验收项(本轮新增,未验):

  1. 打开一本目录不带锚点的书(joseph-conrad-ford-madox-ford_romance.epub 已确认是这类),在两个不同章节各存一个词 → reading_pages 应出现两行source_ref 形如 …romance.epub#ch3 / #ch9
  2. 复习卡的来源标题与点开的缓存页章节一致(本轮 bug 的正面复现)
  3. 「继续阅读」显示的是最近读的那一章(不再永远卡在第一章)

⚠️ 验 5-7 前先把库里那条塌缩的 Romance - I 来源页删掉,否则新旧混着看不清。


5.2 第一轮实机验证清单(✅ 已通过,保留供回归)

Rust 改动需重新编译才生效。四条主路径:

  1. 连开同一本 EPUB 三次reading_notes 零新增(懒建;存词后才 +1)
  2. 「我的记忆 → 回原文」打开快照页 → 「最近打开」列表不出现 rb-cache:// 字样
  3. 在该快照页双击查词 → 不产生 Web Notes;且来源正确指向原书路径带 #锚点
  4. 粘贴文档在「最近打开」显示「粘贴文本」而非内部地址 | T7 | 存量数据清理 ✅ 2026-08-11 已完成(§4) | — | — | — |

跨端影响:零。不动 schema、不动 sync、不动 SM-2、不动预装库。 reading_notes / reading_pages / word_page_links 都是同步表,但本计划只改写入时机与取值, 不改列、不改 push/pull 逻辑,RVH 无需镜像。零 migration。

验收

  1. 同一本 EPUB 连开 3 次 → reading_notes 只增 1 条,标题为元数据 title、author 非空;
  2. 打开「我的记忆 → 回原文」的快照页 → 最近打开列表不出现 rb-cache:// 字样;
  3. 在该快照页双击查词 → 不产生 Web Notes;(做 T3b 则)来源正确指向原始 http(s) URL;
  4. Step 0 查询:无 0 页笔记本。

6. 明确不做(防止范围蔓延)

  • 内容指纹 / 文件哈希去重:外部报告建议的这层是对着一个不存在的根因做设计。 真根因是失效的 INSERT OR IGNORE,身份键(文件路径)本来就是稳的。
  • 粘贴文档"首行摘要+日期"当笔记本名:与"所有粘贴文档共用一个笔记本"的设计冲突(§1.5)。
  • reading_notes 加唯一约束 / 任何 migration:schema.sql 已冻结(红线 #11), 应用层去重已足够。
  • EPUB 改名/移动导致换身份(外部报告未提,backlog B8 已立项):那是文件路径 作为身份键的固有局限,与本计划的三条 bug 无关,不在本轮范围