mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
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:
parent
911949bedc
commit
8357992ee3
23 changed files with 817 additions and 209 deletions
41
docs/qa-group-mapping-parser-decisions.md
Normal file
41
docs/qa-group-mapping-parser-decisions.md
Normal 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.
|
||||
38
docs/qa-group-mapping-parser-gap-report.md
Normal file
38
docs/qa-group-mapping-parser-gap-report.md
Normal 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
52
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
export interface NoteModelDetails {
|
||||
fieldNames: string[];
|
||||
isCloze: boolean;
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }]),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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] ?? "";
|
||||
|
|
|
|||
|
|
@ -138,7 +138,6 @@ describe("AnkiConnectGateway", () => {
|
|||
|
||||
expect(details).toEqual({
|
||||
fieldNames: ["Text", "Extra"],
|
||||
isCloze: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -219,7 +219,6 @@ export class AnkiConnectGateway implements AnkiGroupGateway {
|
|||
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
return {
|
||||
fieldNames: await this.getModelFieldNames(modelName),
|
||||
isCloze: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}}。",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue