feat: QA Group 重构为用户模板驱动并支持自动字段映射 | Refactor QA Group to user-template-driven route with auto field mapping

This commit is contained in:
Dusk 2026-04-24 23:47:09 +08:00
parent d8bd1ebf12
commit 3d82d7fb55
22 changed files with 2185 additions and 244 deletions

View file

@ -0,0 +1,192 @@
# QA Group 用户模板化实现决策
本文锁定本轮“QA Group 改为用户模板 + 自动字段映射”的仓库兼容实现方案。
## 1. 作用域决策
- 只重构 `qa-group` 路径。
- `basic` / `cloze` / `semantic-qa` 的现有同步主线保持不变。
- 不借机重做普通卡片字段映射体系。
## 2. 默认模型决策
- 三类可见卡片默认模板都改为空:
- `basic`
- `qa-group`
- `cloze`
- `semantic-qa` 继续保持当前隐藏状态,不纳入本轮产品行为变更。
- 设置层允许空模板保存;真正的“必须选择模型”约束放到同步时执行。
## 3. 映射类型决策
- `NoteModelFieldMapping` 升级为联合类型:
- basic-like mapping
- cloze mapping
- qa-group mapping
- 新增:
- `QaGroupSlotMapping = { index; questionField; answerField }`
- `QaGroupFieldMapping = { cardType: "qa-group"; modelName; loadedFieldNames; titleField; slots; warnings; acceptedWarnings?; loadedAt }`
- `qa-group` 不再使用 `bodyField``mainField`
## 4. QA Group 自动识别服务决策
- 新增独立服务:`QaGroupFieldMappingService`。
- 它负责:
- 按优先级识别 title 字段
- 识别连续完整的 question/answer slot
- 输出 warnings
- 校验已保存 mapping 是否仍然有效
- `NoteFieldMappingService` 不再负责 qa-group 的 suggest/map 逻辑。
## 5. title 字段优先级决策
- 按以下顺序做不区分大小写的精确优先匹配:
- `题目`
- `标题`
- `正面`
- `Stem`
- `Title`
- 若均不存在,则识别失败。
## 6. slot 识别规则决策
- 问题字段支持:
- `问题01`
- `问题1`
- `Q01`
- `Q1`
- `S01_Q`
- 答案字段支持:
- `答案01`
- `答案1`
- `A01`
- `A1`
- `S01_A`
- 只启用从 1 开始连续且完整配对的 slot。
- 识别到不完整配对时:
- 记录 warning
- 只保留前面的完整连续 slot
- 如果最小索引不是 1则直接识别失败。
- 旧模型 `Stem + S01_Q/S01_A` 作为普通用户模板兼容支持。
## 7. warnings 与确认决策
- `warnings` 保存当前识别结果。
- `acceptedWarnings` 保存用户已确认忽略的 warning 文本列表。
- 当字段列表变化导致 warning 文本变化时:
- 重新计算 `warnings`
- 清空 `acceptedWarnings`
- 同步前的放行条件是:
- `warnings.length === 0`
- 或 `acceptedWarnings` 与当前 `warnings` 完全一致
## 8. 设置页行为决策
- QA Group 行不再渲染 question/body 下拉框。
- QA Group 行显示:
- note type 下拉
- 自动识别出的 title 字段
- 已识别 slot 数量
- warning 文案
- warning 确认控件
- 如果 qa-group 模型已选且缓存字段可用:
- 自动生成 mapping
- 自动保存到 `noteFieldMappings`
- 如果识别失败:
- 不保存无效 mapping
- 在设置页显示清晰错误
- basic/cloze 仍沿用当前字段下拉行为。
## 9. 刷新按钮决策
- 保持当前优化后的按钮流程:
- `listNoteModels()` 一次
- `getModelFieldNamesByModelNames()` 一次
- 刷新后:
- 普通卡片继续只水合 draft mapping
- QA Group 如果当前模型有缓存字段,则立即重算并持久化 qa-group mapping
## 10. 同步时模型选择与报错决策
- 普通卡片:在 `RenderConfigService` 里对空模型抛出 `PluginUserError`
- QA Group`QaGroupSyncService` 中对空模型抛出 `PluginUserError`
- 这样可以满足“空模型在同步时清晰报错”,又不阻止设置页保存。
## 11. QA Group 主同步决策
- `QaGroupSyncService` 不再依赖:
- `QaGroupModelService.ensureModel()`
- `QA_GROUP_MODEL_NAME`
- `QA_GROUP_SLOT_COUNT`
- `buildQaGroupNoteFields(...)`
- 同步输入改为:
- `settings.cardTypeConfigs["qa-group"].noteType`
- `settings.noteFieldMappings["qa-group:<modelName>"]`
- 同步前校验:
- 已选择模型
- mapping 存在且类型为 `qa-group`
- title 字段存在
- 至少 1 个 slot
- warnings 已确认或不存在
- slot 字段仍存在于当前 Anki 模型
## 12. 写字段决策
- 只写:
- `mapping.titleField`
- 每个 `slot.questionField`
- 每个 `slot.answerField`
- 未使用但已映射的 slot 字段统一写空字符串,避免旧内容残留。
- 不再写:
- `GroupId`
- `Src`
- `Sxx_Id`
## 13. 回链决策
- QA Group 不再把回链写入 `Src` 字段。
- 改为同步时直接把回链字符串拼入用户字段内容:
- `question-last-line` -> 追加到 title 字段
- `answer-first-line` -> 追加到每个已用 answer 字段开头
- `answer-last-line` -> 追加到每个已用 answer 字段末尾
- `addObsidianBacklink = false` 时不写回链。
## 14. 恢复策略决策
- 继续保留本地 state 和 GI marker 作为主要身份恢复来源。
- 删除按 `GroupId` / `Src` 查询 Anki 的恢复分支。
- 若只有 `noteId` 可用,可从 Anki note 内容按保存的 qa-group mapping 恢复 question/answer 展示内容。
- 这种场景下恢复出的 `itemId` 使用临时格式:
- `recovered-slot-01`
- `recovered-slot-02`
## 15. slot 上限与 GI marker 决策
- QA Group 容量来自 `mapping.slots.length`
- `GroupMarkerService` 不再把 slot 范围上限硬编码为 12。
- `freeSlots` 的范围改为依据当前 mapping 可用 slot 集合生成。
## 16. 兼容保留决策
- `ManagedNoteModels.QA_GROUP_MODEL_NAME` 可以保留为兼容常量,但不能再被 QA Group 主同步流程使用。
- `QaGroupModelDefinition` / `QaGroupModelService` 可以保留为兼容或测试工具,但不能再由 Manual Sync 主流程调用。
- 若这些文件保留,其测试也只表示“兼容工具仍可工作”,不再代表主流程行为。
## 17. 测试决策
- 新增 `QaGroupFieldMappingService` 单测,覆盖 title/slot 识别与 warning。
- 调整 `PluginSettingTab.test.ts`,覆盖 QA Group 新 UI、自动识别、warning 确认与确认重置。
- 重写 `QaGroupSyncService.test.ts`,覆盖:
- 用户模板字段写入
- 容量校验
- 回链拼接
- 不写内部字段
- 本地 state / GI marker 仍保留 groupId、itemId、slot
- 调整 `ManualSyncService.test.ts``PluginSettings.test.ts`,去掉对托管模型默认值与 ensureModel 主流程的断言。
## 18. 与草案的受控偏离
- 草案写到“如果 warning 存在,允许保存草稿”。当前仓库没有单独的 qa-group draft 持久层,最兼容实现是:直接把带 warnings 的 qa-group mapping 保存进 `noteFieldMappings`,并用 `acceptedWarnings` 控制同步放行。这样既满足“可保存草稿”,也复用现有 data.json 结构。
- 草案没有显式提 GI marker 的 `1..12` 限制但仓库真实实现里这会直接限制用户模板容量因此本轮会一并解除该硬编码这是为满足“N 不固定”所必须的仓库兼容修正。

View file

