QA Group 模型同步修复 / Fix QA Group model sync

- 修复 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
This commit is contained in:
Dusk 2026-04-20 15:20:20 +08:00
parent 4845c721d5
commit bbe1eece12
8 changed files with 634 additions and 24 deletions

View file

@ -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 更适合状态展示,而不是可配置字段映射

View file

@ -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 行为重构

View file

@ -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<typeof buildQaGroupModelDefinition>["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;
}

View file

@ -7,38 +7,101 @@ export class QaGroupModelService {
async ensureModel(): Promise<void> {
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<T>(
action: string,
modelName: string,
summary: string | undefined,
operation: () => Promise<T>,
): Promise<T> {
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}`);
}
}
}

View file

@ -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({

View file

@ -33,6 +33,15 @@ interface ModelStylingResponse {
css?: string;
}
function buildModelUpdateParams(modelName: string, updates: Record<string, unknown>): Record<string, unknown> {
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<void> {
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<void> {
await this.invoke("updateModelStyling", {
model: modelName,
css,
});
await this.invoke("updateModelStyling", buildModelUpdateParams(modelName, { css }));
}
async findNoteIds(query: string): Promise<number[]> {

View file

@ -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<string[]> {
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);

View file

@ -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<void> {
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<void> {
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<void> {
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<CardType, "basic"
return sectionTitle === "Semantic QA" ? "semantic-qa" : "basic";
}
function describeMappingSection(cardType: CardType): string {
if (cardType === "basic") {
return "QA / Basic";
}
if (cardType === "cloze") {
return "Cloze";
}
return "Semantic QA";
}
function getScopeModeSummary(scopeMode: ScopeMode): string {
if (scopeMode === "include") {
return "仅处理下方勾选文件夹中的 Markdown 文件";