feat(settings): simplify QA group field mapping UI

中文: 将问答题(多级列表)的题目字段改为可选择下拉框,并将问题/答案字段摘要简化为只显示已识别组数;同步逻辑保留用户选择的题目字段。

English: Make the QA group title field selectable, simplify the Q/A slot summary to the detected pair count, and preserve the selected title field during sync and cache refresh.
This commit is contained in:
Dusk 2026-04-25 10:19:11 +08:00
parent 3136f50624
commit d46ddb431d
9 changed files with 108 additions and 42 deletions

View file

@ -40,14 +40,14 @@ export function createNoteFieldMappingKey(cardType: NoteModelFieldMappingCardTyp
return `${cardType}:${modelName}`;
}
export function isBasicLikeNoteModelFieldMapping(mapping: NoteModelFieldMapping): mapping is BasicLikeNoteModelFieldMapping {
return mapping.cardType === "basic" || mapping.cardType === "semantic-qa";
export function isBasicLikeNoteModelFieldMapping(mapping: NoteModelFieldMapping | undefined): mapping is BasicLikeNoteModelFieldMapping {
return mapping?.cardType === "basic" || mapping?.cardType === "semantic-qa";
}
export function isClozeNoteModelFieldMapping(mapping: NoteModelFieldMapping): mapping is ClozeNoteModelFieldMapping {
return mapping.cardType === "cloze";
export function isClozeNoteModelFieldMapping(mapping: NoteModelFieldMapping | undefined): mapping is ClozeNoteModelFieldMapping {
return mapping?.cardType === "cloze";
}
export function isQaGroupFieldMapping(mapping: NoteModelFieldMapping): mapping is QaGroupFieldMapping {
return mapping.cardType === "qa-group";
export function isQaGroupFieldMapping(mapping: NoteModelFieldMapping | undefined): mapping is QaGroupFieldMapping {
return mapping?.cardType === "qa-group";
}

View file

@ -48,6 +48,14 @@ describe("QaGroupFieldMappingService", () => {
});
});
it("keeps a user-selected title field when it still exists", () => {
const service = new QaGroupFieldMappingService();
const mapping = service.suggest("问答题(多级列表)", ["题目", "标题", "问题01", "答案01"], 123, undefined, "标题");
expect(mapping.titleField).toBe("标题");
});
it("supports the legacy Stem plus Sxx_Q/Sxx_A shape as a normal user template", () => {
const service = new QaGroupFieldMappingService();
@ -84,13 +92,18 @@ describe("QaGroupFieldMappingService", () => {
expect(reset.acceptedWarnings).toBeUndefined();
});
it("throws when no preferred title field exists", () => {
it("leaves the title field unset when no preferred title field exists", () => {
const service = new QaGroupFieldMappingService();
const error = expectPluginUserError(() => service.suggest("问答题(多级列表)", ["问题01", "答案01"]));
const mapping = service.suggest("问答题(多级列表)", ["问题01", "答案01"]);
expect(mapping.titleField).toBeUndefined();
expect(mapping.slots).toEqual([
{ index: 1, questionField: "问题01", answerField: "答案01" },
]);
const error = expectPluginUserError(() => service.validateMapping(mapping, mapping.loadedFieldNames));
expect(error.userMessage.key).toBe("errors.noteFieldMapping.qaGroupMissingTitle");
expect(error.userMessage.params).toEqual({ modelName: "问答题(多级列表)" });
});
it("throws when the first detected slot does not start from one", () => {
@ -104,4 +117,4 @@ describe("QaGroupFieldMappingService", () => {
firstIndex: 2,
});
});
});
});

View file

@ -25,14 +25,11 @@ export class QaGroupFieldMappingService {
fieldNames: string[],
loadedAt = Date.now(),
acceptedWarnings?: string[],
preferredTitleField?: string,
): QaGroupFieldMapping {
const titleField = findFieldName(fieldNames, TITLE_FIELD_PRIORITY);
if (!titleField) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupMissingTitle", {
modelName,
});
}
const titleField = preferredTitleField && fieldNames.includes(preferredTitleField)
? preferredTitleField
: findFieldName(fieldNames, TITLE_FIELD_PRIORITY);
const detectedFields = detectSlots(fieldNames, modelName);
return {
@ -221,4 +218,4 @@ function serializeQaGroupFieldWarning(warning: QaGroupFieldWarningPayload): stri
function preferredSlotFieldName(kind: "question" | "answer", index: number): string {
return `${kind === "question" ? "问题" : "答案"}${String(index).padStart(2, "0")}`;
}
}

View file

@ -44,6 +44,38 @@ describe("QaGroupSyncService", () => {
expect(result.markerWrites[0]?.freeSlots).toEqual([3]);
});
it("writes the stem to the user-selected QA Group title field", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.modelDetailsByName[QA_GROUP_USER_NOTE_TYPE] = {
fieldNames: ["题目", "标题", "问题01", "答案01", "问题02", "答案02", "问题03", "答案03"],
isCloze: false,
};
const baseSettings = createModule3Settings();
const service = new QaGroupSyncService(ankiGateway);
await service.sync([createIndexedGroupBlock()], createEmptyPluginState(), createModule3Settings({
noteFieldMappings: {
...baseSettings.noteFieldMappings,
[`qa-group:${QA_GROUP_USER_NOTE_TYPE}`]: {
cardType: "qa-group",
modelName: QA_GROUP_USER_NOTE_TYPE,
loadedFieldNames: ["题目", "标题", "问题01", "答案01", "问题02", "答案02", "问题03", "答案03"],
titleField: "标题",
slots: [
{ index: 1, questionField: "问题01", answerField: "答案01" },
{ index: 2, questionField: "问题02", answerField: "答案02" },
{ index: 3, questionField: "问题03", answerField: "答案03" },
],
warnings: [],
loadedAt: 1,
},
},
}));
expect(ankiGateway.addedNotes[0]?.fields.).toBe("Concepts");
expect(ankiGateway.addedNotes[0]?.fields).not.toHaveProperty("题目");
});
it("renders QA Group stem, questions, and answers as inline Markdown HTML", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const vaultGateway = new FakeManualSyncVaultGateway();
@ -800,4 +832,4 @@ function buildBacklinkAnchor(vaultGateway: FakeManualSyncVaultGateway, filePath:
href: vaultGateway.createBacklink({ filePath, headingText }),
label: "Open in Obsidian",
});
}
}

View file

@ -488,6 +488,7 @@ export class QaGroupSyncService {
fieldNames,
this.now(),
isQaGroupFieldMapping(storedMapping) ? storedMapping.acceptedWarnings : undefined,
isQaGroupFieldMapping(storedMapping) ? storedMapping.titleField : undefined,
);
this.qaGroupFieldMappingService.validateMapping(mapping, fieldNames);
this.qaGroupFieldMappingService.validateWarningsAccepted(mapping);

View file

