From e2f973dc46c87a8d223d652b9f4a4e8d1277ec6c Mon Sep 17 00:00:00 2001 From: Dusk Date: Sun, 19 Apr 2026 11:46:37 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=88=87=E6=8D=A2=20manual-sync=20?= =?UTF-8?q?=E4=B8=BB=E9=93=BE=E5=88=B0=20ID=20noteId=20/=20refactor=20manu?= =?UTF-8?q?al-sync=20mainline=20to=20ID=20noteId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 marker、state、writeback 的 durable identity 改为 noteId - Switch marker, state, and writeback durable identity to noteId - 支持满足标题规则的旧 ID 卡片重接进并按当前 renderer 重渲染 - Reconnect legacy ID cards that match heading rules and rerender them with the current renderer - 补充 schema migration、测试、构建验证与插件同步 - Add schema migration, tests, build verification, and plugin sync --- docs/compatibility-final-recommendation.md | 130 ++++++++ docs/compatibility-identity-analysis.md | 190 +++++++++++ docs/compatibility-matrix.md | 57 ++++ docs/compatibility-sample-table.md | 51 +++ docs/compatibility-strategy-comparison.md | 127 ++++++++ docs/compatibility-touchpoints-report.md | 99 ++++++ docs/id-noteid-mainline-decisions.md | 149 +++++++++ docs/id-noteid-mainline-gap-report.md | 262 +++++++++++++++ .../services/AnkiBatchExecutor.test.ts | 44 +-- src/application/services/AnkiBatchExecutor.ts | 38 +-- .../services/FileIndexerService.test.ts | 12 +- .../services/FileIndexerService.ts | 18 +- .../services/ManualSyncService.test.ts | 80 +++-- src/application/services/ManualSyncService.ts | 75 +++-- .../services/MarkdownWriteBackService.ts | 27 +- ...ClearCurrentFileSyncedCardsUseCase.test.ts | 109 ++----- .../ClearCurrentFileSyncedCardsUseCase.ts | 39 +-- src/domain/manual-sync/entities/IdMarker.ts | 9 + .../manual-sync/entities/IndexedCard.ts | 12 +- .../manual-sync/entities/PluginState.ts | 17 +- .../services/CardIndexingService.test.ts | 85 +++-- .../services/CardIndexingService.ts | 152 ++++----- .../services/CardMarkerRemovalService.ts | 6 +- .../services/CardMarkerService.test.ts | 17 +- .../manual-sync/services/CardMarkerService.ts | 45 +-- .../services/DeckResolutionService.test.ts | 6 +- .../services/DiffPlannerService.test.ts | 308 ++++++------------ .../services/DiffPlannerService.ts | 39 ++- .../DataJsonPluginStateRepository.test.ts | 65 +++- .../DataJsonPluginStateRepository.ts | 125 ++++++- 30 files changed, 1744 insertions(+), 649 deletions(-) create mode 100644 docs/compatibility-final-recommendation.md create mode 100644 docs/compatibility-identity-analysis.md create mode 100644 docs/compatibility-matrix.md create mode 100644 docs/compatibility-sample-table.md create mode 100644 docs/compatibility-strategy-comparison.md create mode 100644 docs/compatibility-touchpoints-report.md create mode 100644 docs/id-noteid-mainline-decisions.md create mode 100644 docs/id-noteid-mainline-gap-report.md create mode 100644 src/domain/manual-sync/entities/IdMarker.ts diff --git a/docs/compatibility-final-recommendation.md b/docs/compatibility-final-recommendation.md new file mode 100644 index 0000000..f31db26 --- /dev/null +++ b/docs/compatibility-final-recommendation.md @@ -0,0 +1,130 @@ +# 最终建议 + +## 最终分类 + +**可接管,但必须先做一次迁移写回。** + +这是本次分析在 current manual-sync 主链边界内的最终判断。 + +## 为什么是这个结论 + +### 不是第 1 类:可直接接管 + +因为当前主链并不会把旧 `` 识别为已有身份。 + +直接运行只会: + +- 生成新的 `cardId` +- 把卡放进 `toCreate` +- 在 Markdown 里追加新 AHS marker +- 在 Anki 里创建重复 note + +### 也不是第 3 类:只能当新卡导入 + +因为当前样本已经证明旧卡具备可迁移条件: + +- Markdown 里有精确 noteId +- noteId 在 live Anki 中可验证存在 +- note model / fields 与当前配置兼容 +- deck 语义也兼容 + +所以“完全不能接管”的判断过于悲观。 + +### 更不是第 4 类:必须双 marker 过渡 + +因为当前证据没有显示必须长期维护双协议。 + +一次确定性的 migration write-back 就足以把旧身份翻译进新身份模型。双 marker 只会增加长期 parser 和写回复杂度。 + +## 推荐策略 + +### 主策略 + +做一次显式迁移,把旧块一次性转换成 current manual-sync 的原生形态: + +1. 读出旧 block 的 legacy `noteId` +2. 验证该 `noteId` 在 Anki 中真实存在 +3. 为该 block 生成新的 `cardId` +4. 把 `` 改写成 `` +5. 同步写入 `pluginState.files` 与 `pluginState.cards` +6. 之后再让 normal sync 接管 + +### 迁移完成后的系统状态 + +迁移完成后,这批旧卡在系统里应当表现得和原生 AHS 卡完全一样: + +- Markdown 里只有 AHS marker +- `pluginState.cards[cardId].noteId` 已存在 +- 后续改内容走 `toUpdate` +- 后续改 deck 走 `toChangeDeck` +- 不再依赖 legacy marker 或旧 `syncRegistry` + +## 迁移必须满足的验收门槛 + +### 门槛 1:dry-run 必须先给出审计结果 + +至少要列出: + +- 可迁移块数量 +- 缺失 noteId 的块数量 +- noteId 在 Anki 中不存在的块数量 +- 一对多 / 多对一冲突数量 + +### 门槛 2:迁移后 Markdown 中不能再残留 legacy marker + +理想结果是: + +- 一个 block 只有一个 marker +- 该 marker 一定是 AHS marker + +### 门槛 3:迁移后 `pluginState` 必须成为唯一真相源 + +迁移完成后,后续接管不应再依赖 `syncRegistry` 或 tag 搜索。 + +### 门槛 4:首次接管验证必须做到“零重复建卡” + +对未修改内容的迁移样本,迁移后的第一次 normal sync 应满足: + +- `created = 0` +- 不新增重复 note +- marker 与 state 不再漂移 + +### 门槛 5:旧显式 deck 不得被错误改写 + +像 `[[anki背诵]]::[[《城市居住区规划设计标准》图解2021年]]` 这样的 deck 字面量,在迁移后必须继续解析为同一 deck。 + +## 关于字段是否立即归一 + +这是迁移后的策略位,不影响本次最终分类。 + +### 保守模式 + +- 先只迁 identity +- 不立即触发字段重渲染 +- 优先保证 review history 与 note continuity + +### 归一模式 + +- 迁移后主动触发一次字段更新 +- 把旧 front/back HTML 统一到当前 AHS 渲染风格 +- 代价是会发生一次批量 note update + +无论选哪种,前提都相同:先迁 identity,再谈后续同步。 + +## 何时应局部降级为“只能新建” + +以下 block 不应强行接管,应当单独降级为第 3 类: + +- legacy marker 缺失 +- noteId 非法 +- noteId 对应的 live note 不存在 +- live note 的 note model 与当前映射不兼容 +- 一个 noteId 被多个 block 竞争认领 + +## 一句话建议 + +把“旧卡兼容”定义成一次确定性的身份迁移问题,而不是长期同时维护两套 marker 协议的问题。 + +在当前证据下,最稳的结论不是“直接支持旧卡”,也不是“放弃旧卡”,而是: + +**先迁移写回,再交给 current manual-sync 正常维护。** diff --git a/docs/compatibility-identity-analysis.md b/docs/compatibility-identity-analysis.md new file mode 100644 index 0000000..f33ccb1 --- /dev/null +++ b/docs/compatibility-identity-analysis.md @@ -0,0 +1,190 @@ +# 身份兼容性分析 + +## 当前 manual-sync 的身份模型 + +当前主链的真实身份模型是: + +- Markdown 里有 `cardId` +- `pluginState.cards[cardId]` 保存 `noteId` +- 扫描阶段优先用 `cardId` 找回旧状态 +- 只有在没有 marker 但又能用 `(filePath, rawBlockHash)` 唯一命中旧状态时,才允许无 marker 续命 + +换句话说,当前主链的第一主键是 `cardId`,`noteId` 只是这个主键之下的外部映射。 + +## 旧卡的身份模型 + +用户给出的真实旧样本身份模型是: + +- heading block +- 正文 +- 尾部 legacy marker:`` + +它直接把 `noteId` 放在 Markdown 里,但没有 `cardId`,也没有当前 `pluginState` 需要的状态结构。 + +## 为什么直接接管一定失败 + +下面是旧样本首次进入当前主链时的真实控制流。 + +### 第 1 步:legacy marker 不会被识别 + +`CardMarkerService` 只识别 ``。 + +因此旧样本中的: + +`` + +对当前 `extractMarker()` 来说不是 marker,只是普通正文行。 + +### 第 2 步:legacy marker 会被保留在正文和 hash 里 + +`CardIndexingService.extractMarker()` 只有识别到 AHS marker 时,才会把 marker 行从 `bodyLines` 里剥掉。 + +旧样本 marker 不被识别,意味着: + +- `markerState = "missing"` +- `bodyMarkdown` 仍包含 legacy marker 行 +- `contentEndLine` 落在 legacy marker 那一行 +- `rawBlockHash` 是“正文 + legacy marker”一起算出来的 + +这会带来两个后果: + +1. 当前主链拿不到 `markerNoteId` +2. 将来即便写入新 AHS marker,迁移前后的 `rawBlockHash` 也不天然一致 + +### 第 3 步:`resolveIdentity()` 会生成新的 `cardId` + +旧文件首次扫描时通常同时满足: + +- 没有 `markerCardId` +- `pluginState.cards` 里没有对应条目 +- `(filePath, rawBlockHash)` 也命不中旧状态,因为旧卡还没被迁入 `pluginState` + +于是 `resolveIdentity()` 只能走最后一条分支: + +- 生成一个新的 `cardId` +- 不附带 `noteId` + +### 第 4 步:`DiffPlannerService` 会把旧卡当成新卡 + +`DiffPlannerService` 的判定非常直接: + +- `resolvedNoteId` 为空 -> `toCreate` + +所以当前主链不会把旧 note 当成“待更新”,而会把同一个 Markdown block 当成“待新建”。 + +### 第 5 步:写回还会把新 marker 插到旧 marker 后面 + +因为旧 marker 没被识别,`MarkdownWriteBackService` 后续写回的 AHS marker 会按当前 `contentEndLine` 插入。 + +而当前 `contentEndLine` 恰好落在 legacy marker 那行之后。 + +结果是: + +- 旧 `` 还在 +- 新 `` 又被追加进来 + +这不是设计好的“双 marker 兼容”,只是直接运行后的副作用。 + +## 为什么这不是“只能导入为新卡” + +虽然直接接管失败,但当前证据同时说明:迁移是可行的。 + +### 证据 1:旧 Markdown 自带精确 noteId + +旧样本不是“模糊匹配标题”,而是每个 block 尾部都内嵌了真实 noteId。 + +例如: + +- `` +- `` + +这使迁移不需要做模糊搜索,不需要猜测哪张 Anki note 属于哪个 block。 + +### 证据 2:这些 noteId 在当前 Anki 中仍然存在 + +实时 `notesInfo` / `cardsInfo` 已证实: + +- noteId 可取回 live note +- live note 的 model 是 `问答题` +- deck 仍是旧 Markdown 声明的 wikilink deck + +所以 legacy noteId 不是死数据,而是可落到当前 Anki 真值上的活锚点。 + +### 证据 3:当前主链的数据容器能容纳迁移后的身份 + +当前 `PluginState.CardState` 已经具备迁移后所需的关键字段: + +- `cardId` +- `noteId` +- `rawBlockHash` +- `renderConfigHash` +- `deck` +- block line / offset 信息 + +因此,旧卡不是“没有落点”,而是“没有翻译层”。 + +## 迁移真正要做的身份翻译 + +一次可行的迁移,本质是在每个旧 block 上做四件事: + +1. 读取 legacy `noteId` +2. 生成新的 `cardId` +3. 把 `` 改写成 `` +4. 把同一个 block 的当前状态写入 `pluginState.cards[cardId]` + +只要这四件事一次完成,下一次 normal sync 时,当前主链就会把这些卡当成原生 AHS 卡来处理。 + +## 迁移时必须守住的身份不变量 + +### 不变量 1:hash 必须基于“剥除 legacy marker 后”的 block 内容计算 + +否则迁移写回之后,未来扫描得到的 `rawBlockHash` 会和迁移时落盘的 `rawBlockHash` 不同,造成后续误判。 + +### 不变量 2:`pluginState` 必须与 Markdown 写回同时成立 + +只改 marker 不建状态,下一次主链仍然可能失配。 + +只建状态不改 marker,则 Markdown 侧没有 `cardId`,主链也无法稳定命中。 + +### 不变量 3:不能依赖 `syncRegistry` + +当前 manual-sync 不消费 `syncRegistry`,所以迁移后的真相必须落在 `pluginState`,而不是旧 registry。 + +### 不变量 4:缺失 noteId 的 block 必须降级处理 + +只要某个旧 block 没有可验证的 live noteId,它就不再属于“可接管”的那一类,而应降级为“只能新建导入”。 + +## 字段是否要在迁移后立即归一 + +这不是 identity 阻塞点,但它是一个策略位。 + +### 方案 A:只迁 marker 和状态,不立即重渲染旧 note 字段 + +- 优点:最保守,review history 不受干扰,旧卡外观暂时保持原样 +- 缺点:旧卡会在一段时间内继续保留旧式 front/back HTML + +### 方案 B:迁移完成后强制一次重渲染 + +- 优点:旧卡立即归一到当前 AHS 的 `Open in Obsidian` 渲染风格 +- 缺点:会产生一次大批量字段更新 + +无论选 A 还是 B,都不改变主结论:接管是可行的,但必须先迁移 identity。 + +## 身份分析结论 + +当前主链对旧卡的失败方式是确定性的,不是概率性的: + +- 认不出旧 marker +- 建不出旧 identity +- 进入 `toCreate` +- 产出重复卡 + +但同样,迁移后的成功路径也是确定性的,不是模糊匹配式的: + +- 旧 Markdown 提供 noteId +- live Anki 验证 noteId 真实存在 +- 当前状态模型能容纳迁移结果 + +所以最终判断应是: + +不是“不能接管”,而是“不能直接接管,只能先迁移后接管”。 diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md new file mode 100644 index 0000000..b178172 --- /dev/null +++ b/docs/compatibility-matrix.md @@ -0,0 +1,57 @@ +# 兼容性矩阵 + +## 维度矩阵 + +| 维度 | 旧卡现状 | 当前 manual-sync 要求 | 当前兼容性 | 说明 | +| --- | --- | --- | --- | --- | +| Markdown marker 识别 | `` | `` | 不兼容 | 当前 parser 完全不识别旧 marker | +| 首次 identity 建立 | 只有 `noteId` | 需要 `cardId` 或已存在的 `pluginState` | 不兼容 | 没有 `cardId` 就会生成新身份 | +| 旧状态利用 | data.json 里可能还有 `syncRegistry` | manual-sync 只看 `pluginState` | 不兼容 | 旧 registry 不会自动转成新状态 | +| `rawBlockHash` 一致性 | 旧 marker 是正文最后一行的一部分 | 新主链只会剥离 AHS marker 再算 hash | 不兼容 | 直接扫描旧文件时,legacy marker 会进入 hash | +| 首次 diff 规划 | 旧卡应被认领为已有 note | `resolvedNoteId` 为空就 `toCreate` | 不兼容 | 直接运行会建重复卡 | +| Markdown 写回效果 | 理想上应替换旧 marker | 当前 write-back 只会写 AHS marker | 有风险 | 不做迁移时会出现旧 marker + 新 marker 并存 | +| Note model | 旧样本 live note 为 `问答题` | 当前配置的 QA note type 为 `问答题` | 兼容 | 无需 note type 迁移 | +| 字段映射 | 旧样本 live note 为 `正面 / 背面` | 当前映射也是 `正面 / 背面` | 兼容 | 无需字段结构转换 | +| Deck 语义 | 旧样本使用 wikilink deck 名 | 当前 deck normalization 保留 segment 原文 | 兼容 | deck 不是阻塞点 | +| Tag 利用 | 旧卡常带 `Obsidian_to_Anki` | 当前主链不使用 tag 认领 | 条件兼容 | tag 适合审计,不适合作为主 identity | +| 字段渲染风格 | 旧卡 front/back 含旧式 Obsidian 链接 | 当前渲染使用 `Open in Obsidian` backlink | 条件兼容 | 接管后字段可能被归一 | +| 一次性迁移可行性 | Markdown 已含精确 noteId | 新状态可容纳 `cardId -> noteId` | 兼容 | 可以通过一次迁移把旧身份翻译成新身份 | + +## 结果分层 + +### 直接兼容的维度 + +- note model +- 字段映射 +- deck 名字与 deck 提示 + +### 阻塞直接接管的维度 + +- marker 识别 +- 首次 identity 建立 +- 旧状态复用 +- 首次 diff 规划 +- 直接写回行为 + +### 只有迁移后才兼容的维度 + +- `cardId -> noteId` 状态落盘 +- 新 marker 写回 +- 后续 normal sync 的稳定更新与改 deck + +## 四种结论类别映射 + +| 结论类别 | 与当前证据的匹配度 | 判断 | +| --- | --- | --- | +| 可直接接管 | 低 | 否 | +| 可接管,但必须先做一次迁移写回 | 高 | 是 | +| 不可安全接管,只能导入为新卡 | 中 | 否,除非某些块缺 noteId 或 note 已丢失 | +| 需要双 marker 过渡期 | 低到中 | 否,当前证据不要求双 marker 才能安全完成迁移 | + +## 最核心的矩阵结论 + +如果只看 Anki 侧数据模型,旧卡与当前主链是兼容的。 + +如果看 identity 进入主链的入口,旧卡与当前主链是不兼容的。 + +所以真正的系统级判断不是“不能接管”,而是“不能直接接管,但可以通过一次显式迁移接管”。 diff --git a/docs/compatibility-sample-table.md b/docs/compatibility-sample-table.md new file mode 100644 index 0000000..adc3a6e --- /dev/null +++ b/docs/compatibility-sample-table.md @@ -0,0 +1,51 @@ +# 兼容性样本对照表 + +## 样本边界 + +- 旧 Markdown 样本:`9Anki背诵/《城市居住区规划设计标准》图解2021年.md` +- 新 Markdown 样本:`999,试验卡片/课件2/卡片试验.md` +- 当前插件设置来源:`.obsidian/plugins/Anki Heading Sync/data.json` +- 当前 Anki 真值来源:`/tmp/ahs_compat_raw/*.json` + +## Anki 侧样本发现快照 + +| 查询 | 返回结果 | 解释 | +| --- | --- | --- | +| `findNotes tag:Obsidian_to_Anki` | 返回大量 noteId,包含旧样本里的 `1760624596099` 等 noteId | 说明旧卡语料真实存在于当前 Anki 中 | +| `findNotes deck:"999,试验卡片::课件2::卡片试验"` | `[1776515511480, 1776515511477]` | 说明新链路样本 2 张卡也真实存在于当前 Anki 中 | + +## 旧样本 5 张卡 + +| noteId | cardId | Markdown 标题 | Markdown marker | Markdown deck hint | Anki deck | model / tags | 字段形态摘要 | 样本结论 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 1760624596099 | 1760624596105 | 城市居住区,定义? | `` | `[[anki背诵]]::[[《城市居住区规划设计标准》图解2021年]]` | `[[anki背诵]]::[[《城市居住区规划设计标准》图解2021年]]` | `问答题` / `Obsidian_to_Anki` | Front 含题目和 Obsidian 链接;Back 含块级 backlink + 正文 | noteId 在 Markdown 与 Anki 间可直接对上 | +| 1760624596121 | 1760624596122 | 居住区依据其居住人口规模,可分为几类? | `` | 同上 | 同上 | `问答题` / `Obsidian_to_Anki` | Front 含题目与 Obsidian 链接;Back 为列表 HTML | 数据模型与当前设置兼容 | +| 1760624596130 | 1760624596131 | 十五分钟生活圈居住区?定义? | `` | 同上 | 同上 | `问答题` / `Obsidian_to_Anki` | Back 含强调、标记、旧样式链接 | deck 与字段都能继续沿用 | +| 1760624596139 | 1760624596140 | 十分钟生活圈居住区?定义? | `` | 同上 | 同上 | `问答题` / `Obsidian_to_Anki` | Back 含旧渲染 HTML,含强调与标记 | 接管后字段可能被新渲染归一 | +| 1760624596147 | 1760624596147 | 五分钟生活圈居住区 | `` | 同上 | 同上 | `问答题` / `Obsidian_to_Anki` | Front 含题目与 Obsidian 链接;Back 为旧链路 HTML | 旧卡确有可认领的 live note | + +## 新样本 2 张卡 + +| noteId | cardId | Markdown 标题 | Markdown marker | Deck 来源 | Anki deck | model / tags | 字段形态摘要 | 样本结论 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 1776515511477 | `ahs_a217324b` | 试验的2 | `` | 文件夹 + 文件名 | `999,试验卡片::课件2::卡片试验` | `问答题` / 无 tag | Front 只有题目;Back 含 `Open in Obsidian` backlink | 当前主链是 `cardId + noteId` 双锚点 | +| 1776515511480 | `ahs_dca62365` | 试验2333 | `` | 文件夹 + 文件名 | `999,试验卡片::课件2::卡片试验` | `问答题` / 无 tag | Front 只有题目;Back 含 `Open in Obsidian` backlink | 新链路不依赖 Anki tag 识别 | + +## 旧样本 Markdown 与新样本 Markdown 的直接差异 + +| 维度 | 旧样本 | 新样本 | 含义 | +| --- | --- | --- | --- | +| heading 层级 | `####` | `######` | 当前设置里 `qaHeadingLevel=6`,旧样本若直接进入当前主链,是否被识别为 QA 卡还取决于设置或迁移策略 | +| marker 格式 | `` | `` | identity 模型已经换代 | +| deck 来源 | 文件内 `TARGET DECK` | `folder-and-file` | deck 决策来源不同,但都能映射到 live Anki deck | +| Front 字段内容 | 题目后通常拼接一个 Obsidian 链接 | 仅保留题目 | 接管后若做字段归一,旧卡正面会被改写 | +| Back 字段内容 | 旧链路 HTML,常带块级 backlink | 新链路 HTML,统一 `Open in Obsidian` | 迁移后是否强制重渲染,是一个策略位 | +| Tag | 常带 `Obsidian_to_Anki` | 通常为空 | tag 可用于审计,但不是当前主链 identity 输入 | + +## 三侧真值归纳 + +1. 旧 Markdown 已经提供了最关键的身份锚点:`noteId`。 +2. 旧卡 live Anki 中的 note model 与字段映射和当前插件设置兼容。 +3. 旧卡 live deck 与旧 Markdown 中的 `TARGET DECK` 完全一致,且当前 deck normalization 不会破坏 wikilink deck 名。 +4. 新链路真正新增的是 `cardId` 这一层主键,而不是新的 note model。 +5. 因此旧卡“无法接管”的原因不是数据不够,而是当前主链没有把旧身份格式翻译进新身份模型。 diff --git a/docs/compatibility-strategy-comparison.md b/docs/compatibility-strategy-comparison.md new file mode 100644 index 0000000..8ab2172 --- /dev/null +++ b/docs/compatibility-strategy-comparison.md @@ -0,0 +1,127 @@ +# 兼容策略对比 + +## 对比对象 + +本次按用户要求,对四种结论类别逐一对照当前证据: + +1. 可直接接管 +2. 可接管,但必须先做一次迁移写回 +3. 不可安全接管,只能导入为新卡 +4. 需要双 marker 过渡期 + +## 策略总表 + +| 策略 | 核心做法 | 对当前证据的适配度 | 数据安全性 | 实施复杂度 | 结论 | +| --- | --- | --- | --- | --- | --- | +| 可直接接管 | 不改旧 marker,不做状态迁移,直接让 current manual-sync 扫旧文件 | 低 | 低 | 低 | 否决 | +| 先迁移写回再接管 | 先把 legacy noteId 翻译成新 `cardId + noteId`,再让 current manual-sync 接管 | 高 | 高 | 中 | 推荐 | +| 只能导入为新卡 | 放弃认领旧 noteId,全部按新 note 创建 | 中 | 中到低 | 低 | 仅作失败降级 | +| 双 marker 过渡期 | 同时兼容旧 marker 和新 marker 一段时间 | 低到中 | 中 | 高 | 非必要 | + +## 1. 可直接接管 + +### 假设 + +假设当前主链即使不懂旧 marker,也能靠别的方式认领旧 note。 + +### 为什么被证据否掉 + +- 当前 marker parser 不识别 `` +- 当前状态层不消费 `syncRegistry` +- 当前 indexer 首次进入旧文件时没有 `cardId` 可用 +- `DiffPlannerService` 会把 `resolvedNoteId` 为空的卡直接放进 `toCreate` + +### 实际后果 + +- 新建重复 note +- 旧 marker 仍留在 Markdown +- 新 AHS marker 追加写回 + +### 判断 + +不能选。 + +## 2. 可接管,但必须先做一次迁移写回 + +### 核心思路 + +在进入 current manual-sync 之前,先把旧卡翻译成当前主链原生能理解的形态: + +- legacy marker -> AHS marker +- noteId-only -> `cardId + noteId` +- 无新状态 -> `pluginState.cards` + +### 为什么它和当前证据最匹配 + +- 旧 Markdown 已经给出精确 noteId +- Anki live note 仍存在 +- note model / fields 与当前设置兼容 +- deck 字符串兼容 +- 当前主链只缺一层 identity translation + +### 风险控制点 + +- 必须 dry-run 检查 noteId 是否仍存在 +- 必须在写回时同时落 `pluginState` +- 必须按“剥掉 legacy marker 后”的 block 计算 `rawBlockHash` +- 必须拒绝一对多或多对一的异常映射 + +### 判断 + +这是当前最合理的主方案。 + +## 3. 不可安全接管,只能导入为新卡 + +### 这种判断成立的前提 + +只有在以下情况才应把某些卡降级为“只能新建”: + +- Markdown 中没有可验证的旧 noteId +- noteId 指向的 live note 已经不存在 +- 同一个 noteId 被多个 block 复用 +- 旧卡 model 与当前映射不兼容 + +### 为什么它不是本次样本的总判断 + +当前样本并不满足这些失败前提: + +- noteId 在 Markdown 中明确存在 +- live note 能通过 `notesInfo` / `cardsInfo` 取回 +- model 和 fields 兼容 +- deck 也兼容 + +### 判断 + +不能作为总方案,只能作为迁移过程中的按块降级策略。 + +## 4. 需要双 marker 过渡期 + +### 这种策略通常解决什么问题 + +- 不能原子迁移整批 Markdown +- 需要多客户端、多版本并存一段时间 +- 需要新旧插件同时读写同一批文件 + +### 当前证据为什么不支持它成为必要条件 + +- 旧样本已经给出精确 noteId,不需要靠双 marker 维持身份连续性 +- 当前 manual-sync 的稳定态只认识 AHS marker,保留双 marker 反而会引入长期 parser 负担 +- 用户当前要的是“接管旧卡”,不是“长期同时运行两套身份协议” + +### 什么时候它才值得考虑 + +只有在 rollout 约束非常强,无法保证一次性写回,才应把双 marker 作为临时发布策略,而不是兼容性的核心答案。 + +### 判断 + +不是必要条件。 + +## 策略对比后的落点 + +最稳妥的归纳是: + +- 总体结论属于第 2 类 +- 第 3 类是迁移失败块的兜底分流 +- 第 4 类只在部署方式受限时才可能作为临时工程手段 + +也就是说,正确问题不是“要不要直接支持旧 marker 一辈子”,而是“要不要做一次确定性的身份迁移,然后把系统收敛到单一的新 marker 协议”。 diff --git a/docs/compatibility-touchpoints-report.md b/docs/compatibility-touchpoints-report.md new file mode 100644 index 0000000..7661dd5 --- /dev/null +++ b/docs/compatibility-touchpoints-report.md @@ -0,0 +1,99 @@ +# 兼容性触点报告 + +## 分析边界 + +- 只评估当前 manual-sync 主链,不把旧 syncRegistry 主链当成最终方案。 +- 证据来源限定为四类真值:仓库代码、用户提供的旧 Markdown 样本、用户提供的新 Markdown 样本、实时 AnkiConnect 返回。 +- 目标问题只有一个:当前主链能否安全接管由 Obsidian to Anki 生成的旧卡,并继续后续同步。 + +## 结论先行 + +当前主链不能直接接管旧卡。核心阻塞点不是 note model,也不是 deck,而是 identity bootstrap。 + +- 旧样本只提供 legacy noteId marker:`` +- 新主链只识别 AHS marker:`` +- 当前状态层以 `cardId -> noteId` 为主键,而不是以 `noteId` 为主键 +- 因此,旧文件在首次进入当前主链时,既拿不到 `marker.cardId`,也拿不到 `pluginState.cards[cardId]`,最后只能被当成新卡创建 + +这意味着“直接接管”会造成重复建卡,而不是认领旧卡。 + +## 关键触点一览 + +| 触点 | 当前行为 | 兼容性影响 | 结论 | +| --- | --- | --- | --- | +| `src/domain/manual-sync/services/CardMarkerService.ts` | `isCandidate()` 和 `parse()` 只认 `` | 旧样本 `` 对主链是不可见 marker | 不兼容 | +| `src/domain/manual-sync/services/CardIndexingService.ts` 的 `extractMarker()` | 只会剥离 AHS marker;没识别出的 legacy marker 会被保留在正文里 | 旧 marker 不会转成 `noteId`,还会污染 `bodyMarkdown` 与 `rawBlockHash` | 不兼容 | +| `src/domain/manual-sync/services/CardIndexingService.ts` 的 `resolveIdentity()` | 解析顺序是 `marker.cardId -> pendingWriteBack -> knownCards(filePath, rawBlockHash) -> 生成新 cardId` | 旧文件首次扫描时三条已有身份路径都不存在,必然生成新 `cardId` | 不兼容 | +| `src/domain/manual-sync/services/DiffPlannerService.ts` | `resolvedNoteId` 为空就进入 `toCreate` | 旧卡不会被“接管更新”,而会被“新建重复卡” | 不兼容 | +| `src/domain/manual-sync/entities/PluginState.ts` | `cards` 以 `cardId` 为 key,状态里没有 legacy marker 元数据位 | 当前状态容器无法直接承接“只有 noteId、没有 cardId”的旧卡 | 不兼容 | +| `src/infrastructure/persistence/DataJsonPluginStateRepository.ts` | 只读取 `snapshot.pluginState`,不会消费 `syncRegistry` | 即便 data.json 里还有旧 `syncRegistry` 记录,manual-sync 也不会拿来认领旧卡 | 不兼容 | +| `src/application/services/FileIndexerService.ts` | 只把 `pluginState.cards` 和 `pendingWriteBack` 喂给 indexer | 旧卡若没先迁入 `pluginState`,主链没有任何第二身份来源 | 不兼容 | +| `src/application/services/MarkdownWriteBackService.ts` | 只会写入新的 AHS marker | 如果直接跑在旧文件上,会在 legacy marker 后再插入一个 AHS marker,而不是替换旧 marker | 有风险 | +| `src/domain/manual-sync/services/DeckExtractionService.ts` + `DeckNormalizationService.ts` | 会提取 `TARGET DECK`,并保留 deck segment 原文,只做分隔符规范化 | 旧样本里的 `[[anki背诵]]::[[《城市居住区规划设计标准》图解2021年]]` 能被原样保留 | 兼容 | +| `src/domain/manual-sync/services/ManualCardRenderer.ts` | 当前渲染会在背面追加 `Open in Obsidian` backlink | 即使完成接管,后续同步也会把旧卡字段逐步归一到新渲染样式 | 条件兼容 | +| `src/application/services/HeadingSyncMarkerService.ts` + `src/domain/card/services/CardExtractionService.ts` | 旧链路支持的是 `` 这种 noteId-only marker | 这和本次真实旧样本的 `` 不是同一种格式,也不在 current manual-sync 主链里 | 不能直接复用 | + +## 代码触点拆解 + +### 1. Marker 语义已经换代 + +当前 manual-sync 的 marker 不是“把 noteId 嵌进去就行”,而是显式要求: + +- 一个稳定的 `cardId` +- 一个可选的 `noteId` +- 一个确定的 AHS marker 字面量 + +旧样本只有 `noteId`,没有 `cardId`。这不是缺一个字段那么简单,而是整个主链的身份主键已经从 `noteId` 切成了 `cardId`。 + +### 2. 当前主链不会利用旧 `syncRegistry` + +实时 data.json 里仍然同时存在: + +- `syncRegistry.records[].identityMode = "embedded-note-id"` +- `pluginState.files / pluginState.cards` + +但 `DataJsonPluginStateRepository` 只加载 `pluginState`,manual-sync 完全不看 `syncRegistry`。所以旧状态即便还在 data.json 里,也不会自动帮助旧卡进入新主链。 + +### 3. Deck 不是阻塞点 + +这是这次分析里最重要的反向结论之一。 + +- 旧样本 Markdown 明确声明了 `TARGET DECK: [[anki背诵]]::[[《城市居住区规划设计标准》图解2021年]]` +- 旧样本 live card 的 `cardsInfo.deckName` 也正是这个 wikilink deck 字符串 +- 当前 `DeckNormalizationService` 不会剥掉 `[[...]]`,只会规范 `/`、`\` 和 `::` + +因此,旧 deck 的字面值兼容是成立的。真正不兼容的是 identity,不是 deck。 + +### 4. Note model 与字段映射也不是阻塞点 + +实时样本显示: + +- 旧样本 live note:`modelName = 问答题`,字段为 `正面 / 背面` +- 新样本 live note:`modelName = 问答题`,字段为 `正面 / 背面` +- 当前插件设置:`basic:问答题 -> titleField=正面, bodyField=背面` + +所以旧卡与新卡在 Anki 侧的数据模型是兼容的,后续接管不需要做 note type 迁移。 + +## 最终触点判断 + +### 已兼容 + +- 旧 deck 字符串可继续沿用 +- 旧 note model / 字段映射与当前设置兼容 +- 旧 noteId 在 Anki 中真实存在,具备可迁移基础 + +### 未兼容 + +- legacy marker 识别 +- 首次 identity 认领 +- 旧状态向 `pluginState` 的引导 +- 直接运行时的防重复保障 + +### 因此得到的主结论 + +当前主链不属于“可直接接管”。 + +它属于“可以接管,但必须先做一次迁移写回”: + +- 先把旧 `noteId` 身份翻译成新 `cardId + noteId` 身份 +- 再把 Markdown marker 和 `pluginState` 一次性对齐到 current manual-sync 语义 diff --git a/docs/id-noteid-mainline-decisions.md b/docs/id-noteid-mainline-decisions.md new file mode 100644 index 0000000..b95992f --- /dev/null +++ b/docs/id-noteid-mainline-decisions.md @@ -0,0 +1,149 @@ +# ID NoteId Mainline Decisions + +## Decision Summary + +本轮实现严格按如下决策收口: + +1. `` 是 manual-sync 主链唯一 marker 协议 +2. `noteId` 是 durable identity +3. `syncKey` 只用于一次 sync 运行内的关联,不落盘 +4. 旧 `ID` marker 卡片只在“满足当前标题级别规则”时才参与认领/重渲染 +5. 认领成功的旧卡一律使用当前 renderer 重渲染更新,不保留旧 HTML 风格 +6. 已有 AHS-marker 文件本轮不做兼容,不做迁移 + +## Concrete Decisions + +### 1. Marker service 直接重构,不保留 AHS dual mode + +- 继续沿用 `CardMarkerService` 这个文件名以控制改动范围 +- 但服务语义改成 ID marker service +- 删除 `generateCardId()` 及所有 AHS-specific create/parse 逻辑 +- `create(noteId)` 永远输出 `` + +原因: + +- 本轮契约明确不支持 dual marker +- 保留 dual mode 只会把旧 AHS 分支继续带进主链 + +### 2. IndexedCard 保留 `noteId?`,新增 `syncKey` + +- durable `cardId` 从 `IndexedCard` 删除 +- 新增 `syncKey = filePath + "\0" + blockStartLine + "\0" + rawBlockHash` +- `idMarkerState` 固定为:`missing | present-valid | present-invalid` +- `noteIdSource` 固定为:`marker | state-recovery | pending-writeback | undefined` + +原因: + +- `syncKey` 适合在 planner/executor/writeback 间做单次运行内关联 +- 不需要再把 cardId 作为系统稳定主键 + +### 3. Persisted state 全部切到 noteId 主身份 + +- `PluginState.cards` 保持字段名,但 key 改为 `String(noteId)` +- `CardState.noteId` 改为必填 +- `CardState.cardId` 删除 +- `FileState.cardIds` 改为 `noteIds` +- `PendingWriteBackState` 改为: + - `filePath` + - `blockStartLine` + - `rawBlockHash` + - `targetNoteId` + - `expectedFileHash` + - `targetMarker` + +原因: + +- 这正好对应本轮 durable identity contract +- pending writeback 的匹配条件也必须改成块身份,而不是 cardId + +### 4. Repository load 承担 schema migration + +- 迁移点放在 `DataJsonPluginStateRepository.load()` +- 旧 `cards[cardId]` 如果带合法 `noteId`,迁成 `cards[String(noteId)]` +- 旧 `files[].cardIds` 迁成 `files[].noteIds` +- 旧 `pendingWriteBack` 一律丢弃 + +原因: + +- 这是最靠近持久化边界的位置 +- 可避免把 schema upgrade 逻辑散落到 service 层 + +### 5. Identity resolution 按契约固定顺序实现 + +- 先 valid trailing ID marker +- 再唯一 same-file rawBlockHash state recovery +- 再 pending writeback 命中 +- 否则 `noteId = undefined` + +补充决策: + +- rawBlockHash 基于剥离 trailing valid/invalid ID marker 后的正文计算 +- duplicate noteId claim 时,只保留首次 claim,后续 claim 降级为 `undefined` + +原因: + +- 这能同时满足旧卡重接进、marker 被手动删掉后的恢复,以及创建路径的确定性 + +### 6. Diff planning 不引入额外兼容模式 + +- `toCreate`、`toUpdate`、`toChangeDeck`、`toRewriteMarker`、`toOrphan` 按用户契约原样实现 +- blocks not matching heading rules 在 index 阶段就不进入 planner + +原因: + +- 避免 planner 再承担“忽略不匹配标题块”的第二层分支 + +### 7. Executor 用 syncKey 做 run-time correlation + +- `resolvedNoteIds` 改为 `Map`,key 是 `syncKey` +- `touchedCardIds` 改名并改语义为 touched note/sync keys +- marker write map 以 `syncKey` 为 key +- rendered cards map 也以 `syncKey` 为 key + +原因: + +- 这能把“本次运行内引用”与“持久 identity”彻底分开 + +### 8. Reconnect success always means re-render + +- 只要 block 满足当前标题级别规则 +- 且 `` 合法且 Anki 中 note 存在 +- 就认领该 note +- 然后用当前 renderer 重新计算 fields 并更新该 note + +原因: + +- 这是本轮明确的产品契约 +- 不再引入 preserve legacy HTML mode + +### 9. Not matching heading rules means total silence + +- 不解析 marker +- 不 strip marker +- 不访问 Anki +- 不创建、不更新、不写回、不 warning + +原因: + +- 这是本轮必须实现的静默忽略语义 + +### 10. Test strategy + +- 重写 marker/index/planner/writeback 的单元测试到新协议 +- 为旧 `ID` marker 接管行为补 integration tests +- migration 行为至少覆盖 repository load 级别或 service 可观察级别 + +原因: + +- 现有测试大多锁定 AHS/cardId 旧契约,不能继续作为主保护层 + +## Intentional Non-Decisions + +以下事项本轮不做延伸决策: + +- AHS marker 文件后续怎么迁 +- 是否需要独立 migration command +- 是否给 ignored `` 块做可视化提示 +- 是否为旧样本提供 heading policy compatibility mode + +这些都超出本轮范围。 diff --git a/docs/id-noteid-mainline-gap-report.md b/docs/id-noteid-mainline-gap-report.md new file mode 100644 index 0000000..58d9d47 --- /dev/null +++ b/docs/id-noteid-mainline-gap-report.md @@ -0,0 +1,262 @@ +# ID NoteId Mainline Gap Report + +## Scope + +本报告只针对当前 manual-sync 主链,审查它与本轮契约之间的偏差: + +- 主链 durable identity 切换为 `` +- 满足当前标题级别规则的旧卡支持按 `ID` marker 重接进 +- 认领成功后按当前 renderer 重渲染更新 +- plugin state / writeback / execution 全部切到 noteId 主身份 + +## Executive Summary + +当前实现与本轮契约的偏差是系统性的,不是局部修补能解决的小问题。 + +当前 manual-sync 主链仍然围绕以下假设建立: + +- Markdown marker 协议是 `` +- `cardId` 是 durable identity +- `PluginState.cards` 以 `cardId` 作为 key +- `pendingWriteBack` 以 `cardId` 追踪 +- 一次运行内与持久化状态共用同一身份键 + +而本轮契约要求: + +- `` 成为唯一主链 marker 协议 +- `noteId` 成为 durable identity +- `syncKey` 只作为单次运行内的相关键 +- 旧 `ID` marker 卡片可以在满足当前标题规则时被认领并重渲染 + +结论:当前主链必须做一次贯穿 marker、index、plan、execute、state、writeback 的统一重构。 + +## Gap Inventory + +### 1. Marker protocol is wrong + +文件:`src/domain/manual-sync/services/CardMarkerService.ts` + +现状: + +- 候选 marker 只看 `` +- create/writeback 也只会生成 AHS marker +- service 内还负责生成新的 `cardId` + +与契约冲突: + +- 主链 marker 必须切为 `` +- 合法 marker 只能接受正整数 noteId +- 非法 trailing `` 候选也要剥离并占用 marker 槽位 +- 主链不再以 marker 生成 durable `cardId` + +影响: + +- 当前主链无法识别旧卡 marker +- 当前 writeback 永远不会写出正确协议 + +### 2. IndexedCard schema still treats cardId as durable identity + +文件:`src/domain/manual-sync/entities/IndexedCard.ts` + +现状: + +- `IndexedCard` 仍持有 `cardId` +- 仍使用 `markerState = "missing" | "card-only" | "card-and-note"` +- 没有 `syncKey` +- 没有 `idMarkerState` +- 没有 `noteIdSource` + +与契约冲突: + +- durable `cardId` 应从主链 index 结果中移除 +- 需要新增运行内 `syncKey` +- 需要区分 marker 是否存在且是否合法 +- 需要显式记录 noteId 来源是 marker / state recovery / pending writeback + +影响: + +- 后续 planning / execution 无法按契约判断“认领”“恢复”“重写 marker” + +### 3. Persisted state schema is still cardId-centric + +文件:`src/domain/manual-sync/entities/PluginState.ts` + +现状: + +- `FileState.cardIds` 仍然存在 +- `CardState.cardId` 仍然存在 +- `CardState.noteId` 仍是可选 +- `PendingWriteBackState` 用 `cardId` 追踪 +- `PluginState.cards` 的 key 仍隐含为 `cardId` + +与契约冲突: + +- `PluginState.cards` 必须改为 `String(noteId)` 键控 +- `FileState.cardIds` 必须改为 `noteIds` +- `CardState.noteId` 必须是必填 +- `pendingWriteBack` 必须改成按块身份追踪 + +影响: + +- 当前 persisted state 不能作为 noteId 主身份的真相源 + +### 4. State repository has no schema migration + +文件:`src/infrastructure/persistence/DataJsonPluginStateRepository.ts` + +现状: + +- `load()` 只是直接返回 `snapshot.pluginState` +- 不迁移旧 `cards[cardId]` +- 不迁移 `files[].cardIds` +- 不丢弃旧 pending writeback + +与契约冲突: + +- 需要在 load 阶段显式升级旧 schema +- 旧 `pendingWriteBack` 要直接丢弃 + +影响: + +- 主链切协议后,旧状态无法安全进入新主链 + +### 5. Indexing still resolves identity by cardId + +文件:`src/domain/manual-sync/services/CardIndexingService.ts` + +现状: + +- marker extraction 只识别 AHS marker +- `resolveIdentity()` 顺序是:marker cardId -> pending by cardId -> knownCards by rawBlockHash -> 生成新 cardId +- `usedCardIds` 用于去重 +- `rawBlockHash` 当前只会剥除 AHS marker,不会剥除旧 `ID` marker + +与契约冲突: + +- 先识别“当前标题规则命中的块”,再处理 ID marker +- identity resolution 必须变成:marker noteId -> unique same-file rawBlockHash state recovery -> pending writeback -> undefined +- 去重必须基于 noteId,而不是 cardId +- `rawBlockHash` 必须基于剥除合法/非法 trailing ID marker 后的内容计算 + +影响: + +- 旧 `` 卡会被当成正文内容 +- 会错误走新建分支 + +### 6. Planner still assumes cardId + markerNoteId semantics + +文件:`src/domain/manual-sync/services/DiffPlannerService.ts` + +现状: + +- 使用 `cardsById` 按 `card.cardId` 去重 +- 读取 `state.cards[card.cardId]` +- `pendingWriteBack` 按 `cardId` 命中 +- `toRewriteMarker` 依赖 `markerState = missing/card-only` +- orphan 识别依赖 `card.cardId` + +与契约冲突: + +- 规划必须以最终 noteId 和运行内 syncKey 为中心 +- recovered noteId、invalid marker、新创建 note 都要触发 rewrite marker +- blocks with existing noteId but no state 也能正常进入 update path + +影响: + +- 当前 planner 无法正确表达“旧卡重接进 + 重写 ID marker” + +### 7. Executor still uses cardId as the main run-time correlation key + +文件:`src/application/services/AnkiBatchExecutor.ts` + +现状: + +- `resolvedNoteIds` 按 `card.cardId` 保存 +- `touchedCardIds` 按 `card.cardId` 保存 +- `markerWriteMap` 按 `card.cardId` 保存 +- rendered cards map 也依赖 `card.cardId` + +与契约冲突: + +- durable identity 不能再依赖 cardId +- 运行内相关键应切为 `syncKey` +- note existence check 失败时应降级到 create flow,并最终写回真实 `` + +影响: + +- 即便 planner 修好,executor 仍会把旧身份流重新扭回 cardId 模式 + +### 8. Markdown writeback still writes by cardId + +文件:`src/application/services/MarkdownWriteBackService.ts` + +现状: + +- 结果结构返回 `writtenCardIds` +- 写回请求包含 `cardId` +- pending entry 保存 `cardId` +- target marker 仍通过 AHS marker service 生成 + +与契约冲突: + +- 写回必须统一输出 `` +- pending writeback 要按 `(filePath, blockStartLine, rawBlockHash)` 追踪 +- 结果结构也应转向 noteId/syncKey 语义 + +影响: + +- 当前 writeback 不能支撑新 marker 协议和新 pending shape + +### 9. Tests are still asserting the old contract + +主要文件: + +- `src/domain/manual-sync/services/CardMarkerService.test.ts` +- `src/domain/manual-sync/services/CardIndexingService.test.ts` +- `src/domain/manual-sync/services/DiffPlannerService.test.ts` + +现状: + +- 测试名称和断言大量依赖 `cardId` +- 大量断言使用 AHS marker 字面值 +- 缺少“invalid ID marker but valid block”“not matching heading rules -> ignore marker”“schema migration”这类契约要求 + +影响: + +- 即使代码完成重构,现有测试也会大面积误报失败 +- 当前测试对本轮目标没有形成足够保护 + +## Architecture Notes + +### Good existing structure worth preserving + +- `ManualSyncService -> FileIndexerService -> DiffPlannerService -> AnkiBatchExecutor -> MarkdownWriteBackService` 这条主链分层是合理的 +- 当前 rendering boundary 仍然干净,可以直接复用在旧卡重接进后的重渲染更新 +- `DeckResolution` 和 deck migration 相关逻辑与本轮冲突较少,应尽量少动 + +### Mainline drift that must be removed + +- main sync durable identity 仍与 module 3/4 的 AHS assumptions 紧耦合 +- `cardId` 同时承担 persisted identity 和 in-run correlation key,两类职责未分离 + +## Required Repair Direction + +必须完成的主修复方向: + +1. marker 协议切换到 `` +2. `noteId` 成为持久主身份,`syncKey` 成为运行内相关键 +3. state schema 与 repository load 做显式迁移 +4. index / plan / execute / writeback 全链去掉 durable `cardId` 依赖 +5. 增加对“满足当前标题规则且带旧 `ID` marker”的重接进与重渲染支持 +6. 重写对应测试,补足 integration 覆盖 + +## Out Of Scope + +根据契约,本轮明确不做: + +- 继续支持 `` 文件 +- 双 marker 并读 +- 旧 HTML 保留模式 +- 不匹配标题规则的 `ID` marker 清理工具 +- legacy heading compatibility mode diff --git a/src/application/services/AnkiBatchExecutor.test.ts b/src/application/services/AnkiBatchExecutor.test.ts index 6d43f26..8a4e457 100644 --- a/src/application/services/AnkiBatchExecutor.test.ts +++ b/src/application/services/AnkiBatchExecutor.test.ts @@ -27,13 +27,13 @@ describe("AnkiBatchExecutor", () => { }); const executor = new AnkiBatchExecutor(ankiGateway); - const createOne = createPlannedCard("ahs_1"); - const createTwo = createPlannedCard("ahs_2"); - const updateOne = createPlannedCard("ahs_3", 300); + const createOne = createPlannedCard("sync-1"); + const createTwo = createPlannedCard("sync-2"); + const updateOne = createPlannedCard("sync-3", 300); const renderedCards = new Map([ - [createOne.card.cardId, createRenderedSyncCard(createOne)], - [createTwo.card.cardId, createRenderedSyncCard(createTwo)], - [updateOne.card.cardId, createRenderedSyncCard(updateOne)], + [createOne.card.syncKey, createRenderedSyncCard(createOne)], + [createTwo.card.syncKey, createRenderedSyncCard(createTwo)], + [updateOne.card.syncKey, createRenderedSyncCard(updateOne)], ]); const plan: ManualSyncPlan = { toCreate: [createOne, createTwo], @@ -66,13 +66,13 @@ describe("AnkiBatchExecutor", () => { }); const executor = new AnkiBatchExecutor(ankiGateway); - const createCard = createPlannedCard("ahs_create", undefined, "Folder::Deck"); - const updateCard = createPlannedCard("ahs_update", 300, "Changed::Deck"); - const changeDeckCard = createPlannedCard("ahs_migrate", 300, "Changed::Deck"); + const createCard = createPlannedCard("sync-create", undefined, "Folder::Deck"); + const updateCard = createPlannedCard("sync-update", 300, "Changed::Deck"); + const changeDeckCard = createPlannedCard("sync-migrate", 300, "Changed::Deck"); const renderedCards = new Map([ - [createCard.card.cardId, createRenderedSyncCard(createCard)], - [updateCard.card.cardId, createRenderedSyncCard(updateCard)], - [changeDeckCard.card.cardId, createRenderedSyncCard(changeDeckCard)], + [createCard.card.syncKey, createRenderedSyncCard(createCard)], + [updateCard.card.syncKey, createRenderedSyncCard(updateCard)], + [changeDeckCard.card.syncKey, createRenderedSyncCard(changeDeckCard)], ]); const result = await executor.execute( @@ -98,9 +98,9 @@ describe("AnkiBatchExecutor", () => { }); }); -function createPlannedCard(cardId: string, noteId?: number, deck = "Obsidian"): PlannedCard { +function createPlannedCard(syncKey: string, noteId?: number, deck = "Obsidian"): PlannedCard { return { - card: createIndexedCard(cardId, noteId), + card: createIndexedCard(syncKey, noteId), noteId, deck, noteModel: "Basic", @@ -123,26 +123,26 @@ function createRenderedSyncCard(plannedCard: PlannedCard): RenderedSyncCard { }; } -function createIndexedCard(cardId: string, noteId?: number): IndexedCard { +function createIndexedCard(syncKey: string, noteId?: number): IndexedCard { return { - cardId, noteId, - markerNoteId: noteId, + syncKey, + idMarkerState: noteId ? "present-valid" : "missing", + noteIdSource: noteId ? "marker" : undefined, filePath: "notes/example.md", cardType: "basic", - heading: `Heading ${cardId}`, + heading: `Heading ${syncKey}`, headingLevel: 4, - bodyMarkdown: `Body ${cardId}`, + bodyMarkdown: `Body ${syncKey}`, blockStartOffset: 0, blockEndOffset: 10, blockStartLine: 1, bodyStartLine: 2, blockEndLine: 2, contentEndLine: 2, - rawBlockText: `#### Heading ${cardId}\nBody ${cardId}`, - rawBlockHash: `hash-${cardId}`, + rawBlockText: `#### Heading ${syncKey}\nBody ${syncKey}`, + rawBlockHash: `hash-${syncKey}`, deckWarnings: [], tagsHint: [], - markerState: noteId ? "card-and-note" : "card-only", }; } \ No newline at end of file diff --git a/src/application/services/AnkiBatchExecutor.ts b/src/application/services/AnkiBatchExecutor.ts index b9a6952..2884b62 100644 --- a/src/application/services/AnkiBatchExecutor.ts +++ b/src/application/services/AnkiBatchExecutor.ts @@ -13,7 +13,7 @@ export interface AnkiBatchExecutionResult { uploadedMedia: number; markerWrites: PlannedCard[]; resolvedNoteIds: Map; - touchedCardIds: Set; + touchedSyncKeys: Set; } export class AnkiBatchExecutor { @@ -31,7 +31,7 @@ export class AnkiBatchExecutor { ): Promise { const modelDetailsCache = new Map>>>(); const resolvedNoteIds = new Map(); - const touchedCardIds = new Set(); + const touchedSyncKeys = new Set(); const markerWriteMap = new Map(); let created = 0; let updated = 0; @@ -60,22 +60,22 @@ export class AnkiBatchExecutor { } addQueue.push(await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand)); - markerWriteMap.set(plannedCard.card.cardId, plannedCard); + markerWriteMap.set(plannedCard.card.syncKey, plannedCard); } for (const plannedCard of plan.toRewriteMarker) { - if (markerWriteMap.has(plannedCard.card.cardId)) { + if (markerWriteMap.has(plannedCard.card.syncKey)) { continue; } if (plannedCard.noteId && noteSummariesById.has(plannedCard.noteId)) { - markerWriteMap.set(plannedCard.card.cardId, plannedCard); - resolvedNoteIds.set(plannedCard.card.cardId, plannedCard.noteId); + markerWriteMap.set(plannedCard.card.syncKey, plannedCard); + resolvedNoteIds.set(plannedCard.card.syncKey, plannedCard.noteId); continue; } addQueue.push(await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand)); - markerWriteMap.set(plannedCard.card.cardId, plannedCard); + markerWriteMap.set(plannedCard.card.syncKey, plannedCard); } for (const plannedCard of plan.toChangeDeck) { @@ -84,7 +84,7 @@ export class AnkiBatchExecutor { } changeDeckQueue.push(plannedCard); - resolvedNoteIds.set(plannedCard.card.cardId, plannedCard.noteId); + resolvedNoteIds.set(plannedCard.card.syncKey, plannedCard.noteId); } await this.batchScheduler.runVoidBatches( @@ -114,11 +114,11 @@ export class AnkiBatchExecutor { const renderedCard = addQueue[index]; const noteId = addedNoteIds[index]; created += 1; - touchedCardIds.add(renderedCard.card.cardId); - resolvedNoteIds.set(renderedCard.card.cardId, noteId); + touchedSyncKeys.add(renderedCard.card.syncKey); + resolvedNoteIds.set(renderedCard.card.syncKey, noteId); - const existingMarkerWrite = markerWriteMap.get(renderedCard.card.cardId); - markerWriteMap.set(renderedCard.card.cardId, { + const existingMarkerWrite = markerWriteMap.get(renderedCard.card.syncKey); + markerWriteMap.set(renderedCard.card.syncKey, { ...(existingMarkerWrite ?? { card: renderedCard.card, deck: renderedCard.deck, @@ -141,8 +141,8 @@ export class AnkiBatchExecutor { updated += updateQueue.length; for (const { plannedCard } of updateQueue) { if (plannedCard.noteId) { - touchedCardIds.add(plannedCard.card.cardId); - resolvedNoteIds.set(plannedCard.card.cardId, plannedCard.noteId); + touchedSyncKeys.add(plannedCard.card.syncKey); + resolvedNoteIds.set(plannedCard.card.syncKey, plannedCard.noteId); } } @@ -178,7 +178,7 @@ export class AnkiBatchExecutor { migratedDecks = changeDeckQueue.length; for (const plannedCard of changeDeckQueue) { - touchedCardIds.add(plannedCard.card.cardId); + touchedSyncKeys.add(plannedCard.card.syncKey); } return { @@ -188,10 +188,10 @@ export class AnkiBatchExecutor { uploadedMedia, markerWrites: Array.from(markerWriteMap.values()).map((plannedCard) => ({ ...plannedCard, - noteId: resolvedNoteIds.get(plannedCard.card.cardId) ?? plannedCard.noteId, + noteId: resolvedNoteIds.get(plannedCard.card.syncKey) ?? plannedCard.noteId, })), resolvedNoteIds, - touchedCardIds, + touchedSyncKeys, }; } @@ -200,13 +200,13 @@ export class AnkiBatchExecutor { renderedCards: Map, renderOnDemand: (plannedCard: PlannedCard) => Promise, ): Promise { - const existing = renderedCards.get(plannedCard.card.cardId); + const existing = renderedCards.get(plannedCard.card.syncKey); if (existing) { return existing; } const rendered = await renderOnDemand(plannedCard); - renderedCards.set(plannedCard.card.cardId, rendered); + renderedCards.set(plannedCard.card.syncKey, rendered); return rendered; } diff --git a/src/application/services/FileIndexerService.test.ts b/src/application/services/FileIndexerService.test.ts index 6294ba4..d51d5e0 100644 --- a/src/application/services/FileIndexerService.test.ts +++ b/src/application/services/FileIndexerService.test.ts @@ -37,12 +37,11 @@ describe("FileIndexerService", () => { fileStamp: `1:${["#### One", "Body"].join("\n").length}`, deckRulesFingerprint: createDeckRulesFingerprint(createModule3Settings()), lastIndexedAt: 1, - cardIds: ["ahs_1"], + noteIds: [10], }, }, cards: { - ahs_1: { - cardId: "ahs_1", + "10": { noteId: 10, filePath: "notes/one.md", heading: "One", @@ -73,7 +72,7 @@ describe("FileIndexerService", () => { expect(result.skippedUnchangedFiles).toBe(1); expect(result.skippedUnchangedCards).toBe(1); expect(vaultGateway.readCalls).toEqual([]); - expect(result.cards[0]).toMatchObject({ cardId: "ahs_1", noteId: 10 }); + expect(result.cards[0]).toMatchObject({ noteId: 10, noteIdSource: "marker", idMarkerState: "present-valid" }); }); it("forces re-read when deck rules fingerprint changed even if file stamp is unchanged", async () => { @@ -90,12 +89,11 @@ describe("FileIndexerService", () => { fileStamp: `1:${content.length}`, deckRulesFingerprint: createDeckRulesFingerprint(createModule3Settings({ defaultDeck: "Old::Deck" })), lastIndexedAt: 1, - cardIds: ["ahs_1"], + noteIds: [10], }, }, cards: { - ahs_1: { - cardId: "ahs_1", + "10": { noteId: 10, filePath: "notes/one.md", heading: "One", diff --git a/src/application/services/FileIndexerService.ts b/src/application/services/FileIndexerService.ts index 0484104..6c7df30 100644 --- a/src/application/services/FileIndexerService.ts +++ b/src/application/services/FileIndexerService.ts @@ -1,9 +1,9 @@ import type { PluginSettings } from "@/application/config/PluginSettings"; import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; import { ScanScopeService } from "@/application/services/ScanScopeService"; -import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; +import { createIndexedCardSyncKey, type IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile"; -import type { CardState, PendingWriteBackState, PluginState } from "@/domain/manual-sync/entities/PluginState"; +import { toNoteIdKey, type CardState, type PendingWriteBackState, type PluginState } from "@/domain/manual-sync/entities/PluginState"; import { CardIndexingService } from "@/domain/manual-sync/services/CardIndexingService"; import { hashString } from "@/domain/shared/hash"; @@ -88,7 +88,7 @@ export class FileIndexerService { const pendingWriteBack = stateIndex.pendingByFilePath.get(ref.path) ?? []; const hasPendingWriteBack = pendingWriteBack.length > 0; const knownCards = stateIndex.cardsByFilePath.get(ref.path) ?? []; - const hasMissingKnownCard = existingFileState?.cardIds.some((cardId) => !state.cards[cardId]) ?? false; + const hasMissingKnownCard = (existingFileState?.noteIds ?? []).some((noteId) => !state.cards[toNoteIdKey(noteId)]); const shouldRead = forceReadAll || hasPendingWriteBack || @@ -99,9 +99,9 @@ export class FileIndexerService { if (!shouldRead) { skippedUnchangedFiles += 1; - skippedUnchangedCards += existingFileState?.cardIds.length ?? 0; - const restoredCards = (existingFileState?.cardIds ?? []) - .map((cardId) => state.cards[cardId]) + skippedUnchangedCards += (existingFileState?.noteIds ?? []).length; + const restoredCards = (existingFileState?.noteIds ?? []) + .map((noteId) => state.cards[toNoteIdKey(noteId)]) .filter((card): card is CardState => Boolean(card)) .map((card) => restoreIndexedCard(card)); @@ -177,9 +177,10 @@ export class FileIndexerService { function restoreIndexedCard(card: CardState): IndexedCard { return { - cardId: card.cardId, noteId: card.noteId, - markerNoteId: card.noteId, + syncKey: createIndexedCardSyncKey(card.filePath, card.blockStartLine, card.rawBlockHash), + idMarkerState: "present-valid", + noteIdSource: "marker", filePath: card.filePath, cardType: card.cardType, heading: card.heading, @@ -198,7 +199,6 @@ function restoreIndexedCard(card: CardState): IndexedCard { deckHintSource: card.deckHintSource, deckWarnings: [...card.deckWarnings], tagsHint: card.tagsHint, - markerState: card.noteId ? "card-and-note" : "card-only", }; } diff --git a/src/application/services/ManualSyncService.test.ts b/src/application/services/ManualSyncService.test.ts index 73e5c2b..437fbc0 100644 --- a/src/application/services/ManualSyncService.test.ts +++ b/src/application/services/ManualSyncService.test.ts @@ -28,8 +28,7 @@ describe("ManualSyncService", () => { expect(ankiGateway.ensuredDecks).toEqual([["notes"]]); expect(ankiGateway.deletedNotes).toEqual([]); expect(ankiGateway.deletedDecks).toEqual([]); - expect(vaultGateway.getFileContent("notes/example.md")).toContain(""); expect(stateRepository.savedState?.pendingWriteBack).toEqual([]); expect(Object.values(stateRepository.savedState?.cards ?? {})).toHaveLength(1); }); @@ -47,10 +46,10 @@ describe("ManualSyncService", () => { expect(result.markerWriteConflictFiles).toEqual(["notes/example.md"]); expect(stateRepository.savedState?.pendingWriteBack).toHaveLength(1); - expect(stateRepository.savedState?.pendingWriteBack[0]).toMatchObject({ filePath: "notes/example.md", noteId: 9001 }); + expect(stateRepository.savedState?.pendingWriteBack[0]).toMatchObject({ filePath: "notes/example.md", targetNoteId: 9001 }); }); - it("rebuilds the card index and writes card-only markers without calling Anki", async () => { + it("rebuilds the card index without writing unresolved ID markers or calling Anki", async () => { const vaultGateway = new FakeManualSyncVaultGateway({ "notes/example.md": ["#### Prompt", "Answer"].join("\n"), }); @@ -61,14 +60,13 @@ describe("ManualSyncService", () => { const result = await service.rebuildIndex(createModule3Settings()); expect(result.created).toBe(0); - expect(result.rewrittenMarkers).toBe(1); + expect(result.rewrittenMarkers).toBe(0); expect(result.migratedDecks).toBe(0); expect(ankiGateway.addedNotes).toHaveLength(0); expect(ankiGateway.changedDecks).toEqual([]); expect(ankiGateway.deletedNotes).toEqual([]); expect(ankiGateway.deletedDecks).toEqual([]); - expect(vaultGateway.getFileContent("notes/example.md")).toContain(""); + expect(vaultGateway.getFileContent("notes/example.md")).toContain(""); }); it("creates a new Anki note when marker was deleted and the card content changed", async () => { @@ -145,7 +143,7 @@ describe("ManualSyncService", () => { const stateRepository = new InMemoryPluginStateRepository({ files: {}, cards: { - ahs_known: createStoredSyncedCard(settings), + "42": createStoredSyncedCard(settings), }, pendingWriteBack: [], }); @@ -156,12 +154,10 @@ describe("ManualSyncService", () => { expect(result.created).toBe(1); expect(ankiGateway.addedNotes).toHaveLength(1); - expect(vaultGateway.getFileContent("notes/example.md")).toContain("note=9001"); - expect(vaultGateway.getFileContent("notes/example.md")).not.toContain("AHS:card=ahs_known note=42"); - expect(stateRepository.savedState?.cards.ahs_known?.orphan).toBe(true); - expect( - Object.values(stateRepository.savedState?.cards ?? {}).some((card) => card.cardId !== "ahs_known" && card.noteId === 9001 && !card.orphan), - ).toBe(true); + expect(vaultGateway.getFileContent("notes/example.md")).toContain(""); + expect(vaultGateway.getFileContent("notes/example.md")).not.toContain(""); + expect(stateRepository.savedState?.cards["42"]?.orphan).toBe(true); + expect(stateRepository.savedState?.cards["9001"]?.orphan).toBe(false); }); it("uses YAML deck, emits conflict warning, and continues syncing when YAML and body deck declarations differ", async () => { @@ -205,12 +201,13 @@ describe("ManualSyncService", () => { it("migrates an existing note when only the resolved deck changes", async () => { const settings = createModule3Settings({ defaultDeck: "New::Deck" }); const vaultGateway = new FakeManualSyncVaultGateway({ - "example.md": ["#### Prompt", "Answer", ""].join("\n"), + "example.md": ["#### Prompt", "Answer", ""] .join("\n"), }); const indexedCard = { - cardId: "ahs_known", noteId: 42, - markerNoteId: 42, + syncKey: "example.md\u00001\u0000hash-card", + idMarkerState: "present-valid" as const, + noteIdSource: "marker" as const, filePath: "example.md", cardType: "basic" as const, heading: "Prompt", @@ -227,14 +224,13 @@ describe("ManualSyncService", () => { rawBlockHash: hashString(["#### Prompt", "Answer"].join("\n")), deckWarnings: [], tagsHint: [], - markerState: "card-and-note" as const, }; const oldSettings = createModule3Settings({ defaultDeck: "Old::Deck" }); const renderPlan = new RenderConfigService().resolve(indexedCard, oldSettings); const stateRepository = new InMemoryPluginStateRepository({ files: {}, cards: { - ahs_known: createStoredSyncedCard(settings, { + "42": createStoredSyncedCard(settings, { filePath: "example.md", deck: "Old::Deck", renderConfigHash: renderPlan.renderConfigHash, @@ -262,21 +258,21 @@ describe("ManualSyncService", () => { const oldSettings = createModule3Settings({ folderDeckMode: "off", defaultDeck: "Default::Deck" }); const newSettings = createModule3Settings({ folderDeckMode: "folder", defaultDeck: "Default::Deck" }); const vaultGateway = new FakeManualSyncVaultGateway({ - "课程/数学/第一章/导数.md": ["#### Prompt", "Answer", ""].join("\n"), + "课程/数学/第一章/导数.md": ["#### Prompt", "Answer", ""] .join("\n"), }); const stateRepository = new InMemoryPluginStateRepository({ files: { "课程/数学/第一章/导数.md": { filePath: "课程/数学/第一章/导数.md", fileHash: "hash-a", - fileStamp: `1:${["#### Prompt", "Answer", ""].join("\n").length}`, + fileStamp: `1:${["#### Prompt", "Answer", ""] .join("\n").length}`, deckRulesFingerprint: createDeckRulesFingerprint(oldSettings), lastIndexedAt: 1, - cardIds: ["ahs_known"], + noteIds: [42], }, }, cards: { - ahs_known: createStoredSyncedCard(oldSettings, { + "42": createStoredSyncedCard(oldSettings, { filePath: "课程/数学/第一章/导数.md", deck: "Default::Deck", }), @@ -298,12 +294,12 @@ describe("ManualSyncService", () => { expect(result.migratedDecks).toBe(1); expect(vaultGateway.readCalls).toEqual(["课程/数学/第一章/导数.md"]); expect(ankiGateway.changedDecks).toEqual([{ deckName: "课程::数学::第一章", cardIds: [7001] }]); - expect(stateRepository.savedState?.cards.ahs_known?.deck).toBe("课程::数学::第一章"); + expect(stateRepository.savedState?.cards["42"]?.deck).toBe("课程::数学::第一章"); expect(stateRepository.savedState?.files["课程/数学/第一章/导数.md"]?.deckRulesFingerprint).toBe(createDeckRulesFingerprint(newSettings)); }); it("ordinary sync re-evaluates unchanged files when default deck changed and migrates root-level notes", async () => { - const content = ["#### Prompt", "Answer", ""].join("\n"); + const content = ["#### Prompt", "Answer", ""] .join("\n"); const oldSettings = createModule3Settings({ defaultDeck: "Old::Deck", folderDeckMode: "off" }); const newSettings = createModule3Settings({ defaultDeck: "New::Deck", folderDeckMode: "off" }); const vaultGateway = new FakeManualSyncVaultGateway({ @@ -317,11 +313,11 @@ describe("ManualSyncService", () => { fileStamp: `1:${content.length}`, deckRulesFingerprint: createDeckRulesFingerprint(oldSettings), lastIndexedAt: 1, - cardIds: ["ahs_known"], + noteIds: [42], }, }, cards: { - ahs_known: createStoredSyncedCard(oldSettings, { + "42": createStoredSyncedCard(oldSettings, { filePath: "example.md", deck: "Old::Deck", }), @@ -343,7 +339,7 @@ describe("ManualSyncService", () => { }); it("ordinary sync re-evaluates unchanged files when file deck marker changes and migrates old notes", async () => { - const content = ["MY DECK: Scoped::Deck", "", "#### Prompt", "Answer", ""].join("\n"); + const content = ["MY DECK: Scoped::Deck", "", "#### Prompt", "Answer", ""] .join("\n"); const oldSettings = createModule3Settings({ fileDeckMarker: "TARGET DECK", defaultDeck: "Default::Deck" }); const newSettings = createModule3Settings({ fileDeckMarker: "MY DECK", defaultDeck: "Default::Deck" }); const vaultGateway = new FakeManualSyncVaultGateway({ @@ -357,11 +353,11 @@ describe("ManualSyncService", () => { fileStamp: `1:${content.length}`, deckRulesFingerprint: createDeckRulesFingerprint(oldSettings), lastIndexedAt: 1, - cardIds: ["ahs_known"], + noteIds: [42], }, }, cards: { - ahs_known: createStoredSyncedCard(oldSettings, { + "42": createStoredSyncedCard(oldSettings, { deck: "notes", }), }, @@ -384,7 +380,7 @@ describe("ManualSyncService", () => { }); it("ordinary sync re-evaluates unchanged files when file-level deck is enabled and migrates old notes", async () => { - const content = ["TARGET DECK: Scoped::Deck", "", "#### Prompt", "Answer", ""].join("\n"); + const content = ["TARGET DECK: Scoped::Deck", "", "#### Prompt", "Answer", ""] .join("\n"); const oldSettings = createModule3Settings({ fileDeckEnabled: false, defaultDeck: "Default::Deck" }); const newSettings = createModule3Settings({ fileDeckEnabled: true, defaultDeck: "Default::Deck" }); const vaultGateway = new FakeManualSyncVaultGateway({ @@ -398,11 +394,11 @@ describe("ManualSyncService", () => { fileStamp: `1:${content.length}`, deckRulesFingerprint: createDeckRulesFingerprint(oldSettings), lastIndexedAt: 1, - cardIds: ["ahs_known"], + noteIds: [42], }, }, cards: { - ahs_known: createStoredSyncedCard(oldSettings, { + "42": createStoredSyncedCard(oldSettings, { deck: "notes", }), }, @@ -450,10 +446,12 @@ function createStoredSyncedCard(settings: PluginSettings, overrides: Partial [file.filePath, file])); - const markerWrites = [ - ...plan.toRewriteMarker, - ...plan.toCreate.filter((plannedCard) => plannedCard.card.markerState === "missing"), - ]; + const markerWrites = plan.toRewriteMarker.filter((plannedCard) => plannedCard.noteId !== undefined); const writeBackResult = await this.markdownWriteBackService.write(markerWrites, indexedFilesByPath); const nextState = this.buildNextState( @@ -75,7 +72,7 @@ export class ManualSyncService { indexResult.cards, indexResult.indexedFiles, settings, - new Set(writeBackResult.writtenCardIds), + new Set(writeBackResult.writtenSyncKeys), new Map(), plan.toOrphan, ); @@ -98,7 +95,7 @@ export class ManualSyncService { orphaned: plan.toOrphan.length, uploadedMedia: 0, skippedUnchangedCards: indexResult.skippedUnchangedCards, - rewrittenMarkers: writeBackResult.writtenCardIds.length, + rewrittenMarkers: writeBackResult.writtenSyncKeys.length, markerWriteConflictFiles: writeBackResult.conflictFiles, warnings: plan.warnings, }; @@ -118,8 +115,8 @@ export class ManualSyncService { const renderedCards = new Map(); for (const plannedCard of [...plan.toCreate, ...plan.toUpdate]) { - if (!renderedCards.has(plannedCard.card.cardId)) { - renderedCards.set(plannedCard.card.cardId, this.renderer.render(plannedCard, renderContext)); + if (!renderedCards.has(plannedCard.card.syncKey)) { + renderedCards.set(plannedCard.card.syncKey, this.renderer.render(plannedCard, renderContext)); } } @@ -138,7 +135,7 @@ export class ManualSyncService { indexResult.cards, indexResult.indexedFiles, settings, - new Set([...executionResult.touchedCardIds, ...writeBackResult.writtenCardIds]), + new Set([...executionResult.touchedSyncKeys, ...writeBackResult.writtenSyncKeys]), executionResult.resolvedNoteIds, plan.toOrphan, ); @@ -163,7 +160,7 @@ export class ManualSyncService { orphaned: plan.toOrphan.length, uploadedMedia: executionResult.uploadedMedia, skippedUnchangedCards: indexResult.skippedUnchangedCards, - rewrittenMarkers: writeBackResult.writtenCardIds.length, + rewrittenMarkers: writeBackResult.writtenSyncKeys.length, markerWriteConflictFiles: writeBackResult.conflictFiles, warnings: plan.warnings, }; @@ -174,40 +171,69 @@ export class ManualSyncService { cards: IndexedCard[], indexedFiles: Array<{ filePath: string; fileHash: string; fileStamp: string; content?: string; cards: IndexedCard[] }>, settings: PluginSettings, - touchedCardIds: Set, + touchedSyncKeys: Set, resolvedNoteIds: Map, - orphanCards: Array<{ cardId: string }>, + orphanCards: CardState[], ): PluginState { const now = this.now(); const deckRulesFingerprint = createDeckRulesFingerprint(settings); + const scopedFilePaths = new Set(indexedFiles.filter((indexedFile) => indexedFile.content !== undefined).map((indexedFile) => indexedFile.filePath)); const nextState: PluginState = { files: { ...previousState.files }, cards: { ...previousState.cards }, pendingWriteBack: [...previousState.pendingWriteBack], }; + for (const filePath of scopedFilePaths) { + delete nextState.files[filePath]; + } + + for (const [noteKey, cardState] of Object.entries(nextState.cards)) { + if (scopedFilePaths.has(cardState.filePath)) { + delete nextState.cards[noteKey]; + } + } + + const resolvedNoteIdsBySyncKey = new Map(); + + for (const card of cards) { + const noteId = resolvedNoteIds.get(card.syncKey) ?? card.noteId; + if (noteId === undefined) { + continue; + } + + resolvedNoteIdsBySyncKey.set(card.syncKey, noteId); + } + for (const indexedFile of indexedFiles) { if (indexedFile.content === undefined) { continue; } + const noteIds = Array.from(new Set(indexedFile.cards + .map((card) => resolvedNoteIdsBySyncKey.get(card.syncKey)) + .filter((noteId): noteId is number => noteId !== undefined))); + nextState.files[indexedFile.filePath] = { filePath: indexedFile.filePath, fileHash: indexedFile.fileHash, fileStamp: indexedFile.fileStamp, deckRulesFingerprint, lastIndexedAt: now, - cardIds: indexedFile.cards.map((card) => card.cardId), + noteIds, }; } for (const card of cards) { - const existingState = previousState.cards[card.cardId]; - const renderPlan = this.renderConfigService.resolve(card, settings); - const noteId = resolvedNoteIds.get(card.cardId) ?? existingState?.noteId ?? card.noteId; + const noteId = resolvedNoteIdsBySyncKey.get(card.syncKey); + if (noteId === undefined) { + continue; + } - nextState.cards[card.cardId] = { - cardId: card.cardId, + const existingState = previousState.cards[toNoteIdKey(noteId)] ?? (card.noteId !== undefined ? previousState.cards[toNoteIdKey(card.noteId)] : undefined); + const renderPlan = this.renderConfigService.resolve(card, settings); + + nextState.cards[toNoteIdKey(noteId)] = { noteId, filePath: card.filePath, heading: card.heading, @@ -229,19 +255,14 @@ export class ManualSyncService { deckHintSource: card.deckHintSource, deckWarnings: [...card.deckWarnings], tagsHint: card.tagsHint, - lastSyncedAt: touchedCardIds.has(card.cardId) ? now : existingState?.lastSyncedAt ?? 0, + lastSyncedAt: touchedSyncKeys.has(card.syncKey) ? now : existingState?.lastSyncedAt ?? 0, orphan: false, }; } for (const orphanCard of orphanCards) { - const existing = nextState.cards[orphanCard.cardId]; - if (!existing) { - continue; - } - - nextState.cards[orphanCard.cardId] = { - ...existing, + nextState.cards[toNoteIdKey(orphanCard.noteId)] = { + ...orphanCard, orphan: true, }; } diff --git a/src/application/services/MarkdownWriteBackService.ts b/src/application/services/MarkdownWriteBackService.ts index 7c31081..aea9440 100644 --- a/src/application/services/MarkdownWriteBackService.ts +++ b/src/application/services/MarkdownWriteBackService.ts @@ -6,7 +6,7 @@ import type { PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncP import { CardMarkerService, type MarkerWriteRequest } from "@/domain/manual-sync/services/CardMarkerService"; export interface MarkdownWriteBackResult { - writtenCardIds: string[]; + writtenSyncKeys: string[]; conflictFiles: string[]; failureFiles: Array<{ filePath: string; message: string }>; pendingEntries: PendingWriteBackState[]; @@ -20,7 +20,7 @@ export class MarkdownWriteBackService { async write(plannedCards: PlannedCard[], indexedFilesByPath: Map): Promise { const plannedByFile = new Map(); - const writtenCardIds: string[] = []; + const writtenSyncKeys: string[] = []; const conflictFiles: string[] = []; const failureFiles: Array<{ filePath: string; message: string }> = []; const pendingEntries: PendingWriteBackState[] = []; @@ -52,7 +52,7 @@ export class MarkdownWriteBackService { ); await this.vaultGateway.replaceMarkdownFile(filePath, sourceContent, nextContent); - writtenCardIds.push(...fileCards.map((plannedCard) => plannedCard.card.cardId)); + writtenSyncKeys.push(...fileCards.map((plannedCard) => plannedCard.card.syncKey)); } catch (error) { pendingEntries.push(...fileCards.map((plannedCard) => this.createPendingEntry(filePath, plannedCard, indexedFile.fileHash))); @@ -69,7 +69,7 @@ export class MarkdownWriteBackService { } return { - writtenCardIds, + writtenSyncKeys, conflictFiles, failureFiles, pendingEntries, @@ -79,8 +79,7 @@ export class MarkdownWriteBackService { private toWriteRequest(plannedCard: PlannedCard, sourceContent: string): MarkerWriteRequest { return { filePath: plannedCard.card.filePath, - cardId: plannedCard.card.cardId, - noteId: plannedCard.noteId, + noteId: requireNoteId(plannedCard), blockStartLine: plannedCard.card.blockStartLine, contentEndLine: plannedCard.card.contentEndLine, blockEndLine: plannedCard.card.blockEndLine, @@ -90,13 +89,23 @@ export class MarkdownWriteBackService { } private createPendingEntry(filePath: string, plannedCard: PlannedCard, expectedFileHash: string): PendingWriteBackState { + const noteId = requireNoteId(plannedCard); + return { filePath, - cardId: plannedCard.card.cardId, - noteId: plannedCard.noteId, + blockStartLine: plannedCard.card.blockStartLine, expectedFileHash, - targetMarker: this.markerService.create(plannedCard.card.cardId, plannedCard.noteId).raw, + targetMarker: this.markerService.create(noteId).raw, rawBlockHash: plannedCard.card.rawBlockHash, + targetNoteId: noteId, }; } +} + +function requireNoteId(plannedCard: PlannedCard): number { + if (plannedCard.noteId === undefined) { + throw new Error(`Cannot write marker without noteId for block ${plannedCard.card.filePath}:${plannedCard.card.blockStartLine}.`); + } + + return plannedCard.noteId; } \ No newline at end of file diff --git a/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts b/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts index 22b1009..bf6e613 100644 --- a/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts +++ b/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts @@ -17,11 +17,11 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { const content = [ "#### Prompt", "Answer", - "", + "", "", "#### Prompt 2", "Answer 2", - "", + "", ].join("\n"); const vaultGateway = new FakeManualSyncVaultGateway({ [filePath]: content, @@ -34,12 +34,11 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { fileStamp: `1:${content.length}`, deckRulesFingerprint: createDeckRulesFingerprint(settings), lastIndexedAt: 1, - cardIds: ["ahs_1", "ahs_2"], + noteIds: [41, 42], }, }, cards: { - ahs_1: createStoredSyncedCard(settings, { - cardId: "ahs_1", + "41": createStoredSyncedCard(settings, { noteId: 41, filePath, bodyMarkdown: "Answer", @@ -48,9 +47,8 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { blockEndLine: 4, markerLine: 3, }), - ahs_2: createStoredSyncedCard(settings, { - cardId: "ahs_2", - noteId: undefined, + "42": createStoredSyncedCard(settings, { + noteId: 42, filePath, heading: "Prompt 2", bodyMarkdown: "Answer 2", @@ -65,11 +63,11 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { pendingWriteBack: [ { filePath, - cardId: "ahs_1", - noteId: 41, + blockStartLine: 1, expectedFileHash: hashString(content), - targetMarker: "", + targetMarker: "", rawBlockHash: hashString(["#### Prompt", "Answer"].join("\n")), + targetNoteId: 41, }, ], }); @@ -80,13 +78,13 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { expect(result).toMatchObject({ trackedCards: 2, - deletedNotes: 1, + deletedNotes: 2, removedMarkers: 2, deletedLocalRecords: 2, conflictFiles: [], failureFiles: [], }); - expect(ankiGateway.deletedNotes).toEqual([[41]]); + expect(ankiGateway.deletedNotes).toEqual([[41, 42]]); expect(vaultGateway.getFileContent(filePath)).toBe([ "#### Prompt", "Answer", @@ -95,8 +93,8 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { "Answer 2", ].join("\n")); expect(stateRepository.savedState?.files[filePath]).toBeUndefined(); - expect(stateRepository.savedState?.cards.ahs_1).toBeUndefined(); - expect(stateRepository.savedState?.cards.ahs_2).toBeUndefined(); + expect(stateRepository.savedState?.cards["41"]).toBeUndefined(); + expect(stateRepository.savedState?.cards["42"]).toBeUndefined(); expect(stateRepository.savedState?.pendingWriteBack).toEqual([]); const manualSyncService = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 2000); @@ -104,52 +102,8 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { expect(syncResult.created).toBe(2); expect(ankiGateway.addedNotes).toHaveLength(2); - expect(vaultGateway.getFileContent(filePath)).toContain("note=9001"); - expect(vaultGateway.getFileContent(filePath)).toContain("note=9002"); - }); - - it("cleans local records and markers without deleting Anki notes when noteId is missing", async () => { - const settings = createModule3Settings(); - const filePath = "notes/local-only.md"; - const content = [ - "#### Prompt", - "Answer", - "", - ].join("\n"); - const vaultGateway = new FakeManualSyncVaultGateway({ - [filePath]: content, - }); - const stateRepository = new InMemoryPluginStateRepository({ - files: { - [filePath]: { - filePath, - fileHash: hashString(content), - fileStamp: `1:${content.length}`, - deckRulesFingerprint: createDeckRulesFingerprint(settings), - lastIndexedAt: 1, - cardIds: ["ahs_local"], - }, - }, - cards: { - ahs_local: createStoredSyncedCard(settings, { - cardId: "ahs_local", - noteId: undefined, - filePath, - markerLine: 3, - }), - }, - pendingWriteBack: [], - }); - const ankiGateway = new FakeManualSyncAnkiGateway(); - const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway); - - const result = await useCase.execute(filePath); - - expect(result.deletedNotes).toBe(0); - expect(result.removedMarkers).toBe(1); - expect(result.deletedLocalRecords).toBe(1); - expect(ankiGateway.deletedNotes).toEqual([]); - expect(vaultGateway.getFileContent(filePath)).toBe(["#### Prompt", "Answer"].join("\n")); + expect(vaultGateway.getFileContent(filePath)).toContain(""); + expect(vaultGateway.getFileContent(filePath)).toContain(""); }); it("returns an empty result when the file has no tracked synced cards", async () => { @@ -177,7 +131,7 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { const content = [ "#### Prompt", "Answer", - "", + "", ].join("\n"); const vaultGateway = new FakeManualSyncVaultGateway({ [filePath]: content, @@ -191,12 +145,11 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { fileStamp: `1:${content.length}`, deckRulesFingerprint: createDeckRulesFingerprint(settings), lastIndexedAt: 1, - cardIds: ["ahs_conflict"], + noteIds: [42], }, }, cards: { - ahs_conflict: createStoredSyncedCard(settings, { - cardId: "ahs_conflict", + "42": createStoredSyncedCard(settings, { noteId: 42, filePath, markerLine: 3, @@ -205,11 +158,11 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { pendingWriteBack: [ { filePath, - cardId: "ahs_conflict", - noteId: 42, + blockStartLine: 1, expectedFileHash: hashString(content), - targetMarker: "", + targetMarker: "", rawBlockHash: hashString(["#### Prompt", "Answer"].join("\n")), + targetNoteId: 42, }, ], }); @@ -220,13 +173,13 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { expect(result.deletedNotes).toBe(1); expect(result.removedMarkers).toBe(0); - expect(result.deletedLocalRecords).toBe(0); + expect(result.deletedLocalRecords).toBe(1); expect(result.conflictFiles).toEqual([filePath]); expect(ankiGateway.deletedNotes).toEqual([[42]]); expect(stateRepository.savedState?.files[filePath]).toBeUndefined(); - expect(stateRepository.savedState?.cards.ahs_conflict?.noteId).toBeUndefined(); + expect(stateRepository.savedState?.cards["42"]).toBeUndefined(); expect(stateRepository.savedState?.pendingWriteBack).toEqual([]); - expect(vaultGateway.getFileContent(filePath)).toContain("note=42"); + expect(vaultGateway.getFileContent(filePath)).toContain(""); vaultGateway.conflictPaths.delete(filePath); const manualSyncService = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 3000); @@ -234,8 +187,8 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { expect(syncResult.created).toBe(1); expect(ankiGateway.addedNotes).toHaveLength(1); - expect(vaultGateway.getFileContent(filePath)).toContain("note=9001"); - expect(vaultGateway.getFileContent(filePath)).not.toContain("note=42"); + expect(vaultGateway.getFileContent(filePath)).toContain(""); + expect(vaultGateway.getFileContent(filePath)).not.toContain(""); }); }); @@ -244,10 +197,12 @@ function createStoredSyncedCard(settings: PluginSettings, overrides: Partial ({ cardId: card.cardId })), + trackedCards.map((card) => ({ noteId: card.noteId })), ); - const nextState = buildNextState(state, filePath, trackedCards, markerRemovalResult.conflictFiles.length > 0 || markerRemovalResult.failureFiles.length > 0); + const nextState = buildNextState(state, filePath, trackedCards); await this.pluginStateRepository.save(nextState); return { trackedCards: trackedCards.length, deletedNotes: noteIds.length, removedMarkers: markerRemovalResult.removedMarkers, - deletedLocalRecords: markerRemovalResult.conflictFiles.length === 0 && markerRemovalResult.failureFiles.length === 0 ? trackedCards.length : 0, + deletedLocalRecords: trackedCards.length, conflictFiles: markerRemovalResult.conflictFiles, failureFiles: markerRemovalResult.failureFiles, }; @@ -59,39 +59,32 @@ export class ClearCurrentFileSyncedCardsUseCase { } function collectTrackedCards(state: PluginState, filePath: string): CardState[] { - const trackedCardIds = new Set(); + const trackedNoteKeys = new Set(); - for (const cardId of state.files[filePath]?.cardIds ?? []) { - if (state.cards[cardId]) { - trackedCardIds.add(cardId); + for (const noteId of state.files[filePath]?.noteIds ?? []) { + const noteKey = toNoteIdKey(noteId); + if (state.cards[noteKey]) { + trackedNoteKeys.add(noteKey); } } for (const card of Object.values(state.cards)) { if (card.filePath === filePath) { - trackedCardIds.add(card.cardId); + trackedNoteKeys.add(toNoteIdKey(card.noteId)); } } - return Array.from(trackedCardIds) - .map((cardId) => state.cards[cardId]) + return Array.from(trackedNoteKeys) + .map((noteKey) => state.cards[noteKey]) .filter((card): card is CardState => Boolean(card)); } -function buildNextState(state: PluginState, filePath: string, trackedCards: CardState[], keepSanitizedCards: boolean): PluginState { - const trackedCardIds = new Set(trackedCards.map((card) => card.cardId)); +function buildNextState(state: PluginState, filePath: string, trackedCards: CardState[]): PluginState { + const trackedNoteKeys = new Set(trackedCards.map((card) => toNoteIdKey(card.noteId))); const nextCards = { ...state.cards }; for (const trackedCard of trackedCards) { - if (!keepSanitizedCards) { - delete nextCards[trackedCard.cardId]; - continue; - } - - nextCards[trackedCard.cardId] = { - ...trackedCard, - noteId: undefined, - }; + delete nextCards[toNoteIdKey(trackedCard.noteId)]; } const nextFiles = { ...state.files }; @@ -100,6 +93,6 @@ function buildNextState(state: PluginState, filePath: string, trackedCards: Card return { files: nextFiles, cards: nextCards, - pendingWriteBack: state.pendingWriteBack.filter((pending) => pending.filePath !== filePath && !trackedCardIds.has(pending.cardId)), + pendingWriteBack: state.pendingWriteBack.filter((pending) => pending.filePath !== filePath && !trackedNoteKeys.has(toNoteIdKey(pending.targetNoteId))), }; } \ No newline at end of file diff --git a/src/domain/manual-sync/entities/IdMarker.ts b/src/domain/manual-sync/entities/IdMarker.ts new file mode 100644 index 0000000..5da5743 --- /dev/null +++ b/src/domain/manual-sync/entities/IdMarker.ts @@ -0,0 +1,9 @@ +export interface IdMarker { + noteId: number; + raw: string; + lineIndex: number; +} + +export type IdMarkerState = "missing" | "present-valid" | "present-invalid"; + +export type NoteIdSource = "marker" | "state-recovery" | "pending-writeback"; \ No newline at end of file diff --git a/src/domain/manual-sync/entities/IndexedCard.ts b/src/domain/manual-sync/entities/IndexedCard.ts index 5e054cf..1c60eca 100644 --- a/src/domain/manual-sync/entities/IndexedCard.ts +++ b/src/domain/manual-sync/entities/IndexedCard.ts @@ -1,11 +1,12 @@ import type { CardType } from "@/domain/card/entities/RenderedFields"; -import type { MarkerState } from "@/domain/manual-sync/entities/AhsMarker"; +import type { IdMarkerState, NoteIdSource } from "@/domain/manual-sync/entities/IdMarker"; import type { DeckResolutionWarning, DeckResolutionSource } from "@/domain/manual-sync/value-objects/DeckResolution"; export interface IndexedCard { - cardId: string; noteId?: number; - markerNoteId?: number; + syncKey: string; + idMarkerState: IdMarkerState; + noteIdSource?: NoteIdSource; filePath: string; cardType: CardType; heading: string; @@ -24,6 +25,9 @@ export interface IndexedCard { deckHintSource?: Extract; deckWarnings: DeckResolutionWarning[]; tagsHint: string[]; - markerState: MarkerState; sourceContent?: string; +} + +export function createIndexedCardSyncKey(filePath: string, blockStartLine: number, rawBlockHash: string): string { + return `${filePath}\u0000${blockStartLine}\u0000${rawBlockHash}`; } \ No newline at end of file diff --git a/src/domain/manual-sync/entities/PluginState.ts b/src/domain/manual-sync/entities/PluginState.ts index 4735992..532b1b0 100644 --- a/src/domain/manual-sync/entities/PluginState.ts +++ b/src/domain/manual-sync/entities/PluginState.ts @@ -7,12 +7,11 @@ export interface FileState { fileStamp: string; deckRulesFingerprint?: string; lastIndexedAt: number; - cardIds: string[]; + noteIds: number[]; } export interface CardState { - cardId: string; - noteId?: number; + noteId: number; filePath: string; heading: string; headingLevel: number; @@ -39,11 +38,11 @@ export interface CardState { export interface PendingWriteBackState { filePath: string; - cardId: string; - noteId?: number; + blockStartLine: number; expectedFileHash: string; targetMarker: string; rawBlockHash: string; + targetNoteId: number; } export interface PluginState { @@ -58,4 +57,12 @@ export function createEmptyPluginState(): PluginState { cards: {}, pendingWriteBack: [], }; +} + +export function toNoteIdKey(noteId: number): string { + return String(noteId); +} + +export function createPendingWriteBackKey(filePath: string, blockStartLine: number, rawBlockHash: string): string { + return `${filePath}\u0000${blockStartLine}\u0000${rawBlockHash}`; } \ No newline at end of file diff --git a/src/domain/manual-sync/services/CardIndexingService.test.ts b/src/domain/manual-sync/services/CardIndexingService.test.ts index d282d5a..289a117 100644 --- a/src/domain/manual-sync/services/CardIndexingService.test.ts +++ b/src/domain/manual-sync/services/CardIndexingService.test.ts @@ -6,7 +6,7 @@ import { hashString } from "@/domain/shared/hash"; import { CardIndexingService } from "./CardIndexingService"; describe("CardIndexingService", () => { - it("generates cardId for a missing marker and excludes the marker from body text", () => { + it("leaves noteId unresolved for a missing marker and excludes it from body text", () => { const service = new CardIndexingService(); const indexedFile = service.index( { @@ -24,12 +24,12 @@ describe("CardIndexingService", () => { ); expect(indexedFile.cards).toHaveLength(1); - expect(indexedFile.cards[0]?.cardId.startsWith("ahs_")).toBe(true); - expect(indexedFile.cards[0]?.markerState).toBe("missing"); + expect(indexedFile.cards[0]?.noteId).toBeUndefined(); + expect(indexedFile.cards[0]?.idMarkerState).toBe("missing"); expect(indexedFile.cards[0]?.bodyMarkdown).toBe("Answer"); }); - it("reuses cardId and noteId when marker is missing but the same file has a unique raw block hash match", () => { + it("recovers noteId when the same file has a unique raw block hash match", () => { const service = new CardIndexingService(); const rawBlockText = ["#### Prompt", "Answer"].join("\n"); const indexedFile = service.index( @@ -48,9 +48,9 @@ describe("CardIndexingService", () => { ); expect(indexedFile.cards[0]).toMatchObject({ - cardId: "ahs_known", noteId: 42, - markerState: "missing", + idMarkerState: "missing", + noteIdSource: "state-recovery", }); }); @@ -72,11 +72,10 @@ describe("CardIndexingService", () => { }, ); - expect(indexedFile.cards[0]?.cardId).not.toBe("ahs_known"); expect(indexedFile.cards[0]?.noteId).toBeUndefined(); }); - it("does not reuse card identity when multiple same-file candidates share the same raw block hash", () => { + it("does not reuse noteId when multiple same-file candidates share the same raw block hash", () => { const service = new CardIndexingService(); const rawBlockText = ["#### Prompt", "Answer"].join("\n"); const rawBlockHash = hashString(rawBlockText); @@ -91,19 +90,17 @@ describe("CardIndexingService", () => { clozeHeadingLevel: 5, fileStamp: "1:1", knownCards: [ - createKnownCardState({ cardId: "ahs_known", rawBlockHash }), - createKnownCardState({ cardId: "ahs_other", noteId: 99, rawBlockHash }), + createKnownCardState({ noteId: 42, rawBlockHash }), + createKnownCardState({ noteId: 99, rawBlockHash }), ], pendingWriteBack: [], }, ); - expect(indexedFile.cards[0]?.cardId).not.toBe("ahs_known"); - expect(indexedFile.cards[0]?.cardId).not.toBe("ahs_other"); expect(indexedFile.cards[0]?.noteId).toBeUndefined(); }); - it("restores noteId from the marker cardId before considering hash-based recovery", () => { + it("prefers a trailing ID marker before considering hash-based recovery", () => { const service = new CardIndexingService(); const rawBlockText = ["#### Prompt", "Answer"].join("\n"); const rawBlockHash = hashString(rawBlockText); @@ -111,70 +108,73 @@ describe("CardIndexingService", () => { { path: "notes/example.md", basename: "example", - content: ["#### Prompt", "Answer", ""].join("\n"), + content: ["#### Prompt", "Answer", ""].join("\n"), }, { qaHeadingLevel: 4, clozeHeadingLevel: 5, fileStamp: "1:1", knownCards: [ - createKnownCardState({ cardId: "ahs_known", rawBlockHash }), - createKnownCardState({ cardId: "ahs_hash_match", noteId: 99, rawBlockHash }), + createKnownCardState({ noteId: 99, rawBlockHash }), ], pendingWriteBack: [], }, ); expect(indexedFile.cards[0]).toMatchObject({ - cardId: "ahs_known", noteId: 42, + noteIdSource: "marker", + idMarkerState: "present-valid", }); }); - it("prefers a known card with cleared noteId over a stale marker noteId", () => { + it("recovers noteId from state when the trailing ID marker is invalid", () => { const service = new CardIndexingService(); + const rawBlockText = ["#### Prompt", "Answer"].join("\n"); const indexedFile = service.index( { path: "notes/example.md", basename: "example", - content: ["#### Prompt", "Answer", ""].join("\n"), + content: ["#### Prompt", "Answer", ""].join("\n"), }, { qaHeadingLevel: 4, clozeHeadingLevel: 5, fileStamp: "1:1", - knownCards: [createKnownCardState({ cardId: "ahs_known", noteId: undefined })], + knownCards: [createKnownCardState({ rawBlockHash: hashString(rawBlockText) })], pendingWriteBack: [], }, ); expect(indexedFile.cards[0]).toMatchObject({ - cardId: "ahs_known", - noteId: undefined, - markerNoteId: 42, - markerState: "card-and-note", + noteId: 42, + noteIdSource: "state-recovery", + idMarkerState: "present-invalid", }); }); - it("rejects misplaced or multiple markers inside a single heading block", () => { + it("does not treat a non-trailing ID marker as the marker slot", () => { const service = new CardIndexingService(); + const indexedFile = service.index( + { + path: "notes/example.md", + basename: "example", + content: ["#### Prompt", "", "Answer"].join("\n"), + }, + { + qaHeadingLevel: 4, + clozeHeadingLevel: 5, + fileStamp: "1:1", + knownCards: [], + pendingWriteBack: [], + }, + ); - expect(() => - service.index( - { - path: "notes/example.md", - basename: "example", - content: ["#### Prompt", "", "Answer", ""].join("\n"), - }, - { - qaHeadingLevel: 4, - clozeHeadingLevel: 5, - fileStamp: "1:1", - knownCards: [], - pendingWriteBack: [], - }, - ), - ).toThrow("Multiple AHS markers"); + expect(indexedFile.cards[0]).toMatchObject({ + noteId: undefined, + idMarkerState: "missing", + bodyMarkdown: "\nAnswer", + }); }); it("extracts YAML deck, prefers it over conflicting body deck, and stores a warning on indexed cards", () => { @@ -280,8 +280,7 @@ function createKnownCardState(overrides: Partial = {}): CardState { const rawBlockText = overrides.rawBlockText ?? ["#### Prompt", "Answer"].join("\n"); return { - cardId: overrides.cardId ?? "ahs_known", - noteId: Object.prototype.hasOwnProperty.call(overrides, "noteId") ? overrides.noteId : 42, + noteId: overrides.noteId ?? 42, filePath: overrides.filePath ?? "notes/example.md", heading: overrides.heading ?? "Prompt", headingLevel: overrides.headingLevel ?? 4, diff --git a/src/domain/manual-sync/services/CardIndexingService.ts b/src/domain/manual-sync/services/CardIndexingService.ts index 5bfb05f..12fbc16 100644 --- a/src/domain/manual-sync/services/CardIndexingService.ts +++ b/src/domain/manual-sync/services/CardIndexingService.ts @@ -1,10 +1,10 @@ import type { SourceFile } from "@/domain/card/entities/SourceFile"; -import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; +import { createIndexedCardSyncKey, type IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile"; -import type { CardState, PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState"; +import { createPendingWriteBackKey, type CardState, type PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState"; import { hashString } from "@/domain/shared/hash"; -import { CardMarkerError, CardMarkerService } from "./CardMarkerService"; +import { CardMarkerService } from "./CardMarkerService"; import { DeckExtractionService } from "./DeckExtractionService"; interface HeadingMatch { @@ -15,11 +15,10 @@ interface HeadingMatch { interface MarkerExtractionResult { bodyLines: string[]; - markerCardId?: string; markerNoteId?: number; contentEndLine: number; markerLine?: number; - markerState: IndexedCard["markerState"]; + idMarkerState: IndexedCard["idMarkerState"]; } export interface CardIndexingContext { @@ -49,10 +48,9 @@ export class CardIndexingService { ? this.deckExtractionService.extract(sourceFile, context.fileDeckMarker ?? "TARGET DECK") : { warnings: [] }; const cards: IndexedCard[] = []; - const knownCardsById = new Map(context.knownCards.map((card) => [card.cardId, card])); const knownCardsByBlockKey = groupKnownCardsByBlockKey(context.knownCards); - const pendingByCardId = new Map(context.pendingWriteBack.map((pending) => [pending.cardId, pending])); - const usedCardIds = new Set(); + const pendingByBlockKey = groupPendingWriteBackByBlockKey(context.pendingWriteBack); + const usedNoteIds = new Set(); for (let headingIndex = 0; headingIndex < headings.length; headingIndex += 1) { const heading = headings[headingIndex]; @@ -70,21 +68,23 @@ export class CardIndexingService { const rawBlockHash = hashString(rawBlockText); const resolvedIdentity = this.resolveIdentity( sourceFile.path, + heading.lineIndex + 1, rawBlockHash, - marker.markerCardId, marker.markerNoteId, - knownCardsById, knownCardsByBlockKey, - pendingByCardId, - usedCardIds, + pendingByBlockKey, + usedNoteIds, ); - usedCardIds.add(resolvedIdentity.cardId); + if (resolvedIdentity.noteId !== undefined) { + usedNoteIds.add(resolvedIdentity.noteId); + } cards.push({ - cardId: resolvedIdentity.cardId, noteId: resolvedIdentity.noteId, - markerNoteId: marker.markerNoteId, + syncKey: createIndexedCardSyncKey(sourceFile.path, heading.lineIndex + 1, rawBlockHash), + idMarkerState: marker.idMarkerState, + noteIdSource: resolvedIdentity.noteIdSource, filePath: sourceFile.path, cardType, heading: heading.text, @@ -103,7 +103,6 @@ export class CardIndexingService { deckHintSource: extractedDeck.explicitDeckSource, deckWarnings: [...extractedDeck.warnings], tagsHint: [], - markerState: marker.markerState, sourceContent: sourceFile.content, }); } @@ -119,49 +118,49 @@ export class CardIndexingService { private resolveIdentity( filePath: string, + blockStartLine: number, rawBlockHash: string, - markerCardId: string | undefined, markerNoteId: number | undefined, - knownCardsById: Map, knownCardsByBlockKey: Map, - pendingByCardId: Map, - usedCardIds: Set, - ): { cardId: string; noteId?: number } { - if (markerCardId) { - const pending = pendingByCardId.get(markerCardId); - const known = knownCardsById.get(markerCardId); - - if (pending && pending.noteId !== undefined) { - return { - cardId: markerCardId, - noteId: pending.noteId, - }; - } - - if (known) { - return { - cardId: markerCardId, - noteId: known.noteId, - }; + pendingByBlockKey: Map, + usedNoteIds: Set, + ): { noteId?: number; noteIdSource?: IndexedCard["noteIdSource"] } { + if (markerNoteId !== undefined) { + if (usedNoteIds.has(markerNoteId)) { + return {}; } return { - cardId: markerCardId, noteId: markerNoteId, + noteIdSource: "marker", }; } const knownMatches = knownCardsByBlockKey.get(createKnownCardBlockKey(filePath, rawBlockHash)) ?? []; - if (knownMatches.length === 1 && !usedCardIds.has(knownMatches[0].cardId)) { + if (knownMatches.length === 1) { + if (usedNoteIds.has(knownMatches[0].noteId)) { + return {}; + } + return { - cardId: knownMatches[0].cardId, noteId: knownMatches[0].noteId, + noteIdSource: "state-recovery", }; } - return { - cardId: this.markerService.generateCardId(), - }; + const pendingMatches = pendingByBlockKey.get(createPendingWriteBackKey(filePath, blockStartLine, rawBlockHash)) ?? []; + if (pendingMatches.length === 1) { + if (usedNoteIds.has(pendingMatches[0].targetNoteId)) { + return {}; + } + + return { + noteId: pendingMatches[0].targetNoteId, + noteIdSource: "pending-writeback", + }; + } + + return {}; } } @@ -186,6 +185,23 @@ function groupKnownCardsByBlockKey(knownCards: CardState[]): Map { + const grouped = new Map(); + + for (const pending of pendingWriteBack) { + const key = createPendingWriteBackKey(pending.filePath, pending.blockStartLine, pending.rawBlockHash); + const entries = grouped.get(key); + if (entries) { + entries.push(pending); + continue; + } + + grouped.set(key, [pending]); + } + + return grouped; +} + function createKnownCardBlockKey(filePath: string, rawBlockHash: string): string { return `${filePath}\u0000${rawBlockHash}`; } @@ -282,63 +298,37 @@ function extractMarker( headingLine: number, markerService: CardMarkerService, ): MarkerExtractionResult { - const candidates = bodyLines - .map((line, index) => ({ line, index })) - .filter(({ line }) => markerService.isCandidate(line)); - const validMarkers = candidates - .map(({ line, index }) => markerService.parse(line, bodyStartLine + index)) - .filter((marker): marker is NonNullable => Boolean(marker)); - - if (validMarkers.length > 1) { - throw new CardMarkerError(`Multiple AHS markers found in heading block at line ${headingLine}.`); - } - let lastNonEmptyIndex = bodyLines.length - 1; while (lastNonEmptyIndex >= 0 && !bodyLines[lastNonEmptyIndex].trim()) { lastNonEmptyIndex -= 1; } if (lastNonEmptyIndex < 0) { - if (candidates.length > 0) { - throw new CardMarkerError(`Invalid AHS marker found in heading block at line ${headingLine}.`); - } - return { bodyLines, contentEndLine: headingLine, - markerState: "missing", + idMarkerState: "missing", }; } const lastLine = bodyLines[lastNonEmptyIndex]; - const lastMarker = markerService.parse(lastLine, bodyStartLine + lastNonEmptyIndex); - - if (lastMarker) { - for (const candidate of candidates) { - if (candidate.index !== lastNonEmptyIndex) { - throw new CardMarkerError(`Multiple or misplaced AHS markers found in heading block at line ${headingLine}.`); - } - } - - const nextBodyLines = bodyLines.filter((_line, index) => index !== lastNonEmptyIndex); + if (!markerService.isCandidate(lastLine)) { return { - bodyLines: nextBodyLines, - markerCardId: lastMarker.cardId, - markerNoteId: lastMarker.noteId, - contentEndLine: findContentEndLine(nextBodyLines, bodyStartLine, headingLine), - markerLine: bodyStartLine + lastNonEmptyIndex, - markerState: lastMarker.noteId ? "card-and-note" : "card-only", + bodyLines, + contentEndLine: findContentEndLine(bodyLines, bodyStartLine, headingLine), + idMarkerState: "missing", }; } - if (candidates.length > 0) { - throw new CardMarkerError(`Invalid AHS marker found in heading block at line ${headingLine}.`); - } + const parsedMarker = markerService.parse(lastLine, bodyStartLine + lastNonEmptyIndex); + const nextBodyLines = bodyLines.filter((_line, index) => index !== lastNonEmptyIndex); return { - bodyLines, - contentEndLine: findContentEndLine(bodyLines, bodyStartLine, headingLine), - markerState: "missing", + bodyLines: nextBodyLines, + markerNoteId: parsedMarker?.noteId, + contentEndLine: findContentEndLine(nextBodyLines, bodyStartLine, headingLine), + markerLine: bodyStartLine + lastNonEmptyIndex, + idMarkerState: parsedMarker ? "present-valid" : "present-invalid", }; } diff --git a/src/domain/manual-sync/services/CardMarkerRemovalService.ts b/src/domain/manual-sync/services/CardMarkerRemovalService.ts index 685d53c..323f394 100644 --- a/src/domain/manual-sync/services/CardMarkerRemovalService.ts +++ b/src/domain/manual-sync/services/CardMarkerRemovalService.ts @@ -1,7 +1,7 @@ import { CardMarkerService } from "@/domain/manual-sync/services/CardMarkerService"; export interface MarkerRemovalTarget { - cardId: string; + noteId: number; } export interface MarkerRemovalApplyResult { @@ -20,7 +20,7 @@ export class CardMarkerRemovalService { }; } - const targetCardIds = new Set(targets.map((target) => target.cardId)); + const targetNoteIds = new Set(targets.map((target) => target.noteId)); const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n"; const lines = sourceContent.split(/\r?\n/); const nextLines: string[] = []; @@ -34,7 +34,7 @@ export class CardMarkerRemovalService { } const marker = this.markerService.parse(line, index + 1); - if (!marker || !targetCardIds.has(marker.cardId)) { + if (!marker || !targetNoteIds.has(marker.noteId)) { nextLines.push(line); continue; } diff --git a/src/domain/manual-sync/services/CardMarkerService.test.ts b/src/domain/manual-sync/services/CardMarkerService.test.ts index 0d9c06e..a8fae88 100644 --- a/src/domain/manual-sync/services/CardMarkerService.test.ts +++ b/src/domain/manual-sync/services/CardMarkerService.test.ts @@ -3,25 +3,23 @@ import { describe, expect, it } from "vitest"; import { CardMarkerService } from "./CardMarkerService"; describe("CardMarkerService", () => { - it("parses cardId and noteId from the module 3 marker format", () => { - const service = new CardMarkerService(() => 1); + it("parses noteId from the ID marker format", () => { + const service = new CardMarkerService(); - expect(service.parse("", 3)).toEqual({ - cardId: "ahs_123", + expect(service.parse("", 3)).toEqual({ noteId: 42, - raw: "", + raw: "", lineIndex: 3, }); }); it("writes multiple markers bottom-up in a single file", () => { - const service = new CardMarkerService(() => 1); + const service = new CardMarkerService(); const sourceContent = ["#### Top", "Body 1", "", "#### Bottom", "Body 2"].join("\n"); const nextContent = service.applyBatch(sourceContent, [ { filePath: "notes/example.md", - cardId: "ahs_top", noteId: 11, blockStartLine: 1, contentEndLine: 2, @@ -30,7 +28,6 @@ describe("CardMarkerService", () => { }, { filePath: "notes/example.md", - cardId: "ahs_bottom", noteId: 22, blockStartLine: 4, contentEndLine: 5, @@ -42,11 +39,11 @@ describe("CardMarkerService", () => { expect(nextContent).toBe([ "#### Top", "Body 1", - "", + "", "", "#### Bottom", "Body 2", - "", + "", ].join("\n")); }); }); \ No newline at end of file diff --git a/src/domain/manual-sync/services/CardMarkerService.ts b/src/domain/manual-sync/services/CardMarkerService.ts index 15274c9..f420b10 100644 --- a/src/domain/manual-sync/services/CardMarkerService.ts +++ b/src/domain/manual-sync/services/CardMarkerService.ts @@ -1,11 +1,9 @@ -import { hashString } from "@/domain/shared/hash"; import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; -import type { AhsMarker } from "@/domain/manual-sync/entities/AhsMarker"; +import type { IdMarker } from "@/domain/manual-sync/entities/IdMarker"; export interface MarkerWriteRequest { filePath: string; - cardId: string; - noteId?: number; + noteId: number; blockStartLine: number; contentEndLine: number; blockEndLine: number; @@ -20,48 +18,41 @@ export class CardMarkerError extends Error { } } -const AHS_MARKER_CANDIDATE_REGEXP = /\s*$/; +const ID_MARKER_CANDIDATE_REGEXP = /\s*$/; export class CardMarkerService { - private counter = 0; - - constructor(private readonly now: () => number = () => Date.now()) {} - isCandidate(line: string): boolean { - return AHS_MARKER_CANDIDATE_REGEXP.test(line); + return ID_MARKER_CANDIDATE_REGEXP.test(line); } - parse(line: string, lineIndex: number): AhsMarker | null { - const match = line.match(AHS_MARKER_REGEXP); + parse(line: string, lineIndex: number): IdMarker | null { + const match = line.match(ID_MARKER_REGEXP); if (!match) { return null; } return { - cardId: match[1], - noteId: match[2] ? Number(match[2]) : undefined, + noteId: Number(match[1]), raw: match[0].trim(), lineIndex, }; } - create(cardId: string, noteId?: number): AhsMarker { + create(noteId: number): IdMarker { return { - cardId, noteId, - raw: noteId ? `` : ``, + raw: ``, lineIndex: -1, }; } - createForCard(card: IndexedCard): AhsMarker { - return this.create(card.cardId, card.noteId); - } + createForCard(card: IndexedCard): IdMarker { + if (card.noteId === undefined) { + throw new CardMarkerError("Cannot create an ID marker without a noteId."); + } - generateCardId(): string { - this.counter += 1; - return `ahs_${hashString(`${this.now()}|${this.counter}|${Math.random()}`).slice(0, 12)}`; + return this.create(card.noteId); } applyBatch(sourceContent: string, writes: MarkerWriteRequest[]): string { @@ -100,16 +91,16 @@ export class CardMarkerService { const sortedWrites = [...writes].sort((left, right) => right.blockStartLine - left.blockStartLine); for (const write of sortedWrites) { - const adjustedBlockEndLine = write.markerLine ? write.blockEndLine - 1 : write.blockEndLine; + const adjustedBlockEndLine = write.markerLine !== undefined ? write.blockEndLine - 1 : write.blockEndLine; if (write.contentEndLine < write.blockStartLine || write.contentEndLine > adjustedBlockEndLine) { throw new CardMarkerError(`Cannot write marker outside the heading block in ${write.filePath}.`); } - if (write.markerLine) { + if (write.markerLine !== undefined) { lines.splice(write.markerLine - 1, 1); } - lines.splice(write.contentEndLine, 0, this.create(write.cardId, write.noteId).raw); + lines.splice(write.contentEndLine, 0, this.create(write.noteId).raw); } return lines.join(lineEnding); diff --git a/src/domain/manual-sync/services/DeckResolutionService.test.ts b/src/domain/manual-sync/services/DeckResolutionService.test.ts index f4c1f1d..5b95409 100644 --- a/src/domain/manual-sync/services/DeckResolutionService.test.ts +++ b/src/domain/manual-sync/services/DeckResolutionService.test.ts @@ -76,9 +76,10 @@ describe("DeckResolutionService", () => { function createIndexedCard(overrides: Partial = {}): IndexedCard { return { - cardId: overrides.cardId ?? "ahs_1", noteId: overrides.noteId, - markerNoteId: overrides.markerNoteId, + syncKey: overrides.syncKey ?? "notes/example.md\u00001\u0000hash-1", + idMarkerState: overrides.idMarkerState ?? "missing", + noteIdSource: overrides.noteIdSource, filePath: overrides.filePath ?? "notes/example.md", cardType: overrides.cardType ?? "basic", heading: overrides.heading ?? "Prompt", @@ -97,7 +98,6 @@ function createIndexedCard(overrides: Partial = {}): IndexedCard { deckHintSource: overrides.deckHintSource, deckWarnings: overrides.deckWarnings ?? [], tagsHint: overrides.tagsHint ?? [], - markerState: overrides.markerState ?? "missing", sourceContent: overrides.sourceContent, }; } \ No newline at end of file diff --git a/src/domain/manual-sync/services/DiffPlannerService.test.ts b/src/domain/manual-sync/services/DiffPlannerService.test.ts index 60c5d90..8e86b2e 100644 --- a/src/domain/manual-sync/services/DiffPlannerService.test.ts +++ b/src/domain/manual-sync/services/DiffPlannerService.test.ts @@ -1,35 +1,18 @@ import { describe, expect, it } from "vitest"; import { createModule3Settings } from "@/test-support/manualSyncFakes"; -import { createEmptyPluginState } from "@/domain/manual-sync/entities/PluginState"; +import { createEmptyPluginState, type CardState } from "@/domain/manual-sync/entities/PluginState"; import { RenderConfigService } from "@/application/services/RenderConfigService"; +import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; import { DiffPlannerService } from "./DiffPlannerService"; describe("DiffPlannerService", () => { - it("places cardId-only cards without noteId into toCreate", () => { + it("places unresolved cards without noteId into toCreate", () => { const service = new DiffPlannerService(); const plan = service.plan( [ - { - cardId: "ahs_1", - filePath: "notes/example.md", - cardType: "basic", - heading: "Prompt", - headingLevel: 4, - bodyMarkdown: "Body", - blockStartOffset: 0, - blockEndOffset: 16, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 2, - contentEndLine: 2, - rawBlockText: ["#### Prompt", "Body"].join("\n"), - rawBlockHash: "hash-card", - deckWarnings: [], - tagsHint: [], - markerState: "card-only", - }, + createIndexedCard({ noteId: undefined, idMarkerState: "missing" }), ], createEmptyPluginState(), ["notes/example.md"], @@ -43,53 +26,16 @@ describe("DiffPlannerService", () => { it("rewrites a missing marker without create or update when the restored card content is unchanged", () => { const service = new DiffPlannerService(); const settings = createModule3Settings(); - const card = { - cardId: "ahs_known", + const card = createIndexedCard({ noteId: 42, - filePath: "notes/example.md", - cardType: "basic" as const, - heading: "Prompt", - headingLevel: 4, - bodyMarkdown: "Body", - blockStartOffset: 0, - blockEndOffset: 16, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 2, - contentEndLine: 2, - rawBlockText: ["#### Prompt", "Body"].join("\n"), - rawBlockHash: "hash-card", - deckWarnings: [], - tagsHint: [], - markerState: "missing" as const, - }; + idMarkerState: "missing", + noteIdSource: "state-recovery", + }); const renderPlan = new RenderConfigService().resolve(card, settings); const state = { files: {}, cards: { - ahs_known: { - cardId: "ahs_known", - noteId: 42, - filePath: "notes/example.md", - heading: "Prompt", - headingLevel: 4, - bodyMarkdown: "Body", - cardType: "basic" as const, - blockStartOffset: 0, - blockEndOffset: 16, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 2, - contentEndLine: 2, - rawBlockText: ["#### Prompt", "Body"].join("\n"), - rawBlockHash: "hash-card", - renderConfigHash: renderPlan.renderConfigHash, - deck: renderPlan.deck, - deckWarnings: [], - tagsHint: [], - lastSyncedAt: 1, - orphan: false, - }, + "42": createCardState({ noteId: 42, renderConfigHash: renderPlan.renderConfigHash, deck: renderPlan.deck }), }, pendingWriteBack: [], }; @@ -99,7 +45,7 @@ describe("DiffPlannerService", () => { expect(plan.toCreate).toHaveLength(0); expect(plan.toUpdate).toHaveLength(0); expect(plan.toChangeDeck).toHaveLength(0); - expect(plan.toRewriteMarker.map((plannedCard) => plannedCard.card.cardId)).toEqual(["ahs_known"]); + expect(plan.toRewriteMarker.map((plannedCard) => plannedCard.noteId)).toEqual([42]); expect(plan.unchangedCards).toBe(1); }); @@ -108,65 +54,28 @@ describe("DiffPlannerService", () => { const state = { files: {}, cards: { - ahs_1: { - cardId: "ahs_1", - noteId: 42, - filePath: "notes/example.md", - heading: "Prompt", - headingLevel: 4, - bodyMarkdown: "Body", - cardType: "basic" as const, - blockStartOffset: 0, - blockEndOffset: 16, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 2, - contentEndLine: 2, - rawBlockText: ["#### Prompt", "Body"].join("\n"), - rawBlockHash: "old-hash", - renderConfigHash: "old-render", - deck: "Old", - deckWarnings: [], - tagsHint: [], - lastSyncedAt: 1, - orphan: false, - }, + "42": createCardState({ noteId: 42, rawBlockHash: "old-hash", renderConfigHash: "old-render", deck: "Old" }), }, pendingWriteBack: [ { filePath: "notes/example.md", - cardId: "ahs_1", - noteId: 42, + blockStartLine: 1, expectedFileHash: "hash", - targetMarker: "", + targetMarker: "", rawBlockHash: "hash-card", + targetNoteId: 42, }, ], }; const plan = service.plan( [ - { - cardId: "ahs_1", + createIndexedCard({ noteId: 42, - markerNoteId: 41, - filePath: "notes/example.md", - cardType: "basic", - heading: "Prompt", - headingLevel: 4, - bodyMarkdown: "Body", - blockStartOffset: 0, - blockEndOffset: 16, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 2, - contentEndLine: 2, - rawBlockText: ["#### Prompt", "Body"].join("\n"), rawBlockHash: "hash-card", - deckWarnings: [], - tagsHint: [], - markerState: "card-and-note", - }, + idMarkerState: "present-valid", + noteIdSource: "marker", + }), ], state, ["notes/example.md"], @@ -183,89 +92,30 @@ describe("DiffPlannerService", () => { const state = { files: {}, cards: { - ahs_missing: { - cardId: "ahs_missing", - noteId: 1, - filePath: "notes/example.md", - heading: "Prompt", - headingLevel: 4, - bodyMarkdown: "Body", - cardType: "basic" as const, - blockStartOffset: 0, - blockEndOffset: 16, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 2, - contentEndLine: 2, - rawBlockText: ["#### Prompt", "Body"].join("\n"), - rawBlockHash: "hash-card", - renderConfigHash: "render-hash", - deck: "Obsidian", - deckWarnings: [], - tagsHint: [], - lastSyncedAt: 1, - orphan: false, - }, + "1": createCardState({ noteId: 1 }), }, pendingWriteBack: [], }; const plan = service.plan([], state, ["notes/example.md"], createModule3Settings()); - expect(plan.toOrphan.map((card) => card.cardId)).toEqual(["ahs_missing"]); + expect(plan.toOrphan.map((card) => card.noteId)).toEqual([1]); }); it("schedules deck migration when only the resolved deck changed", () => { const service = new DiffPlannerService(); const settings = createModule3Settings({ defaultDeck: "New::Deck" }); - const card = { - cardId: "ahs_1", + const card = createIndexedCard({ noteId: 42, - markerNoteId: 42, filePath: "example.md", - cardType: "basic" as const, - heading: "Prompt", - headingLevel: 4, - bodyMarkdown: "Body", - blockStartOffset: 0, - blockEndOffset: 16, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 2, - contentEndLine: 2, - rawBlockText: ["#### Prompt", "Body"].join("\n"), - rawBlockHash: "hash-card", - deckWarnings: [], - tagsHint: [], - markerState: "card-and-note" as const, - }; + idMarkerState: "present-valid", + noteIdSource: "marker", + }); const legacyRenderPlan = new RenderConfigService().resolve(card, createModule3Settings({ defaultDeck: "Old::Deck" })); const state = { files: {}, cards: { - ahs_1: { - cardId: "ahs_1", - noteId: 42, - filePath: "example.md", - heading: "Prompt", - headingLevel: 4, - bodyMarkdown: "Body", - cardType: "basic" as const, - blockStartOffset: 0, - blockEndOffset: 16, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 2, - contentEndLine: 2, - rawBlockText: ["#### Prompt", "Body"].join("\n"), - rawBlockHash: "hash-card", - renderConfigHash: legacyRenderPlan.renderConfigHash, - deck: "Old::Deck", - deckWarnings: [], - tagsHint: [], - lastSyncedAt: 1, - orphan: false, - }, + "42": createCardState({ noteId: 42, filePath: "example.md", renderConfigHash: legacyRenderPlan.renderConfigHash, deck: "Old::Deck" }), }, pendingWriteBack: [], }; @@ -280,27 +130,16 @@ describe("DiffPlannerService", () => { it("does not trigger deck migration when only fields changed", () => { const service = new DiffPlannerService(); const settings = createModule3Settings(); - const card = { - cardId: "ahs_1", + const card = createIndexedCard({ noteId: 42, - markerNoteId: 42, - filePath: "example.md", - cardType: "basic" as const, - heading: "Prompt", - headingLevel: 4, bodyMarkdown: "Updated Body", - blockStartOffset: 0, - blockEndOffset: 23, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 2, - contentEndLine: 2, rawBlockText: ["#### Prompt", "Updated Body"].join("\n"), rawBlockHash: "new-hash", - deckWarnings: [], - tagsHint: [], - markerState: "card-and-note" as const, - }; + blockEndOffset: 23, + filePath: "example.md", + idMarkerState: "present-valid", + noteIdSource: "marker", + }); const existingRenderPlan = new RenderConfigService().resolve({ ...card, bodyMarkdown: "Body", @@ -310,29 +149,7 @@ describe("DiffPlannerService", () => { const state = { files: {}, cards: { - ahs_1: { - cardId: "ahs_1", - noteId: 42, - filePath: "example.md", - heading: "Prompt", - headingLevel: 4, - bodyMarkdown: "Body", - cardType: "basic" as const, - blockStartOffset: 0, - blockEndOffset: 16, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 2, - contentEndLine: 2, - rawBlockText: ["#### Prompt", "Body"].join("\n"), - rawBlockHash: "old-hash", - renderConfigHash: existingRenderPlan.renderConfigHash, - deck: existingRenderPlan.deck, - deckWarnings: [], - tagsHint: [], - lastSyncedAt: 1, - orphan: false, - }, + "42": createCardState({ noteId: 42, filePath: "example.md", rawBlockHash: "old-hash", renderConfigHash: existingRenderPlan.renderConfigHash, deck: existingRenderPlan.deck }), }, pendingWriteBack: [], }; @@ -342,4 +159,63 @@ describe("DiffPlannerService", () => { expect(plan.toUpdate).toHaveLength(1); expect(plan.toChangeDeck).toHaveLength(0); }); -}); \ No newline at end of file +}); + +function createIndexedCard(overrides: Partial = {}): IndexedCard { + const rawBlockText = overrides.rawBlockText ?? ["#### Prompt", overrides.bodyMarkdown ?? "Body"].join("\n"); + + return { + noteId: overrides.noteId, + syncKey: overrides.syncKey ?? `example.md\u00001\u0000${overrides.rawBlockHash ?? "hash-card"}`, + idMarkerState: overrides.idMarkerState ?? "present-valid", + noteIdSource: overrides.noteIdSource, + filePath: overrides.filePath ?? "notes/example.md", + cardType: overrides.cardType ?? "basic", + heading: overrides.heading ?? "Prompt", + headingLevel: overrides.headingLevel ?? 4, + bodyMarkdown: overrides.bodyMarkdown ?? "Body", + blockStartOffset: overrides.blockStartOffset ?? 0, + blockEndOffset: overrides.blockEndOffset ?? 16, + blockStartLine: overrides.blockStartLine ?? 1, + bodyStartLine: overrides.bodyStartLine ?? 2, + blockEndLine: overrides.blockEndLine ?? 2, + contentEndLine: overrides.contentEndLine ?? 2, + markerLine: overrides.markerLine, + rawBlockText, + rawBlockHash: overrides.rawBlockHash ?? "hash-card", + deckHint: overrides.deckHint, + deckHintSource: overrides.deckHintSource, + deckWarnings: overrides.deckWarnings ?? [], + tagsHint: overrides.tagsHint ?? [], + }; +} + +function createCardState(overrides: Partial & Pick): CardState { + const rawBlockText = overrides.rawBlockText ?? ["#### Prompt", overrides.bodyMarkdown ?? "Body"].join("\n"); + + return { + noteId: overrides.noteId, + filePath: overrides.filePath ?? "notes/example.md", + heading: overrides.heading ?? "Prompt", + headingLevel: overrides.headingLevel ?? 4, + bodyMarkdown: overrides.bodyMarkdown ?? "Body", + cardType: overrides.cardType ?? "basic", + blockStartOffset: overrides.blockStartOffset ?? 0, + blockEndOffset: overrides.blockEndOffset ?? 16, + blockStartLine: overrides.blockStartLine ?? 1, + bodyStartLine: overrides.bodyStartLine ?? 2, + blockEndLine: overrides.blockEndLine ?? 2, + contentEndLine: overrides.contentEndLine ?? 2, + markerLine: overrides.markerLine, + rawBlockText, + rawBlockHash: overrides.rawBlockHash ?? "hash-card", + renderConfigHash: overrides.renderConfigHash ?? "render-hash", + deck: overrides.deck ?? "Obsidian", + deckHint: overrides.deckHint, + deckHintSource: overrides.deckHintSource, + deckWarnings: overrides.deckWarnings ?? [], + tagsHint: overrides.tagsHint ?? [], + lastSyncedAt: overrides.lastSyncedAt ?? 1, + orphan: overrides.orphan ?? false, + }; +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/DiffPlannerService.ts b/src/domain/manual-sync/services/DiffPlannerService.ts index 5fc0dba..b729c4e 100644 --- a/src/domain/manual-sync/services/DiffPlannerService.ts +++ b/src/domain/manual-sync/services/DiffPlannerService.ts @@ -1,7 +1,7 @@ import type { PluginSettings } from "@/application/config/PluginSettings"; import { RenderConfigService } from "@/application/services/RenderConfigService"; import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; -import type { PluginState } from "@/domain/manual-sync/entities/PluginState"; +import { createPendingWriteBackKey, toNoteIdKey, type PluginState } from "@/domain/manual-sync/entities/PluginState"; import { getDeckResolutionWarningKey, type DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution"; import type { ManualSyncPlan, PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan"; @@ -9,8 +9,9 @@ export class DiffPlannerService { constructor(private readonly renderConfigService = new RenderConfigService()) {} plan(cards: IndexedCard[], state: PluginState, scopedFilePaths: string[], settings: PluginSettings): ManualSyncPlan { - const cardsById = new Map(); - const pendingByCardId = new Map(state.pendingWriteBack.map((pending) => [pending.cardId, pending])); + const cardsBySyncKey = new Map(); + const pendingByBlockKey = new Set(state.pendingWriteBack.map((pending) => createPendingWriteBackKey(pending.filePath, pending.blockStartLine, pending.rawBlockHash))); + const seenNoteKeys = new Set(); const toCreate: PlannedCard[] = []; const toUpdate: PlannedCard[] = []; const toChangeDeck: PlannedCard[] = []; @@ -19,17 +20,27 @@ export class DiffPlannerService { let unchangedCards = 0; for (const card of cards) { - if (cardsById.has(card.cardId)) { - throw new Error(`Duplicate cardId detected in current scan: ${card.cardId}`); + if (cardsBySyncKey.has(card.syncKey)) { + throw new Error(`Duplicate syncKey detected in current scan: ${card.syncKey}`); } - cardsById.set(card.cardId, card); - const existingState = state.cards[card.cardId]; + cardsBySyncKey.set(card.syncKey, card); + if (card.noteId !== undefined) { + const noteKey = toNoteIdKey(card.noteId); + if (seenNoteKeys.has(noteKey)) { + throw new Error(`Duplicate noteId detected in current scan: ${card.noteId}`); + } + + seenNoteKeys.add(noteKey); + } + + const existingState = card.noteId !== undefined ? state.cards[toNoteIdKey(card.noteId)] : undefined; const renderPlan = this.renderConfigService.resolve(card, settings); for (const warning of renderPlan.warnings) { warningMap.set(getDeckResolutionWarningKey(warning), warning); } - const resolvedNoteId = existingState?.noteId ?? card.noteId; + const resolvedNoteId = card.noteId; + const blockKey = createPendingWriteBackKey(card.filePath, card.blockStartLine, card.rawBlockHash); const plannedCard: PlannedCard = { card: { ...card, @@ -46,12 +57,7 @@ export class DiffPlannerService { continue; } - if ( - card.markerState === "missing" || - card.markerState === "card-only" || - card.markerNoteId !== resolvedNoteId || - pendingByCardId.has(card.cardId) - ) { + if (card.idMarkerState !== "present-valid" || card.noteIdSource !== "marker" || pendingByBlockKey.has(blockKey)) { toRewriteMarker.push(plannedCard); } @@ -63,8 +69,7 @@ export class DiffPlannerService { const fieldsChanged = existingState.rawBlockHash !== card.rawBlockHash || existingState.renderConfigHash !== renderPlan.renderConfigHash || - existingState.orphan || - pendingByCardId.has(card.cardId); + existingState.orphan; const deckChanged = existingState.deck !== renderPlan.deck; if (fieldsChanged) { @@ -81,7 +86,7 @@ export class DiffPlannerService { } const scopedPaths = new Set(scopedFilePaths); - const toOrphan = Object.values(state.cards).filter((card) => scopedPaths.has(card.filePath) && !cardsById.has(card.cardId)); + const toOrphan = Object.values(state.cards).filter((card) => scopedPaths.has(card.filePath) && !seenNoteKeys.has(toNoteIdKey(card.noteId))); return { toCreate, diff --git a/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts index 0346fe2..33028a4 100644 --- a/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts +++ b/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { PluginDataStore } from "@/application/ports/PluginDataStore"; import { createEmptyPluginState } from "@/domain/manual-sync/entities/PluginState"; +import { hashString } from "@/domain/shared/hash"; import { DataJsonPluginStateRepository } from "./DataJsonPluginStateRepository"; import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository"; @@ -40,7 +41,7 @@ describe("DataJsonPluginStateRepository", () => { fileHash: "hash", fileStamp: "1:1", lastIndexedAt: 1, - cardIds: ["ahs_1"], + noteIds: [41], }, }, cards: {}, @@ -48,7 +49,67 @@ describe("DataJsonPluginStateRepository", () => { }); const snapshot = await store.load(); - expect(snapshot?.pluginState?.files["notes/example.md"]?.cardIds).toEqual(["ahs_1"]); + expect(snapshot?.pluginState?.files["notes/example.md"]?.noteIds).toEqual([41]); expect(snapshot?.settings?.defaultDeck).toBe("Deck"); }); + + it("migrates legacy cardId-keyed plugin state to noteId-keyed state on load", async () => { + const rawBlockText = ["#### Prompt", "Answer"].join("\n"); + const legacyPluginState = { + files: { + "notes/example.md": { + filePath: "notes/example.md", + fileHash: "hash", + fileStamp: "1:1", + lastIndexedAt: 1, + cardIds: ["ahs_1"], + }, + }, + cards: { + ahs_1: { + cardId: "ahs_1", + noteId: 41, + filePath: "notes/example.md", + heading: "Prompt", + headingLevel: 4, + bodyMarkdown: "Answer", + cardType: "basic", + blockStartOffset: 0, + blockEndOffset: rawBlockText.length, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 2, + contentEndLine: 2, + rawBlockText, + rawBlockHash: hashString(rawBlockText), + renderConfigHash: "render-hash", + deck: "notes", + deckWarnings: [], + tagsHint: [], + lastSyncedAt: 1, + orphan: false, + }, + }, + pendingWriteBack: [ + { + filePath: "notes/example.md", + cardId: "ahs_1", + noteId: 41, + expectedFileHash: "hash", + targetMarker: "", + rawBlockHash: hashString(rawBlockText), + }, + ], + }; + const repository = new DataJsonPluginStateRepository(new InMemoryPluginDataStore({ + pluginState: legacyPluginState as unknown as PluginDataSnapshot["pluginState"], + })); + + const loaded = await repository.load(); + + expect(loaded.files["notes/example.md"]?.noteIds).toEqual([41]); + expect(Object.keys(loaded.cards)).toEqual(["41"]); + expect(loaded.cards["41"]?.noteId).toBe(41); + expect(loaded.pendingWriteBack).toEqual([]); + }); }); \ No newline at end of file diff --git a/src/infrastructure/persistence/DataJsonPluginStateRepository.ts b/src/infrastructure/persistence/DataJsonPluginStateRepository.ts index b75a21d..af6a917 100644 --- a/src/infrastructure/persistence/DataJsonPluginStateRepository.ts +++ b/src/infrastructure/persistence/DataJsonPluginStateRepository.ts @@ -1,15 +1,35 @@ import type { PluginStateRepository } from "@/application/ports/PluginStateRepository"; import type { PluginDataStore } from "@/application/ports/PluginDataStore"; -import { createEmptyPluginState, type PluginState } from "@/domain/manual-sync/entities/PluginState"; +import { createEmptyPluginState, toNoteIdKey, type CardState, type FileState, type PendingWriteBackState, type PluginState } from "@/domain/manual-sync/entities/PluginState"; import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository"; +interface LegacyFileState extends Partial { + cardIds?: string[]; +} + +interface LegacyCardState extends Partial { + cardId?: string; + noteId?: number; +} + +interface LegacyPendingWriteBackState extends Partial { + cardId?: string; + noteId?: number; +} + +interface LegacyPluginState { + files?: Record; + cards?: Record; + pendingWriteBack?: LegacyPendingWriteBackState[]; +} + export class DataJsonPluginStateRepository implements PluginStateRepository { constructor(private readonly pluginDataStore: PluginDataStore) {} async load(): Promise { const snapshot = (await this.pluginDataStore.load()) ?? {}; - return snapshot.pluginState ?? createEmptyPluginState(); + return migratePluginState(snapshot.pluginState); } async save(state: PluginState): Promise { @@ -20,4 +40,105 @@ export class DataJsonPluginStateRepository implements PluginStateRepository { pluginState: state, }); } +} + +export function migratePluginState(pluginState?: PluginState | LegacyPluginState): PluginState { + if (!pluginState) { + return createEmptyPluginState(); + } + + const rawCards = pluginState.cards ?? {}; + const cards: Record = {}; + + for (const rawCard of Object.values(rawCards)) { + const noteId = sanitizeNoteId(rawCard.noteId); + if (noteId === undefined) { + continue; + } + + const nextCard = migrateCardState(rawCard, noteId); + const noteKey = toNoteIdKey(noteId); + const existingCard = cards[noteKey]; + if (!existingCard || existingCard.lastSyncedAt <= nextCard.lastSyncedAt) { + cards[noteKey] = nextCard; + } + } + + const files: Record = {}; + for (const [filePath, rawFile] of Object.entries(pluginState.files ?? {})) { + files[filePath] = { + filePath, + fileHash: typeof rawFile.fileHash === "string" ? rawFile.fileHash : "", + fileStamp: typeof rawFile.fileStamp === "string" ? rawFile.fileStamp : "", + deckRulesFingerprint: typeof rawFile.deckRulesFingerprint === "string" ? rawFile.deckRulesFingerprint : undefined, + lastIndexedAt: typeof rawFile.lastIndexedAt === "number" ? rawFile.lastIndexedAt : 0, + noteIds: collectMigratedFileNoteIds(rawFile, rawCards, cards), + }; + } + + return { + files, + cards, + pendingWriteBack: [], + }; +} + +function migrateCardState(rawCard: LegacyCardState, noteId: number): CardState { + return { + noteId, + filePath: typeof rawCard.filePath === "string" ? rawCard.filePath : "", + heading: typeof rawCard.heading === "string" ? rawCard.heading : "", + headingLevel: typeof rawCard.headingLevel === "number" ? rawCard.headingLevel : 1, + bodyMarkdown: typeof rawCard.bodyMarkdown === "string" ? rawCard.bodyMarkdown : "", + cardType: rawCard.cardType === "cloze" ? "cloze" : "basic", + blockStartOffset: typeof rawCard.blockStartOffset === "number" ? rawCard.blockStartOffset : 0, + blockEndOffset: typeof rawCard.blockEndOffset === "number" ? rawCard.blockEndOffset : 0, + blockStartLine: typeof rawCard.blockStartLine === "number" ? rawCard.blockStartLine : 1, + bodyStartLine: typeof rawCard.bodyStartLine === "number" ? rawCard.bodyStartLine : 1, + blockEndLine: typeof rawCard.blockEndLine === "number" ? rawCard.blockEndLine : 1, + contentEndLine: typeof rawCard.contentEndLine === "number" ? rawCard.contentEndLine : 1, + markerLine: typeof rawCard.markerLine === "number" ? rawCard.markerLine : undefined, + rawBlockText: typeof rawCard.rawBlockText === "string" ? rawCard.rawBlockText : "", + rawBlockHash: typeof rawCard.rawBlockHash === "string" ? rawCard.rawBlockHash : "", + renderConfigHash: typeof rawCard.renderConfigHash === "string" ? rawCard.renderConfigHash : "", + deck: typeof rawCard.deck === "string" ? rawCard.deck : "", + deckHint: typeof rawCard.deckHint === "string" ? rawCard.deckHint : undefined, + deckHintSource: rawCard.deckHintSource === "frontmatter" || rawCard.deckHintSource === "body" ? rawCard.deckHintSource : undefined, + deckWarnings: Array.isArray(rawCard.deckWarnings) ? [...rawCard.deckWarnings] : [], + tagsHint: Array.isArray(rawCard.tagsHint) ? rawCard.tagsHint.filter((tag): tag is string => typeof tag === "string") : [], + lastSyncedAt: typeof rawCard.lastSyncedAt === "number" ? rawCard.lastSyncedAt : 0, + orphan: Boolean(rawCard.orphan), + }; +} + +function collectMigratedFileNoteIds( + rawFile: LegacyFileState, + rawCards: Record, + migratedCards: Record, +): number[] { + const noteIds = new Set(); + + if (Array.isArray(rawFile.noteIds)) { + for (const noteId of rawFile.noteIds) { + const sanitized = sanitizeNoteId(noteId); + if (sanitized !== undefined && migratedCards[toNoteIdKey(sanitized)]) { + noteIds.add(sanitized); + } + } + } + + if (Array.isArray(rawFile.cardIds)) { + for (const cardId of rawFile.cardIds) { + const sanitized = sanitizeNoteId(rawCards[cardId]?.noteId); + if (sanitized !== undefined && migratedCards[toNoteIdKey(sanitized)]) { + noteIds.add(sanitized); + } + } + } + + return Array.from(noteIds); +} + +function sanitizeNoteId(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } \ No newline at end of file