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:
Dusk 2026-04-20 22:14:59 +08:00
parent bbe1eece12
commit 472011c32f
10 changed files with 195 additions and 87 deletions

View file

@ -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,
};
}
}

View file

@ -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;
}
}

View file

@ -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");

View file

@ -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;
}
}

View file

@ -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,
};
}
}

View file

@ -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, "\\\"")}"`;
}
}

View file

@ -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", () => {
},
});
});
});
});

View file

@ -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;
}
}

View file

@ -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);
});
});
});

View file

@ -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 文件";
}
}