Finalize pre-release cleanup

This commit is contained in:
Dusk 2026-04-27 19:54:49 +08:00
parent 771cf9c0cb
commit a9033664b8
36 changed files with 677 additions and 1194 deletions

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dusk
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

35
README.md Normal file
View file

@ -0,0 +1,35 @@
# Anki Heading Sync
Anki Heading Sync is an Obsidian desktop plugin for syncing Markdown heading blocks into Anki with a focused, file-first workflow.
## Features
- Sync basic Q&A cards from heading blocks
- Sync cloze cards from heading blocks with cloze-specific recognition
- Sync QA Group list blocks into managed group notes
- Sync the current file or all in-scope files in the vault
- Write Anki IDs and GI markers back into Markdown after sync
- Support deck routing, Obsidian backlinks, tag sync, and current-file reset
## Requirements
- Obsidian desktop 1.5.0 or newer
- Anki with AnkiConnect enabled
## Commands
- Sync current file to Anki
- Sync vault to Anki
- Clear synced cards in current file
- Clean up empty decks
## Development
```bash
npm install
npm run lint
npm run test
npm run build
```
Release assets should include `main.js`, `manifest.json`, and `styles.css`.

View file

@ -0,0 +1,87 @@
# Pre-release Cleanup Decisions
## Scope behavior
- Keep defaults unchanged:
- `scopeMode: "include"`
- `includeFolders: []`
- New runtime rule:
- if `scopeMode === "include"` and no folders are selected, both current-file sync and vault sync are blocked.
- User-facing message direction:
- use explicit “run scope is not configured” messaging instead of generic out-of-scope or silent zero-file success.
- Settings page behavior:
- show a visible warning inside the scope card when include mode is selected and no folder is checked.
## Semantic QA deletion scope
- Remove Semantic QA from:
- runtime card-type unions
- card-type config defaults and validation
- legacy settings fields
- field mapping types and validation branches
- render-config resolution
- card indexing match order and parser usage
- parser source and parser tests
- i18n messages
- persistence tests that assert Semantic QA retention
- settings tests and fake note-model fixtures
- Do not add any special migration UI.
- Old unknown Semantic QA fields in existing data are allowed to drop naturally through current normalization.
## Rebuild-index deletion scope
- Remove rebuild from:
- plugin wiring
- use case file
- service method
- notice summary method
- i18n labels and failure text
- tests asserting rebuild behavior
- Keep normal indexing services and diff planning intact because they are still used by ordinary sync.
- Retain only non-user-facing words like “rebuild” if they are unrelated to the removed command path; otherwise delete them.
## Writeback migration strategy
- Migrate `ObsidianVaultGateway.replaceMarkdownFile()` from:
- `cachedRead + modify`
- To:
- `vault.process(file, fn)`
- Conflict policy:
- perform expected-content comparison inside the `process` callback.
- throw the same conflict error when current content differs.
- Missing file policy:
- preserve current `MarkdownFileNotFoundError` behavior before entering process.
- No-op writes:
- return the current content unchanged if `nextContent === currentContent`.
## Settings debounce flush strategy
- Keep the existing 500ms debounce while the page is open.
- Replace timer-only cleanup on hide with a flush path:
- cancel timers
- immediately execute the last queued save action for each pending text field
- await or safely chain those saves before teardown completes
- Prefer a centralized pending-save registry instead of field-specific hide logic.
## Release materials and metadata
- Add root `README.md` for future Obsidian community plugin submission readiness.
- Add root `LICENSE`.
- License choice:
- use MIT because `package.json` already declares MIT and there is no repository evidence of a conflicting license strategy.
- Update `manifest.json`:
- `author: "Dusk"`
- `authorUrl: "https://github.com/panAtGitHub/AnkiHeadingSync"`
- Keep `isDesktopOnly: true`.
- Pin `devDependencies.obsidian` to `1.12.3` and update the lockfile.
## Intentionally retained references
- Historical audit documents under `docs/` that mention Semantic QA or rebuild are retained.
- Packaging behavior continues to copy only built plugin files into the Obsidian plugin directory.
- `versions.json` stays in staged package output because the current build pipeline already expects it, but sync-to-vault remains limited to runtime files only.
## Planned deviation handling
- If repository reality requires a narrower deletion than the attached plan, prefer preserving active basic/cloze/qa-group behavior over aggressive cleanup.
- At review time, no deviation is required yet; the current repository structure matches the plan closely enough to implement directly.

View file

@ -0,0 +1,170 @@
# Pre-release Cleanup Gap Report
## Scope
This report is based on a real repository inspection on 2026-04-27. It records the currently active runtime path and the exact cleanup points needed for the pre-release stability plan.
## Confirmed active sync path
- Runtime entry is `main.ts` -> `src/presentation/AnkiHeadingSyncPlugin.ts`.
- `AnkiHeadingSyncPlugin.onload()` creates:
- `DataJsonPluginConfigRepository`
- `DataJsonPluginStateRepository`
- `ObsidianVaultGateway`
- `AnkiConnectGateway`
- `ManualSyncCurrentFileUseCase`
- `ManualSyncVaultUseCase`
- `RebuildCardIndexUseCase`
- `ClearCurrentFileSyncedCardsUseCase`
- `CleanupEmptyDecksUseCase`
- Registered active commands are currently only:
- sync current file
- sync vault
- clear current file synced cards
- cleanup empty decks
- Sync execution path is:
- plugin command callback
- `ManualSyncCurrentFileUseCase` / `ManualSyncVaultUseCase`
- `ManualSyncService`
- `FileIndexerService`
- `CardIndexingService`
- `DiffPlannerService`
- `AnkiBatchExecutor`
- `QaGroupSyncService`
- `MarkdownWriteBackService`
- persisted plugin state
## Confirmed Semantic QA references
### Active
- `src/application/config/PluginSettings.ts`
- `CARD_TYPE_CONFIG_IDS` still includes `semantic-qa`.
- `PluginSettings` still contains `semanticQaMarker` and `semanticQaNoteType`.
- defaults, merge logic, validation, and legacy field derivation still include Semantic QA.
- `src/application/config/NoteModelFieldMapping.ts`
- basic-like mapping still includes `semantic-qa`.
- `src/application/services/NoteFieldMappingService.ts`
- missing/incomplete/title-body validation still includes Semantic QA branches.
- `src/application/services/RenderConfigService.ts`
- runtime note-model resolution still has a `semantic-qa` branch.
- `src/domain/card/entities/RenderedFields.ts`
- `CardType` still includes `semantic-qa`.
- `src/domain/manual-sync/services/CardIndexingService.ts`
- match order still includes `semantic-qa`.
- parser injection and runtime indexing branch are active.
- `src/domain/manual-sync/services/SemanticQaListParser.ts`
- real active parser used by `CardIndexingService`.
- `src/presentation/i18n/messages/en.ts` and `src/presentation/i18n/messages/zh.ts`
- settings labels, validation errors, note-field-mapping errors, and row labels still include Semantic QA.
### UI-visible but intentionally hidden in one place only
- `src/presentation/settings/PluginSettingTab.ts`
- visible card blocks are limited to basic, qa-group, cloze.
- however helper methods and labels still retain `semantic-qa` support branches.
### Tests tied to Semantic QA
- `src/domain/manual-sync/services/SemanticQaListParser.test.ts`
- `src/domain/manual-sync/services/CardIndexingService.test.ts`
- `src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts`
- `src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts`
- `src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts`
- `src/application/config/PluginSettings.test.ts`
- `src/presentation/settings/PluginSettingTab.test.ts`
## Confirmed rebuild-index references
### Active runtime wiring
- `src/presentation/AnkiHeadingSyncPlugin.ts`
- imports and constructs `RebuildCardIndexUseCase`.
- exposes `runRebuildCardIndex()`.
- `src/application/use-cases/RebuildCardIndexUseCase.ts`
- thin wrapper over `ManualSyncService.rebuildIndex()`.
- `src/application/services/ManualSyncService.ts`
- still implements `rebuildIndex()`.
- `src/presentation/notices/NoticeService.ts`
- still implements `showRebuildSummary()`.
- `src/presentation/i18n/messages/en.ts` and `src/presentation/i18n/messages/zh.ts`
- still include rebuild labels, failure text, and rebuild summary copy.
### Not user-exposed as a registered command
- `src/presentation/commands/registerCommands.ts`
- rebuild is not registered.
- `src/presentation/settings/PluginSettingTab.test.ts`
- already asserts the command guide does not show rebuild.
### Tests tied to rebuild path
- `src/application/services/ManualSyncService.test.ts`
- `src/presentation/notices/NoticeService.test.ts`
- `src/presentation/commands/registerCommands.test.ts`
## Current empty-include behavior
- Default settings are still:
- `scopeMode: "include"`
- `includeFolders: []`
- `ScanScopeService.isPathInScope()` returns `false` for include mode when the normalized include list is empty.
- `ManualSyncService.syncFile()` uses `isPathInScope()` and throws `CurrentFileOutOfScopeError`.
- `ManualSyncService.syncVault()` has no dedicated empty-include guard.
- `FileIndexerService.indexVault()` therefore receives an empty ref list and returns:
- `scannedFiles: 0`
- empty cards/group blocks
- Current behavior gap:
- current-file sync reports a generic out-of-scope message
- vault sync can silently scan zero files and produce a normal-looking success summary
- settings page currently shows no explicit warning for include mode with zero selected folders
## Current Markdown writeback implementation
- `src/infrastructure/obsidian/ObsidianVaultGateway.ts :: replaceMarkdownFile()` currently does:
- `getAbstractFileByPath`
- `vault.cachedRead(file)`
- compares against `expectedContent`
- `vault.modify(file, nextContent)`
- Conflict detection exists, but it happens outside an atomic `vault.process(file, fn)` callback.
- Existing writeback semantics depend on this gateway for:
- ID marker writes/removals
- GI marker writes/removals
- deck template insertion
## Current debounce-save behavior
- `src/presentation/settings/PluginSettingTab.ts`
- text inputs use `scheduleDebouncedTextSave()` with a 500ms timeout.
- `hide()` currently calls `clearDebouncedTextSaves()`.
- Current behavior gap:
- hide tears down pending timers
- hide does not flush pending draft values
- closing the settings tab within the debounce window can drop the last edit
## Release metadata and packaging gaps
- Root `README.md` is missing.
- Root `LICENSE` is missing.
- `manifest.json` still has author metadata set to GitHub Copilot / GitHub Copilot URL.
- `package.json` still pins `devDependencies.obsidian` to `latest` instead of `1.12.3`.
- build output is correct for release safety:
- esbuild writes `dist/plugin/main.js` and copies it to root `main.js`
- staging copies `manifest.json`, `versions.json`, and optional `styles.css` into `dist/plugin`
- sync script copies only `main.js`, `manifest.json`, and `styles.css` to the Obsidian plugin directory
- sync script does not overwrite `data.json`
## Risky deletion points
- `PluginSettings.ts`
- removing Semantic QA touches defaults, type unions, validation, merge logic, legacy derivation, and hydration.
- `CardIndexingService.ts`
- Semantic QA is in the active card-type resolution order, so deletion must not disturb basic/cloze/qa-group matching.
- `RenderedFields.ts`, `NoteModelFieldMapping.ts`, `NoteFieldMappingService.ts`, `RenderConfigService.ts`
- all currently assume Semantic QA is a valid runtime card type.
- `ManualSyncService.ts`
- rebuild removal must not break current-file or vault sync orchestration.
- `NoticeService.ts` and i18n files
- rebuild and Semantic QA copy is spread through multiple translation branches.
- `PluginSettingTab.ts`
- debounced text saves are centralized but currently not flushable; hide behavior must be changed carefully to avoid double saves.

54
main.js

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,7 @@
"version": "1.0.0",
"minAppVersion": "1.5.0",
"description": "Focused heading-based Obsidian to Anki sync plugin.",
"author": "GitHub Copilot",
"authorUrl": "https://github.com/github/copilot",
"author": "Dusk",
"authorUrl": "https://github.com/panAtGitHub",
"isDesktopOnly": true
}

6
package-lock.json generated
View file

@ -9,19 +9,19 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"markdown-it": "^14.1.1"
"markdown-it": "^14.1.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.19.17",
"@types/node": "^22.15.3",
"@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1",
"builtin-modules": "^4.0.0",
"esbuild": "^0.25.3",
"eslint": "^9.39.1",
"globals": "^16.4.0",
"obsidian": "latest",
"obsidian": "1.12.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.46.1",
"vitest": "^3.2.4"

View file

@ -2,6 +2,7 @@
"name": "obsidian-anki-heading-sync",
"version": "1.0.0",
"description": "Focused Obsidian to Anki heading-based sync plugin.",
"author": "Dusk",
"main": "dist/plugin/main.js",
"dependencies": {
"markdown-it": "^14.1.0"
@ -21,6 +22,7 @@
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.15.3",
"@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1",
@ -28,7 +30,7 @@
"esbuild": "^0.25.3",
"eslint": "^9.39.1",
"globals": "^16.4.0",
"obsidian": "latest",
"obsidian": "1.12.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.46.1",
"vitest": "^3.2.4"

View file

@ -10,7 +10,7 @@ interface BaseNoteModelFieldMapping {
}
export interface BasicLikeNoteModelFieldMapping extends BaseNoteModelFieldMapping {
cardType: Extract<NoteModelFieldMappingCardType, "basic" | "semantic-qa">;
cardType: Extract<NoteModelFieldMappingCardType, "basic">;
titleField?: string;
bodyField?: string;
}
@ -48,7 +48,7 @@ export function createNoteFieldMappingKey(cardType: NoteModelFieldMappingCardTyp
}
export function isBasicLikeNoteModelFieldMapping(mapping: NoteModelFieldMapping | undefined): mapping is BasicLikeNoteModelFieldMapping {
return mapping?.cardType === "basic" || mapping?.cardType === "semantic-qa";
return mapping?.cardType === "basic";
}
export function isClozeNoteModelFieldMapping(mapping: NoteModelFieldMapping | undefined): mapping is ClozeNoteModelFieldMapping {

View file

@ -102,12 +102,6 @@ describe("PluginSettings", () => {
extraMarker: "#anki-cloze",
noteType: "",
},
"semantic-qa": {
enabled: true,
headingLevel: 4,
extraMarker: "#anki-list-qa",
noteType: "Semantic QA",
},
});
});
@ -373,12 +367,6 @@ describe("PluginSettings", () => {
extraMarker: "#anki-cloze",
noteType: "Legacy Cloze",
},
"semantic-qa": {
enabled: true,
headingLevel: 4,
extraMarker: "#anki-list-qa",
noteType: "Semantic QA",
},
});
});
@ -420,7 +408,7 @@ describe("PluginSettings", () => {
}, "errors.settings.keepPureTagLinesInCardBodyBoolean");
});
it("rejects invalid QA Group markers and semantic marker collisions", () => {
it("accepts custom QA Group markers during validation", () => {
expect(() => validatePluginSettings({
...DEFAULT_SETTINGS,
cardTypeConfigs: {

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", "semantic-qa"] as const;
export const CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze"] as const;
export type CardTypeConfigId = (typeof CARD_TYPE_CONFIG_IDS)[number];
export interface CardTypeConfig {
@ -36,9 +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_SEMANTIC_QA_NOTE_TYPE = "Semantic QA";
const DEFAULT_QA_GROUP_MARKER = "#anki-list";
const DEFAULT_SEMANTIC_QA_MARKER = "#anki-list-qa";
const qaGroupFieldMappingService = new QaGroupFieldMappingService();
export interface PluginSettings {
@ -48,8 +46,6 @@ export interface PluginSettings {
qaGroupMarker: string;
qaNoteType: string;
clozeNoteType: string;
semanticQaMarker: string;
semanticQaNoteType: string;
cardTypeConfigs: CardTypeConfigs;
noteFieldMappings: Record<string, NoteModelFieldMapping>;
ankiNoteTypeCache: string[];
@ -79,8 +75,6 @@ export const DEFAULT_SETTINGS: PluginSettings = {
qaGroupMarker: DEFAULT_QA_GROUP_MARKER,
qaNoteType: DEFAULT_BASIC_NOTE_TYPE,
clozeNoteType: DEFAULT_CLOZE_NOTE_TYPE,
semanticQaMarker: DEFAULT_SEMANTIC_QA_MARKER,
semanticQaNoteType: DEFAULT_SEMANTIC_QA_NOTE_TYPE,
cardTypeConfigs: createDefaultCardTypeConfigs(),
noteFieldMappings: {},
ankiNoteTypeCache: [],
@ -103,14 +97,10 @@ export const DEFAULT_SETTINGS: PluginSettings = {
ankiConnectUrl: "http://127.0.0.1:8765",
};
export const SEMANTIC_QA_MARKER_REGEXP = /^#[^\s#]+$/;
export const HASHTAG_MARKER_REGEXP = /^#[^\s#]+$/;
export function isValidHashtagMarker(marker: string): boolean {
return SEMANTIC_QA_MARKER_REGEXP.test(marker);
}
export function isValidSemanticQaMarker(marker: string): boolean {
return isValidHashtagMarker(marker);
return HASHTAG_MARKER_REGEXP.test(marker);
}
export function normalizeObsidianBacklinkLabel(value: string | null | undefined): string {
@ -219,6 +209,14 @@ export function validatePluginSettings(settings: PluginSettings): void {
}
}
export function isRunScopeConfigured(scopeMode: ScopeMode, includeFolders: string[]): boolean {
if (scopeMode !== "include") {
return true;
}
return includeFolders.some((folder) => folder.trim().length > 0);
}
function validateAnkiNoteTypeCache(ankiNoteTypeCache: string[]): void {
if (!Array.isArray(ankiNoteTypeCache)) {
throw new PluginUserError("errors.settings.ankiNoteTypeCacheArray");
@ -294,10 +292,6 @@ export function validateCardTypeConfigs(cardTypeConfigs: CardTypeConfigs): void
throw new PluginUserError("errors.settings.cardTypeExtraMarkerString");
}
if (configId === "semantic-qa" && !config.noteType.trim()) {
throw new PluginUserError("errors.settings.semanticQaNoteTypeRequired");
}
if (!config.enabled || config.extraMarker.trim().length > 0) {
continue;
}
@ -320,7 +314,7 @@ function validateNoteFieldMappings(noteFieldMappings: Record<string, NoteModelFi
}
for (const mapping of Object.values(noteFieldMappings)) {
if (mapping.cardType !== "basic" && mapping.cardType !== "qa-group" && mapping.cardType !== "cloze" && mapping.cardType !== "semantic-qa") {
if (mapping.cardType !== "basic" && mapping.cardType !== "qa-group" && mapping.cardType !== "cloze") {
throw new PluginUserError("errors.settings.noteFieldMappingsCardType");
}
@ -403,12 +397,6 @@ function createDefaultCardTypeConfigs(): CardTypeConfigs {
extraMarker: DEFAULT_CLOZE_MARKER,
noteType: DEFAULT_CLOZE_NOTE_TYPE,
},
"semantic-qa": {
enabled: true,
headingLevel: DEFAULT_BASIC_HEADING_LEVEL,
extraMarker: DEFAULT_SEMANTIC_QA_MARKER,
noteType: DEFAULT_SEMANTIC_QA_NOTE_TYPE,
},
};
}
@ -432,12 +420,6 @@ function createLegacyBackfilledCardTypeConfigs(settings: Partial<PluginSettings>
headingLevel: sanitizeHeadingLevel(settings.clozeHeadingLevel, defaults.cloze.headingLevel),
noteType: sanitizeNoteType(settings.clozeNoteType, defaults.cloze.noteType),
},
"semantic-qa": {
...defaults["semantic-qa"],
headingLevel: sanitizeHeadingLevel(settings.qaHeadingLevel, defaults["semantic-qa"].headingLevel),
extraMarker: sanitizeMarker(settings.semanticQaMarker, defaults["semantic-qa"].extraMarker),
noteType: sanitizeNoteType(settings.semanticQaNoteType, defaults["semantic-qa"].noteType),
},
};
}
@ -502,15 +484,13 @@ function hasExplicitClozeRecognitionConfig(rawConfig: Partial<CardTypeConfig> |
return "headingLevel" in rawConfig || "extraMarker" in rawConfig;
}
function deriveLegacySettings(cardTypeConfigs: CardTypeConfigs): Pick<PluginSettings, "qaHeadingLevel" | "clozeHeadingLevel" | "qaGroupMarker" | "qaNoteType" | "clozeNoteType" | "semanticQaMarker" | "semanticQaNoteType"> {
function deriveLegacySettings(cardTypeConfigs: CardTypeConfigs): Pick<PluginSettings, "qaHeadingLevel" | "clozeHeadingLevel" | "qaGroupMarker" | "qaNoteType" | "clozeNoteType"> {
return {
qaHeadingLevel: cardTypeConfigs.basic.headingLevel,
clozeHeadingLevel: cardTypeConfigs.cloze.headingLevel,
qaGroupMarker: cardTypeConfigs["qa-group"].extraMarker,
qaNoteType: cardTypeConfigs.basic.noteType,
clozeNoteType: cardTypeConfigs.cloze.noteType,
semanticQaMarker: cardTypeConfigs["semantic-qa"].extraMarker,
semanticQaNoteType: cardTypeConfigs["semantic-qa"].noteType,
};
}
@ -548,7 +528,7 @@ function normalizeNoteFieldMappings(value: unknown): Record<string, NoteModelFie
}
const mapping = rawMapping as Partial<NoteModelFieldMapping> & Record<string, unknown>;
if (mapping.cardType !== "basic" && mapping.cardType !== "qa-group" && mapping.cardType !== "cloze" && mapping.cardType !== "semantic-qa") {
if (mapping.cardType !== "basic" && mapping.cardType !== "qa-group" && mapping.cardType !== "cloze") {
continue;
}
@ -608,7 +588,7 @@ function hydrateMissingNoteFieldMappingsFromCache(settings: PluginSettings): Rec
continue;
}
const cardType = configId === "cloze" ? "cloze" : configId === "semantic-qa" ? "semantic-qa" : "basic";
const cardType = configId === "cloze" ? "cloze" : "basic";
const mappingKey = createNoteFieldMappingKey(cardType, modelName);
if (hydratedMappings[mappingKey]) {
continue;

View file

@ -24,6 +24,18 @@ describe("PluginUserError", () => {
expect(renderUserMessage(error)).toBe("当前文件不在插件作用范围内notes/example.md");
});
it("renders scope-not-configured errors in English and Chinese", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en");
expect(renderUserMessage(new PluginUserError("errors.runScopeNotConfigured"))).toBe(
"Run scope is not configured. In include mode, select at least one folder before syncing.",
);
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
expect(renderUserMessage(new PluginUserError("errors.runScopeNotConfigured"))).toBe(
"运行范围尚未配置。当前是 include 模式,请至少选择一个文件夹后再同步。",
);
});
it("renders write-back failure summaries with localized detail lines", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");

View file

@ -35,7 +35,7 @@ describe("FileIndexerService", () => {
filePath: "notes/one.md",
fileHash: "hash-a",
fileStamp: `1:${["#### One", "Body"].join("\n").length}`,
deckRulesFingerprint: createDeckRulesFingerprint(createModule3Settings()),
deckRulesFingerprint: createDeckRulesFingerprint(createIndexSettingsForPath("notes/one.md")),
lastIndexedAt: 1,
noteIds: [10],
},
@ -68,7 +68,7 @@ describe("FileIndexerService", () => {
pendingWriteBack: [],
};
const result = await service.indexVault(createModule3Settings(), state);
const result = await service.indexVault(createIndexSettingsForPath("notes/one.md"), state);
expect(result.skippedUnchangedFiles).toBe(1);
expect(result.skippedUnchangedCards).toBe(1);
@ -88,7 +88,7 @@ describe("FileIndexerService", () => {
filePath: "notes/one.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(createModule3Settings({ defaultDeck: "Old::Deck" })),
deckRulesFingerprint: createDeckRulesFingerprint(createIndexSettingsForPath("notes/one.md", { defaultDeck: "Old::Deck" })),
lastIndexedAt: 1,
noteIds: [10],
},
@ -121,7 +121,7 @@ describe("FileIndexerService", () => {
pendingWriteBack: [],
};
const result = await service.indexVault(createModule3Settings({ defaultDeck: "New::Deck" }), state);
const result = await service.indexVault(createIndexSettingsForPath("notes/one.md", { defaultDeck: "New::Deck" }), state);
expect(result.skippedUnchangedFiles).toBe(0);
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
@ -129,8 +129,8 @@ describe("FileIndexerService", () => {
it("changes the fingerprint and forces re-read when card answer cutoff mode changed", async () => {
const content = ["#### One", "Body"].join("\n");
const oldSettings = createModule3Settings({ cardAnswerCutoffMode: "heading-block" });
const newSettings = createModule3Settings({ cardAnswerCutoffMode: "double-blank-lines" });
const oldSettings = createIndexSettingsForPath("notes/one.md", { cardAnswerCutoffMode: "heading-block" });
const newSettings = createIndexSettingsForPath("notes/one.md", { cardAnswerCutoffMode: "double-blank-lines" });
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/one.md": content,
});
@ -184,8 +184,8 @@ describe("FileIndexerService", () => {
it("changes the fingerprint and forces re-read when pure tag line cleanup setting changed", async () => {
const content = ["#### One", "#项目A #重点/案例", "", "Body"].join("\n");
const oldSettings = createModule3Settings({ keepPureTagLinesInCardBody: true });
const newSettings = createModule3Settings({ keepPureTagLinesInCardBody: false });
const oldSettings = createIndexSettingsForPath("notes/one.md", { keepPureTagLinesInCardBody: true });
const newSettings = createIndexSettingsForPath("notes/one.md", { keepPureTagLinesInCardBody: false });
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/one.md": content,
});
@ -236,4 +236,20 @@ describe("FileIndexerService", () => {
expect(result.skippedUnchangedFiles).toBe(0);
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
});
});
});
function createIndexSettingsForPath(filePath: string, overrides: Parameters<typeof createModule3Settings>[0] = {}) {
const firstSlashIndex = filePath.indexOf("/");
if (firstSlashIndex === -1) {
return createModule3Settings({
scopeMode: "all",
...overrides,
});
}
return createModule3Settings({
scopeMode: "include",
includeFolders: [filePath.slice(0, firstSlashIndex)],
...overrides,
});
}

View file

@ -6,7 +6,7 @@ import { RenderConfigService } from "@/application/services/RenderConfigService"
import type { CardState } from "@/domain/manual-sync/entities/PluginState";
import { hashString } from "@/domain/shared/hash";
import { CurrentFileOutOfScopeError, ManualSyncService } from "./ManualSyncService";
import { CurrentFileOutOfScopeError, ManualSyncService, RunScopeNotConfiguredError } from "./ManualSyncService";
import { createModule3Settings, FakeManualSyncAnkiGateway, FakeManualSyncVaultGateway, InMemoryPluginStateRepository, QA_GROUP_USER_NOTE_TYPE } from "@/test-support/manualSyncFakes";
describe("ManualSyncService", () => {
@ -18,7 +18,7 @@ describe("ManualSyncService", () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
const result = await service.syncFile("notes/example.md", createModule3Settings());
const result = await service.syncFile("notes/example.md", createSettingsForSyncPath("notes/example.md"));
expect(result.created).toBe(1);
expect(result.updated).toBe(0);
@ -43,7 +43,7 @@ describe("ManualSyncService", () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
const result = await service.syncFile("notes/example.md", createModule3Settings());
const result = await service.syncFile("notes/example.md", createSettingsForSyncPath("notes/example.md"));
expect(result.markerWriteConflictFiles).toEqual(["notes/example.md"]);
expect(stateRepository.savedState?.pendingWriteBack).toHaveLength(1);
@ -64,7 +64,7 @@ describe("ManualSyncService", () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, () => 1234);
const result = await service.syncFile("notes/example.md", createModule3Settings());
const result = await service.syncFile("notes/example.md", createSettingsForSyncPath("notes/example.md"));
expect(result.created).toBe(1);
expect(result.rewrittenMarkers).toBe(1);
@ -82,63 +82,8 @@ describe("ManualSyncService", () => {
expect(stateRepository.savedState?.files["notes/example.md"]?.groupIds).toHaveLength(1);
});
it("rebuilds the card index without writing unresolved ID markers or calling Anki", async () => {
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": ["#### Prompt", "Answer"].join("\n"),
});
const stateRepository = new InMemoryPluginStateRepository();
const ankiGateway = new FakeManualSyncAnkiGateway();
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
const result = await service.rebuildIndex(createModule3Settings());
expect(result.created).toBe(0);
expect(result.rewrittenMarkers).toBe(0);
expect(result.migratedDecks).toBe(0);
expect(ankiGateway.addedNotes).toHaveLength(0);
expect(ankiGateway.changedDecks).toEqual([]);
expect(ankiGateway.deletedNotes).toEqual([]);
expect(ankiGateway.deletedDecks).toEqual([]);
expect(vaultGateway.getFileContent("notes/example.md")).toBe(["#### Prompt", "Answer"].join("\n"));
});
it("rebuildIndex does not migrate decks even when deck rules changed", async () => {
const oldSettings = createModule3Settings({ defaultDeck: "Old::Deck", folderDeckMode: "off" });
const newSettings = createModule3Settings({ defaultDeck: "New::Deck", folderDeckMode: "folder" });
const content = ["#### Prompt", "Answer"].join("\n");
const vaultGateway = new FakeManualSyncVaultGateway({
"课程/数学/第一章/导数.md": content,
});
const stateRepository = new InMemoryPluginStateRepository({
files: {
"课程/数学/第一章/导数.md": {
filePath: "课程/数学/第一章/导数.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(oldSettings),
lastIndexedAt: 1,
noteIds: [42],
},
},
cards: {
"42": createStoredSyncedCard(oldSettings, {
filePath: "课程/数学/第一章/导数.md",
deck: "Old::Deck",
}),
},
pendingWriteBack: [],
});
const ankiGateway = new FakeManualSyncAnkiGateway();
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
const result = await service.rebuildIndex(newSettings);
expect(result.migratedDecks).toBe(0);
expect(ankiGateway.changedDecks).toEqual([]);
});
it("restores the original marker without creating a new Anki note when marker was deleted but content is unchanged", async () => {
const settings = createModule3Settings();
const settings = createSettingsForSyncPath("notes/example.md");
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": ["#### Prompt", "Answer"].join("\n"),
});
@ -170,7 +115,7 @@ describe("ManualSyncService", () => {
});
it("creates a new Anki note when marker was deleted and the card content changed", async () => {
const settings = createModule3Settings();
const settings = createSettingsForSyncPath("notes/example.md");
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": ["#### Prompt", "Updated Answer"].join("\n"),
});
@ -211,7 +156,7 @@ describe("ManualSyncService", () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
const result = await service.syncFile("notes/example.md", createModule3Settings());
const result = await service.syncFile("notes/example.md", createSettingsForSyncPath("notes/example.md"));
expect(result.created).toBe(1);
expect(result.warnings.map((warning) => warning.code)).toEqual(["deck_conflict_yaml_body"]);
@ -226,14 +171,14 @@ describe("ManualSyncService", () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
await service.syncFile("课程/数学/第一章/导数.md", createModule3Settings());
await service.syncFile("课程/数学/第一章/导数.md", createSettingsForSyncPath("课程/数学/第一章/导数.md"));
expect(ankiGateway.addedNotes[0]?.deckName).toBe("课程::数学::第一章");
expect(ankiGateway.ensuredDecks).toEqual([["课程::数学::第一章"]]);
});
it("migrates an existing note when only the resolved deck changes", async () => {
const settings = createModule3Settings({ defaultDeck: "New::Deck" });
const settings = createSettingsForSyncPath("example.md", { defaultDeck: "New::Deck" });
const vaultGateway = new FakeManualSyncVaultGateway({
"example.md": ["#### Prompt", "Answer", "<!--ID: 42-->"] .join("\n"),
});
@ -260,7 +205,7 @@ describe("ManualSyncService", () => {
deckWarnings: [],
tagsHint: [],
};
const oldSettings = createModule3Settings({ defaultDeck: "Old::Deck" });
const oldSettings = createSettingsForSyncPath("example.md", { defaultDeck: "Old::Deck" });
const renderPlan = new RenderConfigService().resolve(indexedCard, oldSettings);
const stateRepository = new InMemoryPluginStateRepository({
files: {},
@ -291,7 +236,7 @@ describe("ManualSyncService", () => {
});
it("aligns deck for a marker-backed existing note even without prior local state", async () => {
const settings = createModule3Settings({ folderDeckMode: "folder-and-file", defaultDeck: "Obsidian1" });
const settings = createSettingsForSyncPath("999试验卡片/城市更新运营类anki.md", { folderDeckMode: "folder-and-file", defaultDeck: "Obsidian1" });
const filePath = "999试验卡片/城市更新运营类anki.md";
const vaultGateway = new FakeManualSyncVaultGateway({
[filePath]: ["#### 城市更新,百人会的核心产品", "Answer", "<!--ID: 42-->"].join("\n"),
@ -315,7 +260,7 @@ describe("ManualSyncService", () => {
});
it("self-heals deck drift when local state already claims the target deck", async () => {
const settings = createModule3Settings({ folderDeckMode: "folder-and-file", defaultDeck: "Obsidian1" });
const settings = createSettingsForSyncPath("999试验卡片/城市更新运营类anki.md", { folderDeckMode: "folder-and-file", defaultDeck: "Obsidian1" });
const filePath = "999试验卡片/城市更新运营类anki.md";
const content = ["#### 城市更新,百人会的核心产品", "Answer", "<!--ID: 42-->"].join("\n");
const vaultGateway = new FakeManualSyncVaultGateway({
@ -364,8 +309,8 @@ describe("ManualSyncService", () => {
});
it("ordinary sync re-evaluates unchanged files when folder deck rules changed and migrates old notes", async () => {
const oldSettings = createModule3Settings({ folderDeckMode: "off", defaultDeck: "Default::Deck" });
const newSettings = createModule3Settings({ folderDeckMode: "folder", defaultDeck: "Default::Deck" });
const oldSettings = createSettingsForSyncPath("课程/数学/第一章/导数.md", { folderDeckMode: "off", defaultDeck: "Default::Deck" });
const newSettings = createSettingsForSyncPath("课程/数学/第一章/导数.md", { folderDeckMode: "folder", defaultDeck: "Default::Deck" });
const vaultGateway = new FakeManualSyncVaultGateway({
"课程/数学/第一章/导数.md": ["#### Prompt", "Answer", "<!--ID: 42-->"] .join("\n"),
});
@ -409,8 +354,8 @@ describe("ManualSyncService", () => {
it("ordinary sync re-evaluates unchanged files when default deck changed and migrates root-level notes", async () => {
const content = ["#### Prompt", "Answer", "<!--ID: 42-->"] .join("\n");
const oldSettings = createModule3Settings({ defaultDeck: "Old::Deck", folderDeckMode: "off" });
const newSettings = createModule3Settings({ defaultDeck: "New::Deck", folderDeckMode: "off" });
const oldSettings = createSettingsForSyncPath("example.md", { defaultDeck: "Old::Deck", folderDeckMode: "off" });
const newSettings = createSettingsForSyncPath("example.md", { defaultDeck: "New::Deck", folderDeckMode: "off" });
const vaultGateway = new FakeManualSyncVaultGateway({
"example.md": content,
});
@ -449,8 +394,8 @@ describe("ManualSyncService", () => {
it("ordinary sync re-evaluates unchanged files when file deck marker changes and migrates old notes", async () => {
const content = ["MY DECK: Scoped::Deck", "", "#### Prompt", "Answer", "<!--ID: 42-->"] .join("\n");
const oldSettings = createModule3Settings({ fileDeckMarker: "TARGET DECK", defaultDeck: "Default::Deck" });
const newSettings = createModule3Settings({ fileDeckMarker: "MY DECK", defaultDeck: "Default::Deck" });
const oldSettings = createSettingsForSyncPath("notes/example.md", { fileDeckMarker: "TARGET DECK", defaultDeck: "Default::Deck" });
const newSettings = createSettingsForSyncPath("notes/example.md", { fileDeckMarker: "MY DECK", defaultDeck: "Default::Deck" });
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": content,
});
@ -490,8 +435,8 @@ describe("ManualSyncService", () => {
it("ordinary sync re-evaluates unchanged files when file-level deck is enabled and migrates old notes", async () => {
const content = ["TARGET DECK: Scoped::Deck", "", "#### Prompt", "Answer", "<!--ID: 42-->"] .join("\n");
const oldSettings = createModule3Settings({ fileDeckEnabled: false, defaultDeck: "Default::Deck" });
const newSettings = createModule3Settings({ fileDeckEnabled: true, defaultDeck: "Default::Deck" });
const oldSettings = createSettingsForSyncPath("notes/example.md", { fileDeckEnabled: false, defaultDeck: "Default::Deck" });
const newSettings = createSettingsForSyncPath("notes/example.md", { fileDeckEnabled: true, defaultDeck: "Default::Deck" });
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": content,
});
@ -548,8 +493,65 @@ describe("ManualSyncService", () => {
expect(vaultGateway.readCalls).toEqual([]);
expect(ankiGateway.addedNotes).toHaveLength(0);
});
it("blocks current file sync when include mode has no selected folders", async () => {
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": ["#### Prompt", "Answer"].join("\n"),
});
const stateRepository = new InMemoryPluginStateRepository();
const ankiGateway = new FakeManualSyncAnkiGateway();
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
await expect(
service.syncFile(
"notes/example.md",
createModule3Settings({
scopeMode: "include",
includeFolders: [],
}),
),
).rejects.toBeInstanceOf(RunScopeNotConfiguredError);
expect(vaultGateway.readCalls).toEqual([]);
expect(ankiGateway.addedNotes).toHaveLength(0);
});
it("blocks vault sync when include mode has no selected folders", async () => {
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": ["#### Prompt", "Answer"].join("\n"),
});
const stateRepository = new InMemoryPluginStateRepository();
const ankiGateway = new FakeManualSyncAnkiGateway();
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
await expect(
service.syncVault(createModule3Settings({
scopeMode: "include",
includeFolders: [],
})),
).rejects.toBeInstanceOf(RunScopeNotConfiguredError);
expect(vaultGateway.readCalls).toEqual([]);
expect(ankiGateway.addedNotes).toHaveLength(0);
});
});
function createSettingsForSyncPath(filePath: string, overrides: Partial<PluginSettings> = {}): PluginSettings {
const firstSlashIndex = filePath.indexOf("/");
if (firstSlashIndex === -1) {
return createModule3Settings({
scopeMode: "all",
...overrides,
});
}
return createModule3Settings({
scopeMode: "include",
includeFolders: [filePath.slice(0, firstSlashIndex)],
...overrides,
});
}
function createStoredSyncedCard(settings: PluginSettings, overrides: Partial<CardState> = {}): CardState {
const heading = overrides.heading ?? "Prompt";
const bodyMarkdown = overrides.bodyMarkdown ?? "Answer";

View file

@ -1,5 +1,5 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import { validatePluginSettings } from "@/application/config/PluginSettings";
import { isRunScopeConfigured, validatePluginSettings } from "@/application/config/PluginSettings";
import { PluginUserError, type PluginFileFailure } from "@/application/errors/PluginUserError";
import type { AnkiGateway, AnkiGroupGateway } from "@/application/ports/AnkiGateway";
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
@ -15,7 +15,6 @@ import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import type { IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
import { toNoteIdKey, type CardState, type GroupBlockState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard";
import { GroupMarkerService, type GroupMarkerWriteRequest } from "@/domain/manual-sync/services/GroupMarkerService";
import { DiffPlannerService } from "@/domain/manual-sync/services/DiffPlannerService";
import { ManualCardRenderer, type ManualCardRenderContext } from "@/domain/manual-sync/services/ManualCardRenderer";
import type { DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
@ -30,10 +29,16 @@ export class CurrentFileOutOfScopeError extends PluginUserError {
}
}
export class RunScopeNotConfiguredError extends PluginUserError {
constructor() {
super("errors.runScopeNotConfigured");
this.name = "RunScopeNotConfiguredError";
}
}
export class ManualSyncService {
private readonly scanScopeService = new ScanScopeService();
private readonly qaGroupSyncService: QaGroupSyncService;
private readonly groupMarkerService: GroupMarkerService;
private readonly renderConfigService: RenderConfigService;
private readonly now: () => number;
@ -59,7 +64,6 @@ export class ManualSyncService {
undefined,
this.vaultGateway.createBacklink.bind(this.vaultGateway),
);
this.groupMarkerService = new GroupMarkerService();
if (isRenderConfigService(renderConfigServiceOrNow)) {
this.renderConfigService = renderConfigServiceOrNow;
@ -73,6 +77,7 @@ export class ManualSyncService {
async syncVault(settings: PluginSettings): Promise<ManualSyncResult> {
validatePluginSettings(settings);
this.assertRunScopeConfigured(settings);
const state = await this.pluginStateRepository.load();
const indexResult = await this.fileIndexerService.indexVault(settings, state);
return this.syncIndexedResult(indexResult, state, settings);
@ -80,6 +85,7 @@ export class ManualSyncService {
async syncFile(filePath: string, settings: PluginSettings): Promise<ManualSyncResult> {
validatePluginSettings(settings);
this.assertRunScopeConfigured(settings);
if (!this.scanScopeService.isPathInScope(filePath, settings.scopeMode, settings.includeFolders, settings.excludeFolders)) {
throw new CurrentFileOutOfScopeError(filePath);
}
@ -89,53 +95,10 @@ export class ManualSyncService {
return this.syncIndexedResult(indexResult, state, settings);
}
async rebuildIndex(settings: PluginSettings): Promise<ManualSyncResult> {
validatePluginSettings(settings);
const state = await this.pluginStateRepository.load();
const indexResult = await this.fileIndexerService.indexVault(settings, state, true);
const plan = this.diffPlannerService.plan(indexResult.cards, state, indexResult.scopedFilePaths, settings);
const indexedFilesByPath = new Map(indexResult.indexedFiles.map((file) => [file.filePath, file]));
const markerWrites = plan.toRewriteMarker.filter((plannedCard) => plannedCard.noteId !== undefined);
const rebuildGroupResult = this.buildReindexedGroupStates(indexResult.groupBlocks, state);
const writeBackResult = await this.markdownWriteBackService.write(markerWrites, indexedFilesByPath, rebuildGroupResult.markerWrites);
const orphanGroupBlocks = collectOrphanGroupBlocks(state, indexResult.scopedFilePaths, rebuildGroupResult.syncedGroupBlocks);
const nextState = this.buildNextState(
state,
indexResult.cards,
indexResult.indexedFiles,
rebuildGroupResult.syncedGroupBlocks,
settings,
new Set(writeBackResult.writtenSyncKeys),
new Map(),
plan.toOrphan,
orphanGroupBlocks,
);
nextState.pendingWriteBack = [
...state.pendingWriteBack.filter((pending) => !new Set(indexResult.scopedFilePaths).has(pending.filePath)),
...writeBackResult.pendingEntries,
];
this.markFilesDirty(nextState, [...writeBackResult.failureFiles.map((failure) => failure.filePath), ...writeBackResult.conflictFiles]);
await this.pluginStateRepository.save(nextState);
if (writeBackResult.failureFiles.length > 0) {
throw this.createWriteBackFailureError(writeBackResult.failureFiles);
private assertRunScopeConfigured(settings: PluginSettings): void {
if (!isRunScopeConfigured(settings.scopeMode, settings.includeFolders)) {
throw new RunScopeNotConfiguredError();
}
return {
scannedFiles: indexResult.scannedFiles,
scannedCards: indexResult.cards.length + indexResult.groupBlocks.length,
created: 0,
updated: 0,
migratedNoteTypes: 0,
migratedDecks: 0,
orphaned: plan.toOrphan.length + orphanGroupBlocks.length,
uploadedMedia: 0,
skippedUnchangedCards: indexResult.skippedUnchangedCards,
rewrittenMarkers: writeBackResult.writtenSyncKeys.length,
markerWriteConflictFiles: writeBackResult.conflictFiles,
warnings: mergeDeckWarnings(plan.warnings, rebuildGroupResult.warnings),
};
}
private async syncIndexedResult(
@ -349,98 +312,6 @@ export class ManualSyncService {
return nextState;
}
private buildReindexedGroupStates(
groupBlocks: IndexedGroupCardBlock[],
state: PluginState,
): { syncedGroupBlocks: GroupBlockState[]; markerWrites: GroupMarkerWriteRequest[]; warnings: DeckResolutionWarning[] } {
const syncedGroupBlocks: GroupBlockState[] = [];
const markerWrites: GroupMarkerWriteRequest[] = [];
const warnings: DeckResolutionWarning[] = [];
const byNoteId = new Map<number, GroupBlockState>(Object.values(state.groupBlocks ?? {})
.filter((groupBlock) => !groupBlock.orphan)
.map((groupBlock) => [groupBlock.noteId, groupBlock]));
const bySrc = new Map<string, GroupBlockState[]>();
for (const groupBlock of Object.values(state.groupBlocks ?? {}).filter((entry) => !entry.orphan)) {
const entries = bySrc.get(groupBlock.src);
if (entries) {
entries.push(groupBlock);
} else {
bySrc.set(groupBlock.src, [groupBlock]);
}
}
for (const block of groupBlocks) {
const existingState = (block.groupId ? state.groupBlocks?.[block.groupId] : undefined)
?? (block.noteId !== undefined ? byNoteId.get(block.noteId) : undefined)
?? uniqueGroupStateMatch(bySrc.get(block.src));
if (!existingState) {
continue;
}
const itemToSlot = Object.fromEntries(existingState.items
.filter((item) => item.itemId && item.slot)
.map((item) => [item.itemId as string, item.slot as number]));
syncedGroupBlocks.push({
...existingState,
filePath: block.filePath,
headingText: block.headingText,
backlinkHeadingText: block.backlinkHeadingText,
headingLevel: block.headingLevel,
stem: block.stem,
src: block.src,
blockStartOffset: block.blockStartOffset,
blockEndOffset: block.blockEndOffset,
blockStartLine: block.blockStartLine,
bodyStartLine: block.bodyStartLine,
blockEndLine: block.blockEndLine,
contentEndLine: block.contentEndLine,
markerLine: block.markerLine,
markerIndent: block.markerIndent,
rawBlockText: block.rawBlockText,
rawBlockHash: block.rawBlockHash,
deckHint: block.deckHint,
deckHintSource: block.deckHintSource,
deckWarnings: [...block.deckWarnings],
tagsHint: block.tagsHint ? [...block.tagsHint] : [],
orphan: false,
});
warnings.push(...block.deckWarnings);
if (
block.sourceContent
&& (
block.markerState !== "present-valid"
|| !this.groupMarkerService.isEquivalent(block.groupMarker, existingState.noteId, itemToSlot, existingState.freeSlots)
|| hasLegacyInnerIdMarkers(block)
)
) {
markerWrites.push({
syncKey: block.syncKey,
filePath: block.filePath,
blockStartLine: block.blockStartLine,
contentEndLine: block.contentEndLine,
blockEndLine: block.blockEndLine,
markerLine: block.markerLine,
markerIndent: block.markerIndent,
noteId: existingState.noteId,
groupId: existingState.groupId,
itemToSlot,
freeSlots: [...existingState.freeSlots],
sourceContent: block.sourceContent,
});
}
}
return {
syncedGroupBlocks,
markerWrites,
warnings,
};
}
private markFilesDirty(nextState: PluginState, filePaths: string[]): void {
for (const filePath of new Set(filePaths)) {
if (nextState.files[filePath]) {
@ -485,26 +356,3 @@ function mergeDeckWarnings(...warningGroups: DeckResolutionWarning[][]): DeckRes
return [...warningMap.values()];
}
function uniqueGroupStateMatch(groupBlocks: GroupBlockState[] | undefined): GroupBlockState | undefined {
if (!groupBlocks || groupBlocks.length !== 1) {
return undefined;
}
return groupBlocks[0];
}
function hasLegacyInnerIdMarkers(block: IndexedGroupCardBlock): boolean {
if (!block.sourceContent) {
return false;
}
const lines = block.sourceContent.split(/\r?\n/);
for (let lineIndex = block.blockStartLine; lineIndex < block.blockEndLine; lineIndex += 1) {
if (/^\s*<!--\s*ID:/i.test(lines[lineIndex] ?? "")) {
return true;
}
}
return false;
}

View file

@ -81,7 +81,7 @@ export class NoteFieldMappingService {
}
if (mapping.titleField === mapping.bodyField) {
throw new PluginUserError(getTitleBodyMustDifferKey(mapping.cardType), {
throw new PluginUserError(getTitleBodyMustDifferKey(), {
modelName: mapping.modelName,
});
}
@ -155,34 +155,28 @@ export class NoteFieldMappingService {
}
}
function isBasicLikeMappingCardType(cardType: NoteModelFieldMappingCardType): cardType is Extract<NoteModelFieldMappingCardType, "basic" | "semantic-qa"> {
return cardType === "basic" || cardType === "semantic-qa";
function isBasicLikeMappingCardType(cardType: NoteModelFieldMappingCardType): cardType is Extract<NoteModelFieldMappingCardType, "basic"> {
return cardType === "basic";
}
function getMissingSavedMappingKey(cardType: CardType): "errors.noteFieldMapping.missingSavedMapping.basic" | "errors.noteFieldMapping.missingSavedMapping.cloze" | "errors.noteFieldMapping.missingSavedMapping.semanticQa" {
function getMissingSavedMappingKey(cardType: CardType): "errors.noteFieldMapping.missingSavedMapping.basic" | "errors.noteFieldMapping.missingSavedMapping.cloze" {
if (cardType === "cloze") {
return "errors.noteFieldMapping.missingSavedMapping.cloze";
}
return cardType === "semantic-qa"
? "errors.noteFieldMapping.missingSavedMapping.semanticQa"
: "errors.noteFieldMapping.missingSavedMapping.basic";
return "errors.noteFieldMapping.missingSavedMapping.basic";
}
function getIncompleteSavedMappingKey(cardType: NoteModelFieldMappingCardType): "errors.noteFieldMapping.incompleteSavedMapping.basic" | "errors.noteFieldMapping.incompleteSavedMapping.cloze" | "errors.noteFieldMapping.incompleteSavedMapping.semanticQa" {
function getIncompleteSavedMappingKey(cardType: NoteModelFieldMappingCardType): "errors.noteFieldMapping.incompleteSavedMapping.basic" | "errors.noteFieldMapping.incompleteSavedMapping.cloze" {
if (cardType === "cloze") {
return "errors.noteFieldMapping.incompleteSavedMapping.cloze";
}
return cardType === "semantic-qa"
? "errors.noteFieldMapping.incompleteSavedMapping.semanticQa"
: "errors.noteFieldMapping.incompleteSavedMapping.basic";
return "errors.noteFieldMapping.incompleteSavedMapping.basic";
}
function getTitleBodyMustDifferKey(cardType: Extract<NoteModelFieldMappingCardType, "basic" | "semantic-qa">): "errors.noteFieldMapping.titleBodyMustDiffer.basic" | "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" {
return cardType === "semantic-qa"
? "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa"
: "errors.noteFieldMapping.titleBodyMustDiffer.basic";
function getTitleBodyMustDifferKey(): "errors.noteFieldMapping.titleBodyMustDiffer.basic" {
return "errors.noteFieldMapping.titleBodyMustDiffer.basic";
}
function findFieldName(fieldNames: string[], preferredNames: string[]): string | undefined {

View file

@ -55,14 +55,6 @@ function resolveNoteModel(cardType: IndexedCard["cardType"], settings: PluginSet
return settings.clozeNoteType;
}
if (cardType === "semantic-qa") {
if (!settings.semanticQaNoteType.trim()) {
throw new PluginUserError("errors.noteFieldMapping.noteTypeNotSelected.semanticQa");
}
return settings.semanticQaNoteType;
}
if (!settings.qaNoteType.trim()) {
throw new PluginUserError("errors.noteFieldMapping.noteTypeNotSelected.basic");
}

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
import type { PluginSettings } from "@/application/config/PluginSettings";
import { createDeckRulesFingerprint } from "@/application/services/FileIndexerService";
import { ManualSyncService } from "@/application/services/ManualSyncService";
@ -14,7 +13,7 @@ import { ClearCurrentFileSyncedCardsUseCase } from "./ClearCurrentFileSyncedCard
describe("ClearCurrentFileSyncedCardsUseCase", () => {
it("deletes notes, removes markers, deletes local records, and allows later sync to recreate cards", async () => {
const settings = createModule3Settings();
const settings = createModule3Settings({ scopeMode: "all" });
const filePath = "notes/example.md";
const content = [
"#### Prompt",
@ -128,99 +127,8 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => {
expect(ankiGateway.deletedNotes).toEqual([]);
});
it("clears semantic QA child cards and allows a later sync to recreate them", async () => {
const settings = createSemanticQaSettings();
const filePath = "notes/semantic.md";
const content = [
"#### Concepts #anki-list-qa",
"- Alpha",
" First answer",
" <!--ID: 41-->",
"- Beta",
" Second answer",
" <!--ID: 42-->",
].join("\n");
const vaultGateway = new FakeManualSyncVaultGateway({
[filePath]: content,
});
const stateRepository = new InMemoryPluginStateRepository({
files: {
[filePath]: createStoredFileState(settings, filePath, content, { noteIds: [41, 42] }),
},
cards: {
"41": createStoredSyncedCard(settings, {
noteId: 41,
filePath,
cardType: "semantic-qa",
heading: "Concepts<br>Alpha",
backlinkHeadingText: "Concepts #anki-list-qa",
bodyMarkdown: "First answer",
rawBlockText: ["semantic-qa:Concepts::1", "Concepts", "Alpha", "First answer"].join("\n"),
bodyStartLine: 3,
contentEndLine: 3,
blockEndLine: 4,
markerLine: 4,
}),
"42": createStoredSyncedCard(settings, {
noteId: 42,
filePath,
cardType: "semantic-qa",
heading: "Concepts<br>Beta",
backlinkHeadingText: "Concepts #anki-list-qa",
bodyMarkdown: "Second answer",
rawBlockText: ["semantic-qa:Concepts::2", "Concepts", "Beta", "Second answer"].join("\n"),
blockStartLine: 5,
bodyStartLine: 6,
contentEndLine: 6,
blockEndLine: 7,
markerLine: 7,
}),
},
pendingWriteBack: [],
});
const ankiGateway = new FakeManualSyncAnkiGateway();
const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway);
await expect(useCase.hasTrackedCards(filePath)).resolves.toBe(true);
const result = await useCase.execute(filePath);
expect(result).toMatchObject({
trackedCards: 2,
trackedGroups: 0,
deletedNotes: 2,
removedMarkers: 2,
removedCardMarkers: 2,
removedGroupMarkers: 0,
deletedLocalRecords: 2,
conflictFiles: [],
failureFiles: [],
});
expect(ankiGateway.deletedNotes).toEqual([[41, 42]]);
expect(vaultGateway.getFileContent(filePath)).toBe([
"#### Concepts #anki-list-qa",
"- Alpha",
" First answer",
"- Beta",
" Second answer",
].join("\n"));
expect(stateRepository.savedState?.files[filePath]).toBeUndefined();
expect(stateRepository.savedState?.cards["41"]).toBeUndefined();
expect(stateRepository.savedState?.cards["42"]).toBeUndefined();
const manualSyncService = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 3000);
const syncResult = await manualSyncService.syncFile(filePath, settings);
expect(syncResult.created).toBe(2);
expect(ankiGateway.addedNotes).toHaveLength(2);
expect(vaultGateway.getFileContent(filePath)).toContain("<!--ID: 9001-->");
expect(vaultGateway.getFileContent(filePath)).toContain("<!--ID: 9002-->");
expect(vaultGateway.getFileContent(filePath)).not.toContain("<!--ID: 41-->");
expect(vaultGateway.getFileContent(filePath)).not.toContain("<!--ID: 42-->");
});
it("clears QA Group records and allows a later sync to recreate the GI marker", async () => {
const settings = createModule3Settings();
const settings = createModule3Settings({ scopeMode: "all" });
const filePath = "notes/group.md";
const content = [
"#### Concepts #anki-list",
@ -277,6 +185,7 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => {
" - Second answer",
].join("\n"));
expect(stateRepository.savedState?.files[filePath]).toBeUndefined();
expect(stateRepository.savedState?.groupBlocks?.["group-1"]).toBeUndefined();
const manualSyncService = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, () => 3000);
@ -290,7 +199,7 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => {
});
it("clears mixed card and group routes in one pass", async () => {
const settings = createModule3Settings();
const settings = createModule3Settings({ scopeMode: "all" });
const filePath = "notes/mixed.md";
const content = [
"#### Prompt",
@ -364,7 +273,7 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => {
});
it("reports marker write conflicts for QA Group clears but still removes local state so a later sync can recreate the note", async () => {
const settings = createModule3Settings();
const settings = createModule3Settings({ scopeMode: "all" });
const filePath = "notes/group-conflict.md";
const content = [
"#### Concepts #anki-list",
@ -433,7 +342,7 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => {
});
it("clears all pending write-back entries for the current file regardless of marker kind", async () => {
const settings = createModule3Settings();
const settings = createModule3Settings({ scopeMode: "all" });
const filePath = "notes/pending.md";
const otherFilePath = "notes/other.md";
const content = [
@ -500,25 +409,6 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => {
});
});
function createSemanticQaSettings(): PluginSettings {
const base = createModule3Settings();
return {
...base,
noteFieldMappings: {
...base.noteFieldMappings,
[createNoteFieldMappingKey("semantic-qa", base.semanticQaNoteType)]: {
cardType: "semantic-qa",
modelName: base.semanticQaNoteType,
loadedFieldNames: ["Front", "Back"],
titleField: "Front",
bodyField: "Back",
loadedAt: 1,
},
},
};
}
function createStoredFileState(
settings: PluginSettings,
filePath: string,

View file

@ -1,11 +0,0 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
import type { ManualSyncService } from "@/application/services/ManualSyncService";
export class RebuildCardIndexUseCase {
constructor(private readonly manualSyncService: ManualSyncService) {}
async execute(settings: PluginSettings): Promise<ManualSyncResult> {
return this.manualSyncService.rebuildIndex(settings);
}
}

View file

@ -1,11 +1,11 @@
export type CardType = "basic" | "cloze" | "semantic-qa";
export type CardType = "basic" | "cloze";
export function isClozeCardType(cardType: CardType): cardType is "cloze" {
return cardType === "cloze";
}
export function isBasicLikeCardType(cardType: CardType): cardType is "basic" | "semantic-qa" {
return cardType !== "cloze";
export function isBasicLikeCardType(cardType: CardType): cardType is "basic" {
return cardType === "basic";
}
export interface RenderedFields {

View file

@ -346,51 +346,6 @@ describe("CardIndexingService", () => {
expect(indexedFile.groupBlocks ?? []).toHaveLength(0);
});
it("splits a tagged QA heading into semantic QA child cards with distinct titles and backlink anchors", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
path: "notes/example.md",
basename: "example",
content: [
"#### Concepts #anki-list-qa",
"- Alpha",
" First answer",
" <!--ID: 42-->",
"- Beta",
" Second answer",
].join("\n"),
},
{
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
semanticQaMarker: "#anki-list-qa",
fileStamp: "1:1",
knownCards: [],
pendingWriteBack: [],
},
);
expect(indexedFile.cards).toHaveLength(2);
expect(indexedFile.cards[0]).toMatchObject({
noteId: 42,
noteIdSource: "marker",
cardType: "semantic-qa",
heading: "Concepts<br>Alpha",
backlinkHeadingText: "Concepts #anki-list-qa",
bodyMarkdown: "First answer",
markerLine: 4,
markerIndent: " ",
});
expect(indexedFile.cards[1]).toMatchObject({
noteId: undefined,
cardType: "semantic-qa",
heading: "Concepts<br>Beta",
backlinkHeadingText: "Concepts #anki-list-qa",
bodyMarkdown: "Second answer",
});
});
it("indexes a #anki-list heading as one QA Group block and parses its GI marker", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
@ -553,7 +508,7 @@ describe("CardIndexingService", () => {
});
});
it("writes file-level tags into basic, cloze, and semantic QA cards", () => {
it("writes file-level tags into basic and cloze cards", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
@ -565,10 +520,6 @@ describe("CardIndexingService", () => {
"",
"##### Cloze",
"{{c1::Body}}",
"",
"#### Concepts #anki-list-qa",
"- Alpha",
" First answer",
].join("\n"),
tags: ["📖::一人公司", "3地区"],
},
@ -581,7 +532,6 @@ describe("CardIndexingService", () => {
extraMarker: "",
},
},
semanticQaMarker: "#anki-list-qa",
syncObsidianTagsToAnki: true,
fileStamp: "1:1",
knownCards: [],
@ -592,7 +542,6 @@ describe("CardIndexingService", () => {
expect(indexedFile.cards.map((card) => card.tagsHint)).toEqual([
["📖::一人公司", "3地区"],
["📖::一人公司", "3地区"],
["📖::一人公司", "3地区"],
]);
});

View file

@ -16,7 +16,6 @@ import { resolveAnswerBoundary } from "./AnswerBoundaryParser";
import { CardMarkerService } from "./CardMarkerService";
import { DeckExtractionService } from "./DeckExtractionService";
import { QaGroupBlockParser } from "./QaGroupBlockParser";
import { SemanticQaListParser } from "./SemanticQaListParser";
interface HeadingMatch {
level: number;
@ -35,7 +34,6 @@ export interface CardIndexingContext {
clozeHeadingLevel?: number;
cardAnswerCutoffMode?: CardAnswerCutoffMode;
qaGroupMarker?: string;
semanticQaMarker?: string;
syncObsidianTagsToAnki?: boolean;
fileStamp: string;
knownCards: CardState[];
@ -46,14 +44,13 @@ export interface CardIndexingContext {
}
const HEADING_REGEXP = /^(#{1,6})\s+(.*?)\s*$/;
const CARD_TYPE_MATCH_ORDER: CardTypeConfigId[] = ["qa-group", "semantic-qa", "cloze", "basic"];
const CARD_TYPE_MATCH_ORDER: CardTypeConfigId[] = ["qa-group", "cloze", "basic"];
export class CardIndexingService {
constructor(
private readonly markerService = new CardMarkerService(),
private readonly deckExtractionService = new DeckExtractionService(),
private readonly qaGroupBlockParser = new QaGroupBlockParser(),
private readonly semanticQaListParser = new SemanticQaListParser(),
) {}
index(sourceFile: SourceFile, context: CardIndexingContext): IndexedFile {
@ -147,63 +144,6 @@ export class CardIndexingService {
continue;
}
if (matchedConfig.configId === "semantic-qa") {
const semanticQaMarker = matchedConfig.config.extraMarker;
for (const semanticCard of this.semanticQaListParser.parse({
parentHeadingText: heading.text,
marker: semanticQaMarker,
bodyLines,
bodyStartLine: heading.lineIndex + 2,
cardAnswerCutoffMode: cutoffMode,
})) {
const resolvedIdentity = this.resolveIdentity(
sourceFile.path,
semanticCard.blockStartLine,
semanticCard.rawBlockHash,
semanticCard.markerNoteId,
knownCardsByBlockKey,
pendingByBlockKey,
usedNoteIds,
);
if (resolvedIdentity.noteId !== undefined) {
usedNoteIds.add(resolvedIdentity.noteId);
}
cards.push({
noteId: resolvedIdentity.noteId,
syncKey: createIndexedCardSyncKey(sourceFile.path, semanticCard.blockStartLine, semanticCard.rawBlockHash),
idMarkerState: semanticCard.idMarkerState,
noteIdSource: resolvedIdentity.noteIdSource,
filePath: sourceFile.path,
cardType: "semantic-qa",
heading: semanticCard.heading,
backlinkHeadingText: semanticCard.backlinkHeadingText,
headingLevel: heading.level,
bodyMarkdown: semanticCard.bodyMarkdown,
blockStartOffset: lineStartOffsets[semanticCard.blockStartLine - 1] ?? 0,
blockEndOffset: semanticCard.blockEndLine < lines.length
? (lineStartOffsets[semanticCard.blockEndLine] ?? sourceFile.content.length)
: sourceFile.content.length,
blockStartLine: semanticCard.blockStartLine,
bodyStartLine: semanticCard.bodyStartLine,
blockEndLine: semanticCard.blockEndLine,
contentEndLine: semanticCard.contentEndLine,
markerLine: semanticCard.markerLine,
markerIndent: semanticCard.markerIndent,
rawBlockText: semanticCard.rawBlockText,
rawBlockHash: semanticCard.rawBlockHash,
deckHint: extractedDeck.explicitDeckHint,
deckHintSource: extractedDeck.explicitDeckSource,
deckWarnings: [...extractedDeck.warnings],
tagsHint: [...fileTags],
sourceContent: sourceFile.content,
});
}
continue;
}
const cardType = matchedConfig.configId === "cloze" ? "cloze" : "basic";
const boundary = resolveAnswerBoundary({
@ -390,11 +330,6 @@ function resolveCardTypeConfigs(context: CardIndexingContext): CardTypeConfigs {
...DEFAULT_SETTINGS.cardTypeConfigs.cloze,
headingLevel: context.clozeHeadingLevel ?? DEFAULT_SETTINGS.cardTypeConfigs.cloze.headingLevel,
},
"semantic-qa": {
...DEFAULT_SETTINGS.cardTypeConfigs["semantic-qa"],
headingLevel: context.qaHeadingLevel ?? DEFAULT_SETTINGS.cardTypeConfigs["semantic-qa"].headingLevel,
extraMarker: context.semanticQaMarker ?? DEFAULT_SETTINGS.cardTypeConfigs["semantic-qa"].extraMarker,
},
};
}

View file

@ -1,138 +0,0 @@
import { describe, expect, it } from "vitest";
import { SemanticQaListParser } from "./SemanticQaListParser";
describe("SemanticQaListParser", () => {
it("splits first-level list items into semantic QA cards and excludes inline labels from the answer", () => {
const parser = new SemanticQaListParser();
const cards = parser.parse({
parentHeadingText: "Concepts #anki-list-qa",
marker: "#anki-list-qa",
bodyLines: [
"- Alpha",
" First line",
" Second line",
" <!--ID: 12-->",
"- Beta",
"- Gamma",
" Third line",
" deeper detail",
],
bodyStartLine: 2,
});
expect(cards).toHaveLength(2);
expect(cards[0]).toMatchObject({
heading: "Concepts<br>Alpha",
backlinkHeadingText: "Concepts #anki-list-qa",
bodyMarkdown: "First line\nSecond line",
blockStartLine: 2,
bodyStartLine: 3,
blockEndLine: 5,
contentEndLine: 4,
markerLine: 5,
markerIndent: " ",
markerNoteId: 12,
idMarkerState: "present-valid",
});
expect(cards[1]).toMatchObject({
heading: "Concepts<br>Gamma",
backlinkHeadingText: "Concepts #anki-list-qa",
bodyMarkdown: "Third line\n deeper detail",
blockStartLine: 7,
bodyStartLine: 8,
blockEndLine: 9,
contentEndLine: 9,
markerLine: undefined,
idMarkerState: "missing",
});
expect(cards[1]?.rawBlockHash).toBeTruthy();
});
it("only treats headings with a trailing configured marker as semantic QA headings", () => {
const parser = new SemanticQaListParser();
expect(parser.isSemanticQaHeading("Concepts #anki-list-qa", "#anki-list-qa")).toBe(true);
expect(parser.isSemanticQaHeading("Concepts #anki-list-qa extra", "#anki-list-qa")).toBe(false);
expect(parser.isSemanticQaHeading("Concepts", "#anki-list-qa")).toBe(false);
});
it("cuts a child answer at the first 2+ blank lines when no valid marker exists", () => {
const parser = new SemanticQaListParser();
const cards = parser.parse({
parentHeadingText: "Concepts #anki-list-qa",
marker: "#anki-list-qa",
cardAnswerCutoffMode: "double-blank-lines",
bodyLines: [
"- Alpha",
" First line",
" ",
" ",
" Remark",
],
bodyStartLine: 2,
});
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
bodyMarkdown: "First line",
contentEndLine: 3,
markerLine: undefined,
idMarkerState: "missing",
});
});
it("keeps the old marker-after-remarks semantic layout compatible", () => {
const parser = new SemanticQaListParser();
const cards = parser.parse({
parentHeadingText: "Concepts #anki-list-qa",
marker: "#anki-list-qa",
bodyLines: [
"- Alpha",
" First line",
" Remark",
" <!--ID: 12-->",
],
bodyStartLine: 2,
});
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
bodyMarkdown: "First line\nRemark",
contentEndLine: 4,
markerLine: 5,
markerNoteId: 12,
idMarkerState: "present-valid",
});
});
it("treats a marker before trailing remarks as the semantic child cutoff", () => {
const parser = new SemanticQaListParser();
const cards = parser.parse({
parentHeadingText: "Concepts #anki-list-qa",
marker: "#anki-list-qa",
bodyLines: [
"- Alpha",
" First line",
" <!--ID: 12-->",
" ",
" ",
" Remark",
],
bodyStartLine: 2,
});
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
bodyMarkdown: "First line",
contentEndLine: 3,
markerLine: 4,
markerNoteId: 12,
idMarkerState: "present-valid",
});
});
});

View file

@ -1,261 +0,0 @@
import type { IdMarkerState } from "@/domain/manual-sync/entities/IdMarker";
import type { CardAnswerCutoffMode } from "@/application/config/PluginSettings";
import { hashString } from "@/domain/shared/hash";
import { CardMarkerService } from "./CardMarkerService";
import { resolveAnswerBoundary } from "./AnswerBoundaryParser";
const LIST_ITEM_REGEXP = /^([ \t]*)(?:[-+*]|\d+[.)])\s+(.*)$/;
export interface SemanticQaListParserInput {
parentHeadingText: string;
marker: string;
bodyLines: string[];
bodyStartLine: number;
cardAnswerCutoffMode?: CardAnswerCutoffMode;
}
export interface SemanticQaListCard {
heading: string;
backlinkHeadingText: string;
bodyMarkdown: string;
blockStartLine: number;
bodyStartLine: number;
blockEndLine: number;
contentEndLine: number;
markerLine?: number;
markerIndent?: string;
markerNoteId?: number;
idMarkerState: IdMarkerState;
rawBlockText: string;
rawBlockHash: string;
}
interface ListItemMatch {
index: number;
indent: string;
labelMarkdown: string;
}
interface ChildRegion {
rawLines: string[];
startIndex: number;
endIndex: number;
}
export class SemanticQaListParser {
constructor(private readonly markerService = new CardMarkerService()) {}
isSemanticQaHeading(headingText: string, marker: string): boolean {
return headingText.trimEnd().endsWith(marker);
}
parse(input: SemanticQaListParserInput): SemanticQaListCard[] {
const displayParentHeadingText = stripSemanticQaMarker(input.parentHeadingText, input.marker);
const rootItems = collectRootListItems(input.bodyLines);
if (rootItems.length === 0) {
return [];
}
const occurrenceByLabel = new Map<string, number>();
const cards: SemanticQaListCard[] = [];
for (let index = 0; index < rootItems.length; index += 1) {
const item = rootItems[index];
const nextItem = rootItems[index + 1];
const childRegion = collectChildRegion(
input.bodyLines,
item.index + 1,
nextItem?.index ?? input.bodyLines.length,
item.indent.length,
);
if (!childRegion) {
continue;
}
const absoluteBodyStartLine = input.bodyStartLine + childRegion.startIndex;
const boundary = resolveAnswerBoundary({
lines: childRegion.rawLines,
startLine: absoluteBodyStartLine,
fallbackLine: input.bodyStartLine + item.index,
cutoffMode: input.cardAnswerCutoffMode ?? "heading-block",
markerAdapter: this.markerService,
});
const trimmedBodyLines = trimBlankEdges(boundary.contentLines);
if (trimmedBodyLines.length === 0) {
continue;
}
const bodyMarkdown = dedentLines(trimmedBodyLines).join("\n");
const normalizedLabel = normalizeSemanticLabel(item.labelMarkdown);
const occurrenceIndex = (occurrenceByLabel.get(normalizedLabel) ?? 0) + 1;
occurrenceByLabel.set(normalizedLabel, occurrenceIndex);
const childKey = `${normalizedLabel}::${occurrenceIndex}`;
const heading = `${displayParentHeadingText}<br>${item.labelMarkdown}`;
const rawBlockText = [
`semantic-qa:${childKey}`,
displayParentHeadingText,
item.labelMarkdown,
bodyMarkdown,
].join("\n").trimEnd();
cards.push({
heading,
backlinkHeadingText: input.parentHeadingText,
bodyMarkdown,
blockStartLine: input.bodyStartLine + item.index,
bodyStartLine: absoluteBodyStartLine,
blockEndLine: input.bodyStartLine + childRegion.endIndex,
contentEndLine: boundary.contentEndLine,
markerLine: boundary.markerLine,
markerIndent: boundary.markerIndent ?? deriveMarkerIndent(trimmedBodyLines, item.indent),
markerNoteId: boundary.marker?.noteId,
idMarkerState: boundary.markerState,
rawBlockText,
rawBlockHash: hashString(rawBlockText),
});
}
return cards;
}
}
function stripSemanticQaMarker(headingText: string, marker: string): string {
const trimmedHeading = headingText.trimEnd();
if (!trimmedHeading.endsWith(marker)) {
return trimmedHeading;
}
return trimmedHeading.slice(0, trimmedHeading.length - marker.length).trimEnd();
}
function collectRootListItems(lines: string[]): ListItemMatch[] {
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;
}
candidates.push({
index,
indent: match[1],
labelMarkdown: match[2].trimEnd(),
});
}
if (candidates.length === 0) {
return [];
}
const rootIndentLength = Math.min(...candidates.map((candidate) => candidate.indent.length));
return candidates.filter((candidate) => candidate.indent.length === rootIndentLength);
}
function collectChildRegion(
lines: string[],
startIndex: number,
endIndexExclusive: number,
parentIndentLength: number,
): ChildRegion | null {
let childStartIndex: number | undefined;
let childEndIndex = startIndex - 1;
let fenceMarker: string | null = null;
for (let index = startIndex; index < endIndexExclusive; index += 1) {
const line = lines[index];
const trimmed = line.trim();
const indentLength = leadingWhitespace(line).length;
const insideFence = Boolean(fenceMarker);
if (childStartIndex === undefined) {
if (!trimmed) {
continue;
}
if (indentLength <= parentIndentLength) {
return null;
}
childStartIndex = index;
} else if (!insideFence && trimmed && indentLength <= parentIndentLength) {
break;
}
childEndIndex = index;
if (isFenceLine(trimmed)) {
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
}
}
if (childStartIndex === undefined) {
return null;
}
return {
rawLines: lines.slice(childStartIndex, childEndIndex + 1),
startIndex: childStartIndex,
endIndex: childEndIndex,
};
}
function trimBlankEdges(lines: string[]): string[] {
let startIndex = 0;
let endIndex = lines.length;
while (startIndex < endIndex && !lines[startIndex].trim()) {
startIndex += 1;
}
while (endIndex > startIndex && !lines[endIndex - 1].trim()) {
endIndex -= 1;
}
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 deriveMarkerIndent(bodyLines: string[], itemIndent: string): string {
const firstNonEmptyLine = bodyLines.find((line) => line.trim().length > 0);
if (firstNonEmptyLine) {
return leadingWhitespace(firstNonEmptyLine);
}
return `${itemIndent} `;
}
function leadingWhitespace(line: string): string {
const match = line.match(/^[ \t]*/);
return match?.[0] ?? "";
}
function normalizeSemanticLabel(labelMarkdown: string): string {
return labelMarkdown.trim().replace(/\s+/g, " ");
}
function isFenceLine(trimmedLine: string): boolean {
return trimmedLine.startsWith("```") || trimmedLine.startsWith("~~~");
}

View file

@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { TFile, TFolder } from "obsidian";
import { MarkdownFileNotFoundError, MarkdownWriteConflictError } from "@/application/ports/VaultGateway";
import { ObsidianVaultGateway } from "./ObsidianVaultGateway";
import { normalizeObsidianTags } from "./normalizeObsidianTags";
@ -87,6 +89,53 @@ describe("ObsidianVaultGateway", () => {
]);
});
it("writes markdown files through vault.process when the expected content still matches", async () => {
const FileCtor = TFile as unknown as new (path: string) => TFile;
const file = new FileCtor("notes/example.md");
let processedFile: TFile | undefined;
let writtenContent: string | undefined;
const gateway = new ObsidianVaultGateway({
vault: {
getAbstractFileByPath: () => file,
process: async (targetFile: TFile, updater: (data: string) => string) => {
processedFile = targetFile;
writtenContent = updater("# Title\nBody");
return writtenContent;
},
},
} as never);
await gateway.replaceMarkdownFile("notes/example.md", "# Title\nBody", "# Title\nUpdated");
expect(processedFile).toBe(file);
expect(writtenContent).toBe("# Title\nUpdated");
});
it("throws a write conflict when vault.process sees changed content", async () => {
const FileCtor = TFile as unknown as new (path: string) => TFile;
const file = new FileCtor("notes/example.md");
const gateway = new ObsidianVaultGateway({
vault: {
getAbstractFileByPath: () => file,
process: async (_targetFile: TFile, updater: (data: string) => string) => updater("# Title\nChanged"),
},
} as never);
await expect(gateway.replaceMarkdownFile("notes/example.md", "# Title\nBody", "# Title\nUpdated"))
.rejects.toBeInstanceOf(MarkdownWriteConflictError);
});
it("throws when replacing a markdown file that does not exist", async () => {
const gateway = new ObsidianVaultGateway({
vault: {
getAbstractFileByPath: () => null,
},
} as never);
await expect(gateway.replaceMarkdownFile("notes/missing.md", "before", "after"))
.rejects.toBeInstanceOf(MarkdownFileNotFoundError);
});
it("normalizes trailing heading tags when creating backlinks", () => {
const gateway = new ObsidianVaultGateway({
vault: {

View file

@ -58,12 +58,13 @@ export class ObsidianVaultGateway implements VaultGateway, ManualSyncVaultGatewa
throw new MarkdownFileNotFoundError(path);
}
const currentContent = await this.app.vault.cachedRead(abstractFile);
if (currentContent !== expectedContent) {
throw new MarkdownWriteConflictError(path);
}
await this.app.vault.process(abstractFile, (currentContent) => {
if (currentContent !== expectedContent) {
throw new MarkdownWriteConflictError(path);
}
await this.app.vault.modify(abstractFile, nextContent);
return nextContent;
});
}
resolveWikiLink(rawTarget: string, sourcePath: string) {

View file

@ -43,8 +43,6 @@ describe("DataJsonPluginConfigRepository", () => {
expect(settings.folderDeckMode).toBe("folder-and-file");
expect(settings.qaGroupMarker).toBe("#anki-list");
expect(settings.cardAnswerCutoffMode).toBe("heading-block");
expect(settings.semanticQaMarker).toBe("#anki-list-qa");
expect(settings.semanticQaNoteType).toBe("Semantic QA");
expect(settings.obsidianBacklinkLabel).toBe("Open in Obsidian");
expect(settings.obsidianBacklinkPlacement).toBe("answer-last-line");
expect(settings.ankiModelFieldCache).toEqual({});
@ -150,49 +148,29 @@ describe("DataJsonPluginConfigRepository", () => {
});
});
it("persists semantic QA settings and mappings across save and reload", async () => {
const store = new InMemoryPluginDataStore();
const repository = new DataJsonPluginConfigRepository(store);
await repository.save({
...DEFAULT_SETTINGS,
cardAnswerCutoffMode: "double-blank-lines",
semanticQaMarker: "#semantic-qa",
semanticQaNoteType: "Semantic QA",
cardTypeConfigs: {
...DEFAULT_SETTINGS.cardTypeConfigs,
"semantic-qa": {
...DEFAULT_SETTINGS.cardTypeConfigs["semantic-qa"],
extraMarker: "#semantic-qa",
noteType: "Semantic QA",
it("drops removed semantic QA config fields and mappings during load normalization", async () => {
const repository = new DataJsonPluginConfigRepository(new InMemoryPluginDataStore({
settings: {
cardAnswerCutoffMode: "double-blank-lines",
semanticQaMarker: "#semantic-qa",
semanticQaNoteType: "Semantic QA",
noteFieldMappings: {
"semantic-qa:Semantic QA": {
cardType: "semantic-qa",
modelName: "Semantic QA",
loadedFieldNames: ["Title", "Body"],
titleField: "Title",
bodyField: "Body",
loadedAt: 456,
},
},
},
noteFieldMappings: {
"semantic-qa:Semantic QA": {
cardType: "semantic-qa",
modelName: "Semantic QA",
loadedFieldNames: ["Title", "Body"],
titleField: "Title",
bodyField: "Body",
loadedAt: 456,
},
},
});
} as never,
}));
const reloaded = await repository.load();
expect(reloaded.cardAnswerCutoffMode).toBe("double-blank-lines");
expect(reloaded.semanticQaMarker).toBe("#semantic-qa");
expect(reloaded.semanticQaNoteType).toBe("Semantic QA");
expect(reloaded.noteFieldMappings).toEqual({
"semantic-qa:Semantic QA": {
cardType: "semantic-qa",
modelName: "Semantic QA",
loadedFieldNames: ["Title", "Body"],
titleField: "Title",
bodyField: "Body",
loadedAt: 456,
},
});
expect(reloaded.noteFieldMappings).toEqual({});
expect("semantic-qa" in reloaded.cardTypeConfigs).toBe(false);
});
});

View file

@ -115,7 +115,7 @@ describe("DataJsonPluginStateRepository", () => {
expect(loaded.pendingWriteBack).toEqual([]);
});
it("preserves semantic QA card type when loading modern plugin state", async () => {
it("normalizes removed semantic QA card types to basic when loading modern plugin state", async () => {
const rawBlockText = ["semantic-qa:核心产品::1", "城市更新", "核心产品", "百人会"].join("\n");
const repository = new DataJsonPluginStateRepository(new InMemoryPluginDataStore({
pluginState: {
@ -136,7 +136,7 @@ describe("DataJsonPluginStateRepository", () => {
backlinkHeadingText: "城市更新 #anki-list-qa",
headingLevel: 4,
bodyMarkdown: "百人会",
cardType: "semantic-qa",
cardType: "semantic-qa" as never,
blockStartOffset: 0,
blockEndOffset: rawBlockText.length,
blockStartLine: 2,
@ -159,7 +159,7 @@ describe("DataJsonPluginStateRepository", () => {
const loaded = await repository.load();
expect(loaded.cards["52"]?.cardType).toBe("semantic-qa");
expect(loaded.cards["52"]?.cardType).toBe("basic");
expect(loaded.cards["52"]?.backlinkHeadingText).toBe("城市更新 #anki-list-qa");
});
});

View file

@ -135,7 +135,7 @@ function migrateCardState(rawCard: LegacyCardState, noteId: number): CardState {
}
function sanitizeCardType(value: unknown): CardState["cardType"] {
if (value === "cloze" || value === "semantic-qa") {
if (value === "cloze") {
return value;
}

View file

@ -6,7 +6,6 @@ import { DeckTemplateInsertionService } from "@/application/services/DeckTemplat
import { CleanupEmptyDecksUseCase } from "@/application/use-cases/CleanupEmptyDecksUseCase";
import { ClearCurrentFileSyncedCardsUseCase } from "@/application/use-cases/ClearCurrentFileSyncedCardsUseCase";
import { ManualSyncCurrentFileUseCase } from "@/application/use-cases/ManualSyncCurrentFileUseCase";
import { RebuildCardIndexUseCase } from "@/application/use-cases/RebuildCardIndexUseCase";
import { ManualSyncVaultUseCase } from "@/application/use-cases/ManualSyncVaultUseCase";
import { AnkiConnectGateway } from "@/infrastructure/anki/AnkiConnectGateway";
import { ObsidianPluginDataStore } from "@/infrastructure/obsidian/ObsidianPluginDataStore";
@ -19,7 +18,7 @@ import { EmptyDeckSelectionModal } from "@/presentation/modals/EmptyDeckSelectio
import { t } from "@/presentation/i18n";
import { NoticeService } from "@/presentation/notices/NoticeService";
import { AnkiHeadingSyncSettingTab } from "@/presentation/settings/PluginSettingTab";
import { CurrentFileOutOfScopeError, ManualSyncService } from "@/application/services/ManualSyncService";
import { CurrentFileOutOfScopeError, ManualSyncService, RunScopeNotConfiguredError } from "@/application/services/ManualSyncService";
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
@ -32,7 +31,6 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
private syncCurrentFileUseCase?: ManualSyncCurrentFileUseCase;
private syncVaultUseCase?: ManualSyncVaultUseCase;
private rebuildCardIndexUseCase?: RebuildCardIndexUseCase;
private clearCurrentFileSyncedCardsUseCase?: ClearCurrentFileSyncedCardsUseCase;
private cleanupEmptyDecksUseCase?: CleanupEmptyDecksUseCase;
private pluginConfigRepository?: DataJsonPluginConfigRepository;
@ -56,7 +54,6 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
const manualSyncService = new ManualSyncService(vaultGateway, pluginStateRepository, this.ankiGateway);
this.syncCurrentFileUseCase = new ManualSyncCurrentFileUseCase(manualSyncService);
this.syncVaultUseCase = new ManualSyncVaultUseCase(manualSyncService);
this.rebuildCardIndexUseCase = new RebuildCardIndexUseCase(manualSyncService);
this.clearCurrentFileSyncedCardsUseCase = new ClearCurrentFileSyncedCardsUseCase(pluginStateRepository, this.ankiGateway, vaultGateway);
this.cleanupEmptyDecksUseCase = new CleanupEmptyDecksUseCase(this.ankiGateway);
@ -119,7 +116,7 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
const result = await this.syncCurrentFileUseCase.execute(activeFile.path, this.settings);
this.noticeService.showSyncSummary("currentFile", result);
} catch (error) {
if (error instanceof CurrentFileOutOfScopeError) {
if (error instanceof CurrentFileOutOfScopeError || error instanceof RunScopeNotConfiguredError) {
this.noticeService.info(renderUserMessage(error));
return;
}
@ -139,26 +136,16 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
const result = await this.syncVaultUseCase.execute(this.settings);
this.noticeService.showSyncSummary("vault", result);
} catch (error) {
if (error instanceof RunScopeNotConfiguredError) {
this.noticeService.info(renderUserMessage(error));
return;
}
console.error("Vault sync failed.", error);
this.noticeService.error(renderUnknownUserFacingError(error, "notice.vaultSyncFailed"));
}
}
async runRebuildCardIndex(): Promise<void> {
if (!this.rebuildCardIndexUseCase) {
this.noticeService.error(t("notice.syncUseCaseNotInitialized"));
return;
}
try {
const result = await this.rebuildCardIndexUseCase.execute(this.settings);
this.noticeService.showRebuildSummary(result);
} catch (error) {
console.error("Card index rebuild failed.", error);
this.noticeService.error(renderUnknownUserFacingError(error, "notice.rebuildFailed"));
}
}
async insertDeckTemplateToCurrentFile(): Promise<void> {
const activeFile = this.app.workspace.getActiveFile();

View file

@ -6,7 +6,6 @@ export const en = {
commands: {
syncCurrentFileToAnki: "Sync current file to Anki",
syncVaultToAnki: "Sync vault to Anki",
rebuildCardIndex: "Rebuild card index",
clearCurrentFileSyncedCards: "Clear synced cards in current file",
cleanupEmptyDecks: "Clean up empty decks",
},
@ -43,19 +42,6 @@ export const en = {
managedNoteType: "Managed note type: {{modelName}}. QA Group sync writes Stem / GroupId / Src / S01..S12 directly. The same note type can also appear in the field mapping panels below if you want to reuse it for other routes.",
managedModelContract: "Managed model contract: {{fieldCount}} fields and {{templateCount}} templates are checked automatically during sync.",
},
semanticQa: {
title: "Semantic QA",
marker: {
name: "Semantic QA marker",
desc: "When a QA heading ends with this hashtag marker, first-level list items with indented child content become separate cards.",
placeholder: "#anki-list-qa",
},
previewTitle: "Semantic QA preview",
triggerHeadingExample: "Trigger heading example: {{heading}}",
previewUnavailable: "Preview unavailable. Use a trailing hashtag marker such as #anki-list-qa.",
questionPreview: "Question preview: {{question}}",
answerPreview: "Answer preview: {{answer}}",
},
syncOptions: {
addObsidianBacklink: {
name: "Add Obsidian backlink",
@ -118,10 +104,6 @@ export const en = {
title: "Cloze",
description: "Choose the cloze note type, read its fields from Anki, then confirm the main field mapping.",
},
semanticQa: {
title: "Semantic QA",
description: "Choose the semantic QA note type, read its fields from Anki, then confirm the title/body mapping for child cards.",
},
},
noteTypeLabel: "{{title}} note type",
noteTypeDesc: "Loaded from Anki note types. Refresh if the latest models are not shown.",
@ -199,6 +181,7 @@ export const en = {
include: "Only process Markdown files in the checked folders below",
exclude: "Process the whole vault, but skip Markdown files in the checked folders below",
},
unconfiguredWarning: "Run scope is not configured yet. Include mode requires at least one selected folder before sync can run.",
option: {
all: "All files",
include: "Only in selected folders",
@ -277,7 +260,6 @@ export const en = {
basic: "Q&A (regular paragraph)",
qaGroup: "Q&A (nested list)",
cloze: "Cloze",
semanticQa: "Semantic Q&A",
},
},
syncContent: {
@ -310,7 +292,6 @@ export const en = {
items: {
syncCurrentFile: "Sync only the active Markdown file to Anki.",
syncVault: "Sync all in-scope Markdown files in the vault to Anki.",
rebuildIndex: "Rebuild the local card index without creating or updating notes.",
clearCurrentFile: "Clear synced card markers and tracked sync state for the active file.",
cleanupDecks: "Delete selected empty decks from Anki.",
},
@ -336,7 +317,6 @@ export const en = {
syncUseCaseNotInitialized: "Sync use case is not initialized.",
currentFileSyncFailed: "Current file sync failed.",
vaultSyncFailed: "Vault sync failed.",
rebuildFailed: "Card index rebuild failed.",
noActiveMarkdownForDeckTemplateInsertion: "No active Markdown file is available for deck template insertion.",
vaultGatewayNotInitialized: "Vault gateway is not initialized.",
markdownFileNotFound: "Markdown file not found: {{filePath}}",
@ -354,7 +334,6 @@ export const en = {
summary: {
currentFileSync: "Current file sync completed: files {{scannedFiles}}, cards {{scannedCards}}, created {{created}}, updated {{updated}}, migrated note types {{migratedNoteTypes}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, media {{uploadedMedia}}, skipped {{skippedUnchangedCards}}.",
vaultSync: "Vault sync completed: files {{scannedFiles}}, cards {{scannedCards}}, created {{created}}, updated {{updated}}, migrated note types {{migratedNoteTypes}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, media {{uploadedMedia}}, skipped {{skippedUnchangedCards}}.",
rebuild: "Card index rebuild completed: files {{scannedFiles}}, cards {{scannedCards}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, rewritten markers {{rewrittenMarkers}}, skipped {{skippedUnchangedCards}}.",
clearCurrentFile: "Current file synced cards cleared: tracked cards {{trackedCards}}, tracked groups {{trackedGroups}}, deleted notes {{deletedNotes}}, removed markers {{removedMarkers}}, removed ID markers {{removedCardMarkers}}, removed GI markers {{removedGroupMarkers}}, deleted local records {{deletedLocalRecords}}.",
cleanupEmptyDecks: "Empty-deck cleanup completed: candidates {{candidateCount}}, selected {{selectedCount}}, deleted {{deletedCount}}, skipped {{skippedCount}}.",
markerWriteConflicts: "Marker write conflicts: {{files}}.",
@ -365,6 +344,7 @@ export const en = {
},
},
errors: {
runScopeNotConfigured: "Run scope is not configured. In include mode, select at least one folder before syncing.",
currentFileOutOfScope: "The current file is outside the plugin scope: {{filePath}}",
deck: {
emptyName: "Deck name cannot be empty. Check the file-level deck declaration or default deck setting.",
@ -381,10 +361,6 @@ export const en = {
qaGroupMarkerRequired: "QA Group marker is required.",
qaGroupMarkerInvalid: "QA Group marker must be a hashtag-style token like #anki-list.",
clozeNoteTypeRequired: "Cloze note type is required.",
semanticQaMarkerRequired: "Semantic QA marker is required.",
semanticQaMarkerInvalid: "Semantic QA marker must be a hashtag-style token like #anki-list-qa.",
qaGroupMarkerConflict: "QA Group marker must be different from Semantic QA marker.",
semanticQaNoteTypeRequired: "Semantic QA note type is required.",
defaultDeckRequired: "Default deck is required.",
fileDeckEnabledBoolean: "File deck enabled must be a boolean.",
syncObsidianTagsToAnkiBoolean: "Sync Obsidian tags to Anki must be a boolean.",
@ -430,23 +406,19 @@ export const en = {
noteTypeNotSelected: {
basic: "No Anki note type is selected for basic cards. Choose one in settings before syncing.",
cloze: "No Anki note type is selected for cloze cards. Choose one in settings before syncing.",
semanticQa: "No Anki note type is selected for semantic QA cards. Choose one in settings before syncing.",
qaGroup: "No Anki note type is selected for QA Group cards. Choose one in settings before syncing.",
},
missingSavedMapping: {
basic: "No saved field mapping found for basic note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
cloze: "No saved field mapping found for cloze note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
semanticQa: "No saved field mapping found for semantic QA note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
qaGroup: "No saved field mapping found for QA Group note type \"{{modelName}}\". Open plugin settings, read fields from Anki, and save the mapping first.",
},
incompleteSavedMapping: {
basic: "Saved field mapping for basic note type \"{{modelName}}\" is incomplete. Open plugin settings and save both title and body fields.",
semanticQa: "Saved field mapping for semantic QA note type \"{{modelName}}\" is incomplete. Open plugin settings and save both title and body fields.",
cloze: "Saved field mapping for cloze note type \"{{modelName}}\" is incomplete. Open plugin settings and save the main field.",
},
titleBodyMustDiffer: {
basic: "Basic note type \"{{modelName}}\" must use different title and body fields.",
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.",
qaGroupMissingTitle: "Saved field mapping for QA Group note type \"{{modelName}}\" is incomplete because the title field is missing. Save the mapping again in settings.",

View file

@ -4,7 +4,6 @@ export const zh = {
commands: {
syncCurrentFileToAnki: "同步当前文件到 Anki",
syncVaultToAnki: "同步全库到 Anki",
rebuildCardIndex: "重建卡片索引",
clearCurrentFileSyncedCards: "清空当前文件已同步卡片",
cleanupEmptyDecks: "清理空牌组",
},
@ -41,19 +40,6 @@ export const zh = {
managedNoteType: "托管笔记类型:{{modelName}}。QA Group 同步会直接写入 Stem / GroupId / Src / S01..S12。如果你想在其他同步路径里复用它它也会出现在下面的字段映射面板中。",
managedModelContract: "托管模型约束:同步时会自动检查 {{fieldCount}} 个字段和 {{templateCount}} 个模板。",
},
semanticQa: {
title: "语义 QA",
marker: {
name: "语义 QA 标记",
desc: "当 QA 标题以这个 hashtag 标记结尾时,带缩进子内容的一级列表项会拆分成独立卡片。",
placeholder: "#anki-list-qa",
},
previewTitle: "语义 QA 预览",
triggerHeadingExample: "触发标题示例:{{heading}}",
previewUnavailable: "当前无法生成预览。请使用类似 #anki-list-qa 的尾随 hashtag 标记。",
questionPreview: "问题预览:{{question}}",
answerPreview: "答案预览:{{answer}}",
},
syncOptions: {
addObsidianBacklink: {
name: "添加 Obsidian 回链",
@ -116,10 +102,6 @@ export const zh = {
title: "填空题",
description: "选择填空题笔记类型,从 Anki 读取字段,然后确认主字段映射。",
},
semanticQa: {
title: "语义 QA",
description: "选择语义 QA 笔记类型,从 Anki 读取字段,然后确认子卡片的标题/正文映射。",
},
},
noteTypeLabel: "{{title}} 笔记类型",
noteTypeDesc: "从 Anki 的笔记类型列表加载。如果最新模型没有显示,请先刷新。",
@ -197,6 +179,7 @@ export const zh = {
include: "仅处理下方勾选文件夹中的 Markdown 文件",
exclude: "处理整个 vault但跳过下方勾选文件夹中的 Markdown 文件",
},
unconfiguredWarning: "运行范围尚未配置。当前是 include 模式,至少选择一个文件夹后才能同步。",
option: {
all: "全部文件",
include: "仅在指定文件夹",
@ -275,7 +258,6 @@ export const zh = {
basic: "问答题(常规段落形式)",
qaGroup: "问答题(多级列表形式)",
cloze: "填空题",
semanticQa: "语义问答题",
},
},
syncContent: {
@ -308,7 +290,6 @@ export const zh = {
items: {
syncCurrentFile: "只把当前活动 Markdown 文件同步到 Anki。",
syncVault: "把全库中所有处于作用范围内的 Markdown 文件同步到 Anki。",
rebuildIndex: "只重建本地卡片索引,不创建或更新远端笔记。",
clearCurrentFile: "清空当前文件的已同步标记和本地跟踪状态。",
cleanupDecks: "从 Anki 删除所选空牌组。",
},
@ -334,7 +315,6 @@ export const zh = {
syncUseCaseNotInitialized: "同步用例尚未初始化。",
currentFileSyncFailed: "当前文件同步失败。",
vaultSyncFailed: "全库同步失败。",
rebuildFailed: "卡片索引重建失败。",
noActiveMarkdownForDeckTemplateInsertion: "当前没有可用于插入牌组模板的活动 Markdown 文件。",
vaultGatewayNotInitialized: "Vault 网关尚未初始化。",
markdownFileNotFound: "未找到 Markdown 文件:{{filePath}}",
@ -352,7 +332,6 @@ export const zh = {
summary: {
currentFileSync: "当前文件同步完成:文件 {{scannedFiles}},卡片 {{scannedCards}},新建 {{created}},更新 {{updated}},迁移模板 {{migratedNoteTypes}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},媒体 {{uploadedMedia}},跳过 {{skippedUnchangedCards}}。",
vaultSync: "全库同步完成:文件 {{scannedFiles}},卡片 {{scannedCards}},新建 {{created}},更新 {{updated}},迁移模板 {{migratedNoteTypes}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},媒体 {{uploadedMedia}},跳过 {{skippedUnchangedCards}}。",
rebuild: "卡片索引重建完成:文件 {{scannedFiles}},卡片 {{scannedCards}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},重写标记 {{rewrittenMarkers}},跳过 {{skippedUnchangedCards}}。",
clearCurrentFile: "当前文件已同步卡片清空完成:已跟踪卡片 {{trackedCards}},已跟踪分组 {{trackedGroups}},删除笔记 {{deletedNotes}},移除标记 {{removedMarkers}},移除 ID 标记 {{removedCardMarkers}},移除 GI 标记 {{removedGroupMarkers}},删除本地记录 {{deletedLocalRecords}}。",
cleanupEmptyDecks: "空牌组清理完成:候选 {{candidateCount}},已选 {{selectedCount}},已删 {{deletedCount}},跳过 {{skippedCount}}。",
markerWriteConflicts: "标记写回冲突:{{files}}。",
@ -363,6 +342,7 @@ export const zh = {
},
},
errors: {
runScopeNotConfigured: "运行范围尚未配置。当前是 include 模式,请至少选择一个文件夹后再同步。",
currentFileOutOfScope: "当前文件不在插件作用范围内:{{filePath}}",
deck: {
emptyName: "牌组不能为空,请检查文件级牌组声明或默认牌组设置。",
@ -379,10 +359,6 @@ export const zh = {
qaGroupMarkerRequired: "QA Group 标记不能为空。",
qaGroupMarkerInvalid: "QA Group 标记必须是类似 #anki-list 的 hashtag 样式 token。",
clozeNoteTypeRequired: "填空题笔记类型不能为空。",
semanticQaMarkerRequired: "语义 QA 标记不能为空。",
semanticQaMarkerInvalid: "语义 QA 标记必须是类似 #anki-list-qa 的 hashtag 样式 token。",
qaGroupMarkerConflict: "QA Group 标记必须和语义 QA 标记不同。",
semanticQaNoteTypeRequired: "语义 QA 笔记类型不能为空。",
defaultDeckRequired: "默认牌组不能为空。",
fileDeckEnabledBoolean: "文件级牌组开关必须是布尔值。",
syncObsidianTagsToAnkiBoolean: "同步 Obsidian 标签到 Anki 开关必须是布尔值。",
@ -428,23 +404,19 @@ export const zh = {
noteTypeNotSelected: {
basic: "基础卡尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
cloze: "填空题尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
semanticQa: "语义 QA 尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
qaGroup: "问答题(多级列表)尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
},
missingSavedMapping: {
basic: "找不到基础卡笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
cloze: "找不到填空题笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
semanticQa: "找不到语义 QA 笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
qaGroup: "找不到问答题(多级列表)笔记模板 \"{{modelName}}\" 的已保存字段映射。请先在设置页从 Anki 读取字段并保存映射。",
},
incompleteSavedMapping: {
basic: "基础卡笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存标题字段和正文字段。",
semanticQa: "语义 QA 笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存标题字段和正文字段。",
cloze: "填空题笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存主字段。",
},
titleBodyMustDiffer: {
basic: "基础卡笔记类型 \"{{modelName}}\" 的标题字段和正文字段必须不同。",
semanticQa: "语义 QA 笔记类型 \"{{modelName}}\" 的标题字段和正文字段必须不同。",
},
stale: "笔记类型 \"{{modelName}}\" 的已保存字段映射已过期,因为这些字段在 Anki 中已不存在:{{fields}}。请重新从 Anki 读取字段。",
qaGroupMissingTitle: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的已保存字段映射不完整,缺少标题字段。请在设置页重新保存字段映射。",

View file

@ -100,20 +100,6 @@ describe("NoticeService", () => {
]);
});
it("renders rebuild summary and warnings in Simplified Chinese", () => {
getLanguageMock.mockReturnValue("zh");
const service = new NoticeService();
service.showRebuildSummary(createManualSyncResult({
warnings: [{ filePath: "notes/a.md", code: "deck_multiple_body_declarations", params: { marker: "MY DECK" } }],
}));
expect(noticeRecords.map((entry) => entry.message)).toEqual([
"卡片索引重建完成:文件 3卡片 8迁移牌组 1孤儿 0重写标记 2跳过 5。 警告1。",
"检测到文件中存在多个 MY DECK 声明,本次同步只使用第一个正文声明。",
]);
});
it("renders clear-current-file summaries with localized failures", () => {
getLanguageMock.mockReturnValue("zh");
const service = new NoticeService();

View file

@ -46,32 +46,6 @@ export class NoticeService {
}
}
showRebuildSummary(result: ManualSyncResult): void {
const locale = resolvePluginLocale();
const segments = [t("notice.summary.rebuild", {
scannedFiles: result.scannedFiles,
scannedCards: result.scannedCards,
migratedDecks: result.migratedDecks,
orphaned: result.orphaned,
rewrittenMarkers: result.rewrittenMarkers,
skippedUnchangedCards: result.skippedUnchangedCards,
})];
if (result.markerWriteConflictFiles.length > 0) {
segments.push(t("notice.summary.markerWriteConflicts", {
files: formatList(locale, result.markerWriteConflictFiles),
}));
}
if (result.warnings.length > 0) {
segments.push(t("notice.summary.warningsCount", { count: result.warnings.length }));
}
this.info(segments.join(" "));
for (const warning of result.warnings.slice(0, 3)) {
this.info(this.renderDeckWarning(warning));
}
}
showClearCurrentFileSummary(result: ClearCurrentFileSyncedCardsResult): void {
const locale = resolvePluginLocale();
const segments = [t("notice.summary.clearCurrentFile", {

View file

@ -414,7 +414,7 @@ class FakePlugin {
public listFolderTreeCalls = 0;
public insertDeckTemplateCalls = 0;
public listNoteModelsError: Error | null = null;
public noteModels = ["Basic", "Custom Basic", "Cloze", "Custom Cloze", "Semantic QA", QA_GROUP_USER_MODEL_NAME];
public noteModels = ["Basic", "Custom Basic", "Cloze", "Custom Cloze", QA_GROUP_USER_MODEL_NAME];
public settings = normalizePluginSettings({
...DEFAULT_SETTINGS,
qaNoteType: "Custom Basic",
@ -504,10 +504,6 @@ class FakePlugin {
return ["Text", "Extra", "Hint"];
}
if (modelName === "Semantic QA") {
return ["Title", "Body", "Source"];
}
if (modelName === "Basic") {
return ["Front", "Back", "Extra"];
}
@ -796,7 +792,6 @@ describe("PluginSettingTab", () => {
"问答题(多级列表形式)",
"填空题",
]);
expect(() => queryByDataset(tab.containerEl, "cardTypeMarker", "semantic-qa")).toThrow("Element not found");
});
it("does not render the legacy card-types description copy", async () => {
@ -1008,6 +1003,32 @@ describe("PluginSettingTab", () => {
expect(plugin.updateCalls).toHaveLength(1);
});
it("flushes pending text saves when the settings tab hides", async () => {
vi.useFakeTimers();
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await expandCard(tab, "card-types");
const markerInput = queryByDataset(tab.containerEl, "cardTypeMarker", "cloze");
markerInput.value = "#cloze-hidden";
await markerInput.trigger("input");
expect(plugin.updateCalls).toHaveLength(0);
tab.hide();
await flushPromises();
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-hidden");
expect(plugin.updateCalls).toHaveLength(1);
vi.runOnlyPendingTimers();
await flushPromises();
expect(plugin.updateCalls).toHaveLength(1);
});
it("blocks conflicting default rows and shows the error in card 1", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
@ -1063,7 +1084,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("已获取 6 个笔记模板,已选择并配置 2 个卡片模式。"))).toBe(true);
expect(collectTexts(tab.containerEl).some((text) => text.includes("已获取 5 个笔记模板,已选择并配置 2 个卡片模式。"))).toBe(true);
});
it("persists loaded Anki note types and uses the cache before the next manual refresh", async () => {
@ -1080,7 +1101,6 @@ describe("PluginSettingTab", () => {
"Cloze",
"Custom Basic",
"Custom Cloze",
"Semantic QA",
QA_GROUP_USER_MODEL_NAME,
].sort((left, right) => left.localeCompare(right)));
expect(plugin.updateCalls.some((call) => Array.isArray(call.ankiNoteTypeCache))).toBe(true);
@ -1581,6 +1601,22 @@ describe("PluginSettingTab", () => {
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
});
it("shows an explicit warning when include mode has no selected folders", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: [],
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
expect(queryByDataset(tab.containerEl, "scopeWarning", "unconfigured").textContent).toContain("至少选择一个文件夹后才能同步");
});
it("toggling file deck mode refreshes only the deck card", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);

View file

@ -2,6 +2,7 @@ import { ButtonComponent, PluginSettingTab, Setting } from "obsidian";
import {
DEFAULT_OBSIDIAN_BACKLINK_LABEL,
isRunScopeConfigured,
normalizePluginSettings,
type CardAnswerCutoffMode,
type CardTypeConfigId,
@ -60,13 +61,18 @@ interface SettingsCardShell {
bodyEl: HTMLElement;
}
interface PendingTextSave {
timer: ReturnType<typeof setTimeout>;
saveAction: (draftValue: string) => Promise<void>;
}
export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private readonly noteFieldMappingService = new NoteFieldMappingService();
private readonly qaGroupFieldMappingService = new QaGroupFieldMappingService();
private readonly availableNoteModels: string[] = [];
private readonly draftMappings: Record<string, NoteModelFieldMapping> = {};
private readonly loadedModelDetails: Record<string, NoteModelDetails> = {};
private readonly debouncedTextSaves = new Map<string, ReturnType<typeof setTimeout>>();
private readonly debouncedTextSaves = new Map<string, PendingTextSave>();
private readonly textDraftValues = new Map<string, string>();
private readonly cardShells = new Map<SettingsCardId, SettingsCardShell>();
private readonly expandedCardIds = new Set<SettingsCardId>();
@ -98,7 +104,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
super.hide();
this.displayInitialized = false;
this.cardShells.clear();
this.clearDebouncedTextSaves();
void this.flushDebouncedTextSaves();
this.cardTypeStatusOverride = null;
this.noteTypeCacheCheckStatus = "idle";
this.noteTypeCacheCheckPromise = null;
@ -678,6 +684,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
if (!isRunScopeConfigured(this.plugin.settings.scopeMode, this.plugin.settings.includeFolders)) {
const warningEl = containerEl.createEl("p", { text: t("settings.scope.unconfiguredWarning") });
warningEl.dataset.scopeWarning = "unconfigured";
warningEl.style.color = "var(--text-warning)";
warningEl.style.fontWeight = "600";
}
if (this.plugin.settings.scopeMode === "all") {
return;
}
@ -1633,7 +1646,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
return "qa-group";
}
return configId === "cloze" ? "cloze" : configId === "semantic-qa" ? "semantic-qa" : "basic";
return configId === "cloze" ? "cloze" : "basic";
}
private ensureFolderTreeLoaded(forceReload = false): void {
@ -1837,24 +1850,30 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const pendingTimer = this.debouncedTextSaves.get(key);
if (pendingTimer) {
globalThis.clearTimeout(pendingTimer);
globalThis.clearTimeout(pendingTimer.timer);
}
const timer = globalThis.setTimeout(() => {
void saveAction(this.textDraftValues.get(key) ?? value).finally(() => {
this.debouncedTextSaves.delete(key);
});
void this.flushDebouncedTextSave(key);
}, TEXT_SAVE_DEBOUNCE_MS);
this.debouncedTextSaves.set(key, timer);
this.debouncedTextSaves.set(key, { timer, saveAction });
}
private clearDebouncedTextSaves(): void {
for (const timer of this.debouncedTextSaves.values()) {
globalThis.clearTimeout(timer);
private async flushDebouncedTextSave(key: string): Promise<void> {
const pendingSave = this.debouncedTextSaves.get(key);
if (!pendingSave) {
return;
}
this.debouncedTextSaves.clear();
globalThis.clearTimeout(pendingSave.timer);
this.debouncedTextSaves.delete(key);
await pendingSave.saveAction(this.textDraftValues.get(key) ?? "");
}
private async flushDebouncedTextSaves(): Promise<void> {
const keys = [...this.debouncedTextSaves.keys()];
await Promise.allSettled(keys.map((key) => this.flushDebouncedTextSave(key)));
}
private getDraftValue(key: string, persistedValue: string): string {
@ -1943,7 +1962,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
return t("settings.cards.cardTypes.rows.cloze");
}
return t("settings.cards.cardTypes.rows.semanticQa");
return t("settings.cards.cardTypes.rows.qaGroup");
}
}