@ -255,7 +255,7 @@ export const en = {
qaGroup: {
noModel: "Select an Anki note type first",
unavailable: "Read fields from Anki first",
detectedSlots: "Detected {{count}} pair(s): {{summary}}",
detectedSlots: "Detected {{count}} pair(s)",
acceptWarnings: "I confirm these warnings can be ignored and sync may continue",
warning: {
missingAnswer: "Slot {{index}} is missing its answer field, so {{questionField}} / {{answerField}} was ignored.",

View file

@ -253,7 +253,7 @@ export const zh = {
qaGroup: {
noModel: "请先选择 Anki 笔记模板",
unavailable: "请先手动读取字段",
detectedSlots: "已识别 {{count}} 组{{summary}}",
detectedSlots: "已识别 {{count}} 组",
acceptWarnings: "我已确认忽略当前警告,并允许同步",
warning: {
missingAnswer: "第 {{index}} 组缺少答案字段,已忽略 {{questionField}} / {{answerField}}。",

View file

@ -384,7 +384,7 @@ class FakePlugin {
}
if (modelName === QA_GROUP_USER_MODEL_NAME) {
return ["题目", "问题01", "答案01", "问题02", "答案02"];
return ["题目", "标题", "问题01", "答案01", "问题02", "答案02"];
}
return ["Title", "Body", "Hint"];
@ -567,10 +567,13 @@ describe("PluginSettingTab", () => {
bodyField: "Body",
}));
expect(queryByDataset(tab.containerEl, "qaGroupTitleField", "qa-group").textContent).toBe("题目");
expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toContain("已识别 2 组");
const qaGroupTitleField = queryByDataset(tab.containerEl, "qaGroupTitleField", "qa-group");
expect(qaGroupTitleField.value).toBe("题目");
qaGroupTitleField.value = "标题";
await qaGroupTitleField.trigger("change");
expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toBe("已识别 2 组");
expect(plugin.settings.noteFieldMappings[createNoteFieldMappingKey("qa-group", plugin.settings.cardTypeConfigs["qa-group"].noteType)]).toEqual(expect.objectContaining({
titleField: "题目",
titleField: "题",
slots: [
{ index: 1, questionField: "问题01", answerField: "答案01" },
{ index: 2, questionField: "问题02", answerField: "答案02" },

View file

@ -324,17 +324,23 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const qaGroupState = this.getQaGroupFieldState(configId);
const titleFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.qaGroupTitleField"));
titleFieldGroup.createEl("span", {
text: qaGroupState.mapping?.titleField
?? (selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel")),
}).dataset.qaGroupTitleField = configId;
if (qaGroupState.mapping) {
const titleFieldSelect = this.createFieldSelect(titleFieldGroup, `card-type-qa-group-title-field:${configId}`);
titleFieldSelect.dataset.qaGroupTitleField = configId;
this.applyFluidEllipsis(titleFieldSelect);
this.populateFieldSelect(titleFieldSelect, qaGroupState.mapping.loadedFieldNames, qaGroupState.mapping.titleField);
titleFieldSelect.addEventListener("change", () => this.saveFieldMapping(configId, { titleField: titleFieldSelect.value || undefined }));
} else {
titleFieldGroup.createEl("span", {
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
}).dataset.qaGroupTitleField = configId;
}
const slotFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.qaGroupSlotFields"));
slotFieldGroup.createEl("span", {
text: qaGroupState.mapping
? t("settings.cards.cardTypes.qaGroup.detectedSlots", {
count: qaGroupState.mapping.slots.length,
summary: summarizeQaGroupSlots(qaGroupState.mapping),
})
: (selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel")),
}).dataset.qaGroupSlotSummary = configId;
@ -808,6 +814,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
modelDetails.fieldNames,
loadedAt,
isQaGroupFieldMapping(currentMapping) ? currentMapping.acceptedWarnings : undefined,
isQaGroupFieldMapping(currentMapping) ? currentMapping.titleField : undefined,
);
} catch {
delete this.draftMappings[mappingKey];
@ -862,7 +869,14 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
if (runtimeCardType === "qa-group") {
try {
const suggestedMapping = this.qaGroupFieldMappingService.suggest(modelName, modelDetails.fieldNames);
const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey];
const suggestedMapping = this.qaGroupFieldMappingService.suggest(
modelName,
modelDetails.fieldNames,
Date.now(),
isQaGroupFieldMapping(currentMapping) ? currentMapping.acceptedWarnings : undefined,
isQaGroupFieldMapping(currentMapping) ? currentMapping.titleField : undefined,
);
this.draftMappings[mappingKey] = suggestedMapping;
return {
...suggestedMapping,
@ -890,7 +904,14 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
if (modelDetails) {
if (runtimeCardType === "qa-group") {
return this.qaGroupFieldMappingService.suggest(modelName, modelDetails.fieldNames);
const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey];
return this.qaGroupFieldMappingService.suggest(
modelName,
modelDetails.fieldNames,
Date.now(),
isQaGroupFieldMapping(currentMapping) ? currentMapping.acceptedWarnings : undefined,
isQaGroupFieldMapping(currentMapping) ? currentMapping.titleField : undefined,
);
}
return this.noteFieldMappingService.suggest(runtimeCardType, modelName, modelDetails.fieldNames);
@ -966,6 +987,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
cacheEntry.fieldNames,
cacheEntry.loadedAt,
isQaGroupFieldMapping(currentMapping) ? currentMapping.acceptedWarnings : undefined,
isQaGroupFieldMapping(currentMapping) ? currentMapping.titleField : undefined,
);
this.draftMappings[mappingKey] = nextMapping;
@ -1009,8 +1031,15 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
try {
const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey];
return {
mapping: this.qaGroupFieldMappingService.suggest(modelName, modelDetails.fieldNames),
mapping: this.qaGroupFieldMappingService.suggest(
modelName,
modelDetails.fieldNames,
Date.now(),
isQaGroupFieldMapping(currentMapping) ? currentMapping.acceptedWarnings : undefined,
isQaGroupFieldMapping(currentMapping) ? currentMapping.titleField : undefined,
),
};
} catch (error) {
return {
@ -1312,15 +1341,6 @@ function getScopeModeSummary(scopeMode: ScopeMode): string {
return t("settings.scope.summary.all");
}
function summarizeQaGroupSlots(mapping: QaGroupFieldMapping): string {
const slotPairs = mapping.slots.map((slot) => `${slot.questionField}/${slot.answerField}`);
if (slotPairs.length <= 3) {
return slotPairs.join(", ");
}
return `${slotPairs.slice(0, 3).join(", ")}...`;
}
function areMappingsEqual(left: NoteModelFieldMapping | undefined, right: NoteModelFieldMapping): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}