mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
feat: add #anki-list QA Group 12 route / 新增 #anki-list QA Group 12 路线
Add a parallel QA Group 12 sync path with GI writeback, fixed model management, and GroupId recovery semantics. 新增并行 QA Group 12 同步路径,支持 GI 写回、固定模型管理,以及基于 GroupId 的恢复语义。
This commit is contained in:
parent
b1b2051502
commit
4845c721d5
30 changed files with 3121 additions and 93 deletions
191
docs/qa-group-12-decisions.md
Normal file
191
docs/qa-group-12-decisions.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# QA Group 12 Decisions
|
||||
|
||||
## Decision Summary
|
||||
|
||||
基于真实仓库结构,本轮锁定如下实现决策:
|
||||
|
||||
1. 保留现有 atomic/basic、cloze、semantic-qa 路线不变。
|
||||
2. 新增一条并行的 QA Group 路线,使用单独 marker:`qaGroupMarker`。
|
||||
3. 新路线不强塞进 `IndexedCard` / `DiffPlannerService` / `AnkiBatchExecutor` 的单卡单 note 主链。
|
||||
4. group route 使用专用 block model、state、write-back 和 sync executor。
|
||||
5. `GroupId` 是 GI 丢失恢复的主锚点;`Src` 只做辅锚点。
|
||||
6. v1 固定目标 model:`ObsiAnki QA Group 12`。
|
||||
7. v1 不做 group model 的 settings field mapping UI。
|
||||
8. v1 的 `A` 只取第一条二级列表项文本。
|
||||
|
||||
## Concrete Decisions
|
||||
|
||||
### 1. New setting name and validation
|
||||
|
||||
新增设置:
|
||||
|
||||
- `qaGroupMarker`
|
||||
|
||||
默认值:
|
||||
|
||||
- `#anki-list`
|
||||
|
||||
校验规则:
|
||||
|
||||
- 必须符合与 semantic marker 同级的 hashtag token 规则
|
||||
- 不允许等于 `semanticQaMarker`
|
||||
|
||||
实现上复用现有 hashtag-style validation helper,而不是创建第二套正则协议。
|
||||
|
||||
### 2. Routing priority
|
||||
|
||||
QA heading 路由顺序固定为:
|
||||
|
||||
1. 命中 `qaGroupMarker` -> QA Group route
|
||||
2. 命中 `semanticQaMarker` -> existing semantic-qa route
|
||||
3. 其他命中 QA level 的 heading -> existing basic route
|
||||
|
||||
这样能确保两个 marker 可并存且互不吞噬。
|
||||
|
||||
### 3. Group route data model stays parallel
|
||||
|
||||
新增并行模型,不复用 `IndexedCard` 作为 group block 的核心表示。仓库中应存在这些概念:
|
||||
|
||||
- group marker representation
|
||||
- group item representation
|
||||
- indexed group block representation
|
||||
- group note payload builder
|
||||
- recovered group state
|
||||
|
||||
原因:
|
||||
|
||||
- 当前 `IndexedCard` 强绑定单 `noteId`
|
||||
- 当前 `PluginState.cards` 强绑定 noteId-keyed atomic state
|
||||
- Group 12 需要在一条 note 内维护 slot identity
|
||||
|
||||
### 4. Parser rules for v1
|
||||
|
||||
v1 固定规则:
|
||||
|
||||
- `Stem` = heading 去 marker 后的文本
|
||||
- `Q` = 一级列表项文本
|
||||
- `A` = 第一条二级列表项文本
|
||||
- deeper nested content 不进入 `A`
|
||||
- fenced code blocks 不能被误判成列表
|
||||
- 块尾允许存在一条 `GI`
|
||||
|
||||
无效一级项定义:
|
||||
|
||||
- 没有二级列表项
|
||||
|
||||
处理策略:
|
||||
|
||||
- 直接跳过,不生成 group item
|
||||
|
||||
### 5. GI protocol is explicit and strict
|
||||
|
||||
采用目标格式:
|
||||
|
||||
```md
|
||||
<!--GI:n=999;i=a1:1,b2:2,c3:3;f=-->
|
||||
```
|
||||
|
||||
语义锁定:
|
||||
|
||||
- `n` = noteId
|
||||
- `i` = itemId -> slot
|
||||
- `f` = free slots
|
||||
|
||||
附加规则:
|
||||
|
||||
- GI 固定写在 group block 尾部
|
||||
- group write-back 只保留一条 GI
|
||||
- 命中 group route 的块会清理内部旧 `<!--ID: ...-->`
|
||||
|
||||
### 6. Item identity strategy
|
||||
|
||||
首次进入组时,插件为每个 item 分配短 token `itemId`。
|
||||
|
||||
本轮采用:
|
||||
|
||||
- 轻量 deterministic short token generator
|
||||
- 一旦分配并写入 GI/state,后续重排不改 `itemId`
|
||||
|
||||
slot 规则:
|
||||
|
||||
- 首次 `1..N`
|
||||
- 删除中间项时进入 `freeSlots`
|
||||
- 新增项优先复用最小 free slot
|
||||
- 重排不改 slot
|
||||
- 超过 12 项直接阻止同步
|
||||
|
||||
### 7. Recovery order
|
||||
|
||||
GI 丢失恢复顺序固定:
|
||||
|
||||
1. 先查本地 group state by `GroupId`
|
||||
2. 若本地无充分信息,再查询 Anki 中同 model note 的 `GroupId`
|
||||
3. `Src` 仅用于辅助缩小范围和校验唯一性
|
||||
4. 唯一命中可重建 GI
|
||||
5. 多命中返回 warning/error,不自动认领
|
||||
6. 未命中则走首次创建
|
||||
|
||||
### 8. Model management strategy
|
||||
|
||||
`AnkiGateway` 扩展 group model 管理接口,至少支持:
|
||||
|
||||
- ensure model exists
|
||||
- inspect fields
|
||||
- inspect templates
|
||||
- create model
|
||||
- add fields
|
||||
- add templates
|
||||
- update templates
|
||||
- update styling
|
||||
- query note ids for recovery
|
||||
- read note fields for recovery
|
||||
|
||||
但这些能力只为 group route 服务,不反向改造 basic/cloze/semantic-qa 的 note field mapping 工作流。
|
||||
|
||||
### 9. Execution integration point
|
||||
|
||||
group route 的执行入口放在 `ManualSyncService` 中,与现有 atomic sync 并列。
|
||||
|
||||
原因:
|
||||
|
||||
- `ManualSyncService` 已持有 index / render / execute / write-back / save state 的总编排能力
|
||||
- 在这里并联 group 流最容易共享 deck / vault / repository / gateway 能力
|
||||
- 也最容易保证旧路线不受影响
|
||||
|
||||
### 10. Settings UI scope control
|
||||
|
||||
只新增最小设置项:
|
||||
|
||||
- `QA Group marker`
|
||||
|
||||
本轮不新增:
|
||||
|
||||
- group model selector
|
||||
- group field mapping panel
|
||||
- group preview complex panel
|
||||
|
||||
原因:
|
||||
|
||||
- v1 模型固定
|
||||
- 字段协议固定
|
||||
- 复杂 UI 会扩大 scope 而不增加正确性
|
||||
|
||||
## Intentional Deviations From The Reference Plan
|
||||
|
||||
### 1. 不把 group route 接进现有 `DiffPlannerService`
|
||||
|
||||
这是对参考方案的结构性调整。
|
||||
|
||||
原因:当前仓库的 planner/executor/state 都是单卡单 note 语义;强行复用会显著增加回归风险。
|
||||
|
||||
### 2. parser 层不引入大而全 warning 总线
|
||||
|
||||
原因:当前仓库没有统一 parser warnings 模型。本轮只在需要阻止同步的地方返回结构化错误,其余无效项直接跳过。
|
||||
|
||||
### 3. `A` 只取第一条二级列表项纯文本/markdown 内容
|
||||
|
||||
原因:这是最符合当前仓库复杂度预算的 v1 规则,也更容易保证 slot 稳定与恢复逻辑清晰。
|
||||
|
||||
### 4. 不做 group field mapping UI
|
||||
|
||||
原因:目标 model 固定,直接生成完整 field payload 更适合当前代码结构。
|
||||
239
docs/qa-group-12-gap-report.md
Normal file
239
docs/qa-group-12-gap-report.md
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
# QA Group 12 Gap Report
|
||||
|
||||
## Scope
|
||||
|
||||
本报告基于真实仓库现状,审查当前 manual-sync 主链与“`#anki-list` QA Group 12 并行路线”之间的差距。
|
||||
|
||||
重点检查:
|
||||
|
||||
- `CardIndexingService`
|
||||
- `SemanticQaListParser`
|
||||
- `DiffPlannerService`
|
||||
- `AnkiBatchExecutor`
|
||||
- `MarkdownWriteBackService`
|
||||
- `PluginState`
|
||||
- `AnkiConnectGateway`
|
||||
- settings / validation / settings UI
|
||||
|
||||
## Current Reality
|
||||
|
||||
### 1. 当前主链仍然是单卡 `IndexedCard` 主线
|
||||
|
||||
真实主链为:
|
||||
|
||||
- `FileIndexerService`
|
||||
- `CardIndexingService`
|
||||
- `DiffPlannerService`
|
||||
- `ManualCardRenderer`
|
||||
- `AnkiBatchExecutor`
|
||||
- `MarkdownWriteBackService`
|
||||
- `PluginState`
|
||||
|
||||
这条链的核心假设是:
|
||||
|
||||
- 一个索引单元对应一个 `IndexedCard`
|
||||
- 一个 `IndexedCard` 对应一个 `noteId`
|
||||
- `PluginState.cards` 以 `noteId` 为 key
|
||||
- `DiffPlannerService` 以 `noteId` 和 `renderConfigHash` 做单 note diff
|
||||
- `AnkiBatchExecutor` 的 create/update 流程也是一条 card -> 一条 note
|
||||
|
||||
这意味着 QA Group 12 不能无损塞进现有 `IndexedCard` / `CardState` / `ManualSyncPlan`,否则会把 group block 强行伪装成“单题单 note 的普通卡”。
|
||||
|
||||
### 2. 现有 `semantic-qa` 只是单卡主线上的多卡拆分
|
||||
|
||||
当前 `semantic-qa` 的真实实现是:
|
||||
|
||||
- `CardIndexingService` 命中 `semanticQaMarker`
|
||||
- `SemanticQaListParser` 把一个标题块拆成多个 `SemanticQaListCard`
|
||||
- 每个子项都转成独立 `IndexedCard`
|
||||
- 每个子项独立持有 `noteId`
|
||||
- 每个子项独立写 `<!--ID: noteId-->`
|
||||
|
||||
所以它不是 group note,它仍属于 atomic 路线。
|
||||
|
||||
### 3. 当前 write-back 只理解 `<!--ID: noteId-->`
|
||||
|
||||
当前 marker 写回链路:
|
||||
|
||||
- `CardMarkerService` 只支持 `<!--ID: ...-->`
|
||||
- `MarkdownWriteBackService` 只会调用 `CardMarkerService.applyBatch()`
|
||||
- `PendingWriteBackState` 只存单卡 marker 的 block key / rawBlockHash / targetNoteId
|
||||
|
||||
没有:
|
||||
|
||||
- `GI` 协议
|
||||
- 组级 marker 解析/序列化
|
||||
- group block 的旧 inner marker 清理
|
||||
- group block 的 repair 逻辑
|
||||
|
||||
### 4. 当前 state 只有 noteId-keyed atomic card state
|
||||
|
||||
`PluginState` 当前只有:
|
||||
|
||||
- `files`
|
||||
- `cards`
|
||||
- `pendingWriteBack`
|
||||
|
||||
其中:
|
||||
|
||||
- `cards` 只适合单卡记录
|
||||
- key 是 `noteId`
|
||||
- 没有 `GroupId`
|
||||
- 没有 `itemId -> slot`
|
||||
- 没有 group-level recovery state
|
||||
|
||||
### 5. 当前 Anki gateway 只具备基础 note/deck 能力
|
||||
|
||||
`AnkiGateway` / `AnkiConnectGateway` 当前支持:
|
||||
|
||||
- deck ensure/list/stats
|
||||
- model name list
|
||||
- model field names + templates 只在 `getModelDetails()` 内部做只读判断
|
||||
- notesInfo/cardsInfo
|
||||
- add/update/delete note
|
||||
- change deck
|
||||
- media upload
|
||||
|
||||
缺失本需求需要的能力:
|
||||
|
||||
- create model
|
||||
- add fields
|
||||
- add templates
|
||||
- update templates
|
||||
- update styling
|
||||
- 按查询语句查 notes 以支持 recovery
|
||||
- 获取 note fields 以恢复 slot 映射
|
||||
|
||||
### 6. 当前 settings 只对 semantic QA 有最小配置支持
|
||||
|
||||
当前 settings 已新增:
|
||||
|
||||
- `semanticQaMarker`
|
||||
- `semanticQaNoteType`
|
||||
|
||||
settings UI 中也已有:
|
||||
|
||||
- `Semantic QA marker`
|
||||
- semantic QA preview
|
||||
- semantic QA mapping controls
|
||||
|
||||
但 QA Group 12 需要的最小新增项还不存在:
|
||||
|
||||
- `qaGroupMarker`
|
||||
- 与 semantic marker 的冲突校验
|
||||
|
||||
### 7. 当前 field mapping UI 与 group route 不匹配
|
||||
|
||||
当前 `NoteFieldMappingService` 和 settings mapping UI 面向:
|
||||
|
||||
- `basic`
|
||||
- `cloze`
|
||||
- `semantic-qa`
|
||||
|
||||
这些路线都属于“插件生成 rendered title/body,再映射到 note fields”。
|
||||
|
||||
QA Group 12 的目标是固定模型协议:
|
||||
|
||||
- `Stem`
|
||||
- `GroupId`
|
||||
- `Src`
|
||||
- `S01..S12`
|
||||
|
||||
这更适合 dedicated payload builder,而不是重用可配置 field mapping UI。
|
||||
|
||||
## What Fits The Reference Plan Well
|
||||
|
||||
以下点与真实仓库吻合:
|
||||
|
||||
1. 新路线应保持并行,不要破坏现有 semantic-qa。
|
||||
2. 新路线应使用独立 marker。
|
||||
3. `GroupId` 必须成为恢复主锚点。
|
||||
4. `GI` 需要独立协议,而不是复用现有 `<!--ID: ...-->`。
|
||||
5. Group 12 不适合走现有 note field mapping UI。
|
||||
6. 需要补 Anki model 管理与 note 查询能力。
|
||||
|
||||
## What Needs Adjustment To Match This Repository
|
||||
|
||||
### 1. 不能把 group route 硬塞进 `DiffPlannerService`
|
||||
|
||||
参考方案强调并行类型,这一点在当前仓库里不是“可选优化”,而是必须条件。
|
||||
|
||||
原因:
|
||||
|
||||
- `DiffPlannerService` 的 `ManualSyncPlan` 全部是 `PlannedCard`
|
||||
- `PlannedCard` 内核是 `IndexedCard + noteId`
|
||||
- group route 一条 block 对应一条 note,但内部又有 1..12 个 slot 和 item-level identity
|
||||
|
||||
因此更合适的接法是:
|
||||
|
||||
- atomic 路线继续走现有 `IndexedCard -> DiffPlannerService -> AnkiBatchExecutor`
|
||||
- group 路线在 `CardIndexingService` 之后并联出单独的 group block collection
|
||||
- `ManualSyncService` 再显式执行一条 group-specific sync 分支
|
||||
|
||||
### 2. 当前 parser 层没有 warning 容器
|
||||
|
||||
参考方案里大量提到“skip 或 warning”。
|
||||
|
||||
当前 `CardIndexingService` / `SemanticQaListParser` 没有结构化 warning 输出面。若强行扩大会影响现有大量签名。
|
||||
|
||||
更适配当前仓库的做法是:
|
||||
|
||||
- parser 层以 deterministic parse result 为主
|
||||
- 明确无效项直接跳过
|
||||
- 对影响同步正确性的情况抛结构化 error
|
||||
- warning 只在必要的 service 结果层补充
|
||||
|
||||
### 3. GI 丢失恢复不能只靠当前 `PluginState`
|
||||
|
||||
因为当前 state 只按 `noteId` 记 atomic card,group 需要新 state 分支,例如:
|
||||
|
||||
- `groupCards` 或等价 group state map
|
||||
- key 为 `GroupId`
|
||||
|
||||
否则无法实现“先查本地 `GroupId`,再查 Anki”的恢复顺序。
|
||||
|
||||
### 4. v1 里的 `A` 规则必须简化为仓库可控实现
|
||||
|
||||
当前 semantic parser 对 deeper content 的处理是“整段 child region dedent 后作为 answer”。
|
||||
|
||||
但本需求明确希望 QA Group 12 v1 只取第一条二级列表项文本。为避免把 group route 复杂化成另一个 semantic parser 变体,应固定:
|
||||
|
||||
- 只取第一条二级列表项
|
||||
- 更深内容不进入 `A`
|
||||
- 若存在多个二级项,保留第一条,其他项忽略
|
||||
|
||||
这样最符合当前仓库的最小安全实现风格。
|
||||
|
||||
## Missing Pieces To Build
|
||||
|
||||
1. 新 settings 字段与校验:`qaGroupMarker`
|
||||
2. 新 group domain model:marker/item/block/state/payload
|
||||
3. 新 parser / serializer:GI + QA Group block
|
||||
4. 新 group write-back service
|
||||
5. 新 group state persistence
|
||||
6. 新 Anki gateway model management / note query / field recovery 能力
|
||||
7. 新 ManualSyncService group 分支
|
||||
8. 新测试夹具与端到端回归
|
||||
|
||||
## Scope Drift Risks
|
||||
|
||||
以下内容不应在本轮扩张:
|
||||
|
||||
1. 动态 arity group model
|
||||
2. group model field mapping UI
|
||||
3. 自动执行 `Empty Cards`
|
||||
4. 旧 semantic-qa 到 group route 的自动迁移
|
||||
5. 复杂的多级答案结构合并
|
||||
6. 重构整个 `ManualSyncPlan`
|
||||
|
||||
## Recommended Integration Path
|
||||
|
||||
最适合当前仓库的路径是:
|
||||
|
||||
1. 保留现有 atomic/basic/cloze/semantic-qa 全链路不动。
|
||||
2. 在 `CardIndexingService` 内部新增 group route 识别,但 group 结果不转成 `IndexedCard`。
|
||||
3. `FileIndexerService` 返回 atomic cards 之外,再返回 group block 集合。
|
||||
4. `ManualSyncService` 在现有 atomic sync 前后增加显式 group sync 分支。
|
||||
5. group route 拥有自己的 state、payload builder、write-back、recovery 和 gateway helper。
|
||||
|
||||
这条路径最符合当前仓库现实,也最容易保证旧路线不回归。
|
||||
127
docs/qa-group-12-implementation-plan.md
Normal file
127
docs/qa-group-12-implementation-plan.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# QA Group 12 Implementation Plan
|
||||
|
||||
## Status
|
||||
|
||||
本文件用于把本轮需求参考方案落盘到仓库中。它是强参考,不是自动生效的最终实现真相。
|
||||
|
||||
最终实现仍需以真实代码审查结果为准,并以:
|
||||
|
||||
- `docs/qa-group-12-gap-report.md`
|
||||
- `docs/qa-group-12-decisions.md`
|
||||
|
||||
作为仓库内的实际实施依据。
|
||||
|
||||
## Feature Intent
|
||||
|
||||
新增一条并行的 `#anki-list` QA Group 12 路线,同时保留现有:
|
||||
|
||||
- atomic / basic
|
||||
- cloze
|
||||
- semantic-qa / `#anki-list-qa`
|
||||
|
||||
不动。
|
||||
|
||||
目标行为:
|
||||
|
||||
1. 一个命中的 Markdown heading block 同步为一条 Anki note。
|
||||
2. 该 note 固定使用 `ObsiAnki QA Group 12`。
|
||||
3. Markdown 只写回一条组级 `GI` marker。
|
||||
4. `GroupId` 是 GI 丢失恢复的主锚点。
|
||||
5. v1 固定 12 个 slot,不做动态 arity model。
|
||||
|
||||
## Reference Summary
|
||||
|
||||
### Marker and routing
|
||||
|
||||
- 新增 `qaGroupMarker`
|
||||
- 默认 `#anki-list`
|
||||
- 必须是 hashtag token
|
||||
- 不允许与 `semanticQaMarker` 相同
|
||||
- 路由优先级:
|
||||
- 命中新 `qaGroupMarker` -> group route
|
||||
- 命中旧 `semanticQaMarker` -> semantic-qa route
|
||||
- 其他 QA 标题 -> 普通 basic route
|
||||
|
||||
### Parallel internal model
|
||||
|
||||
参考方案要求新路线使用并行数据模型,不强行塞进现有单卡结构。核心概念包括:
|
||||
|
||||
- `GroupMarker`
|
||||
- `GroupItem`
|
||||
- `IndexedGroupCardBlock`
|
||||
- `GroupSyncPayload`
|
||||
- `RecoveredGroupState`
|
||||
|
||||
### Parsing protocol
|
||||
|
||||
触发条件:
|
||||
|
||||
- 标题级别等于当前 QA heading level
|
||||
- 标题命中 `qaGroupMarker`
|
||||
- 标题下存在一级列表
|
||||
- 一级项必须至少有一条二级项才算有效
|
||||
- 块尾允许存在一条 `GI`
|
||||
|
||||
v1 取值规则:
|
||||
|
||||
- `Stem` = 标题去 marker
|
||||
- `Q` = 一级列表项文本
|
||||
- `A` = 第一条二级列表项文本
|
||||
|
||||
GI 目标格式:
|
||||
|
||||
```md
|
||||
<!--GI:n=999;i=a1:1,b2:2,c3:3;f=-->
|
||||
```
|
||||
|
||||
语义:
|
||||
|
||||
- `n` = noteId
|
||||
- `i` = itemId -> slot
|
||||
- `f` = free slots
|
||||
|
||||
### Slot and recovery semantics
|
||||
|
||||
- 首次创建按顺序分配 `1..N`
|
||||
- 删除中间项保留空洞
|
||||
- 新增项优先复用最小 free slot
|
||||
- 重排不改变 slot
|
||||
- 超过 12 项阻止同步
|
||||
- GI 丢失恢复以 `GroupId` 为主锚点,`Src` 为辅锚点
|
||||
|
||||
### Anki model management
|
||||
|
||||
参考方案要求补齐 group model 管理能力:
|
||||
|
||||
- ensure model exists
|
||||
- inspect fields/templates
|
||||
- create model if missing
|
||||
- add missing fields/templates
|
||||
- reconcile template/CSS drift
|
||||
- query notes for recovery
|
||||
|
||||
固定目标 model:
|
||||
|
||||
- `ObsiAnki QA Group 12`
|
||||
|
||||
### Write-back and state
|
||||
|
||||
- group block 只保留一条 `GI`
|
||||
- 命中 group route 的块写回时清理内部旧 `<!--ID: ...-->`
|
||||
- state 需要新增以 `GroupId` 为主键的 group state
|
||||
|
||||
### Required validation surface
|
||||
|
||||
最少覆盖:
|
||||
|
||||
- GI 解析与序列化
|
||||
- slot 分配与稳定性
|
||||
- GI 写回与旧 inner marker 清理
|
||||
- GroupId 恢复
|
||||
- model 自动创建/补齐/漂移修复
|
||||
- 端到端 sibling cards 生成
|
||||
- 原有 basic/cloze/semantic-qa 不回归
|
||||
|
||||
## Implementation Note
|
||||
|
||||
本文件只保留参考方案的需求意图和约束摘要,避免后续实现过程中偏离用户 feature contract。
|
||||
|
|
@ -32,6 +32,23 @@ describe("PluginSettings", () => {
|
|||
expect(DEFAULT_SETTINGS.fileDeckTemplate).toBe("obsidian::filename");
|
||||
expect(DEFAULT_SETTINGS.fileDeckInsertLocation).toBe("body");
|
||||
expect(DEFAULT_SETTINGS.folderDeckMode).toBe("off");
|
||||
expect(DEFAULT_SETTINGS.qaGroupMarker).toBe("#anki-list");
|
||||
});
|
||||
|
||||
it("rejects invalid QA Group markers and semantic marker collisions", () => {
|
||||
expect(() =>
|
||||
validatePluginSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
qaGroupMarker: "anki-list",
|
||||
}),
|
||||
).toThrow("QA Group marker must be a hashtag-style token like #anki-list.");
|
||||
|
||||
expect(() =>
|
||||
validatePluginSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
qaGroupMarker: "#anki-list-qa",
|
||||
}),
|
||||
).toThrow("QA Group marker must be different from Semantic QA marker.");
|
||||
});
|
||||
|
||||
it("rejects invalid module 5 enum values", () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export type FolderDeckMode = "off" | "folder" | "folder-and-file";
|
|||
export interface PluginSettings {
|
||||
qaHeadingLevel: number;
|
||||
clozeHeadingLevel: number;
|
||||
qaGroupMarker: string;
|
||||
qaNoteType: string;
|
||||
clozeNoteType: string;
|
||||
semanticQaMarker: string;
|
||||
|
|
@ -29,6 +30,7 @@ export interface PluginSettings {
|
|||
export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
qaHeadingLevel: 4,
|
||||
clozeHeadingLevel: 5,
|
||||
qaGroupMarker: "#anki-list",
|
||||
qaNoteType: "Basic",
|
||||
clozeNoteType: "Cloze",
|
||||
semanticQaMarker: "#anki-list-qa",
|
||||
|
|
@ -50,10 +52,14 @@ export const DEFAULT_SETTINGS: PluginSettings = {
|
|||
|
||||
export const SEMANTIC_QA_MARKER_REGEXP = /^#[^\s#]+$/;
|
||||
|
||||
export function isValidSemanticQaMarker(marker: string): boolean {
|
||||
export function isValidHashtagMarker(marker: string): boolean {
|
||||
return SEMANTIC_QA_MARKER_REGEXP.test(marker);
|
||||
}
|
||||
|
||||
export function isValidSemanticQaMarker(marker: string): boolean {
|
||||
return isValidHashtagMarker(marker);
|
||||
}
|
||||
|
||||
export function validatePluginSettings(settings: PluginSettings): void {
|
||||
const headingLevels = [settings.qaHeadingLevel, settings.clozeHeadingLevel];
|
||||
|
||||
|
|
@ -71,6 +77,14 @@ export function validatePluginSettings(settings: PluginSettings): void {
|
|||
throw new Error("QA note type is required.");
|
||||
}
|
||||
|
||||
if (!settings.qaGroupMarker.trim()) {
|
||||
throw new Error("QA Group marker is required.");
|
||||
}
|
||||
|
||||
if (!isValidHashtagMarker(settings.qaGroupMarker.trim())) {
|
||||
throw new Error("QA Group marker must be a hashtag-style token like #anki-list.");
|
||||
}
|
||||
|
||||
if (!settings.clozeNoteType.trim()) {
|
||||
throw new Error("Cloze note type is required.");
|
||||
}
|
||||
|
|
@ -83,6 +97,10 @@ export function validatePluginSettings(settings: PluginSettings): void {
|
|||
throw new Error("Semantic QA marker must be a hashtag-style token like #anki-list-qa.");
|
||||
}
|
||||
|
||||
if (settings.qaGroupMarker.trim() === settings.semanticQaMarker.trim()) {
|
||||
throw new Error("QA Group marker must be different from Semantic QA marker.");
|
||||
}
|
||||
|
||||
if (!settings.semanticQaNoteType.trim()) {
|
||||
throw new Error("Semantic QA note type is required.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,24 @@ export interface DeckStat {
|
|||
noteCount?: number;
|
||||
}
|
||||
|
||||
export interface AnkiModelTemplate {
|
||||
name: string;
|
||||
front: string;
|
||||
back: string;
|
||||
}
|
||||
|
||||
export interface CreateAnkiModelInput {
|
||||
modelName: string;
|
||||
fieldNames: string[];
|
||||
templates: AnkiModelTemplate[];
|
||||
css: string;
|
||||
isCloze?: boolean;
|
||||
}
|
||||
|
||||
export interface AnkiNoteDetails extends AnkiNoteSummary {
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AnkiGateway {
|
||||
ensureDeckExists(deckName: string): Promise<void>;
|
||||
ensureDecks(deckNames: string[]): Promise<void>;
|
||||
|
|
@ -48,4 +66,17 @@ export interface AnkiGateway {
|
|||
deleteDecks(deckNames: string[]): Promise<void>;
|
||||
storeMedia(asset: MediaAsset): Promise<void>;
|
||||
storeMediaFiles(assets: MediaAsset[]): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AnkiGroupGateway extends AnkiGateway {
|
||||
getModelFieldNames(modelName: string): Promise<string[]>;
|
||||
getModelTemplates(modelName: string): Promise<Record<string, AnkiModelTemplate>>;
|
||||
getModelStyling(modelName: string): Promise<string>;
|
||||
createModel(input: CreateAnkiModelInput): Promise<void>;
|
||||
addModelField(modelName: string, fieldName: string): Promise<void>;
|
||||
addModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void>;
|
||||
updateModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void>;
|
||||
updateModelStyling(modelName: string, css: string): Promise<void>;
|
||||
findNoteIds(query: string): Promise<number[]>;
|
||||
getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]>;
|
||||
}
|
||||
|
|
@ -2,8 +2,9 @@ import type { PluginSettings } from "@/application/config/PluginSettings";
|
|||
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
|
||||
import { ScanScopeService } from "@/application/services/ScanScopeService";
|
||||
import { createIndexedCardSyncKey, type IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
|
||||
import { buildGroupSrc, createIndexedGroupSyncKey, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile";
|
||||
import { toNoteIdKey, type CardState, type PendingWriteBackState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { toNoteIdKey, type CardState, type GroupBlockState, type PendingWriteBackState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { CardIndexingService } from "@/domain/manual-sync/services/CardIndexingService";
|
||||
import { hashString } from "@/domain/shared/hash";
|
||||
|
||||
|
|
@ -12,12 +13,14 @@ export interface FileIndexerResult {
|
|||
scannedFiles: number;
|
||||
indexedFiles: IndexedFile[];
|
||||
cards: IndexedCard[];
|
||||
groupBlocks: IndexedGroupCardBlock[];
|
||||
skippedUnchangedFiles: number;
|
||||
skippedUnchangedCards: number;
|
||||
}
|
||||
|
||||
interface StateIndex {
|
||||
cardsByFilePath: Map<string, CardState[]>;
|
||||
groupBlocksByFilePath: Map<string, GroupBlockState[]>;
|
||||
pendingByFilePath: Map<string, PendingWriteBackState[]>;
|
||||
}
|
||||
|
||||
|
|
@ -52,9 +55,11 @@ export class FileIndexerService {
|
|||
const indexedFile = this.cardIndexingService.index(sourceFile, {
|
||||
qaHeadingLevel: settings.qaHeadingLevel,
|
||||
clozeHeadingLevel: settings.clozeHeadingLevel,
|
||||
qaGroupMarker: settings.qaGroupMarker,
|
||||
semanticQaMarker: settings.semanticQaMarker,
|
||||
fileStamp,
|
||||
knownCards: stateIndex.cardsByFilePath.get(filePath) ?? [],
|
||||
knownGroupBlocks: stateIndex.groupBlocksByFilePath.get(filePath) ?? [],
|
||||
pendingWriteBack: stateIndex.pendingByFilePath.get(filePath) ?? [],
|
||||
fileDeckEnabled: settings.fileDeckEnabled,
|
||||
fileDeckMarker: settings.fileDeckMarker,
|
||||
|
|
@ -65,6 +70,7 @@ export class FileIndexerService {
|
|||
scannedFiles: 1,
|
||||
indexedFiles: [indexedFile],
|
||||
cards: indexedFile.cards,
|
||||
groupBlocks: indexedFile.groupBlocks ?? [],
|
||||
skippedUnchangedFiles: 0,
|
||||
skippedUnchangedCards: 0,
|
||||
};
|
||||
|
|
@ -79,6 +85,7 @@ export class FileIndexerService {
|
|||
): Promise<FileIndexerResult> {
|
||||
const indexedFiles: IndexedFile[] = [];
|
||||
const cards: IndexedCard[] = [];
|
||||
const groupBlocks: IndexedGroupCardBlock[] = [];
|
||||
let skippedUnchangedFiles = 0;
|
||||
let skippedUnchangedCards = 0;
|
||||
const deckRulesFingerprint = createDeckRulesFingerprint(settings);
|
||||
|
|
@ -100,7 +107,11 @@ export class FileIndexerService {
|
|||
|
||||
if (!shouldRead) {
|
||||
skippedUnchangedFiles += 1;
|
||||
skippedUnchangedCards += (existingFileState?.noteIds ?? []).length;
|
||||
const restoredGroups = (existingFileState?.groupIds ?? [])
|
||||
.map((groupId) => state.groupBlocks?.[groupId])
|
||||
.filter((groupBlock): groupBlock is GroupBlockState => Boolean(groupBlock))
|
||||
.map((groupBlock) => restoreIndexedGroupBlock(groupBlock));
|
||||
skippedUnchangedCards += (existingFileState?.noteIds ?? []).length + restoredGroups.length;
|
||||
const restoredCards = (existingFileState?.noteIds ?? [])
|
||||
.map((noteId) => state.cards[toNoteIdKey(noteId)])
|
||||
.filter((card): card is CardState => Boolean(card))
|
||||
|
|
@ -111,8 +122,10 @@ export class FileIndexerService {
|
|||
fileHash: existingFileState?.fileHash ?? "",
|
||||
fileStamp,
|
||||
cards: restoredCards,
|
||||
groupBlocks: restoredGroups,
|
||||
});
|
||||
cards.push(...restoredCards);
|
||||
groupBlocks.push(...restoredGroups);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -124,9 +137,11 @@ export class FileIndexerService {
|
|||
const indexedFile = this.cardIndexingService.index(sourceFile, {
|
||||
qaHeadingLevel: settings.qaHeadingLevel,
|
||||
clozeHeadingLevel: settings.clozeHeadingLevel,
|
||||
qaGroupMarker: settings.qaGroupMarker,
|
||||
semanticQaMarker: settings.semanticQaMarker,
|
||||
fileStamp,
|
||||
knownCards,
|
||||
knownGroupBlocks: stateIndex.groupBlocksByFilePath.get(ref.path) ?? [],
|
||||
pendingWriteBack,
|
||||
fileDeckEnabled: settings.fileDeckEnabled,
|
||||
fileDeckMarker: settings.fileDeckMarker,
|
||||
|
|
@ -134,6 +149,7 @@ export class FileIndexerService {
|
|||
|
||||
indexedFiles.push(indexedFile);
|
||||
cards.push(...indexedFile.cards);
|
||||
groupBlocks.push(...(indexedFile.groupBlocks ?? []));
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -141,6 +157,7 @@ export class FileIndexerService {
|
|||
scannedFiles: refs.length,
|
||||
indexedFiles,
|
||||
cards,
|
||||
groupBlocks,
|
||||
skippedUnchangedFiles,
|
||||
skippedUnchangedCards,
|
||||
};
|
||||
|
|
@ -148,6 +165,7 @@ export class FileIndexerService {
|
|||
|
||||
private buildStateIndex(state: PluginState): StateIndex {
|
||||
const cardsByFilePath = new Map<string, CardState[]>();
|
||||
const groupBlocksByFilePath = new Map<string, GroupBlockState[]>();
|
||||
const pendingByFilePath = new Map<string, PendingWriteBackState[]>();
|
||||
|
||||
for (const card of Object.values(state.cards)) {
|
||||
|
|
@ -160,6 +178,16 @@ export class FileIndexerService {
|
|||
cardsByFilePath.set(card.filePath, [card]);
|
||||
}
|
||||
|
||||
for (const groupBlock of Object.values(state.groupBlocks ?? {})) {
|
||||
const groupBlocks = groupBlocksByFilePath.get(groupBlock.filePath);
|
||||
if (groupBlocks) {
|
||||
groupBlocks.push(groupBlock);
|
||||
continue;
|
||||
}
|
||||
|
||||
groupBlocksByFilePath.set(groupBlock.filePath, [groupBlock]);
|
||||
}
|
||||
|
||||
for (const pending of state.pendingWriteBack) {
|
||||
const pendings = pendingByFilePath.get(pending.filePath);
|
||||
if (pendings) {
|
||||
|
|
@ -172,6 +200,7 @@ export class FileIndexerService {
|
|||
|
||||
return {
|
||||
cardsByFilePath,
|
||||
groupBlocksByFilePath,
|
||||
pendingByFilePath,
|
||||
};
|
||||
}
|
||||
|
|
@ -205,6 +234,37 @@ function restoreIndexedCard(card: CardState): IndexedCard {
|
|||
};
|
||||
}
|
||||
|
||||
function restoreIndexedGroupBlock(groupBlock: GroupBlockState): IndexedGroupCardBlock {
|
||||
return {
|
||||
noteId: groupBlock.noteId,
|
||||
groupId: groupBlock.groupId,
|
||||
syncKey: createIndexedGroupSyncKey(groupBlock.filePath, groupBlock.blockStartLine, groupBlock.rawBlockHash),
|
||||
markerState: "present-valid",
|
||||
identitySource: "state-recovery",
|
||||
filePath: groupBlock.filePath,
|
||||
headingText: groupBlock.headingText,
|
||||
backlinkHeadingText: groupBlock.backlinkHeadingText,
|
||||
headingLevel: groupBlock.headingLevel,
|
||||
stem: groupBlock.stem,
|
||||
src: groupBlock.src || buildGroupSrc(groupBlock.filePath, groupBlock.backlinkHeadingText),
|
||||
blockStartOffset: groupBlock.blockStartOffset,
|
||||
blockEndOffset: groupBlock.blockEndOffset,
|
||||
blockStartLine: groupBlock.blockStartLine,
|
||||
bodyStartLine: groupBlock.bodyStartLine,
|
||||
blockEndLine: groupBlock.blockEndLine,
|
||||
contentEndLine: groupBlock.contentEndLine,
|
||||
markerLine: groupBlock.markerLine,
|
||||
markerIndent: groupBlock.markerIndent,
|
||||
rawBlockText: groupBlock.rawBlockText,
|
||||
rawBlockHash: groupBlock.rawBlockHash,
|
||||
deckHint: groupBlock.deckHint,
|
||||
deckHintSource: groupBlock.deckHintSource,
|
||||
deckWarnings: [...groupBlock.deckWarnings],
|
||||
items: groupBlock.items.map((item) => ({ ...item })),
|
||||
freeSlots: [...groupBlock.freeSlots],
|
||||
};
|
||||
}
|
||||
|
||||
export function createFileStamp(mtime: number, size: number): string {
|
||||
return `${mtime}:${size}`;
|
||||
}
|
||||
|
|
@ -216,6 +276,7 @@ export function createDeckRulesFingerprint(settings: PluginSettings): string {
|
|||
version: DECK_RULES_FINGERPRINT_VERSION,
|
||||
qaHeadingLevel: settings.qaHeadingLevel,
|
||||
clozeHeadingLevel: settings.clozeHeadingLevel,
|
||||
qaGroupMarker: settings.qaGroupMarker,
|
||||
semanticQaMarker: settings.semanticQaMarker,
|
||||
defaultDeck: settings.defaultDeck,
|
||||
fileDeckEnabled: settings.fileDeckEnabled,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { QA_GROUP_MODEL_NAME } from "@/application/services/QaGroupModelDefinition";
|
||||
import type { PluginSettings } from "@/application/config/PluginSettings";
|
||||
import { createDeckRulesFingerprint } from "@/application/services/FileIndexerService";
|
||||
import { RenderConfigService } from "@/application/services/RenderConfigService";
|
||||
|
|
@ -49,6 +50,38 @@ describe("ManualSyncService", () => {
|
|||
expect(stateRepository.savedState?.pendingWriteBack[0]).toMatchObject({ filePath: "notes/example.md", targetNoteId: 9001 });
|
||||
});
|
||||
|
||||
it("syncs a #anki-list block into one QA Group 12 note and writes one GI marker", async () => {
|
||||
const vaultGateway = new FakeManualSyncVaultGateway({
|
||||
"notes/example.md": [
|
||||
"#### Concepts #anki-list",
|
||||
"- Alpha",
|
||||
" - First answer",
|
||||
"- Beta",
|
||||
" - Second answer",
|
||||
].join("\n"),
|
||||
});
|
||||
const stateRepository = new InMemoryPluginStateRepository();
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, () => 1234);
|
||||
|
||||
const result = await service.syncFile("notes/example.md", createModule3Settings());
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
expect(result.rewrittenMarkers).toBe(1);
|
||||
expect(ankiGateway.createdModels[0]?.modelName).toBe(QA_GROUP_MODEL_NAME);
|
||||
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_MODEL_NAME);
|
||||
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
|
||||
Stem: "Concepts",
|
||||
S01_Q: "Alpha",
|
||||
S01_A: "First answer",
|
||||
S02_Q: "Beta",
|
||||
S02_A: "Second answer",
|
||||
});
|
||||
expect(vaultGateway.getFileContent("notes/example.md")).toMatch(/<!--GI:n=9001;i=[^;]+;f=3,4,5,6,7,8,9,10,11,12-->/);
|
||||
expect(Object.values(stateRepository.savedState?.groupBlocks ?? {})).toHaveLength(1);
|
||||
expect(stateRepository.savedState?.files["notes/example.md"]?.groupIds).toHaveLength(1);
|
||||
});
|
||||
|
||||
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"),
|
||||
|
|
|
|||
|
|
@ -1,19 +1,24 @@
|
|||
import type { PluginSettings } from "@/application/config/PluginSettings";
|
||||
import { validatePluginSettings } from "@/application/config/PluginSettings";
|
||||
import type { AnkiGateway } from "@/application/ports/AnkiGateway";
|
||||
import type { AnkiGateway, AnkiGroupGateway } from "@/application/ports/AnkiGateway";
|
||||
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
|
||||
import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
|
||||
import { AnkiBatchExecutor } from "@/application/services/AnkiBatchExecutor";
|
||||
import { FileIndexerService } from "@/application/services/FileIndexerService";
|
||||
import { createDeckRulesFingerprint } from "@/application/services/FileIndexerService";
|
||||
import { MarkdownWriteBackService } from "@/application/services/MarkdownWriteBackService";
|
||||
import { QaGroupSyncService } from "@/application/services/QaGroupSyncService";
|
||||
import { RenderConfigService } from "@/application/services/RenderConfigService";
|
||||
import { ScanScopeService } from "@/application/services/ScanScopeService";
|
||||
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
|
||||
import { toNoteIdKey, type CardState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import type { IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
import { toNoteIdKey, type CardState, type GroupBlockState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard";
|
||||
import { GroupMarkerService, type GroupMarkerWriteRequest } from "@/domain/manual-sync/services/GroupMarkerService";
|
||||
import { DiffPlannerService } from "@/domain/manual-sync/services/DiffPlannerService";
|
||||
import { ManualCardRenderer, type ManualCardRenderContext } from "@/domain/manual-sync/services/ManualCardRenderer";
|
||||
import type { DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
|
||||
import { getDeckResolutionWarningKey } from "@/domain/manual-sync/value-objects/DeckResolution";
|
||||
|
||||
import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
|
||||
|
||||
|
|
@ -26,6 +31,10 @@ export class CurrentFileOutOfScopeError extends Error {
|
|||
|
||||
export class ManualSyncService {
|
||||
private readonly scanScopeService = new ScanScopeService();
|
||||
private readonly qaGroupSyncService: QaGroupSyncService;
|
||||
private readonly groupMarkerService: GroupMarkerService;
|
||||
private readonly renderConfigService: RenderConfigService;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly vaultGateway: ManualSyncVaultGateway,
|
||||
|
|
@ -36,9 +45,21 @@ export class ManualSyncService {
|
|||
private readonly renderer = new ManualCardRenderer(),
|
||||
private readonly ankiBatchExecutor = new AnkiBatchExecutor(ankiGateway),
|
||||
private readonly markdownWriteBackService = new MarkdownWriteBackService(vaultGateway),
|
||||
private readonly renderConfigService = new RenderConfigService(),
|
||||
private readonly now: () => number = () => Date.now(),
|
||||
) {}
|
||||
renderConfigServiceOrNow: RenderConfigService | (() => number) = new RenderConfigService(),
|
||||
now: () => number = () => Date.now(),
|
||||
) {
|
||||
this.qaGroupSyncService = new QaGroupSyncService(ankiGateway as AnkiGroupGateway);
|
||||
this.groupMarkerService = new GroupMarkerService();
|
||||
|
||||
if (isRenderConfigService(renderConfigServiceOrNow)) {
|
||||
this.renderConfigService = renderConfigServiceOrNow;
|
||||
this.now = now;
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderConfigService = new RenderConfigService();
|
||||
this.now = renderConfigServiceOrNow;
|
||||
}
|
||||
|
||||
async syncVault(settings: PluginSettings): Promise<ManualSyncResult> {
|
||||
validatePluginSettings(settings);
|
||||
|
|
@ -65,21 +86,26 @@ export class ManualSyncService {
|
|||
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.filter((plannedCard) => plannedCard.noteId !== undefined);
|
||||
const rebuildGroupResult = this.buildReindexedGroupStates(indexResult.groupBlocks, state);
|
||||
|
||||
const writeBackResult = await this.markdownWriteBackService.write(markerWrites, indexedFilesByPath);
|
||||
const writeBackResult = await this.markdownWriteBackService.write(markerWrites, indexedFilesByPath, rebuildGroupResult.markerWrites);
|
||||
const orphanGroupBlocks = collectOrphanGroupBlocks(state, indexResult.scopedFilePaths, rebuildGroupResult.syncedGroupBlocks);
|
||||
const nextState = this.buildNextState(
|
||||
state,
|
||||
indexResult.cards,
|
||||
indexResult.indexedFiles,
|
||||
rebuildGroupResult.syncedGroupBlocks,
|
||||
settings,
|
||||
new Set(writeBackResult.writtenSyncKeys),
|
||||
new Map(),
|
||||
plan.toOrphan,
|
||||
orphanGroupBlocks,
|
||||
);
|
||||
nextState.pendingWriteBack = [
|
||||
...state.pendingWriteBack.filter((pending) => !new Set(indexResult.scopedFilePaths).has(pending.filePath)),
|
||||
...writeBackResult.pendingEntries,
|
||||
];
|
||||
this.markFilesDirty(nextState, [...writeBackResult.failureFiles.map((failure) => failure.filePath), ...writeBackResult.conflictFiles]);
|
||||
await this.pluginStateRepository.save(nextState);
|
||||
|
||||
if (writeBackResult.failureFiles.length > 0) {
|
||||
|
|
@ -88,16 +114,16 @@ export class ManualSyncService {
|
|||
|
||||
return {
|
||||
scannedFiles: indexResult.scannedFiles,
|
||||
scannedCards: indexResult.cards.length,
|
||||
scannedCards: indexResult.cards.length + indexResult.groupBlocks.length,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
migratedDecks: 0,
|
||||
orphaned: plan.toOrphan.length,
|
||||
orphaned: plan.toOrphan.length + orphanGroupBlocks.length,
|
||||
uploadedMedia: 0,
|
||||
skippedUnchangedCards: indexResult.skippedUnchangedCards,
|
||||
rewrittenMarkers: writeBackResult.writtenSyncKeys.length,
|
||||
markerWriteConflictFiles: writeBackResult.conflictFiles,
|
||||
warnings: plan.warnings,
|
||||
warnings: mergeDeckWarnings(plan.warnings, rebuildGroupResult.warnings),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -126,24 +152,29 @@ export class ManualSyncService {
|
|||
async (plannedCard) => this.renderer.render(plannedCard, renderContext),
|
||||
settings.noteFieldMappings,
|
||||
);
|
||||
const qaGroupExecution = await this.qaGroupSyncService.sync(indexResult.groupBlocks, state, settings);
|
||||
|
||||
const indexedFilesByPath = new Map(indexResult.indexedFiles.map((file) => [file.filePath, file]));
|
||||
const writeBackResult = await this.markdownWriteBackService.write(executionResult.markerWrites, indexedFilesByPath);
|
||||
const writeBackResult = await this.markdownWriteBackService.write(executionResult.markerWrites, indexedFilesByPath, qaGroupExecution.markerWrites);
|
||||
const orphanGroupBlocks = collectOrphanGroupBlocks(state, indexResult.scopedFilePaths, qaGroupExecution.syncedGroupBlocks);
|
||||
|
||||
const nextState = this.buildNextState(
|
||||
state,
|
||||
indexResult.cards,
|
||||
indexResult.indexedFiles,
|
||||
qaGroupExecution.syncedGroupBlocks,
|
||||
settings,
|
||||
new Set([...executionResult.touchedSyncKeys, ...writeBackResult.writtenSyncKeys]),
|
||||
new Set([...executionResult.touchedSyncKeys, ...qaGroupExecution.touchedSyncKeys, ...writeBackResult.writtenSyncKeys]),
|
||||
executionResult.resolvedNoteIds,
|
||||
plan.toOrphan,
|
||||
orphanGroupBlocks,
|
||||
);
|
||||
|
||||
nextState.pendingWriteBack = [
|
||||
...state.pendingWriteBack.filter((pending) => !new Set(indexResult.scopedFilePaths).has(pending.filePath)),
|
||||
...writeBackResult.pendingEntries,
|
||||
];
|
||||
this.markFilesDirty(nextState, [...writeBackResult.failureFiles.map((failure) => failure.filePath), ...writeBackResult.conflictFiles]);
|
||||
|
||||
await this.pluginStateRepository.save(nextState);
|
||||
|
||||
|
|
@ -153,34 +184,46 @@ export class ManualSyncService {
|
|||
|
||||
return {
|
||||
scannedFiles: indexResult.scannedFiles,
|
||||
scannedCards: indexResult.cards.length,
|
||||
created: executionResult.created,
|
||||
updated: executionResult.updated,
|
||||
migratedDecks: executionResult.migratedDecks,
|
||||
orphaned: plan.toOrphan.length,
|
||||
scannedCards: indexResult.cards.length + indexResult.groupBlocks.length,
|
||||
created: executionResult.created + qaGroupExecution.created,
|
||||
updated: executionResult.updated + qaGroupExecution.updated,
|
||||
migratedDecks: executionResult.migratedDecks + qaGroupExecution.migratedDecks,
|
||||
orphaned: plan.toOrphan.length + orphanGroupBlocks.length,
|
||||
uploadedMedia: executionResult.uploadedMedia,
|
||||
skippedUnchangedCards: indexResult.skippedUnchangedCards,
|
||||
rewrittenMarkers: writeBackResult.writtenSyncKeys.length,
|
||||
markerWriteConflictFiles: writeBackResult.conflictFiles,
|
||||
warnings: plan.warnings,
|
||||
warnings: mergeDeckWarnings(plan.warnings, qaGroupExecution.warnings),
|
||||
};
|
||||
}
|
||||
|
||||
private buildNextState(
|
||||
previousState: PluginState,
|
||||
cards: IndexedCard[],
|
||||
indexedFiles: Array<{ filePath: string; fileHash: string; fileStamp: string; content?: string; cards: IndexedCard[] }>,
|
||||
indexedFiles: Array<{ filePath: string; fileHash: string; fileStamp: string; content?: string; cards: IndexedCard[]; groupBlocks?: IndexedGroupCardBlock[] }>,
|
||||
syncedGroupBlocks: GroupBlockState[],
|
||||
settings: PluginSettings,
|
||||
touchedSyncKeys: Set<string>,
|
||||
resolvedNoteIds: Map<string, number | undefined>,
|
||||
orphanCards: CardState[],
|
||||
orphanGroupBlocks: GroupBlockState[],
|
||||
): PluginState {
|
||||
const now = this.now();
|
||||
const deckRulesFingerprint = createDeckRulesFingerprint(settings);
|
||||
const scopedFilePaths = new Set(indexedFiles.filter((indexedFile) => indexedFile.content !== undefined).map((indexedFile) => indexedFile.filePath));
|
||||
const groupBlocksByFilePath = new Map<string, GroupBlockState[]>();
|
||||
for (const groupBlock of syncedGroupBlocks) {
|
||||
const entries = groupBlocksByFilePath.get(groupBlock.filePath);
|
||||
if (entries) {
|
||||
entries.push(groupBlock);
|
||||
} else {
|
||||
groupBlocksByFilePath.set(groupBlock.filePath, [groupBlock]);
|
||||
}
|
||||
}
|
||||
const nextState: PluginState = {
|
||||
files: { ...previousState.files },
|
||||
cards: { ...previousState.cards },
|
||||
groupBlocks: { ...(previousState.groupBlocks ?? {}) },
|
||||
pendingWriteBack: [...previousState.pendingWriteBack],
|
||||
};
|
||||
|
||||
|
|
@ -194,6 +237,12 @@ export class ManualSyncService {
|
|||
}
|
||||
}
|
||||
|
||||
for (const [groupId, groupBlockState] of Object.entries(nextState.groupBlocks ?? {})) {
|
||||
if (scopedFilePaths.has(groupBlockState.filePath)) {
|
||||
delete nextState.groupBlocks?.[groupId];
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedNoteIdsBySyncKey = new Map<string, number>();
|
||||
|
||||
for (const card of cards) {
|
||||
|
|
@ -221,6 +270,7 @@ export class ManualSyncService {
|
|||
deckRulesFingerprint,
|
||||
lastIndexedAt: now,
|
||||
noteIds,
|
||||
groupIds: (groupBlocksByFilePath.get(indexedFile.filePath) ?? []).map((groupBlock) => groupBlock.groupId),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -268,10 +318,172 @@ export class ManualSyncService {
|
|||
};
|
||||
}
|
||||
|
||||
for (const groupBlock of syncedGroupBlocks) {
|
||||
nextState.groupBlocks ??= {};
|
||||
nextState.groupBlocks[groupBlock.groupId] = groupBlock;
|
||||
}
|
||||
|
||||
for (const orphanGroupBlock of orphanGroupBlocks) {
|
||||
nextState.groupBlocks ??= {};
|
||||
nextState.groupBlocks[orphanGroupBlock.groupId] = {
|
||||
...orphanGroupBlock,
|
||||
orphan: true,
|
||||
};
|
||||
}
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
private buildReindexedGroupStates(
|
||||
groupBlocks: IndexedGroupCardBlock[],
|
||||
state: PluginState,
|
||||
): { syncedGroupBlocks: GroupBlockState[]; markerWrites: GroupMarkerWriteRequest[]; warnings: DeckResolutionWarning[] } {
|
||||
const syncedGroupBlocks: GroupBlockState[] = [];
|
||||
const markerWrites: GroupMarkerWriteRequest[] = [];
|
||||
const warnings: DeckResolutionWarning[] = [];
|
||||
const byNoteId = new Map<number, GroupBlockState>(Object.values(state.groupBlocks ?? {})
|
||||
.filter((groupBlock) => !groupBlock.orphan)
|
||||
.map((groupBlock) => [groupBlock.noteId, groupBlock]));
|
||||
const bySrc = new Map<string, GroupBlockState[]>();
|
||||
|
||||
for (const groupBlock of Object.values(state.groupBlocks ?? {}).filter((entry) => !entry.orphan)) {
|
||||
const entries = bySrc.get(groupBlock.src);
|
||||
if (entries) {
|
||||
entries.push(groupBlock);
|
||||
} else {
|
||||
bySrc.set(groupBlock.src, [groupBlock]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const block of groupBlocks) {
|
||||
const existingState = (block.groupId ? state.groupBlocks?.[block.groupId] : undefined)
|
||||
?? (block.noteId !== undefined ? byNoteId.get(block.noteId) : undefined)
|
||||
?? uniqueGroupStateMatch(bySrc.get(block.src));
|
||||
if (!existingState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemToSlot = Object.fromEntries(existingState.items
|
||||
.filter((item) => item.itemId && item.slot)
|
||||
.map((item) => [item.itemId as string, item.slot as number]));
|
||||
|
||||
syncedGroupBlocks.push({
|
||||
...existingState,
|
||||
filePath: block.filePath,
|
||||
headingText: block.headingText,
|
||||
backlinkHeadingText: block.backlinkHeadingText,
|
||||
headingLevel: block.headingLevel,
|
||||
stem: block.stem,
|
||||
src: block.src,
|
||||
blockStartOffset: block.blockStartOffset,
|
||||
blockEndOffset: block.blockEndOffset,
|
||||
blockStartLine: block.blockStartLine,
|
||||
bodyStartLine: block.bodyStartLine,
|
||||
blockEndLine: block.blockEndLine,
|
||||
contentEndLine: block.contentEndLine,
|
||||
markerLine: block.markerLine,
|
||||
markerIndent: block.markerIndent,
|
||||
rawBlockText: block.rawBlockText,
|
||||
rawBlockHash: block.rawBlockHash,
|
||||
deckHint: block.deckHint,
|
||||
deckHintSource: block.deckHintSource,
|
||||
deckWarnings: [...block.deckWarnings],
|
||||
orphan: false,
|
||||
});
|
||||
|
||||
warnings.push(...block.deckWarnings);
|
||||
|
||||
if (
|
||||
block.sourceContent
|
||||
&& (
|
||||
block.markerState !== "present-valid"
|
||||
|| !this.groupMarkerService.isEquivalent(block.groupMarker, existingState.noteId, itemToSlot, existingState.freeSlots)
|
||||
|| hasLegacyInnerIdMarkers(block)
|
||||
)
|
||||
) {
|
||||
markerWrites.push({
|
||||
syncKey: block.syncKey,
|
||||
filePath: block.filePath,
|
||||
blockStartLine: block.blockStartLine,
|
||||
blockEndLine: block.blockEndLine,
|
||||
markerLine: block.markerLine,
|
||||
markerIndent: block.markerIndent,
|
||||
noteId: existingState.noteId,
|
||||
groupId: existingState.groupId,
|
||||
itemToSlot,
|
||||
freeSlots: [...existingState.freeSlots],
|
||||
sourceContent: block.sourceContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
syncedGroupBlocks,
|
||||
markerWrites,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private markFilesDirty(nextState: PluginState, filePaths: string[]): void {
|
||||
for (const filePath of new Set(filePaths)) {
|
||||
if (nextState.files[filePath]) {
|
||||
nextState.files[filePath] = {
|
||||
...nextState.files[filePath],
|
||||
fileHash: "",
|
||||
fileStamp: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createWriteBackFailureMessage(failures: Array<{ filePath: string; message: string }>): string {
|
||||
return `Markdown marker write-back failed for ${failures.length} file(s).\n${failures.map((failure) => `${failure.filePath}: ${failure.message}`).join("\n")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function isRenderConfigService(value: RenderConfigService | (() => number)): value is RenderConfigService {
|
||||
return typeof value === "object" && value !== null && "resolve" in value;
|
||||
}
|
||||
|
||||
function collectOrphanGroupBlocks(previousState: PluginState, scopedFilePaths: string[], syncedGroupBlocks: GroupBlockState[]): GroupBlockState[] {
|
||||
const scopedPaths = new Set(scopedFilePaths);
|
||||
const seenGroupIds = new Set(syncedGroupBlocks.map((groupBlock) => groupBlock.groupId));
|
||||
|
||||
return Object.values(previousState.groupBlocks ?? {})
|
||||
.filter((groupBlock) => scopedPaths.has(groupBlock.filePath) && !seenGroupIds.has(groupBlock.groupId))
|
||||
.map((groupBlock) => ({ ...groupBlock, orphan: true }));
|
||||
}
|
||||
|
||||
function mergeDeckWarnings(...warningGroups: DeckResolutionWarning[][]): DeckResolutionWarning[] {
|
||||
const warningMap = new Map<string, DeckResolutionWarning>();
|
||||
for (const warningGroup of warningGroups) {
|
||||
for (const warning of warningGroup) {
|
||||
warningMap.set(getDeckResolutionWarningKey(warning), warning);
|
||||
}
|
||||
}
|
||||
|
||||
return [...warningMap.values()];
|
||||
}
|
||||
|
||||
function uniqueGroupStateMatch(groupBlocks: GroupBlockState[] | undefined): GroupBlockState | undefined {
|
||||
if (!groupBlocks || groupBlocks.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return groupBlocks[0];
|
||||
}
|
||||
|
||||
function hasLegacyInnerIdMarkers(block: IndexedGroupCardBlock): boolean {
|
||||
if (!block.sourceContent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lines = block.sourceContent.split(/\r?\n/);
|
||||
for (let lineIndex = block.blockStartLine; lineIndex < block.blockEndLine; lineIndex += 1) {
|
||||
if (/^\s*<!--\s*ID:/i.test(lines[lineIndex] ?? "")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile";
|
|||
import type { PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import type { PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan";
|
||||
import { CardMarkerService, type MarkerWriteRequest } from "@/domain/manual-sync/services/CardMarkerService";
|
||||
import { GroupMarkerService, type GroupMarkerWriteRequest, serializeGroupMarker } from "@/domain/manual-sync/services/GroupMarkerService";
|
||||
|
||||
export interface MarkdownWriteBackResult {
|
||||
writtenSyncKeys: string[];
|
||||
|
|
@ -16,10 +17,15 @@ export class MarkdownWriteBackService {
|
|||
constructor(
|
||||
private readonly vaultGateway: ManualSyncVaultGateway,
|
||||
private readonly markerService = new CardMarkerService(),
|
||||
private readonly groupMarkerService = new GroupMarkerService(),
|
||||
) {}
|
||||
|
||||
async write(plannedCards: PlannedCard[], indexedFilesByPath: Map<string, IndexedFile>): Promise<MarkdownWriteBackResult> {
|
||||
const plannedByFile = new Map<string, PlannedCard[]>();
|
||||
async write(
|
||||
plannedCards: PlannedCard[],
|
||||
indexedFilesByPath: Map<string, IndexedFile>,
|
||||
groupWrites: GroupMarkerWriteRequest[] = [],
|
||||
): Promise<MarkdownWriteBackResult> {
|
||||
const plannedByFile = new Map<string, Array<{ kind: "card"; plannedCard: PlannedCard } | { kind: "group"; write: GroupMarkerWriteRequest }>>();
|
||||
const writtenSyncKeys: string[] = [];
|
||||
const conflictFiles: string[] = [];
|
||||
const failureFiles: Array<{ filePath: string; message: string }> = [];
|
||||
|
|
@ -28,33 +34,61 @@ export class MarkdownWriteBackService {
|
|||
for (const plannedCard of plannedCards) {
|
||||
const entries = plannedByFile.get(plannedCard.card.filePath);
|
||||
if (entries) {
|
||||
entries.push(plannedCard);
|
||||
entries.push({ kind: "card", plannedCard });
|
||||
continue;
|
||||
}
|
||||
|
||||
plannedByFile.set(plannedCard.card.filePath, [plannedCard]);
|
||||
plannedByFile.set(plannedCard.card.filePath, [{ kind: "card", plannedCard }]);
|
||||
}
|
||||
|
||||
for (const [filePath, fileCards] of plannedByFile.entries()) {
|
||||
for (const groupWrite of groupWrites) {
|
||||
const entries = plannedByFile.get(groupWrite.filePath);
|
||||
if (entries) {
|
||||
entries.push({ kind: "group", write: groupWrite });
|
||||
continue;
|
||||
}
|
||||
|
||||
plannedByFile.set(groupWrite.filePath, [{ kind: "group", write: groupWrite }]);
|
||||
}
|
||||
|
||||
for (const [filePath, fileWrites] of plannedByFile.entries()) {
|
||||
const indexedFile = indexedFilesByPath.get(filePath);
|
||||
const sourceContent = indexedFile?.content ?? fileCards[0]?.card.sourceContent;
|
||||
const sourceContent = indexedFile?.content ?? resolveSourceContent(fileWrites[0]);
|
||||
|
||||
if (!sourceContent || !indexedFile) {
|
||||
pendingEntries.push(...fileCards.map((plannedCard) => this.createPendingEntry(filePath, plannedCard, indexedFile?.fileHash ?? "")));
|
||||
pendingEntries.push(...fileWrites.map((fileWrite) => this.createPendingEntry(filePath, fileWrite, indexedFile?.fileHash ?? "")));
|
||||
failureFiles.push({ filePath, message: `Missing scanned source content for ${filePath}.` });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextContent = this.markerService.applyBatch(
|
||||
sourceContent,
|
||||
fileCards.map((plannedCard) => this.toWriteRequest(plannedCard, sourceContent)),
|
||||
);
|
||||
const cardWrites = fileWrites.flatMap((fileWrite) => fileWrite.kind === "card" ? [this.toWriteRequest(fileWrite.plannedCard, sourceContent)] : []);
|
||||
const resolvedGroupWrites = fileWrites.flatMap((fileWrite) => fileWrite.kind === "group" ? [fileWrite.write] : []);
|
||||
this.markerService.validateBatch(sourceContent, cardWrites);
|
||||
this.groupMarkerService.validateBatch(sourceContent, resolvedGroupWrites);
|
||||
|
||||
const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n";
|
||||
const lines = sourceContent.split(/\r?\n/);
|
||||
const orderedWrites = [
|
||||
...cardWrites.map((write) => ({ kind: "card" as const, blockStartLine: write.blockStartLine, write })),
|
||||
...resolvedGroupWrites.map((write) => ({ kind: "group" as const, blockStartLine: write.blockStartLine, write })),
|
||||
].sort((left, right) => right.blockStartLine - left.blockStartLine || (left.kind === right.kind ? 0 : left.kind === "group" ? -1 : 1));
|
||||
|
||||
for (const orderedWrite of orderedWrites) {
|
||||
if (orderedWrite.kind === "card") {
|
||||
this.markerService.applyWrite(lines, orderedWrite.write);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.groupMarkerService.applyWrite(lines, orderedWrite.write);
|
||||
}
|
||||
|
||||
const nextContent = lines.join(lineEnding);
|
||||
|
||||
await this.vaultGateway.replaceMarkdownFile(filePath, sourceContent, nextContent);
|
||||
writtenSyncKeys.push(...fileCards.map((plannedCard) => plannedCard.card.syncKey));
|
||||
writtenSyncKeys.push(...fileWrites.map((fileWrite) => fileWrite.kind === "card" ? fileWrite.plannedCard.card.syncKey : fileWrite.write.syncKey));
|
||||
} catch (error) {
|
||||
pendingEntries.push(...fileCards.map((plannedCard) => this.createPendingEntry(filePath, plannedCard, indexedFile.fileHash)));
|
||||
pendingEntries.push(...fileWrites.map((fileWrite) => this.createPendingEntry(filePath, fileWrite, indexedFile.fileHash)));
|
||||
|
||||
if (error instanceof MarkdownWriteConflictError) {
|
||||
conflictFiles.push(filePath);
|
||||
|
|
@ -89,20 +123,49 @@ export class MarkdownWriteBackService {
|
|||
};
|
||||
}
|
||||
|
||||
private createPendingEntry(filePath: string, plannedCard: PlannedCard, expectedFileHash: string): PendingWriteBackState {
|
||||
const noteId = requireNoteId(plannedCard);
|
||||
private createPendingEntry(
|
||||
filePath: string,
|
||||
fileWrite: { kind: "card"; plannedCard: PlannedCard } | { kind: "group"; write: GroupMarkerWriteRequest },
|
||||
expectedFileHash: string,
|
||||
): PendingWriteBackState {
|
||||
if (fileWrite.kind === "card") {
|
||||
const noteId = requireNoteId(fileWrite.plannedCard);
|
||||
return {
|
||||
filePath,
|
||||
blockStartLine: fileWrite.plannedCard.card.blockStartLine,
|
||||
expectedFileHash,
|
||||
targetMarker: this.markerService.create(noteId).raw,
|
||||
rawBlockHash: fileWrite.plannedCard.card.rawBlockHash,
|
||||
targetNoteId: noteId,
|
||||
markerKind: "card-id",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
blockStartLine: plannedCard.card.blockStartLine,
|
||||
blockStartLine: fileWrite.write.blockStartLine,
|
||||
expectedFileHash,
|
||||
targetMarker: this.markerService.create(noteId).raw,
|
||||
rawBlockHash: plannedCard.card.rawBlockHash,
|
||||
targetNoteId: noteId,
|
||||
targetMarker: serializeGroupMarker(fileWrite.write.noteId, fileWrite.write.itemToSlot, fileWrite.write.freeSlots),
|
||||
rawBlockHash: getRawBlockHashFromSyncKey(fileWrite.write.syncKey),
|
||||
targetNoteId: fileWrite.write.noteId,
|
||||
markerKind: "group-gi",
|
||||
targetGroupId: fileWrite.write.groupId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSourceContent(fileWrite: { kind: "card"; plannedCard: PlannedCard } | { kind: "group"; write: GroupMarkerWriteRequest } | undefined): string | undefined {
|
||||
if (!fileWrite) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fileWrite.kind === "card" ? fileWrite.plannedCard.card.sourceContent : fileWrite.write.sourceContent;
|
||||
}
|
||||
|
||||
function getRawBlockHashFromSyncKey(syncKey: string): string {
|
||||
return syncKey.split("\u0000").at(-1) ?? "";
|
||||
}
|
||||
|
||||
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}.`);
|
||||
|
|
|
|||
97
src/application/services/QaGroupModelDefinition.ts
Normal file
97
src/application/services/QaGroupModelDefinition.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import type { AnkiModelTemplate, CreateAnkiModelInput } from "@/application/ports/AnkiGateway";
|
||||
import type { GroupItem } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
|
||||
export const QA_GROUP_MODEL_NAME = "ObsiAnki QA Group 12";
|
||||
export const QA_GROUP_SLOT_COUNT = 12;
|
||||
|
||||
export function buildQaGroupModelDefinition(): CreateAnkiModelInput {
|
||||
return {
|
||||
modelName: QA_GROUP_MODEL_NAME,
|
||||
fieldNames: buildQaGroupFieldNames(),
|
||||
templates: buildQaGroupTemplates(),
|
||||
css: QA_GROUP_MODEL_CSS,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQaGroupFieldNames(): string[] {
|
||||
const fieldNames = ["Stem", "GroupId", "Src"];
|
||||
|
||||
for (let slot = 1; slot <= QA_GROUP_SLOT_COUNT; slot += 1) {
|
||||
const slotId = formatQaGroupSlot(slot);
|
||||
fieldNames.push(`${slotId}_Id`, `${slotId}_Q`, `${slotId}_A`);
|
||||
}
|
||||
|
||||
return fieldNames;
|
||||
}
|
||||
|
||||
export function buildQaGroupTemplates(): AnkiModelTemplate[] {
|
||||
const templates: AnkiModelTemplate[] = [];
|
||||
|
||||
for (let slot = 1; slot <= QA_GROUP_SLOT_COUNT; slot += 1) {
|
||||
const slotId = formatQaGroupSlot(slot);
|
||||
templates.push({
|
||||
name: `Q${slotId.slice(1)}`,
|
||||
front: `{{#${slotId}_Q}}{{#${slotId}_A}}<div class="stem">{{Stem}}</div>\n<div class="q">{{${slotId}_Q}}</div>{{/${slotId}_A}}{{/${slotId}_Q}}`,
|
||||
back: `{{FrontSide}}\n\n<hr id="answer">\n\n<div class="a">{{${slotId}_A}}</div>\n<div class="meta">{{Src}}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
export function buildQaGroupNoteFields(stem: string, groupId: string, src: string, items: GroupItem[]): Record<string, string> {
|
||||
const fields = Object.fromEntries(buildQaGroupFieldNames().map((fieldName) => [fieldName, ""]));
|
||||
fields.Stem = stem;
|
||||
fields.GroupId = groupId;
|
||||
fields.Src = src;
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.itemId || !item.slot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const slotId = formatQaGroupSlot(item.slot);
|
||||
fields[`${slotId}_Id`] = item.itemId;
|
||||
fields[`${slotId}_Q`] = item.title;
|
||||
fields[`${slotId}_A`] = item.answer;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function formatQaGroupSlot(slot: number): string {
|
||||
return `S${String(slot).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export const QA_GROUP_MODEL_CSS = [
|
||||
".card {",
|
||||
" font-family: 'Helvetica Neue', Arial, sans-serif;",
|
||||
" font-size: 22px;",
|
||||
" line-height: 1.45;",
|
||||
" text-align: left;",
|
||||
"}",
|
||||
"",
|
||||
".stem {",
|
||||
" font-size: 0.85em;",
|
||||
" opacity: 0.72;",
|
||||
" margin-bottom: 0.75em;",
|
||||
"}",
|
||||
"",
|
||||
".q {",
|
||||
" font-size: 1.25em;",
|
||||
" font-weight: 700;",
|
||||
" margin-bottom: 0.85em;",
|
||||
"}",
|
||||
"",
|
||||
".a {",
|
||||
" font-size: 1.08em;",
|
||||
"}",
|
||||
"",
|
||||
".meta {",
|
||||
" margin-top: 1.2em;",
|
||||
" padding-top: 0.65em;",
|
||||
" border-top: 1px solid rgba(0, 0, 0, 0.16);",
|
||||
" font-size: 0.75em;",
|
||||
" opacity: 0.72;",
|
||||
"}",
|
||||
].join("\n");
|
||||
44
src/application/services/QaGroupModelService.ts
Normal file
44
src/application/services/QaGroupModelService.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { AnkiGroupGateway } from "@/application/ports/AnkiGateway";
|
||||
|
||||
import { buildQaGroupModelDefinition } from "./QaGroupModelDefinition";
|
||||
|
||||
export class QaGroupModelService {
|
||||
constructor(private readonly ankiGateway: AnkiGroupGateway) {}
|
||||
|
||||
async ensureModel(): Promise<void> {
|
||||
const definition = buildQaGroupModelDefinition();
|
||||
const modelNames = await this.ankiGateway.listNoteModels();
|
||||
|
||||
if (!modelNames.includes(definition.modelName)) {
|
||||
await this.ankiGateway.createModel(definition);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingFieldNames = await this.ankiGateway.getModelFieldNames(definition.modelName);
|
||||
for (const fieldName of definition.fieldNames) {
|
||||
if (existingFieldNames.includes(fieldName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.ankiGateway.addModelField(definition.modelName, fieldName);
|
||||
}
|
||||
|
||||
const existingTemplates = await this.ankiGateway.getModelTemplates(definition.modelName);
|
||||
for (const template of definition.templates) {
|
||||
const existingTemplate = existingTemplates[template.name];
|
||||
if (!existingTemplate) {
|
||||
await this.ankiGateway.addModelTemplate(definition.modelName, template);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingTemplate.front !== template.front || existingTemplate.back !== template.back) {
|
||||
await this.ankiGateway.updateModelTemplate(definition.modelName, template);
|
||||
}
|
||||
}
|
||||
|
||||
const existingCss = await this.ankiGateway.getModelStyling(definition.modelName);
|
||||
if (existingCss !== definition.css) {
|
||||
await this.ankiGateway.updateModelStyling(definition.modelName, definition.css);
|
||||
}
|
||||
}
|
||||
}
|
||||
166
src/application/services/QaGroupSyncService.test.ts
Normal file
166
src/application/services/QaGroupSyncService.test.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildGroupSrc, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
import { createEmptyPluginState, type GroupBlockState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { QA_GROUP_MODEL_NAME, buildQaGroupNoteFields } from "@/application/services/QaGroupModelDefinition";
|
||||
import { createModule3Settings, FakeManualSyncAnkiGateway } from "@/test-support/manualSyncFakes";
|
||||
|
||||
import { QaGroupSyncService } from "./QaGroupSyncService";
|
||||
|
||||
describe("QaGroupSyncService", () => {
|
||||
it("creates one QA Group note and assigns fresh item ids and slots", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const itemIds = ["item-1", "item-2"];
|
||||
const service = new QaGroupSyncService(
|
||||
ankiGateway,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => 1234,
|
||||
() => "group-1",
|
||||
() => itemIds.shift() ?? "item-x",
|
||||
);
|
||||
|
||||
const result = await service.sync([createIndexedGroupBlock()], createEmptyPluginState(), createModule3Settings());
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_MODEL_NAME);
|
||||
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
|
||||
Stem: "Concepts",
|
||||
GroupId: "group-1",
|
||||
Src: buildGroupSrc("notes/example.md", "Concepts #anki-list"),
|
||||
S01_Q: "Alpha",
|
||||
S01_A: "First answer",
|
||||
S02_Q: "Beta",
|
||||
S02_A: "Second answer",
|
||||
});
|
||||
expect(result.syncedGroupBlocks[0]?.items).toMatchObject([
|
||||
{ itemId: "item-1", slot: 1 },
|
||||
{ itemId: "item-2", slot: 2 },
|
||||
]);
|
||||
expect(result.markerWrites[0]?.itemToSlot).toEqual({ "item-1": 1, "item-2": 2 });
|
||||
});
|
||||
|
||||
it("preserves slots across reorder and reuses a free slot for a new item", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const existingState = createStoredGroupBlockState();
|
||||
ankiGateway.noteDetailsById.set(42, {
|
||||
noteId: 42,
|
||||
modelName: QA_GROUP_MODEL_NAME,
|
||||
cardIds: [7001, 7002],
|
||||
deckNames: ["notes"],
|
||||
fields: buildQaGroupNoteFields(existingState.stem, existingState.groupId, existingState.src, existingState.items),
|
||||
});
|
||||
const service = new QaGroupSyncService(
|
||||
ankiGateway,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => 1234,
|
||||
() => "group-x",
|
||||
() => "item-c",
|
||||
);
|
||||
|
||||
const result = await service.sync([
|
||||
createIndexedGroupBlock({
|
||||
noteId: 42,
|
||||
groupId: "group-1",
|
||||
rawBlockHash: "hash-updated",
|
||||
items: [
|
||||
{ title: "Beta", answer: "Second answer", ordinalInMarkdown: 1 },
|
||||
{ title: "Gamma", answer: "Third answer", ordinalInMarkdown: 2 },
|
||||
{ title: "Alpha", answer: "First answer", ordinalInMarkdown: 3 },
|
||||
],
|
||||
}),
|
||||
], {
|
||||
...createEmptyPluginState(),
|
||||
groupBlocks: {
|
||||
"group-1": existingState,
|
||||
},
|
||||
}, createModule3Settings());
|
||||
|
||||
expect(result.updated).toBe(1);
|
||||
expect(result.syncedGroupBlocks[0]?.items).toMatchObject([
|
||||
{ title: "Beta", itemId: "item-b", slot: 3 },
|
||||
{ title: "Gamma", itemId: "item-c", slot: 2 },
|
||||
{ title: "Alpha", itemId: "item-a", slot: 1 },
|
||||
]);
|
||||
expect(result.markerWrites[0]?.itemToSlot).toEqual({
|
||||
"item-a": 1,
|
||||
"item-c": 2,
|
||||
"item-b": 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createIndexedGroupBlock(overrides: Partial<IndexedGroupCardBlock> = {}): IndexedGroupCardBlock {
|
||||
return {
|
||||
syncKey: overrides.syncKey ?? "notes/example.md\u0000group\u00001\u0000hash-1",
|
||||
markerState: overrides.markerState ?? "missing",
|
||||
filePath: overrides.filePath ?? "notes/example.md",
|
||||
headingText: overrides.headingText ?? "Concepts #anki-list",
|
||||
backlinkHeadingText: overrides.backlinkHeadingText ?? "Concepts #anki-list",
|
||||
headingLevel: overrides.headingLevel ?? 4,
|
||||
stem: overrides.stem ?? "Concepts",
|
||||
src: overrides.src ?? buildGroupSrc("notes/example.md", "Concepts #anki-list"),
|
||||
blockStartOffset: overrides.blockStartOffset ?? 0,
|
||||
blockEndOffset: overrides.blockEndOffset ?? 64,
|
||||
blockStartLine: overrides.blockStartLine ?? 1,
|
||||
bodyStartLine: overrides.bodyStartLine ?? 2,
|
||||
blockEndLine: overrides.blockEndLine ?? 5,
|
||||
contentEndLine: overrides.contentEndLine ?? 5,
|
||||
markerLine: overrides.markerLine,
|
||||
markerIndent: overrides.markerIndent,
|
||||
rawBlockText: overrides.rawBlockText ?? "qa-group:Concepts",
|
||||
rawBlockHash: overrides.rawBlockHash ?? "hash-1",
|
||||
deckWarnings: overrides.deckWarnings ?? [],
|
||||
items: overrides.items ?? [
|
||||
{ title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 },
|
||||
{ title: "Beta", answer: "Second answer", ordinalInMarkdown: 2 },
|
||||
],
|
||||
groupMarker: overrides.groupMarker,
|
||||
freeSlots: overrides.freeSlots ?? [],
|
||||
sourceContent: overrides.sourceContent ?? [
|
||||
"#### Concepts #anki-list",
|
||||
"- Alpha",
|
||||
" - First answer",
|
||||
"- Beta",
|
||||
" - Second answer",
|
||||
].join("\n"),
|
||||
noteId: overrides.noteId,
|
||||
groupId: overrides.groupId,
|
||||
identitySource: overrides.identitySource,
|
||||
deckHint: overrides.deckHint,
|
||||
deckHintSource: overrides.deckHintSource,
|
||||
};
|
||||
}
|
||||
|
||||
function createStoredGroupBlockState(): GroupBlockState {
|
||||
return {
|
||||
groupId: "group-1",
|
||||
noteId: 42,
|
||||
filePath: "notes/example.md",
|
||||
headingText: "Concepts #anki-list",
|
||||
backlinkHeadingText: "Concepts #anki-list",
|
||||
headingLevel: 4,
|
||||
stem: "Concepts",
|
||||
src: buildGroupSrc("notes/example.md", "Concepts #anki-list"),
|
||||
blockStartOffset: 0,
|
||||
blockEndOffset: 64,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 5,
|
||||
contentEndLine: 5,
|
||||
rawBlockText: "qa-group:Concepts",
|
||||
rawBlockHash: "hash-previous",
|
||||
deck: "notes",
|
||||
deckWarnings: [],
|
||||
items: [
|
||||
{ itemId: "item-a", title: "Alpha", answer: "First answer", slot: 1, ordinalInMarkdown: 1 },
|
||||
{ itemId: "item-b", title: "Beta", answer: "Second answer", slot: 3, ordinalInMarkdown: 2 },
|
||||
],
|
||||
freeSlots: [2, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
lastSyncedAt: 100,
|
||||
orphan: false,
|
||||
};
|
||||
}
|
||||
483
src/application/services/QaGroupSyncService.ts
Normal file
483
src/application/services/QaGroupSyncService.ts
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
import type { PluginSettings } from "@/application/config/PluginSettings";
|
||||
import type { AnkiGroupGateway, AnkiNoteDetails } from "@/application/ports/AnkiGateway";
|
||||
import { buildGroupSrc, type GroupItem, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
import type { GroupBlockState, PluginState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { GroupMarkerService, type GroupMarkerWriteRequest } from "@/domain/manual-sync/services/GroupMarkerService";
|
||||
import { DeckResolutionService } from "@/domain/manual-sync/services/DeckResolutionService";
|
||||
import type { DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
|
||||
import { hashString } from "@/domain/shared/hash";
|
||||
|
||||
import { buildQaGroupNoteFields, formatQaGroupSlot, QA_GROUP_MODEL_NAME, QA_GROUP_SLOT_COUNT } from "./QaGroupModelDefinition";
|
||||
import { QaGroupModelService } from "./QaGroupModelService";
|
||||
|
||||
export interface QaGroupSyncExecutionResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
migratedDecks: number;
|
||||
markerWrites: GroupMarkerWriteRequest[];
|
||||
resolvedNoteIds: Map<string, number>;
|
||||
touchedSyncKeys: string[];
|
||||
syncedGroupBlocks: GroupBlockState[];
|
||||
warnings: DeckResolutionWarning[];
|
||||
}
|
||||
|
||||
interface GroupStateIndex {
|
||||
byGroupId: Map<string, GroupBlockState>;
|
||||
byNoteId: Map<number, GroupBlockState>;
|
||||
bySrc: Map<string, GroupBlockState[]>;
|
||||
}
|
||||
|
||||
interface RecoveredGroupRecord {
|
||||
noteId?: number;
|
||||
groupId?: string;
|
||||
items: GroupItem[];
|
||||
freeSlots: number[];
|
||||
noteDetails?: AnkiNoteDetails;
|
||||
stateRecord?: GroupBlockState;
|
||||
}
|
||||
|
||||
export class QaGroupSyncService {
|
||||
private sequence = 0;
|
||||
|
||||
constructor(
|
||||
private readonly ankiGateway: AnkiGroupGateway,
|
||||
private readonly qaGroupModelService = new QaGroupModelService(ankiGateway),
|
||||
private readonly deckResolutionService = new DeckResolutionService(),
|
||||
private readonly groupMarkerService = new GroupMarkerService(),
|
||||
private readonly now: () => number = () => Date.now(),
|
||||
private readonly createGroupId: () => string = () => `g_${hashString(`${Date.now()}_${Math.random()}_${Date.now()}`).slice(0, 12)}`,
|
||||
private readonly createItemId: () => string = () => {
|
||||
this.sequence += 1;
|
||||
return `i_${hashString(`${Date.now()}_${this.sequence}_${Math.random()}`).slice(0, 10)}`;
|
||||
},
|
||||
) {}
|
||||
|
||||
async sync(blocks: IndexedGroupCardBlock[], state: PluginState, settings: PluginSettings): Promise<QaGroupSyncExecutionResult> {
|
||||
if (blocks.length === 0) {
|
||||
return {
|
||||
created: 0,
|
||||
updated: 0,
|
||||
migratedDecks: 0,
|
||||
markerWrites: [],
|
||||
resolvedNoteIds: new Map(),
|
||||
touchedSyncKeys: [],
|
||||
syncedGroupBlocks: [],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
await this.qaGroupModelService.ensureModel();
|
||||
|
||||
const stateIndex = buildGroupStateIndex(state.groupBlocks ?? {});
|
||||
const ensuredDecks = new Set<string>();
|
||||
const warningMap = new Map<string, DeckResolutionWarning>();
|
||||
const markerWrites: GroupMarkerWriteRequest[] = [];
|
||||
const resolvedNoteIds = new Map<string, number>();
|
||||
const touchedSyncKeys = new Set<string>();
|
||||
const syncedGroupBlocks: GroupBlockState[] = [];
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let migratedDecks = 0;
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.items.length > QA_GROUP_SLOT_COUNT) {
|
||||
throw new Error(`QA Group block exceeds 12 items at ${block.filePath}:${block.blockStartLine}.`);
|
||||
}
|
||||
|
||||
const recovered = await this.resolveRecoveredGroup(block, stateIndex);
|
||||
const groupId = recovered.groupId ?? block.groupId ?? this.createGroupId();
|
||||
const resolvedItems = reconcileGroupItems(block.items, recovered.items, this.createItemId);
|
||||
if (resolvedItems.length > QA_GROUP_SLOT_COUNT) {
|
||||
throw new Error(`QA Group block exceeds 12 items after recovery at ${block.filePath}:${block.blockStartLine}.`);
|
||||
}
|
||||
|
||||
const freeSlots = buildFreeSlotsFromItems(resolvedItems);
|
||||
const deckResolution = this.deckResolutionService.resolve({
|
||||
filePath: block.filePath,
|
||||
deckHint: block.deckHint,
|
||||
deckHintSource: block.deckHintSource,
|
||||
deckWarnings: block.deckWarnings,
|
||||
} as never, settings.defaultDeck, settings.folderDeckMode);
|
||||
for (const warning of deckResolution.warnings) {
|
||||
warningMap.set(`${warning.filePath}:${warning.code}:${warning.message}`, warning);
|
||||
}
|
||||
|
||||
const deck = deckResolution.resolvedDeck.value;
|
||||
if (!ensuredDecks.has(deck)) {
|
||||
await this.ankiGateway.ensureDeckExists(deck);
|
||||
ensuredDecks.add(deck);
|
||||
}
|
||||
|
||||
const fields = buildQaGroupNoteFields(block.stem, groupId, block.src, resolvedItems);
|
||||
let noteId = recovered.noteId ?? block.noteId;
|
||||
let existingNote = recovered.noteDetails;
|
||||
|
||||
if (noteId === undefined) {
|
||||
noteId = await this.ankiGateway.addNote({
|
||||
deckName: deck,
|
||||
modelName: QA_GROUP_MODEL_NAME,
|
||||
fields,
|
||||
tags: [],
|
||||
});
|
||||
created += 1;
|
||||
touchedSyncKeys.add(block.syncKey);
|
||||
} else {
|
||||
const stateUnchanged = recovered.stateRecord
|
||||
&& recovered.stateRecord.rawBlockHash === block.rawBlockHash
|
||||
&& recovered.stateRecord.deck === deck
|
||||
&& !recovered.stateRecord.orphan;
|
||||
|
||||
let fieldsChanged = !stateUnchanged;
|
||||
let deckChanged = recovered.stateRecord ? recovered.stateRecord.deck !== deck : false;
|
||||
|
||||
if (!stateUnchanged) {
|
||||
existingNote ??= await this.loadQaGroupNote(noteId, block);
|
||||
fieldsChanged = !haveEqualFields(existingNote.fields, fields);
|
||||
deckChanged = !(existingNote.deckNames ?? []).includes(deck);
|
||||
}
|
||||
|
||||
if (fieldsChanged || deckChanged) {
|
||||
await this.ankiGateway.updateNote({
|
||||
noteId,
|
||||
fields,
|
||||
deckName: deckChanged ? deck : undefined,
|
||||
});
|
||||
if (fieldsChanged) {
|
||||
updated += 1;
|
||||
}
|
||||
if (deckChanged) {
|
||||
migratedDecks += 1;
|
||||
}
|
||||
touchedSyncKeys.add(block.syncKey);
|
||||
}
|
||||
}
|
||||
|
||||
resolvedNoteIds.set(block.syncKey, noteId);
|
||||
|
||||
const itemToSlot = Object.fromEntries(resolvedItems
|
||||
.filter((item) => item.itemId && item.slot)
|
||||
.map((item) => [item.itemId as string, item.slot as number]));
|
||||
if (
|
||||
block.markerState !== "present-valid"
|
||||
|| !this.groupMarkerService.isEquivalent(block.groupMarker, noteId, itemToSlot, freeSlots)
|
||||
|| hasLegacyInnerIdMarkers(block)
|
||||
) {
|
||||
markerWrites.push({
|
||||
syncKey: block.syncKey,
|
||||
filePath: block.filePath,
|
||||
blockStartLine: block.blockStartLine,
|
||||
blockEndLine: block.blockEndLine,
|
||||
markerLine: block.markerLine,
|
||||
markerIndent: block.markerIndent,
|
||||
noteId,
|
||||
groupId,
|
||||
itemToSlot,
|
||||
freeSlots,
|
||||
sourceContent: block.sourceContent ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
const now = this.now();
|
||||
syncedGroupBlocks.push({
|
||||
groupId,
|
||||
noteId,
|
||||
filePath: block.filePath,
|
||||
headingText: block.headingText,
|
||||
backlinkHeadingText: block.backlinkHeadingText,
|
||||
headingLevel: block.headingLevel,
|
||||
stem: block.stem,
|
||||
src: block.src || buildGroupSrc(block.filePath, block.backlinkHeadingText),
|
||||
blockStartOffset: block.blockStartOffset,
|
||||
blockEndOffset: block.blockEndOffset,
|
||||
blockStartLine: block.blockStartLine,
|
||||
bodyStartLine: block.bodyStartLine,
|
||||
blockEndLine: block.blockEndLine,
|
||||
contentEndLine: block.contentEndLine,
|
||||
markerLine: block.markerLine,
|
||||
markerIndent: block.markerIndent,
|
||||
rawBlockText: block.rawBlockText,
|
||||
rawBlockHash: block.rawBlockHash,
|
||||
deck,
|
||||
deckHint: block.deckHint,
|
||||
deckHintSource: block.deckHintSource,
|
||||
deckWarnings: [...deckResolution.warnings],
|
||||
items: resolvedItems,
|
||||
freeSlots,
|
||||
lastSyncedAt: touchedSyncKeys.has(block.syncKey) ? now : recovered.stateRecord?.lastSyncedAt ?? now,
|
||||
orphan: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
created,
|
||||
updated,
|
||||
migratedDecks,
|
||||
markerWrites,
|
||||
resolvedNoteIds,
|
||||
touchedSyncKeys: [...touchedSyncKeys],
|
||||
syncedGroupBlocks,
|
||||
warnings: [...warningMap.values()],
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveRecoveredGroup(block: IndexedGroupCardBlock, stateIndex: GroupStateIndex): Promise<RecoveredGroupRecord> {
|
||||
const stateRecord = (block.groupId ? stateIndex.byGroupId.get(block.groupId) : undefined)
|
||||
?? (block.noteId !== undefined ? stateIndex.byNoteId.get(block.noteId) : undefined)
|
||||
?? uniqueMatch(stateIndex.bySrc.get(block.src));
|
||||
|
||||
if (stateRecord) {
|
||||
return {
|
||||
noteId: stateRecord.noteId,
|
||||
groupId: stateRecord.groupId,
|
||||
items: stateRecord.items.map((item) => ({ ...item })),
|
||||
freeSlots: [...stateRecord.freeSlots],
|
||||
stateRecord,
|
||||
};
|
||||
}
|
||||
|
||||
if (block.groupId) {
|
||||
const recoveredByGroupId = await this.findSingleQaGroupNote(`note:${quoteAnkiValue(QA_GROUP_MODEL_NAME)} GroupId:${quoteAnkiValue(block.groupId)}`, block);
|
||||
if (recoveredByGroupId) {
|
||||
return noteDetailsToRecoveredGroup(recoveredByGroupId, block.groupId);
|
||||
}
|
||||
}
|
||||
|
||||
if (block.noteId !== undefined) {
|
||||
return noteDetailsToRecoveredGroup(await this.loadQaGroupNote(block.noteId, block), block.groupId);
|
||||
}
|
||||
|
||||
const recoveredBySrc = await this.findSingleQaGroupNote(`note:${quoteAnkiValue(QA_GROUP_MODEL_NAME)} Src:${quoteAnkiValue(block.src)}`, block);
|
||||
if (recoveredBySrc) {
|
||||
return noteDetailsToRecoveredGroup(recoveredBySrc, block.groupId);
|
||||
}
|
||||
|
||||
return {
|
||||
items: [],
|
||||
freeSlots: Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1),
|
||||
};
|
||||
}
|
||||
|
||||
private async findSingleQaGroupNote(query: string, block: IndexedGroupCardBlock): Promise<AnkiNoteDetails | null> {
|
||||
const noteIds = await this.ankiGateway.findNoteIds(query);
|
||||
if (noteIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (noteIds.length > 1) {
|
||||
throw new Error(`QA Group recovery is ambiguous at ${block.filePath}:${block.blockStartLine}. Query: ${query}`);
|
||||
}
|
||||
|
||||
return this.loadQaGroupNote(noteIds[0], block);
|
||||
}
|
||||
|
||||
private async loadQaGroupNote(noteId: number, block: IndexedGroupCardBlock): Promise<AnkiNoteDetails> {
|
||||
const note = (await this.ankiGateway.getNoteDetails([noteId]))[0];
|
||||
if (!note) {
|
||||
throw new Error(`Unable to load QA Group note ${noteId} for ${block.filePath}:${block.blockStartLine}.`);
|
||||
}
|
||||
|
||||
if (note.modelName !== QA_GROUP_MODEL_NAME) {
|
||||
throw new Error(`Note ${noteId} for ${block.filePath}:${block.blockStartLine} is ${note.modelName}, expected ${QA_GROUP_MODEL_NAME}.`);
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
}
|
||||
|
||||
function buildGroupStateIndex(groupBlocks: Record<string, GroupBlockState>): GroupStateIndex {
|
||||
const byGroupId = new Map<string, GroupBlockState>();
|
||||
const byNoteId = new Map<number, GroupBlockState>();
|
||||
const bySrc = new Map<string, GroupBlockState[]>();
|
||||
|
||||
for (const groupBlock of Object.values(groupBlocks)) {
|
||||
if (groupBlock.orphan) {
|
||||
continue;
|
||||
}
|
||||
|
||||
byGroupId.set(groupBlock.groupId, groupBlock);
|
||||
byNoteId.set(groupBlock.noteId, groupBlock);
|
||||
const entries = bySrc.get(groupBlock.src);
|
||||
if (entries) {
|
||||
entries.push(groupBlock);
|
||||
continue;
|
||||
}
|
||||
|
||||
bySrc.set(groupBlock.src, [groupBlock]);
|
||||
}
|
||||
|
||||
return {
|
||||
byGroupId,
|
||||
byNoteId,
|
||||
bySrc,
|
||||
};
|
||||
}
|
||||
|
||||
function noteDetailsToRecoveredGroup(note: AnkiNoteDetails, fallbackGroupId?: string): RecoveredGroupRecord {
|
||||
const items: GroupItem[] = [];
|
||||
const occupiedSlots = new Set<number>();
|
||||
|
||||
for (let slot = 1; slot <= QA_GROUP_SLOT_COUNT; slot += 1) {
|
||||
const slotId = formatQaGroupSlot(slot);
|
||||
const itemId = (note.fields[`${slotId}_Id`] ?? "").trim() || `legacy_${slotId.toLowerCase()}`;
|
||||
const title = (note.fields[`${slotId}_Q`] ?? "").trim();
|
||||
const answer = (note.fields[`${slotId}_A`] ?? "").trim();
|
||||
if (!title && !answer && !(note.fields[`${slotId}_Id`] ?? "").trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
occupiedSlots.add(slot);
|
||||
items.push({
|
||||
itemId,
|
||||
title,
|
||||
answer,
|
||||
slot,
|
||||
ordinalInMarkdown: slot,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
noteId: note.noteId,
|
||||
groupId: (note.fields.GroupId ?? "").trim() || fallbackGroupId,
|
||||
items,
|
||||
freeSlots: Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1).filter((slot) => !occupiedSlots.has(slot)),
|
||||
noteDetails: note,
|
||||
};
|
||||
}
|
||||
|
||||
function reconcileGroupItems(currentItems: GroupItem[], recoveredItems: GroupItem[], createItemId: () => string): GroupItem[] {
|
||||
const recoveredQueues = new Map<string, GroupItem[]>();
|
||||
const unmatchedRecovered: GroupItem[] = [];
|
||||
|
||||
for (const item of recoveredItems) {
|
||||
const key = createItemContentKey(item.title, item.answer);
|
||||
const queue = recoveredQueues.get(key);
|
||||
if (queue) {
|
||||
queue.push(item);
|
||||
} else {
|
||||
recoveredQueues.set(key, [item]);
|
||||
}
|
||||
unmatchedRecovered.push(item);
|
||||
}
|
||||
|
||||
const resolvedItems: GroupItem[] = [];
|
||||
const unmatchedCurrentIndexes: number[] = [];
|
||||
|
||||
for (let index = 0; index < currentItems.length; index += 1) {
|
||||
const currentItem = currentItems[index];
|
||||
const queue = recoveredQueues.get(createItemContentKey(currentItem.title, currentItem.answer));
|
||||
const matchedRecovered = queue?.shift();
|
||||
if (!matchedRecovered) {
|
||||
unmatchedCurrentIndexes.push(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
removeRecoveredReference(unmatchedRecovered, matchedRecovered);
|
||||
resolvedItems[index] = {
|
||||
...currentItem,
|
||||
itemId: matchedRecovered.itemId,
|
||||
slot: matchedRecovered.slot,
|
||||
};
|
||||
}
|
||||
|
||||
const remainingRecovered = [...unmatchedRecovered].sort((left, right) => (left.ordinalInMarkdown - right.ordinalInMarkdown) || ((left.slot ?? 999) - (right.slot ?? 999)));
|
||||
for (const currentIndex of unmatchedCurrentIndexes) {
|
||||
const currentItem = currentItems[currentIndex];
|
||||
const recoveredItem = remainingRecovered.shift();
|
||||
if (recoveredItem) {
|
||||
resolvedItems[currentIndex] = {
|
||||
...currentItem,
|
||||
itemId: recoveredItem.itemId,
|
||||
slot: recoveredItem.slot,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
resolvedItems[currentIndex] = {
|
||||
...currentItem,
|
||||
itemId: createItemId(),
|
||||
};
|
||||
}
|
||||
|
||||
const occupiedSlots = new Set<number>(resolvedItems.map((item) => item.slot).filter((slot): slot is number => typeof slot === "number"));
|
||||
const freeSlots = Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1).filter((slot) => !occupiedSlots.has(slot));
|
||||
for (const item of resolvedItems) {
|
||||
if (item.slot !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextSlot = freeSlots.shift();
|
||||
if (nextSlot === undefined) {
|
||||
throw new Error("No free QA Group slots are available.");
|
||||
}
|
||||
|
||||
item.slot = nextSlot;
|
||||
}
|
||||
|
||||
return resolvedItems.map((item, index) => ({
|
||||
...item,
|
||||
ordinalInMarkdown: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildFreeSlotsFromItems(items: GroupItem[]): number[] {
|
||||
const occupiedSlots = new Set<number>(items.map((item) => item.slot).filter((slot): slot is number => typeof slot === "number"));
|
||||
return Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1).filter((slot) => !occupiedSlots.has(slot));
|
||||
}
|
||||
|
||||
function createItemContentKey(title: string, answer: string): string {
|
||||
return `${title}\u0000${answer}`;
|
||||
}
|
||||
|
||||
function removeRecoveredReference(items: GroupItem[], target: GroupItem): void {
|
||||
const index = items.findIndex((item) => item.itemId === target.itemId && item.slot === target.slot && item.title === target.title && item.answer === target.answer);
|
||||
if (index >= 0) {
|
||||
items.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueMatch<T>(items: T[] | undefined): T | undefined {
|
||||
if (!items || items.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items[0];
|
||||
}
|
||||
|
||||
function haveEqualFields(left: Record<string, string>, right: Record<string, string>): boolean {
|
||||
const leftKeys = Object.keys(left).sort();
|
||||
const rightKeys = Object.keys(right).sort();
|
||||
if (leftKeys.length !== rightKeys.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < leftKeys.length; index += 1) {
|
||||
if (leftKeys[index] !== rightKeys[index]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((left[leftKeys[index]] ?? "") !== (right[rightKeys[index]] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasLegacyInnerIdMarkers(block: IndexedGroupCardBlock): boolean {
|
||||
if (!block.sourceContent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lines = block.sourceContent.split(/\r?\n/);
|
||||
for (let lineIndex = block.blockStartLine; lineIndex < block.blockEndLine; lineIndex += 1) {
|
||||
if (/^\s*<!--\s*ID:/i.test(lines[lineIndex] ?? "")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function quoteAnkiValue(value: string): string {
|
||||
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
|
||||
import type { IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
|
||||
export interface IndexedFile {
|
||||
filePath: string;
|
||||
|
|
@ -6,4 +7,5 @@ export interface IndexedFile {
|
|||
fileStamp: string;
|
||||
content?: string;
|
||||
cards: IndexedCard[];
|
||||
groupBlocks?: IndexedGroupCardBlock[];
|
||||
}
|
||||
57
src/domain/manual-sync/entities/IndexedGroupCardBlock.ts
Normal file
57
src/domain/manual-sync/entities/IndexedGroupCardBlock.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { DeckResolutionWarning, DeckResolutionSource } from "@/domain/manual-sync/value-objects/DeckResolution";
|
||||
|
||||
export type GroupMarkerState = "missing" | "present-valid" | "present-invalid";
|
||||
export type GroupIdentitySource = "gi-marker" | "state-recovery" | "anki-recovery";
|
||||
|
||||
export interface GroupMarker {
|
||||
noteId?: number;
|
||||
itemToSlot: Record<string, number>;
|
||||
freeSlots: number[];
|
||||
}
|
||||
|
||||
export interface GroupItem {
|
||||
itemId?: string;
|
||||
title: string;
|
||||
answer: string;
|
||||
slot?: number;
|
||||
ordinalInMarkdown: number;
|
||||
}
|
||||
|
||||
export interface IndexedGroupCardBlock {
|
||||
noteId?: number;
|
||||
groupId?: string;
|
||||
syncKey: string;
|
||||
markerState: GroupMarkerState;
|
||||
identitySource?: GroupIdentitySource;
|
||||
filePath: string;
|
||||
headingText: string;
|
||||
backlinkHeadingText: string;
|
||||
headingLevel: number;
|
||||
stem: string;
|
||||
src: string;
|
||||
blockStartOffset: number;
|
||||
blockEndOffset: number;
|
||||
blockStartLine: number;
|
||||
bodyStartLine: number;
|
||||
blockEndLine: number;
|
||||
contentEndLine: number;
|
||||
markerLine?: number;
|
||||
markerIndent?: string;
|
||||
rawBlockText: string;
|
||||
rawBlockHash: string;
|
||||
deckHint?: string;
|
||||
deckHintSource?: Extract<DeckResolutionSource, "frontmatter" | "body">;
|
||||
deckWarnings: DeckResolutionWarning[];
|
||||
items: GroupItem[];
|
||||
groupMarker?: GroupMarker;
|
||||
freeSlots: number[];
|
||||
sourceContent?: string;
|
||||
}
|
||||
|
||||
export function createIndexedGroupSyncKey(filePath: string, blockStartLine: number, rawBlockHash: string): string {
|
||||
return `${filePath}\u0000group\u0000${blockStartLine}\u0000${rawBlockHash}`;
|
||||
}
|
||||
|
||||
export function buildGroupSrc(filePath: string, headingText: string): string {
|
||||
return `${filePath}#${headingText}`;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { CardType } from "@/domain/card/entities/RenderedFields";
|
||||
import type { GroupItem } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
import type { DeckResolutionWarning, DeckResolutionSource } from "@/domain/manual-sync/value-objects/DeckResolution";
|
||||
|
||||
export interface FileState {
|
||||
|
|
@ -8,6 +9,7 @@ export interface FileState {
|
|||
deckRulesFingerprint?: string;
|
||||
lastIndexedAt: number;
|
||||
noteIds: number[];
|
||||
groupIds?: string[];
|
||||
}
|
||||
|
||||
export interface CardState {
|
||||
|
|
@ -44,11 +46,43 @@ export interface PendingWriteBackState {
|
|||
targetMarker: string;
|
||||
rawBlockHash: string;
|
||||
targetNoteId: number;
|
||||
markerKind?: "card-id" | "group-gi";
|
||||
targetGroupId?: string;
|
||||
}
|
||||
|
||||
export interface GroupBlockState {
|
||||
groupId: string;
|
||||
noteId: number;
|
||||
filePath: string;
|
||||
headingText: string;
|
||||
backlinkHeadingText: string;
|
||||
headingLevel: number;
|
||||
stem: string;
|
||||
src: string;
|
||||
blockStartOffset: number;
|
||||
blockEndOffset: number;
|
||||
blockStartLine: number;
|
||||
bodyStartLine: number;
|
||||
blockEndLine: number;
|
||||
contentEndLine: number;
|
||||
markerLine?: number;
|
||||
markerIndent?: string;
|
||||
rawBlockText: string;
|
||||
rawBlockHash: string;
|
||||
deck: string;
|
||||
deckHint?: string;
|
||||
deckHintSource?: Extract<DeckResolutionSource, "frontmatter" | "body">;
|
||||
deckWarnings: DeckResolutionWarning[];
|
||||
items: GroupItem[];
|
||||
freeSlots: number[];
|
||||
lastSyncedAt: number;
|
||||
orphan: boolean;
|
||||
}
|
||||
|
||||
export interface PluginState {
|
||||
files: Record<string, FileState>;
|
||||
cards: Record<string, CardState>;
|
||||
groupBlocks?: Record<string, GroupBlockState>;
|
||||
pendingWriteBack: PendingWriteBackState[];
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +90,7 @@ export function createEmptyPluginState(): PluginState {
|
|||
return {
|
||||
files: {},
|
||||
cards: {},
|
||||
groupBlocks: {},
|
||||
pendingWriteBack: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CardState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { buildGroupSrc } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
import type { CardState, GroupBlockState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { hashString } from "@/domain/shared/hash";
|
||||
|
||||
import { CardIndexingService } from "./CardIndexingService";
|
||||
|
|
@ -222,6 +223,98 @@ describe("CardIndexingService", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("indexes a #anki-list heading as one QA Group block and parses its GI marker", () => {
|
||||
const service = new CardIndexingService();
|
||||
const indexedFile = service.index(
|
||||
{
|
||||
path: "notes/example.md",
|
||||
basename: "example",
|
||||
content: [
|
||||
"#### Concepts #anki-list",
|
||||
"- Alpha",
|
||||
" - First answer",
|
||||
"- Beta",
|
||||
" - Second answer",
|
||||
"<!--GI:n=42;i=item_a:1,item_b:2;f=3,4,5,6,7,8,9,10,11,12-->",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
qaHeadingLevel: 4,
|
||||
clozeHeadingLevel: 5,
|
||||
qaGroupMarker: "#anki-list",
|
||||
fileStamp: "1:1",
|
||||
knownCards: [],
|
||||
pendingWriteBack: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(indexedFile.cards).toHaveLength(0);
|
||||
expect(indexedFile.groupBlocks).toHaveLength(1);
|
||||
expect(indexedFile.groupBlocks?.[0]).toMatchObject({
|
||||
noteId: 42,
|
||||
markerState: "present-valid",
|
||||
stem: "Concepts",
|
||||
items: [
|
||||
{ title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 },
|
||||
{ title: "Beta", answer: "Second answer", ordinalInMarkdown: 2 },
|
||||
],
|
||||
freeSlots: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers QA Group note identity from prior group state when GI is missing", () => {
|
||||
const service = new CardIndexingService();
|
||||
const headingText = "Concepts #anki-list";
|
||||
const rawBlockText = [
|
||||
"qa-group:Concepts",
|
||||
"Alpha\nFirst answer",
|
||||
"Beta\nSecond answer",
|
||||
"- Alpha",
|
||||
" - First answer",
|
||||
"- Beta",
|
||||
" - Second answer",
|
||||
].join("\n");
|
||||
const indexedFile = service.index(
|
||||
{
|
||||
path: "notes/example.md",
|
||||
basename: "example",
|
||||
content: [
|
||||
`#### ${headingText}`,
|
||||
"- Alpha",
|
||||
" - First answer",
|
||||
"- Beta",
|
||||
" - Second answer",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
qaHeadingLevel: 4,
|
||||
clozeHeadingLevel: 5,
|
||||
qaGroupMarker: "#anki-list",
|
||||
fileStamp: "1:1",
|
||||
knownCards: [],
|
||||
knownGroupBlocks: [createKnownGroupState({
|
||||
groupId: "group-1",
|
||||
noteId: 42,
|
||||
headingText,
|
||||
backlinkHeadingText: headingText,
|
||||
stem: "Concepts",
|
||||
src: buildGroupSrc("notes/example.md", headingText),
|
||||
rawBlockText,
|
||||
rawBlockHash: hashString(rawBlockText),
|
||||
})],
|
||||
pendingWriteBack: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(indexedFile.groupBlocks).toHaveLength(1);
|
||||
expect(indexedFile.groupBlocks?.[0]).toMatchObject({
|
||||
noteId: 42,
|
||||
groupId: "group-1",
|
||||
identitySource: "state-recovery",
|
||||
markerState: "missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts YAML deck, prefers it over conflicting body deck, and stores a warning on indexed cards", () => {
|
||||
const service = new CardIndexingService();
|
||||
const indexedFile = service.index(
|
||||
|
|
@ -350,4 +443,35 @@ function createKnownCardState(overrides: Partial<CardState> = {}): CardState {
|
|||
lastSyncedAt: overrides.lastSyncedAt ?? 1,
|
||||
orphan: overrides.orphan ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function createKnownGroupState(overrides: Partial<GroupBlockState> = {}): GroupBlockState {
|
||||
return {
|
||||
groupId: overrides.groupId ?? "group-1",
|
||||
noteId: overrides.noteId ?? 42,
|
||||
filePath: overrides.filePath ?? "notes/example.md",
|
||||
headingText: overrides.headingText ?? "Concepts #anki-list",
|
||||
backlinkHeadingText: overrides.backlinkHeadingText ?? "Concepts #anki-list",
|
||||
headingLevel: overrides.headingLevel ?? 4,
|
||||
stem: overrides.stem ?? "Concepts",
|
||||
src: overrides.src ?? buildGroupSrc("notes/example.md", "Concepts #anki-list"),
|
||||
blockStartOffset: overrides.blockStartOffset ?? 0,
|
||||
blockEndOffset: overrides.blockEndOffset ?? 0,
|
||||
blockStartLine: overrides.blockStartLine ?? 1,
|
||||
bodyStartLine: overrides.bodyStartLine ?? 2,
|
||||
blockEndLine: overrides.blockEndLine ?? 5,
|
||||
contentEndLine: overrides.contentEndLine ?? 5,
|
||||
markerLine: overrides.markerLine,
|
||||
markerIndent: overrides.markerIndent,
|
||||
rawBlockText: overrides.rawBlockText ?? "qa-group:Concepts",
|
||||
rawBlockHash: overrides.rawBlockHash ?? hashString(overrides.rawBlockText ?? "qa-group:Concepts"),
|
||||
deck: overrides.deck ?? "notes",
|
||||
deckHint: overrides.deckHint,
|
||||
deckHintSource: overrides.deckHintSource,
|
||||
deckWarnings: overrides.deckWarnings ?? [],
|
||||
items: overrides.items ?? [],
|
||||
freeSlots: overrides.freeSlots ?? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
lastSyncedAt: overrides.lastSyncedAt ?? 1,
|
||||
orphan: overrides.orphan ?? false,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import type { SourceFile } from "@/domain/card/entities/SourceFile";
|
||||
import { createIndexedCardSyncKey, type IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
|
||||
import { buildGroupSrc, createIndexedGroupSyncKey, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile";
|
||||
import { createPendingWriteBackKey, type CardState, type PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { createPendingWriteBackKey, type CardState, type GroupBlockState, type PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { hashString } from "@/domain/shared/hash";
|
||||
|
||||
import { CardMarkerService } from "./CardMarkerService";
|
||||
import { DeckExtractionService } from "./DeckExtractionService";
|
||||
import { QaGroupBlockParser } from "./QaGroupBlockParser";
|
||||
import { SemanticQaListParser } from "./SemanticQaListParser";
|
||||
|
||||
interface HeadingMatch {
|
||||
|
|
@ -25,9 +27,11 @@ interface MarkerExtractionResult {
|
|||
export interface CardIndexingContext {
|
||||
qaHeadingLevel: number;
|
||||
clozeHeadingLevel: number;
|
||||
qaGroupMarker?: string;
|
||||
semanticQaMarker?: string;
|
||||
fileStamp: string;
|
||||
knownCards: CardState[];
|
||||
knownGroupBlocks?: GroupBlockState[];
|
||||
pendingWriteBack: PendingWriteBackState[];
|
||||
fileDeckEnabled?: boolean;
|
||||
fileDeckMarker?: string;
|
||||
|
|
@ -38,6 +42,7 @@ export class CardIndexingService {
|
|||
constructor(
|
||||
private readonly markerService = new CardMarkerService(),
|
||||
private readonly deckExtractionService = new DeckExtractionService(),
|
||||
private readonly qaGroupBlockParser = new QaGroupBlockParser(),
|
||||
private readonly semanticQaListParser = new SemanticQaListParser(),
|
||||
) {}
|
||||
|
||||
|
|
@ -51,9 +56,12 @@ export class CardIndexingService {
|
|||
? this.deckExtractionService.extract(sourceFile, context.fileDeckMarker ?? "TARGET DECK")
|
||||
: { warnings: [] };
|
||||
const cards: IndexedCard[] = [];
|
||||
const groupBlocks: IndexedGroupCardBlock[] = [];
|
||||
const knownCardsByBlockKey = groupKnownCardsByBlockKey(context.knownCards);
|
||||
const pendingByBlockKey = groupPendingWriteBackByBlockKey(context.pendingWriteBack);
|
||||
const usedNoteIds = new Set<number>();
|
||||
const usedGroupIds = new Set<string>();
|
||||
const knownGroupBlocks = context.knownGroupBlocks ?? [];
|
||||
|
||||
for (let headingIndex = 0; headingIndex < headings.length; headingIndex += 1) {
|
||||
const heading = headings[headingIndex];
|
||||
|
|
@ -64,6 +72,66 @@ export class CardIndexingService {
|
|||
|
||||
const blockEndLineIndex = findBlockEndLineIndex(headings, headingIndex, lines.length);
|
||||
const bodyLines = lines.slice(heading.lineIndex + 1, blockEndLineIndex);
|
||||
const qaGroupMarker = context.qaGroupMarker ?? "#anki-list";
|
||||
if (cardType === "basic" && this.qaGroupBlockParser.isQaGroupHeading(heading.text, qaGroupMarker)) {
|
||||
const parsedGroupBlock = this.qaGroupBlockParser.parse({
|
||||
parentHeadingText: heading.text,
|
||||
marker: qaGroupMarker,
|
||||
bodyLines,
|
||||
bodyStartLine: heading.lineIndex + 2,
|
||||
});
|
||||
const src = buildGroupSrc(sourceFile.path, heading.text);
|
||||
const resolvedGroupIdentity = this.resolveGroupIdentity(
|
||||
src,
|
||||
parsedGroupBlock.rawBlockHash,
|
||||
parsedGroupBlock.groupMarker?.noteId,
|
||||
knownGroupBlocks,
|
||||
usedNoteIds,
|
||||
usedGroupIds,
|
||||
);
|
||||
|
||||
if (resolvedGroupIdentity.noteId !== undefined) {
|
||||
usedNoteIds.add(resolvedGroupIdentity.noteId);
|
||||
}
|
||||
|
||||
if (resolvedGroupIdentity.groupId) {
|
||||
usedGroupIds.add(resolvedGroupIdentity.groupId);
|
||||
}
|
||||
|
||||
groupBlocks.push({
|
||||
noteId: resolvedGroupIdentity.noteId,
|
||||
groupId: resolvedGroupIdentity.groupId,
|
||||
syncKey: createIndexedGroupSyncKey(sourceFile.path, heading.lineIndex + 1, parsedGroupBlock.rawBlockHash),
|
||||
markerState: parsedGroupBlock.markerState,
|
||||
identitySource: resolvedGroupIdentity.identitySource,
|
||||
filePath: sourceFile.path,
|
||||
headingText: heading.text,
|
||||
backlinkHeadingText: heading.text,
|
||||
headingLevel: heading.level,
|
||||
stem: parsedGroupBlock.stem,
|
||||
src,
|
||||
blockStartOffset: lineStartOffsets[heading.lineIndex] ?? 0,
|
||||
blockEndOffset: blockEndLineIndex < lines.length ? (lineStartOffsets[blockEndLineIndex] ?? sourceFile.content.length) : sourceFile.content.length,
|
||||
blockStartLine: heading.lineIndex + 1,
|
||||
bodyStartLine: heading.lineIndex + 2,
|
||||
blockEndLine: blockEndLineIndex,
|
||||
contentEndLine: parsedGroupBlock.contentEndLine,
|
||||
markerLine: parsedGroupBlock.markerLine,
|
||||
markerIndent: parsedGroupBlock.markerIndent,
|
||||
rawBlockText: parsedGroupBlock.rawBlockText,
|
||||
rawBlockHash: parsedGroupBlock.rawBlockHash,
|
||||
deckHint: extractedDeck.explicitDeckHint,
|
||||
deckHintSource: extractedDeck.explicitDeckSource,
|
||||
deckWarnings: [...extractedDeck.warnings],
|
||||
items: parsedGroupBlock.items,
|
||||
groupMarker: parsedGroupBlock.groupMarker,
|
||||
freeSlots: parsedGroupBlock.groupMarker?.freeSlots ?? resolvedGroupIdentity.freeSlots,
|
||||
sourceContent: sourceFile.content,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const semanticQaMarker = context.semanticQaMarker ?? "#anki-list-qa";
|
||||
if (cardType === "basic" && this.semanticQaListParser.isSemanticQaHeading(heading.text, semanticQaMarker)) {
|
||||
for (const semanticCard of this.semanticQaListParser.parse({
|
||||
|
|
@ -173,6 +241,7 @@ export class CardIndexingService {
|
|||
fileStamp: context.fileStamp,
|
||||
content: sourceFile.content,
|
||||
cards,
|
||||
groupBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -222,6 +291,59 @@ export class CardIndexingService {
|
|||
|
||||
return {};
|
||||
}
|
||||
|
||||
private resolveGroupIdentity(
|
||||
src: string,
|
||||
rawBlockHash: string,
|
||||
markerNoteId: number | undefined,
|
||||
knownGroupBlocks: GroupBlockState[],
|
||||
usedNoteIds: Set<number>,
|
||||
usedGroupIds: Set<string>,
|
||||
): { noteId?: number; groupId?: string; freeSlots: number[]; identitySource?: IndexedGroupCardBlock["identitySource"] } {
|
||||
if (markerNoteId !== undefined && !usedNoteIds.has(markerNoteId)) {
|
||||
const stateMatch = knownGroupBlocks.find((groupBlock) => !groupBlock.orphan && groupBlock.noteId === markerNoteId);
|
||||
if (!stateMatch) {
|
||||
return {
|
||||
noteId: markerNoteId,
|
||||
freeSlots: [],
|
||||
identitySource: "gi-marker",
|
||||
};
|
||||
}
|
||||
|
||||
if (!usedGroupIds.has(stateMatch.groupId)) {
|
||||
return {
|
||||
noteId: stateMatch.noteId,
|
||||
groupId: stateMatch.groupId,
|
||||
freeSlots: [...stateMatch.freeSlots],
|
||||
identitySource: "gi-marker",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const srcMatches = knownGroupBlocks.filter((groupBlock) => !groupBlock.orphan && groupBlock.src === src);
|
||||
if (srcMatches.length === 1 && !usedGroupIds.has(srcMatches[0].groupId) && !usedNoteIds.has(srcMatches[0].noteId)) {
|
||||
return {
|
||||
noteId: srcMatches[0].noteId,
|
||||
groupId: srcMatches[0].groupId,
|
||||
freeSlots: [...srcMatches[0].freeSlots],
|
||||
identitySource: "state-recovery",
|
||||
};
|
||||
}
|
||||
|
||||
const hashMatches = knownGroupBlocks.filter((groupBlock) => !groupBlock.orphan && groupBlock.rawBlockHash === rawBlockHash);
|
||||
if (hashMatches.length === 1 && !usedGroupIds.has(hashMatches[0].groupId) && !usedNoteIds.has(hashMatches[0].noteId)) {
|
||||
return {
|
||||
noteId: hashMatches[0].noteId,
|
||||
groupId: hashMatches[0].groupId,
|
||||
freeSlots: [...hashMatches[0].freeSlots],
|
||||
identitySource: "state-recovery",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
freeSlots: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function groupKnownCardsByBlockKey(knownCards: CardState[]): Map<string, CardState[]> {
|
||||
|
|
@ -248,7 +370,7 @@ function groupKnownCardsByBlockKey(knownCards: CardState[]): Map<string, CardSta
|
|||
function groupPendingWriteBackByBlockKey(pendingWriteBack: PendingWriteBackState[]): Map<string, PendingWriteBackState[]> {
|
||||
const grouped = new Map<string, PendingWriteBackState[]>();
|
||||
|
||||
for (const pending of pendingWriteBack) {
|
||||
for (const pending of pendingWriteBack.filter((entry) => entry.markerKind !== "group-gi")) {
|
||||
const key = createPendingWriteBackKey(pending.filePath, pending.blockStartLine, pending.rawBlockHash);
|
||||
const entries = grouped.get(key);
|
||||
if (entries) {
|
||||
|
|
|
|||
|
|
@ -61,11 +61,22 @@ export class CardMarkerService {
|
|||
return sourceContent;
|
||||
}
|
||||
|
||||
const filePath = writes[0]?.filePath;
|
||||
const seenBlocks = new Set<number>();
|
||||
this.validateBatch(sourceContent, writes);
|
||||
const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n";
|
||||
const lines = sourceContent.split(/\r?\n/);
|
||||
|
||||
const sortedWrites = [...writes].sort((left, right) => right.blockStartLine - left.blockStartLine);
|
||||
for (const write of sortedWrites) {
|
||||
this.applyWrite(lines, write);
|
||||
}
|
||||
|
||||
return lines.join(lineEnding);
|
||||
}
|
||||
|
||||
validateBatch(sourceContent: string, writes: MarkerWriteRequest[]): void {
|
||||
const filePath = writes[0]?.filePath;
|
||||
const seenBlocks = new Set<number>();
|
||||
|
||||
for (const write of writes) {
|
||||
if (write.filePath !== filePath) {
|
||||
throw new CardMarkerError("Batch marker writes must belong to the same Markdown file.");
|
||||
|
|
@ -89,21 +100,18 @@ export class CardMarkerService {
|
|||
|
||||
seenBlocks.add(write.blockStartLine);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedWrites = [...writes].sort((left, right) => right.blockStartLine - left.blockStartLine);
|
||||
for (const write of sortedWrites) {
|
||||
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 !== undefined) {
|
||||
lines.splice(write.markerLine - 1, 1);
|
||||
}
|
||||
|
||||
lines.splice(write.contentEndLine, 0, `${write.markerIndent ?? ""}${this.create(write.noteId).raw}`);
|
||||
applyWrite(lines: string[], write: MarkerWriteRequest): void {
|
||||
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}.`);
|
||||
}
|
||||
|
||||
return lines.join(lineEnding);
|
||||
if (write.markerLine !== undefined) {
|
||||
lines.splice(write.markerLine - 1, 1);
|
||||
}
|
||||
|
||||
lines.splice(write.contentEndLine, 0, `${write.markerIndent ?? ""}${this.create(write.noteId).raw}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,9 @@ export class DiffPlannerService {
|
|||
|
||||
plan(cards: IndexedCard[], state: PluginState, scopedFilePaths: string[], settings: PluginSettings): ManualSyncPlan {
|
||||
const cardsBySyncKey = new Map<string, IndexedCard>();
|
||||
const pendingByBlockKey = new Set(state.pendingWriteBack.map((pending) => createPendingWriteBackKey(pending.filePath, pending.blockStartLine, pending.rawBlockHash)));
|
||||
const pendingByBlockKey = new Set(state.pendingWriteBack
|
||||
.filter((pending) => pending.markerKind !== "group-gi")
|
||||
.map((pending) => createPendingWriteBackKey(pending.filePath, pending.blockStartLine, pending.rawBlockHash)));
|
||||
const seenNoteKeys = new Set<string>();
|
||||
const toCreate: PlannedCard[] = [];
|
||||
const toUpdate: PlannedCard[] = [];
|
||||
|
|
|
|||
33
src/domain/manual-sync/services/GroupMarkerService.test.ts
Normal file
33
src/domain/manual-sync/services/GroupMarkerService.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GroupMarkerService } from "./GroupMarkerService";
|
||||
|
||||
describe("GroupMarkerService", () => {
|
||||
it("rewrites the trailing GI marker and removes legacy inner ID markers", () => {
|
||||
const service = new GroupMarkerService();
|
||||
const sourceContent = [
|
||||
"#### Concepts #anki-list",
|
||||
"- Alpha",
|
||||
" - First answer",
|
||||
" <!--ID: 42-->",
|
||||
"- Beta",
|
||||
" - Second answer",
|
||||
"<!--GI:n=42;i=item_a:1;f=2,3,4,5,6,7,8,9,10,11,12-->",
|
||||
].join("\n");
|
||||
|
||||
const nextContent = service.applyBatch(sourceContent, [{
|
||||
syncKey: "notes/example.md\u0000group\u00001\u0000hash",
|
||||
filePath: "notes/example.md",
|
||||
blockStartLine: 1,
|
||||
blockEndLine: 7,
|
||||
markerLine: 7,
|
||||
noteId: 42,
|
||||
itemToSlot: { item_a: 1, item_b: 2 },
|
||||
freeSlots: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
sourceContent,
|
||||
}]);
|
||||
|
||||
expect(nextContent).not.toContain("<!--ID: 42-->");
|
||||
expect(nextContent.split("\n").at(-1)).toBe("<!--GI:n=42;i=item_a:1,item_b:2;f=3,4,5,6,7,8,9,10,11,12-->");
|
||||
});
|
||||
});
|
||||
269
src/domain/manual-sync/services/GroupMarkerService.ts
Normal file
269
src/domain/manual-sync/services/GroupMarkerService.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import type { GroupMarker } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
|
||||
export interface ParsedGroupMarker extends GroupMarker {
|
||||
raw: string;
|
||||
lineIndex: number;
|
||||
}
|
||||
|
||||
export interface GroupMarkerWriteRequest {
|
||||
syncKey: string;
|
||||
filePath: string;
|
||||
blockStartLine: number;
|
||||
blockEndLine: number;
|
||||
markerLine?: number;
|
||||
markerIndent?: string;
|
||||
noteId: number;
|
||||
groupId?: string;
|
||||
itemToSlot: Record<string, number>;
|
||||
freeSlots: number[];
|
||||
sourceContent: string;
|
||||
}
|
||||
|
||||
export class GroupMarkerError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "GroupMarkerError";
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_MARKER_CANDIDATE_REGEXP = /<!--\s*GI:/;
|
||||
const GROUP_MARKER_REGEXP = /^\s*<!--\s*GI:n=([1-9]\d*);i=([^;]*);f=([^;]*)\s*-->\s*$/;
|
||||
const GROUP_ITEM_ID_REGEXP = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
||||
|
||||
export class GroupMarkerService {
|
||||
isCandidate(line: string): boolean {
|
||||
return GROUP_MARKER_CANDIDATE_REGEXP.test(line);
|
||||
}
|
||||
|
||||
parse(line: string, lineIndex: number): ParsedGroupMarker | null {
|
||||
const match = line.match(GROUP_MARKER_REGEXP);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const noteId = Number(match[1]);
|
||||
const itemToSlot = parseItemToSlotMapping(match[2]);
|
||||
const freeSlots = parseFreeSlots(match[3]);
|
||||
validateSlotRanges(itemToSlot, freeSlots);
|
||||
|
||||
return {
|
||||
noteId,
|
||||
itemToSlot,
|
||||
freeSlots,
|
||||
raw: match[0].trim(),
|
||||
lineIndex,
|
||||
};
|
||||
}
|
||||
|
||||
create(noteId: number, itemToSlot: Record<string, number>, freeSlots: number[]): ParsedGroupMarker {
|
||||
validateSlotRanges(itemToSlot, freeSlots);
|
||||
|
||||
return {
|
||||
noteId,
|
||||
itemToSlot: { ...itemToSlot },
|
||||
freeSlots: [...new Set(freeSlots)].sort((left, right) => left - right),
|
||||
raw: serializeGroupMarker(noteId, itemToSlot, freeSlots),
|
||||
lineIndex: -1,
|
||||
};
|
||||
}
|
||||
|
||||
isEquivalent(marker: GroupMarker | undefined, noteId: number, itemToSlot: Record<string, number>, freeSlots: number[]): boolean {
|
||||
if (!marker || marker.noteId !== noteId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingEntries = Object.entries(marker.itemToSlot).sort(compareMarkerEntry);
|
||||
const nextEntries = Object.entries(itemToSlot).sort(compareMarkerEntry);
|
||||
if (existingEntries.length !== nextEntries.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < existingEntries.length; index += 1) {
|
||||
if (existingEntries[index][0] !== nextEntries[index][0] || existingEntries[index][1] !== nextEntries[index][1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const existingFreeSlots = [...marker.freeSlots].sort((left, right) => left - right);
|
||||
const nextFreeSlots = [...freeSlots].sort((left, right) => left - right);
|
||||
if (existingFreeSlots.length !== nextFreeSlots.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return existingFreeSlots.every((slot, index) => slot === nextFreeSlots[index]);
|
||||
}
|
||||
|
||||
validateBatch(sourceContent: string, writes: GroupMarkerWriteRequest[]): void {
|
||||
if (writes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = writes[0]?.filePath;
|
||||
const seenBlocks = new Set<number>();
|
||||
|
||||
for (const write of writes) {
|
||||
if (write.filePath !== filePath) {
|
||||
throw new GroupMarkerError("Batch GI marker writes must belong to the same Markdown file.");
|
||||
}
|
||||
|
||||
if (write.sourceContent !== sourceContent) {
|
||||
throw new GroupMarkerError(`GI marker writes for ${write.filePath} must share the scanned source content.`);
|
||||
}
|
||||
|
||||
if (write.markerLine !== undefined && write.markerLine < write.blockStartLine) {
|
||||
throw new GroupMarkerError(`Cannot replace GI marker outside the heading block in ${write.filePath}.`);
|
||||
}
|
||||
|
||||
if (seenBlocks.has(write.blockStartLine)) {
|
||||
throw new GroupMarkerError(`Duplicate GI marker write detected for block ${write.blockStartLine} in ${write.filePath}.`);
|
||||
}
|
||||
|
||||
seenBlocks.add(write.blockStartLine);
|
||||
}
|
||||
}
|
||||
|
||||
applyBatch(sourceContent: string, writes: GroupMarkerWriteRequest[]): string {
|
||||
if (writes.length === 0) {
|
||||
return sourceContent;
|
||||
}
|
||||
|
||||
this.validateBatch(sourceContent, writes);
|
||||
const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n";
|
||||
const lines = sourceContent.split(/\r?\n/);
|
||||
|
||||
const sortedWrites = [...writes].sort((left, right) => right.blockStartLine - left.blockStartLine);
|
||||
for (const write of sortedWrites) {
|
||||
this.applyWrite(lines, write);
|
||||
}
|
||||
|
||||
return lines.join(lineEnding);
|
||||
}
|
||||
|
||||
applyWrite(lines: string[], write: GroupMarkerWriteRequest): void {
|
||||
const uniqueRemovals = new Set<number>(collectLegacyIdMarkerLineIndexes(lines, write.blockStartLine, write.blockEndLine));
|
||||
if (write.markerLine !== undefined) {
|
||||
uniqueRemovals.add(write.markerLine - 1);
|
||||
}
|
||||
|
||||
const removalIndexes = [...uniqueRemovals].sort((left, right) => right - left);
|
||||
const removalsWithinBlock = [...uniqueRemovals].filter((lineIndex) => lineIndex <= write.blockEndLine - 1).length;
|
||||
const insertionIndex = write.blockEndLine - removalsWithinBlock;
|
||||
|
||||
for (const lineIndex of removalIndexes) {
|
||||
lines.splice(lineIndex, 1);
|
||||
}
|
||||
|
||||
lines.splice(insertionIndex, 0, `${write.markerIndent ?? ""}${serializeGroupMarker(write.noteId, write.itemToSlot, write.freeSlots)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeGroupMarker(noteId: number, itemToSlot: Record<string, number>, freeSlots: number[]): string {
|
||||
validateSlotRanges(itemToSlot, freeSlots);
|
||||
const itemEntries = Object.entries(itemToSlot)
|
||||
.sort((left, right) => left[1] - right[1] || left[0].localeCompare(right[0]))
|
||||
.map(([itemId, slot]) => `${itemId}:${slot}`)
|
||||
.join(",");
|
||||
const freeEntry = [...new Set(freeSlots)].sort((left, right) => left - right).join(",");
|
||||
return `<!--GI:n=${noteId};i=${itemEntries};f=${freeEntry}-->`;
|
||||
}
|
||||
|
||||
function parseItemToSlotMapping(rawValue: string): Record<string, number> {
|
||||
if (!rawValue.trim()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const itemToSlot: Record<string, number> = {};
|
||||
const usedSlots = new Set<number>();
|
||||
|
||||
for (const entry of rawValue.split(",")) {
|
||||
const trimmedEntry = entry.trim();
|
||||
if (!trimmedEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [itemId, slotValue] = trimmedEntry.split(":");
|
||||
if (!itemId || !slotValue || !GROUP_ITEM_ID_REGEXP.test(itemId)) {
|
||||
throw new GroupMarkerError(`Invalid GI item mapping entry: ${entry}`);
|
||||
}
|
||||
|
||||
const slot = Number(slotValue);
|
||||
if (!Number.isInteger(slot) || slot < 1) {
|
||||
throw new GroupMarkerError(`Invalid GI slot value: ${slotValue}`);
|
||||
}
|
||||
|
||||
if (itemToSlot[itemId] !== undefined || usedSlots.has(slot)) {
|
||||
throw new GroupMarkerError(`Duplicate GI mapping entry detected: ${entry}`);
|
||||
}
|
||||
|
||||
itemToSlot[itemId] = slot;
|
||||
usedSlots.add(slot);
|
||||
}
|
||||
|
||||
return itemToSlot;
|
||||
}
|
||||
|
||||
function parseFreeSlots(rawValue: string): number[] {
|
||||
if (!rawValue.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const freeSlots: number[] = [];
|
||||
const seen = new Set<number>();
|
||||
for (const part of rawValue.split(",")) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const slot = Number(trimmed);
|
||||
if (!Number.isInteger(slot) || slot < 1) {
|
||||
throw new GroupMarkerError(`Invalid GI free slot value: ${trimmed}`);
|
||||
}
|
||||
|
||||
if (seen.has(slot)) {
|
||||
throw new GroupMarkerError(`Duplicate GI free slot detected: ${trimmed}`);
|
||||
}
|
||||
|
||||
seen.add(slot);
|
||||
freeSlots.push(slot);
|
||||
}
|
||||
|
||||
return freeSlots.sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function validateSlotRanges(itemToSlot: Record<string, number>, freeSlots: number[]): void {
|
||||
const usedSlots = new Set<number>();
|
||||
for (const slot of Object.values(itemToSlot)) {
|
||||
if (!Number.isInteger(slot) || slot < 1 || slot > 12) {
|
||||
throw new GroupMarkerError(`GI item slot must be an integer between 1 and 12. Received: ${slot}`);
|
||||
}
|
||||
|
||||
usedSlots.add(slot);
|
||||
}
|
||||
|
||||
for (const slot of freeSlots) {
|
||||
if (!Number.isInteger(slot) || slot < 1 || slot > 12) {
|
||||
throw new GroupMarkerError(`GI free slot must be an integer between 1 and 12. Received: ${slot}`);
|
||||
}
|
||||
|
||||
if (usedSlots.has(slot)) {
|
||||
throw new GroupMarkerError(`GI free slot ${slot} overlaps with an occupied slot.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectLegacyIdMarkerLineIndexes(lines: string[], blockStartLine: number, blockEndLine: number): number[] {
|
||||
const indexes: number[] = [];
|
||||
|
||||
for (let lineIndex = blockStartLine; lineIndex < blockEndLine; lineIndex += 1) {
|
||||
if (/^\s*<!--\s*ID:/i.test(lines[lineIndex] ?? "")) {
|
||||
indexes.push(lineIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return indexes;
|
||||
}
|
||||
|
||||
function compareMarkerEntry(left: [string, number], right: [string, number]): number {
|
||||
return left[1] - right[1] || left[0].localeCompare(right[0]);
|
||||
}
|
||||
307
src/domain/manual-sync/services/QaGroupBlockParser.ts
Normal file
307
src/domain/manual-sync/services/QaGroupBlockParser.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import { hashString } from "@/domain/shared/hash";
|
||||
import type { GroupItem, GroupMarkerState } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
|
||||
import { GroupMarkerService, type ParsedGroupMarker } from "./GroupMarkerService";
|
||||
|
||||
const LIST_ITEM_REGEXP = /^([ \t]*)(?:[-+*]|\d+[.)])\s+(.*)$/;
|
||||
|
||||
export interface QaGroupBlockParserInput {
|
||||
parentHeadingText: string;
|
||||
marker: string;
|
||||
bodyLines: string[];
|
||||
bodyStartLine: number;
|
||||
}
|
||||
|
||||
export interface ParsedQaGroupBlock {
|
||||
stem: string;
|
||||
items: GroupItem[];
|
||||
markerState: GroupMarkerState;
|
||||
groupMarker?: ParsedGroupMarker;
|
||||
contentEndLine: number;
|
||||
markerLine?: number;
|
||||
markerIndent?: string;
|
||||
rawBlockText: string;
|
||||
rawBlockHash: string;
|
||||
}
|
||||
|
||||
interface ListItemMatch {
|
||||
index: number;
|
||||
indent: string;
|
||||
labelMarkdown: string;
|
||||
}
|
||||
|
||||
interface ChildRegion {
|
||||
rawLines: string[];
|
||||
}
|
||||
|
||||
export class QaGroupBlockParser {
|
||||
constructor(private readonly groupMarkerService = new GroupMarkerService()) {}
|
||||
|
||||
isQaGroupHeading(headingText: string, marker: string): boolean {
|
||||
return headingText.trimEnd().endsWith(marker);
|
||||
}
|
||||
|
||||
parse(input: QaGroupBlockParserInput): ParsedQaGroupBlock {
|
||||
const stem = stripQaGroupMarker(input.parentHeadingText, input.marker);
|
||||
const trailingMarker = extractTrailingGroupMarker(input.bodyLines, input.bodyStartLine, this.groupMarkerService);
|
||||
const rootItems = collectRootListItems(trailingMarker.bodyLines);
|
||||
const items: GroupItem[] = [];
|
||||
|
||||
for (let index = 0; index < rootItems.length; index += 1) {
|
||||
const item = rootItems[index];
|
||||
const nextItem = rootItems[index + 1];
|
||||
const childRegion = collectChildRegion(
|
||||
trailingMarker.bodyLines,
|
||||
item.index + 1,
|
||||
nextItem?.index ?? trailingMarker.bodyLines.length,
|
||||
item.indent.length,
|
||||
);
|
||||
|
||||
if (!childRegion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstSecondLevelItem = findFirstSecondLevelItem(childRegion.rawLines, item.indent.length);
|
||||
if (!firstSecondLevelItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
title: item.labelMarkdown,
|
||||
answer: firstSecondLevelItem.labelMarkdown,
|
||||
ordinalInMarkdown: index + 1,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedBodyLines = trimBlankEdges(trailingMarker.bodyLines.filter((line) => !isLegacyCardMarkerLine(line)));
|
||||
const rawBlockText = [
|
||||
`qa-group:${stem}`,
|
||||
...items.map((item) => `${item.title}\n${item.answer}`),
|
||||
...normalizedBodyLines,
|
||||
].join("\n").trimEnd();
|
||||
|
||||
return {
|
||||
stem,
|
||||
items,
|
||||
markerState: trailingMarker.markerState,
|
||||
groupMarker: trailingMarker.groupMarker,
|
||||
contentEndLine: findContentEndLine(trailingMarker.bodyLines, input.bodyStartLine, input.bodyStartLine - 1),
|
||||
markerLine: trailingMarker.markerLine,
|
||||
markerIndent: trailingMarker.markerIndent,
|
||||
rawBlockText,
|
||||
rawBlockHash: hashString(rawBlockText),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function stripQaGroupMarker(headingText: string, marker: string): string {
|
||||
const trimmedHeading = headingText.trimEnd();
|
||||
if (!trimmedHeading.endsWith(marker)) {
|
||||
return trimmedHeading;
|
||||
}
|
||||
|
||||
return trimmedHeading.slice(0, trimmedHeading.length - marker.length).trimEnd();
|
||||
}
|
||||
|
||||
function extractTrailingGroupMarker(
|
||||
rawLines: string[],
|
||||
bodyStartLine: number,
|
||||
groupMarkerService: GroupMarkerService,
|
||||
): {
|
||||
bodyLines: string[];
|
||||
markerState: GroupMarkerState;
|
||||
groupMarker?: ParsedGroupMarker;
|
||||
markerLine?: number;
|
||||
markerIndent?: string;
|
||||
} {
|
||||
let lastNonEmptyIndex = rawLines.length - 1;
|
||||
while (lastNonEmptyIndex >= 0 && !rawLines[lastNonEmptyIndex].trim()) {
|
||||
lastNonEmptyIndex -= 1;
|
||||
}
|
||||
|
||||
if (lastNonEmptyIndex < 0) {
|
||||
return {
|
||||
bodyLines: rawLines,
|
||||
markerState: "missing",
|
||||
};
|
||||
}
|
||||
|
||||
const lastLine = rawLines[lastNonEmptyIndex];
|
||||
if (!groupMarkerService.isCandidate(lastLine)) {
|
||||
return {
|
||||
bodyLines: rawLines,
|
||||
markerState: "missing",
|
||||
};
|
||||
}
|
||||
|
||||
const bodyLines = rawLines.filter((_line, index) => index !== lastNonEmptyIndex);
|
||||
const parsedMarker = groupMarkerService.parse(lastLine, bodyStartLine + lastNonEmptyIndex);
|
||||
|
||||
return {
|
||||
bodyLines,
|
||||
markerState: parsedMarker ? "present-valid" : "present-invalid",
|
||||
groupMarker: parsedMarker ?? undefined,
|
||||
markerLine: bodyStartLine + lastNonEmptyIndex,
|
||||
markerIndent: leadingWhitespace(lastLine),
|
||||
};
|
||||
}
|
||||
|
||||
function collectRootListItems(lines: string[]): ListItemMatch[] {
|
||||
const candidates: ListItemMatch[] = [];
|
||||
let fenceMarker: string | null = null;
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
if (isFenceLine(trimmed)) {
|
||||
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fenceMarker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(LIST_ITEM_REGEXP);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
index,
|
||||
indent: match[1],
|
||||
labelMarkdown: match[2].trimEnd(),
|
||||
});
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rootIndentLength = Math.min(...candidates.map((candidate) => candidate.indent.length));
|
||||
return candidates.filter((candidate) => candidate.indent.length === rootIndentLength);
|
||||
}
|
||||
|
||||
function collectChildRegion(
|
||||
lines: string[],
|
||||
startIndex: number,
|
||||
endIndexExclusive: number,
|
||||
parentIndentLength: number,
|
||||
): ChildRegion | null {
|
||||
let childStartIndex: number | undefined;
|
||||
let childEndIndex = startIndex - 1;
|
||||
let fenceMarker: string | null = null;
|
||||
|
||||
for (let index = startIndex; index < endIndexExclusive; index += 1) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
const indentLength = leadingWhitespace(line).length;
|
||||
const insideFence = Boolean(fenceMarker);
|
||||
|
||||
if (childStartIndex === undefined) {
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indentLength <= parentIndentLength) {
|
||||
return null;
|
||||
}
|
||||
|
||||
childStartIndex = index;
|
||||
} else if (!insideFence && trimmed && indentLength <= parentIndentLength) {
|
||||
break;
|
||||
}
|
||||
|
||||
childEndIndex = index;
|
||||
if (isFenceLine(trimmed)) {
|
||||
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
|
||||
}
|
||||
}
|
||||
|
||||
if (childStartIndex === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
rawLines: lines.slice(childStartIndex, childEndIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function findFirstSecondLevelItem(lines: string[], parentIndentLength: number): ListItemMatch | null {
|
||||
const candidates: ListItemMatch[] = [];
|
||||
let fenceMarker: string | null = null;
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
if (isFenceLine(trimmed)) {
|
||||
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fenceMarker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(LIST_ITEM_REGEXP);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const indent = match[1];
|
||||
if (indent.length <= parentIndentLength) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
index,
|
||||
indent,
|
||||
labelMarkdown: match[2].trimEnd(),
|
||||
});
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directIndentLength = Math.min(...candidates.map((candidate) => candidate.indent.length));
|
||||
return candidates.find((candidate) => candidate.indent.length === directIndentLength) ?? null;
|
||||
}
|
||||
|
||||
function findContentEndLine(bodyLines: string[], bodyStartLine: number, fallbackLine: number): number {
|
||||
for (let index = bodyLines.length - 1; index >= 0; index -= 1) {
|
||||
if (bodyLines[index].trim()) {
|
||||
return bodyStartLine + index;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackLine;
|
||||
}
|
||||
|
||||
function trimBlankEdges(lines: string[]): string[] {
|
||||
let startIndex = 0;
|
||||
let endIndex = lines.length;
|
||||
|
||||
while (startIndex < endIndex && !lines[startIndex].trim()) {
|
||||
startIndex += 1;
|
||||
}
|
||||
|
||||
while (endIndex > startIndex && !lines[endIndex - 1].trim()) {
|
||||
endIndex -= 1;
|
||||
}
|
||||
|
||||
return lines.slice(startIndex, endIndex);
|
||||
}
|
||||
|
||||
function leadingWhitespace(line: string): string {
|
||||
const match = line.match(/^[ \t]*/);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
function isFenceLine(trimmedLine: string): boolean {
|
||||
return trimmedLine.startsWith("```") || trimmedLine.startsWith("~~~");
|
||||
}
|
||||
|
||||
function isLegacyCardMarkerLine(line: string): boolean {
|
||||
return /^\s*<!--\s*ID:/i.test(line);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
|
||||
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
|
||||
import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
|
||||
import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
|
||||
import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
|
||||
|
||||
interface AnkiResponse<T> {
|
||||
|
|
@ -11,6 +11,7 @@ interface AnkiResponse<T> {
|
|||
|
||||
interface NoteInfo {
|
||||
cards: number[];
|
||||
fields?: Record<string, { value?: string }>;
|
||||
modelName?: string;
|
||||
noteId?: number;
|
||||
}
|
||||
|
|
@ -26,9 +27,13 @@ interface RawDeckStats {
|
|||
total_in_deck?: number;
|
||||
}
|
||||
|
||||
type ModelTemplates = Record<string, unknown>;
|
||||
type ModelTemplates = Record<string, { Front?: string; Back?: string }>;
|
||||
|
||||
export class AnkiConnectGateway implements AnkiGateway {
|
||||
interface ModelStylingResponse {
|
||||
css?: string;
|
||||
}
|
||||
|
||||
export class AnkiConnectGateway implements AnkiGroupGateway {
|
||||
constructor(private readonly getBaseUrl: () => string) {}
|
||||
|
||||
async ensureDeckExists(deckName: string): Promise<void> {
|
||||
|
|
@ -52,40 +57,82 @@ export class AnkiConnectGateway implements AnkiGateway {
|
|||
return Object.keys(deckNamesAndIds);
|
||||
}
|
||||
|
||||
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
const fieldNames = await this.invoke<string[]>("modelFieldNames", { modelName });
|
||||
let isCloze = modelName.toLowerCase().includes("cloze");
|
||||
|
||||
try {
|
||||
const templates = await this.invoke<ModelTemplates>("modelTemplates", { modelName });
|
||||
isCloze = isCloze || Object.keys(templates).some((templateName) => templateName.toLowerCase().includes("cloze"));
|
||||
} catch {
|
||||
isCloze = isCloze || fieldNames.some((fieldName) => fieldName.toLowerCase() === "text");
|
||||
}
|
||||
|
||||
return {
|
||||
fieldNames,
|
||||
isCloze,
|
||||
};
|
||||
async getModelFieldNames(modelName: string): Promise<string[]> {
|
||||
return this.invoke<string[]>("modelFieldNames", { modelName });
|
||||
}
|
||||
|
||||
async getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
|
||||
if (deckNames.length === 0) {
|
||||
return [];
|
||||
async getModelTemplates(modelName: string): Promise<Record<string, AnkiModelTemplate>> {
|
||||
const templates = await this.invoke<ModelTemplates>("modelTemplates", { modelName });
|
||||
return Object.fromEntries(Object.entries(templates).map(([templateName, template]) => [templateName, {
|
||||
name: templateName,
|
||||
front: template.Front ?? "",
|
||||
back: template.Back ?? "",
|
||||
}]));
|
||||
}
|
||||
|
||||
async getModelStyling(modelName: string): Promise<string> {
|
||||
const styling = await this.invoke<ModelStylingResponse | string>("modelStyling", { modelName });
|
||||
if (typeof styling === "string") {
|
||||
return styling;
|
||||
}
|
||||
|
||||
const deckNamesAndIds = await this.invoke<Record<string, number>>("deckNamesAndIds", {});
|
||||
const rawStats = await this.invoke<unknown>("getDeckStats", {
|
||||
decks: deckNames,
|
||||
return styling.css ?? "";
|
||||
}
|
||||
|
||||
async createModel(input: CreateAnkiModelInput): Promise<void> {
|
||||
await this.invoke("createModel", {
|
||||
modelName: input.modelName,
|
||||
inOrderFields: input.fieldNames,
|
||||
css: input.css,
|
||||
isCloze: Boolean(input.isCloze),
|
||||
cardTemplates: input.templates.map((template) => ({
|
||||
Name: template.name,
|
||||
Front: template.front,
|
||||
Back: template.back,
|
||||
})),
|
||||
});
|
||||
|
||||
return deckNames.map((deckName) => ({
|
||||
deckName,
|
||||
noteCount: extractDeckNoteCount(rawStats, deckNamesAndIds[deckName]),
|
||||
}));
|
||||
}
|
||||
|
||||
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
async addModelField(modelName: string, fieldName: string): Promise<void> {
|
||||
await this.invoke("modelFieldAdd", {
|
||||
modelName,
|
||||
fieldName,
|
||||
});
|
||||
}
|
||||
|
||||
async addModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void> {
|
||||
await this.invoke("modelTemplateAdd", {
|
||||
modelName,
|
||||
templateName: template.name,
|
||||
Front: template.front,
|
||||
Back: template.back,
|
||||
});
|
||||
}
|
||||
|
||||
async updateModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void> {
|
||||
await this.invoke("updateModelTemplates", {
|
||||
model: modelName,
|
||||
templates: {
|
||||
[template.name]: {
|
||||
Front: template.front,
|
||||
Back: template.back,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateModelStyling(modelName: string, css: string): Promise<void> {
|
||||
await this.invoke("updateModelStyling", {
|
||||
model: modelName,
|
||||
css,
|
||||
});
|
||||
}
|
||||
|
||||
async findNoteIds(query: string): Promise<number[]> {
|
||||
return this.invoke<number[]>("findNotes", { query });
|
||||
}
|
||||
|
||||
async getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]> {
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -117,6 +164,7 @@ export class AnkiConnectGateway implements AnkiGateway {
|
|||
}
|
||||
|
||||
const cardIds = Array.isArray(entry.cards) ? entry.cards : [];
|
||||
const fields = Object.fromEntries(Object.entries(entry.fields ?? {}).map(([fieldName, fieldValue]) => [fieldName, fieldValue?.value ?? ""]));
|
||||
|
||||
return [{
|
||||
noteId: entry.noteId,
|
||||
|
|
@ -125,10 +173,53 @@ export class AnkiConnectGateway implements AnkiGateway {
|
|||
deckNames: Array.from(new Set(cardIds
|
||||
.map((cardId) => cardDeckNamesByCardId.get(cardId))
|
||||
.filter((deckName): deckName is string => typeof deckName === "string"))),
|
||||
fields,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
const fieldNames = await this.getModelFieldNames(modelName);
|
||||
let isCloze = modelName.toLowerCase().includes("cloze");
|
||||
|
||||
try {
|
||||
const templates = await this.getModelTemplates(modelName);
|
||||
isCloze = isCloze || Object.keys(templates).some((templateName) => templateName.toLowerCase().includes("cloze"));
|
||||
} catch {
|
||||
isCloze = isCloze || fieldNames.some((fieldName) => fieldName.toLowerCase() === "text");
|
||||
}
|
||||
|
||||
return {
|
||||
fieldNames,
|
||||
isCloze,
|
||||
};
|
||||
}
|
||||
|
||||
async getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
|
||||
if (deckNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const deckNamesAndIds = await this.invoke<Record<string, number>>("deckNamesAndIds", {});
|
||||
const rawStats = await this.invoke<unknown>("getDeckStats", {
|
||||
decks: deckNames,
|
||||
});
|
||||
|
||||
return deckNames.map((deckName) => ({
|
||||
deckName,
|
||||
noteCount: extractDeckNoteCount(rawStats, deckNamesAndIds[deckName]),
|
||||
}));
|
||||
}
|
||||
|
||||
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
return (await this.getNoteDetails(noteIds)).map((note) => ({
|
||||
noteId: note.noteId,
|
||||
modelName: note.modelName,
|
||||
cardIds: note.cardIds,
|
||||
deckNames: note.deckNames,
|
||||
}));
|
||||
}
|
||||
|
||||
async addNote(input: AddAnkiNoteInput): Promise<number> {
|
||||
return this.invoke<number>("addNote", {
|
||||
note: {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ describe("DataJsonPluginConfigRepository", () => {
|
|||
expect(settings.fileDeckTemplate).toBe("obsidian::filename");
|
||||
expect(settings.fileDeckInsertLocation).toBe("body");
|
||||
expect(settings.folderDeckMode).toBe("off");
|
||||
expect(settings.qaGroupMarker).toBe("#anki-list");
|
||||
expect(settings.semanticQaMarker).toBe("#anki-list-qa");
|
||||
expect(settings.semanticQaNoteType).toBe("Semantic QA");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
|
||||
import type { PluginDataStore } from "@/application/ports/PluginDataStore";
|
||||
import { createEmptyPluginState, toNoteIdKey, type CardState, type FileState, type PendingWriteBackState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { createEmptyPluginState, toNoteIdKey, type CardState, type FileState, type GroupBlockState, type PendingWriteBackState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
|
||||
|
||||
import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository";
|
||||
|
||||
|
|
@ -18,9 +18,14 @@ interface LegacyPendingWriteBackState extends Partial<PendingWriteBackState> {
|
|||
noteId?: number;
|
||||
}
|
||||
|
||||
interface LegacyGroupBlockState extends Partial<GroupBlockState> {
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
interface LegacyPluginState {
|
||||
files?: Record<string, LegacyFileState>;
|
||||
cards?: Record<string, LegacyCardState>;
|
||||
groupBlocks?: Record<string, LegacyGroupBlockState>;
|
||||
pendingWriteBack?: LegacyPendingWriteBackState[];
|
||||
}
|
||||
|
||||
|
|
@ -73,12 +78,27 @@ export function migratePluginState(pluginState?: PluginState | LegacyPluginState
|
|||
deckRulesFingerprint: typeof rawFile.deckRulesFingerprint === "string" ? rawFile.deckRulesFingerprint : undefined,
|
||||
lastIndexedAt: typeof rawFile.lastIndexedAt === "number" ? rawFile.lastIndexedAt : 0,
|
||||
noteIds: collectMigratedFileNoteIds(rawFile, rawCards, cards),
|
||||
groupIds: collectMigratedFileGroupIds(rawFile, pluginState.groupBlocks ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
const groupBlocks: Record<string, GroupBlockState> = {};
|
||||
for (const [groupId, rawGroupBlock] of Object.entries(pluginState.groupBlocks ?? {})) {
|
||||
const nextGroupBlock = migrateGroupBlockState(rawGroupBlock, groupId);
|
||||
if (!nextGroupBlock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingGroupBlock = groupBlocks[groupId];
|
||||
if (!existingGroupBlock || existingGroupBlock.lastSyncedAt <= nextGroupBlock.lastSyncedAt) {
|
||||
groupBlocks[groupId] = nextGroupBlock;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
cards,
|
||||
groupBlocks,
|
||||
pendingWriteBack: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -122,6 +142,65 @@ function sanitizeCardType(value: unknown): CardState["cardType"] {
|
|||
return "basic";
|
||||
}
|
||||
|
||||
function migrateGroupBlockState(rawGroupBlock: LegacyGroupBlockState, groupId: string): GroupBlockState | null {
|
||||
const noteId = sanitizeNoteId(rawGroupBlock.noteId);
|
||||
if (noteId === undefined || !groupId.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
groupId,
|
||||
noteId,
|
||||
filePath: typeof rawGroupBlock.filePath === "string" ? rawGroupBlock.filePath : "",
|
||||
headingText: typeof rawGroupBlock.headingText === "string" ? rawGroupBlock.headingText : "",
|
||||
backlinkHeadingText: typeof rawGroupBlock.backlinkHeadingText === "string"
|
||||
? rawGroupBlock.backlinkHeadingText
|
||||
: (typeof rawGroupBlock.headingText === "string" ? rawGroupBlock.headingText : ""),
|
||||
headingLevel: typeof rawGroupBlock.headingLevel === "number" ? rawGroupBlock.headingLevel : 1,
|
||||
stem: typeof rawGroupBlock.stem === "string" ? rawGroupBlock.stem : "",
|
||||
src: typeof rawGroupBlock.src === "string" ? rawGroupBlock.src : "",
|
||||
blockStartOffset: typeof rawGroupBlock.blockStartOffset === "number" ? rawGroupBlock.blockStartOffset : 0,
|
||||
blockEndOffset: typeof rawGroupBlock.blockEndOffset === "number" ? rawGroupBlock.blockEndOffset : 0,
|
||||
blockStartLine: typeof rawGroupBlock.blockStartLine === "number" ? rawGroupBlock.blockStartLine : 1,
|
||||
bodyStartLine: typeof rawGroupBlock.bodyStartLine === "number" ? rawGroupBlock.bodyStartLine : 1,
|
||||
blockEndLine: typeof rawGroupBlock.blockEndLine === "number" ? rawGroupBlock.blockEndLine : 1,
|
||||
contentEndLine: typeof rawGroupBlock.contentEndLine === "number" ? rawGroupBlock.contentEndLine : 1,
|
||||
markerLine: typeof rawGroupBlock.markerLine === "number" ? rawGroupBlock.markerLine : undefined,
|
||||
markerIndent: typeof rawGroupBlock.markerIndent === "string" ? rawGroupBlock.markerIndent : undefined,
|
||||
rawBlockText: typeof rawGroupBlock.rawBlockText === "string" ? rawGroupBlock.rawBlockText : "",
|
||||
rawBlockHash: typeof rawGroupBlock.rawBlockHash === "string" ? rawGroupBlock.rawBlockHash : "",
|
||||
deck: typeof rawGroupBlock.deck === "string" ? rawGroupBlock.deck : "",
|
||||
deckHint: typeof rawGroupBlock.deckHint === "string" ? rawGroupBlock.deckHint : undefined,
|
||||
deckHintSource: rawGroupBlock.deckHintSource === "frontmatter" || rawGroupBlock.deckHintSource === "body" ? rawGroupBlock.deckHintSource : undefined,
|
||||
deckWarnings: Array.isArray(rawGroupBlock.deckWarnings) ? [...rawGroupBlock.deckWarnings] : [],
|
||||
items: Array.isArray(rawGroupBlock.items)
|
||||
? rawGroupBlock.items.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nextItem = item as GroupBlockState["items"][number];
|
||||
if (typeof nextItem.title !== "string" || typeof nextItem.answer !== "string" || typeof nextItem.ordinalInMarkdown !== "number") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
itemId: typeof nextItem.itemId === "string" ? nextItem.itemId : undefined,
|
||||
title: nextItem.title,
|
||||
answer: nextItem.answer,
|
||||
slot: typeof nextItem.slot === "number" ? nextItem.slot : undefined,
|
||||
ordinalInMarkdown: nextItem.ordinalInMarkdown,
|
||||
}];
|
||||
})
|
||||
: [],
|
||||
freeSlots: Array.isArray(rawGroupBlock.freeSlots)
|
||||
? rawGroupBlock.freeSlots.filter((slot): slot is number => typeof slot === "number" && Number.isInteger(slot) && slot > 0)
|
||||
: [],
|
||||
lastSyncedAt: typeof rawGroupBlock.lastSyncedAt === "number" ? rawGroupBlock.lastSyncedAt : 0,
|
||||
orphan: Boolean(rawGroupBlock.orphan),
|
||||
};
|
||||
}
|
||||
|
||||
function collectMigratedFileNoteIds(
|
||||
rawFile: LegacyFileState,
|
||||
rawCards: Record<string, LegacyCardState>,
|
||||
|
|
@ -150,6 +229,17 @@ function collectMigratedFileNoteIds(
|
|||
return Array.from(noteIds);
|
||||
}
|
||||
|
||||
function collectMigratedFileGroupIds(
|
||||
rawFile: LegacyFileState,
|
||||
rawGroupBlocks: Record<string, LegacyGroupBlockState>,
|
||||
): string[] {
|
||||
if (!Array.isArray(rawFile.groupIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawFile.groupIds.filter((groupId): groupId is string => typeof groupId === "string" && Boolean(rawGroupBlocks[groupId]));
|
||||
}
|
||||
|
||||
function sanitizeNoteId(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
|
@ -559,6 +559,18 @@ describe("AnkiHeadingSyncSettingTab", () => {
|
|||
expect(container.textNodes).toContain("Trigger heading example: 城市更新 #semantic-qa");
|
||||
});
|
||||
|
||||
it("saves the QA Group marker independently from the semantic QA marker", async () => {
|
||||
const plugin = new FakePlugin();
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
const container = tab.containerEl as unknown as FakeContainerElInstance;
|
||||
|
||||
tab.display();
|
||||
await getText(findSetting(container, "QA Group marker")).triggerChange("#anki-list-12");
|
||||
|
||||
expect(plugin.settings.qaGroupMarker).toBe("#anki-list-12");
|
||||
expect(plugin.settings.semanticQaMarker).toBe("#anki-list-qa");
|
||||
});
|
||||
|
||||
it("removes the old folder textareas and hides the folder tree in all mode", async () => {
|
||||
const plugin = new FakePlugin();
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { PluginSettingTab, Setting } from "obsidian";
|
||||
|
||||
import { isValidSemanticQaMarker, type FileDeckInsertLocation, type FolderDeckMode, type ScopeMode } from "@/application/config/PluginSettings";
|
||||
import { isValidHashtagMarker, isValidSemanticQaMarker, type FileDeckInsertLocation, type FolderDeckMode, type ScopeMode } from "@/application/config/PluginSettings";
|
||||
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
|
||||
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
|
||||
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
|
||||
|
|
@ -97,6 +97,22 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
});
|
||||
|
||||
containerEl.createEl("h3", { text: "QA Group 12" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("QA Group marker")
|
||||
.setDesc("When a QA heading ends with this hashtag marker, the whole heading block syncs as one ObsiAnki QA Group 12 note.")
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("#anki-list").setValue(settings.qaGroupMarker).onChange(async (value) => {
|
||||
const nextValue = value.trim();
|
||||
if (!nextValue || !isValidHashtagMarker(nextValue) || nextValue === settings.semanticQaMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.plugin.updateSettings({ qaGroupMarker: nextValue });
|
||||
});
|
||||
});
|
||||
|
||||
containerEl.createEl("h3", { text: "Semantic QA" });
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { PluginSettings } from "@/application/config/PluginSettings";
|
||||
import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings";
|
||||
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
|
||||
import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
|
||||
import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
|
||||
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
|
||||
import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
|
||||
import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway";
|
||||
|
|
@ -134,7 +134,7 @@ export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway {
|
|||
}
|
||||
}
|
||||
|
||||
export class FakeManualSyncAnkiGateway implements AnkiGateway {
|
||||
export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
||||
public ensuredDecks: string[][] = [];
|
||||
public addedNotes: AddAnkiNoteInput[] = [];
|
||||
public deletedNotes: number[][] = [];
|
||||
|
|
@ -143,12 +143,21 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway {
|
|||
public deletedDecks: string[][] = [];
|
||||
public storedMedia: MediaAsset[] = [];
|
||||
public noteSummariesById = new Map<number, AnkiNoteSummary>();
|
||||
public noteDetailsById = new Map<number, AnkiNoteDetails>();
|
||||
public deckStatsByName = new Map<string, DeckStat>();
|
||||
public listedDeckNames: string[] | null = null;
|
||||
public createdModels: CreateAnkiModelInput[] = [];
|
||||
public addedModelFields: Array<{ modelName: string; fieldName: string }> = [];
|
||||
public addedModelTemplates: Array<{ modelName: string; template: AnkiModelTemplate }> = [];
|
||||
public updatedModelTemplates: Array<{ modelName: string; template: AnkiModelTemplate }> = [];
|
||||
public updatedModelStyling: Array<{ modelName: string; css: string }> = [];
|
||||
public foundNoteIds = new Map<string, number[]>();
|
||||
public modelDetailsByName: Record<string, NoteModelDetails> = {
|
||||
Basic: { fieldNames: ["Front", "Back"], isCloze: false },
|
||||
Cloze: { fieldNames: ["Text", "Extra"], isCloze: true },
|
||||
};
|
||||
public modelTemplatesByName: Record<string, Record<string, AnkiModelTemplate>> = {};
|
||||
public modelStylingByName: Record<string, string> = {};
|
||||
|
||||
private nextNoteId = 9000;
|
||||
|
||||
|
|
@ -172,6 +181,74 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway {
|
|||
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false };
|
||||
}
|
||||
|
||||
async getModelFieldNames(modelName: string): Promise<string[]> {
|
||||
return this.modelDetailsByName[modelName]?.fieldNames ?? [];
|
||||
}
|
||||
|
||||
async getModelTemplates(modelName: string): Promise<Record<string, AnkiModelTemplate>> {
|
||||
return this.modelTemplatesByName[modelName] ?? {};
|
||||
}
|
||||
|
||||
async getModelStyling(modelName: string): Promise<string> {
|
||||
return this.modelStylingByName[modelName] ?? "";
|
||||
}
|
||||
|
||||
async createModel(input: CreateAnkiModelInput): Promise<void> {
|
||||
this.createdModels.push(input);
|
||||
this.modelDetailsByName[input.modelName] = {
|
||||
fieldNames: [...input.fieldNames],
|
||||
isCloze: Boolean(input.isCloze),
|
||||
};
|
||||
this.modelTemplatesByName[input.modelName] = Object.fromEntries(input.templates.map((template) => [template.name, { ...template }]));
|
||||
this.modelStylingByName[input.modelName] = input.css;
|
||||
}
|
||||
|
||||
async addModelField(modelName: string, fieldName: string): Promise<void> {
|
||||
this.addedModelFields.push({ modelName, fieldName });
|
||||
const existing = this.modelDetailsByName[modelName] ?? { fieldNames: [], isCloze: false };
|
||||
if (!existing.fieldNames.includes(fieldName)) {
|
||||
existing.fieldNames.push(fieldName);
|
||||
}
|
||||
this.modelDetailsByName[modelName] = existing;
|
||||
}
|
||||
|
||||
async addModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void> {
|
||||
this.addedModelTemplates.push({ modelName, template });
|
||||
this.modelTemplatesByName[modelName] = {
|
||||
...(this.modelTemplatesByName[modelName] ?? {}),
|
||||
[template.name]: { ...template },
|
||||
};
|
||||
}
|
||||
|
||||
async updateModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void> {
|
||||
this.updatedModelTemplates.push({ modelName, template });
|
||||
this.modelTemplatesByName[modelName] = {
|
||||
...(this.modelTemplatesByName[modelName] ?? {}),
|
||||
[template.name]: { ...template },
|
||||
};
|
||||
}
|
||||
|
||||
async updateModelStyling(modelName: string, css: string): Promise<void> {
|
||||
this.updatedModelStyling.push({ modelName, css });
|
||||
this.modelStylingByName[modelName] = css;
|
||||
}
|
||||
|
||||
async findNoteIds(query: string): Promise<number[]> {
|
||||
return [...(this.foundNoteIds.get(query) ?? [])];
|
||||
}
|
||||
|
||||
async getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]> {
|
||||
return noteIds.flatMap((noteId) => {
|
||||
const detail = this.noteDetailsById.get(noteId);
|
||||
if (detail) {
|
||||
return [{ ...detail, cardIds: [...detail.cardIds], deckNames: detail.deckNames ? [...detail.deckNames] : undefined, fields: { ...detail.fields } }];
|
||||
}
|
||||
|
||||
const summary = this.noteSummariesById.get(noteId);
|
||||
return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, fields: {} }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
|
||||
return deckNames.map((deckName) => this.deckStatsByName.get(deckName) ?? { deckName });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue