chore(qa-group): remove managed model remnants

中文: 删除托管 QA Group 12 死代码,统一保留用户自选笔记模板与字段映射路径。

English: Removes dead managed QA Group 12 code and keeps user-selected note templates and field mappings as the active path.
This commit is contained in:
Dusk 2026-04-27 22:03:52 +08:00
parent 98d64dac21
commit b77d4b6332
14 changed files with 170 additions and 880 deletions

View file

@ -0,0 +1,68 @@
# Remove Managed QA Group 12 Decisions
## Final files to delete
- `src/application/config/ManagedNoteModels.ts`
- `src/application/services/QaGroupModelDefinition.ts`
- `src/application/services/QaGroupModelService.ts`
- `src/application/services/QaGroupModelService.test.ts`
## Final gateway methods and types to remove
- Remove from `AnkiGroupGateway` and corresponding implementations/tests:
- `getModelTemplates`
- `getModelStyling`
- `createModel`
- `addModelField`
- `addModelTemplate`
- `updateModelTemplate`
- `updateModelStyling`
- Remove managed-model-only types:
- `AnkiModelTemplate`
- `CreateAnkiModelInput`
## Final AnkiConnectGateway cleanup
- Remove the AnkiConnect action wrappers that only serve the deleted managed model path:
- `modelTemplates`
- `modelStyling`
- `createModel`
- `modelFieldAdd`
- `modelTemplateAdd`
- `updateModelTemplates`
- `updateModelStyling`
## Current QA Group path preserved
- Preserve `QaGroupSyncService` as the active runtime path.
- Preserve `QaGroupFieldMappingService` as the active mapping/slot authority.
- Preserve user-selected QA Group note types via `settings.cardTypeConfigs["qa-group"].noteType`.
- Preserve saved mappings keyed by `qa-group:<modelName>`.
- Preserve GI marker, slot, deck, tag, backlink, and clear-current-file behavior unchanged.
## Test update strategy
- Delete managed model service tests.
- Remove AnkiConnectGateway tests that only cover deleted model-management methods.
- Update remaining tests to use neutral example model names such as `QA Group List`.
- Keep QA Group sync and QA Group field mapping tests as the regression guard for the real active path.
## Active wording cleanup strategy
- Ensure active source/tests/README/manifest do not keep the old managed QA Group 12 route.
- Historical docs are intentionally excluded from bulk rewrite.
## Intentionally retained references
- Old QA Group 12 references in historical docs remain for audit history.
- Generic model-reading methods still used by active settings and sync paths remain untouched:
- `listNoteModels`
- `getModelDetails`
- `getModelFieldNames`
- `getModelFieldNamesByModelNames`
- `updateNoteModel`
## Intentional deviations from the plan
- `manifest.json` and `README.md` do not need changes in this round because current repository reality already removed the old managed QA Group 12 wording from those active files.
- Internal QA Group sync semantics are intentionally unchanged; this round is dead-code removal only.

View file

@ -0,0 +1,79 @@
# Remove Managed QA Group 12 Gap Report
## Scope
This report records the real repository state on 2026-04-27 before deleting the remaining dead managed QA Group model path.
## Active QA Group runtime path
- Active QA Group sync runs through `src/application/services/QaGroupSyncService.ts`.
- The current model name is loaded from `settings.cardTypeConfigs["qa-group"].noteType`.
- The current field mapping is loaded from `settings.noteFieldMappings[createNoteFieldMappingKey("qa-group", modelName)]`.
- `src/application/services/QaGroupFieldMappingService.ts` remains the authority for:
- suggested QA Group field mappings
- saved slot derivation
- mapping validation
- user-selected QA Group model support
- `src/presentation/settings/PluginSettingTab.ts` already builds and saves QA Group mappings against the selected note type.
- There is no current runtime call from QA Group sync or settings into the managed-model service path.
## Files confirmed as dead production code
- `src/application/config/ManagedNoteModels.ts`
- only exports the old managed model constant
- no active production path requires it
- `src/application/services/QaGroupModelDefinition.ts`
- only builds the old managed model definition and templates
- only referenced by the dead managed-model service and its tests
- `src/application/services/QaGroupModelService.ts`
- no active production caller found
- only references the old model management gateway methods
## Gateway methods confirmed as managed-model-only
- In `src/application/ports/AnkiGateway.ts`, the following `AnkiGroupGateway` methods are only used by the dead managed model path:
- `getModelTemplates`
- `getModelStyling`
- `createModel`
- `addModelField`
- `addModelTemplate`
- `updateModelTemplate`
- `updateModelStyling`
- The following types are only used by those methods and related dead tests/fakes:
- `AnkiModelTemplate`
- `CreateAnkiModelInput`
- Search found no other active production call site for these methods outside:
- `QaGroupModelService.ts`
- `AnkiConnectGateway.ts`
- tests and test fakes
## Tests to delete or update
- Delete:
- `src/application/services/QaGroupModelService.test.ts`
- Update:
- `src/infrastructure/anki/AnkiConnectGateway.test.ts`
- remove model creation/template/styling wrapper tests
- `src/application/services/QaGroupFieldMappingService.test.ts`
- replace old example model name `ObsiAnki QA Group 12` with a neutral name
- `src/test-support/manualSyncFakes.ts`
- remove now-unused managed model gateway methods/types from the fake implementation
## References that may remain only in historical docs
- Historical docs under `docs/` still contain old QA Group 12 and managed model history.
- These are not active product surfaces and do not need bulk rewrite in this round.
## Risk points
- `AnkiConnectGateway.ts` and `AnkiGateway.ts` must be cleaned consistently so interface and implementation stay aligned.
- `src/test-support/manualSyncFakes.ts` must be updated with the same interface pruning or the test suite will break.
- The active QA Group path must keep:
- user-selected note type resolution
- saved field mappings
- GI marker behavior
- slot handling
- deck sync
- tag sync
- backlink behavior
- current-file cleanup behavior

40
main.js

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
export const QA_GROUP_MODEL_NAME = "ObsiAnki QA Group 12";

View file

@ -44,20 +44,6 @@ export interface DeckStat {
noteCount?: number;
}
export interface AnkiModelTemplate {
name: string;
front: string;
back: string;
}
export interface CreateAnkiModelInput {
modelName: string;
fieldNames: string[];
templates: AnkiModelTemplate[];
css: string;
isCloze?: boolean;
}
export interface AnkiNoteDetails extends AnkiNoteSummary {
fields: Record<string, string>;
}
@ -86,13 +72,6 @@ export interface AnkiGateway {
export interface AnkiGroupGateway extends AnkiGateway {
getModelFieldNames(modelName: string): Promise<string[]>;
getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>>;
getModelTemplates(modelName: string): Promise<Record<string, AnkiModelTemplate>>;
getModelStyling(modelName: string): Promise<string>;
createModel(input: CreateAnkiModelInput): Promise<void>;
addModelField(modelName: string, fieldName: string): Promise<void>;
addModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void>;
updateModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void>;
updateModelStyling(modelName: string, css: string): Promise<void>;
findNoteIds(query: string): Promise<number[]>;
getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]>;
}

View file

@ -68,7 +68,6 @@ describe("ManualSyncService", () => {
expect(result.created).toBe(1);
expect(result.rewrittenMarkers).toBe(1);
expect(ankiGateway.createdModels).toEqual([]);
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_USER_NOTE_TYPE);
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
: "Concepts",

View file

@ -62,7 +62,7 @@ describe("QaGroupFieldMappingService", () => {
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"]);
const mapping = service.suggest("QA Group List", ["Stem", "S01_Q", "S01_A", "S02_Q", "S02_A"]);
expect(mapping.titleField).toBe("Stem");
expect(mapping.slots).toEqual([

View file

@ -1,259 +0,0 @@
import type { AnkiModelTemplate, CreateAnkiModelInput } from "@/application/ports/AnkiGateway";
import {
DEFAULT_OBSIDIAN_BACKLINK_LABEL,
type ObsidianBacklinkPlacement,
normalizeObsidianBacklinkLabel,
} from "@/application/config/PluginSettings";
import { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels";
import type { GroupItem } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
import { renderObsidianBacklinkAnchor } from "@/domain/shared/renderObsidianBacklink";
export const QA_GROUP_SLOT_COUNT = 12;
interface QaGroupModelDefinitionOptions {
obsidianBacklinkLabel?: string;
obsidianBacklinkPlacement?: ObsidianBacklinkPlacement;
}
export { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels";
export function buildQaGroupModelDefinition(options: QaGroupModelDefinitionOptions = {}): CreateAnkiModelInput {
const backlinkLabel = normalizeObsidianBacklinkLabel(options.obsidianBacklinkLabel ?? DEFAULT_OBSIDIAN_BACKLINK_LABEL);
const backlinkPlacement = options.obsidianBacklinkPlacement ?? "answer-last-line";
return {
modelName: QA_GROUP_MODEL_NAME,
fieldNames: buildQaGroupFieldNames(),
templates: buildQaGroupTemplates(backlinkLabel, backlinkPlacement),
css: QA_GROUP_MODEL_CSS,
};
}
export function buildQaGroupFieldNames(): string[] {
const fieldNames = ["Stem", "GroupId", "Src"];
for (let slot = 1; slot <= QA_GROUP_SLOT_COUNT; slot += 1) {
const slotId = formatQaGroupSlot(slot);
fieldNames.push(`${slotId}_Id`, `${slotId}_Q`, `${slotId}_A`);
}
return fieldNames;
}
export function buildQaGroupTemplates(
backlinkLabel = DEFAULT_OBSIDIAN_BACKLINK_LABEL,
backlinkPlacement: ObsidianBacklinkPlacement = "answer-last-line",
): AnkiModelTemplate[] {
const templates: AnkiModelTemplate[] = [];
const backlinkAnchor = renderObsidianBacklinkAnchor({
href: "{{Src}}",
label: backlinkLabel,
escapeHref: false,
});
const backlinkLine = `{{#Src}}<p>${backlinkAnchor}</p>{{/Src}}`;
for (let slot = 1; slot <= QA_GROUP_SLOT_COUNT; slot += 1) {
const slotId = formatQaGroupSlot(slot);
templates.push({
name: `Q${slotId.slice(1)}`,
front: `{{#${slotId}_Q}}{{#${slotId}_A}}<div class="stem">{{Stem}}</div>\n<div class="q">{{${slotId}_Q}}</div>{{/${slotId}_A}}{{/${slotId}_Q}}`,
back: buildQaGroupBackTemplate(slotId, backlinkLine, backlinkPlacement),
});
}
return templates;
}
export function buildQaGroupNoteFields(stem: string, groupId: string, src: string, items: GroupItem[]): Record<string, string> {
const fields = Object.fromEntries(buildQaGroupFieldNames().map((fieldName) => [fieldName, ""]));
fields.Stem = stem;
fields.GroupId = groupId;
fields.Src = src;
for (const item of items) {
if (!item.itemId || !item.slot) {
continue;
}
const slotId = formatQaGroupSlot(item.slot);
fields[`${slotId}_Id`] = item.itemId;
fields[`${slotId}_Q`] = item.title;
fields[`${slotId}_A`] = item.answer;
}
return fields;
}
export function formatQaGroupSlot(slot: number): string {
return `S${String(slot).padStart(2, "0")}`;
}
function buildQaGroupBackTemplate(
slotId: string,
backlinkLine: string,
backlinkPlacement: ObsidianBacklinkPlacement,
): string {
if (backlinkPlacement === "question-last-line") {
return `{{FrontSide}}\n${backlinkLine}\n\n<hr id="answer">\n\n<div class="a">{{${slotId}_A}}</div>`;
}
if (backlinkPlacement === "answer-first-line") {
return `{{FrontSide}}\n\n<hr id="answer">\n\n${backlinkLine}\n<div class="a">{{${slotId}_A}}</div>`;
}
return `{{FrontSide}}\n\n<hr id="answer">\n\n<div class="a">{{${slotId}_A}}</div>\n${backlinkLine}`;
}
export const QA_GROUP_MODEL_CSS = [
".card {",
" font-family: arial;",
" font-size: 16px;",
" text-align: left;",
" color: black;",
" background-color: white;",
"}",
"",
".stem {",
" margin-bottom: 0.85em;",
"}",
"",
".q {",
" margin-bottom: 0.85em;",
"}",
"",
".a {",
" margin-top: 0.85em;",
"}",
"",
"em {",
" color: #111;",
" background-color: #69E147;",
" border-radius: 5px;",
" padding: 2px 5px;",
" font-style: normal;",
" -webkit-box-decoration-break: clone;",
" box-decoration-break: clone;",
"}",
"",
"strong {",
" color: #111;",
" background-color: #FFB347;",
" border-radius: 5px;",
" padding: 2px 5px;",
" font-weight: 450;",
" text-shadow: none;",
" -webkit-box-decoration-break: clone;",
" box-decoration-break: clone;",
"}",
"",
"mark {",
" border-radius: 5px;",
" padding: 2px 5px;",
"}",
"",
"blockquote {",
" background-color: #ECECEC;",
" padding: 2px 5px;",
" border: 2px solid #7F7F7F;",
" border-radius: 5px;",
"}",
"",
"pre,",
"code {",
" font-family: \"JetBrains Mono\", \"Courier New\", monospace;",
" color: #24292e;",
" text-shadow: none;",
" font-weight: normal;",
" -webkit-font-smoothing: antialiased;",
" -moz-osx-font-smoothing: grayscale;",
"}",
"",
"pre em,",
"code em,",
"pre strong,",
"code strong {",
" background: none;",
" color: inherit;",
" padding: 0;",
" font-style: inherit;",
" font-weight: inherit;",
" text-shadow: none;",
"}",
"",
".hljs-comment,",
".hljs-quote {",
" color: #6a737d;",
" font-style: italic;",
"}",
"",
".hljs-keyword,",
".hljs-selector-tag,",
".hljs-name,",
".hljs-tag {",
" color: #d73a49;",
"}",
"",
".hljs-attr {",
" color: #6f42c1;",
"}",
"",
".hljs-string {",
" color: #032f62;",
"}",
"",
".hljs-built_in,",
".hljs-title {",
" color: #005cc5;",
"}",
"",
".hljs-literal {",
" color: #e36209;",
"}",
"",
".hljs-number {",
" color: #005cc5;",
"}",
"",
".hljs-section,",
".hljs-selector-id {",
" color: #22863a;",
"}",
"",
".hljs-emphasis {",
" font-style: italic;",
"}",
"",
".hljs-strong {",
" font-weight: bold;",
"}",
"",
"a > code {",
" color: inherit;",
" text-decoration: none;",
"}",
"",
".ahs-ob-tag {",
" display: inline-block;",
" box-sizing: border-box;",
" max-width: 100%;",
" margin: 0 4px 3px 0;",
" padding: 1px 8px;",
" border: 1px solid rgba(124, 101, 255, 0.16);",
" border-radius: 999px;",
" background: rgba(124, 101, 255, 0.12);",
" color: #5b4fd6;",
" font-size: 0.88em;",
" font-weight: 500;",
" line-height: 1.55;",
" vertical-align: baseline;",
" white-space: nowrap;",
" text-decoration: none;",
"}",
"",
".nightMode .ahs-ob-tag,",
".card.nightMode .ahs-ob-tag {",
" background: rgba(150, 135, 255, 0.18);",
" color: #c9c1ff;",
" border-color: rgba(150, 135, 255, 0.28);",
"}",
].join("\n");

View file

@ -1,189 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { FakeManualSyncAnkiGateway } from "@/test-support/manualSyncFakes";
import { buildQaGroupModelDefinition } from "./QaGroupModelDefinition";
import { QaGroupModelService } from "./QaGroupModelService";
describe("QaGroupModelService", () => {
it("builds the default QA Group template with a clickable backlink at the answer end", () => {
const definition = buildQaGroupModelDefinition();
expect(definition.templates[0]?.back).toContain('<a class="anki-heading-sync-backlink" href="{{Src}}">Open in Obsidian</a>');
expect(definition.templates[0]?.back).toContain('<div class="a">{{S01_A}}</div>');
});
it("aligns the managed QA Group CSS with the configured QA note styling", () => {
const definition = buildQaGroupModelDefinition();
expect(definition.css).toContain("font-family: arial;");
expect(definition.css).toContain("font-size: 16px;");
expect(definition.css).toContain("em {\n color: #111;\n background-color: #69E147;");
expect(definition.css).toContain("strong {\n color: #111;\n background-color: #FFB347;");
expect(definition.css).not.toContain("font-size: 22px;");
expect(definition.css).not.toContain("font-weight: 700;");
});
it("escapes a custom QA Group backlink label", () => {
const definition = buildQaGroupModelDefinition({
obsidianBacklinkLabel: 'Open <Obsidian> & "Now"',
});
expect(definition.templates[0]?.back).toContain('Open &lt;Obsidian&gt; &amp; &quot;Now&quot;');
expect(definition.templates[0]?.back).not.toContain('Open <Obsidian> & "Now"');
});
it("builds three different QA Group backlink placements", () => {
const questionPlacement = buildQaGroupModelDefinition({
obsidianBacklinkPlacement: "question-last-line",
});
const answerFirstPlacement = buildQaGroupModelDefinition({
obsidianBacklinkPlacement: "answer-first-line",
});
const answerLastPlacement = buildQaGroupModelDefinition({
obsidianBacklinkPlacement: "answer-last-line",
});
expect(questionPlacement.templates[0]?.back).toContain('{{FrontSide}}\n{{#Src}}<p><a class="anki-heading-sync-backlink" href="{{Src}}">Open in Obsidian</a></p>{{/Src}}\n\n<hr id="answer">');
expect(answerFirstPlacement.templates[0]?.back).toContain('<hr id="answer">\n\n{{#Src}}<p><a class="anki-heading-sync-backlink" href="{{Src}}">Open in Obsidian</a></p>{{/Src}}\n<div class="a">{{S01_A}}</div>');
expect(answerLastPlacement.templates[0]?.back).toContain('<div class="a">{{S01_A}}</div>\n{{#Src}}<p><a class="anki-heading-sync-backlink" href="{{Src}}">Open in Obsidian</a></p>{{/Src}}');
});
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 templates when the configured backlink label or placement drifts", async () => {
const ankiGateway = createExistingQaGroupGateway();
const service = new QaGroupModelService(ankiGateway);
const definition = buildQaGroupModelDefinition({
obsidianBacklinkLabel: "Open note",
obsidianBacklinkPlacement: "question-last-line",
});
await service.ensureModel({
obsidianBacklinkLabel: "Open note",
obsidianBacklinkPlacement: "question-last-line",
});
expect(ankiGateway.updatedModelTemplates).toEqual(
definition.templates.map((template) => ({
modelName: definition.modelName,
template,
})),
);
});
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)],
};
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

@ -1,108 +0,0 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import type { AnkiGroupGateway } from "@/application/ports/AnkiGateway";
import { buildQaGroupModelDefinition } from "./QaGroupModelDefinition";
export class QaGroupModelService {
constructor(private readonly ankiGateway: AnkiGroupGateway) {}
async ensureModel(settings?: Pick<PluginSettings, "obsidianBacklinkLabel" | "obsidianBacklinkPlacement">): Promise<void> {
const definition = buildQaGroupModelDefinition(settings);
const modelNames = await this.runModelAction(
"listNoteModels",
definition.modelName,
undefined,
() => this.ankiGateway.listNoteModels(),
);
if (!modelNames.includes(definition.modelName)) {
await this.runModelAction(
"createModel",
definition.modelName,
`fields=${definition.fieldNames.length}, templates=${definition.templates.length}`,
() => this.ankiGateway.createModel(definition),
);
return;
}
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.runModelAction(
"addModelField",
definition.modelName,
`field=${fieldName}`,
() => this.ankiGateway.addModelField(definition.modelName, fieldName),
);
}
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.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.runModelAction(
"updateModelTemplate",
definition.modelName,
`template=${template.name}`,
() => this.ankiGateway.updateModelTemplate(definition.modelName, template),
);
}
}
const existingCss = await this.runModelAction(
"getModelStyling",
definition.modelName,
undefined,
() => this.ankiGateway.getModelStyling(definition.modelName),
);
if (existingCss !== 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

@ -26,7 +26,6 @@ describe("QaGroupSyncService", () => {
const result = await service.sync([createIndexedGroupBlock()], createEmptyPluginState(), createModule3Settings());
expect(result.created).toBe(1);
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");
@ -210,7 +209,6 @@ describe("QaGroupSyncService", () => {
obsidianBacklinkPlacement: "answer-first-line",
}));
expect(ankiGateway.createdModels).toEqual([]);
expect(ankiGateway.addedNotes[0]?.fields.01).toContain('Open &lt;Vault&gt;');
expect(ankiGateway.addedNotes[0]?.fields.01).toMatch(/^<p><a class="anki-heading-sync-backlink"/);
expect(ankiGateway.addedNotes[0]?.fields).not.toHaveProperty("Src");

View file

@ -141,150 +141,6 @@ 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

@ -1,7 +1,7 @@
import { requestUrl } from "obsidian";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, SyncAnkiNoteTagsInput, UpdateAnkiNoteInput, UpdateAnkiNoteModelInput } from "@/application/ports/AnkiGateway";
import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, DeckStat, SyncAnkiNoteTagsInput, UpdateAnkiNoteInput, UpdateAnkiNoteModelInput } from "@/application/ports/AnkiGateway";
import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
interface AnkiResponse<T> {
@ -33,21 +33,6 @@ interface RawDeckStats {
total_in_deck?: number;
}
type ModelTemplates = Record<string, { Front?: string; Back?: string }>;
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) {}
@ -102,69 +87,6 @@ export class AnkiConnectGateway implements AnkiGroupGateway {
}, {});
}
async getModelTemplates(modelName: string): Promise<Record<string, AnkiModelTemplate>> {
const templates = await this.invoke<ModelTemplates>("modelTemplates", { modelName });
return Object.fromEntries(Object.entries(templates).map(([templateName, template]) => [templateName, {
name: templateName,
front: template.Front ?? "",
back: template.Back ?? "",
}]));
}
async getModelStyling(modelName: string): Promise<string> {
const styling = await this.invoke<ModelStylingResponse | string>("modelStyling", { modelName });
if (typeof styling === "string") {
return styling;
}
return styling.css ?? "";
}
async createModel(input: CreateAnkiModelInput): Promise<void> {
await this.invoke("createModel", {
modelName: input.modelName,
inOrderFields: input.fieldNames,
css: input.css,
isCloze: Boolean(input.isCloze),
cardTemplates: input.templates.map((template) => ({
Name: template.name,
Front: template.front,
Back: template.back,
})),
});
}
async addModelField(modelName: string, fieldName: string): Promise<void> {
await this.invoke("modelFieldAdd", {
modelName,
fieldName,
});
}
async addModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void> {
await this.invoke("modelTemplateAdd", {
modelName,
templateName: template.name,
Front: template.front,
Back: template.back,
});
}
async updateModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void> {
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", buildModelUpdateParams(modelName, { css }));
}
async findNoteIds(query: string): Promise<number[]> {
return this.invoke<number[]>("findNotes", { query });
}

View file

@ -1,7 +1,7 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings";
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, SyncAnkiNoteTagsInput, UpdateAnkiNoteInput, UpdateAnkiNoteModelInput } from "@/application/ports/AnkiGateway";
import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, DeckStat, SyncAnkiNoteTagsInput, UpdateAnkiNoteInput, UpdateAnkiNoteModelInput } from "@/application/ports/AnkiGateway";
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway";
@ -151,11 +151,6 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
public noteDetailsById = new Map<number, AnkiNoteDetails>();
public deckStatsByName = new Map<string, DeckStat>();
public listedDeckNames: string[] | null = null;
public createdModels: CreateAnkiModelInput[] = [];
public addedModelFields: Array<{ modelName: string; fieldName: string }> = [];
public addedModelTemplates: Array<{ modelName: string; template: AnkiModelTemplate }> = [];
public updatedModelTemplates: Array<{ modelName: string; template: AnkiModelTemplate }> = [];
public updatedModelStyling: Array<{ modelName: string; css: string }> = [];
public foundNoteIds = new Map<string, number[]>();
public modelDetailsByName: Record<string, NoteModelDetails> = {
Basic: { fieldNames: ["Front", "Back"] },
@ -164,8 +159,6 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
fieldNames: ["题目", "问题01", "答案01", "问题02", "答案02", "问题03", "答案03"],
},
};
public modelTemplatesByName: Record<string, Record<string, AnkiModelTemplate>> = {};
public modelStylingByName: Record<string, string> = {};
public updateNoteModelError: Error | null = null;
private nextNoteId = 9000;
@ -201,53 +194,6 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
])));
}
async getModelTemplates(modelName: string): Promise<Record<string, AnkiModelTemplate>> {
return this.modelTemplatesByName[modelName] ?? {};
}
async getModelStyling(modelName: string): Promise<string> {
return this.modelStylingByName[modelName] ?? "";
}
async createModel(input: CreateAnkiModelInput): Promise<void> {
this.createdModels.push(input);
this.modelDetailsByName[input.modelName] = {
fieldNames: [...input.fieldNames],
};
this.modelTemplatesByName[input.modelName] = Object.fromEntries(input.templates.map((template) => [template.name, { ...template }]));
this.modelStylingByName[input.modelName] = input.css;
}
async addModelField(modelName: string, fieldName: string): Promise<void> {
this.addedModelFields.push({ modelName, fieldName });
const existing = this.modelDetailsByName[modelName] ?? { fieldNames: [] };
if (!existing.fieldNames.includes(fieldName)) {
existing.fieldNames.push(fieldName);
}
this.modelDetailsByName[modelName] = existing;
}
async addModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void> {
this.addedModelTemplates.push({ modelName, template });
this.modelTemplatesByName[modelName] = {
...(this.modelTemplatesByName[modelName] ?? {}),
[template.name]: { ...template },
};
}
async updateModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void> {
this.updatedModelTemplates.push({ modelName, template });
this.modelTemplatesByName[modelName] = {
...(this.modelTemplatesByName[modelName] ?? {}),
[template.name]: { ...template },
};
}
async updateModelStyling(modelName: string, css: string): Promise<void> {
this.updatedModelStyling.push({ modelName, css });
this.modelStylingByName[modelName] = css;
}
async findNoteIds(query: string): Promise<number[]> {
return [...(this.foundNoteIds.get(query) ?? [])];
}