From 3d82d7fb550509462b1210fe32dabdfa2ed78d1e Mon Sep 17 00:00:00 2001 From: Dusk Date: Fri, 24 Apr 2026 23:47:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20QA=20Group=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=B8=BA=E7=94=A8=E6=88=B7=E6=A8=A1=E6=9D=BF=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E8=87=AA=E5=8A=A8=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=98=A0=E5=B0=84=20|=20Refactor=20QA=20Group=20to=20user-temp?= =?UTF-8?q?late-driven=20route=20with=20auto=20field=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/qa-group-user-template-decisions.md | 192 +++++++++++ docs/qa-group-user-template-gap-report.md | 153 ++++++++ .../settings-anki-template-cache-decisions.md | 115 ++++++ ...settings-anki-template-cache-gap-report.md | 111 ++++++ .../config/NoteModelFieldMapping.ts | 44 ++- src/application/config/PluginSettings.test.ts | 78 ++++- src/application/config/PluginSettings.ts | 249 ++++++++++++- .../services/ManualSyncService.test.ts | 22 +- .../services/NoteFieldMappingService.ts | 34 +- .../QaGroupFieldMappingService.test.ts | 107 ++++++ .../services/QaGroupFieldMappingService.ts | 224 ++++++++++++ .../services/QaGroupSyncService.test.ts | 249 ++++++++++--- .../services/QaGroupSyncService.ts | 296 ++++++++++------ .../services/RenderConfigService.ts | 19 +- ...ClearCurrentFileSyncedCardsUseCase.test.ts | 2 +- .../services/GroupMarkerService.test.ts | 12 + .../services/GroupMarkerService.ts | 8 +- src/presentation/i18n/messages/en.ts | 33 ++ src/presentation/i18n/messages/zh.ts | 33 ++ .../settings/PluginSettingTab.test.ts | 86 ++++- src/presentation/settings/PluginSettingTab.ts | 326 ++++++++++++++++-- src/test-support/manualSyncFakes.ts | 36 ++ 22 files changed, 2185 insertions(+), 244 deletions(-) create mode 100644 docs/qa-group-user-template-decisions.md create mode 100644 docs/qa-group-user-template-gap-report.md create mode 100644 docs/settings-anki-template-cache-decisions.md create mode 100644 docs/settings-anki-template-cache-gap-report.md create mode 100644 src/application/services/QaGroupFieldMappingService.test.ts create mode 100644 src/application/services/QaGroupFieldMappingService.ts diff --git a/docs/qa-group-user-template-decisions.md b/docs/qa-group-user-template-decisions.md new file mode 100644 index 0000000..a43dcd7 --- /dev/null +++ b/docs/qa-group-user-template-decisions.md @@ -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:"]` +- 同步前校验: + - 已选择模型 + - 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 不固定”所必须的仓库兼容修正。 \ No newline at end of file diff --git a/docs/qa-group-user-template-gap-report.md b/docs/qa-group-user-template-gap-report.md new file mode 100644 index 0000000..a1b0a19 --- /dev/null +++ b/docs/qa-group-user-template-gap-report.md @@ -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 内部身份字段写入 \ No newline at end of file diff --git a/docs/settings-anki-template-cache-decisions.md b/docs/settings-anki-template-cache-decisions.md new file mode 100644 index 0000000..8512b10 --- /dev/null +++ b/docs/settings-anki-template-cache-decisions.md @@ -0,0 +1,115 @@ +# 设置页 Anki 模板缓存实现决策 + +本文锁定本轮“设置页 Anki 模板加载性能优化”的仓库兼容实现方案。 + +## 1. 设置模型新增字段缓存 + +- 保留现有 `ankiNoteTypeCache: string[]`。 +- 新增: + - `ankiModelFieldCache: Record` +- 该缓存只用于设置页 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>` +- `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 和字段映射。为了不扩大产品行为回退范围,本轮不顺手收紧它,只做缓存与批量读取优化。 \ No newline at end of file diff --git a/docs/settings-anki-template-cache-gap-report.md b/docs/settings-anki-template-cache-gap-report.md new file mode 100644 index 0000000..4be93c2 --- /dev/null +++ b/docs/settings-anki-template-cache-gap-report.md @@ -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 \ No newline at end of file diff --git a/src/application/config/NoteModelFieldMapping.ts b/src/application/config/NoteModelFieldMapping.ts index 6df26d5..fe93224 100644 --- a/src/application/config/NoteModelFieldMapping.ts +++ b/src/application/config/NoteModelFieldMapping.ts @@ -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; + 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"; +} diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts index 20123e9..9c817d1 100644 --- a/src/application/config/PluginSettings.test.ts +++ b/src/application/config/PluginSettings.test.ts @@ -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({ diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts index c4de1da..d6dd4bf 100644 --- a/src/application/config/PluginSettings.ts +++ b/src/application/config/PluginSettings.ts @@ -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 | 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 ...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 { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + const normalizedMappings: Record = {}; + + for (const rawMapping of Object.values(value)) { + if (!rawMapping || typeof rawMapping !== "object" || Array.isArray(rawMapping)) { + continue; + } + + const mapping = rawMapping as Partial & Record; + 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, + 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; + 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); + } + } +} diff --git a/src/application/services/ManualSyncService.test.ts b/src/application/services/ManualSyncService.test.ts index 5fefd5f..379bba4 100644 --- a/src/application/services/ManualSyncService.test.ts +++ b/src/application/services/ManualSyncService.test.ts @@ -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(//); + expect(vaultGateway.getFileContent("notes/example.md")).toMatch(//); expect(Object.values(stateRepository.savedState?.groupBlocks ?? {})).toHaveLength(1); expect(stateRepository.savedState?.files["notes/example.md"]?.groupIds).toHaveLength(1); }); diff --git a/src/application/services/NoteFieldMappingService.ts b/src/application/services/NoteFieldMappingService.ts index bfe08dd..f8b142d 100644 --- a/src/application/services/NoteFieldMappingService.ts +++ b/src/application/services/NoteFieldMappingService.ts @@ -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 { + private mapBasic(card: RenderedCardInput, mapping: BasicLikeNoteModelFieldMapping): Record { const titleFieldName = mapping.titleField; const bodyFieldName = mapping.bodyField; @@ -117,7 +133,7 @@ export class NoteFieldMappingService { }; } - private mapCloze(card: RenderedCardInput, noteModelDetails: NoteModelDetails, mapping: NoteModelFieldMapping): Record { + private mapCloze(card: RenderedCardInput, noteModelDetails: NoteModelDetails, mapping: ClozeNoteModelFieldMapping): Record { 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 { - return cardType === "qa-group" || cardType === "basic" || cardType === "semantic-qa"; +function isBasicLikeMappingCardType(cardType: NoteModelFieldMappingCardType): cardType is Extract { + 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): "errors.noteFieldMapping.titleBodyMustDiffer.basic" | "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" { +function getTitleBodyMustDifferKey(cardType: Extract): "errors.noteFieldMapping.titleBodyMustDiffer.basic" | "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" { return cardType === "semantic-qa" ? "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" : "errors.noteFieldMapping.titleBodyMustDiffer.basic"; diff --git a/src/application/services/QaGroupFieldMappingService.test.ts b/src/application/services/QaGroupFieldMappingService.test.ts new file mode 100644 index 0000000..11969ec --- /dev/null +++ b/src/application/services/QaGroupFieldMappingService.test.ts @@ -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, + }); + }); +}); \ No newline at end of file diff --git a/src/application/services/QaGroupFieldMappingService.ts b/src/application/services/QaGroupFieldMappingService.ts new file mode 100644 index 0000000..7d080dc --- /dev/null +++ b/src/application/services/QaGroupFieldMappingService.ts @@ -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(); + 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; + 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(); + const answerFieldByIndex = new Map(); + + 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")}`; +} \ No newline at end of file diff --git a/src/application/services/QaGroupSyncService.test.ts b/src/application/services/QaGroupSyncService.test.ts index 527a39e..b59efb1 100644 --- a/src/application/services/QaGroupSyncService.test.ts +++ b/src/application/services/QaGroupSyncService.test.ts @@ -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 Stem", - S01_Q: "Alpha bold", + 题目: "Concepts Stem", + 问题01: "Alpha bold", }); - expect(ankiGateway.addedNotes[0]?.fields.S01_A).toContain("First answer and 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 answer and 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 ', + obsidianBacklinkLabel: "Open ", obsidianBacklinkPlacement: "answer-first-line", })); - expect(ankiGateway.createdModels[0]?.templates[0]?.back).toContain('Open <Vault>'); - expect(ankiGateway.createdModels[0]?.templates[0]?.back).toContain('
\n\n{{#Src}}

Open <Vault>

{{/Src}}\n
{{S01_A}}
'); + expect(ankiGateway.createdModels).toEqual([]); + expect(ankiGateway.addedNotes[0]?.fields.答案01).toContain('Open <Vault>'); + expect(ankiGateway.addedNotes[0]?.fields.答案01).toMatch(/^

{ @@ -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 { @@ -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 { + const fields: Record = { + 题目: 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", + }); +} \ No newline at end of file diff --git a/src/application/services/QaGroupSyncService.ts b/src/application/services/QaGroupSyncService.ts index 7078e6a..de3c085 100644 --- a/src/application/services/QaGroupSyncService.ts +++ b/src/application/services/QaGroupSyncService.ts @@ -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(); @@ -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 { + private async resolveRecoveredGroup( + block: IndexedGroupCardBlock, + stateIndex: GroupStateIndex, + modelName: string, + mapping: QaGroupFieldMapping, + availableSlots: number[], + ): Promise { 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 { - 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 { + private async tryLoadQaGroupNote(noteId: number, block: IndexedGroupCardBlock, modelName: string): Promise { 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 { + 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 = { + [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): GroupStateIndex { @@ -418,39 +499,42 @@ function buildGroupStateIndex(groupBlocks: Record): 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(); - 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(); const unmatchedRecovered: GroupItem[] = []; @@ -505,7 +589,7 @@ function reconcileGroupItems(currentItems: GroupItem[], recoveredItems: GroupIte } const occupiedSlots = new Set(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(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(items: T[] | undefined): T | undefined { } function haveEqualFields(left: Record, right: Record): 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(/

]*>.*?<\/a><\/p>/g, "") + .replace(/
]*>.*?<\/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, "\\\"")}"`; -} diff --git a/src/application/services/RenderConfigService.ts b/src/application/services/RenderConfigService.ts index e5c17aa..b97e236 100644 --- a/src/application/services/RenderConfigService.ts +++ b/src/application/services/RenderConfigService.ts @@ -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; } diff --git a/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts b/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts index 03748a7..9881485 100644 --- a/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts +++ b/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts @@ -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(//); + expect(vaultGateway.getFileContent(filePath)).toMatch(//); expect(vaultGateway.getFileContent(filePath)).not.toContain(""); + expect(service.parse(marker.raw, 9)).toMatchObject({ + noteId: 42, + itemToSlot: { item_a: 13 }, + freeSlots: [1, 2, 14], + }); + }); }); diff --git a/src/domain/manual-sync/services/GroupMarkerService.ts b/src/domain/manual-sync/services/GroupMarkerService.ts index 0d02afb..4cb1067 100644 --- a/src/domain/manual-sync/services/GroupMarkerService.ts +++ b/src/domain/manual-sync/services/GroupMarkerService.ts @@ -235,16 +235,16 @@ function parseFreeSlots(rawValue: string): number[] { function validateSlotRanges(itemToSlot: Record, freeSlots: number[]): void { const usedSlots = new Set(); 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)) { diff --git a/src/presentation/i18n/messages/en.ts b/src/presentation/i18n/messages/en.ts index 7bbb799..32b6aa8 100644 --- a/src/presentation/i18n/messages/en.ts +++ b/src/presentation/i18n/messages/en.ts @@ -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).", diff --git a/src/presentation/i18n/messages/zh.ts b/src/presentation/i18n/messages/zh.ts index 3d58078..a75c814 100644 --- a/src/presentation/i18n/messages/zh.ts +++ b/src/presentation/i18n/messages/zh.ts @@ -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}} 个文件。", diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index 8f80488..7377325 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -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> = []; + public readonly fieldNamesByModelName: Record = {}; public listNoteModelsCalls = 0; public getModelFieldNamesByModelNamesCalls = 0; public getNoteModelDetailsCalls = 0; @@ -335,7 +337,7 @@ class FakePlugin { async listNoteModels(): Promise { 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> { @@ -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({ diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index daf411e..92ca151 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -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 = {}; private readonly loadedModelDetails: Record = {}; @@ -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): 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, accepted: boolean): Promise { + 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 { + 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): { 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"); diff --git a/src/test-support/manualSyncFakes.ts b/src/test-support/manualSyncFakes.ts index 754ce76..35103ae 100644 --- a/src/test-support/manualSyncFakes.ts +++ b/src/test-support/manualSyncFakes.ts @@ -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 = { 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> = {}; public modelStylingByName: Record = {}; @@ -341,6 +347,23 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway { export function createModule3Settings(overrides: Partial = {}): 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 = {}): 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, };