@ -0,0 +1,153 @@
# QA Group 改为用户模板的差距审计
本文基于当前仓库真实实现审计“QA Group 从插件托管模型改为用户模板 + 自动字段映射”的落地差距。
## 1. 主同步流程仍强依赖硬编码托管模型
- 当前真实控制点在 [src/application/services/QaGroupSyncService.ts](src/application/services/QaGroupSyncService.ts)。
- 该服务在进入同步时会先调用 `qaGroupModelService.ensureModel()`
- 随后固定使用:
- `QA_GROUP_MODEL_NAME`
- `QA_GROUP_SLOT_COUNT = 12`
- `buildQaGroupNoteFields(...)`
- 这说明 QA Group 目前仍是“插件托管模型”,不是“用户选择模板”。
## 2. 当前 QA Group 仍把内部身份字段写进 Anki
- `buildQaGroupNoteFields(...)` 当前会写入:
- `Stem`
- `GroupId`
- `Src`
- `Sxx_Id`
- `Sxx_Q`
- `Sxx_A`
- 这与新方案冲突:新方案要求 Anki 不再保存 `GroupId / Src / Sxx_Id`
## 3. 当前 QA Group 恢复逻辑仍依赖 Anki 内部字段
- `QaGroupSyncService.resolveRecoveredGroup()` 当前会按以下顺序尝试恢复:
- 本地 state
- `GroupId` 查询
- `noteId`
- `Src` 查询
- 查询语句直接依赖 Anki 字段:
- `GroupId`
- `Src`
- 这与新方案冲突:新方案明确要求 Anki 不再承担内部身份恢复来源。
## 4. 当前 item 槽位上限和 GI marker 仍硬编码为 12
- `QaGroupSyncService` 当前所有容量与 freeSlots 计算都基于 `QA_GROUP_SLOT_COUNT = 12`
- [src/domain/manual-sync/services/GroupMarkerService.ts](src/domain/manual-sync/services/GroupMarkerService.ts) 也把 GI slot 范围限制为 `1..12`
- 新方案要求容量来自用户模板可识别的 `slots.length`,因此这两处都必须从固定 12 脱钩。
## 5. 当前设置层把 QA Group 当作托管默认模型
- [src/application/config/PluginSettings.ts](src/application/config/PluginSettings.ts) 当前默认值是:
- `basic.noteType = "Basic"`
- `qa-group.noteType = QA_GROUP_MODEL_NAME`
- `cloze.noteType = "Cloze"`
- `validateCardTypeConfigs()` 还要求这三类 noteType 不能为空。
- 这与新方案冲突:三类可见卡片的模板默认都应为空,并在同步时给出清晰错误。
## 6. 当前 QA Group 字段映射模型仍是 basic-like
- [src/application/config/NoteModelFieldMapping.ts](src/application/config/NoteModelFieldMapping.ts) 当前只有统一结构:
- `titleField`
- `bodyField`
- `mainField`
- `qa-group` 当前被 [src/application/services/NoteFieldMappingService.ts](src/application/services/NoteFieldMappingService.ts) 当成 basic-like 卡片处理。
- 这与新方案冲突QA Group 需要专用映射结构,至少包含:
- `titleField`
- `slots[]`
- `warnings[]`
- `acceptedWarnings[]`
## 7. 当前设置页 UI 仍把 QA Group 当成单组字段映射
- [src/presentation/settings/PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts) 当前为 `qa-group` 渲染的是:
- note type 下拉
- question field 下拉
- answer field 下拉
- 这与新方案冲突QA Group 行应显示自动识别结果,而不是单组字段下拉。
## 8. 当前设置页缓存已具备字段缓存基础,但尚无 QA Group 专用识别/确认流
- 仓库已经完成:
- `ankiNoteTypeCache`
- `ankiModelFieldCache`
- 设置页打开时缓存优先渲染
- 按钮刷新时 `modelNames + multi(modelFieldNames...)`
- 但当前仍缺:
- QA Group 自动识别服务
- warnings 展示与确认状态
- 字段变化后重算并重置确认状态
- QA Group 映射自动保存
## 9. 当前普通卡片同步路径与 QA Group 路径是分离的
- 普通卡片通过:
- [src/application/services/RenderConfigService.ts](src/application/services/RenderConfigService.ts)
- [src/application/services/AnkiBatchExecutor.ts](src/application/services/AnkiBatchExecutor.ts)
- [src/application/services/NoteFieldMappingService.ts](src/application/services/NoteFieldMappingService.ts)
- QA Group 通过:
- [src/application/services/QaGroupSyncService.ts](src/application/services/QaGroupSyncService.ts)
- 这意味着本轮可以局部改 QA Group 而不必重做普通卡片主线。
## 10. 当前 groupId / itemId 本地持久化已经存在,可直接保留
- [src/domain/manual-sync/entities/PluginState.ts](src/domain/manual-sync/entities/PluginState.ts) 已保存:
- `groupId`
- `noteId`
- `items[]`(含 `itemId``slot`
- `freeSlots`
- [src/domain/manual-sync/services/CardIndexingService.ts](src/domain/manual-sync/services/CardIndexingService.ts) 已优先用:
- GI marker
- 本地 state
- `src` / `rawBlockHash` 状态恢复
- 这与新方案兼容,说明本轮无需改本地状态建模,只需移除 Anki 内部字段恢复依赖。
## 11. 当前回链实现仍依赖 `Src` 字段,而不是写回用户字段内容
- 当前 QA Group 回链位置是通过 `QaGroupModelDefinition` 的模板 HTML 来决定的。
- 也就是说当前回链是:
- 把 URL 写入 `Src`
- 再由模板引用 `{{Src}}`
- 新方案要求反过来处理:
- 不写 `Src`
- 在同步时把回链拼进 title 或 answer 字段内容
## 12. 当前测试面仍围绕旧托管模型设计
- `QaGroupSyncService.test.ts` 当前大量断言:
- 创建模型
- 固定模型名
- `GroupId / Src / Sxx_Id`
- 固定 12 槽
- `ManualSyncService.test.ts` 也断言会创建 QA Group 托管模型。
- `PluginSettingTab.test.ts` 仍把 QA Group 当作 question/body 两个下拉。
- `PluginSettings.test.ts` 仍断言 QA Group 默认 noteType 为 `QA_GROUP_MODEL_NAME`
## 13. 结论
当前仓库与目标方案的主要冲突点有六类:
1. 主同步流程仍调用 `ensureModel()` 并固定使用 `QA_GROUP_MODEL_NAME`
2. QA Group 仍写入 `GroupId / Src / Sxx_Id`
3. QA Group 恢复仍查询 `GroupId / Src`
4. QA Group 映射仍是 basic-like 的 `titleField/bodyField`
5. 设置页仍把 QA Group 当成单组字段映射
6. slot/freeSlots/GI 校验仍固定为 12
当前仓库里可直接复用的基础设施也很明确:
1. 设置页已有缓存优先 + 批量字段读取
2. 本地 group/item state 与 GI marker 已能承担主要身份恢复
3. 普通卡片和 QA Group 同步路径已隔离,便于局部替换
因此本轮最安全的仓库兼容路径是:
1. 保留普通卡片主线不变
2. 为 QA Group 新增专用字段识别与映射模型
3. 把 QA Group 同步改为完全基于“用户选中的 modelName + 保存好的 qa-group mapping”
4. 移除主流程中的 `ensureModel()`、固定模型名依赖和 Anki 内部身份字段写入

View file

@ -0,0 +1,115 @@
# 设置页 Anki 模板缓存实现决策
本文锁定本轮“设置页 Anki 模板加载性能优化”的仓库兼容实现方案。
## 1. 设置模型新增字段缓存
- 保留现有 `ankiNoteTypeCache: string[]`
- 新增:
- `ankiModelFieldCache: Record<string, { fieldNames: string[]; loadedAt: number }>`
- 该缓存只用于设置页 UI 加速。
- 同步路径继续通过 live `getModelDetails()` 做实时校验。
## 2. 字段缓存 normalize 规则
- `ankiNoteTypeCache`
- 继续 trim
- 过滤空字符串
- 去重
- 按稳定字典序保存
- `ankiModelFieldCache`
- 只接受对象
- key 只保留 trim 后非空的模型名
- `fieldNames` 只保留字符串项
- 对每个模型内的字段名trim、过滤空项、去重保留原出现顺序
- `loadedAt` 非有限数字时回退为 0
- 最终按模型名字典序稳定输出
## 3. validate 规则
- 对 `ankiModelFieldCache` 做结构校验:
- 顶层必须是对象
- 每个模型缓存值必须是对象
- `fieldNames` 必须是数组
- 数组项必须全是字符串
- `loadedAt` 必须是有限数字
- normalize 负责“纠偏和过滤”validate 负责拒绝直接传入的非法结构。
## 4. gateway 接口设计
- 在 `AnkiGroupGateway` 中新增:
- `getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>>`
- `AnkiConnectGateway` 内部使用一次 `multi` 请求发送多个 `modelFieldNames` action。
- 单个 action 若返回 `{ error }`:跳过该模型。
- 顶层 `multi` 若失败:抛错。
- 本轮不重构通用 `invokeMulti()`;新增一个专门解析该响应的方法,降低行为风险。
## 5. 设置页卡片 1 的数据来源
- 页面打开时:
- note type 下拉来自 `ankiNoteTypeCache`
- field 下拉来自 `ankiModelFieldCache[selectedModel].fieldNames`
- 若字段缓存存在,则在设置页内即时水合为 `loadedModelDetails` / draft mapping 可消费的形态。
- 若某个已选模板没有字段缓存:
- 不阻塞渲染
- 直接显示“请先读取字段”占位
## 6. 手动刷新按钮的精确流程
- 点击按钮后:
- `listNoteModels()` 一次
- `getModelFieldNamesByModelNames(modelNames)` 一次
- 将两份缓存合并成一次 `updateSettings()`
- 保存成功后:
- 刷新当前设置页实例内的 `availableNoteModels`
- 用字段缓存为 3 个可见卡片类型生成或刷新 draft mapping
- 状态显示:
- `x = 模板数量`
- `y = basic / qa-group / cloze 中,所选模板已有字段缓存的数量`
- 不再在按钮流程里默认调用 `getNoteModelDetails()`
- 不再在按钮流程里默认调用 `modelTemplates()`
## 7. 切换 note type 后的行为
- 切换模板仍立即保存 `cardTypeConfigs[configId].noteType`
- 保存后卡片 1 会局部重渲染。
- 若新模板在 `ankiModelFieldCache` 中已有字段缓存:
- 字段下拉立即显示缓存字段
- draft mapping 若尚不存在,则用 `NoteFieldMappingService.suggest()` 现算一份 UI draft
- 不因为只是切换下拉而立刻访问 AnkiConnect。
## 8. QA Group 行与可见卡片范围
- 继续维持当前设置页只展示三行:
- `basic`
- `qa-group`
- `cloze`
- 不把 `semantic-qa` 加回设置页,避免超出本轮范围。
- `qa-group` 当前仓库已经允许普通 note type / 字段映射编辑;本轮不扩大为产品改动,只沿用仓库现状。
## 9. Cloze 边界
- 设置页只需要字段名缓存和字段建议。
- Cloze 完整兼容性校验继续留在同步链路:
- `getModelDetails()`
- `isCloze` 判定
- 本轮不把 Cloze 严格校验搬到设置页刷新按钮里。
## 10. 卡片 1 局部刷新策略
- 按钮加载状态只存在于 card 1。
- 刷新操作前后只调用 `renderCard("card-types")`
- 不调用整页 `display()`
- 缓存保存尽量合并成一次 `updateSettings()`,避免按钮流程内多次写 settings。
## 11. 测试决策
- 配置层:补 `ankiModelFieldCache` 默认值、normalize、validate 覆盖。
- gateway为新批量字段接口单独补 `multi` 请求与解析测试。
- 设置页:新增缓存优先渲染、刷新按钮调用次数、状态文案、切换模板后即时字段缓存生效等断言。
## 12. 与原计划的受控偏离
- 原计划写到“semantic-qa 继续不显示在设置页”,当前仓库确实只显示三行,因此保持不变。
- 原计划提到“当前 3 个可见卡片类型”,但仓库里的三行是 `basic / qa-group / cloze`,不是 `basic / cloze / semantic-qa`;本轮按仓库真实 UI 执行。
- 原计划说 QA Group 行是托管模型,但当前仓库已经允许 QA Group 行编辑 note type 和字段映射。为了不扩大产品行为回退范围,本轮不顺手收紧它,只做缓存与批量读取优化。

View file

@ -0,0 +1,111 @@
# 设置页 Anki 模板缓存差距审计
本文基于当前仓库真实实现,核对“设置页 Anki 模板加载性能优化方案”的落地差距。
## 1. 当前设置模型只有模板名缓存,没有字段缓存
- `PluginSettings` 当前已经有 `ankiNoteTypeCache: string[]`
- `DEFAULT_SETTINGS`、`normalizePluginSettings()`、`validatePluginSettings()` 也已经接入了该字段。
- 但当前还没有:
- `ankiModelFieldCache`
- 字段缓存的 normalize / validate
- 旧快照缺省该字段时的补齐逻辑测试
- 结果是:重新打开设置页时只能立即显示模板下拉,字段下拉仍依赖实时加载。
## 2. 卡片 1 的“手动读取”流程仍是串行 live 读取
- 设置页入口在 `src/presentation/settings/PluginSettingTab.ts`
- 当前 `loadAnkiCardTypeConfig()` 的真实流程是:
- 先调用一次 `listNoteModels()`
- 保存 `ankiNoteTypeCache`
- 然后对当前可见 3 个卡片类型逐个执行 `getNoteModelDetails(modelName)`
- 这和计划要求冲突:
- 当前不是“1 次 modelNames + 1 次 multi(modelFieldNames...)”
- 当前会对 3 个卡片类型做串行 live 请求
- 当前还会间接触发 `modelTemplates`
## 3. 当前 `getNoteModelDetails()` 会额外拉取模板 HTML
- `AnkiConnectGateway.getModelDetails()` 当前流程:
- `getModelFieldNames(modelName)`
- 再尝试 `getModelTemplates(modelName)`
- 用模板名是否含 cloze 推导 `isCloze`
- 这意味着设置页为了拿字段下拉,也会额外访问 `modelTemplates`
- 计划要求本轮设置页优化不再默认读取 `modelTemplates`,而 Cloze 完整校验继续留在同步路径。
## 4. 当前 gateway 没有批量字段读取接口
- `AnkiGateway` / `AnkiGroupGateway` 目前只有单模型字段读取:
- `getModelFieldNames(modelName)`
- `AnkiConnectGateway` 内部已经有 `invokeMulti()`,但没有面向“批量 modelFieldNames”语义的安全封装。
- 当前缺失的接口是:
- `getModelFieldNamesByModelNames(modelNames)`
- 当前也缺少对 `multi` 单项 `{ result, error }` 包装结构的专门解析。
## 5. 卡片 1 已经具备局部刷新骨架,可直接复用
- 当前设置页已经不是整页 `display()` 重建。
- `display()` 只初始化 5 张卡片外壳,后续通过 `renderCard(cardId)` 做局部刷新。
- `loadAnkiCardTypeConfig()` 当前已经只刷新 `card-types` 卡片,不会整页 `empty()`
- 这与计划一致,说明本轮无需再重做 UI 架构,重点只在卡片 1 数据来源和保存策略。
## 6. 当前字段下拉数据来源只认 draft/live 结果,不认 data.json 字段缓存
- 当前字段下拉来自 `getCurrentMappingForConfig()`
- 优先 draft mapping
- 其次保存好的 `noteFieldMappings`
- 再其次 `loadedModelDetails`
- 如果没有 `loadedModelDetails`,则直接显示“请先读取字段”。
- 当前没有从 settings 中的持久字段缓存反推 `loadedModelDetails` 或直接喂字段下拉。
- 结果是:即便上一次已经读过字段,只要没重新请求,字段下拉在页面重开后仍可能为空。
## 7. 当前“切换模板后立即使用缓存字段”能力不完整
- 当前切换 note type 只会保存 `cardTypeConfigs[configId].noteType`
- 字段下拉能否立刻更新,取决于是否已有 draft mapping 或 `loadedModelDetails`
- 仓库里没有“从 settings 字段缓存即时水合字段列表”的路径。
- 这和计划要求不一致:计划要求切换模板后,若该模板已有缓存字段,应立即进入下拉。
## 8. 现有测试覆盖还停留在旧缓存粒度
- `PluginSettings.test.ts` 目前只覆盖:
- `ankiNoteTypeCache`
- `cardTypeConfigs`
- `AnkiConnectGateway.test.ts` 目前没有:
- 批量字段 multi 请求
- 单项 error 跳过
- 顶层 multi error 抛出
- `PluginSettingTab.test.ts` 目前覆盖了:
- 卡片局部刷新
- 模板名缓存渲染
- 但还没有覆盖:
- data.json 字段缓存渲染字段下拉
- 点击刷新时只打 1 次 `listNoteModels` + 1 次批量字段请求
- 点击刷新不再逐个调用 `getNoteModelDetails`
- 状态文案里的 `x/y` 统计
- 切换模板后立即使用字段缓存
## 9. 当前同步时 Cloze 校验边界是正确的,应保持不动
- 当前实时同步仍通过 `AnkiConnectGateway.getModelDetails()` 走 live 字段 + `isCloze` 判定。
- 这是计划要求保留的严格边界。
- 因此本轮只优化设置页卡片 1不应把同步路径改为依赖缓存字段。
## 10. 结论
当前仓库已经完成了两件可复用基础设施:
- 5 卡片局部刷新壳层
- 模板名缓存 `ankiNoteTypeCache`
本轮还缺三块关键实现:
1. `ankiModelFieldCache` 的设置模型、normalize、validate、持久化与测试
2. gateway 侧基于 `multi(modelFieldNames...)` 的批量字段读取接口
3. 卡片 1 从“live 逐行读取”切换为“缓存优先渲染 + 刷新时两次 AnkiConnect 往返 + 一次保存”
按当前仓库结构,最安全的路径是:
- 保持 `getModelDetails()` 和同步时 Cloze 校验不变
- 新增一个独立批量字段读取方法,不顺手重构通用 multi
- 设置页通过 settings 里的字段缓存水合下拉和 draft mapping

View file

@ -2,16 +2,52 @@ import type { CardType } from "@/domain/card/entities/RenderedFields";
export type NoteModelFieldMappingCardType = CardType | "qa-group";
export interface NoteModelFieldMapping {
interface BaseNoteModelFieldMapping {
cardType: NoteModelFieldMappingCardType;
modelName: string;
loadedFieldNames: string[];
titleField?: string;
bodyField?: string;
mainField?: string;
loadedAt: number;
}
export interface BasicLikeNoteModelFieldMapping extends BaseNoteModelFieldMapping {
cardType: Extract<NoteModelFieldMappingCardType, "basic" | "semantic-qa">;
titleField?: string;
bodyField?: string;
}
export interface ClozeNoteModelFieldMapping extends BaseNoteModelFieldMapping {
cardType: "cloze";
mainField?: string;
}
export interface QaGroupSlotMapping {
index: number;
questionField: string;
answerField: string;
}
export interface QaGroupFieldMapping extends BaseNoteModelFieldMapping {
cardType: "qa-group";
titleField?: string;
slots: QaGroupSlotMapping[];
warnings: string[];
acceptedWarnings?: string[];
}
export type NoteModelFieldMapping = BasicLikeNoteModelFieldMapping | ClozeNoteModelFieldMapping | QaGroupFieldMapping;
export function createNoteFieldMappingKey(cardType: NoteModelFieldMappingCardType, modelName: string): string {
return `${cardType}:${modelName}`;
}
export function isBasicLikeNoteModelFieldMapping(mapping: NoteModelFieldMapping): mapping is BasicLikeNoteModelFieldMapping {
return mapping.cardType === "basic" || mapping.cardType === "semantic-qa";
}
export function isClozeNoteModelFieldMapping(mapping: NoteModelFieldMapping): mapping is ClozeNoteModelFieldMapping {
return mapping.cardType === "cloze";
}
export function isQaGroupFieldMapping(mapping: NoteModelFieldMapping): mapping is QaGroupFieldMapping {
return mapping.cardType === "qa-group";
}

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels";
import { PluginUserError } from "@/application/errors/PluginUserError";
import {
@ -87,19 +86,19 @@ describe("PluginSettings", () => {
enabled: true,
headingLevel: 4,
extraMarker: "",
noteType: "Basic",
noteType: "",
},
"qa-group": {
enabled: true,
headingLevel: 4,
extraMarker: "#anki-list",
noteType: QA_GROUP_MODEL_NAME,
noteType: "",
},
cloze: {
enabled: true,
headingLevel: 5,
extraMarker: "",
noteType: "Cloze",
noteType: "",
},
"semantic-qa": {
enabled: true,
@ -264,7 +263,7 @@ describe("PluginSettings", () => {
enabled: true,
headingLevel: 4,
extraMarker: "#anki-list",
noteType: QA_GROUP_MODEL_NAME,
noteType: "",
},
cloze: {
enabled: true,
@ -346,6 +345,75 @@ describe("PluginSettings", () => {
expect(settings.cardTypeConfigs["qa-group"].noteType).toBe("Custom QA Group");
});
it("allows empty basic qa-group and cloze note types in saved settings", () => {
expect(() => validatePluginSettings({
...DEFAULT_SETTINGS,
cardTypeConfigs: {
...DEFAULT_SETTINGS.cardTypeConfigs,
basic: {
...DEFAULT_SETTINGS.cardTypeConfigs.basic,
noteType: "",
},
"qa-group": {
...DEFAULT_SETTINGS.cardTypeConfigs["qa-group"],
noteType: "",
},
cloze: {
...DEFAULT_SETTINGS.cardTypeConfigs.cloze,
noteType: "",
},
},
})).not.toThrow();
});
it("normalizes legacy qa-group mappings into the new slot structure", () => {
const settings = mergePluginSettings({
noteFieldMappings: {
"qa-group:Custom QA Group": {
cardType: "qa-group",
modelName: "Custom QA Group",
loadedFieldNames: ["题目", "问题01", "答案01", "问题02", "答案02"],
titleField: "题目",
bodyField: "答案01",
loadedAt: 1,
} as never,
},
});
expect(settings.noteFieldMappings["qa-group:Custom QA Group"]).toEqual({
cardType: "qa-group",
modelName: "Custom QA Group",
loadedFieldNames: ["题目", "问题01", "答案01", "问题02", "答案02"],
titleField: "题目",
slots: [
{ index: 1, questionField: "问题01", answerField: "答案01" },
{ index: 2, questionField: "问题02", answerField: "答案02" },
],
warnings: [],
acceptedWarnings: undefined,
loadedAt: 1,
});
});
it("rejects invalid qa-group slot metadata in note field mappings", () => {
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
noteFieldMappings: {
"qa-group:Custom QA Group": {
cardType: "qa-group",
modelName: "Custom QA Group",
loadedFieldNames: ["题目", "问题01", "答案01"],
titleField: "题目",
slots: [{ index: 0, questionField: "问题01", answerField: "答案01" }],
warnings: [],
loadedAt: 1,
},
},
});
}, "errors.settings.noteFieldMappingsQaGroupSlotIndex");
});
it("rejects invalid module 5 enum values", () => {
expectPluginUserError(() => {
validatePluginSettings({

View file

@ -1,6 +1,7 @@
import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels";
import { createNoteFieldMappingKey, isQaGroupFieldMapping, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import { PluginUserError } from "@/application/errors/PluginUserError";
import { QaGroupFieldMappingService } from "@/application/services/QaGroupFieldMappingService";
import type { TranslationKey } from "@/presentation/i18n";
export type ScopeMode = "all" | "include" | "exclude";
export type FileDeckInsertLocation = "yaml" | "body";
@ -30,11 +31,12 @@ export const DEFAULT_OBSIDIAN_BACKLINK_LABEL = "Open in Obsidian";
const DEFAULT_BASIC_HEADING_LEVEL = 4;
const DEFAULT_CLOZE_HEADING_LEVEL = 5;
const DEFAULT_BASIC_NOTE_TYPE = "Basic";
const DEFAULT_CLOZE_NOTE_TYPE = "Cloze";
const DEFAULT_BASIC_NOTE_TYPE = "";
const DEFAULT_CLOZE_NOTE_TYPE = "";
const DEFAULT_SEMANTIC_QA_NOTE_TYPE = "Semantic QA";
const DEFAULT_QA_GROUP_MARKER = "#anki-list";
const DEFAULT_SEMANTIC_QA_MARKER = "#anki-list-qa";
const qaGroupFieldMappingService = new QaGroupFieldMappingService();
export interface PluginSettings {
qaHeadingLevel: number;
@ -125,6 +127,7 @@ export function normalizePluginSettings(settings: PluginSettings): PluginSetting
...settings,
...legacySettings,
cardTypeConfigs,
noteFieldMappings: normalizeNoteFieldMappings(settings.noteFieldMappings),
ankiNoteTypeCache: normalizeAnkiNoteTypeCache(settings.ankiNoteTypeCache),
ankiModelFieldCache: normalizeAnkiModelFieldCache(settings.ankiModelFieldCache),
obsidianBacklinkLabel: normalizeObsidianBacklinkLabel(settings.obsidianBacklinkLabel),
@ -139,7 +142,7 @@ export function mergePluginSettings(settings?: Partial<PluginSettings> | null):
...DEFAULT_SETTINGS,
...partialSettings,
cardTypeConfigs,
noteFieldMappings: partialSettings.noteFieldMappings ?? DEFAULT_SETTINGS.noteFieldMappings,
noteFieldMappings: normalizeNoteFieldMappings(partialSettings.noteFieldMappings ?? DEFAULT_SETTINGS.noteFieldMappings),
ankiModelFieldCache: partialSettings.ankiModelFieldCache ?? DEFAULT_SETTINGS.ankiModelFieldCache,
includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
excludeFolders: partialSettings.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders,
@ -284,18 +287,6 @@ export function validateCardTypeConfigs(cardTypeConfigs: CardTypeConfigs): void
throw new PluginUserError("errors.settings.cardTypeExtraMarkerString");
}
if (configId === "basic" && !config.noteType.trim()) {
throw new PluginUserError("errors.settings.qaNoteTypeRequired");
}
if (configId === "qa-group" && !config.noteType.trim()) {
throw new PluginUserError("errors.settings.qaNoteTypeRequired");
}
if (configId === "cloze" && !config.noteType.trim()) {
throw new PluginUserError("errors.settings.clozeNoteTypeRequired");
}
if (configId === "semantic-qa" && !config.noteType.trim()) {
throw new PluginUserError("errors.settings.semanticQaNoteTypeRequired");
}
@ -337,6 +328,33 @@ function validateNoteFieldMappings(noteFieldMappings: Record<string, NoteModelFi
if (!Number.isFinite(mapping.loadedAt)) {
throw new PluginUserError("errors.settings.noteFieldMappingsLoadedAt");
}
if (isQaGroupFieldMapping(mapping)) {
if (!Array.isArray(mapping.slots)) {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupSlotsArray");
}
for (const slot of mapping.slots) {
if (!slot || typeof slot !== "object" || Array.isArray(slot)) {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupSlotObject");
}
if (!Number.isInteger(slot.index) || slot.index < 1) {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupSlotIndex");
}
if (typeof slot.questionField !== "string") {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupSlotQuestionField");
}
if (typeof slot.answerField !== "string") {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupSlotAnswerField");
}
}
validateStringList(mapping.warnings, "errors.settings.noteFieldMappingsQaGroupWarningsArray", "errors.settings.noteFieldMappingsQaGroupWarningsStrings");
validateStringList(mapping.acceptedWarnings, "errors.settings.noteFieldMappingsQaGroupAcceptedWarningsArray", "errors.settings.noteFieldMappingsQaGroupAcceptedWarningsStrings", true);
}
}
}
@ -352,7 +370,7 @@ function createDefaultCardTypeConfigs(): CardTypeConfigs {
enabled: true,
headingLevel: DEFAULT_BASIC_HEADING_LEVEL,
extraMarker: DEFAULT_QA_GROUP_MARKER,
noteType: QA_GROUP_MODEL_NAME,
noteType: "",
},
cloze: {
enabled: true,
@ -382,7 +400,7 @@ function createLegacyBackfilledCardTypeConfigs(settings: Partial<PluginSettings>
...defaults["qa-group"],
headingLevel: sanitizeHeadingLevel(settings.qaHeadingLevel, defaults["qa-group"].headingLevel),
extraMarker: sanitizeMarker(settings.qaGroupMarker, defaults["qa-group"].extraMarker),
noteType: sanitizeNoteType(settings.cardTypeConfigs?.["qa-group"]?.noteType ?? QA_GROUP_MODEL_NAME, defaults["qa-group"].noteType),
noteType: sanitizeNoteType(settings.cardTypeConfigs?.["qa-group"]?.noteType, defaults["qa-group"].noteType),
},
cloze: {
...defaults.cloze,
@ -456,6 +474,115 @@ function sanitizeNoteType(value: string | undefined, fallback: string): string {
return trimmedValue.length > 0 ? trimmedValue : fallback;
}
function normalizeNoteFieldMappings(value: unknown): Record<string, NoteModelFieldMapping> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const normalizedMappings: Record<string, NoteModelFieldMapping> = {};
for (const rawMapping of Object.values(value)) {
if (!rawMapping || typeof rawMapping !== "object" || Array.isArray(rawMapping)) {
continue;
}
const mapping = rawMapping as Partial<NoteModelFieldMapping> & Record<string, unknown>;
if (mapping.cardType !== "basic" && mapping.cardType !== "qa-group" && mapping.cardType !== "cloze" && mapping.cardType !== "semantic-qa") {
continue;
}
const modelName = typeof mapping.modelName === "string" ? mapping.modelName.trim() : "";
if (modelName.length === 0) {
continue;
}
const loadedFieldNames = normalizeModelFieldNames(mapping.loadedFieldNames);
const loadedAt = typeof mapping.loadedAt === "number" && Number.isFinite(mapping.loadedAt) ? mapping.loadedAt : 0;
if (mapping.cardType === "qa-group") {
const normalizedQaGroupMapping = normalizeQaGroupFieldMapping(mapping, modelName, loadedFieldNames, loadedAt);
normalizedMappings[createNoteFieldMappingKey(normalizedQaGroupMapping.cardType, normalizedQaGroupMapping.modelName)] = normalizedQaGroupMapping;
continue;
}
if (mapping.cardType === "cloze") {
normalizedMappings[createNoteFieldMappingKey(mapping.cardType, modelName)] = {
cardType: mapping.cardType,
modelName,
loadedFieldNames,
mainField: normalizeOptionalFieldName(mapping.mainField),
loadedAt,
};
continue;
}
normalizedMappings[createNoteFieldMappingKey(mapping.cardType, modelName)] = {
cardType: mapping.cardType,
modelName,
loadedFieldNames,
titleField: normalizeOptionalFieldName(mapping.titleField),
bodyField: normalizeOptionalFieldName(mapping.bodyField),
loadedAt,
};
}
return normalizedMappings;
}
function normalizeQaGroupFieldMapping(
mapping: Record<string, unknown>,
modelName: string,
loadedFieldNames: string[],
loadedAt: number,
): NoteModelFieldMapping {
const acceptedWarnings = normalizeStringList(mapping.acceptedWarnings);
const normalizedSlots = normalizeQaGroupSlots(mapping.slots);
const normalizedWarnings = normalizeStringList(mapping.warnings);
const normalizedTitleField = normalizeOptionalFieldName(mapping.titleField);
const hasExplicitQaGroupShape = normalizedSlots.length > 0 || normalizedWarnings.length > 0 || acceptedWarnings.length > 0;
if (hasExplicitQaGroupShape) {
return {
cardType: "qa-group",
modelName,
loadedFieldNames,
titleField: normalizedTitleField,
slots: normalizedSlots,
warnings: normalizedWarnings,
acceptedWarnings: acceptedWarnings.length > 0 ? acceptedWarnings : undefined,
loadedAt,
};
}
if (loadedFieldNames.length > 0) {
try {
return qaGroupFieldMappingService.suggest(modelName, loadedFieldNames, loadedAt, acceptedWarnings);
} catch {
return {
cardType: "qa-group",
modelName,
loadedFieldNames,
titleField: normalizedTitleField,
slots: [],
warnings: [],
acceptedWarnings: undefined,
loadedAt,
};
}
}
return {
cardType: "qa-group",
modelName,
loadedFieldNames,
titleField: normalizedTitleField,
slots: [],
warnings: [],
acceptedWarnings: undefined,
loadedAt,
};
}
function normalizeAnkiNoteTypeCache(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
@ -523,3 +650,87 @@ function normalizeModelFieldNames(value: unknown): string[] {
return normalizedFieldNames;
}
function normalizeQaGroupSlots(value: unknown): { index: number; questionField: string; answerField: string }[] {
if (!Array.isArray(value)) {
return [];
}
const normalizedSlots: { index: number; questionField: string; answerField: string }[] = [];
for (const slot of value) {
if (!slot || typeof slot !== "object" || Array.isArray(slot)) {
continue;
}
const rawSlot = slot as Record<string, unknown>;
if (!Number.isInteger(rawSlot.index) || (rawSlot.index as number) < 1) {
continue;
}
const questionField = normalizeOptionalFieldName(rawSlot.questionField);
const answerField = normalizeOptionalFieldName(rawSlot.answerField);
if (!questionField || !answerField) {
continue;
}
normalizedSlots.push({
index: rawSlot.index as number,
questionField,
answerField,
});
}
return normalizedSlots;
}
function normalizeOptionalFieldName(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmedValue = value.trim();
return trimmedValue.length > 0 ? trimmedValue : undefined;
}
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const normalizedValues: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
continue;
}
const trimmedEntry = entry.trim();
if (trimmedEntry.length === 0) {
continue;
}
normalizedValues.push(trimmedEntry);
}
return normalizedValues;
}
function validateStringList(
value: unknown,
arrayKey: TranslationKey,
stringKey: TranslationKey,
allowUndefined = false,
): void {
if (typeof value === "undefined" && allowUndefined) {
return;
}
if (!Array.isArray(value)) {
throw new PluginUserError(arrayKey);
}
for (const entry of value) {
if (typeof entry !== "string") {
throw new PluginUserError(stringKey);
}
}
}

View file

@ -1,6 +1,5 @@
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";
@ -8,7 +7,7 @@ import type { CardState } from "@/domain/manual-sync/entities/PluginState";
import { hashString } from "@/domain/shared/hash";
import { CurrentFileOutOfScopeError, ManualSyncService } from "./ManualSyncService";
import { createModule3Settings, FakeManualSyncAnkiGateway, FakeManualSyncVaultGateway, InMemoryPluginStateRepository } from "@/test-support/manualSyncFakes";
import { createModule3Settings, FakeManualSyncAnkiGateway, FakeManualSyncVaultGateway, InMemoryPluginStateRepository, QA_GROUP_USER_NOTE_TYPE } from "@/test-support/manualSyncFakes";
describe("ManualSyncService", () => {
it("syncs one file, creates Anki notes, writes the new marker format, and saves plugin state", async () => {
@ -50,7 +49,7 @@ 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 () => {
it("syncs a #anki-list block into one user-template QA Group note and writes one GI marker", async () => {
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": [
"#### Concepts #anki-list",
@ -68,17 +67,16 @@ describe("ManualSyncService", () => {
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.createdModels).toEqual([]);
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_USER_NOTE_TYPE);
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
Stem: "Concepts",
Src: "obsidian://open?vault=Vault&file=notes/example.md#Concepts #anki-list",
S01_Q: "Alpha",
S01_A: "First answer",
S02_Q: "Beta",
S02_A: "Second answer",
: "Concepts",
01: "Alpha",
答案01: expect.stringContaining("First answer"),
02: "Beta",
答案02: expect.stringContaining("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(vaultGateway.getFileContent("notes/example.md")).toMatch(/<!--GI:n=9001;i=[^;]+;f=3-->/);
expect(Object.values(stateRepository.savedState?.groupBlocks ?? {})).toHaveLength(1);
expect(stateRepository.savedState?.files["notes/example.md"]?.groupIds).toHaveLength(1);
});

View file

@ -1,4 +1,12 @@
import { createNoteFieldMappingKey, type NoteModelFieldMapping, type NoteModelFieldMappingCardType } from "@/application/config/NoteModelFieldMapping";
import {
createNoteFieldMappingKey,
isBasicLikeNoteModelFieldMapping,
isClozeNoteModelFieldMapping,
type BasicLikeNoteModelFieldMapping,
type ClozeNoteModelFieldMapping,
type NoteModelFieldMapping,
type NoteModelFieldMappingCardType,
} from "@/application/config/NoteModelFieldMapping";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import { PluginUserError } from "@/application/errors/PluginUserError";
import { isBasicLikeCardType, type CardType, type RenderedFields } from "@/domain/card/entities/RenderedFields";
@ -27,13 +35,17 @@ export class NoteFieldMappingService {
this.validateMapping(mapping, noteModelDetails);
if (isBasicLikeCardType(card.type)) {
return this.mapBasic(card, mapping);
return this.mapBasic(card, mapping as BasicLikeNoteModelFieldMapping);
}
return this.mapCloze(card, noteModelDetails, mapping);
return this.mapCloze(card, noteModelDetails, mapping as ClozeNoteModelFieldMapping);
}
suggest(cardType: NoteModelFieldMappingCardType, modelName: string, fieldNames: string[], loadedAt = Date.now()): NoteModelFieldMapping {
if (cardType === "qa-group") {
throw new Error("QaGroupFieldMappingService must be used for qa-group mappings.");
}
return isBasicLikeMappingCardType(cardType)
? {
cardType,
@ -55,9 +67,13 @@ export class NoteFieldMappingService {
}
validateMapping(mapping: NoteModelFieldMapping, noteModelDetails: NoteModelDetails): void {
if (!isBasicLikeNoteModelFieldMapping(mapping) && !isClozeNoteModelFieldMapping(mapping)) {
throw new Error("QaGroupFieldMappingService validates qa-group mappings.");
}
const availableFields = new Set(noteModelDetails.fieldNames);
if (isBasicLikeMappingCardType(mapping.cardType)) {
if (isBasicLikeNoteModelFieldMapping(mapping)) {
if (!mapping.titleField || !mapping.bodyField) {
throw new PluginUserError(getIncompleteSavedMappingKey(mapping.cardType), {
modelName: mapping.modelName,
@ -101,7 +117,7 @@ export class NoteFieldMappingService {
}
}
private mapBasic(card: RenderedCardInput, mapping: NoteModelFieldMapping): Record<string, string> {
private mapBasic(card: RenderedCardInput, mapping: BasicLikeNoteModelFieldMapping): Record<string, string> {
const titleFieldName = mapping.titleField;
const bodyFieldName = mapping.bodyField;
@ -117,7 +133,7 @@ export class NoteFieldMappingService {
};
}
private mapCloze(card: RenderedCardInput, noteModelDetails: NoteModelDetails, mapping: NoteModelFieldMapping): Record<string, string> {
private mapCloze(card: RenderedCardInput, noteModelDetails: NoteModelDetails, mapping: ClozeNoteModelFieldMapping): Record<string, string> {
if (!noteModelDetails.isCloze) {
throw new PluginUserError("errors.noteFieldMapping.clozeIncompatible", {
modelName: card.noteModel,
@ -151,8 +167,8 @@ export class NoteFieldMappingService {
}
}
function isBasicLikeMappingCardType(cardType: NoteModelFieldMappingCardType): cardType is Extract<NoteModelFieldMappingCardType, "basic" | "qa-group" | "semantic-qa"> {
return cardType === "qa-group" || cardType === "basic" || cardType === "semantic-qa";
function isBasicLikeMappingCardType(cardType: NoteModelFieldMappingCardType): cardType is Extract<NoteModelFieldMappingCardType, "basic" | "semantic-qa"> {
return cardType === "basic" || cardType === "semantic-qa";
}
function getMissingSavedMappingKey(cardType: CardType): "errors.noteFieldMapping.missingSavedMapping.basic" | "errors.noteFieldMapping.missingSavedMapping.cloze" | "errors.noteFieldMapping.missingSavedMapping.semanticQa" {
@ -175,7 +191,7 @@ function getIncompleteSavedMappingKey(cardType: NoteModelFieldMappingCardType):
: "errors.noteFieldMapping.incompleteSavedMapping.basic";
}
function getTitleBodyMustDifferKey(cardType: Extract<NoteModelFieldMappingCardType, "basic" | "qa-group" | "semantic-qa">): "errors.noteFieldMapping.titleBodyMustDiffer.basic" | "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" {
function getTitleBodyMustDifferKey(cardType: Extract<NoteModelFieldMappingCardType, "basic" | "semantic-qa">): "errors.noteFieldMapping.titleBodyMustDiffer.basic" | "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" {
return cardType === "semantic-qa"
? "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa"
: "errors.noteFieldMapping.titleBodyMustDiffer.basic";

View file

@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { PluginUserError } from "@/application/errors/PluginUserError";
import {
areQaGroupWarningsAccepted,
parseQaGroupFieldWarning,
QaGroupFieldMappingService,
} from "./QaGroupFieldMappingService";
function expectPluginUserError(action: () => void): PluginUserError {
try {
action();
} catch (error) {
expect(error).toBeInstanceOf(PluginUserError);
return error as PluginUserError;
}
throw new Error("Expected PluginUserError");
}
describe("QaGroupFieldMappingService", () => {
it("detects a preferred title field and continuous Chinese QA slots", () => {
const service = new QaGroupFieldMappingService();
const mapping = service.suggest("问答题(多级列表)", ["题目", "问题01", "答案01", "问题02", "答案02"], 123);
expect(mapping).toEqual({
cardType: "qa-group",
modelName: "问答题(多级列表)",
loadedFieldNames: ["题目", "问题01", "答案01", "问题02", "答案02"],
titleField: "题目",
slots: [
{
index: 1,
questionField: "问题01",
answerField: "答案01",
},
{
index: 2,
questionField: "问题02",
answerField: "答案02",
},
],
warnings: [],
acceptedWarnings: undefined,
loadedAt: 123,
});
});
it("supports the legacy Stem plus Sxx_Q/Sxx_A shape as a normal user template", () => {
const service = new QaGroupFieldMappingService();
const mapping = service.suggest("ObsiAnki QA Group 12", ["Stem", "S01_Q", "S01_A", "S02_Q", "S02_A"]);
expect(mapping.titleField).toBe("Stem");
expect(mapping.slots).toEqual([
{ index: 1, questionField: "S01_Q", answerField: "S01_A" },
{ index: 2, questionField: "S02_Q", answerField: "S02_A" },
]);
});
it("records warnings for incomplete tail slots and preserves accepted warnings only when they still match", () => {
const service = new QaGroupFieldMappingService();
const mapping = service.suggest("问答题(多级列表)", ["题目", "问题01", "答案01", "问题02"], 1);
expect(mapping.slots).toEqual([
{ index: 1, questionField: "问题01", answerField: "答案01" },
]);
expect(mapping.warnings).toHaveLength(1);
expect(parseQaGroupFieldWarning(mapping.warnings[0] ?? "")).toEqual({
kind: "missing-answer",
index: 2,
questionField: "问题02",
answerField: "答案02",
});
expect(mapping.acceptedWarnings).toBeUndefined();
const accepted = service.suggest("问答题(多级列表)", ["题目", "问题01", "答案01", "问题02"], 1, mapping.warnings);
expect(accepted.acceptedWarnings).toEqual(accepted.warnings);
expect(areQaGroupWarningsAccepted(accepted.warnings, accepted.acceptedWarnings)).toBe(true);
const reset = service.suggest("问答题(多级列表)", ["题目", "问题01", "答案01", "问题02", "答案03"], 1, mapping.warnings);
expect(reset.acceptedWarnings).toBeUndefined();
});
it("throws when no preferred title field exists", () => {
const service = new QaGroupFieldMappingService();
const error = expectPluginUserError(() => service.suggest("问答题(多级列表)", ["问题01", "答案01"]));
expect(error.userMessage.key).toBe("errors.noteFieldMapping.qaGroupMissingTitle");
expect(error.userMessage.params).toEqual({ modelName: "问答题(多级列表)" });
});
it("throws when the first detected slot does not start from one", () => {
const service = new QaGroupFieldMappingService();
const error = expectPluginUserError(() => service.suggest("问答题(多级列表)", ["题目", "问题02", "答案02"]));
expect(error.userMessage.key).toBe("errors.noteFieldMapping.qaGroupNonContinuousSlots");
expect(error.userMessage.params).toEqual({
modelName: "问答题(多级列表)",
firstIndex: 2,
});
});
});

View file

@ -0,0 +1,224 @@
import type { QaGroupFieldMapping, QaGroupSlotMapping } from "@/application/config/NoteModelFieldMapping";
import { PluginUserError } from "@/application/errors/PluginUserError";
type QaGroupFieldWarningKind = "missing-answer" | "missing-question";
interface QaGroupFieldWarningPayload {
kind: QaGroupFieldWarningKind;
index: number;
questionField: string;
answerField: string;
}
interface QaGroupDetectedFields {
slots: QaGroupSlotMapping[];
warnings: string[];
}
const TITLE_FIELD_PRIORITY = ["题目", "标题", "正面", "Stem", "Title"] as const;
const QUESTION_FIELD_PATTERNS = [/^问题0*(\d+)$/i, /^Q0*(\d+)$/i, /^S0*(\d+)_Q$/i] as const;
const ANSWER_FIELD_PATTERNS = [/^答案0*(\d+)$/i, /^A0*(\d+)$/i, /^S0*(\d+)_A$/i] as const;
export class QaGroupFieldMappingService {
suggest(
modelName: string,
fieldNames: string[],
loadedAt = Date.now(),
acceptedWarnings?: string[],
): QaGroupFieldMapping {
const titleField = findFieldName(fieldNames, TITLE_FIELD_PRIORITY);
if (!titleField) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupMissingTitle", {
modelName,
});
}
const detectedFields = detectSlots(fieldNames, modelName);
return {
cardType: "qa-group",
modelName,
loadedFieldNames: [...fieldNames],
titleField,
slots: detectedFields.slots,
warnings: detectedFields.warnings,
acceptedWarnings: detectedFields.warnings.length > 0 && areQaGroupWarningsAccepted(detectedFields.warnings, acceptedWarnings)
? [...detectedFields.warnings]
: undefined,
loadedAt,
};
}
validateMapping(mapping: QaGroupFieldMapping, fieldNames: string[]): void {
if (!mapping.titleField) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupMissingTitle", {
modelName: mapping.modelName,
});
}
if (mapping.slots.length === 0) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupNoCompleteSlots", {
modelName: mapping.modelName,
});
}
const availableFields = new Set(fieldNames);
const missingFields = new Set<string>();
if (!availableFields.has(mapping.titleField)) {
missingFields.add(mapping.titleField);
}
for (const slot of mapping.slots) {
if (!availableFields.has(slot.questionField)) {
missingFields.add(slot.questionField);
}
if (!availableFields.has(slot.answerField)) {
missingFields.add(slot.answerField);
}
}
if (missingFields.size > 0) {
throw new PluginUserError("errors.noteFieldMapping.stale", {
modelName: mapping.modelName,
fields: [...missingFields].map((fieldName) => `"${fieldName}"`),
});
}
}
validateWarningsAccepted(mapping: QaGroupFieldMapping): void {
if (!mapping.warnings.length || areQaGroupWarningsAccepted(mapping.warnings, mapping.acceptedWarnings)) {
return;
}
throw new PluginUserError("errors.noteFieldMapping.qaGroupWarningsUnaccepted", {
modelName: mapping.modelName,
});
}
}
export function areQaGroupWarningsAccepted(warnings: string[], acceptedWarnings?: string[]): boolean {
if (warnings.length === 0) {
return true;
}
if (!acceptedWarnings || warnings.length !== acceptedWarnings.length) {
return false;
}
return warnings.every((warning, index) => warning === acceptedWarnings[index]);
}
export function parseQaGroupFieldWarning(warning: string): QaGroupFieldWarningPayload | undefined {
try {
const parsed = JSON.parse(warning) as Partial<QaGroupFieldWarningPayload>;
if (
(parsed.kind !== "missing-answer" && parsed.kind !== "missing-question") ||
typeof parsed.index !== "number" ||
!Number.isInteger(parsed.index) ||
parsed.index < 1 ||
typeof parsed.questionField !== "string" ||
typeof parsed.answerField !== "string"
) {
return undefined;
}
return {
kind: parsed.kind,
index: parsed.index,
questionField: parsed.questionField,
answerField: parsed.answerField,
};
} catch {
return undefined;
}
}
function detectSlots(fieldNames: string[], modelName: string): QaGroupDetectedFields {
const questionFieldByIndex = new Map<number, string>();
const answerFieldByIndex = new Map<number, string>();
for (const fieldName of fieldNames) {
const questionIndex = detectIndexedField(fieldName, QUESTION_FIELD_PATTERNS);
if (questionIndex && !questionFieldByIndex.has(questionIndex)) {
questionFieldByIndex.set(questionIndex, fieldName);
continue;
}
const answerIndex = detectIndexedField(fieldName, ANSWER_FIELD_PATTERNS);
if (answerIndex && !answerFieldByIndex.has(answerIndex)) {
answerFieldByIndex.set(answerIndex, fieldName);
}
}
const candidateIndices = [...new Set([...questionFieldByIndex.keys(), ...answerFieldByIndex.keys()])].sort((left, right) => left - right);
if (candidateIndices.length > 0 && candidateIndices[0] !== 1) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupNonContinuousSlots", {
modelName,
firstIndex: candidateIndices[0],
});
}
const slots: QaGroupSlotMapping[] = [];
const warnings: string[] = [];
for (const index of candidateIndices) {
const questionField = questionFieldByIndex.get(index);
const answerField = answerFieldByIndex.get(index);
if (!questionField || !answerField) {
warnings.push(serializeQaGroupFieldWarning({
kind: questionField ? "missing-answer" : "missing-question",
index,
questionField: questionField ?? preferredSlotFieldName("question", index),
answerField: answerField ?? preferredSlotFieldName("answer", index),
}));
continue;
}
if (index !== slots.length + 1) {
break;
}
slots.push({
index,
questionField,
answerField,
});
}
if (slots.length === 0) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupNoCompleteSlots", {
modelName,
});
}
return { slots, warnings };
}
function detectIndexedField(fieldName: string, patterns: readonly RegExp[]): number | undefined {
for (const pattern of patterns) {
const match = fieldName.match(pattern);
if (!match) {
continue;
}
const index = Number.parseInt(match[1] ?? "", 10);
if (Number.isInteger(index) && index > 0) {
return index;
}
}
return undefined;
}
function findFieldName(fieldNames: string[], preferredNames: readonly string[]): string | undefined {
return fieldNames.find((fieldName) => preferredNames.some((preferredName) => preferredName.toLowerCase() === fieldName.toLowerCase()));
}
function serializeQaGroupFieldWarning(warning: QaGroupFieldWarningPayload): string {
return JSON.stringify(warning);
}
function preferredSlotFieldName(kind: "question" | "answer", index: number): string {
return `${kind === "question" ? "问题" : "答案"}${String(index).padStart(2, "0")}`;
}

View file

@ -1,14 +1,14 @@
import { describe, expect, it } from "vitest";
import { buildGroupSrc, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
import { buildGroupSrc, type GroupItem, 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, FakeManualSyncVaultGateway } from "@/test-support/manualSyncFakes";
import { applyObsidianBacklinkPlacement, renderObsidianBacklinkAnchor } from "@/domain/shared/renderObsidianBacklink";
import { createModule3Settings, FakeManualSyncAnkiGateway, FakeManualSyncVaultGateway, QA_GROUP_USER_NOTE_TYPE } from "@/test-support/manualSyncFakes";
import { QaGroupSyncService } from "./QaGroupSyncService";
describe("QaGroupSyncService", () => {
it("creates one QA Group note and assigns fresh item ids and slots", async () => {
it("creates one QA Group note from the user-selected template and assigns fresh item ids and slots", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const vaultGateway = new FakeManualSyncVaultGateway();
const itemIds = ["item-1", "item-2"];
@ -26,21 +26,22 @@ describe("QaGroupSyncService", () => {
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: "obsidian://open?vault=Vault&file=notes/example.md#Concepts #anki-list",
S01_Q: "Alpha",
S01_A: "First answer",
S02_Q: "Beta",
S02_A: "Second answer",
});
expect(ankiGateway.createdModels).toEqual([]);
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_USER_NOTE_TYPE);
expect(ankiGateway.addedNotes[0]?.fields.).toBe("Concepts");
expect(ankiGateway.addedNotes[0]?.fields.01).toBe("Alpha");
expect(ankiGateway.addedNotes[0]?.fields.01).toContain("First answer");
expect(ankiGateway.addedNotes[0]?.fields.01).toContain("anki-heading-sync-backlink");
expect(ankiGateway.addedNotes[0]?.fields.02).toBe("Beta");
expect(ankiGateway.addedNotes[0]?.fields.02).toContain("Second answer");
expect(ankiGateway.addedNotes[0]?.fields).not.toHaveProperty("GroupId");
expect(ankiGateway.addedNotes[0]?.fields).not.toHaveProperty("Src");
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 });
expect(result.markerWrites[0]?.freeSlots).toEqual([3]);
});
it("renders QA Group stem, questions, and answers as inline Markdown HTML", async () => {
@ -72,15 +73,15 @@ describe("QaGroupSyncService", () => {
expect(result.created).toBe(1);
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
Stem: "Concepts <em>Stem</em>",
S01_Q: "Alpha <strong>bold</strong>",
: "Concepts <em>Stem</em>",
01: "Alpha <strong>bold</strong>",
});
expect(ankiGateway.addedNotes[0]?.fields.S01_A).toContain("First <em>answer</em> and <mark>mark</mark>");
expect(ankiGateway.addedNotes[0]?.fields.S01_A).toContain('class="ahs-ob-tag"');
expect(ankiGateway.addedNotes[0]?.fields.S01_A).toContain('data-tag="3地区"');
expect(ankiGateway.addedNotes[0]?.fields.01).toContain("First <em>answer</em> and <mark>mark</mark>");
expect(ankiGateway.addedNotes[0]?.fields.01).toContain('class="ahs-ob-tag"');
expect(ankiGateway.addedNotes[0]?.fields.01).toContain('data-tag="3地区"');
});
it("ensures the QA Group model using the current backlink settings before syncing", async () => {
it("writes the backlink directly into user fields instead of a managed Src field", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const vaultGateway = new FakeManualSyncVaultGateway();
const service = new QaGroupSyncService(
@ -97,12 +98,14 @@ describe("QaGroupSyncService", () => {
await service.sync([
createIndexedGroupBlock(),
], createEmptyPluginState(), createModule3Settings({
obsidianBacklinkLabel: 'Open <Vault>',
obsidianBacklinkLabel: "Open <Vault>",
obsidianBacklinkPlacement: "answer-first-line",
}));
expect(ankiGateway.createdModels[0]?.templates[0]?.back).toContain('Open &lt;Vault&gt;');
expect(ankiGateway.createdModels[0]?.templates[0]?.back).toContain('<hr id="answer">\n\n{{#Src}}<p><a class="anki-heading-sync-backlink" href="{{Src}}">Open &lt;Vault&gt;</a></p>{{/Src}}\n<div class="a">{{S01_A}}</div>');
expect(ankiGateway.createdModels).toEqual([]);
expect(ankiGateway.addedNotes[0]?.fields.01).toContain('Open &lt;Vault&gt;');
expect(ankiGateway.addedNotes[0]?.fields.01).toMatch(/^<p><a class="anki-heading-sync-backlink"/);
expect(ankiGateway.addedNotes[0]?.fields).not.toHaveProperty("Src");
});
it("preserves slots across reorder and reuses a free slot for a new item", async () => {
@ -111,10 +114,10 @@ describe("QaGroupSyncService", () => {
const existingState = createStoredGroupBlockState();
ankiGateway.noteDetailsById.set(42, {
noteId: 42,
modelName: QA_GROUP_MODEL_NAME,
modelName: QA_GROUP_USER_NOTE_TYPE,
cardIds: [7001, 7002],
deckNames: ["notes"],
fields: buildQaGroupNoteFields(existingState.stem, existingState.groupId, existingState.src, existingState.items),
fields: buildQaGroupUserTemplateFields(existingState.stem, existingState.items),
});
const service = new QaGroupSyncService(
ankiGateway,
@ -193,12 +196,12 @@ describe("QaGroupSyncService", () => {
expect(result.created).toBe(1);
expect(result.updated).toBe(0);
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_MODEL_NAME);
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_USER_NOTE_TYPE);
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
GroupId: "group-1",
S01_Q: "Alpha",
S02_Q: "Gamma",
S03_Q: "Beta",
: "Concepts",
01: "Alpha",
02: "Gamma",
03: "Beta",
});
expect(result.syncedGroupBlocks[0]?.groupId).toBe("group-1");
expect(result.syncedGroupBlocks[0]?.noteId).toBeDefined();
@ -238,9 +241,8 @@ describe("QaGroupSyncService", () => {
expect(result.touchedSyncKeys).toContain("notes/example.md\u0000group\u00001\u0000hash-1");
expect(ankiGateway.addedNotes).toHaveLength(1);
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
GroupId: "group-1",
S01_Q: "Alpha",
S03_Q: "Beta",
01: "Alpha",
03: "Beta",
});
expect(result.markerWrites[0]?.noteId).toBe(result.syncedGroupBlocks[0]?.noteId);
expect(result.syncedGroupBlocks[0]?.noteId).not.toBe(42);
@ -252,10 +254,10 @@ describe("QaGroupSyncService", () => {
const existingState = createStoredGroupBlockState();
ankiGateway.noteDetailsById.set(42, {
noteId: 42,
modelName: QA_GROUP_MODEL_NAME,
modelName: QA_GROUP_USER_NOTE_TYPE,
cardIds: [7001],
deckNames: ["notes"],
fields: buildQaGroupNoteFields(existingState.stem, existingState.groupId, existingState.src, [
fields: buildQaGroupUserTemplateFields(existingState.stem, [
{ itemId: "item-a", slot: 1, title: "Alpha", answer: "#项目A #重点/案例\n\n第一段", ordinalInMarkdown: 1 },
{ itemId: "item-b", slot: 3, title: "Beta", answer: "Second answer", ordinalInMarkdown: 2 },
]),
@ -289,9 +291,8 @@ describe("QaGroupSyncService", () => {
}, createModule3Settings({ keepPureTagLinesInCardBody: false }));
expect(result.updated).toBe(1);
expect(ankiGateway.updatedNotes[0]?.fields).toMatchObject({
S01_A: "第一段",
});
expect(ankiGateway.updatedNotes[0]?.fields.01).toContain("第一段");
expect(ankiGateway.updatedNotes[0]?.fields.01).not.toContain("#项目A");
});
it("renders remaining inline tags in QA Group answers as chips after cleanup", async () => {
@ -300,10 +301,10 @@ describe("QaGroupSyncService", () => {
const existingState = createStoredGroupBlockState();
ankiGateway.noteDetailsById.set(42, {
noteId: 42,
modelName: QA_GROUP_MODEL_NAME,
modelName: QA_GROUP_USER_NOTE_TYPE,
cardIds: [7001],
deckNames: ["notes"],
fields: buildQaGroupNoteFields(existingState.stem, existingState.groupId, existingState.src, [
fields: buildQaGroupUserTemplateFields(existingState.stem, [
{ itemId: "item-a", slot: 1, title: "Alpha", answer: "旧答案", ordinalInMarkdown: 1 },
{ itemId: "item-b", slot: 3, title: "Beta", answer: "Second answer", ordinalInMarkdown: 2 },
]),
@ -337,11 +338,11 @@ describe("QaGroupSyncService", () => {
}, createModule3Settings({ keepPureTagLinesInCardBody: false }));
expect(result.updated).toBe(1);
expect(ankiGateway.updatedNotes[0]?.fields.S01_A).toContain('class="ahs-ob-tag"');
expect(ankiGateway.updatedNotes[0]?.fields.S01_A).toContain('data-tag="3地区"');
expect(ankiGateway.updatedNotes[0]?.fields.S01_A).not.toContain('data-tag="项目A"');
expect(ankiGateway.updatedNotes[0]?.fields.S01_A).not.toContain('data-tag="重点::案例"');
expect(ankiGateway.updatedNotes[0]?.fields.S01_A).not.toContain('data-tag="代码"');
expect(ankiGateway.updatedNotes[0]?.fields.01).toContain('class="ahs-ob-tag"');
expect(ankiGateway.updatedNotes[0]?.fields.01).toContain('data-tag="3地区"');
expect(ankiGateway.updatedNotes[0]?.fields.01).not.toContain('data-tag="项目A"');
expect(ankiGateway.updatedNotes[0]?.fields.01).not.toContain('data-tag="重点::案例"');
expect(ankiGateway.updatedNotes[0]?.fields.01).not.toContain('data-tag="代码"');
});
it("syncs QA Group note tags from file-level tag hints", async () => {
@ -350,11 +351,11 @@ describe("QaGroupSyncService", () => {
const existingState = createStoredGroupBlockState();
ankiGateway.noteDetailsById.set(42, {
noteId: 42,
modelName: QA_GROUP_MODEL_NAME,
modelName: QA_GROUP_USER_NOTE_TYPE,
cardIds: [7001],
deckNames: ["notes"],
tags: ["old", "shared"],
fields: buildQaGroupNoteFields(existingState.stem, existingState.groupId, existingState.src, existingState.items),
fields: buildQaGroupUserTemplateFields(existingState.stem, existingState.items),
});
const service = new QaGroupSyncService(
ankiGateway,
@ -391,6 +392,119 @@ describe("QaGroupSyncService", () => {
},
]);
});
it("rejects a QA Group block when the selected template exposes fewer slots than the block needs", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const service = new QaGroupSyncService(ankiGateway);
await expect(service.sync([
createIndexedGroupBlock({
items: [
{ title: "A", answer: "1", ordinalInMarkdown: 1 },
{ title: "B", answer: "2", ordinalInMarkdown: 2 },
{ title: "C", answer: "3", ordinalInMarkdown: 3 },
{ title: "D", answer: "4", ordinalInMarkdown: 4 },
],
}),
], createEmptyPluginState(), createModule3Settings())).rejects.toMatchObject({
userMessage: {
key: "errors.noteFieldMapping.qaGroupSlotCapacityExceeded",
},
});
});
it("blocks sync when the selected QA Group template still has unconfirmed field warnings", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.modelDetailsByName[QA_GROUP_USER_NOTE_TYPE] = {
fieldNames: ["题目", "问题01", "答案01", "问题02"],
isCloze: false,
};
const service = new QaGroupSyncService(ankiGateway);
const baseSettings = createModule3Settings();
await expect(service.sync([
createIndexedGroupBlock(),
], createEmptyPluginState(), {
...baseSettings,
noteFieldMappings: {
...baseSettings.noteFieldMappings,
[`qa-group:${QA_GROUP_USER_NOTE_TYPE}`]: {
cardType: "qa-group",
modelName: QA_GROUP_USER_NOTE_TYPE,
loadedFieldNames: ["题目", "问题01", "答案01", "问题02"],
titleField: "题目",
slots: [{ index: 1, questionField: "问题01", answerField: "答案01" }],
warnings: [JSON.stringify({ kind: "missing-answer", index: 2, questionField: "问题02", answerField: "答案02" })],
loadedAt: 1,
},
},
})).rejects.toMatchObject({
userMessage: {
key: "errors.noteFieldMapping.qaGroupWarningsUnaccepted",
},
});
});
it("fails clearly when QA Group sync starts without a selected Anki note type", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const service = new QaGroupSyncService(ankiGateway);
const baseSettings = createModule3Settings();
await expect(service.sync([
createIndexedGroupBlock(),
], createEmptyPluginState(), {
...baseSettings,
cardTypeConfigs: {
...baseSettings.cardTypeConfigs,
"qa-group": {
...baseSettings.cardTypeConfigs["qa-group"],
noteType: "",
},
},
})).rejects.toMatchObject({
userMessage: {
key: "errors.noteFieldMapping.noteTypeNotSelected.qaGroup",
},
});
});
it("recovers note content by noteId and uses temporary item ids when local state is missing", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const vaultGateway = new FakeManualSyncVaultGateway();
ankiGateway.noteDetailsById.set(42, {
noteId: 42,
modelName: QA_GROUP_USER_NOTE_TYPE,
cardIds: [7001],
deckNames: ["notes"],
fields: buildQaGroupUserTemplateFields("Concepts", [
{ itemId: "ignored", slot: 1, title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 },
], buildBacklinkAnchor(vaultGateway, "notes/example.md", "Concepts #anki-list")),
});
const service = new QaGroupSyncService(
ankiGateway,
undefined,
undefined,
undefined,
() => 1234,
() => "group-x",
() => "item-new",
vaultGateway.createBacklink.bind(vaultGateway),
);
const result = await service.sync([
createIndexedGroupBlock({
noteId: 42,
groupId: undefined,
items: [{ title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 }],
}),
], createEmptyPluginState(), createModule3Settings());
expect(result.updated).toBe(0);
expect(result.syncedGroupBlocks[0]?.items[0]).toMatchObject({
itemId: "recovered-slot-01",
slot: 1,
});
});
});
function createIndexedGroupBlock(overrides: Partial<IndexedGroupCardBlock> = {}): IndexedGroupCardBlock {
@ -460,8 +574,47 @@ function createStoredGroupBlockState(): GroupBlockState {
{ 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],
freeSlots: [2],
lastSyncedAt: 100,
orphan: false,
};
}
function buildQaGroupUserTemplateFields(
stem: string,
items: GroupItem[],
backlinkAnchor = buildBacklinkAnchor(new FakeManualSyncVaultGateway(), "notes/example.md", "Concepts #anki-list"),
placement: "question-last-line" | "answer-first-line" | "answer-last-line" = "answer-last-line",
): Record<string, string> {
const fields: Record<string, string> = {
题目: placement === "question-last-line"
? applyObsidianBacklinkPlacement({ title: stem, body: "" }, backlinkAnchor, placement).title
: stem,
01: "",
01: "",
02: "",
02: "",
03: "",
03: "",
};
for (const item of items) {
if (!item.slot) {
continue;
}
fields[`问题${String(item.slot).padStart(2, "0")}`] = item.title;
fields[`答案${String(item.slot).padStart(2, "0")}`] = placement === "question-last-line"
? item.answer
: applyObsidianBacklinkPlacement({ title: item.title, body: item.answer }, backlinkAnchor, placement).body;
}
return fields;
}
function buildBacklinkAnchor(vaultGateway: FakeManualSyncVaultGateway, filePath: string, headingText: string): string {
return renderObsidianBacklinkAnchor({
href: vaultGateway.createBacklink({ filePath, headingText }),
label: "Open in Obsidian",
});
}

View file

@ -1,20 +1,22 @@
import MarkdownIt from "markdown-it";
import { createNoteFieldMappingKey, isQaGroupFieldMapping, type QaGroupFieldMapping } from "@/application/config/NoteModelFieldMapping";
import type { PluginSettings } from "@/application/config/PluginSettings";
import { PluginUserError } from "@/application/errors/PluginUserError";
import type { AnkiGroupGateway, AnkiNoteDetails } from "@/application/ports/AnkiGateway";
import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation";
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 { preprocessCardBodyMarkdown } from "@/domain/manual-sync/services/preprocessCardBodyMarkdown";
import { renderObsidianTagChipsInHtml } from "@/domain/manual-sync/services/renderObsidianTagChips";
import { DeckResolutionService } from "@/domain/manual-sync/services/DeckResolutionService";
import { getDeckResolutionWarningKey, type DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
import { hashString } from "@/domain/shared/hash";
import { preprocessCardBodyMarkdown } from "@/domain/manual-sync/services/preprocessCardBodyMarkdown";
import { renderObsidianTagChipsInHtml } from "@/domain/manual-sync/services/renderObsidianTagChips";
import { applyObsidianBacklinkPlacement, renderObsidianBacklinkAnchor } from "@/domain/shared/renderObsidianBacklink";
import { diffTagSets } from "@/domain/manual-sync/services/tagSetUtils";
import { buildQaGroupNoteFields, formatQaGroupSlot, QA_GROUP_MODEL_NAME, QA_GROUP_SLOT_COUNT } from "./QaGroupModelDefinition";
import { QaGroupModelService } from "./QaGroupModelService";
import { QaGroupFieldMappingService } from "./QaGroupFieldMappingService";
const markdown = new MarkdownIt({
breaks: true,
@ -56,7 +58,7 @@ export class QaGroupSyncService {
constructor(
private readonly ankiGateway: AnkiGroupGateway,
private readonly qaGroupModelService = new QaGroupModelService(ankiGateway),
private readonly qaGroupFieldMappingService = new QaGroupFieldMappingService(),
private readonly deckResolutionService = new DeckResolutionService(),
private readonly groupMarkerService = new GroupMarkerService(),
private readonly now: () => number = () => Date.now(),
@ -82,10 +84,7 @@ export class QaGroupSyncService {
};
}
await this.qaGroupModelService.ensureModel({
obsidianBacklinkLabel: settings.obsidianBacklinkLabel,
obsidianBacklinkPlacement: settings.obsidianBacklinkPlacement,
});
const { modelName, mapping, availableSlots, legacyInternalFields } = await this.resolveQaGroupModel(settings);
const stateIndex = buildGroupStateIndex(state.groupBlocks ?? {});
const ensuredDecks = new Set<string>();
@ -97,20 +96,34 @@ export class QaGroupSyncService {
let created = 0;
let updated = 0;
let migratedDecks = 0;
const backlinkAnchor = this.buildGroupBacklinkAnchor(blocks[0] ?? null, settings);
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}.`);
if (block.items.length > availableSlots.length) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupSlotCapacityExceeded", {
modelName,
capacity: availableSlots.length,
itemCount: block.items.length,
filePath: block.filePath,
blockStartLine: block.blockStartLine,
});
}
const recovered = await this.resolveRecoveredGroup(block, stateIndex);
const recovered = await this.resolveRecoveredGroup(block, stateIndex, modelName, mapping, availableSlots);
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 recoveredItems = normalizeRecoveredItemsForAvailableSlots(recovered.items, availableSlots);
const resolvedItems = reconcileGroupItems(block.items, recoveredItems, this.createItemId, availableSlots);
if (resolvedItems.length > availableSlots.length) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupSlotCapacityExceeded", {
modelName,
capacity: availableSlots.length,
itemCount: resolvedItems.length,
filePath: block.filePath,
blockStartLine: block.blockStartLine,
});
}
const freeSlots = buildFreeSlotsFromItems(resolvedItems);
const freeSlots = buildFreeSlotsFromItems(resolvedItems, availableSlots);
const deckResolution = this.deckResolutionService.resolve({
filePath: block.filePath,
deckHint: block.deckHint,
@ -136,10 +149,12 @@ export class QaGroupSyncService {
),
}));
const fields = buildQaGroupNoteFields(
mapping,
renderQaGroupInlineMarkdown(block.stem, false),
groupId,
this.buildGroupBacklink(block, settings),
renderedItems,
this.buildGroupBacklinkAnchor(block, settings) || backlinkAnchor,
settings.obsidianBacklinkPlacement,
legacyInternalFields,
);
let noteId = recovered.noteId ?? block.noteId;
let existingNote = recovered.noteDetails;
@ -147,18 +162,18 @@ export class QaGroupSyncService {
if (noteId === undefined) {
noteId = await this.ankiGateway.addNote({
deckName: deck,
modelName: QA_GROUP_MODEL_NAME,
modelName,
fields,
tags: block.tagsHint ?? [],
});
created += 1;
touchedSyncKeys.add(block.syncKey);
} else {
existingNote ??= await this.tryLoadQaGroupNote(noteId, block);
existingNote ??= await this.tryLoadQaGroupNote(noteId, block, modelName);
if (!existingNote) {
noteId = await this.ankiGateway.addNote({
deckName: deck,
modelName: QA_GROUP_MODEL_NAME,
modelName,
fields,
tags: block.tagsHint ?? [],
});
@ -304,7 +319,13 @@ export class QaGroupSyncService {
};
}
private async resolveRecoveredGroup(block: IndexedGroupCardBlock, stateIndex: GroupStateIndex): Promise<RecoveredGroupRecord> {
private async resolveRecoveredGroup(
block: IndexedGroupCardBlock,
stateIndex: GroupStateIndex,
modelName: string,
mapping: QaGroupFieldMapping,
availableSlots: number[],
): 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));
@ -319,75 +340,135 @@ export class QaGroupSyncService {
};
}
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) {
const note = await this.tryLoadQaGroupNote(block.noteId, block);
const note = await this.tryLoadQaGroupNote(block.noteId, block, modelName);
if (note) {
return noteDetailsToRecoveredGroup(note, block.groupId);
return noteDetailsToRecoveredGroup(note, mapping, availableSlots, 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),
freeSlots: [...availableSlots],
};
}
private async findSingleQaGroupNote(query: string, block: IndexedGroupCardBlock): Promise<AnkiNoteDetails | undefined> {
const noteIds = await this.ankiGateway.findNoteIds(query);
if (noteIds.length === 0) {
return undefined;
}
if (noteIds.length > 1) {
throw new Error(`QA Group recovery is ambiguous at ${block.filePath}:${block.blockStartLine}. Query: ${query}`);
}
return this.tryLoadQaGroupNote(noteIds[0], block);
}
private async tryLoadQaGroupNote(noteId: number, block: IndexedGroupCardBlock): Promise<AnkiNoteDetails | undefined> {
private async tryLoadQaGroupNote(noteId: number, block: IndexedGroupCardBlock, modelName: string): Promise<AnkiNoteDetails | undefined> {
const note = (await this.ankiGateway.getNoteDetails([noteId]))[0];
if (!note) {
return undefined;
}
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}.`);
if (note.modelName !== modelName) {
throw new Error(`Note ${noteId} for ${block.filePath}:${block.blockStartLine} is ${note.modelName}, expected ${modelName}.`);
}
return note;
}
private buildGroupBacklink(block: IndexedGroupCardBlock, settings: PluginSettings): string {
if (!settings.addObsidianBacklink || !this.createBacklink) {
private buildGroupBacklinkAnchor(block: IndexedGroupCardBlock | null, settings: PluginSettings): string {
if (!block || !settings.addObsidianBacklink || !this.createBacklink) {
return "";
}
return this.createBacklink({
filePath: block.filePath,
sourceContent: block.sourceContent,
headingLine: block.blockStartLine,
blockStartLine: block.blockStartLine,
bodyStartLine: block.bodyStartLine,
blockEndLine: block.blockEndLine,
contentEndLine: block.contentEndLine,
markerLine: block.markerLine,
headingLevel: block.headingLevel,
headingText: block.backlinkHeadingText,
return renderObsidianBacklinkAnchor({
href: this.createBacklink({
filePath: block.filePath,
sourceContent: block.sourceContent,
headingLine: block.blockStartLine,
blockStartLine: block.blockStartLine,
bodyStartLine: block.bodyStartLine,
blockEndLine: block.blockEndLine,
contentEndLine: block.contentEndLine,
markerLine: block.markerLine,
headingLevel: block.headingLevel,
headingText: block.backlinkHeadingText,
}),
label: settings.obsidianBacklinkLabel,
});
}
private async resolveQaGroupModel(settings: PluginSettings): Promise<{
modelName: string;
mapping: QaGroupFieldMapping;
availableSlots: number[];
legacyInternalFields: string[];
}> {
const modelName = settings.cardTypeConfigs["qa-group"].noteType.trim();
if (!modelName) {
throw new PluginUserError("errors.noteFieldMapping.noteTypeNotSelected.qaGroup");
}
const storedMapping = settings.noteFieldMappings[createNoteFieldMappingKey("qa-group", modelName)];
const fieldNames = await this.ankiGateway.getModelFieldNames(modelName);
const mapping = this.qaGroupFieldMappingService.suggest(
modelName,
fieldNames,
this.now(),
isQaGroupFieldMapping(storedMapping) ? storedMapping.acceptedWarnings : undefined,
);
this.qaGroupFieldMappingService.validateMapping(mapping, fieldNames);
this.qaGroupFieldMappingService.validateWarningsAccepted(mapping);
return {
modelName,
mapping,
availableSlots: mapping.slots.map((slot) => slot.index),
legacyInternalFields: collectLegacyManagedInternalFields(fieldNames),
};
}
}
function buildQaGroupNoteFields(
mapping: QaGroupFieldMapping,
stem: string,
items: GroupItem[],
backlinkAnchor: string,
backlinkPlacement: PluginSettings["obsidianBacklinkPlacement"],
legacyInternalFields: string[],
): Record<string, string> {
if (!mapping.titleField) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupMissingTitle", {
modelName: mapping.modelName,
});
}
const titleFieldValue = backlinkAnchor && backlinkPlacement === "question-last-line"
? applyObsidianBacklinkPlacement({ title: stem, body: "" }, backlinkAnchor, backlinkPlacement).title
: stem;
const fields: Record<string, string> = {
[mapping.titleField]: titleFieldValue,
};
for (const slot of mapping.slots) {
fields[slot.questionField] = "";
fields[slot.answerField] = "";
}
for (const fieldName of legacyInternalFields) {
fields[fieldName] = "";
}
const slotByIndex = new Map(mapping.slots.map((slot) => [slot.index, slot]));
for (const item of items) {
if (item.slot === undefined) {
continue;
}
const slot = slotByIndex.get(item.slot);
if (!slot) {
continue;
}
const answerValue = backlinkAnchor && backlinkPlacement !== "question-last-line"
? applyObsidianBacklinkPlacement({ title: item.title, body: item.answer }, backlinkAnchor, backlinkPlacement).body
: item.answer;
fields[slot.questionField] = item.title;
fields[slot.answerField] = answerValue;
}
return fields;
}
function buildGroupStateIndex(groupBlocks: Record<string, GroupBlockState>): GroupStateIndex {
@ -418,39 +499,42 @@ function buildGroupStateIndex(groupBlocks: Record<string, GroupBlockState>): Gro
};
}
function noteDetailsToRecoveredGroup(note: AnkiNoteDetails, fallbackGroupId?: string): RecoveredGroupRecord {
function noteDetailsToRecoveredGroup(
note: AnkiNoteDetails,
mapping: QaGroupFieldMapping,
availableSlots: number[],
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()) {
for (const slot of mapping.slots) {
const title = sanitizeRecoveredQaGroupField(note.fields[slot.questionField] ?? "");
const answer = sanitizeRecoveredQaGroupField(note.fields[slot.answerField] ?? "");
if (!title && !answer) {
continue;
}
occupiedSlots.add(slot);
occupiedSlots.add(slot.index);
items.push({
itemId,
itemId: `recovered-slot-${String(slot.index).padStart(2, "0")}`,
title,
answer,
slot,
ordinalInMarkdown: slot,
slot: slot.index,
ordinalInMarkdown: slot.index,
});
}
return {
noteId: note.noteId,
groupId: (note.fields.GroupId ?? "").trim() || fallbackGroupId,
groupId: fallbackGroupId,
items,
freeSlots: Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1).filter((slot) => !occupiedSlots.has(slot)),
freeSlots: availableSlots.filter((slot) => !occupiedSlots.has(slot)),
noteDetails: note,
};
}
function reconcileGroupItems(currentItems: GroupItem[], recoveredItems: GroupItem[], createItemId: () => string): GroupItem[] {
function reconcileGroupItems(currentItems: GroupItem[], recoveredItems: GroupItem[], createItemId: () => string, availableSlots: number[]): GroupItem[] {
const recoveredQueues = new Map<string, GroupItem[]>();
const unmatchedRecovered: GroupItem[] = [];
@ -505,7 +589,7 @@ function reconcileGroupItems(currentItems: GroupItem[], recoveredItems: GroupIte
}
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));
const freeSlots = [...availableSlots].filter((slot) => !occupiedSlots.has(slot));
for (const item of resolvedItems) {
if (item.slot !== undefined) {
continue;
@ -525,9 +609,9 @@ function reconcileGroupItems(currentItems: GroupItem[], recoveredItems: GroupIte
}));
}
function buildFreeSlotsFromItems(items: GroupItem[]): number[] {
function buildFreeSlotsFromItems(items: GroupItem[], availableSlots: number[]): 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));
return availableSlots.filter((slot) => !occupiedSlots.has(slot));
}
function createItemContentKey(title: string, answer: string): string {
@ -550,18 +634,9 @@ function uniqueMatch<T>(items: T[] | undefined): T | undefined {
}
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]] ?? "")) {
for (const key of rightKeys) {
if ((left[key] ?? "") !== (right[key] ?? "")) {
return false;
}
}
@ -593,6 +668,35 @@ function renderQaGroupInlineMarkdown(markdownText: string, renderTagChips: boole
return renderTagChips ? renderObsidianTagChipsInHtml(html) : html;
}
function normalizeRecoveredItemsForAvailableSlots(items: GroupItem[], availableSlots: number[]): GroupItem[] {
const availableSlotSet = new Set(availableSlots);
return items.map((item) => ({
...item,
slot: typeof item.slot === "number" && availableSlotSet.has(item.slot) ? item.slot : undefined,
}));
}
function sanitizeRecoveredQaGroupField(value: string): string {
return value
.replace(/<p><a class="anki-heading-sync-backlink"[^>]*>.*?<\/a><\/p>/g, "")
.replace(/<br><a class="anki-heading-sync-backlink"[^>]*>.*?<\/a>/g, "")
.trim();
}
function collectLegacyManagedInternalFields(fieldNames: string[]): string[] {
const hasLegacyShape = fieldNames.includes("GroupId")
&& fieldNames.includes("Src")
&& fieldNames.some((fieldName) => /^S\d+_Id$/i.test(fieldName))
&& fieldNames.some((fieldName) => /^S\d+_Q$/i.test(fieldName))
&& fieldNames.some((fieldName) => /^S\d+_A$/i.test(fieldName));
if (!hasLegacyShape) {
return [];
}
return fieldNames.filter((fieldName) => fieldName === "GroupId" || fieldName === "Src" || /^S\d+_Id$/i.test(fieldName));
}
function protectInlineCode(text: string): { text: string; restore: (value: string) => string } {
const matches: string[] = [];
const nextText = text.replace(INLINE_CODE_PATTERN, (segment) => {
@ -607,7 +711,3 @@ function protectInlineCode(text: string): { text: string; restore: (value: strin
matches.reduce((current, segment, index) => current.split(`@@QA_GROUP_INLINE_CODE_${index}@@`).join(segment), value),
};
}
function quoteAnkiValue(value: string): string {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
}

View file

@ -1,5 +1,6 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
import { PluginUserError } from "@/application/errors/PluginUserError";
import { isClozeCardType } from "@/domain/card/entities/RenderedFields";
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import { DeckResolutionService } from "@/domain/manual-sync/services/DeckResolutionService";
@ -47,8 +48,24 @@ export class RenderConfigService {
function resolveNoteModel(cardType: IndexedCard["cardType"], settings: PluginSettings): string {
if (isClozeCardType(cardType)) {
if (!settings.clozeNoteType.trim()) {
throw new PluginUserError("errors.noteFieldMapping.noteTypeNotSelected.cloze");
}
return settings.clozeNoteType;
}
return cardType === "semantic-qa" ? settings.semanticQaNoteType : settings.qaNoteType;
if (cardType === "semantic-qa") {
if (!settings.semanticQaNoteType.trim()) {
throw new PluginUserError("errors.noteFieldMapping.noteTypeNotSelected.semanticQa");
}
return settings.semanticQaNoteType;
}
if (!settings.qaNoteType.trim()) {
throw new PluginUserError("errors.noteFieldMapping.noteTypeNotSelected.basic");
}
return settings.qaNoteType;
}

View file

@ -285,7 +285,7 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => {
expect(syncResult.created).toBe(1);
expect(syncResult.rewrittenMarkers).toBe(1);
expect(ankiGateway.addedNotes).toHaveLength(1);
expect(vaultGateway.getFileContent(filePath)).toMatch(/<!--GI:n=9001;i=[^;]+;f=3,4,5,6,7,8,9,10,11,12-->/);
expect(vaultGateway.getFileContent(filePath)).toMatch(/<!--GI:n=9001;i=[^;]+;f=3-->/);
expect(vaultGateway.getFileContent(filePath)).not.toContain("<!--GI:n=42;");
});

View file

@ -79,4 +79,16 @@ describe("GroupMarkerService", () => {
"Remarks",
].join("\n"));
});
it("accepts GI markers with slots above 12 when the user template exposes more capacity", () => {
const service = new GroupMarkerService();
const marker = service.create(42, { item_a: 13 }, [1, 2, 14]);
expect(marker.raw).toBe("<!--GI:n=42;i=item_a:13;f=1,2,14-->");
expect(service.parse(marker.raw, 9)).toMatchObject({
noteId: 42,
itemToSlot: { item_a: 13 },
freeSlots: [1, 2, 14],
});
});
});

View file

@ -235,16 +235,16 @@ function parseFreeSlots(rawValue: string): number[] {
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}`);
if (!Number.isInteger(slot) || slot < 1) {
throw new GroupMarkerError(`GI item slot must be a positive integer. 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 (!Number.isInteger(slot) || slot < 1) {
throw new GroupMarkerError(`GI free slot must be a positive integer. Received: ${slot}`);
}
if (usedSlots.has(slot)) {

View file

@ -248,6 +248,19 @@ export const en = {
answerField: "Answer field:",
mainField: "Main field:",
fields: "Fields:",
qaGroupTitleField: "Title field:",
qaGroupSlotFields: "Q/A fields:",
qaGroupWarnings: "Warnings:",
},
qaGroup: {
noModel: "Select an Anki note type first",
unavailable: "Read fields from Anki first",
detectedSlots: "Detected {{count}} pair(s): {{summary}}",
acceptWarnings: "I confirm these warnings can be ignored and sync may continue",
warning: {
missingAnswer: "Slot {{index}} is missing its answer field, so {{questionField}} / {{answerField}} was ignored.",
missingQuestion: "Slot {{index}} is missing its question field, so {{questionField}} / {{answerField}} was ignored.",
},
},
rows: {
basic: "Q&A (regular paragraph)",
@ -377,8 +390,23 @@ export const en = {
noteFieldMappingsModelName: "Note field mappings must include a model name.",
noteFieldMappingsLoadedFieldNames: "Note field mappings must include loaded field names.",
noteFieldMappingsLoadedAt: "Note field mappings must include a loaded timestamp.",
noteFieldMappingsQaGroupSlotsArray: "QA Group field mapping slots must be an array.",
noteFieldMappingsQaGroupSlotObject: "Each QA Group field mapping slot must be an object.",
noteFieldMappingsQaGroupSlotIndex: "Each QA Group field mapping slot.index must be an integer greater than 0.",
noteFieldMappingsQaGroupSlotQuestionField: "Each QA Group field mapping slot.questionField must be a string.",
noteFieldMappingsQaGroupSlotAnswerField: "Each QA Group field mapping slot.answerField must be a string.",
noteFieldMappingsQaGroupWarningsArray: "QA Group field mapping warnings must be an array.",
noteFieldMappingsQaGroupWarningsStrings: "QA Group field mapping warnings can only contain strings.",
noteFieldMappingsQaGroupAcceptedWarningsArray: "QA Group field mapping acceptedWarnings must be an array.",
noteFieldMappingsQaGroupAcceptedWarningsStrings: "QA Group field mapping acceptedWarnings can only contain strings.",
},
noteFieldMapping: {
noteTypeNotSelected: {
basic: "No Anki note type is selected for basic cards. Choose one in settings before syncing.",
cloze: "No Anki note type is selected for cloze cards. Choose one in settings before syncing.",
semanticQa: "No Anki note type is selected for semantic QA cards. Choose one in settings before syncing.",
qaGroup: "No Anki note type is selected for QA Group cards. Choose one in settings before syncing.",
},
missingSavedMapping: {
basic: "No saved field mapping found for basic note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
cloze: "No saved field mapping found for cloze note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
@ -395,6 +423,11 @@ export const en = {
},
stale: "Saved field mapping for note type \"{{modelName}}\" is stale because these fields no longer exist in Anki: {{fields}}. Open plugin settings and read fields from Anki again.",
clozeIncompatible: "Cloze note type \"{{modelName}}\" is not cloze-compatible in Anki.",
qaGroupMissingTitle: "QA Group note type \"{{modelName}}\" does not expose a recognizable title field. Use one of 题目, 标题, 正面, Stem, or Title.",
qaGroupNoCompleteSlots: "QA Group note type \"{{modelName}}\" does not expose any complete question/answer slot pairs.",
qaGroupNonContinuousSlots: "QA Group note type \"{{modelName}}\" must start its question/answer slots at pair {{firstIndex}} = 1. Renumber the first pair to 1.",
qaGroupWarningsUnaccepted: "QA Group note type \"{{modelName}}\" still has unconfirmed field warnings. Confirm them in settings before syncing.",
qaGroupSlotCapacityExceeded: "QA Group note type \"{{modelName}}\" only exposes {{capacity}} question/answer pair(s), but the current block has {{itemCount}} item(s): {{filePath}}:{{blockStartLine}}.",
},
writeBack: {
summary: "Markdown marker write-back failed for {{fileCount}} file(s).",

View file

@ -246,6 +246,19 @@ export const zh = {
answerField: "答案字段:",
mainField: "主字段:",
fields: "字段:",
qaGroupTitleField: "题目字段:",
qaGroupSlotFields: "问题/答案字段:",
qaGroupWarnings: "警告:",
},
qaGroup: {
noModel: "请先选择 Anki 笔记模板",
unavailable: "请先手动读取字段",
detectedSlots: "已识别 {{count}} 组:{{summary}}",
acceptWarnings: "我已确认忽略当前警告,并允许同步",
warning: {
missingAnswer: "第 {{index}} 组缺少答案字段,已忽略 {{questionField}} / {{answerField}}。",
missingQuestion: "第 {{index}} 组缺少问题字段,已忽略 {{questionField}} / {{answerField}}。",
},
},
rows: {
basic: "问答题(常规段落形式)",
@ -375,8 +388,23 @@ export const zh = {
noteFieldMappingsModelName: "字段映射必须包含模型名称。",
noteFieldMappingsLoadedFieldNames: "字段映射必须包含已加载字段名。",
noteFieldMappingsLoadedAt: "字段映射必须包含已加载时间戳。",
noteFieldMappingsQaGroupSlotsArray: "QA Group 字段映射中的 slots 必须是数组。",
noteFieldMappingsQaGroupSlotObject: "QA Group 字段映射中的每个 slot 都必须是对象。",
noteFieldMappingsQaGroupSlotIndex: "QA Group 字段映射中的 slot.index 必须是大于 0 的整数。",
noteFieldMappingsQaGroupSlotQuestionField: "QA Group 字段映射中的 slot.questionField 必须是字符串。",
noteFieldMappingsQaGroupSlotAnswerField: "QA Group 字段映射中的 slot.answerField 必须是字符串。",
noteFieldMappingsQaGroupWarningsArray: "QA Group 字段映射中的 warnings 必须是数组。",
noteFieldMappingsQaGroupWarningsStrings: "QA Group 字段映射中的 warnings 只能包含字符串。",
noteFieldMappingsQaGroupAcceptedWarningsArray: "QA Group 字段映射中的 acceptedWarnings 必须是数组。",
noteFieldMappingsQaGroupAcceptedWarningsStrings: "QA Group 字段映射中的 acceptedWarnings 只能包含字符串。",
},
noteFieldMapping: {
noteTypeNotSelected: {
basic: "基础卡尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
cloze: "Cloze 尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
semanticQa: "语义 QA 尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
qaGroup: "问答题(多级列表)尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
},
missingSavedMapping: {
basic: "找不到基础卡笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
cloze: "找不到 Cloze 笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
@ -393,6 +421,11 @@ export const zh = {
},
stale: "笔记类型 \"{{modelName}}\" 的已保存字段映射已过期,因为这些字段在 Anki 中已不存在:{{fields}}。请重新从 Anki 读取字段。",
clozeIncompatible: "Cloze 笔记类型 \"{{modelName}}\" 在 Anki 中不是 cloze 兼容模型。",
qaGroupMissingTitle: "问答题(多级列表)笔记模板 \"{{modelName}}\" 缺少可识别的题目字段。请使用 题目、标题、正面、Stem 或 Title 之一。",
qaGroupNoCompleteSlots: "问答题(多级列表)笔记模板 \"{{modelName}}\" 没有可用的完整问题/答案字段组。",
qaGroupNonContinuousSlots: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的第一组问题/答案字段不是从第 {{firstIndex}} 组开始连续编号。请从第 1 组开始。",
qaGroupWarningsUnaccepted: "问答题(多级列表)笔记模板 \"{{modelName}}\" 仍有未确认的字段警告。请先在设置页确认后再同步。",
qaGroupSlotCapacityExceeded: "问答题(多级列表)笔记模板 \"{{modelName}}\" 只识别到 {{capacity}} 组问题/答案字段,但当前块有 {{itemCount}} 项:{{filePath}}:{{blockStartLine}}。",
},
writeBack: {
summary: "Markdown 标记写回失败,共 {{fileCount}} 个文件。",

View file

@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels";
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
import { DEFAULT_SETTINGS, normalizePluginSettings } from "@/application/config/PluginSettings";
const QA_GROUP_USER_MODEL_NAME = "问答题(多级列表)";
const {
FakeElement,
FakePluginSettingTab,
@ -282,6 +283,7 @@ type QueryRoot = FakeContainer | FakeElementInstance | HTMLElement;
class FakePlugin {
public readonly app = {};
public readonly updateCalls: Array<Record<string, unknown>> = [];
public readonly fieldNamesByModelName: Record<string, string[]> = {};
public listNoteModelsCalls = 0;
public getModelFieldNamesByModelNamesCalls = 0;
public getNoteModelDetailsCalls = 0;
@ -335,7 +337,7 @@ class FakePlugin {
async listNoteModels(): Promise<string[]> {
this.listNoteModelsCalls += 1;
return ["Basic", "Custom Basic", "Cloze", "Custom Cloze", "Semantic QA", QA_GROUP_MODEL_NAME];
return ["Basic", "Custom Basic", "Cloze", "Custom Cloze", "Semantic QA", QA_GROUP_USER_MODEL_NAME];
}
async getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
@ -365,6 +367,10 @@ class FakePlugin {
}
private getFieldNamesForModel(modelName: string): string[] {
if (this.fieldNamesByModelName[modelName]) {
return this.fieldNamesByModelName[modelName];
}
if (modelName.includes("Cloze")) {
return ["Text", "Extra", "Hint"];
}
@ -377,6 +383,10 @@ class FakePlugin {
return ["Front", "Back", "Extra"];
}
if (modelName === QA_GROUP_USER_MODEL_NAME) {
return ["题目", "问题01", "答案01", "问题02", "答案02"];
}
return ["Title", "Body", "Hint"];
}
}
@ -540,9 +550,9 @@ describe("PluginSettingTab", () => {
expect(plugin.settings.cardTypeConfigs.basic.noteType).toBe("Basic");
const qaGroupNoteType = queryByDataset(tab.containerEl, "cardTypeNoteType", "qa-group");
qaGroupNoteType.value = "Custom Basic";
qaGroupNoteType.value = QA_GROUP_USER_MODEL_NAME;
await qaGroupNoteType.trigger("change");
expect(plugin.settings.cardTypeConfigs["qa-group"].noteType).toBe("Custom Basic");
expect(plugin.settings.cardTypeConfigs["qa-group"].noteType).toBe(QA_GROUP_USER_MODEL_NAME);
await flushPromises();
const basicQuestionField = queryByDataset(tab.containerEl, "cardTypeQuestionField", "basic");
@ -557,16 +567,15 @@ describe("PluginSettingTab", () => {
bodyField: "Body",
}));
const qaGroupQuestionField = queryByDataset(tab.containerEl, "cardTypeQuestionField", "qa-group");
qaGroupQuestionField.value = "Title";
await qaGroupQuestionField.trigger("change");
const qaGroupAnswerField = queryByDataset(tab.containerEl, "cardTypeAnswerField", "qa-group");
qaGroupAnswerField.value = "Body";
await qaGroupAnswerField.trigger("change");
expect(queryByDataset(tab.containerEl, "qaGroupTitleField", "qa-group").textContent).toBe("题目");
expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toContain("已识别 2 组");
expect(plugin.settings.noteFieldMappings[createNoteFieldMappingKey("qa-group", plugin.settings.cardTypeConfigs["qa-group"].noteType)]).toEqual(expect.objectContaining({
titleField: "Title",
bodyField: "Body",
titleField: "题目",
slots: [
{ index: 1, questionField: "问题01", answerField: "答案01" },
{ index: 2, questionField: "问题02", answerField: "答案02" },
],
warnings: [],
}));
});
@ -637,7 +646,7 @@ describe("PluginSettingTab", () => {
expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(1);
expect(plugin.getNoteModelDetailsCalls).toBe(0);
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
expect(collectTexts(tab.containerEl).some((text) => text.includes("已获取 6 个笔记模板,已选择并配置 3 个卡片模式。"))).toBe(true);
expect(collectTexts(tab.containerEl).some((text) => text.includes("已获取 6 个笔记模板,已选择并配置 2 个卡片模式。"))).toBe(true);
});
it("persists loaded Anki note types and uses the cache before the next manual refresh", async () => {
@ -653,9 +662,9 @@ describe("PluginSettingTab", () => {
"Cloze",
"Custom Basic",
"Custom Cloze",
QA_GROUP_MODEL_NAME,
"Semantic QA",
]);
QA_GROUP_USER_MODEL_NAME,
].sort((left, right) => left.localeCompare(right)));
expect(plugin.updateCalls.some((call) => Array.isArray(call.ankiNoteTypeCache))).toBe(true);
expect(plugin.settings.ankiModelFieldCache).toEqual(expect.objectContaining({
Basic: {
@ -743,6 +752,51 @@ describe("PluginSettingTab", () => {
expect(plugin.getNoteModelDetailsCalls).toBe(0);
});
it("shows qa-group warnings, saves acceptance, and resets acceptance after field refresh changes warnings", async () => {
const plugin = new FakePlugin();
plugin.fieldNamesByModelName[QA_GROUP_USER_MODEL_NAME] = ["题目", "问题01", "答案01", "问题02"];
plugin.settings = normalizePluginSettings({
...plugin.settings,
ankiNoteTypeCache: [QA_GROUP_USER_MODEL_NAME],
ankiModelFieldCache: {
[QA_GROUP_USER_MODEL_NAME]: {
fieldNames: ["题目", "问题01", "答案01", "问题02"],
loadedAt: 100,
},
},
cardTypeConfigs: {
...plugin.settings.cardTypeConfigs,
"qa-group": {
...plugin.settings.cardTypeConfigs["qa-group"],
noteType: QA_GROUP_USER_MODEL_NAME,
},
},
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
expect(queryByDataset(tab.containerEl, "qaGroupWarning", "qa-group").textContent).toContain("第 02 组缺少答案字段");
const acceptWarning = queryByDataset(tab.containerEl, "qaGroupWarningAccept", "qa-group");
acceptWarning.checked = true;
await acceptWarning.trigger("change");
const mappingKey = createNoteFieldMappingKey("qa-group", QA_GROUP_USER_MODEL_NAME);
expect(plugin.settings.noteFieldMappings[mappingKey]).toEqual(expect.objectContaining({
acceptedWarnings: expect.any(Array),
}));
plugin.fieldNamesByModelName[QA_GROUP_USER_MODEL_NAME] = ["题目", "问题01", "答案01", "问题02", "答案02", "问题03"];
await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click");
await flushPromises();
expect(plugin.settings.noteFieldMappings[mappingKey]).toEqual(expect.objectContaining({
warnings: expect.any(Array),
acceptedWarnings: undefined,
}));
expect(queryByDataset(tab.containerEl, "qaGroupWarning", "qa-group").textContent).toContain("第 03 组缺少答案字段");
});
it("folder tree expand and check refresh only the scope card", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({

View file

@ -11,11 +11,22 @@ import {
type ScopeMode,
validatePluginSettings,
} from "@/application/config/PluginSettings";
import { createNoteFieldMappingKey, type NoteModelFieldMapping, type NoteModelFieldMappingCardType } from "@/application/config/NoteModelFieldMapping";
import {
createNoteFieldMappingKey,
isQaGroupFieldMapping,
type NoteModelFieldMapping,
type NoteModelFieldMappingCardType,
type QaGroupFieldMapping,
} from "@/application/config/NoteModelFieldMapping";
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import { renderUserFacingMessage, toUserFacingMessage, type UserFacingMessage } from "@/application/errors/PluginUserError";
import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService";
import {
areQaGroupWarningsAccepted,
parseQaGroupFieldWarning,
QaGroupFieldMappingService,
} from "@/application/services/QaGroupFieldMappingService";
import { t } from "@/presentation/i18n";
import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin";
@ -37,6 +48,7 @@ interface SettingsCardShell {
export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private readonly noteFieldMappingService = new NoteFieldMappingService();
private readonly qaGroupFieldMappingService = new QaGroupFieldMappingService();
private readonly availableNoteModels: string[] = [];
private readonly draftMappings: Record<string, NoteModelFieldMapping> = {};
private readonly loadedModelDetails: Record<string, NoteModelDetails> = {};
@ -177,9 +189,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
loadButton.type = "button";
loadButton.dataset.cardTypesRefresh = "true";
loadButton.disabled = this.ankiConfigLoading;
loadButton.addEventListener("click", () => {
void this.loadAnkiCardTypeConfig();
});
loadButton.addEventListener("click", () => this.loadAnkiCardTypeConfig());
actionRow.createEl("span", {
text: `${t("settings.cards.cardTypes.statusLabel")}${renderUserFacingMessage(this.cardTypeStatus)}`,
});
@ -215,9 +225,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
enabledCheckbox.type = "checkbox";
enabledCheckbox.checked = config.enabled;
enabledCheckbox.dataset.cardTypeEnabled = configId;
enabledCheckbox.addEventListener("change", () => {
void this.saveCardTypeConfig(configId, { enabled: enabledCheckbox.checked });
});
enabledCheckbox.addEventListener("change", () => this.saveCardTypeConfig(configId, { enabled: enabledCheckbox.checked }));
const titleEl = headerRow.createEl("strong", { text: this.getCardTypeLabel(configId) });
titleEl.style.fontSize = "var(--font-ui-medium)";
@ -236,9 +244,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.appendOption(headingSelect, String(level), `H${level}`);
}
headingSelect.value = String(config.headingLevel);
headingSelect.addEventListener("change", () => {
void this.saveCardTypeConfig(configId, { headingLevel: Number(headingSelect.value) });
});
headingSelect.addEventListener("change", () => this.saveCardTypeConfig(configId, { headingLevel: Number(headingSelect.value) }));
const markerGroup = this.createInlineControlGroup(recognitionRow, t("settings.cards.cardTypes.labels.extraMarker"));
const markerInput = markerGroup.createEl("input") as HTMLInputElement;
@ -274,44 +280,102 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
noteTypeSelect.value = config.noteType;
noteTypeSelect.disabled = this.ankiConfigLoading;
noteTypeSelect.addEventListener("change", () => {
void this.saveCardTypeConfig(configId, { noteType: noteTypeSelect.value });
});
noteTypeSelect.addEventListener("change", () => this.saveCardTypeConfig(configId, { noteType: noteTypeSelect.value }));
this.renderCardTypeFieldControls(ankiRow, configId);
}
private renderCardTypeFieldControls(containerEl: HTMLElement, configId: CardTypeConfigId): void {
if (configId === "qa-group") {
this.renderQaGroupFieldControls(containerEl, configId);
return;
}
const mapping = this.getCurrentMappingForConfig(configId);
if (configId === "cloze") {
const mainFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.mainField"));
const mainFieldSelect = this.createFieldSelect(mainFieldGroup, `card-type-question-field:${configId}`);
const clozeMapping = mapping && "mainField" in mapping ? mapping : undefined;
mainFieldSelect.dataset.cardTypeQuestionField = configId;
this.applyFluidEllipsis(mainFieldSelect);
this.populateFieldSelect(mainFieldSelect, mapping?.loadedFieldNames ?? [], mapping?.mainField);
mainFieldSelect.addEventListener("change", () => {
void this.saveFieldMapping(configId, { mainField: mainFieldSelect.value || undefined });
});
this.populateFieldSelect(mainFieldSelect, clozeMapping?.loadedFieldNames ?? [], clozeMapping?.mainField);
mainFieldSelect.addEventListener("change", () => this.saveFieldMapping(configId, { mainField: mainFieldSelect.value || undefined }));
return;
}
const titleFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.questionField"));
const titleFieldSelect = this.createFieldSelect(titleFieldGroup, `card-type-question-field:${configId}`);
const basicLikeMapping = mapping && "bodyField" in mapping ? mapping : undefined;
titleFieldSelect.dataset.cardTypeQuestionField = configId;
this.applyFluidEllipsis(titleFieldSelect);
this.populateFieldSelect(titleFieldSelect, mapping?.loadedFieldNames ?? [], mapping?.titleField);
titleFieldSelect.addEventListener("change", () => {
void this.saveFieldMapping(configId, { titleField: titleFieldSelect.value || undefined });
});
this.populateFieldSelect(titleFieldSelect, basicLikeMapping?.loadedFieldNames ?? [], basicLikeMapping?.titleField);
titleFieldSelect.addEventListener("change", () => this.saveFieldMapping(configId, { titleField: titleFieldSelect.value || undefined }));
const bodyFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.answerField"));
const bodyFieldSelect = this.createFieldSelect(bodyFieldGroup, `card-type-answer-field:${configId}`);
bodyFieldSelect.dataset.cardTypeAnswerField = configId;
this.applyFluidEllipsis(bodyFieldSelect);
this.populateFieldSelect(bodyFieldSelect, mapping?.loadedFieldNames ?? [], mapping?.bodyField);
bodyFieldSelect.addEventListener("change", () => {
void this.saveFieldMapping(configId, { bodyField: bodyFieldSelect.value || undefined });
});
this.populateFieldSelect(bodyFieldSelect, basicLikeMapping?.loadedFieldNames ?? [], basicLikeMapping?.bodyField);
bodyFieldSelect.addEventListener("change", () => this.saveFieldMapping(configId, { bodyField: bodyFieldSelect.value || undefined }));
}
private renderQaGroupFieldControls(containerEl: HTMLElement, configId: Extract<CardTypeConfigId, "qa-group">): void {
const selectedModelName = this.plugin.settings.cardTypeConfigs[configId].noteType.trim();
const qaGroupState = this.getQaGroupFieldState(configId);
const titleFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.qaGroupTitleField"));
titleFieldGroup.createEl("span", {
text: qaGroupState.mapping?.titleField
?? (selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel")),
}).dataset.qaGroupTitleField = configId;
const slotFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.qaGroupSlotFields"));
slotFieldGroup.createEl("span", {
text: qaGroupState.mapping
? t("settings.cards.cardTypes.qaGroup.detectedSlots", {
count: qaGroupState.mapping.slots.length,
summary: summarizeQaGroupSlots(qaGroupState.mapping),
})
: (selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel")),
}).dataset.qaGroupSlotSummary = configId;
if (qaGroupState.error) {
const errorEl = containerEl.createDiv({
text: renderUserFacingMessage(qaGroupState.error),
});
errorEl.dataset.qaGroupError = configId;
errorEl.style.gridColumn = "1 / -1";
return;
}
if (!qaGroupState.mapping || qaGroupState.mapping.warnings.length === 0) {
return;
}
const warningsContainer = containerEl.createDiv();
warningsContainer.dataset.qaGroupWarnings = configId;
warningsContainer.style.gridColumn = "1 / -1";
warningsContainer.style.display = "flex";
warningsContainer.style.flexDirection = "column";
warningsContainer.style.gap = "6px";
warningsContainer.createEl("strong", { text: t("settings.cards.cardTypes.labels.qaGroupWarnings") });
for (const warning of qaGroupState.mapping.warnings) {
warningsContainer.createEl("div", {
text: this.renderQaGroupWarning(warning),
}).dataset.qaGroupWarning = configId;
}
const acceptLabel = warningsContainer.createEl("label");
acceptLabel.style.display = "inline-flex";
acceptLabel.style.alignItems = "center";
acceptLabel.style.gap = "8px";
const acceptCheckbox = acceptLabel.createEl("input") as HTMLInputElement;
acceptCheckbox.type = "checkbox";
acceptCheckbox.checked = areQaGroupWarningsAccepted(qaGroupState.mapping.warnings, qaGroupState.mapping.acceptedWarnings);
acceptCheckbox.dataset.qaGroupWarningAccept = configId;
acceptCheckbox.addEventListener("change", () => this.saveQaGroupWarningAcceptance(configId, acceptCheckbox.checked));
acceptLabel.createEl("span", { text: t("settings.cards.cardTypes.qaGroup.acceptWarnings") });
}
private renderSyncContentCard(containerEl: HTMLElement): void {
@ -599,6 +663,29 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.cardTypeStatus = NOTE_TYPE_STATUS_IDLE;
await this.plugin.updateSettings({ cardTypeConfigs: nextCardTypeConfigs });
if (configId === "qa-group" && typeof partialConfig.noteType === "string") {
await this.syncQaGroupMappingFromCache(nextCardTypeConfigs[configId].noteType);
const mappingKey = createNoteFieldMappingKey("qa-group", nextCardTypeConfigs[configId].noteType);
if (!this.plugin.settings.noteFieldMappings[mappingKey]) {
const cacheEntry = this.plugin.settings.ankiModelFieldCache[nextCardTypeConfigs[configId].noteType];
if (cacheEntry) {
const fallbackMapping = this.qaGroupFieldMappingService.suggest(
nextCardTypeConfigs[configId].noteType,
cacheEntry.fieldNames,
cacheEntry.loadedAt,
);
await this.plugin.updateSettings({
noteFieldMappings: {
...this.plugin.settings.noteFieldMappings,
[mappingKey]: fallbackMapping,
},
});
}
}
}
this.renderCard("card-types");
return true;
}
@ -611,7 +698,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const nextMapping = {
...(mapping ?? this.createFallbackMapping(runtimeCardType, modelName)),
...partialMapping,
};
} as NoteModelFieldMapping;
this.draftMappings[mappingKey] = nextMapping;
await this.plugin.updateSettings({
@ -620,7 +707,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
[mappingKey]: {
...nextMapping,
loadedFieldNames: [...nextMapping.loadedFieldNames],
},
} as NoteModelFieldMapping,
},
});
this.renderCard("card-types");
@ -652,6 +739,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
this.hydrateVisibleCardTypeCaches(ankiModelFieldCache);
await this.syncQaGroupMappingFromCache(this.plugin.settings.cardTypeConfigs["qa-group"].noteType, ankiModelFieldCache);
const configuredCount = this.countConfiguredCardTypes(ankiModelFieldCache);
this.cardTypeStatus = {
@ -713,6 +801,20 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName);
const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey];
if (runtimeCardType === "qa-group") {
try {
this.draftMappings[mappingKey] = this.qaGroupFieldMappingService.suggest(
modelName,
modelDetails.fieldNames,
loadedAt,
isQaGroupFieldMapping(currentMapping) ? currentMapping.acceptedWarnings : undefined,
);
} catch {
delete this.draftMappings[mappingKey];
}
return;
}
if (currentMapping) {
this.draftMappings[mappingKey] = {
...currentMapping,
@ -758,6 +860,22 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
return undefined;
}
if (runtimeCardType === "qa-group") {
try {
const suggestedMapping = this.qaGroupFieldMappingService.suggest(modelName, modelDetails.fieldNames);
this.draftMappings[mappingKey] = suggestedMapping;
return {
...suggestedMapping,
loadedFieldNames: [...suggestedMapping.loadedFieldNames],
slots: suggestedMapping.slots.map((slot) => ({ ...slot })),
warnings: [...suggestedMapping.warnings],
acceptedWarnings: suggestedMapping.acceptedWarnings ? [...suggestedMapping.acceptedWarnings] : undefined,
};
} catch {
return undefined;
}
}
const suggestedMapping = this.noteFieldMappingService.suggest(runtimeCardType, modelName, modelDetails.fieldNames);
this.draftMappings[mappingKey] = suggestedMapping;
return {
@ -771,9 +889,26 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const modelDetails = this.loadedModelDetails[mappingKey];
if (modelDetails) {
if (runtimeCardType === "qa-group") {
return this.qaGroupFieldMappingService.suggest(modelName, modelDetails.fieldNames);
}
return this.noteFieldMappingService.suggest(runtimeCardType, modelName, modelDetails.fieldNames);
}
if (runtimeCardType === "qa-group") {
return {
cardType: runtimeCardType,
modelName,
titleField: undefined,
slots: [],
warnings: [],
acceptedWarnings: undefined,
loadedFieldNames: [],
loadedAt: Date.now(),
};
}
return {
cardType: runtimeCardType,
modelName,
@ -782,6 +917,130 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
};
}
private async saveQaGroupWarningAcceptance(configId: Extract<CardTypeConfigId, "qa-group">, accepted: boolean): Promise<void> {
const mapping = this.getCurrentMappingForConfig(configId);
if (!mapping || !isQaGroupFieldMapping(mapping)) {
return;
}
const runtimeCardType = this.getRuntimeCardType(configId);
const mappingKey = createNoteFieldMappingKey(runtimeCardType, mapping.modelName);
const nextMapping: QaGroupFieldMapping = {
...mapping,
slots: mapping.slots.map((slot) => ({ ...slot })),
warnings: [...mapping.warnings],
acceptedWarnings: accepted ? [...mapping.warnings] : undefined,
loadedFieldNames: [...mapping.loadedFieldNames],
};
this.draftMappings[mappingKey] = nextMapping;
await this.plugin.updateSettings({
noteFieldMappings: {
...this.plugin.settings.noteFieldMappings,
[mappingKey]: nextMapping,
},
});
this.renderCard("card-types");
}
private async syncQaGroupMappingFromCache(
modelName: string,
ankiModelFieldCache: AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"] = this.plugin.settings.ankiModelFieldCache,
): Promise<void> {
const trimmedModelName = modelName.trim();
if (trimmedModelName.length === 0) {
return;
}
const cacheEntry = ankiModelFieldCache[trimmedModelName];
if (!cacheEntry) {
return;
}
const mappingKey = createNoteFieldMappingKey("qa-group", trimmedModelName);
const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey];
try {
const nextMapping = this.qaGroupFieldMappingService.suggest(
trimmedModelName,
cacheEntry.fieldNames,
cacheEntry.loadedAt,
isQaGroupFieldMapping(currentMapping) ? currentMapping.acceptedWarnings : undefined,
);
this.draftMappings[mappingKey] = nextMapping;
if (!areMappingsEqual(this.plugin.settings.noteFieldMappings[mappingKey], nextMapping)) {
await this.plugin.updateSettings({
noteFieldMappings: {
...this.plugin.settings.noteFieldMappings,
[mappingKey]: nextMapping,
},
});
}
} catch {
delete this.draftMappings[mappingKey];
if (this.plugin.settings.noteFieldMappings[mappingKey]) {
const nextMappings = { ...this.plugin.settings.noteFieldMappings };
delete nextMappings[mappingKey];
await this.plugin.updateSettings({ noteFieldMappings: nextMappings });
}
}
}
private getQaGroupFieldState(configId: Extract<CardTypeConfigId, "qa-group">): { mapping?: QaGroupFieldMapping; error?: UserFacingMessage } {
const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType;
const mappingKey = createNoteFieldMappingKey("qa-group", modelName);
const currentMapping = this.draftMappings[mappingKey] ?? this.plugin.settings.noteFieldMappings[mappingKey];
if (currentMapping && isQaGroupFieldMapping(currentMapping)) {
return {
mapping: {
...currentMapping,
loadedFieldNames: [...currentMapping.loadedFieldNames],
slots: currentMapping.slots.map((slot) => ({ ...slot })),
warnings: [...currentMapping.warnings],
acceptedWarnings: currentMapping.acceptedWarnings ? [...currentMapping.acceptedWarnings] : undefined,
},
};
}
const modelDetails = this.loadedModelDetails[mappingKey];
if (!modelDetails) {
return {};
}
try {
return {
mapping: this.qaGroupFieldMappingService.suggest(modelName, modelDetails.fieldNames),
};
} catch (error) {
return {
error: toUserFacingMessage(error, "settings.cards.cardTypes.failedSave"),
};
}
}
private renderQaGroupWarning(warning: string): string {
const parsedWarning = parseQaGroupFieldWarning(warning);
if (!parsedWarning) {
return warning;
}
const index = String(parsedWarning.index).padStart(2, "0");
if (parsedWarning.kind === "missing-answer") {
return t("settings.cards.cardTypes.qaGroup.warning.missingAnswer", {
index,
questionField: parsedWarning.questionField,
answerField: parsedWarning.answerField,
});
}
return t("settings.cards.cardTypes.qaGroup.warning.missingQuestion", {
index,
questionField: parsedWarning.questionField,
answerField: parsedWarning.answerField,
});
}
private getRuntimeCardType(configId: CardTypeConfigId): NoteModelFieldMapping["cardType"] {
if (configId === "qa-group") {
return "qa-group";
@ -1053,6 +1312,19 @@ function getScopeModeSummary(scopeMode: ScopeMode): string {
return t("settings.scope.summary.all");
}
function summarizeQaGroupSlots(mapping: QaGroupFieldMapping): string {
const slotPairs = mapping.slots.map((slot) => `${slot.questionField}/${slot.answerField}`);
if (slotPairs.length <= 3) {
return slotPairs.join(", ");
}
return `${slotPairs.slice(0, 3).join(", ")}...`;
}
function areMappingsEqual(left: NoteModelFieldMapping | undefined, right: NoteModelFieldMapping): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function getScopeModeTreeDescription(scopeMode: ScopeMode): string {
if (scopeMode === "include") {
return t("settings.scope.treeDescription.include");

View file

@ -10,6 +10,8 @@ import type { SourceFile } from "@/domain/card/entities/SourceFile";
import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
import { createEmptyPluginState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
export const QA_GROUP_USER_NOTE_TYPE = "问答题(多级列表)";
export class InMemoryPluginStateRepository implements PluginStateRepository {
public savedState: PluginState | null = null;
@ -157,6 +159,10 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
public modelDetailsByName: Record<string, NoteModelDetails> = {
Basic: { fieldNames: ["Front", "Back"], isCloze: false },
Cloze: { fieldNames: ["Text", "Extra"], isCloze: true },
[QA_GROUP_USER_NOTE_TYPE]: {
fieldNames: ["题目", "问题01", "答案01", "问题02", "答案02", "问题03", "答案03"],
isCloze: false,
},
};
public modelTemplatesByName: Record<string, Record<string, AnkiModelTemplate>> = {};
public modelStylingByName: Record<string, string> = {};
@ -341,6 +347,23 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
export function createModule3Settings(overrides: Partial<PluginSettings> = {}): PluginSettings {
return {
...DEFAULT_SETTINGS,
qaNoteType: "Basic",
clozeNoteType: "Cloze",
cardTypeConfigs: {
...DEFAULT_SETTINGS.cardTypeConfigs,
basic: {
...DEFAULT_SETTINGS.cardTypeConfigs.basic,
noteType: "Basic",
},
"qa-group": {
...DEFAULT_SETTINGS.cardTypeConfigs["qa-group"],
noteType: QA_GROUP_USER_NOTE_TYPE,
},
cloze: {
...DEFAULT_SETTINGS.cardTypeConfigs.cloze,
noteType: "Cloze",
},
},
fileDeckEnabled: true,
fileDeckMarker: "TARGET DECK",
fileDeckTemplate: "obsidian::filename",
@ -362,6 +385,19 @@ export function createModule3Settings(overrides: Partial<PluginSettings> = {}):
mainField: "Text",
loadedAt: 1,
},
[`qa-group:${QA_GROUP_USER_NOTE_TYPE}`]: {
cardType: "qa-group",
modelName: QA_GROUP_USER_NOTE_TYPE,
loadedFieldNames: ["题目", "问题01", "答案01", "问题02", "答案02", "问题03", "答案03"],
titleField: "题目",
slots: [
{ index: 1, questionField: "问题01", answerField: "答案01" },
{ index: 2, questionField: "问题02", answerField: "答案02" },
{ index: 3, questionField: "问题03", answerField: "答案03" },
],
warnings: [],
loadedAt: 1,
},
},
...overrides,
};