diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7d1321b
--- /dev/null
+++ b/LICENSE
@@ -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.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b609e80
--- /dev/null
+++ b/README.md
@@ -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`.
diff --git a/docs/pre-release-cleanup-decisions.md b/docs/pre-release-cleanup-decisions.md
new file mode 100644
index 0000000..af1cc23
--- /dev/null
+++ b/docs/pre-release-cleanup-decisions.md
@@ -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.
diff --git a/docs/pre-release-cleanup-gap-report.md b/docs/pre-release-cleanup-gap-report.md
new file mode 100644
index 0000000..ed47fa4
--- /dev/null
+++ b/docs/pre-release-cleanup-gap-report.md
@@ -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.
diff --git a/main.js b/main.js
index 03cf176..b258b14 100644
--- a/main.js
+++ b/main.js
@@ -1,49 +1,47 @@
/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
*/
-"use strict";var Cn=Object.defineProperty;var So=Object.getOwnPropertyDescriptor;var Co=Object.getOwnPropertyNames;var Fo=Object.prototype.hasOwnProperty;var Qe=(e,t)=>{for(var n in t)Cn(e,n,{get:t[n],enumerable:!0})},Mo=(e,t,n,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of Co(t))!Fo.call(e,i)&&i!==n&&Cn(e,i,{get:()=>t[i],enumerable:!(r=So(t,i))||r.enumerable});return e};var wo=e=>Mo(Cn({},"__esModule",{value:!0}),e);var xl={};Qe(xl,{default:()=>bl});module.exports=wo(xl);var bo=require("obsidian");function L(e,t){return`${e}:${t}`}function Fn(e){return e?.cardType==="basic"||e?.cardType==="semantic-qa"}function Kr(e){return e?.cardType==="cloze"}function B(e){return e?.cardType==="qa-group"}var Zr=require("obsidian");function ie(){return(0,Zr.getLanguage)()==="zh"?"zh":"en"}function ae(e,t){return t.join(e==="zh"?"\u3001":", ")}var Mn={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"},settings:{pluginTitle:"Anki Heading Sync",ankiConnectUrl:{name:"AnkiConnect URL",desc:"Default is http://127.0.0.1:8765",placeholder:"http://127.0.0.1:8765"},qaHeadingLevel:{name:"QA heading level",desc:"Default is H4"},clozeHeadingLevel:{name:"Cloze heading level",desc:"Default is H5"},cardAnswerCutoffMode:{name:"Card answer cutoff mode",desc:"A valid sync marker always wins. Without a valid marker, either keep the whole heading block or stop before the first 2+ consecutive blank lines.",options:{headingBlock:"heading-block: whole heading block",doubleBlankLines:"double-blank-lines: stop before the first 2+ blank lines"}},qaGroup:{title:"QA Group 12",marker:{name:"QA Group marker",desc:"When a QA heading ends with this hashtag marker, the whole heading block syncs as one ObsiAnki QA Group 12 note.",placeholder:"#anki-list"},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",desc:"Append a backlink to the source heading into synced cards."},obsidianBacklinkLabel:{name:"Obsidian backlink label",desc:"The text shown for the backlink. Blank input falls back to Open in Obsidian.",placeholder:"Open in Obsidian"},obsidianBacklinkPlacement:{name:"Obsidian backlink placement",desc:"Choose whether the backlink is appended to the question field or placed at the start or end of the answer body.",options:{questionLastLine:"Last line of question field",answerFirstLine:"First line of answer body",answerLastLine:"Last line of answer body"}},highlightsToCloze:{name:"Highlights to Cloze",desc:"Convert ==highlight== segments into cloze deletions for cloze cards."},syncObsidianTagsToAnki:{name:"Sync Obsidian tags to Anki",desc:"Sync Obsidian-recognized tags from the current note into Anki note tags, including nested tags. On each sync, these tags overwrite the managed tags in Anki based on the current Obsidian content."},keepPureTagLinesInCardBody:{name:"Keep pure tag lines in card body",desc:'When turned off, remove every line in the card body that contains only tags, such as "#ProjectA #\u91CD\u70B9/\u6848\u4F8B". Inline tags like "This is a #tag example" and lines with ordinary text like "\u6807\u7B7E\uFF1A#\u9879\u76EEA" are kept. Extra blank lines created by removal are cleaned up automatically.'}},mapping:{title:"Note type field mappings",refresh:{name:"Refresh note types from Anki",button:"Refresh note types"},status:{idle:"Refresh note types from Anki to load the available note types.",loadedCount:"Loaded {{count}} note types from Anki.",empty:"Anki returned no note types.",failedLoadNoteTypes:"Failed to load note types from Anki.",loadedSavedMapping:"Loaded saved mapping for {{modelName}}.",selectedModel:"Selected {{modelName}}. Read fields from Anki to create or refresh its mapping.",loadedFieldsForModel:"Loaded fields for {{modelName}}. Review the suggested mapping and save it.",failedLoadFields:"Failed to load fields for {{modelName}}.",noLoadedFields:"No loaded fields for {{modelName}}. Read fields from Anki first.",savedMapping:"Saved mapping for {{modelName}}.",failedSaveMapping:"Failed to save mapping for {{modelName}}.",savedMappingReady:"Saved mapping is ready for {{title}}.",noSavedMapping:"No saved mapping for {{title}}. Read fields from Anki first."},section:{basic:{title:"QA / Basic",description:"Choose the QA note type, read its fields from Anki, then confirm the title/body mapping."},cloze:{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.",fieldsLabel:"{{title}} fields",fieldsDesc:"Load the current note type fields from Anki, then review the suggested mapping.",readFieldsButton:"Read fields from Anki",loadedFields:"Loaded fields: {{fields}}",loadedFieldsNone:"Loaded fields: none. Read fields from Anki first.",mappingLabel:"{{title}} mapping",mappingDesc:"Save the current field mapping for this specific note type.",saveMappingButton:"Save mapping",titleFieldLabel:"{{title}} title field",titleFieldDesc:"Which Anki field should receive the heading/title fragment.",bodyFieldLabel:"{{title}} body field",bodyFieldDesc:"Which Anki field should receive the body fragment.",mainField:{name:"Cloze main field",desc:"The selected field receives title +
=p))){a=o===61?1:2;break}}if(e.sCount[u]<0)continue;let f=!1;for(let g=0,p=r.length;g
3||e.sCount[a]<0)continue;let c=!1;for(let l=0,d=r.length;l ${t} ]*>.*?<\/a><\/p>/g,"").replace(/ =p))){a=o===61?1:2;break}}if(e.sCount[u]<0)continue;let f=!1;for(let m=0,p=r.length;m 3||e.sCount[a]<0)continue;let c=!1;for(let l=0,d=r.length;l ${t} ]*>.*?<\/a><\/p>/g,"").replace(/ne((2147483647-i)/c)&&ke("overflow"),i+=(s-r)*c,r=s;for(let l of e)if(l
${t}`:t,body:e.body};let r=`
]*>.*?<\/a>/g,"").trim()}function rl(e){return e.includes("GroupId")&&e.includes("Src")&&e.some(n=>/^S\d+_Id$/i.test(n))&&e.some(n=>/^S\d+_Q$/i.test(n))&&e.some(n=>/^S\d+_A$/i.test(n))?e.filter(n=>n==="GroupId"||n==="Src"||/^S\d+_Id$/i.test(n)):[]}function il(e){let t=[];return{text:e.replace($d,r=>{let i=`@@QA_GROUP_INLINE_CODE_${t.length}@@`;return t.push(r),i}),restore:r=>t.reduce((i,a,o)=>i.split(`@@QA_GROUP_INLINE_CODE_${o}@@`).join(a),r)}}var al="manual-render-v3",ve=class{constructor(t=new Oe){this.deckResolutionService=t}resolve(t,n){let r=ol(t.cardType,n),i=this.deckResolutionService.resolve(t,n.defaultDeck,n.folderDeckMode),a=i.resolvedDeck.value,o=n.noteFieldMappings[L(t.cardType,r)]??null,u={version:al,cardType:t.cardType,noteModel:r,mapping:o,addObsidianBacklink:n.addObsidianBacklink,obsidianBacklinkLabel:n.obsidianBacklinkLabel,obsidianBacklinkPlacement:n.obsidianBacklinkPlacement,convertHighlightsToCloze:n.convertHighlightsToCloze,keepPureTagLinesInCardBody:n.keepPureTagLinesInCardBody},s=O(JSON.stringify(u));return{deck:a,noteModel:r,renderConfigHash:s,warnings:i.warnings}}};function ol(e,t){if(Ai(e)){if(!t.clozeNoteType.trim())throw new C("errors.noteFieldMapping.noteTypeNotSelected.cloze");return t.clozeNoteType}if(e==="semantic-qa"){if(!t.semanticQaNoteType.trim())throw new C("errors.noteFieldMapping.noteTypeNotSelected.semanticQa");return t.semanticQaNoteType}if(!t.qaNoteType.trim())throw new C("errors.noteFieldMapping.noteTypeNotSelected.basic");return t.qaNoteType}var yn=class{constructor(t=new ve){this.renderConfigService=t}plan(t,n,r,i){let a=new Map,o=new Set(n.pendingWriteBack.filter(y=>y.markerKind!=="group-gi").map(y=>Ee(y.filePath,y.blockStartLine,y.rawBlockHash))),u=new Set,s=[],c=[],l=[],d=[],f=[],g=new Map,p=0;for(let y of t){if(a.has(y.syncKey))throw new Error(`Duplicate syncKey detected in current scan: ${y.syncKey}`);if(a.set(y.syncKey,y),y.noteId!==void 0){let b=P(y.noteId);if(u.has(b))throw new Error(`Duplicate noteId detected in current scan: ${y.noteId}`);u.add(b)}let S=y.noteId!==void 0?n.cards[P(y.noteId)]:void 0,x=this.renderConfigService.resolve(y,i);for(let b of x.warnings)g.set(ze(b),b);let k=y.noteId,M=Ee(y.filePath,y.blockStartLine,y.rawBlockHash),v={card:{...y,noteId:k},noteId:k,deck:x.deck,noteModel:x.noteModel,renderConfigHash:x.renderConfigHash};if(!k){s.push(v);continue}if(l.push(v),(y.idMarkerState!=="present-valid"||y.noteIdSource!=="marker"||o.has(M))&&f.push(v),!S){c.push(v);continue}let _=S.rawBlockHash!==y.rawBlockHash||S.renderConfigHash!==x.renderConfigHash||!Ki(S.tagsHint,y.tagsHint)||S.orphan,A=S.deck!==x.deck;_&&c.push(v),A&&d.push(v),!_&&!A&&(p+=1)}let F=new Set(r),h=Object.values(n.cards).filter(y=>F.has(y.filePath)&&!u.has(P(y.noteId)));return{toCreate:s,toUpdate:c,toVerifyDeck:l,toChangeDeck:d,toRewriteMarker:f,toOrphan:h,unchangedCards:p,warnings:Array.from(g.values())}}};var mo=new tt({breaks:!0,html:!0,linkify:!0}),ul=/`[^`\n]+`/g,sl=/```[\s\S]*?```|~~~[\s\S]*?~~~/g,cl=/(?`\\[${h.trim()}\\]`),c=c.replace(dl,(F,h)=>`\\(${h.trim()}\\)`),i&&r.convertHighlightsToCloze&&(c=c.replace(ho,"{$1}")),i&&(c=c.replace(pl,(F,h,y)=>`{{c${h?Number(h):d++}::${y}}}`)),c=c.replace(fl,(F,h)=>{let y=r.resourceResolver.resolveEmbed(h,n.card.filePath);return y?(l.push({kind:y.kind,fileName:y.fileName,absolutePath:y.absolutePath,altText:y.altText}),y.kind==="audio"?`[sound:${y.fileName}]`:``):F}),c=c.replace(gl,(F,h)=>{let y=r.resourceResolver.resolveWikiLink(h,n.card.filePath);return y?`${rt(y.displayText)}`:F}),c=c.replace(ho,"$1");let f=jr(c,ll,"MATH");c=f.text,c=s.restore(c),c=u.restore(c);let g=(a?mo.renderInline(c):mo.render(c)).trim(),p=f.restore(g,rt);return{html:a?p:gn(p),media:l}}};function jr(e,t,n){let r=[];return{text:e.replace(t,a=>{let o=`@@${n}_${r.length}@@`;return r.push(a),o}),restore:(a,o=u=>u)=>r.reduce((u,s,c)=>u.split(`@@${n}_${c}@@`).join(o(s)),a)}}function ml(e){let t=new Set;return e.filter(n=>{let r=`${n.kind}:${n.absolutePath}:${n.fileName}`;return t.has(r)?!1:(t.add(r),!0)})}function rt(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}var it=class extends C{constructor(t){super("errors.currentFileOutOfScope",{filePath:t}),this.name="CurrentFileOutOfScopeError"}},xn=class{constructor(t,n,r,i=new Ut(t),a=new yn,o=new bn,u=new Bt(r),s=new jt(t),c=new ve,l=()=>Date.now()){this.vaultGateway=t;this.pluginStateRepository=n;this.ankiGateway=r;this.fileIndexerService=i;this.diffPlannerService=a;this.renderer=o;this.ankiBatchExecutor=u;this.markdownWriteBackService=s;this.scanScopeService=new Le;if(this.qaGroupSyncService=new kn(r,void 0,void 0,void 0,void 0,void 0,void 0,this.vaultGateway.createBacklink.bind(this.vaultGateway)),this.groupMarkerService=new j,hl(c)){this.renderConfigService=c,this.now=l;return}this.renderConfigService=new ve,this.now=c}async syncVault(t){oe(t);let n=await this.pluginStateRepository.load(),r=await this.fileIndexerService.indexVault(t,n);return this.syncIndexedResult(r,n,t)}async syncFile(t,n){if(oe(n),!this.scanScopeService.isPathInScope(t,n.scopeMode,n.includeFolders,n.excludeFolders))throw new it(t);let r=await this.pluginStateRepository.load(),i=await this.fileIndexerService.indexFile(t,n,r);return this.syncIndexedResult(i,r,n)}async rebuildIndex(t){oe(t);let n=await this.pluginStateRepository.load(),r=await this.fileIndexerService.indexVault(t,n,!0),i=this.diffPlannerService.plan(r.cards,n,r.scopedFilePaths,t),a=new Map(r.indexedFiles.map(d=>[d.filePath,d])),o=i.toRewriteMarker.filter(d=>d.noteId!==void 0),u=this.buildReindexedGroupStates(r.groupBlocks,n),s=await this.markdownWriteBackService.write(o,a,u.markerWrites),c=ko(n,r.scopedFilePaths,u.syncedGroupBlocks),l=this.buildNextState(n,r.cards,r.indexedFiles,u.syncedGroupBlocks,t,new Set(s.writtenSyncKeys),new Map,i.toOrphan,c);if(l.pendingWriteBack=[...n.pendingWriteBack.filter(d=>!new Set(r.scopedFilePaths).has(d.filePath)),...s.pendingEntries],this.markFilesDirty(l,[...s.failureFiles.map(d=>d.filePath),...s.conflictFiles]),await this.pluginStateRepository.save(l),s.failureFiles.length>0)throw this.createWriteBackFailureError(s.failureFiles);return{scannedFiles:r.scannedFiles,scannedCards:r.cards.length+r.groupBlocks.length,created:0,updated:0,migratedNoteTypes:0,migratedDecks:0,orphaned:i.toOrphan.length+c.length,uploadedMedia:0,skippedUnchangedCards:r.skippedUnchangedCards,rewrittenMarkers:s.writtenSyncKeys.length,markerWriteConflictFiles:s.conflictFiles,warnings:yo(i.warnings,u.warnings)}}async syncIndexedResult(t,n,r){let i=this.diffPlannerService.plan(t.cards,n,t.scopedFilePaths,r),a={addObsidianBacklink:r.addObsidianBacklink,obsidianBacklinkLabel:r.obsidianBacklinkLabel,obsidianBacklinkPlacement:r.obsidianBacklinkPlacement,convertHighlightsToCloze:r.convertHighlightsToCloze,keepPureTagLinesInCardBody:r.keepPureTagLinesInCardBody,resourceResolver:this.vaultGateway},o=new Map;for(let g of[...i.toCreate,...i.toUpdate])o.has(g.card.syncKey)||o.set(g.card.syncKey,this.renderer.render(g,a));let u=await this.ankiBatchExecutor.execute(i,o,async g=>this.renderer.render(g,a),r.noteFieldMappings),s=await this.qaGroupSyncService.sync(t.groupBlocks,n,r),c=new Map(t.indexedFiles.map(g=>[g.filePath,g])),l=await this.markdownWriteBackService.write(u.markerWrites,c,s.markerWrites),d=ko(n,t.scopedFilePaths,s.syncedGroupBlocks),f=this.buildNextState(n,t.cards,t.indexedFiles,s.syncedGroupBlocks,r,new Set([...u.touchedSyncKeys,...s.touchedSyncKeys,...l.writtenSyncKeys]),u.resolvedNoteIds,i.toOrphan,d);if(f.pendingWriteBack=[...n.pendingWriteBack.filter(g=>!new Set(t.scopedFilePaths).has(g.filePath)),...l.pendingEntries],this.markFilesDirty(f,[...l.failureFiles.map(g=>g.filePath),...l.conflictFiles]),await this.pluginStateRepository.save(f),l.failureFiles.length>0)throw this.createWriteBackFailureError(l.failureFiles);return{scannedFiles:t.scannedFiles,scannedCards:t.cards.length+t.groupBlocks.length,created:u.created+s.created,updated:u.updated+s.updated,migratedNoteTypes:u.migratedNoteTypes+s.migratedNoteTypes,migratedDecks:u.migratedDecks+s.migratedDecks,orphaned:i.toOrphan.length+d.length,uploadedMedia:u.uploadedMedia,skippedUnchangedCards:t.skippedUnchangedCards,rewrittenMarkers:l.writtenSyncKeys.length,markerWriteConflictFiles:l.conflictFiles,warnings:yo(i.warnings,s.warnings)}}buildNextState(t,n,r,i,a,o,u,s,c){let l=this.now(),d=zn(a),f=new Set(r.filter(h=>h.content!==void 0).map(h=>h.filePath)),g=new Map;for(let h of i){let y=g.get(h.filePath);y?y.push(h):g.set(h.filePath,[h])}let p={files:{...t.files},cards:{...t.cards},groupBlocks:{...t.groupBlocks??{}},pendingWriteBack:[...t.pendingWriteBack]};for(let h of f)delete p.files[h];for(let[h,y]of Object.entries(p.cards))f.has(y.filePath)&&delete p.cards[h];for(let[h,y]of Object.entries(p.groupBlocks??{}))f.has(y.filePath)&&delete p.groupBlocks?.[h];let F=new Map;for(let h of n){let y=u.get(h.syncKey)??h.noteId;y!==void 0&&F.set(h.syncKey,y)}for(let h of r){if(h.content===void 0)continue;let y=Array.from(new Set(h.cards.map(S=>F.get(S.syncKey)).filter(S=>S!==void 0)));p.files[h.filePath]={filePath:h.filePath,fileHash:h.fileHash,fileStamp:h.fileStamp,deckRulesFingerprint:d,lastIndexedAt:l,noteIds:y,groupIds:(g.get(h.filePath)??[]).map(S=>S.groupId)}}for(let h of n){let y=F.get(h.syncKey);if(y===void 0)continue;let S=t.cards[P(y)]??(h.noteId!==void 0?t.cards[P(h.noteId)]:void 0),x=this.renderConfigService.resolve(h,a);p.cards[P(y)]={noteId:y,filePath:h.filePath,heading:h.heading,backlinkHeadingText:h.backlinkHeadingText,headingLevel:h.headingLevel,bodyMarkdown:h.bodyMarkdown,cardType:h.cardType,blockStartOffset:h.blockStartOffset,blockEndOffset:h.blockEndOffset,blockStartLine:h.blockStartLine,bodyStartLine:h.bodyStartLine,blockEndLine:h.blockEndLine,contentEndLine:h.contentEndLine,markerLine:h.markerLine,rawBlockText:h.rawBlockText,rawBlockHash:h.rawBlockHash,renderConfigHash:x.renderConfigHash,deck:x.deck,deckHint:h.deckHint,deckHintSource:h.deckHintSource,deckWarnings:[...h.deckWarnings],tagsHint:h.tagsHint,lastSyncedAt:o.has(h.syncKey)?l:S?.lastSyncedAt??0,orphan:!1}}for(let h of s)p.cards[P(h.noteId)]={...h,orphan:!0};for(let h of i)p.groupBlocks??(p.groupBlocks={}),p.groupBlocks[h.groupId]=h;for(let h of c)p.groupBlocks??(p.groupBlocks={}),p.groupBlocks[h.groupId]={...h,orphan:!0};return p}buildReindexedGroupStates(t,n){let r=[],i=[],a=[],o=new Map(Object.values(n.groupBlocks??{}).filter(s=>!s.orphan).map(s=>[s.noteId,s])),u=new Map;for(let s of Object.values(n.groupBlocks??{}).filter(c=>!c.orphan)){let c=u.get(s.src);c?c.push(s):u.set(s.src,[s])}for(let s of t){let c=(s.groupId?n.groupBlocks?.[s.groupId]:void 0)??(s.noteId!==void 0?o.get(s.noteId):void 0)??kl(u.get(s.src));if(!c)continue;let l=Object.fromEntries(c.items.filter(d=>d.itemId&&d.slot).map(d=>[d.itemId,d.slot]));r.push({...c,filePath:s.filePath,headingText:s.headingText,backlinkHeadingText:s.backlinkHeadingText,headingLevel:s.headingLevel,stem:s.stem,src:s.src,blockStartOffset:s.blockStartOffset,blockEndOffset:s.blockEndOffset,blockStartLine:s.blockStartLine,bodyStartLine:s.bodyStartLine,blockEndLine:s.blockEndLine,contentEndLine:s.contentEndLine,markerLine:s.markerLine,markerIndent:s.markerIndent,rawBlockText:s.rawBlockText,rawBlockHash:s.rawBlockHash,deckHint:s.deckHint,deckHintSource:s.deckHintSource,deckWarnings:[...s.deckWarnings],tagsHint:s.tagsHint?[...s.tagsHint]:[],orphan:!1}),a.push(...s.deckWarnings),s.sourceContent&&(s.markerState!=="present-valid"||!this.groupMarkerService.isEquivalent(s.groupMarker,c.noteId,l,c.freeSlots)||yl(s))&&i.push({syncKey:s.syncKey,filePath:s.filePath,blockStartLine:s.blockStartLine,contentEndLine:s.contentEndLine,blockEndLine:s.blockEndLine,markerLine:s.markerLine,markerIndent:s.markerIndent,noteId:c.noteId,groupId:c.groupId,itemToSlot:l,freeSlots:[...c.freeSlots],sourceContent:s.sourceContent})}return{syncedGroupBlocks:r,markerWrites:i,warnings:a}}markFilesDirty(t,n){for(let r of new Set(n))t.files[r]&&(t.files[r]={...t.files[r],fileHash:"",fileStamp:""})}createWriteBackFailureError(t){return new C("errors.writeBack.summary",{fileCount:t.length},{failures:t})}};function hl(e){return typeof e=="object"&&e!==null&&"resolve"in e}function ko(e,t,n){let r=new Set(t),i=new Set(n.map(a=>a.groupId));return Object.values(e.groupBlocks??{}).filter(a=>r.has(a.filePath)&&!i.has(a.groupId)).map(a=>({...a,orphan:!0}))}function yo(...e){let t=new Map;for(let n of e)for(let r of n)t.set(ze(r),r);return[...t.values()]}function kl(e){if(!(!e||e.length!==1))return e[0]}function yl(e){if(!e.sourceContent)return!1;let t=e.sourceContent.split(/\r?\n/);for(let n=e.blockStartLine;n
=4||e.src.charCodeAt(i)!==62)return!1;if(r)return!0;let u=[],s=[],c=[],l=[],d=e.md.block.ruler.getRules("blockquote"),f=e.parentType;e.parentType="blockquote";let m=!1,p;for(p=t;p=4||e.src.charCodeAt(i)!==91)return!1;function u(S){let x=e.lineMax;if(S>=x||e.isEmpty(S))return null;let h=!1;if(e.sCount[S]-e.blkIndent>3&&(h=!0),e.sCount[S]<0&&(h=!0),!h){let E=e.md.block.ruler.getRules("reference"),T=e.parentType;e.parentType="reference";let b=!1;for(let w=0,A=E.length;w"u"&&(e.env.references={}),typeof e.env.references[y]>"u"&&(e.env.references[y]={title:k,href:d}),e.line=o),!0):!1}var La=["address","article","aside","base","basefont","blockquote","body","caption","center","col","colgroup","dd","details","dialog","dir","div","dl","dt","fieldset","figcaption","figure","footer","form","frame","frameset","h1","h2","h3","h4","h5","h6","head","header","hr","html","iframe","legend","li","link","main","menu","menuitem","nav","noframes","ol","optgroup","option","p","param","search","section","summary","table","tbody","td","tfoot","th","thead","title","tr","track","ul"];var Fc="[a-zA-Z_:][a-zA-Z0-9:._-]*",vc="[^\"'=<>`\\x00-\\x20]+",Mc="'[^']*'",wc='"[^"]*"',Tc="(?:"+vc+"|"+Mc+"|"+wc+")",Ac="(?:\\s+"+Fc+"(?:\\s*=\\s*"+Tc+")?)",Na="<[A-Za-z][A-Za-z0-9\\-]*"+Ac+"*\\s*\\/?>",Pa="<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>",Dc="",Ec="<[?][\\s\\S]*?[?]>",_c="]*>",Ic="",Ra=new RegExp("^(?:"+Na+"|"+Pa+"|"+Dc+"|"+Ec+"|"+_c+"|"+Ic+")"),Ga=new RegExp("^(?:"+Na+"|"+Pa+")");var qe=[[/^<(script|pre|style|textarea)(?=(\s|>|$))/i,/<\/(script|pre|style|textarea)>/i,!0],[/^/,!0],[/^<\?/,/\?>/,!0],[/^/,!0],[/^/,!0],[new RegExp("^?("+La.join("|")+")(?=(\\s|/?>|$))","i"),/^$/,!0],[new RegExp(Ga.source+"\\s*$"),/^$/,!1]];function kr(e,t,n,r){let i=e.bMarks[t]+e.tShift[t],a=e.eMarks[t];if(e.sCount[t]-e.blkIndent>=4||!e.md.options.html||e.src.charCodeAt(i)!==60)return!1;let o=e.src.slice(i,a),u=0;for(;u
te((2147483647-i)/c)&&he("overflow"),i+=(s-r)*c,r=s;for(let l of e)if(l
${t}`:t,body:e.body};let r=`
]*>.*?<\/a>/g,"").trim()}function Wd(e){return e.includes("GroupId")&&e.includes("Src")&&e.some(n=>/^S\d+_Id$/i.test(n))&&e.some(n=>/^S\d+_Q$/i.test(n))&&e.some(n=>/^S\d+_A$/i.test(n))?e.filter(n=>n==="GroupId"||n==="Src"||/^S\d+_Id$/i.test(n)):[]}function $d(e){let t=[];return{text:e.replace(_d,r=>{let i=`@@QA_GROUP_INLINE_CODE_${t.length}@@`;return t.push(r),i}),restore:r=>t.reduce((i,a,o)=>i.split(`@@QA_GROUP_INLINE_CODE_${o}@@`).join(a),r)}}var Qd="manual-render-v3",we=class{constructor(t=new He){this.deckResolutionService=t}resolve(t,n){let r=Ud(t.cardType,n),i=this.deckResolutionService.resolve(t,n.defaultDeck,n.folderDeckMode),a=i.resolvedDeck.value,o=n.noteFieldMappings[I(t.cardType,r)]??null,u={version:Qd,cardType:t.cardType,noteModel:r,mapping:o,addObsidianBacklink:n.addObsidianBacklink,obsidianBacklinkLabel:n.obsidianBacklinkLabel,obsidianBacklinkPlacement:n.obsidianBacklinkPlacement,convertHighlightsToCloze:n.convertHighlightsToCloze,keepPureTagLinesInCardBody:n.keepPureTagLinesInCardBody},s=W(JSON.stringify(u));return{deck:a,noteModel:r,renderConfigHash:s,warnings:i.warnings}}};function Ud(e,t){if(Mi(e)){if(!t.clozeNoteType.trim())throw new C("errors.noteFieldMapping.noteTypeNotSelected.cloze");return t.clozeNoteType}if(!t.qaNoteType.trim())throw new C("errors.noteFieldMapping.noteTypeNotSelected.basic");return t.qaNoteType}var yn=class{constructor(t=new we){this.renderConfigService=t}plan(t,n,r,i){let a=new Map,o=new Set(n.pendingWriteBack.filter(y=>y.markerKind!=="group-gi").map(y=>Ee(y.filePath,y.blockStartLine,y.rawBlockHash))),u=new Set,s=[],c=[],l=[],d=[],f=[],m=new Map,p=0;for(let y of t){if(a.has(y.syncKey))throw new Error(`Duplicate syncKey detected in current scan: ${y.syncKey}`);if(a.set(y.syncKey,y),y.noteId!==void 0){let b=P(y.noteId);if(u.has(b))throw new Error(`Duplicate noteId detected in current scan: ${y.noteId}`);u.add(b)}let S=y.noteId!==void 0?n.cards[P(y.noteId)]:void 0,x=this.renderConfigService.resolve(y,i);for(let b of x.warnings)m.set(ze(b),b);let h=y.noteId,v=Ee(y.filePath,y.blockStartLine,y.rawBlockHash),M={card:{...y,noteId:h},noteId:h,deck:x.deck,noteModel:x.noteModel,renderConfigHash:x.renderConfigHash};if(!h){s.push(M);continue}if(l.push(M),(y.idMarkerState!=="present-valid"||y.noteIdSource!=="marker"||o.has(v))&&f.push(M),!S){c.push(M);continue}let E=S.rawBlockHash!==y.rawBlockHash||S.renderConfigHash!==x.renderConfigHash||!Ui(S.tagsHint,y.tagsHint)||S.orphan,T=S.deck!==x.deck;E&&c.push(M),T&&d.push(M),!E&&!T&&(p+=1)}let F=new Set(r),k=Object.values(n.cards).filter(y=>F.has(y.filePath)&&!u.has(P(y.noteId)));return{toCreate:s,toUpdate:c,toVerifyDeck:l,toChangeDeck:d,toRewriteMarker:f,toOrphan:k,unchangedCards:p,warnings:Array.from(m.values())}}};var lo=new Xe({breaks:!0,html:!0,linkify:!0}),Vd=/`[^`\n]+`/g,jd=/```[\s\S]*?```|~~~[\s\S]*?~~~/g,Kd=/(?`\\[${k.trim()}\\]`),c=c.replace(Zd,(F,k)=>`\\(${k.trim()}\\)`),i&&r.convertHighlightsToCloze&&(c=c.replace(po,"{$1}")),i&&(c=c.replace(Xd,(F,k,y)=>`{{c${k?Number(k):d++}::${y}}}`)),c=c.replace(Jd,(F,k)=>{let y=r.resourceResolver.resolveEmbed(k,n.card.filePath);return y?(l.push({kind:y.kind,fileName:y.fileName,absolutePath:y.absolutePath,altText:y.altText}),y.kind==="audio"?`[sound:${y.fileName}]`:``):F}),c=c.replace(el,(F,k)=>{let y=r.resourceResolver.resolveWikiLink(k,n.card.filePath);return y?`${et(y.displayText)}`:F}),c=c.replace(po,"$1");let f=Qr(c,Yd,"MATH");c=f.text,c=s.restore(c),c=u.restore(c);let m=(a?lo.renderInline(c):lo.render(c)).trim(),p=f.restore(m,et);return{html:a?p:gn(p),media:l}}};function Qr(e,t,n){let r=[];return{text:e.replace(t,a=>{let o=`@@${n}_${r.length}@@`;return r.push(a),o}),restore:(a,o=u=>u)=>r.reduce((u,s,c)=>u.split(`@@${n}_${c}@@`).join(o(s)),a)}}function tl(e){let t=new Set;return e.filter(n=>{let r=`${n.kind}:${n.absolutePath}:${n.fileName}`;return t.has(r)?!1:(t.add(r),!0)})}function et(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}var tt=class extends C{constructor(t){super("errors.currentFileOutOfScope",{filePath:t}),this.name="CurrentFileOutOfScopeError"}},Oe=class extends C{constructor(){super("errors.runScopeNotConfigured"),this.name="RunScopeNotConfiguredError"}},xn=class{constructor(t,n,r,i=new Ut(t),a=new yn,o=new bn,u=new Gt(r),s=new Vt(t),c=new we,l=()=>Date.now()){this.vaultGateway=t;this.pluginStateRepository=n;this.ankiGateway=r;this.fileIndexerService=i;this.diffPlannerService=a;this.renderer=o;this.ankiBatchExecutor=u;this.markdownWriteBackService=s;this.scanScopeService=new Le;if(this.qaGroupSyncService=new kn(r,void 0,void 0,void 0,void 0,void 0,void 0,this.vaultGateway.createBacklink.bind(this.vaultGateway)),nl(c)){this.renderConfigService=c,this.now=l;return}this.renderConfigService=new we,this.now=c}async syncVault(t){le(t),this.assertRunScopeConfigured(t);let n=await this.pluginStateRepository.load(),r=await this.fileIndexerService.indexVault(t,n);return this.syncIndexedResult(r,n,t)}async syncFile(t,n){if(le(n),this.assertRunScopeConfigured(n),!this.scanScopeService.isPathInScope(t,n.scopeMode,n.includeFolders,n.excludeFolders))throw new tt(t);let r=await this.pluginStateRepository.load(),i=await this.fileIndexerService.indexFile(t,n,r);return this.syncIndexedResult(i,r,n)}assertRunScopeConfigured(t){if(!ct(t.scopeMode,t.includeFolders))throw new Oe}async syncIndexedResult(t,n,r){let i=this.diffPlannerService.plan(t.cards,n,t.scopedFilePaths,r),a={addObsidianBacklink:r.addObsidianBacklink,obsidianBacklinkLabel:r.obsidianBacklinkLabel,obsidianBacklinkPlacement:r.obsidianBacklinkPlacement,convertHighlightsToCloze:r.convertHighlightsToCloze,keepPureTagLinesInCardBody:r.keepPureTagLinesInCardBody,resourceResolver:this.vaultGateway},o=new Map;for(let m of[...i.toCreate,...i.toUpdate])o.has(m.card.syncKey)||o.set(m.card.syncKey,this.renderer.render(m,a));let u=await this.ankiBatchExecutor.execute(i,o,async m=>this.renderer.render(m,a),r.noteFieldMappings),s=await this.qaGroupSyncService.sync(t.groupBlocks,n,r),c=new Map(t.indexedFiles.map(m=>[m.filePath,m])),l=await this.markdownWriteBackService.write(u.markerWrites,c,s.markerWrites),d=rl(n,t.scopedFilePaths,s.syncedGroupBlocks),f=this.buildNextState(n,t.cards,t.indexedFiles,s.syncedGroupBlocks,r,new Set([...u.touchedSyncKeys,...s.touchedSyncKeys,...l.writtenSyncKeys]),u.resolvedNoteIds,i.toOrphan,d);if(f.pendingWriteBack=[...n.pendingWriteBack.filter(m=>!new Set(t.scopedFilePaths).has(m.filePath)),...l.pendingEntries],this.markFilesDirty(f,[...l.failureFiles.map(m=>m.filePath),...l.conflictFiles]),await this.pluginStateRepository.save(f),l.failureFiles.length>0)throw this.createWriteBackFailureError(l.failureFiles);return{scannedFiles:t.scannedFiles,scannedCards:t.cards.length+t.groupBlocks.length,created:u.created+s.created,updated:u.updated+s.updated,migratedNoteTypes:u.migratedNoteTypes+s.migratedNoteTypes,migratedDecks:u.migratedDecks+s.migratedDecks,orphaned:i.toOrphan.length+d.length,uploadedMedia:u.uploadedMedia,skippedUnchangedCards:t.skippedUnchangedCards,rewrittenMarkers:l.writtenSyncKeys.length,markerWriteConflictFiles:l.conflictFiles,warnings:il(i.warnings,s.warnings)}}buildNextState(t,n,r,i,a,o,u,s,c){let l=this.now(),d=Hn(a),f=new Set(r.filter(k=>k.content!==void 0).map(k=>k.filePath)),m=new Map;for(let k of i){let y=m.get(k.filePath);y?y.push(k):m.set(k.filePath,[k])}let p={files:{...t.files},cards:{...t.cards},groupBlocks:{...t.groupBlocks??{}},pendingWriteBack:[...t.pendingWriteBack]};for(let k of f)delete p.files[k];for(let[k,y]of Object.entries(p.cards))f.has(y.filePath)&&delete p.cards[k];for(let[k,y]of Object.entries(p.groupBlocks??{}))f.has(y.filePath)&&delete p.groupBlocks?.[k];let F=new Map;for(let k of n){let y=u.get(k.syncKey)??k.noteId;y!==void 0&&F.set(k.syncKey,y)}for(let k of r){if(k.content===void 0)continue;let y=Array.from(new Set(k.cards.map(S=>F.get(S.syncKey)).filter(S=>S!==void 0)));p.files[k.filePath]={filePath:k.filePath,fileHash:k.fileHash,fileStamp:k.fileStamp,deckRulesFingerprint:d,lastIndexedAt:l,noteIds:y,groupIds:(m.get(k.filePath)??[]).map(S=>S.groupId)}}for(let k of n){let y=F.get(k.syncKey);if(y===void 0)continue;let S=t.cards[P(y)]??(k.noteId!==void 0?t.cards[P(k.noteId)]:void 0),x=this.renderConfigService.resolve(k,a);p.cards[P(y)]={noteId:y,filePath:k.filePath,heading:k.heading,backlinkHeadingText:k.backlinkHeadingText,headingLevel:k.headingLevel,bodyMarkdown:k.bodyMarkdown,cardType:k.cardType,blockStartOffset:k.blockStartOffset,blockEndOffset:k.blockEndOffset,blockStartLine:k.blockStartLine,bodyStartLine:k.bodyStartLine,blockEndLine:k.blockEndLine,contentEndLine:k.contentEndLine,markerLine:k.markerLine,rawBlockText:k.rawBlockText,rawBlockHash:k.rawBlockHash,renderConfigHash:x.renderConfigHash,deck:x.deck,deckHint:k.deckHint,deckHintSource:k.deckHintSource,deckWarnings:[...k.deckWarnings],tagsHint:k.tagsHint,lastSyncedAt:o.has(k.syncKey)?l:S?.lastSyncedAt??0,orphan:!1}}for(let k of s)p.cards[P(k.noteId)]={...k,orphan:!0};for(let k of i)p.groupBlocks??(p.groupBlocks={}),p.groupBlocks[k.groupId]=k;for(let k of c)p.groupBlocks??(p.groupBlocks={}),p.groupBlocks[k.groupId]={...k,orphan:!0};return p}markFilesDirty(t,n){for(let r of new Set(n))t.files[r]&&(t.files[r]={...t.files[r],fileHash:"",fileStamp:""})}createWriteBackFailureError(t){return new C("errors.writeBack.summary",{fileCount:t.length},{failures:t})}};function nl(e){return typeof e=="object"&&e!==null&&"resolve"in e}function rl(e,t,n){let r=new Set(t),i=new Set(n.map(a=>a.groupId));return Object.values(e.groupBlocks??{}).filter(a=>r.has(a.filePath)&&!i.has(a.groupId)).map(a=>({...a,orphan:!0}))}function il(...e){let t=new Map;for(let n of e)for(let r of n)t.set(ze(r),r);return[...t.values()]}var nt=class extends fo.Plugin{constructor(){super(...arguments);this.settings=H;this.noticeService=new Dt;this.ankiGateway=new xt(()=>this.settings.ankiConnectUrl);this.deckTemplateInsertionService=new dt}async onload(){let n=new St(this);this.pluginConfigRepository=new Mt(n);let r=new wt(n),i=new vt(this.app);this.vaultGateway=i;try{this.settings=await this.pluginConfigRepository.load()}catch(o){console.error("Failed to load plugin settings, falling back to defaults.",o),this.settings=H,this.noticeService.error(g("notice.invalidSettingsRestored"))}let a=new xn(i,r,this.ankiGateway);this.syncCurrentFileUseCase=new yt(a),this.syncVaultUseCase=new bt(a),this.clearCurrentFileSyncedCardsUseCase=new kt(r,this.ankiGateway,i),this.cleanupEmptyDecksUseCase=new lt(this.ankiGateway),vi(this),this.addSettingTab(new Lt(this))}async updateSettings(n){if(!this.pluginConfigRepository)return;let r=be({...this.settings,...n});try{await this.pluginConfigRepository.save(r),this.settings=r}catch(i){this.noticeService.error(ye(i,"notice.failedSavePluginSettings"))}}async listNoteModels(){return this.ankiGateway.listNoteModels()}async getModelFieldNamesByModelNames(n){return this.ankiGateway.getModelFieldNamesByModelNames(n)}async getNoteModelDetails(n){return this.ankiGateway.getModelDetails(n)}async listFolderTree(){return this.vaultGateway?this.vaultGateway.listFolderTree():[]}async runSyncCurrentFile(){let n=this.app.workspace.getActiveFile();if(!n||n.extension.toLowerCase()!=="md"){this.noticeService.error(g("notice.noActiveMarkdownForSync"));return}if(!this.syncCurrentFileUseCase){this.noticeService.error(g("notice.syncUseCaseNotInitialized"));return}try{let r=await this.syncCurrentFileUseCase.execute(n.path,this.settings);this.noticeService.showSyncSummary("currentFile",r)}catch(r){if(r instanceof tt||r instanceof Oe){this.noticeService.info(rt(r));return}console.error("Current file sync failed.",r),this.noticeService.error(ye(r,"notice.currentFileSyncFailed"))}}async runSyncVault(){if(!this.syncVaultUseCase){this.noticeService.error(g("notice.syncUseCaseNotInitialized"));return}try{let n=await this.syncVaultUseCase.execute(this.settings);this.noticeService.showSyncSummary("vault",n)}catch(n){if(n instanceof Oe){this.noticeService.info(rt(n));return}console.error("Vault sync failed.",n),this.noticeService.error(ye(n,"notice.vaultSyncFailed"))}}async insertDeckTemplateToCurrentFile(){let n=this.app.workspace.getActiveFile();if(!n||n.extension.toLowerCase()!=="md"){this.noticeService.error(g("notice.noActiveMarkdownForDeckTemplateInsertion"));return}if(!this.vaultGateway){this.noticeService.error(g("notice.vaultGatewayNotInitialized"));return}try{let r=await this.vaultGateway.readMarkdownFile(n.path);if(!r){this.noticeService.error(g("notice.markdownFileNotFound",{filePath:n.path}));return}let i=this.deckTemplateInsertionService.insert(r,this.settings.fileDeckMarker,this.settings.fileDeckTemplate,this.settings.fileDeckInsertLocation);if(i.nextContent===i.expectedContent){this.noticeService.info(g("notice.deckTemplateAlreadyCurrent"));return}await this.vaultGateway.replaceMarkdownFile(n.path,i.expectedContent,i.nextContent),this.noticeService.info(g("notice.deckTemplateInserted",{deck:i.insertedDeck}))}catch(r){console.error("Deck template insertion failed.",r),this.noticeService.error(ye(r,"notice.deckTemplateInsertionFailed"))}}async runClearCurrentFileSyncedCards(){let n=this.app.workspace.getActiveFile();if(!n||n.extension.toLowerCase()!=="md"){this.noticeService.error(g("notice.noActiveMarkdownForReset"));return}if(!this.clearCurrentFileSyncedCardsUseCase){this.noticeService.error(g("notice.clearCurrentFileUseCaseNotInitialized"));return}try{if(!await this.clearCurrentFileSyncedCardsUseCase.hasTrackedCards(n.path)){this.noticeService.info(g("notice.noTrackedCardsInCurrentFile"));return}let i=await this.clearCurrentFileSyncedCardsUseCase.execute(n.path);this.noticeService.showClearCurrentFileSummary(i)}catch(r){console.error("Clear current file synced cards failed.",r),this.noticeService.error(ye(r,"notice.clearCurrentFileFailed"))}}async runCleanupEmptyDecks(){if(!this.cleanupEmptyDecksUseCase){this.noticeService.error(g("notice.cleanupEmptyDecksUseCaseNotInitialized"));return}try{let n=await this.cleanupEmptyDecksUseCase.listCandidates();if(n.length===0){this.noticeService.info(g("notice.noEmptyDeckCandidates"));return}let r=await new At(this.app,n).openAndGetSelection();if(r===null){this.noticeService.info(g("notice.cleanupEmptyDecksCancelled"));return}let i=await this.cleanupEmptyDecksUseCase.execute(r,n);this.noticeService.showCleanupEmptyDecksSummary(i)}catch(n){console.error("Cleanup empty decks failed.",n),this.noticeService.error(ye(n,"notice.cleanupEmptyDecksFailed"))}}};var al=nt;
diff --git a/manifest.json b/manifest.json
index 3f2ca9b..af05c95 100644
--- a/manifest.json
+++ b/manifest.json
@@ -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
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 2c6c7f4..3dac488 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
diff --git a/package.json b/package.json
index 6cc7508..4e6c298 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/src/application/config/NoteModelFieldMapping.ts b/src/application/config/NoteModelFieldMapping.ts
index db90007..790d877 100644
--- a/src/application/config/NoteModelFieldMapping.ts
+++ b/src/application/config/NoteModelFieldMapping.ts
@@ -10,7 +10,7 @@ interface BaseNoteModelFieldMapping {
}
export interface BasicLikeNoteModelFieldMapping extends BaseNoteModelFieldMapping {
- cardType: Extract
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
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("");
- expect(vaultGateway.getFileContent(filePath)).toContain("");
- expect(vaultGateway.getFileContent(filePath)).not.toContain("");
- expect(vaultGateway.getFileContent(filePath)).not.toContain("");
- });
-
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,
diff --git a/src/application/use-cases/RebuildCardIndexUseCase.ts b/src/application/use-cases/RebuildCardIndexUseCase.ts
deleted file mode 100644
index 8d7ea6f..0000000
--- a/src/application/use-cases/RebuildCardIndexUseCase.ts
+++ /dev/null
@@ -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
Alpha",
- backlinkHeadingText: "Concepts #anki-list-qa",
- bodyMarkdown: "First answer",
- markerLine: 4,
- markerIndent: " ",
- });
- expect(indexedFile.cards[1]).toMatchObject({
- noteId: undefined,
- cardType: "semantic-qa",
- heading: "Concepts
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地区"],
]);
});
diff --git a/src/domain/manual-sync/services/CardIndexingService.ts b/src/domain/manual-sync/services/CardIndexingService.ts
index e142d97..3c77b99 100644
--- a/src/domain/manual-sync/services/CardIndexingService.ts
+++ b/src/domain/manual-sync/services/CardIndexingService.ts
@@ -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,
- },
};
}
diff --git a/src/domain/manual-sync/services/SemanticQaListParser.test.ts b/src/domain/manual-sync/services/SemanticQaListParser.test.ts
deleted file mode 100644
index 1aac451..0000000
--- a/src/domain/manual-sync/services/SemanticQaListParser.test.ts
+++ /dev/null
@@ -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",
- " ",
- "- Beta",
- "- Gamma",
- " Third line",
- " deeper detail",
- ],
- bodyStartLine: 2,
- });
-
- expect(cards).toHaveLength(2);
- expect(cards[0]).toMatchObject({
- heading: "Concepts
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
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",
- " ",
- ],
- 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",
- " ",
- " ",
- " ",
- " Remark",
- ],
- bodyStartLine: 2,
- });
-
- expect(cards).toHaveLength(1);
- expect(cards[0]).toMatchObject({
- bodyMarkdown: "First line",
- contentEndLine: 3,
- markerLine: 4,
- markerNoteId: 12,
- idMarkerState: "present-valid",
- });
- });
-});
\ No newline at end of file
diff --git a/src/domain/manual-sync/services/SemanticQaListParser.ts b/src/domain/manual-sync/services/SemanticQaListParser.ts
deleted file mode 100644
index caca80f..0000000
--- a/src/domain/manual-sync/services/SemanticQaListParser.ts
+++ /dev/null
@@ -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
${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("~~~");
-}
\ No newline at end of file
diff --git a/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts b/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts
index 6941777..63d617d 100644
--- a/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts
+++ b/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts
@@ -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: {
diff --git a/src/infrastructure/obsidian/ObsidianVaultGateway.ts b/src/infrastructure/obsidian/ObsidianVaultGateway.ts
index 15534c7..32db40e 100644
--- a/src/infrastructure/obsidian/ObsidianVaultGateway.ts
+++ b/src/infrastructure/obsidian/ObsidianVaultGateway.ts
@@ -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) {
diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts
index 5a4b8bc..9364f7f 100644
--- a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts
+++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts
@@ -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);
});
});
diff --git a/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts
index 6d8fecf..ad8bcbc 100644
--- a/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts
+++ b/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts
@@ -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");
});
});
\ No newline at end of file
diff --git a/src/infrastructure/persistence/DataJsonPluginStateRepository.ts b/src/infrastructure/persistence/DataJsonPluginStateRepository.ts
index e458ef5..e6fbdf3 100644
--- a/src/infrastructure/persistence/DataJsonPluginStateRepository.ts
+++ b/src/infrastructure/persistence/DataJsonPluginStateRepository.ts
@@ -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;
}
diff --git a/src/presentation/AnkiHeadingSyncPlugin.ts b/src/presentation/AnkiHeadingSyncPlugin.ts
index 1c03906..d5348d9 100644
--- a/src/presentation/AnkiHeadingSyncPlugin.ts
+++ b/src/presentation/AnkiHeadingSyncPlugin.ts
@@ -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