mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
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:
parent
4845c721d5
commit
bbe1eece12
8 changed files with 634 additions and 24 deletions
82
docs/qa-group-model-fix-decisions.md
Normal file
82
docs/qa-group-model-fix-decisions.md
Normal 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 更适合状态展示,而不是可配置字段映射
|
||||
92
docs/qa-group-model-fix-gap-report.md
Normal file
92
docs/qa-group-model-fix-gap-report.md
Normal 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 行为重构
|
||||
126
src/application/services/QaGroupModelService.test.ts
Normal file
126
src/application/services/QaGroupModelService.test.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 文件";
|
||||
|
|
|
|||
Loading…
Reference in a new issue