From bbe1eece12c8bd644fb5c1ecc5741e3deec610af Mon Sep 17 00:00:00 2001 From: Dusk Date: Mon, 20 Apr 2026 15:20:20 +0800 Subject: [PATCH] =?UTF-8?q?QA=20Group=20=E6=A8=A1=E5=9E=8B=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E4=BF=AE=E5=A4=8D=20/=20Fix=20QA=20Group=20model=20sy?= =?UTF-8?q?nc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 QA Group model 管理的 AnkiConnect payload 契约,模板与样式更新改为嵌套 model payload - 修复 QA Group model 自检与更新链路,补充带 action/model 上下文的错误信息与回归测试 - 解耦 QA Group 与旧 semantic-qa mapping 路径,设置页移除保留 model 的旧映射入口并增加只读状态说明 - Fix the AnkiConnect payload contract for QA Group model management by using nested model payloads for template and styling updates - Repair the QA Group model ensure/update flow with action/model-aware errors and regression tests - Decouple QA Group from the legacy semantic-qa mapping path by removing the reserved model from old mapping controls and adding read-only status guidance --- docs/qa-group-model-fix-decisions.md | 82 ++++++++++ docs/qa-group-model-fix-gap-report.md | 92 +++++++++++ .../services/QaGroupModelService.test.ts | 126 +++++++++++++++ .../services/QaGroupModelService.ts | 81 ++++++++-- .../anki/AnkiConnectGateway.test.ts | 144 ++++++++++++++++++ src/infrastructure/anki/AnkiConnectGateway.ts | 19 ++- .../settings/PluginSettingTab.test.ts | 32 +++- src/presentation/settings/PluginSettingTab.ts | 82 +++++++++- 8 files changed, 634 insertions(+), 24 deletions(-) create mode 100644 docs/qa-group-model-fix-decisions.md create mode 100644 docs/qa-group-model-fix-gap-report.md create mode 100644 src/application/services/QaGroupModelService.test.ts diff --git a/docs/qa-group-model-fix-decisions.md b/docs/qa-group-model-fix-decisions.md new file mode 100644 index 0000000..a62fc7b --- /dev/null +++ b/docs/qa-group-model-fix-decisions.md @@ -0,0 +1,82 @@ +# QA Group Model Fix Decisions + +## Decision Summary + +本轮按真实仓库结构锁定以下实现决策: + +1. QA Group model-management 的更新类 action 统一采用嵌套 `params.model` 契约。 +2. `createModel`、`modelFieldAdd`、`modelTemplateAdd` 保持当前仓库已使用的参数命名,不引入第二套 payload 风格。 +3. `QaGroupModelService.ensureModel()` 保持现有执行顺序,但所有失败都补 action/model 上下文。 +4. template 与 CSS 继续使用严格字符串比较,不引入归一化逻辑。 +5. QA Group 执行链保持独立字段构造,不回接旧 mapping service。 +6. settings/UI 不再允许 `ObsiAnki QA Group 12` 进入旧 `basic / cloze / semantic-qa` mapping 面板。 +7. settings/UI 为 QA Group 仅保留 marker 与只读 model 状态信息,不新增可编辑 field mapping。 + +## Concrete Decisions + +### 1. Gateway contract target + +当前仓库对 QA Group model-management action 的目标契约定义为: + +- `createModel`: 顶层 `modelName / inOrderFields / css / isCloze / cardTemplates` +- `modelFieldAdd`: 顶层 `modelName / fieldName` +- `modelTemplateAdd`: 顶层 `modelName / templateName / Front / Back` +- `updateModelTemplates`: 顶层只保留 `model`,其中嵌套 `name / templates` +- `updateModelStyling`: 顶层只保留 `model`,其中嵌套 `name / css` + +这样做的原因是:本次真实运行失败只出现在 update 系列 action,且其错误文本明确指向顶层 `templates` 非法。 + +### 2. One helper, one contract + +在 `AnkiConnectGateway` 中新增局部 helper,用来构建 model update payload。 + +目的: + +- 直接表达“本项目针对当前运行目标使用嵌套 `model` 更新契约” +- 避免后续在多个 action 里再次混入旧顶层写法 + +### 3. Ensure flow error policy + +`QaGroupModelService.ensureModel()` 的每个外部动作都用统一 helper 包装异常,错误信息至少包含: + +- action 名 +- model 名 +- 一个简短参数摘要 + +示例粒度: + +- `createModel` +- `addModelField` +- `addModelTemplate` +- `updateModelTemplate` +- `updateModelStyling` + +### 4. Route/UI decoupling policy + +QA Group route 与旧 mapping path 的解耦采用“限制错误入口”而不是“重写执行链”: + +- `QaGroupSyncService` 继续直接构造完整字段表 +- `NoteFieldMappingService` 不扩展到 QA Group +- `PluginSettingTab` 过滤 QA Group model,不让它出现在旧 mapping 下拉中 +- `PluginSettingTab` 增加 QA Group model 的只读状态展示,说明该 model 由插件自动校验和维护 + +### 5. Backward compatibility policy + +必须保留的旧行为: + +- `semantic-qa` 仍然通过 title/body field mapping 工作 +- `basic` 与 `cloze` 现有 mapping 行为不变 +- `#anki-list` 继续走 QA Group route +- `#anki-list-qa` 继续走 semantic-qa route + +## Intentional Deviation From Draft Plan + +偏离点只有一处: + +- 不新增 QA Group 可编辑 mapping 面板,而是改为只读状态区块 + +原因: + +- 真实执行链本来就不依赖旧 mapping service +- 继续暴露可编辑 mapping 只会制造错误配置入口 +- 固定协议 model 更适合状态展示,而不是可配置字段映射 \ No newline at end of file diff --git a/docs/qa-group-model-fix-gap-report.md b/docs/qa-group-model-fix-gap-report.md new file mode 100644 index 0000000..5d03d37 --- /dev/null +++ b/docs/qa-group-model-fix-gap-report.md @@ -0,0 +1,92 @@ +# QA Group Model Fix Gap Report + +## Scope + +本报告基于当前仓库真实代码审查,聚焦这三类差距: + +- QA Group model-management 的 AnkiConnect payload 契约 +- QA Group model ensure/update 链路的错误分层与回归风险 +- QA Group route 与旧 semantic-qa mapping UI 的残余耦合 + +## Confirmed Gaps + +### 1. 运行报错的直接根因已在网关中确认 + +`src/infrastructure/anki/AnkiConnectGateway.ts` 当前实现里: + +- `updateModelTemplate()` 发送的是 `{ model: modelName, templates: ... }` +- `updateModelStyling()` 发送的是 `{ model: modelName, css }` + +这与用户提供的真实运行错误一致:当前运行目标要求把更新内容嵌套进 `params.model` 对象,而不是把 `templates` / `css` 放在顶层。 + +### 2. 同类 model-management action 需要统一收口,但并非全部都错 + +同文件中已审查的其他 action: + +- `createModel()` 当前使用 `modelName / inOrderFields / css / isCloze / cardTemplates` +- `addModelField()` 当前使用 `modelName / fieldName` +- `addModelTemplate()` 当前使用 `modelName / templateName / Front / Back` + +基于当前仓库既有实现与测试夹具,这三处没有出现和本次报错同型的“顶层/嵌套混用”问题,但缺少明确契约说明与专门测试,仍有回退风险。 + +### 3. QA Group ensure 流程顺序基本正确,但失败信息过粗 + +`src/application/services/QaGroupModelService.ts` 已按以下顺序执行: + +1. `listNoteModels` +2. 缺失则 `createModel` +3. 读取并补齐 fields +4. 读取并补齐 / 更新 templates +5. 读取并更新 CSS + +问题不在顺序,而在: + +- 任一步失败都会直接抛原始异常 +- 错误里没有 action 名、model 名、参数摘要 +- 目前没有单元测试锁定“缺 field / 缺 template / template drift / css drift / 错误上下文” + +### 4. QA Group 运行链路本身已基本独立,不是通过旧 mapping 组装字段 + +真实代码显示: + +- `QaGroupSyncService` 直接调用 `buildQaGroupNoteFields()` 生成 `Stem / GroupId / Src / S01..S12` +- `ManualSyncService` 将 QA Group 作为独立分支执行 +- `NoteFieldMappingService` 仅服务 `basic / cloze / semantic-qa` + +因此,QA Group 的运行链路没有继续依赖旧 title/body mapping 来生成字段。 + +### 5. 真正残留的耦合点在 settings UI,而不是执行链 + +`src/presentation/settings/PluginSettingTab.ts` 当前问题是: + +- note type 下拉框会把所有模型都暴露给 `basic / cloze / semantic-qa` mapping 区块 +- 这允许用户把 `ObsiAnki QA Group 12` 选进旧 mapping UI +- 一旦被选中,界面仍会要求配置 title/body 或 main field,形成误导 + +也就是说,问题是“旧 UI 允许错误配置 QA Group model”,不是“QA Group route 运行时真的复用了旧 mapping 逻辑”。 + +### 6. 测试覆盖存在明确空洞 + +当前缺少: + +- `AnkiConnectGateway` 针对 model-management payload 的专门契约测试 +- `QaGroupModelService.ensureModel()` 的服务级测试 +- settings/UI 对 QA Group model 过滤或状态展示的回归测试 + +## Repository-Compatible Fix Direction + +最安全的修复路径是: + +1. 在 `AnkiConnectGateway` 内统一 QA Group model-management payload 契约,明确更新类 action 使用嵌套 `model`。 +2. 在 `QaGroupModelService` 保持现有顺序,但给每个动作补上下文化错误包装和服务级测试。 +3. 保持 QA Group 独立字段构造链不动,只在 settings/UI 层切断 `ObsiAnki QA Group 12` 进入旧 mapping 面板的入口,并补一个只读状态区块。 +4. 用 gateway/service/settings 三层测试锁定回归面。 + +## Out Of Scope + +本轮不扩张到: + +- 新增 QA Group 自定义 field mapping UI +- 模板 / CSS 归一化比较系统 +- semantic-qa 与 QA Group 的自动迁移 +- 非 QA Group 相关的 AnkiConnect 行为重构 \ No newline at end of file diff --git a/src/application/services/QaGroupModelService.test.ts b/src/application/services/QaGroupModelService.test.ts new file mode 100644 index 0000000..c56412f --- /dev/null +++ b/src/application/services/QaGroupModelService.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from "vitest"; + +import { FakeManualSyncAnkiGateway } from "@/test-support/manualSyncFakes"; + +import { buildQaGroupModelDefinition } from "./QaGroupModelDefinition"; +import { QaGroupModelService } from "./QaGroupModelService"; + +describe("QaGroupModelService", () => { + it("creates the QA Group model when it is missing", async () => { + const ankiGateway = new FakeManualSyncAnkiGateway(); + const service = new QaGroupModelService(ankiGateway); + const definition = buildQaGroupModelDefinition(); + + await service.ensureModel(); + + expect(ankiGateway.createdModels).toEqual([definition]); + }); + + it("adds missing fields when the QA Group model already exists", async () => { + const definition = buildQaGroupModelDefinition(); + const ankiGateway = createExistingQaGroupGateway({ + fieldNames: definition.fieldNames.filter((fieldName) => fieldName !== "S12_A"), + }); + const service = new QaGroupModelService(ankiGateway); + + await service.ensureModel(); + + expect(ankiGateway.addedModelFields).toEqual([ + { + modelName: definition.modelName, + fieldName: "S12_A", + }, + ]); + }); + + it("adds missing templates when the QA Group model already exists", async () => { + const definition = buildQaGroupModelDefinition(); + const missingTemplate = definition.templates[definition.templates.length - 1]; + const ankiGateway = createExistingQaGroupGateway({ + templates: definition.templates.slice(0, -1), + }); + const service = new QaGroupModelService(ankiGateway); + + await service.ensureModel(); + + expect(ankiGateway.addedModelTemplates).toEqual([ + { + modelName: definition.modelName, + template: missingTemplate, + }, + ]); + }); + + it("updates drifted templates when the QA Group model already exists", async () => { + const definition = buildQaGroupModelDefinition(); + const driftedTemplate = { + ...definition.templates[0], + front: "drifted-front", + }; + const ankiGateway = createExistingQaGroupGateway({ + templates: [driftedTemplate, ...definition.templates.slice(1)], + }); + const service = new QaGroupModelService(ankiGateway); + + await service.ensureModel(); + + expect(ankiGateway.updatedModelTemplates).toEqual([ + { + modelName: definition.modelName, + template: definition.templates[0], + }, + ]); + }); + + it("updates drifted CSS when the QA Group model already exists", async () => { + const definition = buildQaGroupModelDefinition(); + const ankiGateway = createExistingQaGroupGateway({ + css: ".card { color: red; }", + }); + const service = new QaGroupModelService(ankiGateway); + + await service.ensureModel(); + + expect(ankiGateway.updatedModelStyling).toEqual([ + { + modelName: definition.modelName, + css: definition.css, + }, + ]); + }); + + it("includes the action name and model name when an update step fails", async () => { + const definition = buildQaGroupModelDefinition(); + const driftedTemplate = { + ...definition.templates[0], + back: "drifted-back", + }; + const ankiGateway = createExistingQaGroupGateway({ + templates: [driftedTemplate, ...definition.templates.slice(1)], + }); + vi.spyOn(ankiGateway, "updateModelTemplate").mockRejectedValue(new Error("boom")); + const service = new QaGroupModelService(ankiGateway); + + await expect(service.ensureModel()).rejects.toThrow( + `QA Group model sync failed during updateModelTemplate for "${definition.modelName}" (template=${definition.templates[0].name}): boom`, + ); + }); +}); + +function createExistingQaGroupGateway(overrides: { + fieldNames?: string[]; + templates?: ReturnType["templates"]; + css?: string; +} = {}): FakeManualSyncAnkiGateway { + const definition = buildQaGroupModelDefinition(); + const ankiGateway = new FakeManualSyncAnkiGateway(); + ankiGateway.modelDetailsByName[definition.modelName] = { + fieldNames: [...(overrides.fieldNames ?? definition.fieldNames)], + isCloze: false, + }; + ankiGateway.modelTemplatesByName[definition.modelName] = Object.fromEntries( + (overrides.templates ?? definition.templates).map((template) => [template.name, { ...template }]), + ); + ankiGateway.modelStylingByName[definition.modelName] = overrides.css ?? definition.css; + return ankiGateway; +} \ No newline at end of file diff --git a/src/application/services/QaGroupModelService.ts b/src/application/services/QaGroupModelService.ts index 5dfa9a2..56798e5 100644 --- a/src/application/services/QaGroupModelService.ts +++ b/src/application/services/QaGroupModelService.ts @@ -7,38 +7,101 @@ export class QaGroupModelService { async ensureModel(): Promise { const definition = buildQaGroupModelDefinition(); - const modelNames = await this.ankiGateway.listNoteModels(); + const modelNames = await this.runModelAction( + "listNoteModels", + definition.modelName, + undefined, + () => this.ankiGateway.listNoteModels(), + ); if (!modelNames.includes(definition.modelName)) { - await this.ankiGateway.createModel(definition); + await this.runModelAction( + "createModel", + definition.modelName, + `fields=${definition.fieldNames.length}, templates=${definition.templates.length}`, + () => this.ankiGateway.createModel(definition), + ); return; } - const existingFieldNames = await this.ankiGateway.getModelFieldNames(definition.modelName); + const existingFieldNames = await this.runModelAction( + "getModelFieldNames", + definition.modelName, + undefined, + () => this.ankiGateway.getModelFieldNames(definition.modelName), + ); + for (const fieldName of definition.fieldNames) { if (existingFieldNames.includes(fieldName)) { continue; } - await this.ankiGateway.addModelField(definition.modelName, fieldName); + await this.runModelAction( + "addModelField", + definition.modelName, + `field=${fieldName}`, + () => this.ankiGateway.addModelField(definition.modelName, fieldName), + ); } - const existingTemplates = await this.ankiGateway.getModelTemplates(definition.modelName); + const existingTemplates = await this.runModelAction( + "getModelTemplates", + definition.modelName, + undefined, + () => this.ankiGateway.getModelTemplates(definition.modelName), + ); + for (const template of definition.templates) { const existingTemplate = existingTemplates[template.name]; if (!existingTemplate) { - await this.ankiGateway.addModelTemplate(definition.modelName, template); + await this.runModelAction( + "addModelTemplate", + definition.modelName, + `template=${template.name}`, + () => this.ankiGateway.addModelTemplate(definition.modelName, template), + ); continue; } if (existingTemplate.front !== template.front || existingTemplate.back !== template.back) { - await this.ankiGateway.updateModelTemplate(definition.modelName, template); + await this.runModelAction( + "updateModelTemplate", + definition.modelName, + `template=${template.name}`, + () => this.ankiGateway.updateModelTemplate(definition.modelName, template), + ); } } - const existingCss = await this.ankiGateway.getModelStyling(definition.modelName); + const existingCss = await this.runModelAction( + "getModelStyling", + definition.modelName, + undefined, + () => this.ankiGateway.getModelStyling(definition.modelName), + ); + if (existingCss !== definition.css) { - await this.ankiGateway.updateModelStyling(definition.modelName, definition.css); + await this.runModelAction( + "updateModelStyling", + definition.modelName, + `cssLength=${definition.css.length}`, + () => this.ankiGateway.updateModelStyling(definition.modelName, definition.css), + ); + } + } + + private async runModelAction( + action: string, + modelName: string, + summary: string | undefined, + operation: () => Promise, + ): Promise { + try { + return await operation(); + } catch (error) { + const details = summary ? ` (${summary})` : ""; + const message = error instanceof Error ? error.message : String(error); + throw new Error(`QA Group model sync failed during ${action} for "${modelName}"${details}: ${message}`); } } } \ No newline at end of file diff --git a/src/infrastructure/anki/AnkiConnectGateway.test.ts b/src/infrastructure/anki/AnkiConnectGateway.test.ts index 32ea3db..d110aee 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.test.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.test.ts @@ -83,6 +83,150 @@ describe("AnkiConnectGateway", () => { }); }); + it("creates models with the repository's model-management contract", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: null, + result: null, + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + await gateway.createModel({ + modelName: "ObsiAnki QA Group 12", + fieldNames: ["Stem", "GroupId"], + css: ".card {}", + templates: [ + { + name: "Card 1", + front: "{{Front}}", + back: "{{Back}}", + }, + ], + }); + + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "createModel", + version: 6, + params: { + modelName: "ObsiAnki QA Group 12", + inOrderFields: ["Stem", "GroupId"], + css: ".card {}", + isCloze: false, + cardTemplates: [ + { + Name: "Card 1", + Front: "{{Front}}", + Back: "{{Back}}", + }, + ], + }, + }); + }); + + it("adds model fields with modelName and fieldName params", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: null, + result: null, + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + await gateway.addModelField("ObsiAnki QA Group 12", "Stem"); + + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "modelFieldAdd", + version: 6, + params: { + modelName: "ObsiAnki QA Group 12", + fieldName: "Stem", + }, + }); + }); + + it("adds model templates with templateName plus Front and Back", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: null, + result: null, + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + await gateway.addModelTemplate("ObsiAnki QA Group 12", { + name: "Card 1", + front: "{{Front}}", + back: "{{Back}}", + }); + + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "modelTemplateAdd", + version: 6, + params: { + modelName: "ObsiAnki QA Group 12", + templateName: "Card 1", + Front: "{{Front}}", + Back: "{{Back}}", + }, + }); + }); + + it("updates model templates with nested model params", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: null, + result: null, + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + await gateway.updateModelTemplate("ObsiAnki QA Group 12", { + name: "Card 1", + front: "{{Front}}", + back: "{{Back}}", + }); + + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "updateModelTemplates", + version: 6, + params: { + model: { + name: "ObsiAnki QA Group 12", + templates: { + "Card 1": { + Front: "{{Front}}", + Back: "{{Back}}", + }, + }, + }, + }, + }); + }); + + it("updates model styling with nested model params", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: null, + result: null, + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + await gateway.updateModelStyling("ObsiAnki QA Group 12", ".card { color: red; }"); + + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "updateModelStyling", + version: 6, + params: { + model: { + name: "ObsiAnki QA Group 12", + css: ".card { color: red; }", + }, + }, + }); + }); + it("returns note existence and model summaries", async () => { requestUrlMock .mockResolvedValueOnce({ diff --git a/src/infrastructure/anki/AnkiConnectGateway.ts b/src/infrastructure/anki/AnkiConnectGateway.ts index 3553c28..b07acc8 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.ts @@ -33,6 +33,15 @@ interface ModelStylingResponse { css?: string; } +function buildModelUpdateParams(modelName: string, updates: Record): Record { + return { + model: { + name: modelName, + ...updates, + }, + }; +} + export class AnkiConnectGateway implements AnkiGroupGateway { constructor(private readonly getBaseUrl: () => string) {} @@ -110,22 +119,18 @@ export class AnkiConnectGateway implements AnkiGroupGateway { } async updateModelTemplate(modelName: string, template: AnkiModelTemplate): Promise { - await this.invoke("updateModelTemplates", { - model: modelName, + await this.invoke("updateModelTemplates", buildModelUpdateParams(modelName, { templates: { [template.name]: { Front: template.front, Back: template.back, }, }, - }); + })); } async updateModelStyling(modelName: string, css: string): Promise { - await this.invoke("updateModelStyling", { - model: modelName, - css, - }); + await this.invoke("updateModelStyling", buildModelUpdateParams(modelName, { css })); } async findNoteIds(query: string): Promise { diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index 784ba09..f823cd2 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping"; import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings"; +import { QA_GROUP_MODEL_NAME } from "@/application/services/QaGroupModelDefinition"; const { FakeButtonComponent, @@ -308,7 +309,7 @@ class FakePlugin { } async listNoteModels(): Promise { - return ["Basic", "Cloze", "Custom Basic", "Custom Cloze", "Semantic QA"]; + return ["Basic", "Cloze", "Custom Basic", "Custom Cloze", "Semantic QA", QA_GROUP_MODEL_NAME]; } async getNoteModelDetails(modelName: string) { @@ -475,6 +476,35 @@ describe("AnkiHeadingSyncSettingTab", () => { expect(dropdown.options.map((option: { value: string }) => option.value)).toEqual(["Basic", "Cloze", "Custom Basic", "Custom Cloze", "Semantic QA"]); }); + it("shows QA Group model status and keeps it out of the field mapping dropdowns", async () => { + const plugin = new FakePlugin(); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + const container = tab.containerEl as unknown as FakeContainerElInstance; + + tab.display(); + + expect(container.textNodes).toContain(`Managed note type: ${QA_GROUP_MODEL_NAME}. QA Group sync writes Stem / GroupId / Src / S01..S12 directly and does not use the field mapping panels below.`); + expect(container.textNodes).toContain("Managed model contract: 39 fields and 12 templates are checked automatically during sync."); + + await getButton(findSetting(container, "Refresh note types from Anki")).click(); + + const semanticDropdown = getDropdown(findSetting(container, "Semantic QA note type")); + expect(semanticDropdown.options.map((option: { value: string }) => option.value)).not.toContain(QA_GROUP_MODEL_NAME); + }); + + it("blocks selecting the QA Group model inside semantic QA mapping controls", async () => { + const plugin = new FakePlugin(); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + const container = tab.containerEl as unknown as FakeContainerElInstance; + + tab.display(); + await getButton(findSetting(container, "Refresh note types from Anki")).click(); + await getDropdown(findSetting(container, "Semantic QA note type")).triggerChange(QA_GROUP_MODEL_NAME); + + expect(plugin.settings.semanticQaNoteType).toBe("Semantic QA"); + expect(container.textNodes).toContain(`${QA_GROUP_MODEL_NAME} is reserved for QA Group sync and cannot be selected for Semantic QA.`); + }); + it("loads fields, applies suggestions, and saves a user-adjusted basic mapping", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index f0e2232..b179814 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -5,6 +5,7 @@ import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/applica import type { FolderTreeNode } from "@/application/dto/FolderTreeNode"; import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService"; +import { buildQaGroupModelDefinition, QA_GROUP_MODEL_NAME } from "@/application/services/QaGroupModelDefinition"; import type { CardType } from "@/domain/card/entities/RenderedFields"; import { SemanticQaListParser } from "@/domain/manual-sync/services/SemanticQaListParser"; import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin"; @@ -113,6 +114,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { }); }); + this.renderQaGroupModelStatus(containerEl); + containerEl.createEl("h3", { text: "Semantic QA" }); new Setting(containerEl) @@ -181,18 +184,25 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { } private renderMappingSection(containerEl: HTMLElement, config: MappingSectionConfig): void { - const selectedModelName = this.getSelectedNoteType(config.cardType); + const storedModelName = this.getSelectedNoteType(config.cardType); + const selectedModelName = this.getSelectedMappingNoteType(config.cardType); const mappingKey = createNoteFieldMappingKey(config.cardType, selectedModelName); const currentMapping = this.getCurrentMapping(mappingKey); containerEl.createEl("h4", { text: config.title }); containerEl.createEl("p", { text: config.description }); + if (storedModelName !== selectedModelName) { + containerEl.createEl("p", { + text: `${QA_GROUP_MODEL_NAME} is reserved for QA Group sync and cannot be configured in ${config.title}. Choose another note type for these mapping controls.`, + }); + } + new Setting(containerEl) .setName(`${config.title} note type`) .setDesc("Loaded from Anki note types. Refresh if the latest models are not shown.") .addDropdown((dropdown) => { - for (const noteModel of this.getSelectableNoteModels(selectedModelName)) { + for (const noteModel of this.getSelectableNoteModels(config.cardType, selectedModelName)) { dropdown.addOption(noteModel, noteModel); } @@ -314,6 +324,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { } private async updateSelectedNoteType(cardType: CardType, modelName: string): Promise { + if (!this.isSelectableMappingNoteType(modelName)) { + this.sectionStatuses[cardType] = `${QA_GROUP_MODEL_NAME} is reserved for QA Group sync and cannot be selected for ${describeMappingSection(cardType)}.`; + this.display(); + return; + } + if (cardType === "basic") { await this.plugin.updateSettings({ qaNoteType: modelName }); } else if (cardType === "cloze") { @@ -330,7 +346,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { } private async loadFieldsFromAnki(cardType: CardType): Promise { - const modelName = this.getSelectedNoteType(cardType); + const modelName = this.getSelectedMappingNoteType(cardType); try { const modelDetails = await this.plugin.getNoteModelDetails(modelName); @@ -348,7 +364,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { } private async saveMapping(cardType: CardType): Promise { - const modelName = this.getSelectedNoteType(cardType); + const modelName = this.getSelectedMappingNoteType(cardType); const mappingKey = createNoteFieldMappingKey(cardType, modelName); const mapping = this.getCurrentMapping(mappingKey); @@ -382,10 +398,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { this.display(); } - private getSelectableNoteModels(selectedModelName: string): string[] { - const noteModels = this.availableNoteModels.length > 0 ? [...this.availableNoteModels] : []; + private getSelectableNoteModels(cardType: CardType, selectedModelName: string): string[] { + void cardType; + const noteModels = (this.availableNoteModels.length > 0 ? [...this.availableNoteModels] : []) + .filter((noteModel) => this.isSelectableMappingNoteType(noteModel)); - if (!noteModels.includes(selectedModelName)) { + if (this.isSelectableMappingNoteType(selectedModelName) && !noteModels.includes(selectedModelName)) { noteModels.unshift(selectedModelName); } @@ -435,6 +453,44 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { return this.plugin.settings.semanticQaNoteType; } + private getSelectedMappingNoteType(cardType: CardType): string { + const selectedModelName = this.getSelectedNoteType(cardType); + if (this.isSelectableMappingNoteType(selectedModelName)) { + return selectedModelName; + } + + return this.getFallbackMappingNoteType(cardType); + } + + private getFallbackMappingNoteType(cardType: CardType): string { + const preferredModelName = cardType === "basic" + ? "Basic" + : cardType === "cloze" + ? "Cloze" + : "Semantic QA"; + + const availableNoteModels = this.availableNoteModels.filter((noteModel) => this.isSelectableMappingNoteType(noteModel)); + if (availableNoteModels.includes(preferredModelName)) { + return preferredModelName; + } + + return availableNoteModels[0] ?? preferredModelName; + } + + private isSelectableMappingNoteType(modelName: string): boolean { + return modelName !== QA_GROUP_MODEL_NAME; + } + + private renderQaGroupModelStatus(containerEl: HTMLElement): void { + const definition = buildQaGroupModelDefinition(); + containerEl.createEl("p", { + text: `Managed note type: ${definition.modelName}. QA Group sync writes Stem / GroupId / Src / S01..S12 directly and does not use the field mapping panels below.`, + }); + containerEl.createEl("p", { + text: `Managed model contract: ${definition.fieldNames.length} fields and ${definition.templates.length} templates are checked automatically during sync.`, + }); + } + private renderSemanticQaPreview(containerEl: HTMLElement, marker: string): void { containerEl.createEl("h4", { text: "Semantic QA preview" }); containerEl.createEl("p", { text: `Trigger heading example: 城市更新 ${marker}` }); @@ -740,6 +796,18 @@ function inferBasicLikeCardType(sectionTitle: string): Extract