mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
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:
parent
d4d73487a0
commit
e2f973dc46
30 changed files with 1744 additions and 649 deletions
130
docs/compatibility-final-recommendation.md
Normal file
130
docs/compatibility-final-recommendation.md
Normal 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`
|
||||
|
||||
## 迁移必须满足的验收门槛
|
||||
|
||||
### 门槛 1:dry-run 必须先给出审计结果
|
||||
|
||||
至少要列出:
|
||||
|
||||
- 可迁移块数量
|
||||
- 缺失 noteId 的块数量
|
||||
- noteId 在 Anki 中不存在的块数量
|
||||
- 一对多 / 多对一冲突数量
|
||||
|
||||
### 门槛 2:迁移后 Markdown 中不能再残留 legacy marker
|
||||
|
||||
理想结果是:
|
||||
|
||||
- 一个 block 只有一个 marker
|
||||
- 该 marker 一定是 AHS marker
|
||||
|
||||
### 门槛 3:迁移后 `pluginState` 必须成为唯一真相源
|
||||
|
||||
迁移完成后,后续接管不应再依赖 `syncRegistry` 或 tag 搜索。
|
||||
|
||||
### 门槛 4:首次接管验证必须做到“零重复建卡”
|
||||
|
||||
对未修改内容的迁移样本,迁移后的第一次 normal sync 应满足:
|
||||
|
||||
- `created = 0`
|
||||
- 不新增重复 note
|
||||
- marker 与 state 不再漂移
|
||||
|
||||
### 门槛 5:旧显式 deck 不得被错误改写
|
||||
|
||||
像 `[[anki背诵]]::[[《城市居住区规划设计标准》图解2021年]]` 这样的 deck 字面量,在迁移后必须继续解析为同一 deck。
|
||||
|
||||
## 关于字段是否立即归一
|
||||
|
||||
这是迁移后的策略位,不影响本次最终分类。
|
||||
|
||||
### 保守模式
|
||||
|
||||
- 先只迁 identity
|
||||
- 不立即触发字段重渲染
|
||||
- 优先保证 review history 与 note continuity
|
||||
|
||||
### 归一模式
|
||||
|
||||
- 迁移后主动触发一次字段更新
|
||||
- 把旧 front/back HTML 统一到当前 AHS 渲染风格
|
||||
- 代价是会发生一次批量 note update
|
||||
|
||||
无论选哪种,前提都相同:先迁 identity,再谈后续同步。
|
||||
|
||||
## 何时应局部降级为“只能新建”
|
||||
|
||||
以下 block 不应强行接管,应当单独降级为第 3 类:
|
||||
|
||||
- legacy marker 缺失
|
||||
- noteId 非法
|
||||
- noteId 对应的 live note 不存在
|
||||
- live note 的 note model 与当前映射不兼容
|
||||
- 一个 noteId 被多个 block 竞争认领
|
||||
|
||||
## 一句话建议
|
||||
|
||||
把“旧卡兼容”定义成一次确定性的身份迁移问题,而不是长期同时维护两套 marker 协议的问题。
|
||||
|
||||
在当前证据下,最稳的结论不是“直接支持旧卡”,也不是“放弃旧卡”,而是:
|
||||
|
||||
**先迁移写回,再交给 current manual-sync 正常维护。**
|
||||
190
docs/compatibility-identity-analysis.md
Normal file
190
docs/compatibility-identity-analysis.md
Normal 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 卡来处理。
|
||||
|
||||
## 迁移时必须守住的身份不变量
|
||||
|
||||
### 不变量 1:hash 必须基于“剥除 legacy marker 后”的 block 内容计算
|
||||
|
||||
否则迁移写回之后,未来扫描得到的 `rawBlockHash` 会和迁移时落盘的 `rawBlockHash` 不同,造成后续误判。
|
||||
|
||||
### 不变量 2:`pluginState` 必须与 Markdown 写回同时成立
|
||||
|
||||
只改 marker 不建状态,下一次主链仍然可能失配。
|
||||
|
||||
只建状态不改 marker,则 Markdown 侧没有 `cardId`,主链也无法稳定命中。
|
||||
|
||||
### 不变量 3:不能依赖 `syncRegistry`
|
||||
|
||||
当前 manual-sync 不消费 `syncRegistry`,所以迁移后的真相必须落在 `pluginState`,而不是旧 registry。
|
||||
|
||||
### 不变量 4:缺失 noteId 的 block 必须降级处理
|
||||
|
||||
只要某个旧 block 没有可验证的 live noteId,它就不再属于“可接管”的那一类,而应降级为“只能新建导入”。
|
||||
|
||||
## 字段是否要在迁移后立即归一
|
||||
|
||||
这不是 identity 阻塞点,但它是一个策略位。
|
||||
|
||||
### 方案 A:只迁 marker 和状态,不立即重渲染旧 note 字段
|
||||
|
||||
- 优点:最保守,review history 不受干扰,旧卡外观暂时保持原样
|
||||
- 缺点:旧卡会在一段时间内继续保留旧式 front/back HTML
|
||||
|
||||
### 方案 B:迁移完成后强制一次重渲染
|
||||
|
||||
- 优点:旧卡立即归一到当前 AHS 的 `Open in Obsidian` 渲染风格
|
||||
- 缺点:会产生一次大批量字段更新
|
||||
|
||||
无论选 A 还是 B,都不改变主结论:接管是可行的,但必须先迁移 identity。
|
||||
|
||||
## 身份分析结论
|
||||
|
||||
当前主链对旧卡的失败方式是确定性的,不是概率性的:
|
||||
|
||||
- 认不出旧 marker
|
||||
- 建不出旧 identity
|
||||
- 进入 `toCreate`
|
||||
- 产出重复卡
|
||||
|
||||
但同样,迁移后的成功路径也是确定性的,不是模糊匹配式的:
|
||||
|
||||
- 旧 Markdown 提供 noteId
|
||||
- live Anki 验证 noteId 真实存在
|
||||
- 当前状态模型能容纳迁移结果
|
||||
|
||||
所以最终判断应是:
|
||||
|
||||
不是“不能接管”,而是“不能直接接管,只能先迁移后接管”。
|
||||
57
docs/compatibility-matrix.md
Normal file
57
docs/compatibility-matrix.md
Normal 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 进入主链的入口,旧卡与当前主链是不兼容的。
|
||||
|
||||
所以真正的系统级判断不是“不能接管”,而是“不能直接接管,但可以通过一次显式迁移接管”。
|
||||
51
docs/compatibility-sample-table.md
Normal file
51
docs/compatibility-sample-table.md
Normal 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. 因此旧卡“无法接管”的原因不是数据不够,而是当前主链没有把旧身份格式翻译进新身份模型。
|
||||
127
docs/compatibility-strategy-comparison.md
Normal file
127
docs/compatibility-strategy-comparison.md
Normal 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 协议”。
|
||||
99
docs/compatibility-touchpoints-report.md
Normal file
99
docs/compatibility-touchpoints-report.md
Normal 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 语义
|
||||
149
docs/id-noteid-mainline-decisions.md
Normal file
149
docs/id-noteid-mainline-decisions.md
Normal 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
|
||||
|
||||
这些都超出本轮范围。
|
||||
262
docs/id-noteid-mainline-gap-report.md
Normal file
262
docs/id-noteid-mainline-gap-report.md
Normal 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
|
||||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
};
|
||||
}
|
||||
9
src/domain/manual-sync/entities/IdMarker.ts
Normal file
9
src/domain/manual-sync/entities/IdMarker.ts
Normal 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";
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue