mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
feat: QA Group 重构为用户模板驱动并支持自动字段映射 | Refactor QA Group to user-template-driven route with auto field mapping
This commit is contained in:
parent
d8bd1ebf12
commit
3d82d7fb55
22 changed files with 2185 additions and 244 deletions
192
docs/qa-group-user-template-decisions.md
Normal file
192
docs/qa-group-user-template-decisions.md
Normal 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 不固定”所必须的仓库兼容修正。
|
||||
153
docs/qa-group-user-template-gap-report.md
Normal file
153
docs/qa-group-user-template-gap-report.md
Normal 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 内部身份字段写入
|
||||
115
docs/settings-anki-template-cache-decisions.md
Normal file
115
docs/settings-anki-template-cache-decisions.md
Normal 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 和字段映射。为了不扩大产品行为回退范围,本轮不顺手收紧它,只做缓存与批量读取优化。
|
||||
111
docs/settings-anki-template-cache-gap-report.md
Normal file
111
docs/settings-anki-template-cache-gap-report.md
Normal 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
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
107
src/application/services/QaGroupFieldMappingService.test.ts
Normal file
107
src/application/services/QaGroupFieldMappingService.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
224
src/application/services/QaGroupFieldMappingService.ts
Normal file
224
src/application/services/QaGroupFieldMappingService.ts
Normal 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")}`;
|
||||
}
|
||||
|
|
@ -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 <Vault>');
|
||||
expect(ankiGateway.createdModels[0]?.templates[0]?.back).toContain('<hr id="answer">\n\n{{#Src}}<p><a class="anki-heading-sync-backlink" href="{{Src}}">Open <Vault></a></p>{{/Src}}\n<div class="a">{{S01_A}}</div>');
|
||||
expect(ankiGateway.createdModels).toEqual([]);
|
||||
expect(ankiGateway.addedNotes[0]?.fields.答案01).toContain('Open <Vault>');
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
|
@ -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, "\\\"")}"`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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).",
|
||||
|
|
|
|||
|
|
@ -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}} 个文件。",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue