mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
Finalize pre-release cleanup
This commit is contained in:
parent
771cf9c0cb
commit
a9033664b8
36 changed files with 677 additions and 1194 deletions
21
LICENSE
Normal file
21
LICENSE
Normal 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
35
README.md
Normal 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`.
|
||||
87
docs/pre-release-cleanup-decisions.md
Normal file
87
docs/pre-release-cleanup-decisions.md
Normal 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.
|
||||
170
docs/pre-release-cleanup-gap-report.md
Normal file
170
docs/pre-release-cleanup-gap-report.md
Normal 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
54
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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
6
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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地区"],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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("~~~");
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}}\" 的已保存字段映射不完整,缺少标题字段。请在设置页重新保存字段映射。",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue