feat(cloze): add all-cloze card variant

中文: 新增填空题(全部挖空)变种,支持 #anki-cloze-all 并复用现有填空题模板与主字段。

English: Adds an all-cloze variant using #anki-cloze-all while reusing the existing Cloze note type and main field mapping.
This commit is contained in:
Dusk 2026-04-27 23:04:16 +08:00
parent b77d4b6332
commit 4ad25b28a7
16 changed files with 377 additions and 39 deletions

View file

@ -0,0 +1,58 @@
# Cloze All Variant Decisions
## Final config id
- New card type config id: `cloze-all`.
## Runtime card type decision
- Runtime `cardType` remains `cloze` for both Cloze variants.
- `cloze-all` is a recognition/config id, not a new runtime note-mapping type.
## Cloze mode shape and storage
- Add optional `clozeMode?: "sequential" | "all"` to `IndexedCard`.
- Indexing assigns:
- `sequential` for normal `cloze`
- `all` for `cloze-all`
- Missing or legacy mode is treated as `sequential`.
- No separate persisted card-state schema migration is required if the mode remains transient on indexed/render planning data and hash payload only.
## Rendering behavior
- Unnumbered clozes in sequential mode use `c1/c2/c3/...`.
- Unnumbered clozes in all mode use `c1/c1/c1/...`.
- Explicit numbering is preserved unchanged in both modes.
## Settings UI behavior
- Rename current `cloze` row label to `填空题(逐个挖空)`.
- Add `填空题(全部挖空)` row.
- `cloze-all` row shows only:
- enabled toggle
- heading level
- extra marker
- explanatory text that it reuses the same Cloze note type and main field
- `cloze-all` does not expose separate note type or field mapping controls.
## Shared mapping compatibility
- `cloze-all` reuses the same Cloze note type as `cloze`.
- `cloze-all` reuses the existing `cloze:<modelName>` mapping key.
- `getRuntimeCardType(configId)` and settings helpers are the compatibility seam for this reuse.
## Configured-count decision
- Count `cloze-all` as a separate enabled configured mode in the settings summary.
- The count is considered configured when the shared Cloze note type has cached fields available.
- This keeps the summary aligned with visible recognition modes while still reusing one mapping.
## Compatibility and migration
- Old settings missing `cloze-all` are normalized by adding the default config.
- Existing `cloze` config remains untouched.
- Existing Markdown and existing `#anki-cloze` behavior remain unchanged.
## Intentional deviation from the plan
- No new persisted plugin-state field is planned unless a later code path requires it; current repository structure supports the feature by carrying `clozeMode` on indexed/render-path data and by including it in render hashing.

View file

@ -0,0 +1,71 @@
# Cloze All Variant Gap Report
## Scope
This report records the real repository state before adding the new `cloze-all` recognition variant.
## Current Cloze recognition flow
- Card type recognition is driven by `cardTypeConfigs` in `PluginSettings.ts`.
- Current config ids are `basic`, `qa-group`, and `cloze`.
- `CardIndexingService.ts` resolves headings by:
- enabled config
- matching heading level
- matching extra marker suffix when present
- falling back to the empty-marker row only if no marker row matched
- Current Cloze recognition is the `cloze` config only.
- Current default Cloze recognition is `H4 + #anki-cloze`.
- Indexed normal cards currently only carry `cardType: "basic" | "cloze"`; there is no Cloze mode metadata yet.
## Current Cloze render and numbering flow
- `ManualCardRenderer.ts` converts unnumbered highlight or brace cloze segments into Anki native cloze markup.
- Current auto-numbering is always sequential because the renderer uses a local `nextClozeIndex` counter.
- Explicit numbering is already preserved because the renderer prefers the parsed explicit index when present.
- Current behavior already preserves:
- `{c2:内容}` as `c2`
- `{{c2::内容}}` as `c2`
- Current render config hashing is handled by `RenderConfigService.ts`.
- The render config hash does not currently include any Cloze-mode discriminator.
## Current settings table behavior
- Visible settings rows are controlled by `VISIBLE_CARD_TYPE_CONFIG_IDS` in `PluginSettingTab.ts`.
- Current visible rows are `basic`, `qa-group`, and `cloze`.
- `getRuntimeCardType(configId)` already separates config id from runtime mapping type.
- `cloze` currently shows:
- enabled toggle
- heading level
- extra marker
- note type selector
- main field selector
- Field mapping cache hydration and configured-count logic currently assume each visible config resolves its own note type.
## Exact model and type changes needed
- Add a new config id: `cloze-all`.
- Extend default card type configs and normalization so old settings missing `cloze-all` are backfilled safely.
- Keep runtime `cardType` as `cloze` for both `cloze` and `cloze-all`.
- Add optional `clozeMode` metadata to `IndexedCard`.
- Have indexing emit:
- `clozeMode: "sequential"` for `cloze`
- `clozeMode: "all"` for `cloze-all`
- Update `ManualCardRenderer` to use `clozeMode` when assigning numbers to unnumbered cloze segments.
- Update `RenderConfigService` so hash payload includes `clozeMode` with legacy fallback to `sequential`.
- Update settings-page helpers so `cloze-all` reuses the existing Cloze note type and mapping instead of introducing a new one.
## Tests likely to change
- `src/application/config/PluginSettings.test.ts`
- `src/presentation/settings/PluginSettingTab.test.ts`
- `src/domain/manual-sync/services/CardIndexingService.test.ts`
- `src/domain/manual-sync/services/ManualCardRenderer.test.ts`
- tests for render config hash or diff behavior around `RenderConfigService` / diff planning
## Risk points
- Adding a new config id affects exhaustive loops over `CARD_TYPE_CONFIG_IDS` and visible row lists.
- `cloze-all` must not create or require a second Cloze field mapping entry.
- Configured-count and cache hydration logic in the settings page must stay internally consistent when one visible row reuses another row's model and mapping.
- Marker matching must stay unambiguous when both `#anki-cloze` and `#anki-cloze-all` are enabled on the same heading level.
- Render hash must change when switching between sequential and all mode, or Anki updates will be skipped.

42
main.js

File diff suppressed because one or more lines are too long

View file

@ -102,6 +102,12 @@ describe("PluginSettings", () => {
extraMarker: "#anki-cloze",
noteType: "",
},
"cloze-all": {
enabled: true,
headingLevel: 4,
extraMarker: "#anki-cloze-all",
noteType: "",
},
});
});
@ -270,6 +276,26 @@ describe("PluginSettings", () => {
}));
});
it("backfills cloze-all and mirrors the shared cloze note type", () => {
const settings = mergePluginSettings({
cardTypeConfigs: {
basic: DEFAULT_SETTINGS.cardTypeConfigs.basic,
"qa-group": DEFAULT_SETTINGS.cardTypeConfigs["qa-group"],
cloze: {
...DEFAULT_SETTINGS.cardTypeConfigs.cloze,
noteType: "Custom Cloze",
},
} as never,
});
expect(settings.cardTypeConfigs["cloze-all"]).toEqual({
enabled: true,
headingLevel: 4,
extraMarker: "#anki-cloze-all",
noteType: "Custom Cloze",
});
});
it("rejects invalid cached Anki note type lists", () => {
expectPluginUserError(() => {
validatePluginSettings({
@ -367,6 +393,12 @@ describe("PluginSettings", () => {
extraMarker: "#anki-cloze",
noteType: "Legacy Cloze",
},
"cloze-all": {
enabled: true,
headingLevel: 4,
extraMarker: "#anki-cloze-all",
noteType: "Legacy Cloze",
},
});
});

View file

@ -8,7 +8,7 @@ export type FileDeckInsertLocation = "yaml" | "body";
export type FolderDeckMode = "off" | "folder" | "folder-and-file";
export type CardAnswerCutoffMode = "heading-block" | "double-blank-lines";
export type ObsidianBacklinkPlacement = "question-last-line" | "answer-first-line" | "answer-last-line";
export const CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze"] as const;
export const CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze", "cloze-all"] as const;
export type CardTypeConfigId = (typeof CARD_TYPE_CONFIG_IDS)[number];
export interface CardTypeConfig {
@ -36,6 +36,7 @@ const DEFAULT_BASIC_NOTE_TYPE = "";
const DEFAULT_CLOZE_NOTE_TYPE = "";
const LEGACY_DEFAULT_CLOZE_MARKER = "";
const DEFAULT_CLOZE_MARKER = "#anki-cloze";
const DEFAULT_CLOZE_ALL_MARKER = "#anki-cloze-all";
const DEFAULT_QA_GROUP_MARKER = "#anki-list";
const qaGroupFieldMappingService = new QaGroupFieldMappingService();
@ -397,6 +398,12 @@ function createDefaultCardTypeConfigs(): CardTypeConfigs {
extraMarker: DEFAULT_CLOZE_MARKER,
noteType: DEFAULT_CLOZE_NOTE_TYPE,
},
"cloze-all": {
enabled: true,
headingLevel: DEFAULT_CLOZE_HEADING_LEVEL,
extraMarker: DEFAULT_CLOZE_ALL_MARKER,
noteType: DEFAULT_CLOZE_NOTE_TYPE,
},
};
}
@ -420,6 +427,11 @@ function createLegacyBackfilledCardTypeConfigs(settings: Partial<PluginSettings>
headingLevel: sanitizeHeadingLevel(settings.clozeHeadingLevel, defaults.cloze.headingLevel),
noteType: sanitizeNoteType(settings.clozeNoteType, defaults.cloze.noteType),
},
"cloze-all": {
...defaults["cloze-all"],
headingLevel: sanitizeHeadingLevel(settings.clozeHeadingLevel, defaults["cloze-all"].headingLevel),
noteType: sanitizeNoteType(settings.clozeNoteType, defaults["cloze-all"].noteType),
},
};
}
@ -449,6 +461,11 @@ function mergeCardTypeConfigs(
: normalizedConfig;
}
nextConfigs["cloze-all"] = {
...nextConfigs["cloze-all"],
noteType: nextConfigs.cloze.noteType,
};
return nextConfigs;
}
@ -588,7 +605,7 @@ function hydrateMissingNoteFieldMappingsFromCache(settings: PluginSettings): Rec
continue;
}
const cardType = configId === "cloze" ? "cloze" : "basic";
const cardType = configId === "cloze" || configId === "cloze-all" ? "cloze" : "basic";
const mappingKey = createNoteFieldMappingKey(cardType, modelName);
if (hydratedMappings[mappingKey]) {
continue;

View file

@ -24,9 +24,13 @@ export class RenderConfigService {
const deckResolution = this.deckResolutionService.resolve(card, settings.defaultDeck, settings.folderDeckMode);
const deck = deckResolution.resolvedDeck.value;
const mapping = settings.noteFieldMappings[createNoteFieldMappingKey(card.cardType, noteModel)] ?? null;
const clozeMode = card.cardType === "cloze" && card.clozeMode === "all"
? "all"
: undefined;
const renderConfigPayload = {
version: MANUAL_RENDER_CONFIG_VERSION,
cardType: card.cardType,
clozeMode,
noteModel,
mapping,
addObsidianBacklink: settings.addObsidianBacklink,

View file

@ -2,6 +2,8 @@ import type { CardType } from "@/domain/card/entities/RenderedFields";
import type { IdMarkerState, NoteIdSource } from "@/domain/manual-sync/entities/IdMarker";
import type { DeckResolutionWarning, DeckResolutionSource } from "@/domain/manual-sync/value-objects/DeckResolution";
export type ClozeMode = "sequential" | "all";
export interface IndexedCard {
noteId?: number;
syncKey: string;
@ -9,6 +11,7 @@ export interface IndexedCard {
noteIdSource?: NoteIdSource;
filePath: string;
cardType: CardType;
clozeMode?: ClozeMode;
heading: string;
backlinkHeadingText: string;
headingLevel: number;

View file

@ -293,6 +293,9 @@ describe("CardIndexingService", () => {
"#### Cloze #anki-cloze",
"{{c1::Answer}}",
"",
"#### Cloze All #anki-cloze-all",
"{Answer}",
"",
"#### Concepts #anki-list",
"- Alpha",
" - First answer",
@ -306,7 +309,11 @@ describe("CardIndexingService", () => {
},
);
expect(indexedFile.cards.map((card) => card.cardType)).toEqual(["basic", "cloze"]);
expect(indexedFile.cards.map((card) => ({ cardType: card.cardType, clozeMode: card.clozeMode }))).toEqual([
{ cardType: "basic", clozeMode: undefined },
{ cardType: "cloze", clozeMode: "sequential" },
{ cardType: "cloze", clozeMode: "all" },
]);
expect(indexedFile.groupBlocks?.[0]).toMatchObject({
headingText: "Concepts #anki-list",
headingLevel: 4,
@ -346,6 +353,43 @@ describe("CardIndexingService", () => {
expect(indexedFile.groupBlocks ?? []).toHaveLength(0);
});
it("does not produce cloze-all cards when the cloze-all config is disabled", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
path: "notes/example.md",
basename: "example",
content: [
"#### Cloze All #anki-cloze-all",
"{Answer}",
].join("\n"),
},
{
cardTypeConfigs: {
...DEFAULT_SETTINGS.cardTypeConfigs,
basic: {
...DEFAULT_SETTINGS.cardTypeConfigs.basic,
enabled: false,
},
cloze: {
...DEFAULT_SETTINGS.cardTypeConfigs.cloze,
enabled: false,
},
"cloze-all": {
...DEFAULT_SETTINGS.cardTypeConfigs["cloze-all"],
enabled: false,
},
},
fileStamp: "1:1",
knownCards: [],
pendingWriteBack: [],
},
);
expect(indexedFile.cards).toHaveLength(0);
expect(indexedFile.groupBlocks ?? []).toHaveLength(0);
});
it("indexes a #anki-list heading as one QA Group block and parses its GI marker", () => {
const service = new CardIndexingService();
const indexedFile = service.index(

View file

@ -44,7 +44,7 @@ export interface CardIndexingContext {
}
const HEADING_REGEXP = /^(#{1,6})\s+(.*?)\s*$/;
const CARD_TYPE_MATCH_ORDER: CardTypeConfigId[] = ["qa-group", "cloze", "basic"];
const CARD_TYPE_MATCH_ORDER: CardTypeConfigId[] = ["qa-group", "cloze-all", "cloze", "basic"];
export class CardIndexingService {
constructor(
@ -144,7 +144,11 @@ export class CardIndexingService {
continue;
}
const cardType = matchedConfig.configId === "cloze" ? "cloze" : "basic";
const isClozeCard = matchedConfig.configId === "cloze" || matchedConfig.configId === "cloze-all";
const cardType = isClozeCard ? "cloze" : "basic";
const clozeMode = matchedConfig.configId === "cloze-all"
? "all"
: (matchedConfig.configId === "cloze" ? "sequential" : undefined);
const boundary = resolveAnswerBoundary({
lines: bodyLines,
@ -178,6 +182,7 @@ export class CardIndexingService {
noteIdSource: resolvedIdentity.noteIdSource,
filePath: sourceFile.path,
cardType,
clozeMode,
heading: heading.text,
backlinkHeadingText: heading.text,
headingLevel: heading.level,
@ -330,6 +335,10 @@ function resolveCardTypeConfigs(context: CardIndexingContext): CardTypeConfigs {
...DEFAULT_SETTINGS.cardTypeConfigs.cloze,
headingLevel: context.clozeHeadingLevel ?? DEFAULT_SETTINGS.cardTypeConfigs.cloze.headingLevel,
},
"cloze-all": {
...DEFAULT_SETTINGS.cardTypeConfigs["cloze-all"],
headingLevel: context.clozeHeadingLevel ?? DEFAULT_SETTINGS.cardTypeConfigs["cloze-all"].headingLevel,
},
};
}

View file

@ -258,6 +258,47 @@ describe("DiffPlannerService", () => {
expect(plan.toUpdate).toHaveLength(1);
});
it("schedules an update when only the cloze mode changes", () => {
const service = new DiffPlannerService();
const settings = createModule3Settings();
const sequentialCard = createIndexedCard({
noteId: 42,
cardType: "cloze",
clozeMode: "sequential",
rawBlockHash: "same-hash",
idMarkerState: "present-valid",
noteIdSource: "marker",
});
const oldRenderPlan = new RenderConfigService().resolve(sequentialCard, settings);
const state = {
files: {},
cards: {
"42": createCardState({
noteId: 42,
cardType: "cloze",
rawBlockHash: "same-hash",
renderConfigHash: oldRenderPlan.renderConfigHash,
deck: oldRenderPlan.deck,
}),
},
pendingWriteBack: [],
};
const plan = service.plan([
createIndexedCard({
noteId: 42,
cardType: "cloze",
clozeMode: "all",
rawBlockHash: "same-hash",
idMarkerState: "present-valid",
noteIdSource: "marker",
}),
], state, ["notes/example.md"], settings);
expect(plan.toUpdate).toHaveLength(1);
expect(plan.toChangeDeck).toHaveLength(0);
});
});
function createIndexedCard(overrides: Partial<IndexedCard> = {}): IndexedCard {
@ -270,6 +311,7 @@ function createIndexedCard(overrides: Partial<IndexedCard> = {}): IndexedCard {
noteIdSource: overrides.noteIdSource,
filePath: overrides.filePath ?? "notes/example.md",
cardType: overrides.cardType ?? "basic",
clozeMode: overrides.clozeMode,
heading: overrides.heading ?? "Prompt",
backlinkHeadingText: overrides.backlinkHeadingText ?? (overrides.heading ?? "Prompt"),
headingLevel: overrides.headingLevel ?? 4,

View file

@ -128,6 +128,38 @@ describe("ManualCardRenderer", () => {
expect(rendered.renderedFields.body).not.toContain('anki-heading-sync-backlink');
});
it("numbers unnumbered clozes sequentially for the sequential variant", () => {
const rendered = renderer.render(createPlannedCard({
cardType: "cloze",
clozeMode: "sequential",
bodyMarkdown: "{甲} {乙} {丙}",
}), baseContext);
expect(rendered.renderedFields.body).toContain("{{c1::甲}} {{c2::乙}} {{c3::丙}}");
});
it("numbers unnumbered clozes as c1 for the all-cloze variant", () => {
const rendered = renderer.render(createPlannedCard({
cardType: "cloze",
clozeMode: "all",
bodyMarkdown: "{甲} {乙} {丙}",
}), baseContext);
expect(rendered.renderedFields.body).toContain("{{c1::甲}} {{c1::乙}} {{c1::丙}}");
});
it("preserves handwritten and native cloze numbers while auto-filling the remaining clozes", () => {
const rendered = renderer.render(createPlannedCard({
cardType: "cloze",
clozeMode: "all",
bodyMarkdown: "{c2:手写} {自动} {{c3::原生}}",
}), baseContext);
expect(rendered.renderedFields.body).toContain("{{c2::手写}}");
expect(rendered.renderedFields.body).toContain("{{c1::自动}}");
expect(rendered.renderedFields.body).toContain("{{c3::原生}}");
});
it("does not render backlinks when the toggle is disabled", () => {
const rendered = renderer.render(createPlannedCard(), {
...baseContext,
@ -150,6 +182,7 @@ function createPlannedCard(overrides: Partial<IndexedCard> = {}): PlannedCard {
noteIdSource: overrides.noteIdSource,
filePath: overrides.filePath ?? "notes/example.md",
cardType: overrides.cardType ?? "basic",
clozeMode: overrides.clozeMode,
heading: overrides.heading ?? "标题",
backlinkHeadingText: overrides.backlinkHeadingText ?? overrides.heading ?? "标题",
headingLevel: overrides.headingLevel ?? 2,

View file

@ -114,7 +114,9 @@ export class ManualCardRenderer {
if (cloze) {
transformed = transformed.replace(CLOZE_PATTERN, (_match, explicitIndex: string | undefined, content: string) => {
const clozeIndex = explicitIndex ? Number(explicitIndex) : nextClozeIndex++;
const clozeIndex = explicitIndex
? Number(explicitIndex)
: (plannedCard.card.clozeMode === "all" ? 1 : nextClozeIndex++);
return `{{c${clozeIndex}::${content}}}`;
});
}

View file

@ -256,10 +256,12 @@ export const en = {
missingQuestion: "Slot {{index}} is missing its question field, so {{questionField}} / {{answerField}} was ignored.",
},
},
sharedClozeMappingHint: "Reuse the current Anki note type and main field from the sequential cloze row. No separate setup is needed.",
rows: {
basic: "Q&A (regular paragraph)",
qaGroup: "Q&A (nested list)",
cloze: "Cloze",
cloze: "Cloze (sequential deletions)",
clozeAll: "Cloze (all deletions)",
},
},
syncContent: {

View file

@ -254,10 +254,12 @@ export const zh = {
missingQuestion: "第 {{index}} 组缺少问题字段,已忽略 {{questionField}} / {{answerField}}。",
},
},
sharedClozeMappingHint: "复用“填空题(逐个挖空)”当前选择的 Anki 笔记模板和主字段,无需单独设置。",
rows: {
basic: "问答题(常规段落形式)",
qaGroup: "问答题(多级列表形式)",
cloze: "填空题",
cloze: "填空题(逐个挖空)",
clozeAll: "填空题(全部挖空)",
},
},
syncContent: {

View file

@ -774,7 +774,7 @@ describe("PluginSettingTab", () => {
expect(resizeObserver?.disconnected).toBe(true);
});
it("renders card 1 as three readable card type blocks", async () => {
it("renders card 1 as four readable card type blocks", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
@ -786,12 +786,17 @@ describe("PluginSettingTab", () => {
expect(() => findSetting(tab.containerEl, "AnkiConnect URL")).toThrow("Setting not found");
const rowLabels = findElements(tab.containerEl, (element) => element.dataset.cardTypeConfig !== undefined)
.map((row) => collectTexts(row).find((text) => text.includes("")));
.map((row) => collectTexts(row).find((text) => text.includes("")));
expect(rowLabels).toEqual([
"问答题(常规段落形式)",
"问答题(多级列表形式)",
"填空题",
"填空题(逐个挖空)",
"填空题(全部挖空)",
]);
expect(queryByDataset(tab.containerEl, "cardTypeSharedHint", "cloze-all").textContent).toContain("复用“填空题(逐个挖空)”当前选择的 Anki 笔记模板和主字段");
expect(() => queryByDataset(tab.containerEl, "cardTypeNoteType", "cloze-all")).toThrow();
expect(() => queryByDataset(tab.containerEl, "cardTypeQuestionField", "cloze-all")).toThrow();
});
it("does not render the legacy card-types description copy", async () => {
@ -1084,7 +1089,7 @@ describe("PluginSettingTab", () => {
expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(1);
expect(plugin.getNoteModelDetailsCalls).toBe(0);
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
expect(collectTexts(tab.containerEl).some((text) => text.includes("已获取 5 个笔记模板,已选择并配置 2 个卡片模式。"))).toBe(true);
expect(collectTexts(tab.containerEl).some((text) => text.includes("已获取 5 个笔记模板,已选择并配置 3 个卡片模式。"))).toBe(true);
});
it("persists loaded Anki note types and uses the cache before the next manual refresh", async () => {
@ -1265,7 +1270,7 @@ describe("PluginSettingTab", () => {
expect(plugin.listNoteModelsCalls).toBe(2);
expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(1);
expect(collectTexts(tab.containerEl).some((text) => text.includes("已获取 3 个笔记模板,已选择并配置 2 个卡片模式。"))).toBe(true);
expect(collectTexts(tab.containerEl).some((text) => text.includes("已获取 3 个笔记模板,已选择并配置 3 个卡片模式。"))).toBe(true);
expect(collectTexts(tab.containerEl).some((text) => text.includes("和本页缓存的 2 个模板不一致"))).toBe(false);
});
@ -1314,13 +1319,13 @@ describe("PluginSettingTab", () => {
await expandCard(tab, "card-types");
await flushPromises();
expect(collectTexts(tab.containerEl).some((text) => text.includes("当前使用缓存的 3 个笔记模板,已选择并配置 2 个卡片模式。"))).toBe(true);
expect(collectTexts(tab.containerEl).some((text) => text.includes("当前使用缓存的 3 个笔记模板,已选择并配置 3 个卡片模式。"))).toBe(true);
const clozeToggle = queryByDataset(tab.containerEl, "cardTypeEnabled", "cloze");
clozeToggle.checked = true;
await clozeToggle.trigger("change");
expect(collectTexts(tab.containerEl).some((text) => text.includes("当前使用缓存的 3 个笔记模板,已选择并配置 3 个卡片模式。"))).toBe(true);
expect(collectTexts(tab.containerEl).some((text) => text.includes("当前使用缓存的 3 个笔记模板,已选择并配置 4 个卡片模式。"))).toBe(true);
});
it("excludes enabled card types without cached fields from the configured count", async () => {

View file

@ -36,7 +36,7 @@ import { buildFolderTreeSelection, toggleFolderTreeSelection, type FolderTreeSel
const FOLDER_TREE_STATUS_LOADING: UserFacingMessage = { key: "settings.scope.loading" };
const TEXT_SAVE_DEBOUNCE_MS = 500;
const SETTINGS_CARD_ORDER = ["card-types", "sync-content", "deck", "scope", "commands"] as const;
const VISIBLE_CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze"] as const;
const VISIBLE_CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze", "cloze-all"] as const;
const SETTINGS_STICKY_CARD_GAP_PX = 8;
const SETTINGS_PAGE_HEADER_FALLBACK_HEIGHT_PX = 64;
const SETTINGS_PAGE_HEADER_BACKGROUND = "var(--modal-background, var(--background-primary))";
@ -385,6 +385,16 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
if (configId === "cloze-all") {
const sharedHintEl = blockEl.createEl("p", {
text: t("settings.cards.cardTypes.sharedClozeMappingHint"),
});
sharedHintEl.dataset.cardTypeSharedHint = configId;
sharedHintEl.style.margin = "0";
sharedHintEl.style.color = "var(--text-muted)";
return;
}
const ankiRow = blockEl.createDiv();
ankiRow.style.display = "grid";
ankiRow.style.gridTemplateColumns = "minmax(0, 1.1fr) minmax(0, 0.75fr) minmax(220px, 0.95fr)";
@ -1168,7 +1178,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
ankiModelFieldCache: AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"] = this.plugin.settings.ankiModelFieldCache,
): Promise<void> {
for (const configId of VISIBLE_CARD_TYPE_CONFIG_IDS) {
if (configId === "qa-group") {
if (configId === "qa-group" || configId === "cloze-all") {
continue;
}
@ -1646,7 +1656,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
return "qa-group";
}
return configId === "cloze" ? "cloze" : "basic";
return configId === "cloze" || configId === "cloze-all" ? "cloze" : "basic";
}
private ensureFolderTreeLoaded(forceReload = false): void {
@ -1962,6 +1972,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
return t("settings.cards.cardTypes.rows.cloze");
}
if (configId === "cloze-all") {
return t("settings.cards.cardTypes.rows.clozeAll");
}
return t("settings.cards.cardTypes.rows.qaGroup");
}
}