mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
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:
parent
3136f50624
commit
d46ddb431d
9 changed files with 108 additions and 42 deletions
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ export const zh = {
|
|||
qaGroup: {
|
||||
noModel: "请先选择 Anki 笔记模板",
|
||||
unavailable: "请先手动读取字段",
|
||||
detectedSlots: "已识别 {{count}} 组:{{summary}}",
|
||||
detectedSlots: "已识别 {{count}} 组",
|
||||
acceptWarnings: "我已确认忽略当前警告,并允许同步",
|
||||
warning: {
|
||||
missingAnswer: "第 {{index}} 组缺少答案字段,已忽略 {{questionField}} / {{answerField}}。",
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue