mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
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:
parent
b77d4b6332
commit
4ad25b28a7
16 changed files with 377 additions and 39 deletions
58
docs/cloze-all-variant-decisions.md
Normal file
58
docs/cloze-all-variant-decisions.md
Normal 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.
|
||||
71
docs/cloze-all-variant-gap-report.md
Normal file
71
docs/cloze-all-variant-gap-report.md
Normal 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
42
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}}}`;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -254,10 +254,12 @@ export const zh = {
|
|||
missingQuestion: "第 {{index}} 组缺少问题字段,已忽略 {{questionField}} / {{answerField}}。",
|
||||
},
|
||||
},
|
||||
sharedClozeMappingHint: "复用“填空题(逐个挖空)”当前选择的 Anki 笔记模板和主字段,无需单独设置。",
|
||||
rows: {
|
||||
basic: "问答题(常规段落形式)",
|
||||
qaGroup: "问答题(多级列表形式)",
|
||||
cloze: "填空题",
|
||||
cloze: "填空题(逐个挖空)",
|
||||
clozeAll: "填空题(全部挖空)",
|
||||
},
|
||||
},
|
||||
syncContent: {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue