mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 17:10:28 +00:00
fix(qa-group): harden sync and backlink behavior
中文: 修复 QA Group 同步链路中的多处稳定性问题,包括可点击回链、丢失 noteId 时自动重建、避免在未指定目标 deck 时误调用 changeDeck,以及统一设置页 note type 下拉行为。 English: Fix multiple QA Group stability issues, including clickable backlinks, self-healing recreation when stored noteIds are missing, skipping changeDeck when no target deck is provided, and unifying note type dropdown behavior in settings.
This commit is contained in:
parent
bbe1eece12
commit
472011c32f
10 changed files with 195 additions and 87 deletions
|
|
@ -72,6 +72,7 @@ describe("ManualSyncService", () => {
|
|||
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_MODEL_NAME);
|
||||
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
|
||||
Stem: "Concepts",
|
||||
Src: "obsidian://open?vault=Vault&file=notes/example.md#Concepts #anki-list",
|
||||
S01_Q: "Alpha",
|
||||
S01_A: "First answer",
|
||||
S02_Q: "Beta",
|
||||
|
|
@ -609,4 +610,4 @@ function createStoredSyncedCard(settings: PluginSettings, overrides: Partial<Car
|
|||
lastSyncedAt: overrides.lastSyncedAt ?? 1,
|
||||
orphan: overrides.orphan ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,16 @@ export class ManualSyncService {
|
|||
renderConfigServiceOrNow: RenderConfigService | (() => number) = new RenderConfigService(),
|
||||
now: () => number = () => Date.now(),
|
||||
) {
|
||||
this.qaGroupSyncService = new QaGroupSyncService(ankiGateway as AnkiGroupGateway);
|
||||
this.qaGroupSyncService = new QaGroupSyncService(
|
||||
ankiGateway as AnkiGroupGateway,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
this.vaultGateway.createBacklink.bind(this.vaultGateway),
|
||||
);
|
||||
this.groupMarkerService = new GroupMarkerService();
|
||||
|
||||
if (isRenderConfigService(renderConfigServiceOrNow)) {
|
||||
|
|
@ -486,4 +495,4 @@ function hasLegacyInnerIdMarkers(block: IndexedGroupCardBlock): boolean {
|
|||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function buildQaGroupTemplates(): AnkiModelTemplate[] {
|
|||
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: `{{FrontSide}}\n\n<hr id="answer">\n\n<div class="a">{{${slotId}_A}}</div>\n<div class="meta">{{Src}}</div>`,
|
||||
back: `{{FrontSide}}\n\n<hr id="answer">\n\n<div class="a">{{${slotId}_A}}</div>\n{{#Src}}<p><a class="anki-heading-sync-backlink" href="{{Src}}">Open in Obsidian</a></p>{{/Src}}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -94,4 +94,4 @@ export const QA_GROUP_MODEL_CSS = [
|
|||
" font-size: 0.75em;",
|
||||
" opacity: 0.72;",
|
||||
"}",
|
||||
].join("\n");
|
||||
].join("\n");
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ import { buildQaGroupModelDefinition } from "./QaGroupModelDefinition";
|
|||
import { QaGroupModelService } from "./QaGroupModelService";
|
||||
|
||||
describe("QaGroupModelService", () => {
|
||||
it("builds QA Group templates with a clickable Obsidian backlink", () => {
|
||||
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).not.toContain('<div class="meta">{{Src}}</div>');
|
||||
});
|
||||
|
||||
it("creates the QA Group model when it is missing", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const service = new QaGroupModelService(ankiGateway);
|
||||
|
|
@ -123,4 +130,4 @@ function createExistingQaGroupGateway(overrides: {
|
|||
);
|
||||
ankiGateway.modelStylingByName[definition.modelName] = overrides.css ?? definition.css;
|
||||
return ankiGateway;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@ import { describe, expect, it } from "vitest";
|
|||
import { buildGroupSrc, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
import { createEmptyPluginState, type GroupBlockState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { QA_GROUP_MODEL_NAME, buildQaGroupNoteFields } from "@/application/services/QaGroupModelDefinition";
|
||||
import { createModule3Settings, FakeManualSyncAnkiGateway } from "@/test-support/manualSyncFakes";
|
||||
import { createModule3Settings, FakeManualSyncAnkiGateway, FakeManualSyncVaultGateway } from "@/test-support/manualSyncFakes";
|
||||
|
||||
import { QaGroupSyncService } from "./QaGroupSyncService";
|
||||
|
||||
describe("QaGroupSyncService", () => {
|
||||
it("creates one QA Group note and assigns fresh item ids and slots", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const vaultGateway = new FakeManualSyncVaultGateway();
|
||||
const itemIds = ["item-1", "item-2"];
|
||||
const service = new QaGroupSyncService(
|
||||
ankiGateway,
|
||||
|
|
@ -19,6 +20,7 @@ describe("QaGroupSyncService", () => {
|
|||
() => 1234,
|
||||
() => "group-1",
|
||||
() => itemIds.shift() ?? "item-x",
|
||||
vaultGateway.createBacklink.bind(vaultGateway),
|
||||
);
|
||||
|
||||
const result = await service.sync([createIndexedGroupBlock()], createEmptyPluginState(), createModule3Settings());
|
||||
|
|
@ -28,7 +30,7 @@ describe("QaGroupSyncService", () => {
|
|||
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
|
||||
Stem: "Concepts",
|
||||
GroupId: "group-1",
|
||||
Src: buildGroupSrc("notes/example.md", "Concepts #anki-list"),
|
||||
Src: "obsidian://open?vault=Vault&file=notes/example.md#Concepts #anki-list",
|
||||
S01_Q: "Alpha",
|
||||
S01_A: "First answer",
|
||||
S02_Q: "Beta",
|
||||
|
|
@ -43,6 +45,7 @@ describe("QaGroupSyncService", () => {
|
|||
|
||||
it("preserves slots across reorder and reuses a free slot for a new item", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const vaultGateway = new FakeManualSyncVaultGateway();
|
||||
const existingState = createStoredGroupBlockState();
|
||||
ankiGateway.noteDetailsById.set(42, {
|
||||
noteId: 42,
|
||||
|
|
@ -59,6 +62,7 @@ describe("QaGroupSyncService", () => {
|
|||
() => 1234,
|
||||
() => "group-x",
|
||||
() => "item-c",
|
||||
vaultGateway.createBacklink.bind(vaultGateway),
|
||||
);
|
||||
|
||||
const result = await service.sync([
|
||||
|
|
@ -91,6 +95,52 @@ describe("QaGroupSyncService", () => {
|
|||
"item-b": 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("recreates a missing QA Group note instead of failing when the stored noteId no longer exists", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const vaultGateway = new FakeManualSyncVaultGateway();
|
||||
const existingState = createStoredGroupBlockState();
|
||||
const service = new QaGroupSyncService(
|
||||
ankiGateway,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => 1234,
|
||||
() => "group-x",
|
||||
() => "item-new",
|
||||
vaultGateway.createBacklink.bind(vaultGateway),
|
||||
);
|
||||
|
||||
const result = await service.sync([
|
||||
createIndexedGroupBlock({
|
||||
noteId: 42,
|
||||
groupId: "group-1",
|
||||
rawBlockHash: "hash-updated",
|
||||
items: [
|
||||
{ title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 },
|
||||
{ title: "Beta", answer: "Second answer", ordinalInMarkdown: 2 },
|
||||
{ title: "Gamma", answer: "Third answer", ordinalInMarkdown: 3 },
|
||||
],
|
||||
}),
|
||||
], {
|
||||
...createEmptyPluginState(),
|
||||
groupBlocks: {
|
||||
"group-1": existingState,
|
||||
},
|
||||
}, createModule3Settings());
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
expect(result.updated).toBe(0);
|
||||
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_MODEL_NAME);
|
||||
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
|
||||
GroupId: "group-1",
|
||||
S01_Q: "Alpha",
|
||||
S02_Q: "Gamma",
|
||||
S03_Q: "Beta",
|
||||
});
|
||||
expect(result.syncedGroupBlocks[0]?.groupId).toBe("group-1");
|
||||
expect(result.syncedGroupBlocks[0]?.noteId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
function createIndexedGroupBlock(overrides: Partial<IndexedGroupCardBlock> = {}): IndexedGroupCardBlock {
|
||||
|
|
@ -163,4 +213,4 @@ function createStoredGroupBlockState(): GroupBlockState {
|
|||
lastSyncedAt: 100,
|
||||
orphan: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { PluginSettings } from "@/application/config/PluginSettings";
|
||||
import type { AnkiGroupGateway, AnkiNoteDetails } from "@/application/ports/AnkiGateway";
|
||||
import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation";
|
||||
import { buildGroupSrc, type GroupItem, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
|
||||
import type { GroupBlockState, PluginState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { GroupMarkerService, type GroupMarkerWriteRequest } from "@/domain/manual-sync/services/GroupMarkerService";
|
||||
|
|
@ -50,6 +51,7 @@ export class QaGroupSyncService {
|
|||
this.sequence += 1;
|
||||
return `i_${hashString(`${Date.now()}_${this.sequence}_${Math.random()}`).slice(0, 10)}`;
|
||||
},
|
||||
private readonly createBacklink?: (location: SourceLocation) => string,
|
||||
) {}
|
||||
|
||||
async sync(blocks: IndexedGroupCardBlock[], state: PluginState, settings: PluginSettings): Promise<QaGroupSyncExecutionResult> {
|
||||
|
|
@ -108,7 +110,7 @@ export class QaGroupSyncService {
|
|||
ensuredDecks.add(deck);
|
||||
}
|
||||
|
||||
const fields = buildQaGroupNoteFields(block.stem, groupId, block.src, resolvedItems);
|
||||
const fields = buildQaGroupNoteFields(block.stem, groupId, this.buildGroupBacklink(block, settings), resolvedItems);
|
||||
let noteId = recovered.noteId ?? block.noteId;
|
||||
let existingNote = recovered.noteDetails;
|
||||
|
||||
|
|
@ -131,12 +133,23 @@ export class QaGroupSyncService {
|
|||
let deckChanged = recovered.stateRecord ? recovered.stateRecord.deck !== deck : false;
|
||||
|
||||
if (!stateUnchanged) {
|
||||
existingNote ??= await this.loadQaGroupNote(noteId, block);
|
||||
fieldsChanged = !haveEqualFields(existingNote.fields, fields);
|
||||
deckChanged = !(existingNote.deckNames ?? []).includes(deck);
|
||||
existingNote ??= await this.tryLoadQaGroupNote(noteId, block);
|
||||
if (!existingNote) {
|
||||
noteId = await this.ankiGateway.addNote({
|
||||
deckName: deck,
|
||||
modelName: QA_GROUP_MODEL_NAME,
|
||||
fields,
|
||||
tags: [],
|
||||
});
|
||||
created += 1;
|
||||
touchedSyncKeys.add(block.syncKey);
|
||||
} else {
|
||||
fieldsChanged = !haveEqualFields(existingNote.fields, fields);
|
||||
deckChanged = !(existingNote.deckNames ?? []).includes(deck);
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldsChanged || deckChanged) {
|
||||
if (existingNote && (fieldsChanged || deckChanged)) {
|
||||
await this.ankiGateway.updateNote({
|
||||
noteId,
|
||||
fields,
|
||||
|
|
@ -243,7 +256,10 @@ export class QaGroupSyncService {
|
|||
}
|
||||
|
||||
if (block.noteId !== undefined) {
|
||||
return noteDetailsToRecoveredGroup(await this.loadQaGroupNote(block.noteId, block), block.groupId);
|
||||
const note = await this.tryLoadQaGroupNote(block.noteId, block);
|
||||
if (note) {
|
||||
return noteDetailsToRecoveredGroup(note, block.groupId);
|
||||
}
|
||||
}
|
||||
|
||||
const recoveredBySrc = await this.findSingleQaGroupNote(`note:${quoteAnkiValue(QA_GROUP_MODEL_NAME)} Src:${quoteAnkiValue(block.src)}`, block);
|
||||
|
|
@ -257,23 +273,23 @@ export class QaGroupSyncService {
|
|||
};
|
||||
}
|
||||
|
||||
private async findSingleQaGroupNote(query: string, block: IndexedGroupCardBlock): Promise<AnkiNoteDetails | null> {
|
||||
private async findSingleQaGroupNote(query: string, block: IndexedGroupCardBlock): Promise<AnkiNoteDetails | undefined> {
|
||||
const noteIds = await this.ankiGateway.findNoteIds(query);
|
||||
if (noteIds.length === 0) {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (noteIds.length > 1) {
|
||||
throw new Error(`QA Group recovery is ambiguous at ${block.filePath}:${block.blockStartLine}. Query: ${query}`);
|
||||
}
|
||||
|
||||
return this.loadQaGroupNote(noteIds[0], block);
|
||||
return this.tryLoadQaGroupNote(noteIds[0], block);
|
||||
}
|
||||
|
||||
private async loadQaGroupNote(noteId: number, block: IndexedGroupCardBlock): Promise<AnkiNoteDetails> {
|
||||
private async tryLoadQaGroupNote(noteId: number, block: IndexedGroupCardBlock): Promise<AnkiNoteDetails | undefined> {
|
||||
const note = (await this.ankiGateway.getNoteDetails([noteId]))[0];
|
||||
if (!note) {
|
||||
throw new Error(`Unable to load QA Group note ${noteId} for ${block.filePath}:${block.blockStartLine}.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (note.modelName !== QA_GROUP_MODEL_NAME) {
|
||||
|
|
@ -282,6 +298,25 @@ export class QaGroupSyncService {
|
|||
|
||||
return note;
|
||||
}
|
||||
|
||||
private buildGroupBacklink(block: IndexedGroupCardBlock, settings: PluginSettings): string {
|
||||
if (!settings.addObsidianBacklink || !this.createBacklink) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return this.createBacklink({
|
||||
filePath: block.filePath,
|
||||
sourceContent: block.sourceContent,
|
||||
headingLine: block.blockStartLine,
|
||||
blockStartLine: block.blockStartLine,
|
||||
bodyStartLine: block.bodyStartLine,
|
||||
blockEndLine: block.blockEndLine,
|
||||
contentEndLine: block.contentEndLine,
|
||||
markerLine: block.markerLine,
|
||||
headingLevel: block.headingLevel,
|
||||
headingText: block.backlinkHeadingText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildGroupStateIndex(groupBlocks: Record<string, GroupBlockState>): GroupStateIndex {
|
||||
|
|
@ -480,4 +515,4 @@ function hasLegacyInnerIdMarkers(block: IndexedGroupCardBlock): boolean {
|
|||
|
||||
function quoteAnkiValue(value: string): string {
|
||||
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -669,6 +669,34 @@ describe("AnkiConnectGateway", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("updates note fields without calling changeDeck when no target deck is provided", async () => {
|
||||
requestUrlMock
|
||||
.mockResolvedValueOnce({
|
||||
json: {
|
||||
error: null,
|
||||
result: null,
|
||||
},
|
||||
});
|
||||
|
||||
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
|
||||
await gateway.updateNote({
|
||||
noteId: 10,
|
||||
fields: { Front: "Prompt", Back: "Answer" },
|
||||
});
|
||||
|
||||
expect(requestUrlMock).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
action: "updateNoteFields",
|
||||
version: 6,
|
||||
params: {
|
||||
note: {
|
||||
id: 10,
|
||||
fields: { Front: "Prompt", Back: "Answer" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes notes and empty decks through direct actions", async () => {
|
||||
requestUrlMock
|
||||
.mockResolvedValueOnce({
|
||||
|
|
@ -704,4 +732,4 @@ describe("AnkiConnectGateway", () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -276,6 +276,10 @@ export class AnkiConnectGateway implements AnkiGroupGateway {
|
|||
},
|
||||
});
|
||||
|
||||
if (!input.deckName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteInfo = await this.invoke<NoteInfo[]>("notesInfo", {
|
||||
notes: [input.noteId],
|
||||
});
|
||||
|
|
@ -390,4 +394,4 @@ function extractDeckNoteCount(rawStats: unknown, deckId: number | undefined): nu
|
|||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -472,27 +472,47 @@ describe("AnkiHeadingSyncSettingTab", () => {
|
|||
tab.display();
|
||||
await getButton(findSetting(container, "Refresh note types from Anki")).click();
|
||||
|
||||
const refreshSetting = findSetting(container, "Refresh note types from Anki");
|
||||
const dropdown = getDropdown(findSetting(container, "QA / Basic note type"));
|
||||
expect(dropdown.options.map((option: { value: string }) => option.value)).toEqual(["Basic", "Cloze", "Custom Basic", "Custom Cloze", "Semantic QA"]);
|
||||
expect(dropdown.options.map((option: { value: string }) => option.value)).toEqual(["Basic", "Cloze", "Custom Basic", "Custom Cloze", QA_GROUP_MODEL_NAME, "Semantic QA"]);
|
||||
expect(refreshSetting.desc).toBe("Loaded 6 note types from Anki.");
|
||||
});
|
||||
|
||||
it("shows QA Group model status and keeps it out of the field mapping dropdowns", async () => {
|
||||
it("uses the same total note type count for status text and dropdown options", 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();
|
||||
|
||||
const refreshSetting = findSetting(container, "Refresh note types from Anki");
|
||||
const basicDropdown = getDropdown(findSetting(container, "QA / Basic note type"));
|
||||
const clozeDropdown = getDropdown(findSetting(container, "Cloze note type"));
|
||||
const semanticDropdown = getDropdown(findSetting(container, "Semantic QA note type"));
|
||||
|
||||
expect(refreshSetting.desc).toBe(`Loaded ${basicDropdown.options.length} note types from Anki.`);
|
||||
expect(clozeDropdown.options).toHaveLength(basicDropdown.options.length);
|
||||
expect(semanticDropdown.options).toHaveLength(basicDropdown.options.length);
|
||||
});
|
||||
|
||||
it("shows QA Group model status and includes it in 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 note type: ${QA_GROUP_MODEL_NAME}. QA Group sync writes Stem / GroupId / Src / S01..S12 directly. The same note type can also appear in the field mapping panels below if you want to reuse it for other routes.`);
|
||||
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);
|
||||
expect(semanticDropdown.options.map((option: { value: string }) => option.value)).toContain(QA_GROUP_MODEL_NAME);
|
||||
});
|
||||
|
||||
it("blocks selecting the QA Group model inside semantic QA mapping controls", async () => {
|
||||
it("allows 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;
|
||||
|
|
@ -501,8 +521,8 @@ describe("AnkiHeadingSyncSettingTab", () => {
|
|||
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.`);
|
||||
expect(plugin.settings.semanticQaNoteType).toBe(QA_GROUP_MODEL_NAME);
|
||||
expect(container.textNodes).toContain("Selected ObsiAnki QA Group 12. Read fields from Anki to create or refresh its mapping.");
|
||||
});
|
||||
|
||||
it("loads fields, applies suggestions, and saves a user-adjusted basic mapping", async () => {
|
||||
|
|
@ -862,4 +882,4 @@ describe("AnkiHeadingSyncSettingTab", () => {
|
|||
|
||||
expect(plugin.insertDeckTemplateCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +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 { buildQaGroupModelDefinition } 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";
|
||||
|
|
@ -184,7 +184,6 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
private renderMappingSection(containerEl: HTMLElement, config: MappingSectionConfig): void {
|
||||
const storedModelName = this.getSelectedNoteType(config.cardType);
|
||||
const selectedModelName = this.getSelectedMappingNoteType(config.cardType);
|
||||
const mappingKey = createNoteFieldMappingKey(config.cardType, selectedModelName);
|
||||
const currentMapping = this.getCurrentMapping(mappingKey);
|
||||
|
|
@ -192,12 +191,6 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
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.")
|
||||
|
|
@ -324,12 +317,6 @@ 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") {
|
||||
|
|
@ -400,16 +387,19 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
private getSelectableNoteModels(cardType: CardType, selectedModelName: string): string[] {
|
||||
void cardType;
|
||||
const noteModels = (this.availableNoteModels.length > 0 ? [...this.availableNoteModels] : [])
|
||||
.filter((noteModel) => this.isSelectableMappingNoteType(noteModel));
|
||||
const noteModels = this.getSelectableMappingNoteModels();
|
||||
|
||||
if (this.isSelectableMappingNoteType(selectedModelName) && !noteModels.includes(selectedModelName)) {
|
||||
if (!noteModels.includes(selectedModelName)) {
|
||||
noteModels.unshift(selectedModelName);
|
||||
}
|
||||
|
||||
return noteModels;
|
||||
}
|
||||
|
||||
private getSelectableMappingNoteModels(): string[] {
|
||||
return this.availableNoteModels.length > 0 ? [...this.availableNoteModels] : [];
|
||||
}
|
||||
|
||||
private updateDraftMapping(
|
||||
cardType: CardType,
|
||||
modelName: string,
|
||||
|
|
@ -454,37 +444,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
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;
|
||||
return this.getSelectedNoteType(cardType);
|
||||
}
|
||||
|
||||
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.`,
|
||||
text: `Managed note type: ${definition.modelName}. QA Group sync writes Stem / GroupId / Src / S01..S12 directly. The same note type can also appear in the field mapping panels below if you want to reuse it for other routes.`,
|
||||
});
|
||||
containerEl.createEl("p", {
|
||||
text: `Managed model contract: ${definition.fieldNames.length} fields and ${definition.templates.length} templates are checked automatically during sync.`,
|
||||
|
|
@ -796,18 +762,6 @@ 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 文件";
|
||||
|
|
@ -826,4 +780,4 @@ function getScopeModeTreeDescription(scopeMode: ScopeMode): string {
|
|||
}
|
||||
|
||||
return "排除指定文件夹:处理整个 vault,但跳过下方勾选文件夹中的 Markdown 文件";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue