From d46ddb431dc889f39b7ea8ba2c60a901f174aefd Mon Sep 17 00:00:00 2001 From: Dusk Date: Sat, 25 Apr 2026 10:19:11 +0800 Subject: [PATCH] feat(settings): simplify QA group field mapping UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文: 将问答题(多级列表)的题目字段改为可选择下拉框,并将问题/答案字段摘要简化为只显示已识别组数;同步逻辑保留用户选择的题目字段。 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. --- .../config/NoteModelFieldMapping.ts | 12 ++--- .../QaGroupFieldMappingService.test.ts | 21 ++++++-- .../services/QaGroupFieldMappingService.ts | 13 ++--- .../services/QaGroupSyncService.test.ts | 34 +++++++++++- .../services/QaGroupSyncService.ts | 1 + src/presentation/i18n/messages/en.ts | 2 +- src/presentation/i18n/messages/zh.ts | 2 +- .../settings/PluginSettingTab.test.ts | 11 ++-- src/presentation/settings/PluginSettingTab.ts | 54 +++++++++++++------ 9 files changed, 108 insertions(+), 42 deletions(-) diff --git a/src/application/config/NoteModelFieldMapping.ts b/src/application/config/NoteModelFieldMapping.ts index fe93224..3ea61da 100644 --- a/src/application/config/NoteModelFieldMapping.ts +++ b/src/application/config/NoteModelFieldMapping.ts @@ -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"; } diff --git a/src/application/services/QaGroupFieldMappingService.test.ts b/src/application/services/QaGroupFieldMappingService.test.ts index 11969ec..0c82aae 100644 --- a/src/application/services/QaGroupFieldMappingService.test.ts +++ b/src/application/services/QaGroupFieldMappingService.test.ts @@ -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, }); }); -}); \ No newline at end of file +}); diff --git a/src/application/services/QaGroupFieldMappingService.ts b/src/application/services/QaGroupFieldMappingService.ts index 7d080dc..5b2e8cc 100644 --- a/src/application/services/QaGroupFieldMappingService.ts +++ b/src/application/services/QaGroupFieldMappingService.ts @@ -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")}`; -} \ No newline at end of file +} diff --git a/src/application/services/QaGroupSyncService.test.ts b/src/application/services/QaGroupSyncService.test.ts index 84147ef..c7734ba 100644 --- a/src/application/services/QaGroupSyncService.test.ts +++ b/src/application/services/QaGroupSyncService.test.ts @@ -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", }); -} \ No newline at end of file +} diff --git a/src/application/services/QaGroupSyncService.ts b/src/application/services/QaGroupSyncService.ts index c2e2f11..bfbb19d 100644 --- a/src/application/services/QaGroupSyncService.ts +++ b/src/application/services/QaGroupSyncService.ts @@ -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); diff --git a/src/presentation/i18n/messages/en.ts b/src/presentation/i18n/messages/en.ts index 84637d6..6e4688d 100644 --- a/src/presentation/i18n/messages/en.ts +++ b/src/presentation/i18n/messages/en.ts @@ -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.", diff --git a/src/presentation/i18n/messages/zh.ts b/src/presentation/i18n/messages/zh.ts index 06fbc73..3e781ce 100644 --- a/src/presentation/i18n/messages/zh.ts +++ b/src/presentation/i18n/messages/zh.ts @@ -253,7 +253,7 @@ export const zh = { qaGroup: { noModel: "请先选择 Anki 笔记模板", unavailable: "请先手动读取字段", - detectedSlots: "已识别 {{count}} 组:{{summary}}", + detectedSlots: "已识别 {{count}} 组", acceptWarnings: "我已确认忽略当前警告,并允许同步", warning: { missingAnswer: "第 {{index}} 组缺少答案字段,已忽略 {{questionField}} / {{answerField}}。", diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index 7377325..0ca5a02 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -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" }, diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index 92ca151..5f30278 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -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); }