refactor: 切换 manual-sync 主链到 ID noteId / refactor manual-sync mainline to ID noteId

- 将 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
This commit is contained in:
Dusk 2026-04-19 11:46:37 +08:00
parent d4d73487a0
commit e2f973dc46
30 changed files with 1744 additions and 649 deletions

View file

@ -0,0 +1,130 @@
# 最终建议
## 最终分类
**可接管,但必须先做一次迁移写回。**
这是本次分析在 current manual-sync 主链边界内的最终判断。
## 为什么是这个结论
### 不是第 1 类:可直接接管
因为当前主链并不会把旧 `<!--ID: ...-->` 识别为已有身份。
直接运行只会:
- 生成新的 `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. 把 `<!--ID: noteId-->` 改写成 `<!-- AHS:card=cardId note=noteId -->`
5. 同步写入 `pluginState.files``pluginState.cards`
6. 之后再让 normal sync 接管
### 迁移完成后的系统状态
迁移完成后,这批旧卡在系统里应当表现得和原生 AHS 卡完全一样:
- Markdown 里只有 AHS marker
- `pluginState.cards[cardId].noteId` 已存在
- 后续改内容走 `toUpdate`
- 后续改 deck 走 `toChangeDeck`
- 不再依赖 legacy marker 或旧 `syncRegistry`
## 迁移必须满足的验收门槛
### 门槛 1dry-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 正常维护。**

View file

@ -0,0 +1,190 @@
# 身份兼容性分析
## 当前 manual-sync 的身份模型
当前主链的真实身份模型是:
- Markdown 里有 `cardId`
- `pluginState.cards[cardId]` 保存 `noteId`
- 扫描阶段优先用 `cardId` 找回旧状态
- 只有在没有 marker 但又能用 `(filePath, rawBlockHash)` 唯一命中旧状态时,才允许无 marker 续命
换句话说,当前主链的第一主键是 `cardId``noteId` 只是这个主键之下的外部映射。
## 旧卡的身份模型
用户给出的真实旧样本身份模型是:
- heading block
- 正文
- 尾部 legacy marker`<!--ID: noteId-->`
它直接把 `noteId` 放在 Markdown 里,但没有 `cardId`,也没有当前 `pluginState` 需要的状态结构。
## 为什么直接接管一定失败
下面是旧样本首次进入当前主链时的真实控制流。
### 第 1 步legacy marker 不会被识别
`CardMarkerService` 只识别 `<!-- AHS:card=... note=... -->`
因此旧样本中的:
`<!--ID: 1760624596099-->`
对当前 `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 那行之后。
结果是:
- 旧 `<!--ID: ...-->` 还在
- 新 `<!-- AHS:card=... note=... -->` 又被追加进来
这不是设计好的“双 marker 兼容”,只是直接运行后的副作用。
## 为什么这不是“只能导入为新卡”
虽然直接接管失败,但当前证据同时说明:迁移是可行的。
### 证据 1旧 Markdown 自带精确 noteId
旧样本不是“模糊匹配标题”,而是每个 block 尾部都内嵌了真实 noteId。
例如:
- `<!--ID: 1760624596099-->`
- `<!--ID: 1760624596121-->`
这使迁移不需要做模糊搜索,不需要猜测哪张 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. 把 `<!--ID: noteId-->` 改写成 `<!-- AHS:card=cardId note=noteId -->`
4. 把同一个 block 的当前状态写入 `pluginState.cards[cardId]`
只要这四件事一次完成,下一次 normal sync 时,当前主链就会把这些卡当成原生 AHS 卡来处理。
## 迁移时必须守住的身份不变量
### 不变量 1hash 必须基于“剥除 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 真实存在
- 当前状态模型能容纳迁移结果
所以最终判断应是:
不是“不能接管”,而是“不能直接接管,只能先迁移后接管”。

View file

@ -0,0 +1,57 @@
# 兼容性矩阵
## 维度矩阵
| 维度 | 旧卡现状 | 当前 manual-sync 要求 | 当前兼容性 | 说明 |
| --- | --- | --- | --- | --- |
| Markdown marker 识别 | `<!--ID: noteId-->` | `<!-- AHS:card=... note=... -->` | 不兼容 | 当前 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 进入主链的入口,旧卡与当前主链是不兼容的。
所以真正的系统级判断不是“不能接管”,而是“不能直接接管,但可以通过一次显式迁移接管”。

View file

@ -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 | 城市居住区,定义? | `<!--ID: 1760624596099-->` | `[[anki背诵]]::[[《城市居住区规划设计标准》图解2021年]]` | `[[anki背诵]]::[[《城市居住区规划设计标准》图解2021年]]` | `问答题` / `Obsidian_to_Anki` | Front 含题目和 Obsidian 链接Back 含块级 backlink + 正文 | noteId 在 Markdown 与 Anki 间可直接对上 |
| 1760624596121 | 1760624596122 | 居住区依据其居住人口规模,可分为几类? | `<!--ID: 1760624596121-->` | 同上 | 同上 | `问答题` / `Obsidian_to_Anki` | Front 含题目与 Obsidian 链接Back 为列表 HTML | 数据模型与当前设置兼容 |
| 1760624596130 | 1760624596131 | 十五分钟生活圈居住区?定义? | `<!--ID: 1760624596130-->` | 同上 | 同上 | `问答题` / `Obsidian_to_Anki` | Back 含强调、标记、旧样式链接 | deck 与字段都能继续沿用 |
| 1760624596139 | 1760624596140 | 十分钟生活圈居住区?定义? | `<!--ID: 1760624596139-->` | 同上 | 同上 | `问答题` / `Obsidian_to_Anki` | Back 含旧渲染 HTML含强调与标记 | 接管后字段可能被新渲染归一 |
| 1760624596147 | 1760624596147 | 五分钟生活圈居住区 | `<!--ID: 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 | `<!-- AHS:card=ahs_a217324b note=1776515511477 -->` | 文件夹 + 文件名 | `999试验卡片::课件2::卡片试验` | `问答题` / 无 tag | Front 只有题目Back 含 `Open in Obsidian` backlink | 当前主链是 `cardId + noteId` 双锚点 |
| 1776515511480 | `ahs_dca62365` | 试验2333 | `<!-- AHS:card=ahs_dca62365 note=1776515511480 -->` | 文件夹 + 文件名 | `999试验卡片::课件2::卡片试验` | `问答题` / 无 tag | Front 只有题目Back 含 `Open in Obsidian` backlink | 新链路不依赖 Anki tag 识别 |
## 旧样本 Markdown 与新样本 Markdown 的直接差异
| 维度 | 旧样本 | 新样本 | 含义 |
| --- | --- | --- | --- |
| heading 层级 | `####` | `######` | 当前设置里 `qaHeadingLevel=6`,旧样本若直接进入当前主链,是否被识别为 QA 卡还取决于设置或迁移策略 |
| marker 格式 | `<!--ID: noteId-->` | `<!-- AHS:card=... note=... -->` | 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. 因此旧卡“无法接管”的原因不是数据不够,而是当前主链没有把旧身份格式翻译进新身份模型。

View file

@ -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 不识别 `<!--ID: ...-->`
- 当前状态层不消费 `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 协议”。

View file

@ -0,0 +1,99 @@
# 兼容性触点报告
## 分析边界
- 只评估当前 manual-sync 主链,不把旧 syncRegistry 主链当成最终方案。
- 证据来源限定为四类真值:仓库代码、用户提供的旧 Markdown 样本、用户提供的新 Markdown 样本、实时 AnkiConnect 返回。
- 目标问题只有一个:当前主链能否安全接管由 Obsidian to Anki 生成的旧卡,并继续后续同步。
## 结论先行
当前主链不能直接接管旧卡。核心阻塞点不是 note model也不是 deck而是 identity bootstrap。
- 旧样本只提供 legacy noteId marker`<!--ID: 1760624596099-->`
- 新主链只识别 AHS marker`<!-- AHS:card=ahs_xxx note=1776515511477 -->`
- 当前状态层以 `cardId -> noteId` 为主键,而不是以 `noteId` 为主键
- 因此,旧文件在首次进入当前主链时,既拿不到 `marker.cardId`,也拿不到 `pluginState.cards[cardId]`,最后只能被当成新卡创建
这意味着“直接接管”会造成重复建卡,而不是认领旧卡。
## 关键触点一览
| 触点 | 当前行为 | 兼容性影响 | 结论 |
| --- | --- | --- | --- |
| `src/domain/manual-sync/services/CardMarkerService.ts` | `isCandidate()``parse()` 只认 `<!-- AHS:card=... note=... -->` | 旧样本 `<!--ID: ...-->` 对主链是不可见 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` | 旧链路支持的是 `<!-- AHS:123 -->` 这种 noteId-only marker | 这和本次真实旧样本的 `<!--ID: ...-->` 不是同一种格式,也不在 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 语义

View file

@ -0,0 +1,149 @@
# ID NoteId Mainline Decisions
## Decision Summary
本轮实现严格按如下决策收口:
1. `<!--ID: noteId-->` 是 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)` 永远输出 `<!--ID: <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<string, number | undefined>`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 满足当前标题级别规则
- 且 `<!--ID: noteId-->` 合法且 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 `<!--ID: ...-->` 块做可视化提示
- 是否为旧样本提供 heading policy compatibility mode
这些都超出本轮范围。

View file

@ -0,0 +1,262 @@
# ID NoteId Mainline Gap Report
## Scope
本报告只针对当前 manual-sync 主链,审查它与本轮契约之间的偏差:
- 主链 durable identity 切换为 `<!--ID: noteId-->`
- 满足当前标题级别规则的旧卡支持按 `ID` marker 重接进
- 认领成功后按当前 renderer 重渲染更新
- plugin state / writeback / execution 全部切到 noteId 主身份
## Executive Summary
当前实现与本轮契约的偏差是系统性的,不是局部修补能解决的小问题。
当前 manual-sync 主链仍然围绕以下假设建立:
- Markdown marker 协议是 `<!-- AHS:card=... note=... -->`
- `cardId` 是 durable identity
- `PluginState.cards``cardId` 作为 key
- `pendingWriteBack``cardId` 追踪
- 一次运行内与持久化状态共用同一身份键
而本轮契约要求:
- `<!--ID: noteId-->` 成为唯一主链 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 只看 `<!-- AHS:`
- 合法 marker 只接受 `<!-- AHS:card=... note=... -->`
- create/writeback 也只会生成 AHS marker
- service 内还负责生成新的 `cardId`
与契约冲突:
- 主链 marker 必须切为 `<!--ID: noteId-->`
- 合法 marker 只能接受正整数 noteId
- 非法 trailing `<!--ID: ...-->` 候选也要剥离并占用 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 后的内容计算
影响:
- 旧 `<!--ID: ...-->` 卡会被当成正文内容
- 会错误走新建分支
### 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并最终写回真实 `<!--ID: newNoteId-->`
影响:
- 即便 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 生成
与契约冲突:
- 写回必须统一输出 `<!--ID: noteId-->`
- 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 协议切换到 `<!--ID: noteId-->`
2. `noteId` 成为持久主身份,`syncKey` 成为运行内相关键
3. state schema 与 repository load 做显式迁移
4. index / plan / execute / writeback 全链去掉 durable `cardId` 依赖
5. 增加对“满足当前标题规则且带旧 `ID` marker”的重接进与重渲染支持
6. 重写对应测试,补足 integration 覆盖
## Out Of Scope
根据契约,本轮明确不做:
- 继续支持 `<!-- AHS:card=... note=... -->` 文件
- 双 marker 并读
- 旧 HTML 保留模式
- 不匹配标题规则的 `ID` marker 清理工具
- legacy heading compatibility mode

View file

@ -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<string, RenderedSyncCard>([
[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<string, RenderedSyncCard>([
[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",
};
}

View file

@ -13,7 +13,7 @@ export interface AnkiBatchExecutionResult {
uploadedMedia: number;
markerWrites: PlannedCard[];
resolvedNoteIds: Map<string, number | undefined>;
touchedCardIds: Set<string>;
touchedSyncKeys: Set<string>;
}
export class AnkiBatchExecutor {
@ -31,7 +31,7 @@ export class AnkiBatchExecutor {
): Promise<AnkiBatchExecutionResult> {
const modelDetailsCache = new Map<string, Promise<Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>>();
const resolvedNoteIds = new Map<string, number | undefined>();
const touchedCardIds = new Set<string>();
const touchedSyncKeys = new Set<string>();
const markerWriteMap = new Map<string, PlannedCard>();
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<string, RenderedSyncCard>,
renderOnDemand: (plannedCard: PlannedCard) => Promise<RenderedSyncCard>,
): Promise<RenderedSyncCard> {
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;
}

View file

@ -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",

View file

@ -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",
};
}

View file

@ -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("<!-- AHS:card=");
expect(vaultGateway.getFileContent("notes/example.md")).toContain("note=9001");
expect(vaultGateway.getFileContent("notes/example.md")).toContain("<!--ID: 9001-->");
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("<!-- AHS:card=");
expect(vaultGateway.getFileContent("notes/example.md")).not.toContain("note=");
expect(vaultGateway.getFileContent("notes/example.md")).toBe(["#### Prompt", "Answer"].join("\n"));
});
it("rebuildIndex does not migrate decks even when deck rules changed", async () => {
@ -86,11 +84,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: "课程/数学/第一章/导数.md",
deck: "Old::Deck",
}),
@ -115,7 +113,7 @@ describe("ManualSyncService", () => {
const stateRepository = new InMemoryPluginStateRepository({
files: {},
cards: {
ahs_known: storedCard,
"42": storedCard,
},
pendingWriteBack: [],
});
@ -134,7 +132,7 @@ describe("ManualSyncService", () => {
expect(result.rewrittenMarkers).toBe(1);
expect(ankiGateway.addedNotes).toHaveLength(0);
expect(ankiGateway.updatedNotes).toHaveLength(0);
expect(vaultGateway.getFileContent("notes/example.md")).toContain("<!-- AHS:card=ahs_known note=42 -->");
expect(vaultGateway.getFileContent("notes/example.md")).toContain("<!--ID: 42-->");
});
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("<!--ID: 9001-->");
expect(vaultGateway.getFileContent("notes/example.md")).not.toContain("<!--ID: 42-->");
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", "<!-- AHS:card=ahs_known note=42 -->"].join("\n"),
"example.md": ["#### Prompt", "Answer", "<!--ID: 42-->"] .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", "<!-- AHS:card=ahs_known note=42 -->"].join("\n"),
"课程/数学/第一章/导数.md": ["#### Prompt", "Answer", "<!--ID: 42-->"] .join("\n"),
});
const stateRepository = new InMemoryPluginStateRepository({
files: {
"课程/数学/第一章/导数.md": {
filePath: "课程/数学/第一章/导数.md",
fileHash: "hash-a",
fileStamp: `1:${["#### Prompt", "Answer", "<!-- AHS:card=ahs_known note=42 -->"].join("\n").length}`,
fileStamp: `1:${["#### Prompt", "Answer", "<!--ID: 42-->"] .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", "<!-- AHS:card=ahs_known note=42 -->"].join("\n");
const content = ["#### Prompt", "Answer", "<!--ID: 42-->"] .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", "<!-- AHS:card=ahs_known note=42 -->"].join("\n");
const content = ["MY DECK: Scoped::Deck", "", "#### Prompt", "Answer", "<!--ID: 42-->"] .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", "<!-- AHS:card=ahs_known note=42 -->"].join("\n");
const content = ["TARGET DECK: Scoped::Deck", "", "#### Prompt", "Answer", "<!--ID: 42-->"] .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<Car
const bodyMarkdown = overrides.bodyMarkdown ?? "Answer";
const rawBlockText = overrides.rawBlockText ?? [`#### ${heading}`, bodyMarkdown].join("\n");
const rawBlockHash = overrides.rawBlockHash ?? hashString(rawBlockText);
const noteId = overrides.noteId ?? 42;
const indexedCard = {
cardId: overrides.cardId ?? "ahs_known",
noteId: overrides.noteId ?? 42,
markerNoteId: overrides.noteId ?? 42,
noteId,
syncKey: `${overrides.filePath ?? "notes/example.md"}\u0000${overrides.blockStartLine ?? 1}\u0000${rawBlockHash}`,
idMarkerState: "present-valid" as const,
noteIdSource: "marker" as const,
filePath: overrides.filePath ?? "notes/example.md",
cardType: overrides.cardType ?? "basic",
heading,
@ -472,12 +470,10 @@ function createStoredSyncedCard(settings: PluginSettings, overrides: Partial<Car
deckHintSource: overrides.deckHintSource,
deckWarnings: overrides.deckWarnings ?? [],
tagsHint: overrides.tagsHint ?? [],
markerState: "card-and-note" as const,
};
const renderPlan = new RenderConfigService().resolve(indexedCard, settings);
return {
cardId: indexedCard.cardId,
noteId: indexedCard.noteId,
filePath: indexedCard.filePath,
heading: indexedCard.heading,

View file

@ -10,7 +10,7 @@ import { MarkdownWriteBackService } from "@/application/services/MarkdownWriteBa
import { RenderConfigService } from "@/application/services/RenderConfigService";
import { ScanScopeService } from "@/application/services/ScanScopeService";
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import type { PluginState } from "@/domain/manual-sync/entities/PluginState";
import { toNoteIdKey, type CardState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard";
import { DiffPlannerService } from "@/domain/manual-sync/services/DiffPlannerService";
import { ManualCardRenderer, type ManualCardRenderContext } from "@/domain/manual-sync/services/ManualCardRenderer";
@ -64,10 +64,7 @@ export class ManualSyncService {
const indexResult = await this.fileIndexerService.indexVault(settings, state, true);
const plan = this.diffPlannerService.plan(indexResult.cards, state, indexResult.scopedFilePaths, settings);
const indexedFilesByPath = new Map(indexResult.indexedFiles.map((file) => [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<string, RenderedSyncCard>();
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<string>,
touchedSyncKeys: Set<string>,
resolvedNoteIds: Map<string, number | undefined>,
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<string, number>();
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,
};
}

View file

@ -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<string, IndexedFile>): Promise<MarkdownWriteBackResult> {
const plannedByFile = new Map<string, PlannedCard[]>();
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;
}

View file

@ -17,11 +17,11 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => {
const content = [
"#### Prompt",
"Answer",
"<!-- AHS:card=ahs_1 note=41 -->",
"<!--ID: 41-->",
"",
"#### Prompt 2",
"Answer 2",
"<!-- AHS:card=ahs_2 -->",
"<!--ID: 42-->",
].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: "<!-- AHS:card=ahs_1 note=41 -->",
targetMarker: "<!--ID: 41-->",
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",
"<!-- AHS:card=ahs_local -->",
].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("<!--ID: 9001-->");
expect(vaultGateway.getFileContent(filePath)).toContain("<!--ID: 9002-->");
});
it("returns an empty result when the file has no tracked synced cards", async () => {
@ -177,7 +131,7 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => {
const content = [
"#### Prompt",
"Answer",
"<!-- AHS:card=ahs_conflict note=42 -->",
"<!--ID: 42-->",
].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: "<!-- AHS:card=ahs_conflict note=42 -->",
targetMarker: "<!--ID: 42-->",
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("<!--ID: 42-->");
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("<!--ID: 9001-->");
expect(vaultGateway.getFileContent(filePath)).not.toContain("<!--ID: 42-->");
});
});
@ -244,10 +197,12 @@ function createStoredSyncedCard(settings: PluginSettings, overrides: Partial<Car
const bodyMarkdown = overrides.bodyMarkdown ?? "Answer";
const rawBlockText = overrides.rawBlockText ?? [`#### ${heading}`, bodyMarkdown].join("\n");
const rawBlockHash = overrides.rawBlockHash ?? hashString(rawBlockText);
const noteId = overrides.noteId ?? 42;
const indexedCard = {
cardId: overrides.cardId ?? "ahs_known",
noteId: overrides.noteId,
markerNoteId: overrides.noteId,
noteId,
syncKey: `${overrides.filePath ?? "notes/example.md"}\u0000${overrides.blockStartLine ?? 1}\u0000${rawBlockHash}`,
idMarkerState: "present-valid" as const,
noteIdSource: "marker" as const,
filePath: overrides.filePath ?? "notes/example.md",
cardType: overrides.cardType ?? "basic",
heading,
@ -266,12 +221,10 @@ function createStoredSyncedCard(settings: PluginSettings, overrides: Partial<Car
deckHintSource: overrides.deckHintSource,
deckWarnings: overrides.deckWarnings ?? [],
tagsHint: overrides.tagsHint ?? [],
markerState: overrides.noteId ? "card-and-note" as const : "card-only" as const,
};
const renderPlan = new RenderConfigService().resolve(indexedCard, settings);
return {
cardId: indexedCard.cardId,
noteId: indexedCard.noteId,
filePath: indexedCard.filePath,
heading: indexedCard.heading,

View file

@ -2,7 +2,7 @@ import type { AnkiGateway } from "@/application/ports/AnkiGateway";
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
import { MarkdownMarkerRemovalService } from "@/application/services/MarkdownMarkerRemovalService";
import type { CardState, PluginState } from "@/domain/manual-sync/entities/PluginState";
import { toNoteIdKey, type CardState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
import type { ClearCurrentFileSyncedCardsResult } from "./cleanupResetTypes";
@ -41,17 +41,17 @@ export class ClearCurrentFileSyncedCardsUseCase {
const markerRemovalResult = await this.markdownMarkerRemovalService.remove(
filePath,
trackedCards.map((card) => ({ 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<string>();
const trackedNoteKeys = new Set<string>();
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))),
};
}

View file

@ -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";

View file

@ -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<DeckResolutionSource, "frontmatter" | "body">;
deckWarnings: DeckResolutionWarning[];
tagsHint: string[];
markerState: MarkerState;
sourceContent?: string;
}
export function createIndexedCardSyncKey(filePath: string, blockStartLine: number, rawBlockHash: string): string {
return `${filePath}\u0000${blockStartLine}\u0000${rawBlockHash}`;
}

View file

@ -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}`;
}

View file

@ -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", "<!-- AHS:card=ahs_known -->"].join("\n"),
content: ["#### Prompt", "Answer", "<!--ID: 42-->"].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", "<!-- AHS:card=ahs_known note=42 -->"].join("\n"),
content: ["#### Prompt", "Answer", "<!--ID: invalid-->"].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", "<!--ID: 42-->", "Answer"].join("\n"),
},
{
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
fileStamp: "1:1",
knownCards: [],
pendingWriteBack: [],
},
);
expect(() =>
service.index(
{
path: "notes/example.md",
basename: "example",
content: ["#### Prompt", "<!-- AHS:card=ahs_1 -->", "Answer", "<!-- AHS:card=ahs_2 -->"].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: "<!--ID: 42-->\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> = {}): 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,

View file

@ -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<string>();
const pendingByBlockKey = groupPendingWriteBackByBlockKey(context.pendingWriteBack);
const usedNoteIds = new Set<number>();
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<string, CardState>,
knownCardsByBlockKey: Map<string, CardState[]>,
pendingByCardId: Map<string, PendingWriteBackState>,
usedCardIds: Set<string>,
): { 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<string, PendingWriteBackState[]>,
usedNoteIds: Set<number>,
): { 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<string, CardSta
return grouped;
}
function groupPendingWriteBackByBlockKey(pendingWriteBack: PendingWriteBackState[]): Map<string, PendingWriteBackState[]> {
const grouped = new Map<string, PendingWriteBackState[]>();
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<typeof marker> => 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",
};
}

View file

@ -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;
}

View file

@ -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("<!-- AHS:card=ahs_123 note=42 -->", 3)).toEqual({
cardId: "ahs_123",
expect(service.parse("<!--ID: 42-->", 3)).toEqual({
noteId: 42,
raw: "<!-- AHS:card=ahs_123 note=42 -->",
raw: "<!--ID: 42-->",
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",
"<!-- AHS:card=ahs_top note=11 -->",
"<!--ID: 11-->",
"",
"#### Bottom",
"Body 2",
"<!-- AHS:card=ahs_bottom note=22 -->",
"<!--ID: 22-->",
].join("\n"));
});
});

View file

@ -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*AHS:/;
const AHS_MARKER_REGEXP = /^\s*<!--\s*AHS:card=([A-Za-z0-9_-]+)(?:\s+note=([1-9]\d*))?\s*-->\s*$/;
const ID_MARKER_CANDIDATE_REGEXP = /<!--\s*ID:/;
const ID_MARKER_REGEXP = /^\s*<!--\s*ID:\s*([1-9]\d*)\s*-->\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 ? `<!-- AHS:card=${cardId} note=${noteId} -->` : `<!-- AHS:card=${cardId} -->`,
raw: `<!--ID: ${noteId}-->`,
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);

View file

@ -76,9 +76,10 @@ describe("DeckResolutionService", () => {
function createIndexedCard(overrides: Partial<IndexedCard> = {}): 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> = {}): IndexedCard {
deckHintSource: overrides.deckHintSource,
deckWarnings: overrides.deckWarnings ?? [],
tagsHint: overrides.tagsHint ?? [],
markerState: overrides.markerState ?? "missing",
sourceContent: overrides.sourceContent,
};
}

View file

@ -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: "<!-- AHS:card=ahs_1 note=42 -->",
targetMarker: "<!--ID: 42-->",
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);
});
});
});
function createIndexedCard(overrides: Partial<IndexedCard> = {}): 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<CardState> & Pick<CardState, "noteId">): 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,
};
}

View file

@ -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<string, IndexedCard>();
const pendingByCardId = new Map(state.pendingWriteBack.map((pending) => [pending.cardId, pending]));
const cardsBySyncKey = new Map<string, IndexedCard>();
const pendingByBlockKey = new Set(state.pendingWriteBack.map((pending) => createPendingWriteBackKey(pending.filePath, pending.blockStartLine, pending.rawBlockHash)));
const seenNoteKeys = new Set<string>();
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,

View file

@ -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: "<!-- AHS:card=ahs_1 note=41 -->",
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([]);
});
});

View file

@ -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<FileState> {
cardIds?: string[];
}
interface LegacyCardState extends Partial<CardState> {
cardId?: string;
noteId?: number;
}
interface LegacyPendingWriteBackState extends Partial<PendingWriteBackState> {
cardId?: string;
noteId?: number;
}
interface LegacyPluginState {
files?: Record<string, LegacyFileState>;
cards?: Record<string, LegacyCardState>;
pendingWriteBack?: LegacyPendingWriteBackState[];
}
export class DataJsonPluginStateRepository implements PluginStateRepository {
constructor(private readonly pluginDataStore: PluginDataStore<PluginDataSnapshot>) {}
async load(): Promise<PluginState> {
const snapshot = (await this.pluginDataStore.load()) ?? {};
return snapshot.pluginState ?? createEmptyPluginState();
return migratePluginState(snapshot.pluginState);
}
async save(state: PluginState): Promise<void> {
@ -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<string, CardState> = {};
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<string, FileState> = {};
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<string, LegacyCardState>,
migratedCards: Record<string, CardState>,
): number[] {
const noteIds = new Set<number>();
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;
}