feat(sync): refine QA mappings and cloze output

中文: 优化问答题多级列表字段映射与解析,移除填空题兼容性误判,并调整填空题正文分隔符和设置页展示。

English: Refine QA group field mapping and parser behavior, remove stale cloze compatibility assumptions, and update cloze body separator plus settings UI copy.
This commit is contained in:
Dusk 2026-04-26 16:55:31 +08:00
parent 911949bedc
commit 8357992ee3
23 changed files with 817 additions and 209 deletions

View file

@ -0,0 +1,41 @@
# QA Group Mapping / Parser Decisions
## Mapping Authority
- Concrete saved slots remain the runtime sync authority.
- Derivation metadata is settings-time metadata, not a runtime replacement for saved slots.
## First-Pair Derivation
- QA Group mappings now optionally persist:
- `titleField`
- `derivation.mode = "first-pair"`
- `derivation.firstQuestionField`
- `derivation.firstAnswerField`
- When both selected first fields contain a numeric segment:
- use the last numeric segment in each field name
- require both fields to start at the same index
- require the first selected pair to start at `1`
- preserve numeric width when generating later fields
- stop at the first missing complete pair
- When one or both selected first fields have no numeric segment:
- keep exactly one configured pair
- do not try to auto-extend further pairs
## Refresh Behavior
- If a saved QA Group mapping has derivation metadata, reading fields from Anki regenerates slots from the stored first pair.
- If a saved QA Group mapping does not have derivation metadata, reading fields from Anki only refreshes `loadedFieldNames` and preserves the saved explicit slots.
## Parser Behavior
- QA Group answers now use the entire child block under each first-level item.
- The parser dedents the child block before storing it as answer markdown.
- If the child block is exactly one direct child list item, the parser unwraps that outer bullet so existing simple answers stay stable.
- If the child block contains multiple child bullets, paragraphs, nested lists, or fences, the parser keeps the markdown structure.
## Cloze Cleanup
- Removed `NoteModelDetails.isCloze` because sync no longer depends on Cloze-compatibility metadata.
- Kept `CreateAnkiModelInput.isCloze` because model creation still needs the AnkiConnect `createModel` contract.
- Removed the dead `clozeIncompatible` i18n message because no code path still emits it.

View file

@ -0,0 +1,38 @@
# QA Group Mapping / Parser Gap Report
## Audit Baseline
- Sync-side authority for QA Group was already correct before this change: sync used saved slots and only validated field existence.
- Settings UI was still under-specified: users could only change the title field and see an auto-detected slot summary.
- QA Group field detection still depended on fixed question/answer naming heuristics and could not persist a user-selected first pair.
- QA Group parsing still truncated answers to the first direct second-level list item label.
- Stale Cloze compatibility remnants still existed in `NoteModelDetails.isCloze` and the unused `clozeIncompatible` i18n copy.
## Closed Gaps
- Added optional QA Group derivation metadata so settings can persist the first selected question/answer pair while keeping concrete slots as sync authority.
- Refactored QA Group field derivation to support “choose the first pair once, derive the rest automatically”.
- Updated the QA Group settings UI to expose:
- title field
- first question field
- first answer field
- configured pair count with first/last example
- Changed field refresh behavior so only mappings with derivation metadata are regenerated from the latest Anki fields.
- Preserved old explicit QA Group slot mappings when no derivation metadata exists.
- Updated the QA Group parser so answers now keep the full child block markdown, including multiline text, nested lists, and code fences.
- Removed stale `NoteModelDetails.isCloze` metadata and deleted the dead `clozeIncompatible` copy.
## Intentional Compatibility Rules
- Old QA Group mappings with explicit slots and no derivation metadata continue to work unchanged.
- New QA Group mappings no longer emit incomplete-tail warnings during suggestion; they derive continuous pairs from the selected first pair and stop at the first missing complete pair.
- Simple QA Group answers that were previously represented as a single nested bullet still serialize to the same plain-text answer where practical.
## Validation Status
- Focused tests passed for:
- QA Group field derivation service
- plugin settings normalization / validation
- settings UI behavior
- QA Group parser behavior
- sync / gateway / batch executor slices touched by `NoteModelDetails`

52
main.js

File diff suppressed because one or more lines are too long

View file

@ -26,12 +26,19 @@ export interface QaGroupSlotMapping {
answerField: string;
}
export interface QaGroupFieldDerivation {
mode: "first-pair";
firstQuestionField?: string;
firstAnswerField?: string;
}
export interface QaGroupFieldMapping extends BaseNoteModelFieldMapping {
cardType: "qa-group";
titleField?: string;
slots: QaGroupSlotMapping[];
warnings: string[];
acceptedWarnings?: string[];
derivation?: QaGroupFieldDerivation;
}
export type NoteModelFieldMapping = BasicLikeNoteModelFieldMapping | ClozeNoteModelFieldMapping | QaGroupFieldMapping;

View file

@ -486,6 +486,11 @@ describe("PluginSettings", () => {
modelName: "Custom QA Group",
loadedFieldNames: ["题目", "问题01", "答案01", "问题02", "答案02"],
titleField: "题目",
derivation: {
mode: "first-pair",
firstQuestionField: "问题01",
firstAnswerField: "答案01",
},
slots: [
{ index: 1, questionField: "问题01", answerField: "答案01" },
{ index: 2, questionField: "问题02", answerField: "答案02" },
@ -515,6 +520,28 @@ describe("PluginSettings", () => {
}, "errors.settings.noteFieldMappingsQaGroupSlotIndex");
});
it("rejects invalid qa-group derivation metadata in note field mappings", () => {
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
noteFieldMappings: {
"qa-group:Custom QA Group": {
cardType: "qa-group",
modelName: "Custom QA Group",
loadedFieldNames: ["题目", "问题01", "答案01"],
titleField: "题目",
slots: [{ index: 1, questionField: "问题01", answerField: "答案01" }],
warnings: [],
derivation: {
mode: "invalid",
},
loadedAt: 1,
} as never,
},
});
}, "errors.settings.noteFieldMappingsQaGroupDerivationMode");
});
it("rejects invalid module 5 enum values", () => {
expectPluginUserError(() => {
validatePluginSettings({

View file

@ -361,6 +361,24 @@ function validateNoteFieldMappings(noteFieldMappings: Record<string, NoteModelFi
validateStringList(mapping.warnings, "errors.settings.noteFieldMappingsQaGroupWarningsArray", "errors.settings.noteFieldMappingsQaGroupWarningsStrings");
validateStringList(mapping.acceptedWarnings, "errors.settings.noteFieldMappingsQaGroupAcceptedWarningsArray", "errors.settings.noteFieldMappingsQaGroupAcceptedWarningsStrings", true);
if (mapping.derivation !== undefined) {
if (!mapping.derivation || typeof mapping.derivation !== "object" || Array.isArray(mapping.derivation)) {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupDerivationObject");
}
if (mapping.derivation.mode !== "first-pair") {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupDerivationMode");
}
if (mapping.derivation.firstQuestionField !== undefined && typeof mapping.derivation.firstQuestionField !== "string") {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupDerivationFirstQuestionField");
}
if (mapping.derivation.firstAnswerField !== undefined && typeof mapping.derivation.firstAnswerField !== "string") {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupDerivationFirstAnswerField");
}
}
}
}
}
@ -637,7 +655,8 @@ function normalizeQaGroupFieldMapping(
const normalizedSlots = normalizeQaGroupSlots(mapping.slots);
const normalizedWarnings = normalizeStringList(mapping.warnings);
const normalizedTitleField = normalizeOptionalFieldName(mapping.titleField);
const hasExplicitQaGroupShape = normalizedSlots.length > 0 || normalizedWarnings.length > 0 || acceptedWarnings.length > 0;
const normalizedDerivation = normalizeQaGroupDerivation(mapping.derivation);
const hasExplicitQaGroupShape = normalizedSlots.length > 0 || normalizedWarnings.length > 0 || acceptedWarnings.length > 0 || normalizedDerivation !== undefined;
if (hasExplicitQaGroupShape) {
return {
@ -648,13 +667,14 @@ function normalizeQaGroupFieldMapping(
slots: normalizedSlots,
warnings: normalizedWarnings,
acceptedWarnings: acceptedWarnings.length > 0 ? acceptedWarnings : undefined,
derivation: normalizedDerivation,
loadedAt,
};
}
if (loadedFieldNames.length > 0) {
try {
return qaGroupFieldMappingService.suggest(modelName, loadedFieldNames, loadedAt, acceptedWarnings);
return qaGroupFieldMappingService.suggest(modelName, loadedFieldNames, loadedAt, acceptedWarnings, normalizedTitleField);
} catch {
return {
cardType: "qa-group",
@ -664,6 +684,7 @@ function normalizeQaGroupFieldMapping(
slots: [],
warnings: [],
acceptedWarnings: undefined,
derivation: normalizedDerivation,
loadedAt,
};
}
@ -677,10 +698,28 @@ function normalizeQaGroupFieldMapping(
slots: [],
warnings: [],
acceptedWarnings: undefined,
derivation: normalizedDerivation,
loadedAt,
};
}
function normalizeQaGroupDerivation(value: unknown): { mode: "first-pair"; firstQuestionField?: string; firstAnswerField?: string } | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const derivation = value as Partial<{ mode: unknown; firstQuestionField: unknown; firstAnswerField: unknown }>;
if (derivation.mode !== "first-pair") {
return undefined;
}
return {
mode: "first-pair",
firstQuestionField: normalizeOptionalFieldName(derivation.firstQuestionField),
firstAnswerField: normalizeOptionalFieldName(derivation.firstAnswerField),
};
}
function normalizeAnkiNoteTypeCache(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];

View file

@ -1,4 +1,3 @@
export interface NoteModelDetails {
fieldNames: string[];
isCloze: boolean;
}

View file

@ -196,7 +196,7 @@ describe("AnkiBatchExecutor", () => {
it("migrates a basic note in place when the current note model changed", async () => {
const ankiGateway = new CountingAnkiGateway();
ankiGateway.modelDetailsByName["New Basic"] = { fieldNames: ["Front", "Back"], isCloze: false };
ankiGateway.modelDetailsByName["New Basic"] = { fieldNames: ["Front", "Back"] };
ankiGateway.noteSummariesById.set(300, {
noteId: 300,
modelName: "Old Basic",
@ -250,7 +250,7 @@ describe("AnkiBatchExecutor", () => {
it("migrates a cloze note in place when the current note model changed", async () => {
const ankiGateway = new CountingAnkiGateway();
ankiGateway.modelDetailsByName["New Cloze"] = { fieldNames: ["Text", "Extra"], isCloze: true };
ankiGateway.modelDetailsByName["New Cloze"] = { fieldNames: ["Text", "Extra"] };
ankiGateway.noteSummariesById.set(300, {
noteId: 300,
modelName: "Old Cloze",
@ -291,7 +291,7 @@ describe("AnkiBatchExecutor", () => {
noteId: 300,
modelName: "New Cloze",
fields: {
Text: "Heading sync-migrate-cloze<br><br>Body sync-migrate-cloze",
Text: "Heading sync-migrate-cloze<br><hr>Body sync-migrate-cloze",
},
},
]);
@ -331,7 +331,7 @@ describe("AnkiBatchExecutor", () => {
it("surfaces the existing mapping error instead of migrating when the target mapping is incomplete", async () => {
const ankiGateway = new CountingAnkiGateway();
ankiGateway.modelDetailsByName["New Basic"] = { fieldNames: ["Front", "Back"], isCloze: false };
ankiGateway.modelDetailsByName["New Basic"] = { fieldNames: ["Front", "Back"] };
ankiGateway.noteSummariesById.set(300, {
noteId: 300,
modelName: "Old Basic",
@ -427,4 +427,4 @@ function createIndexedCard(syncKey: string, noteId?: number, cardType: IndexedCa
deckWarnings: [],
tagsHint: [],
};
}
}

View file

@ -46,7 +46,6 @@ describe("NoteFieldMappingService", () => {
card,
{
fieldNames: ["Title", "Body"],
isCloze: false,
},
{
[createNoteFieldMappingKey("basic", "Basic")]: {
@ -76,7 +75,6 @@ describe("NoteFieldMappingService", () => {
}),
{
fieldNames: ["Text", "Extra"],
isCloze: false,
},
{
[createNoteFieldMappingKey("cloze", "Cloze")]: {
@ -89,7 +87,7 @@ describe("NoteFieldMappingService", () => {
},
);
expect(fields).toEqual({ Text: "Context<br><br>{{c1::answer}}" });
expect(fields).toEqual({ Text: "Context<br><hr>{{c1::answer}}" });
});
it("throws when a cloze main field is missing from the saved mapping", () => {
@ -101,7 +99,7 @@ describe("NoteFieldMappingService", () => {
type: "cloze",
noteModel: "Cloze",
}),
{ fieldNames: ["Text", "Extra"], isCloze: false },
{ fieldNames: ["Text", "Extra"] },
{
[createNoteFieldMappingKey("cloze", "Cloze")]: {
cardType: "cloze",
@ -118,7 +116,7 @@ describe("NoteFieldMappingService", () => {
expect(error.userMessage.params).toEqual({ modelName: "Cloze" });
});
it("throws when a cloze main field is stale even if isCloze is false", () => {
it("throws when a cloze main field is stale based on saved field existence only", () => {
const service = new NoteFieldMappingService();
const error = expectPluginUserError(() =>
@ -127,7 +125,7 @@ describe("NoteFieldMappingService", () => {
type: "cloze",
noteModel: "Cloze",
}),
{ fieldNames: ["Body", "Extra"], isCloze: false },
{ fieldNames: ["Body", "Extra"] },
{
[createNoteFieldMappingKey("cloze", "Cloze")]: {
cardType: "cloze",
@ -151,7 +149,7 @@ describe("NoteFieldMappingService", () => {
const service = new NoteFieldMappingService();
const error = expectPluginUserError(() =>
service.map(createCard({}), { fieldNames: ["Front", "Back"], isCloze: false }, {}),
service.map(createCard({}), { fieldNames: ["Front", "Back"] }, {}),
);
expect(error.userMessage.key).toBe("errors.noteFieldMapping.missingSavedMapping.basic");
@ -164,7 +162,7 @@ describe("NoteFieldMappingService", () => {
const error = expectPluginUserError(() =>
service.map(
createCard({}),
{ fieldNames: ["Front", "Body"], isCloze: false },
{ fieldNames: ["Front", "Body"] },
{
[createNoteFieldMappingKey("basic", "Basic")]: {
cardType: "basic",
@ -191,7 +189,7 @@ describe("NoteFieldMappingService", () => {
const error = expectPluginUserError(() =>
service.map(
createCard({}),
{ fieldNames: ["Front", "Back"], isCloze: false },
{ fieldNames: ["Front", "Back"] },
{
[createNoteFieldMappingKey("basic", "Basic")]: {
cardType: "basic",
@ -208,4 +206,4 @@ describe("NoteFieldMappingService", () => {
expect(error.userMessage.key).toBe("errors.noteFieldMapping.titleBodyMustDiffer.basic");
expect(error.userMessage.params).toEqual({ modelName: "Basic" });
});
});
});

View file

@ -135,7 +135,7 @@ export class NoteFieldMappingService {
}
return {
[mapping.mainField]: `${card.renderedFields.title}<br><br>${card.renderedFields.body}`,
[mapping.mainField]: `${card.renderedFields.title}<br><hr>${card.renderedFields.body}`,
};
}

View file

@ -3,8 +3,6 @@ import { describe, expect, it } from "vitest";
import { PluginUserError } from "@/application/errors/PluginUserError";
import {
areQaGroupWarningsAccepted,
parseQaGroupFieldWarning,
QaGroupFieldMappingService,
} from "./QaGroupFieldMappingService";
@ -30,6 +28,11 @@ describe("QaGroupFieldMappingService", () => {
modelName: "问答题(多级列表)",
loadedFieldNames: ["题目", "问题01", "答案01", "问题02", "答案02"],
titleField: "题目",
derivation: {
mode: "first-pair",
firstQuestionField: "问题01",
firstAnswerField: "答案01",
},
slots: [
{
index: 1,
@ -68,28 +71,48 @@ describe("QaGroupFieldMappingService", () => {
]);
});
it("records warnings for incomplete tail slots and preserves accepted warnings only when they still match", () => {
it("derives slots from a selected first pair and stops at the first missing pair", () => {
const service = new QaGroupFieldMappingService();
const mapping = service.suggest("问答题(多级列表)", ["题目", "问题01", "答案01", "问题02"], 1);
const mapping = service.createMappingFromSelection(
"问答题(多级列表)",
["题目", "问题01", "答案01", "问题02"],
{
titleField: "题目",
firstQuestionField: "问题01",
firstAnswerField: "答案01",
},
1,
);
expect(mapping.slots).toEqual([
{ index: 1, questionField: "问题01", answerField: "答案01" },
]);
expect(mapping.warnings).toHaveLength(1);
expect(parseQaGroupFieldWarning(mapping.warnings[0] ?? "")).toEqual({
kind: "missing-answer",
index: 2,
questionField: "问题02",
answerField: "答案02",
expect(mapping.derivation).toEqual({
mode: "first-pair",
firstQuestionField: "问题01",
firstAnswerField: "答案01",
});
expect(mapping.warnings).toEqual([]);
expect(mapping.acceptedWarnings).toBeUndefined();
});
const accepted = service.suggest("问答题(多级列表)", ["题目", "问题01", "答案01", "问题02"], 1, mapping.warnings);
expect(accepted.acceptedWarnings).toEqual(accepted.warnings);
expect(areQaGroupWarningsAccepted(accepted.warnings, accepted.acceptedWarnings)).toBe(true);
it("keeps a single slot when the selected first pair does not contain numbers", () => {
const service = new QaGroupFieldMappingService();
const reset = service.suggest("问答题(多级列表)", ["题目", "问题01", "答案01", "问题02", "答案03"], 1, mapping.warnings);
expect(reset.acceptedWarnings).toBeUndefined();
const mapping = service.createMappingFromSelection(
"Custom QA Group",
["题目", "Question", "Answer"],
{
titleField: "题目",
firstQuestionField: "Question",
firstAnswerField: "Answer",
},
1,
);
expect(mapping.slots).toEqual([
{ index: 1, questionField: "Question", answerField: "Answer" },
]);
});
it("leaves the title field unset when no preferred title field exists", () => {
@ -117,4 +140,22 @@ describe("QaGroupFieldMappingService", () => {
firstIndex: 2,
});
});
it("rejects selected first pairs whose numbers do not line up", () => {
const service = new QaGroupFieldMappingService();
const error = expectPluginUserError(() => {
service.createMappingFromSelection(
"问答题(多级列表)",
["题目", "问题01", "答案02"],
{
titleField: "题目",
firstQuestionField: "问题01",
firstAnswerField: "答案02",
},
);
});
expect(error.userMessage.key).toBe("errors.noteFieldMapping.qaGroupFirstPairNumberMismatch");
});
});

View file

@ -1,4 +1,4 @@
import type { QaGroupFieldMapping, QaGroupSlotMapping } from "@/application/config/NoteModelFieldMapping";
import type { QaGroupFieldDerivation, QaGroupFieldMapping, QaGroupSlotMapping } from "@/application/config/NoteModelFieldMapping";
import { PluginUserError } from "@/application/errors/PluginUserError";
type QaGroupFieldWarningKind = "missing-answer" | "missing-question";
@ -10,9 +10,17 @@ interface QaGroupFieldWarningPayload {
answerField: string;
}
interface QaGroupDetectedFields {
slots: QaGroupSlotMapping[];
warnings: string[];
interface QaGroupFieldSelection {
titleField?: string;
firstQuestionField?: string;
firstAnswerField?: string;
}
interface IndexedFieldSegment {
prefix: string;
digits: string;
suffix: string;
index: number;
}
const TITLE_FIELD_PRIORITY = ["题目", "标题", "正面", "Stem", "Title"] as const;
@ -27,21 +35,56 @@ export class QaGroupFieldMappingService {
acceptedWarnings?: string[],
preferredTitleField?: string,
): QaGroupFieldMapping {
void acceptedWarnings;
const titleField = preferredTitleField && fieldNames.includes(preferredTitleField)
? preferredTitleField
: findFieldName(fieldNames, TITLE_FIELD_PRIORITY);
const detectedFields = detectSlots(fieldNames, modelName);
const firstPair = detectSuggestedFirstPair(fieldNames, modelName);
return this.createMappingFromSelection(
modelName,
fieldNames,
{
titleField,
firstQuestionField: firstPair.questionField,
firstAnswerField: firstPair.answerField,
},
loadedAt,
);
}
createMappingFromSelection(
modelName: string,
fieldNames: string[],
selection: QaGroupFieldSelection,
loadedAt = Date.now(),
): QaGroupFieldMapping {
const loadedFieldNames = [...fieldNames];
const titleField = normalizeOptionalFieldName(selection.titleField);
const firstQuestionField = normalizeOptionalFieldName(selection.firstQuestionField);
const firstAnswerField = normalizeOptionalFieldName(selection.firstAnswerField);
const derivation = buildQaGroupDerivation(firstQuestionField, firstAnswerField);
const availableFields = new Set(loadedFieldNames);
for (const field of [titleField, firstQuestionField, firstAnswerField]) {
if (field && !availableFields.has(field)) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupSelectedFieldMissing", {
modelName,
field,
});
}
}
return {
cardType: "qa-group",
modelName,
loadedFieldNames: [...fieldNames],
loadedFieldNames,
titleField,
slots: detectedFields.slots,
warnings: detectedFields.warnings,
acceptedWarnings: detectedFields.warnings.length > 0 && areQaGroupWarningsAccepted(detectedFields.warnings, acceptedWarnings)
? [...detectedFields.warnings]
: undefined,
slots: deriveQaGroupSlots(modelName, loadedFieldNames, titleField, firstQuestionField, firstAnswerField),
warnings: [],
acceptedWarnings: undefined,
derivation,
loadedAt,
};
}
@ -61,6 +104,8 @@ export class QaGroupFieldMappingService {
const availableFields = new Set(fieldNames);
const missingFields = new Set<string>();
const usedFields = new Set<string>([mapping.titleField]);
if (!availableFields.has(mapping.titleField)) {
missingFields.add(mapping.titleField);
}
@ -73,6 +118,15 @@ export class QaGroupFieldMappingService {
if (!availableFields.has(slot.answerField)) {
missingFields.add(slot.answerField);
}
if (usedFields.has(slot.questionField) || usedFields.has(slot.answerField) || slot.questionField === slot.answerField) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupDuplicateFields", {
modelName: mapping.modelName,
});
}
usedFields.add(slot.questionField);
usedFields.add(slot.answerField);
}
if (missingFields.size > 0) {
@ -131,7 +185,7 @@ export function parseQaGroupFieldWarning(warning: string): QaGroupFieldWarningPa
}
}
function detectSlots(fieldNames: string[], modelName: string): QaGroupDetectedFields {
function detectSuggestedFirstPair(fieldNames: string[], modelName: string): { questionField: string; answerField: string } {
const questionFieldByIndex = new Map<number, string>();
const answerFieldByIndex = new Map<number, string>();
@ -156,40 +210,18 @@ function detectSlots(fieldNames: string[], modelName: string): QaGroupDetectedFi
});
}
const slots: QaGroupSlotMapping[] = [];
const warnings: string[] = [];
for (const index of candidateIndices) {
const questionField = questionFieldByIndex.get(index);
const answerField = answerFieldByIndex.get(index);
if (!questionField || !answerField) {
warnings.push(serializeQaGroupFieldWarning({
kind: questionField ? "missing-answer" : "missing-question",
index,
questionField: questionField ?? preferredSlotFieldName("question", index),
answerField: answerField ?? preferredSlotFieldName("answer", index),
}));
continue;
}
if (index !== slots.length + 1) {
break;
}
slots.push({
index,
questionField,
answerField,
});
}
if (slots.length === 0) {
const firstQuestionField = questionFieldByIndex.get(1);
const firstAnswerField = answerFieldByIndex.get(1);
if (!firstQuestionField || !firstAnswerField) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupNoCompleteSlots", {
modelName,
});
}
return { slots, warnings };
return {
questionField: firstQuestionField,
answerField: firstAnswerField,
};
}
function detectIndexedField(fieldName: string, patterns: readonly RegExp[]): number | undefined {
@ -200,7 +232,7 @@ function detectIndexedField(fieldName: string, patterns: readonly RegExp[]): num
}
const index = Number.parseInt(match[1] ?? "", 10);
if (Number.isInteger(index) && index > 0) {
if (Number.isInteger(index) && index >= 1) {
return index;
}
}
@ -212,10 +244,107 @@ function findFieldName(fieldNames: string[], preferredNames: readonly string[]):
return fieldNames.find((fieldName) => preferredNames.some((preferredName) => preferredName.toLowerCase() === fieldName.toLowerCase()));
}
function serializeQaGroupFieldWarning(warning: QaGroupFieldWarningPayload): string {
return JSON.stringify(warning);
function deriveQaGroupSlots(
modelName: string,
fieldNames: string[],
titleField: string | undefined,
firstQuestionField: string | undefined,
firstAnswerField: string | undefined,
): QaGroupSlotMapping[] {
if (!firstQuestionField || !firstAnswerField) {
return [];
}
const usedFields = new Set<string>();
if (titleField) {
usedFields.add(titleField);
}
if (usedFields.has(firstQuestionField) || usedFields.has(firstAnswerField) || firstQuestionField === firstAnswerField) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupDuplicateFields", {
modelName,
});
}
const questionSegment = parseLastIndexedFieldSegment(firstQuestionField);
const answerSegment = parseLastIndexedFieldSegment(firstAnswerField);
if (!questionSegment || !answerSegment) {
return [{ index: 1, questionField: firstQuestionField, answerField: firstAnswerField }];
}
if (questionSegment.index !== answerSegment.index) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupFirstPairNumberMismatch", {
modelName,
questionField: firstQuestionField,
answerField: firstAnswerField,
});
}
if (questionSegment.index !== 1) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupFirstPairMustStartAtOne", {
modelName,
firstIndex: questionSegment.index,
});
}
const availableFields = new Set(fieldNames);
const slots: QaGroupSlotMapping[] = [];
for (let index = 1; ; index += 1) {
const questionField = index === 1 ? firstQuestionField : formatIndexedField(questionSegment, index);
const answerField = index === 1 ? firstAnswerField : formatIndexedField(answerSegment, index);
if (!availableFields.has(questionField) || !availableFields.has(answerField)) {
break;
}
if (usedFields.has(questionField) || usedFields.has(answerField) || questionField === answerField) {
throw new PluginUserError("errors.noteFieldMapping.qaGroupDuplicateFields", {
modelName,
});
}
usedFields.add(questionField);
usedFields.add(answerField);
slots.push({ index, questionField, answerField });
}
return slots;
}
function preferredSlotFieldName(kind: "question" | "answer", index: number): string {
return `${kind === "question" ? "问题" : "答案"}${String(index).padStart(2, "0")}`;
function buildQaGroupDerivation(firstQuestionField: string | undefined, firstAnswerField: string | undefined): QaGroupFieldDerivation | undefined {
if (!firstQuestionField && !firstAnswerField) {
return undefined;
}
return {
mode: "first-pair",
firstQuestionField,
firstAnswerField,
};
}
function normalizeOptionalFieldName(value: string | undefined): string | undefined {
const trimmedValue = value?.trim();
return trimmedValue ? trimmedValue : undefined;
}
function parseLastIndexedFieldSegment(fieldName: string): IndexedFieldSegment | undefined {
const matches = [...fieldName.matchAll(/\d+/g)];
const lastMatch = matches[matches.length - 1];
if (!lastMatch || lastMatch.index === undefined) {
return undefined;
}
return {
prefix: fieldName.slice(0, lastMatch.index),
digits: lastMatch[0],
suffix: fieldName.slice(lastMatch.index + lastMatch[0].length),
index: Number.parseInt(lastMatch[0], 10),
};
}
function formatIndexedField(segment: IndexedFieldSegment, index: number): string {
return `${segment.prefix}${String(index).padStart(segment.digits.length, "0")}${segment.suffix}`;
}

View file

@ -180,7 +180,6 @@ function createExistingQaGroupGateway(overrides: {
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.modelDetailsByName[definition.modelName] = {
fieldNames: [...(overrides.fieldNames ?? definition.fieldNames)],
isCloze: false,
};
ankiGateway.modelTemplatesByName[definition.modelName] = Object.fromEntries(
(overrides.templates ?? definition.templates).map((template) => [template.name, { ...template }]),

View file

@ -48,7 +48,6 @@ describe("QaGroupSyncService", () => {
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);
@ -80,7 +79,6 @@ describe("QaGroupSyncService", () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.modelDetailsByName[QA_GROUP_USER_NOTE_TYPE] = {
fieldNames: ["题目", "问题01", "答案01", "Titre", "QPerso", "APerso"],
isCloze: false,
};
const service = new QaGroupSyncService(ankiGateway);
const baseSettings = createModule3Settings();
@ -120,7 +118,6 @@ describe("QaGroupSyncService", () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.modelDetailsByName[QA_GROUP_USER_NOTE_TYPE] = {
fieldNames: ["Titre", "QuestionLibre", "ReponseLibre"],
isCloze: false,
};
const service = new QaGroupSyncService(ankiGateway);
const baseSettings = createModule3Settings();
@ -578,7 +575,6 @@ describe("QaGroupSyncService", () => {
"问题05", "答案05",
"问题06", "答案06",
],
isCloze: false,
};
ankiGateway.noteDetailsById.set(42, {
noteId: 42,
@ -711,7 +707,6 @@ describe("QaGroupSyncService", () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.modelDetailsByName[QA_GROUP_USER_NOTE_TYPE] = {
fieldNames: ["题目", "问题01"],
isCloze: false,
};
const service = new QaGroupSyncService(ankiGateway);
const baseSettings = createModule3Settings();
@ -743,7 +738,6 @@ describe("QaGroupSyncService", () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.modelDetailsByName[QA_GROUP_USER_NOTE_TYPE] = {
fieldNames: ["题目", "问题01", "答案01", "问题02"],
isCloze: false,
};
const service = new QaGroupSyncService(ankiGateway);
const baseSettings = createModule3Settings();

View file

@ -95,4 +95,81 @@ describe("QaGroupBlockParser", () => {
expect(firstParse.rawBlockText).toContain("Remarks");
expect(secondParse.rawBlockHash).toBe(firstParse.rawBlockHash);
});
it("keeps a simple single nested bullet answer compatible", () => {
const parser = new QaGroupBlockParser();
const block = parser.parse({
parentHeadingText: "Concepts #anki-list",
marker: "#anki-list",
bodyStartLine: 2,
bodyLines: [
"- Alpha",
" - First answer",
],
});
expect(block.items).toEqual([
{ title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 },
]);
});
it("preserves multiline child blocks, nested lists, and fences inside the answer", () => {
const parser = new QaGroupBlockParser();
const block = parser.parse({
parentHeadingText: "Concepts #anki-list",
marker: "#anki-list",
bodyStartLine: 2,
bodyLines: [
"- Alpha",
" First line",
" Second line",
" - detail one",
" - detail two",
" ```ts",
" const value = 1;",
" ```",
],
});
expect(block.items).toEqual([
{
title: "Alpha",
answer: [
"First line",
"Second line",
"- detail one",
"- detail two",
"```ts",
"const value = 1;",
"```",
].join("\n"),
ordinalInMarkdown: 1,
},
]);
});
it("keeps multiple direct child bullets as a markdown list answer", () => {
const parser = new QaGroupBlockParser();
const block = parser.parse({
parentHeadingText: "Concepts #anki-list",
marker: "#anki-list",
bodyStartLine: 2,
bodyLines: [
"- Alpha",
" - First answer",
" - Follow-up answer",
],
});
expect(block.items).toEqual([
{
title: "Alpha",
answer: "- First answer\n- Follow-up answer",
ordinalInMarkdown: 1,
},
]);
});
});

View file

@ -70,14 +70,14 @@ export class QaGroupBlockParser {
continue;
}
const firstSecondLevelItem = findFirstSecondLevelItem(childRegion.rawLines, item.indent.length);
if (!firstSecondLevelItem) {
const answerMarkdown = renderChildAnswerMarkdown(childRegion.rawLines);
if (!answerMarkdown) {
continue;
}
items.push({
title: item.labelMarkdown,
answer: firstSecondLevelItem.labelMarkdown,
answer: answerMarkdown,
ordinalInMarkdown: index + 1,
});
}
@ -193,45 +193,33 @@ function collectChildRegion(
};
}
function findFirstSecondLevelItem(lines: string[], parentIndentLength: number): ListItemMatch | null {
const candidates: ListItemMatch[] = [];
let fenceMarker: string | null = null;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const trimmed = line.trim();
if (isFenceLine(trimmed)) {
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
continue;
}
if (fenceMarker) {
continue;
}
const match = line.match(LIST_ITEM_REGEXP);
if (!match) {
continue;
}
const indent = match[1];
if (indent.length <= parentIndentLength) {
continue;
}
candidates.push({
index,
indent,
labelMarkdown: match[2].trimEnd(),
});
function renderChildAnswerMarkdown(lines: string[]): string {
const trimmedLines = trimBlankEdges(lines);
if (trimmedLines.length === 0) {
return "";
}
if (candidates.length === 0) {
return null;
const dedentedLines = dedentLines(trimmedLines);
const rootItems = collectRootListItems(dedentedLines);
if (rootItems.length === 1 && rootItems[0]?.index === 0) {
return unwrapSingleRootListItem(dedentedLines, rootItems[0]);
}
const directIndentLength = Math.min(...candidates.map((candidate) => candidate.indent.length));
return candidates.find((candidate) => candidate.indent.length === directIndentLength) ?? null;
return dedentedLines.join("\n").trimEnd();
}
function unwrapSingleRootListItem(lines: string[], rootItem: ListItemMatch): string {
const childRegion = collectChildRegion(lines, rootItem.index + 1, lines.length, rootItem.indent.length);
if (!childRegion) {
return rootItem.labelMarkdown;
}
const childLines = trimBlankEdges(childRegion.rawLines);
if (childLines.length === 0) {
return rootItem.labelMarkdown;
}
return [rootItem.labelMarkdown, ...dedentLines(childLines)].join("\n").trimEnd();
}
function trimBlankEdges(lines: string[]): string[] {
@ -249,6 +237,15 @@ function trimBlankEdges(lines: string[]): string[] {
return lines.slice(startIndex, endIndex);
}
function dedentLines(lines: string[]): string[] {
const indentLengths = lines
.filter((line) => line.trim().length > 0)
.map((line) => leadingWhitespace(line).length);
const commonIndent = indentLengths.length > 0 ? Math.min(...indentLengths) : 0;
return lines.map((line) => line.slice(Math.min(commonIndent, line.length)));
}
function leadingWhitespace(line: string): string {
const match = line.match(/^[ \t]*/);
return match?.[0] ?? "";

View file

@ -138,7 +138,6 @@ describe("AnkiConnectGateway", () => {
expect(details).toEqual({
fieldNames: ["Text", "Extra"],
isCloze: false,
});
});

View file

@ -219,7 +219,6 @@ export class AnkiConnectGateway implements AnkiGroupGateway {
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
return {
fieldNames: await this.getModelFieldNames(modelName),
isCloze: false,
};
}

View file

@ -139,7 +139,7 @@ export const en = {
bodyFieldDesc: "Which Anki field should receive the body fragment.",
mainField: {
name: "Cloze main field",
desc: "The selected field receives title + <br><br> + body during sync.",
desc: "The selected field receives title + <br><hr> + body during sync.",
},
selectFieldPlaceholder: "-- Select field --",
},
@ -255,13 +255,18 @@ export const en = {
mainField: "Main field:",
fields: "Fields:",
qaGroupTitleField: "Title field:",
qaGroupFirstQuestionField: "First question field:",
qaGroupFirstAnswerField: "First answer field:",
qaGroupConfiguredSlots: "Configured pairs:",
qaGroupSlotFields: "Q/A fields:",
qaGroupWarnings: "Warnings:",
},
qaGroup: {
noModel: "Select an Anki note type first",
unavailable: "Read fields from Anki first",
detectedSlots: "Detected {{count}} pair(s)",
detectedSlots: "Configured {{count}} pair(s)",
configuredSlots: "Configured {{count}} pair(s)",
configuredSlotExample: "First {{firstQuestionField}} / {{firstAnswerField}}, last {{lastQuestionField}} / {{lastAnswerField}}",
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.",
@ -416,6 +421,10 @@ export const en = {
noteFieldMappingsQaGroupWarningsStrings: "QA Group field mapping warnings can only contain strings.",
noteFieldMappingsQaGroupAcceptedWarningsArray: "QA Group field mapping acceptedWarnings must be an array.",
noteFieldMappingsQaGroupAcceptedWarningsStrings: "QA Group field mapping acceptedWarnings can only contain strings.",
noteFieldMappingsQaGroupDerivationObject: "QA Group field mapping derivation must be an object.",
noteFieldMappingsQaGroupDerivationMode: "QA Group field mapping derivation.mode must be first-pair.",
noteFieldMappingsQaGroupDerivationFirstQuestionField: "QA Group field mapping derivation.firstQuestionField must be a string.",
noteFieldMappingsQaGroupDerivationFirstAnswerField: "QA Group field mapping derivation.firstAnswerField must be a string.",
},
noteFieldMapping: {
noteTypeNotSelected: {
@ -440,10 +449,13 @@ export const en = {
semanticQa: "Semantic QA note type \"{{modelName}}\" must use different title and body fields.",
},
stale: "Saved field mapping for note type \"{{modelName}}\" is stale because these fields no longer exist in Anki: {{fields}}. Open plugin settings and read fields from Anki again.",
clozeIncompatible: "Cloze note type \"{{modelName}}\" is not cloze-compatible in Anki.",
qaGroupMissingTitle: "Saved field mapping for QA Group note type \"{{modelName}}\" is incomplete because the title field is missing. Save the mapping again in settings.",
qaGroupNoCompleteSlots: "Saved field mapping for QA Group note type \"{{modelName}}\" is incomplete because it has no saved question/answer slot pairs. Save the mapping again in settings.",
qaGroupNonContinuousSlots: "QA Group note type \"{{modelName}}\" must start its question/answer slots at pair {{firstIndex}} = 1. Renumber the first pair to 1.",
qaGroupSelectedFieldMissing: "The selected field \"{{field}}\" for QA Group note type \"{{modelName}}\" is no longer present in Anki. Read fields again and save the mapping again.",
qaGroupDuplicateFields: "QA Group note type \"{{modelName}}\" cannot reuse the same title, question, or answer field.",
qaGroupFirstPairNumberMismatch: "The first question field \"{{questionField}}\" and first answer field \"{{answerField}}\" for QA Group note type \"{{modelName}}\" do not use the same pair number.",
qaGroupFirstPairMustStartAtOne: "QA Group note type \"{{modelName}}\" must start its first selected pair at 1, but the current pair starts at {{firstIndex}}.",
qaGroupWarningsUnaccepted: "QA Group note type \"{{modelName}}\" still has unconfirmed field warnings. Confirm them in settings before syncing.",
qaGroupSlotCapacityExceeded: "Saved field mapping for QA Group note type \"{{modelName}}\" only includes {{capacity}} question/answer pair(s), but the current block has {{itemCount}} item(s): {{filePath}}:{{blockStartLine}}.",
},

View file

@ -137,7 +137,7 @@ export const zh = {
bodyFieldDesc: "选择接收正文片段的 Anki 字段。",
mainField: {
name: "填空题主字段",
desc: "同步时,所选字段会接收 标题 + <br><br> + 正文。",
desc: "同步时,所选字段会接收 标题 + <br><hr> + 正文。",
},
selectFieldPlaceholder: "-- 选择字段 --",
},
@ -253,13 +253,18 @@ export const zh = {
mainField: "主字段:",
fields: "字段:",
qaGroupTitleField: "题目字段:",
qaGroupFirstQuestionField: "首个问题字段:",
qaGroupFirstAnswerField: "首个答案字段:",
qaGroupConfiguredSlots: "已配置组数:",
qaGroupSlotFields: "问题/答案字段:",
qaGroupWarnings: "警告:",
},
qaGroup: {
noModel: "请先选择 Anki 笔记模板",
unavailable: "请先手动读取字段",
detectedSlots: "已识别 {{count}} 组",
detectedSlots: "已配置{{count}}组",
configuredSlots: "已配置{{count}}组",
configuredSlotExample: "首组 {{firstQuestionField}} / {{firstAnswerField}},末组 {{lastQuestionField}} / {{lastAnswerField}}",
acceptWarnings: "我已确认忽略当前警告,并允许同步",
warning: {
missingAnswer: "第 {{index}} 组缺少答案字段,已忽略 {{questionField}} / {{answerField}}。",
@ -414,6 +419,10 @@ export const zh = {
noteFieldMappingsQaGroupWarningsStrings: "QA Group 字段映射中的 warnings 只能包含字符串。",
noteFieldMappingsQaGroupAcceptedWarningsArray: "QA Group 字段映射中的 acceptedWarnings 必须是数组。",
noteFieldMappingsQaGroupAcceptedWarningsStrings: "QA Group 字段映射中的 acceptedWarnings 只能包含字符串。",
noteFieldMappingsQaGroupDerivationObject: "QA Group 字段映射中的 derivation 必须是对象。",
noteFieldMappingsQaGroupDerivationMode: "QA Group 字段映射中的 derivation.mode 只能是 first-pair。",
noteFieldMappingsQaGroupDerivationFirstQuestionField: "QA Group 字段映射中的 derivation.firstQuestionField 必须是字符串。",
noteFieldMappingsQaGroupDerivationFirstAnswerField: "QA Group 字段映射中的 derivation.firstAnswerField 必须是字符串。",
},
noteFieldMapping: {
noteTypeNotSelected: {
@ -438,10 +447,13 @@ export const zh = {
semanticQa: "语义 QA 笔记类型 \"{{modelName}}\" 的标题字段和正文字段必须不同。",
},
stale: "笔记类型 \"{{modelName}}\" 的已保存字段映射已过期,因为这些字段在 Anki 中已不存在:{{fields}}。请重新从 Anki 读取字段。",
clozeIncompatible: "填空题笔记类型 \"{{modelName}}\" 在 Anki 中不是填空题兼容模型。",
qaGroupMissingTitle: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的已保存字段映射不完整,缺少标题字段。请在设置页重新保存字段映射。",
qaGroupNoCompleteSlots: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的已保存字段映射不完整,至少需要一组问题/答案字段。请在设置页重新保存字段映射。",
qaGroupNonContinuousSlots: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的第一组问题/答案字段不是从第 {{firstIndex}} 组开始连续编号。请从第 1 组开始。",
qaGroupSelectedFieldMissing: "问答题(多级列表)笔记模板 \"{{modelName}}\" 中已选择字段 \"{{field}}\",但它当前不在 Anki 字段列表里。请重新读取字段后再保存。",
qaGroupDuplicateFields: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的题目字段、问题字段和答案字段不能重复。",
qaGroupFirstPairNumberMismatch: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的首个问题字段 \"{{questionField}}\" 与首个答案字段 \"{{answerField}}\" 编号不一致。请选择同一组字段。",
qaGroupFirstPairMustStartAtOne: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的首组字段必须从第 1 组开始,当前是第 {{firstIndex}} 组。",
qaGroupWarningsUnaccepted: "问答题(多级列表)笔记模板 \"{{modelName}}\" 仍有未确认的字段警告。请先在设置页确认后再同步。",
qaGroupSlotCapacityExceeded: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的已保存字段映射只包含 {{capacity}} 组问题/答案字段,但当前块有 {{itemCount}} 项:{{filePath}}:{{blockStartLine}}。",
},

View file

@ -430,11 +430,10 @@ class FakePlugin {
}, {});
}
async getNoteModelDetails(modelName: string): Promise<{ fieldNames: string[]; isCloze: boolean }> {
async getNoteModelDetails(modelName: string): Promise<{ fieldNames: string[] }> {
this.getNoteModelDetailsCalls += 1;
return {
fieldNames: this.getFieldNamesForModel(modelName),
isCloze: modelName.includes("Cloze"),
};
}
@ -840,11 +839,18 @@ describe("PluginSettingTab", () => {
const qaGroupTitleField = queryByDataset(tab.containerEl, "qaGroupTitleField", "qa-group");
expect(qaGroupTitleField.value).toBe("题目");
expect(queryByDataset(tab.containerEl, "qaGroupFirstQuestionField", "qa-group").value).toBe("问题01");
expect(queryByDataset(tab.containerEl, "qaGroupFirstAnswerField", "qa-group").value).toBe("答案01");
qaGroupTitleField.value = "标题";
await qaGroupTitleField.trigger("change");
expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toBe("已识别 2 组");
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: "标题",
derivation: {
mode: "first-pair",
firstQuestionField: "问题01",
firstAnswerField: "答案01",
},
slots: [
{ index: 1, questionField: "问题01", answerField: "答案01" },
{ index: 2, questionField: "问题02", answerField: "答案02" },
@ -1263,9 +1269,8 @@ describe("PluginSettingTab", () => {
expect(plugin.getNoteModelDetailsCalls).toBe(0);
});
it("shows qa-group warnings, saves acceptance, and resets acceptance after field refresh changes warnings", async () => {
it("extends saved qa-group slots after field refresh only when derivation metadata exists", async () => {
const plugin = new FakePlugin();
plugin.fieldNamesByModelName[QA_GROUP_USER_MODEL_NAME] = ["题目", "问题01", "答案01", "问题02"];
plugin.settings = normalizePluginSettings({
...plugin.settings,
ankiNoteTypeCache: [QA_GROUP_USER_MODEL_NAME],
@ -1282,31 +1287,51 @@ describe("PluginSettingTab", () => {
noteType: QA_GROUP_USER_MODEL_NAME,
},
},
noteFieldMappings: {
...plugin.settings.noteFieldMappings,
[createNoteFieldMappingKey("qa-group", QA_GROUP_USER_MODEL_NAME)]: {
cardType: "qa-group",
modelName: QA_GROUP_USER_MODEL_NAME,
loadedFieldNames: ["题目", "问题01", "答案01", "问题02"],
titleField: "题目",
derivation: {
mode: "first-pair",
firstQuestionField: "问题01",
firstAnswerField: "答案01",
},
slots: [
{ index: 1, questionField: "问题01", answerField: "答案01" },
],
warnings: [],
loadedAt: 1,
},
},
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await expandCard(tab, "card-types");
expect(queryByDataset(tab.containerEl, "qaGroupWarning", "qa-group").textContent).toContain("第 02 组缺少答案字段");
const acceptWarning = queryByDataset(tab.containerEl, "qaGroupWarningAccept", "qa-group");
acceptWarning.checked = true;
await acceptWarning.trigger("change");
const mappingKey = createNoteFieldMappingKey("qa-group", QA_GROUP_USER_MODEL_NAME);
expect(plugin.settings.noteFieldMappings[mappingKey]).toEqual(expect.objectContaining({
acceptedWarnings: expect.any(Array),
}));
expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toBe("已配置1组");
plugin.fieldNamesByModelName[QA_GROUP_USER_MODEL_NAME] = ["题目", "问题01", "答案01", "问题02", "答案02", "问题03"];
plugin.fieldNamesByModelName[QA_GROUP_USER_MODEL_NAME] = ["题目", "问题01", "答案01", "问题02", "答案02", "问题03", "答案03"];
await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click");
await flushPromises();
expect(plugin.settings.noteFieldMappings[mappingKey]).toEqual(expect.objectContaining({
warnings: expect.any(Array),
acceptedWarnings: expect.any(Array),
derivation: {
mode: "first-pair",
firstQuestionField: "问题01",
firstAnswerField: "答案01",
},
slots: [
{ index: 1, questionField: "问题01", answerField: "答案01" },
{ index: 2, questionField: "问题02", answerField: "答案02" },
{ index: 3, questionField: "问题03", answerField: "答案03" },
],
}));
expect(queryByDataset(tab.containerEl, "qaGroupWarning", "qa-group").textContent).toContain("第 02 组缺少答案字段");
expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toBe("已配置3组");
});
it("keeps the saved qa-group title field and slots when reading Anki fields again", async () => {
@ -1359,7 +1384,9 @@ describe("PluginSettingTab", () => {
loadedFieldNames: ["题目", "问题01", "答案01", "Titre", "QPerso", "APerso"],
}));
expect(queryByDataset(tab.containerEl, "qaGroupTitleField", "qa-group").value).toBe("Titre");
expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toBe("已识别 1 组");
expect(queryByDataset(tab.containerEl, "qaGroupFirstQuestionField", "qa-group").value).toBe("QPerso");
expect(queryByDataset(tab.containerEl, "qaGroupFirstAnswerField", "qa-group").value).toBe("APerso");
expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toBe("已配置1组");
});
it("renders the scope card with renamed copy and aligned folder tree rows", async () => {

View file

@ -433,6 +433,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private renderQaGroupFieldControls(containerEl: HTMLElement, configId: Extract<CardTypeConfigId, "qa-group">): void {
const selectedModelName = this.plugin.settings.cardTypeConfigs[configId].noteType.trim();
const qaGroupState = this.getQaGroupFieldState(configId);
const selectedFields = qaGroupState.mapping ? this.getQaGroupSelectedFields(qaGroupState.mapping) : undefined;
const titleFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.qaGroupTitleField"));
if (qaGroupState.mapping) {
@ -440,23 +441,80 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
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 }));
const qaGroupPairRow = containerEl.createDiv();
qaGroupPairRow.dataset.qaGroupPairRow = configId;
qaGroupPairRow.style.gridColumn = "1 / -1";
qaGroupPairRow.style.display = "grid";
qaGroupPairRow.style.gridTemplateColumns = "minmax(0, 1fr) minmax(0, 1fr) max-content";
qaGroupPairRow.style.alignItems = "center";
qaGroupPairRow.style.columnGap = "16px";
qaGroupPairRow.style.rowGap = "8px";
qaGroupPairRow.style.width = "100%";
const firstQuestionFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstQuestionField"));
const firstQuestionFieldSelect = this.createFieldSelect(firstQuestionFieldGroup, `card-type-qa-group-first-question-field:${configId}`);
firstQuestionFieldSelect.dataset.qaGroupFirstQuestionField = configId;
this.applyFluidEllipsis(firstQuestionFieldSelect);
this.populateFieldSelect(firstQuestionFieldSelect, qaGroupState.mapping.loadedFieldNames, selectedFields?.firstQuestionField);
const firstAnswerFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstAnswerField"));
const firstAnswerFieldSelect = this.createFieldSelect(firstAnswerFieldGroup, `card-type-qa-group-first-answer-field:${configId}`);
firstAnswerFieldSelect.dataset.qaGroupFirstAnswerField = configId;
this.applyFluidEllipsis(firstAnswerFieldSelect);
this.populateFieldSelect(firstAnswerFieldSelect, qaGroupState.mapping.loadedFieldNames, selectedFields?.firstAnswerField);
const saveSelection = () => this.saveQaGroupFieldSelection(configId, {
titleField: titleFieldSelect.value || undefined,
firstQuestionField: firstQuestionFieldSelect.value || undefined,
firstAnswerField: firstAnswerFieldSelect.value || undefined,
});
titleFieldSelect.addEventListener("change", saveSelection);
firstQuestionFieldSelect.addEventListener("change", saveSelection);
firstAnswerFieldSelect.addEventListener("change", saveSelection);
const slotSummaryEl = qaGroupPairRow.createEl("span", {
text: t("settings.cards.cardTypes.qaGroup.configuredSlots", {
count: qaGroupState.mapping.slots.length,
}),
});
slotSummaryEl.dataset.qaGroupSlotSummary = configId;
slotSummaryEl.style.whiteSpace = "nowrap";
slotSummaryEl.style.justifySelf = "start";
this.applyFluidEllipsis(slotSummaryEl);
} 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"));
const slotSummaryEl = slotFieldGroup.createEl("span", {
text: qaGroupState.mapping
? t("settings.cards.cardTypes.qaGroup.detectedSlots", {
count: qaGroupState.mapping.slots.length,
})
: (selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel")),
});
slotSummaryEl.dataset.qaGroupSlotSummary = configId;
this.applyFluidEllipsis(slotSummaryEl);
const qaGroupPairRow = containerEl.createDiv();
qaGroupPairRow.dataset.qaGroupPairRow = configId;
qaGroupPairRow.style.gridColumn = "1 / -1";
qaGroupPairRow.style.display = "grid";
qaGroupPairRow.style.gridTemplateColumns = "minmax(0, 1fr) minmax(0, 1fr) max-content";
qaGroupPairRow.style.alignItems = "center";
qaGroupPairRow.style.columnGap = "16px";
qaGroupPairRow.style.rowGap = "8px";
qaGroupPairRow.style.width = "100%";
const firstQuestionFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstQuestionField"));
firstQuestionFieldGroup.createEl("span", {
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
}).dataset.qaGroupFirstQuestionField = configId;
const firstAnswerFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstAnswerField"));
firstAnswerFieldGroup.createEl("span", {
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
}).dataset.qaGroupFirstAnswerField = configId;
const slotSummaryEl = qaGroupPairRow.createEl("span", {
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
});
slotSummaryEl.dataset.qaGroupSlotSummary = configId;
slotSummaryEl.style.whiteSpace = "nowrap";
slotSummaryEl.style.justifySelf = "start";
this.applyFluidEllipsis(slotSummaryEl);
}
if (qaGroupState.error) {
const errorEl = containerEl.createDiv({
@ -1132,9 +1190,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
private createCachedModelDetails(modelName: string, fieldNames: string[]): NoteModelDetails {
void modelName;
return {
fieldNames: [...fieldNames],
isCloze: false,
};
}
@ -1207,6 +1265,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const mapping = this.draftMappings[mappingKey] ?? this.plugin.settings.noteFieldMappings[mappingKey];
if (mapping) {
if (isQaGroupFieldMapping(mapping)) {
return this.cloneQaGroupFieldMapping(mapping);
}
return {
...mapping,
loadedFieldNames: [...mapping.loadedFieldNames],
@ -1229,13 +1291,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
isQaGroupFieldMapping(currentMapping) ? currentMapping.titleField : undefined,
);
this.draftMappings[mappingKey] = suggestedMapping;
return {
...suggestedMapping,
loadedFieldNames: [...suggestedMapping.loadedFieldNames],
slots: suggestedMapping.slots.map((slot) => ({ ...slot })),
warnings: [...suggestedMapping.warnings],
acceptedWarnings: suggestedMapping.acceptedWarnings ? [...suggestedMapping.acceptedWarnings] : undefined,
};
return this.cloneQaGroupFieldMapping(suggestedMapping);
} catch {
return undefined;
}
@ -1276,6 +1332,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
slots: [],
warnings: [],
acceptedWarnings: undefined,
derivation: undefined,
loadedFieldNames: [],
loadedAt: Date.now(),
};
@ -1316,6 +1373,43 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.renderCard("card-types");
}
private async saveQaGroupFieldSelection(
configId: Extract<CardTypeConfigId, "qa-group">,
selection: { titleField?: string; firstQuestionField?: string; firstAnswerField?: string },
): Promise<void> {
const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType.trim();
if (modelName.length === 0) {
return;
}
const mappingKey = createNoteFieldMappingKey("qa-group", modelName);
const currentMapping = this.getCurrentMappingForConfig(configId);
const loadedFieldNames = currentMapping && isQaGroupFieldMapping(currentMapping)
? currentMapping.loadedFieldNames
: (this.loadedModelDetails[mappingKey]?.fieldNames ?? []);
const loadedAt = currentMapping && isQaGroupFieldMapping(currentMapping) ? currentMapping.loadedAt : Date.now();
const draftMapping = this.createQaGroupSelectionDraft(modelName, loadedFieldNames, loadedAt, selection);
this.draftMappings[mappingKey] = draftMapping;
try {
const nextMapping = this.qaGroupFieldMappingService.createMappingFromSelection(modelName, loadedFieldNames, selection, loadedAt);
this.cardTypeStatusOverride = null;
this.draftMappings[mappingKey] = nextMapping;
await this.plugin.updateSettings({
noteFieldMappings: {
...this.plugin.settings.noteFieldMappings,
[mappingKey]: nextMapping,
},
});
} catch {
this.cardTypeStatusOverride = null;
this.renderCard("card-types");
return;
}
this.renderCard("card-types");
}
private async syncQaGroupMappingFromCache(
modelName: string,
ankiModelFieldCache: AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"] = this.plugin.settings.ankiModelFieldCache,
@ -1380,14 +1474,27 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const mappingKey = createNoteFieldMappingKey("qa-group", modelName);
const currentMapping = this.draftMappings[mappingKey] ?? this.plugin.settings.noteFieldMappings[mappingKey];
if (currentMapping && isQaGroupFieldMapping(currentMapping)) {
if (currentMapping.derivation?.mode === "first-pair") {
const selectedFields = this.getQaGroupSelectedFields(currentMapping);
try {
return {
mapping: this.qaGroupFieldMappingService.createMappingFromSelection(
modelName,
currentMapping.loadedFieldNames,
selectedFields,
currentMapping.loadedAt,
),
};
} catch (error) {
return {
mapping: this.cloneQaGroupFieldMapping(currentMapping),
error: toUserFacingMessage(error, "settings.cards.cardTypes.failedSave"),
};
}
}
return {
mapping: {
...currentMapping,
loadedFieldNames: [...currentMapping.loadedFieldNames],
slots: currentMapping.slots.map((slot) => ({ ...slot })),
warnings: [...currentMapping.warnings],
acceptedWarnings: currentMapping.acceptedWarnings ? [...currentMapping.acceptedWarnings] : undefined,
},
mapping: this.cloneQaGroupFieldMapping(currentMapping),
};
}
@ -1415,12 +1522,78 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
private refreshSavedQaGroupMapping(mapping: QaGroupFieldMapping, fieldNames: string[], loadedAt: number): QaGroupFieldMapping {
if (mapping.derivation?.mode === "first-pair") {
try {
return this.qaGroupFieldMappingService.createMappingFromSelection(
mapping.modelName,
fieldNames,
this.getQaGroupSelectedFields(mapping),
loadedAt,
);
} catch {
return {
...this.cloneQaGroupFieldMapping(mapping),
loadedFieldNames: [...fieldNames],
loadedAt,
};
}
}
return {
...mapping,
loadedFieldNames: [...fieldNames],
slots: mapping.slots.map((slot) => ({ ...slot })),
warnings: [...mapping.warnings],
acceptedWarnings: mapping.acceptedWarnings ? [...mapping.acceptedWarnings] : undefined,
derivation: mapping.derivation ? { ...mapping.derivation } : undefined,
loadedAt,
};
}
private cloneQaGroupFieldMapping(mapping: QaGroupFieldMapping): QaGroupFieldMapping {
return {
...mapping,
derivation: mapping.derivation ? { ...mapping.derivation } : undefined,
loadedFieldNames: [...mapping.loadedFieldNames],
slots: mapping.slots.map((slot) => ({ ...slot })),
warnings: [...mapping.warnings],
acceptedWarnings: mapping.acceptedWarnings ? [...mapping.acceptedWarnings] : undefined,
};
}
private getQaGroupSelectedFields(mapping: QaGroupFieldMapping): { titleField?: string; firstQuestionField?: string; firstAnswerField?: string } {
return {
titleField: mapping.titleField,
firstQuestionField: mapping.derivation?.firstQuestionField ?? mapping.slots[0]?.questionField,
firstAnswerField: mapping.derivation?.firstAnswerField ?? mapping.slots[0]?.answerField,
};
}
private createQaGroupSelectionDraft(
modelName: string,
fieldNames: string[],
loadedAt: number,
selection: { titleField?: string; firstQuestionField?: string; firstAnswerField?: string },
): QaGroupFieldMapping {
const titleField = selection.titleField?.trim() || undefined;
const firstQuestionField = selection.firstQuestionField?.trim() || undefined;
const firstAnswerField = selection.firstAnswerField?.trim() || undefined;
return {
cardType: "qa-group",
modelName,
titleField,
slots: [],
warnings: [],
acceptedWarnings: undefined,
derivation: firstQuestionField || firstAnswerField
? {
mode: "first-pair",
firstQuestionField,
firstAnswerField,
}
: undefined,
loadedFieldNames: [...fieldNames],
loadedAt,
};
}

View file

@ -158,11 +158,10 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
public updatedModelStyling: Array<{ modelName: string; css: string }> = [];
public foundNoteIds = new Map<string, number[]>();
public modelDetailsByName: Record<string, NoteModelDetails> = {
Basic: { fieldNames: ["Front", "Back"], isCloze: false },
Cloze: { fieldNames: ["Text", "Extra"], isCloze: true },
Basic: { fieldNames: ["Front", "Back"] },
Cloze: { fieldNames: ["Text", "Extra"] },
[QA_GROUP_USER_NOTE_TYPE]: {
fieldNames: ["题目", "问题01", "答案01", "问题02", "答案02", "问题03", "答案03"],
isCloze: false,
},
};
public modelTemplatesByName: Record<string, Record<string, AnkiModelTemplate>> = {};
@ -188,7 +187,7 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
}
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false };
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"] };
}
async getModelFieldNames(modelName: string): Promise<string[]> {
@ -214,7 +213,6 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
this.createdModels.push(input);
this.modelDetailsByName[input.modelName] = {
fieldNames: [...input.fieldNames],
isCloze: Boolean(input.isCloze),
};
this.modelTemplatesByName[input.modelName] = Object.fromEntries(input.templates.map((template) => [template.name, { ...template }]));
this.modelStylingByName[input.modelName] = input.css;
@ -222,7 +220,7 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
async addModelField(modelName: string, fieldName: string): Promise<void> {
this.addedModelFields.push({ modelName, fieldName });
const existing = this.modelDetailsByName[modelName] ?? { fieldNames: [], isCloze: false };
const existing = this.modelDetailsByName[modelName] ?? { fieldNames: [] };
if (!existing.fieldNames.includes(fieldName)) {
existing.fieldNames.push(fieldName);
}