diff --git a/README.md b/README.md
index b609e80..09ac219 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ Anki Heading Sync is an Obsidian desktop plugin for syncing Markdown heading blo
- 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 QA Group list blocks into multi-level list QA 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
diff --git a/docs/prerelease-remaining-fixes-decisions.md b/docs/prerelease-remaining-fixes-decisions.md
new file mode 100644
index 0000000..94d4340
--- /dev/null
+++ b/docs/prerelease-remaining-fixes-decisions.md
@@ -0,0 +1,53 @@
+# Pre-release Remaining Fixes Decisions
+
+## Pending writeback validation rules
+
+- Preserve modern `PendingWriteBackState` entries during load only when all required fields are valid.
+- Required fields:
+ - `filePath`: non-empty string
+ - `blockStartLine`: positive integer
+ - `expectedFileHash`: non-empty string
+ - `targetMarker`: non-empty string
+ - `rawBlockHash`: non-empty string
+ - `targetNoteId`: positive integer
+- `markerKind` rules:
+ - `undefined` is valid
+ - `"card-id"` is valid
+ - `"group-gi"` is valid
+ - any other value is invalid and drops the item
+- `targetGroupId` rules:
+ - if absent, keep normal validation result
+ - if present, it must be a non-empty string
+ - malformed `targetGroupId` drops the item
+
+## Legacy pending dropping rule
+
+- Do not migrate legacy pending entries that only contain legacy identity fields such as `cardId` or `noteId`.
+- Any pending entry missing `targetNoteId` is dropped.
+- Do not synthesize or coerce malformed pending items into the modern shape.
+
+## Old Semantic QA state dropping rule
+
+- Current supported loaded card state remains limited to:
+ - `basic`
+ - `cloze`
+- Removed card types such as `semantic-qa` are dropped during load.
+- Unknown removed card types are also dropped during load.
+- Dropped cards are not written into `state.cards`.
+- File `noteIds` are rebuilt from the filtered migrated card map so dropped cards cannot leave dangling note IDs behind.
+
+## README wording decision
+
+- Replace `managed group notes` with current-product wording centered on multi-level list QA cards/notes.
+- Keep the README change focused to the active feature description only.
+
+## i18n wording decision
+
+- Remove user-facing managed-model narrative from active settings copy.
+- Replace old wording such as `QA Group 12`, `ObsiAnki QA Group 12`, `Managed note type`, and `Managed model contract` with current route wording for multi-level list QA notes.
+- Keep internal model-definition constants unchanged unless a user-facing behavior requires a rename.
+
+## Intentional deviations from the plan
+
+- Internal technical constants such as `QA_GROUP_MODEL_NAME` are not renamed in this round because the requested cleanup is limited to active user-facing wording and persistence behavior.
+- Historical audit docs are left untouched even if they still mention old QA Group 12 or Semantic QA history.
diff --git a/docs/prerelease-remaining-fixes-gap-report.md b/docs/prerelease-remaining-fixes-gap-report.md
new file mode 100644
index 0000000..d145478
--- /dev/null
+++ b/docs/prerelease-remaining-fixes-gap-report.md
@@ -0,0 +1,90 @@
+# Pre-release Remaining Fixes Gap Report
+
+## Scope
+
+This report records the real repository state on 2026-04-27 before implementing the remaining pre-release fixes.
+
+## Pending writeback load behavior
+
+- Runtime state shape already defines modern `PendingWriteBackState` in `src/domain/manual-sync/entities/PluginState.ts` with:
+ - `filePath`
+ - `blockStartLine`
+ - `expectedFileHash`
+ - `targetMarker`
+ - `rawBlockHash`
+ - `targetNoteId`
+ - optional `markerKind`
+ - optional `targetGroupId`
+- `src/application/services/MarkdownWriteBackService.ts` already emits modern pending entries for both:
+ - card ID marker writes with `markerKind: "card-id"`
+ - group GI writes with `markerKind: "group-gi"` and `targetGroupId`
+- `src/infrastructure/persistence/DataJsonPluginStateRepository.ts` currently drops all persisted pending entries on load by returning `pendingWriteBack: []` from `migratePluginState()`.
+- Result:
+ - writeback conflicts are persisted during a session
+ - after Obsidian restart, the recovery queue is lost
+
+## Malformed and legacy pending handling
+
+- `DataJsonPluginStateRepository.ts` declares `LegacyPendingWriteBackState`, but current load migration does not validate or preserve any pending items.
+- Current repository behavior therefore treats all pending shapes the same way:
+ - valid modern items are dropped
+ - malformed items are dropped
+ - legacy `cardId` / `noteId` only items are dropped
+- There is currently no explicit validation contract for pending items during load.
+
+## Old Semantic QA state behavior
+
+- `DataJsonPluginStateRepository.ts` still migrates every loaded card through `migrateCardState()`.
+- `sanitizeCardType()` currently keeps `"cloze"` and converts every other value to `"basic"`.
+- Result:
+ - removed `semantic-qa` cards are silently converted to `basic`
+ - converted cards remain in `state.cards`
+ - converted cards still contribute note IDs to `files[*].noteIds` through `collectMigratedFileNoteIds()`
+- Current test coverage in `src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts` explicitly asserts this silent conversion.
+
+## Release link status
+
+- `manifest.json` currently has:
+ - `author: "Dusk"`
+ - `authorUrl: "https://github.com/panAtGitHub"`
+- Required repository link is not yet applied.
+
+## README wording status
+
+- `README.md` still says:
+ - `Sync QA Group list blocks into managed group notes`
+- This is stale product language relative to the current user-facing route.
+
+## i18n legacy wording status
+
+- Active user-facing wording in `src/presentation/i18n/messages/en.ts` and `src/presentation/i18n/messages/zh.ts` still includes:
+ - `QA Group 12`
+ - `ObsiAnki QA Group 12`
+ - `Managed note type`
+ - `Managed model contract`
+ - `托管笔记类型`
+ - `托管模型约束`
+- These messages are still part of the live settings UI.
+- Current active text suggests a fixed managed-model narrative that does not match the intended release wording.
+
+## Build and sync script status
+
+- `scripts/stage-plugin-dist.mjs` stages:
+ - `manifest.json`
+ - `versions.json`
+ - optional `styles.css`
+ - generated package `README.md`
+- `scripts/sync-plugin-dist.mjs` copies from `dist/plugin`:
+ - `main.js`
+ - `manifest.json`
+ - optional `styles.css`
+- The generic sync script does not delete `data.json`, but repository instructions still require strict file selection during final vault sync.
+
+## Exact files likely to change
+
+- `src/infrastructure/persistence/DataJsonPluginStateRepository.ts`
+- `src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts`
+- `manifest.json`
+- `README.md`
+- `src/presentation/i18n/messages/en.ts`
+- `src/presentation/i18n/messages/zh.ts`
diff --git a/main.js b/main.js
index b258b14..b49bde0 100644
--- a/main.js
+++ b/main.js
@@ -1,35 +1,35 @@
/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
*/
-"use strict";var Cn=Object.defineProperty;var mo=Object.getOwnPropertyDescriptor;var ho=Object.getOwnPropertyNames;var ko=Object.prototype.hasOwnProperty;var We=(e,t)=>{for(var n in t)Cn(e,n,{get:t[n],enumerable:!0})},yo=(e,t,n,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of ho(t))!ko.call(e,i)&&i!==n&&Cn(e,i,{get:()=>t[i],enumerable:!(r=mo(t,i))||r.enumerable});return e};var bo=e=>yo(Cn({},"__esModule",{value:!0}),e);var ol={};We(ol,{default:()=>al});module.exports=bo(ol);var fo=require("obsidian");function I(e,t){return`${e}:${t}`}function Fn(e){return e?.cardType==="basic"}function Vr(e){return e?.cardType==="cloze"}function G(e){return e?.cardType==="qa-group"}var jr=require("obsidian");function ue(){return(0,jr.getLanguage)()==="zh"?"zh":"en"}function se(e,t){return t.join(e==="zh"?"\u3001":", ")}var vn={commands:{syncCurrentFileToAnki:"Sync current file to Anki",syncVaultToAnki:"Sync vault to Anki",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."},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."}},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 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(/ =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(/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;
+`;break;default:}return r};Ge.prototype.render=function(e,t,n){let r="",i=this.rules;for(let a=0,o=e.length;a
=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 Na=["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 Ac="[a-zA-Z_:][a-zA-Z0-9:._-]*",Dc="[^\"'=<>`\\x00-\\x20]+",Ec="'[^']*'",_c='"[^"]*"',Ic="(?:"+Dc+"|"+Ec+"|"+_c+")",Lc="(?:\\s+"+Ac+"(?:\\s*=\\s*"+Ic+")?)",Pa="<[A-Za-z][A-Za-z0-9\\-]*"+Lc+"*\\s*\\/?>",Ra="<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>",Nc="",Pc="<[?][\\s\\S]*?[?]>",Rc="]*>",Gc="",Ga=new RegExp("^(?:"+Pa+"|"+Ra+"|"+Nc+"|"+Pc+"|"+Rc+"|"+Gc+")"),Ba=new RegExp("^(?:"+Pa+"|"+Ra+")");var qe=[[/^<(script|pre|style|textarea)(?=(\s|>|$))/i,/<\/(script|pre|style|textarea)>/i,!0],[/^/,!0],[/^<\?/,/\?>/,!0],[/^/,!0],[/^/,!0],[new RegExp("^?("+Na.join("|")+")(?=(\\s|/?>|$))","i"),/^$/,!0],[new RegExp(Ba.source+"\\s*$"),/^$/,!1]];function yr(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 jd(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 Kd(e){let t=[];return{text:e.replace(Rd,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 Zd="manual-render-v3",we=class{constructor(t=new He){this.deckResolutionService=t}resolve(t,n){let r=Yd(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:Zd,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 Yd(e,t){if(wi(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 bn=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,M=Ee(y.filePath,y.blockStartLine,y.rawBlockHash),v={card:{...y,noteId:h},noteId:h,deck:x.deck,noteModel:x.noteModel,renderConfigHash:x.renderConfigHash};if(!h){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 E=S.rawBlockHash!==y.rawBlockHash||S.renderConfigHash!==x.renderConfigHash||!Vi(S.tagsHint,y.tagsHint)||S.orphan,T=S.deck!==x.deck;E&&c.push(v),T&&d.push(v),!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 po=new et({breaks:!0,html:!0,linkify:!0}),Xd=/`[^`\n]+`/g,Jd=/```[\s\S]*?```|~~~[\s\S]*?~~~/g,el=/(?`\\[${k.trim()}\\]`),c=c.replace(tl,(F,k)=>`\\(${k.trim()}\\)`),i&&r.convertHighlightsToCloze&&(c=c.replace(fo,"{$1}")),i&&(c=c.replace(rl,(F,k,y)=>`{{c${k?Number(k):d++}::${y}}}`)),c=c.replace(il,(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(al,(F,k)=>{let y=r.resourceResolver.resolveWikiLink(k,n.card.filePath);return y?`${nt(y.displayText)}`:F}),c=c.replace(fo,"$1");let f=Ur(c,nl,"MATH");c=f.text,c=s.restore(c),c=u.restore(c);let m=(a?po.renderInline(c):po.render(c)).trim(),p=f.restore(m,nt);return{html:a?p:mn(p),media:l}}};function Ur(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 ol(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 nt(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}var rt=class extends C{constructor(t){super("errors.currentFileOutOfScope",{filePath:t}),this.name="CurrentFileOutOfScopeError"}},Oe=class extends C{constructor(){super("errors.runScopeNotConfigured"),this.name="RunScopeNotConfiguredError"}},Sn=class{constructor(t,n,r,i=new Vt(t),a=new bn,o=new xn,u=new Bt(r),s=new jt(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 yn(r,void 0,void 0,void 0,void 0,void 0,void 0,this.vaultGateway.createBacklink.bind(this.vaultGateway)),ul(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 rt(t);let r=await this.pluginStateRepository.load(),i=await this.fileIndexerService.indexFile(t,n,r);return this.syncIndexedResult(i,r,n)}assertRunScopeConfigured(t){if(!lt(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=sl(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:cl(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(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 ul(e){return typeof e=="object"&&e!==null&&"resolve"in e}function sl(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 cl(...e){let t=new Map;for(let n of e)for(let r of n)t.set(ze(r),r);return[...t.values()]}var it=class extends go.Plugin{constructor(){super(...arguments);this.settings=H;this.noticeService=new Et;this.ankiGateway=new Ct(()=>this.settings.ankiConnectUrl);this.deckTemplateInsertionService=new pt}async onload(){let n=new Ft(this);this.pluginConfigRepository=new Tt(n);let r=new At(n),i=new wt(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 Sn(i,r,this.ankiGateway);this.syncCurrentFileUseCase=new xt(a),this.syncVaultUseCase=new St(a),this.clearCurrentFileSyncedCardsUseCase=new bt(r,this.ankiGateway,i),this.cleanupEmptyDecksUseCase=new ft(this.ankiGateway),vi(this),this.addSettingTab(new Nt(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 rt||r instanceof Oe){this.noticeService.info(at(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(at(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 Dt(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 dl=it;
diff --git a/manifest.json b/manifest.json
index af05c95..28da46e 100644
--- a/manifest.json
+++ b/manifest.json
@@ -5,6 +5,6 @@
"minAppVersion": "1.5.0",
"description": "Focused heading-based Obsidian to Anki sync plugin.",
"author": "Dusk",
- "authorUrl": "https://github.com/panAtGitHub",
+ "authorUrl": "https://github.com/panAtGitHub/AnkiHeadingSync",
"isDesktopOnly": true
}
\ No newline at end of file
diff --git a/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts
index ad8bcbc..4e21be5 100644
--- a/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts
+++ b/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { PluginDataStore } from "@/application/ports/PluginDataStore";
-import { createEmptyPluginState } from "@/domain/manual-sync/entities/PluginState";
+import { createEmptyPluginState, type PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState";
import { hashString } from "@/domain/shared/hash";
import { DataJsonPluginStateRepository } from "./DataJsonPluginStateRepository";
@@ -115,7 +115,116 @@ describe("DataJsonPluginStateRepository", () => {
expect(loaded.pendingWriteBack).toEqual([]);
});
- it("normalizes removed semantic QA card types to basic when loading modern plugin state", async () => {
+ it("preserves valid modern pending writeback entries across save and load", async () => {
+ const store = new InMemoryPluginDataStore();
+ const repository = new DataJsonPluginStateRepository(store);
+ const pendingWriteBack: PendingWriteBackState[] = [
+ createPendingWriteBackState({
+ filePath: "notes/card.md",
+ blockStartLine: 2,
+ expectedFileHash: "hash-card",
+ targetMarker: "",
+ rawBlockHash: "raw-card",
+ targetNoteId: 41,
+ markerKind: "card-id",
+ }),
+ createPendingWriteBackState({
+ filePath: "notes/group.md",
+ blockStartLine: 6,
+ expectedFileHash: "hash-group",
+ targetMarker: "",
+ rawBlockHash: "raw-group",
+ targetNoteId: 42,
+ markerKind: "group-gi",
+ targetGroupId: "group-1",
+ }),
+ createPendingWriteBackState({
+ filePath: "notes/legacy-shape-but-modern-fields.md",
+ blockStartLine: 9,
+ expectedFileHash: "hash-no-kind",
+ targetMarker: "",
+ rawBlockHash: "raw-no-kind",
+ targetNoteId: 43,
+ }),
+ ];
+
+ await repository.save({
+ files: {},
+ cards: {},
+ pendingWriteBack,
+ });
+
+ await expect(repository.load()).resolves.toMatchObject({
+ pendingWriteBack,
+ });
+ });
+
+ it("drops malformed and legacy pending writeback entries during load", async () => {
+ const repository = new DataJsonPluginStateRepository(new InMemoryPluginDataStore({
+ pluginState: {
+ files: {},
+ cards: {},
+ pendingWriteBack: [
+ {
+ filePath: "notes/missing-note-id.md",
+ blockStartLine: 1,
+ expectedFileHash: "hash",
+ targetMarker: "",
+ rawBlockHash: "raw",
+ },
+ {
+ filePath: "notes/wrong-line.md",
+ blockStartLine: "1" as never,
+ expectedFileHash: "hash",
+ targetMarker: "",
+ rawBlockHash: "raw",
+ targetNoteId: 2,
+ },
+ {
+ filePath: "notes/invalid-kind.md",
+ blockStartLine: 1,
+ expectedFileHash: "hash",
+ targetMarker: "",
+ rawBlockHash: "raw",
+ targetNoteId: 3,
+ markerKind: "other" as never,
+ },
+ {
+ filePath: "notes/invalid-group-id.md",
+ blockStartLine: 1,
+ expectedFileHash: "hash",
+ targetMarker: "",
+ rawBlockHash: "raw",
+ targetNoteId: 4,
+ markerKind: "group-gi",
+ targetGroupId: "",
+ },
+ {
+ filePath: "notes/legacy-only.md",
+ cardId: "ahs_1",
+ noteId: 5,
+ expectedFileHash: "hash",
+ targetMarker: "",
+ rawBlockHash: "raw",
+ },
+ {
+ filePath: "notes/missing-hash.md",
+ blockStartLine: 1,
+ expectedFileHash: "",
+ targetMarker: "",
+ rawBlockHash: "raw",
+ targetNoteId: 6,
+ },
+ ],
+ } as never,
+ }));
+
+ await expect(repository.load()).resolves.toMatchObject({
+ pendingWriteBack: [],
+ });
+ });
+
+ it("drops removed semantic QA card state instead of converting it to basic", async () => {
const rawBlockText = ["semantic-qa:核心产品::1", "城市更新", "核心产品", "百人会"].join("\n");
const repository = new DataJsonPluginStateRepository(new InMemoryPluginDataStore({
pluginState: {
@@ -159,7 +268,94 @@ describe("DataJsonPluginStateRepository", () => {
const loaded = await repository.load();
- expect(loaded.cards["52"]?.cardType).toBe("basic");
- expect(loaded.cards["52"]?.backlinkHeadingText).toBe("城市更新 #anki-list-qa");
+ expect(loaded.cards["52"]).toBeUndefined();
+ expect(loaded.files["notes/example.md"]?.noteIds).toEqual([]);
});
-});
\ No newline at end of file
+
+ it("keeps supported basic and cloze card states while filtering dangling file noteIds", async () => {
+ const basicRawBlockText = ["#### Prompt", "Answer"].join("\n");
+ const clozeRawBlockText = ["##### Cloze", "{{c1::Body}}"].join("\n");
+ const repository = new DataJsonPluginStateRepository(new InMemoryPluginDataStore({
+ pluginState: {
+ files: {
+ "notes/example.md": {
+ filePath: "notes/example.md",
+ fileHash: "hash",
+ fileStamp: "1:1",
+ lastIndexedAt: 1,
+ noteIds: [41, 42, 99],
+ },
+ },
+ cards: {
+ "41": {
+ noteId: 41,
+ filePath: "notes/example.md",
+ heading: "Prompt",
+ backlinkHeadingText: "Prompt",
+ headingLevel: 4,
+ bodyMarkdown: "Answer",
+ cardType: "basic",
+ blockStartOffset: 0,
+ blockEndOffset: basicRawBlockText.length,
+ blockStartLine: 1,
+ bodyStartLine: 2,
+ blockEndLine: 2,
+ contentEndLine: 2,
+ rawBlockText: basicRawBlockText,
+ rawBlockHash: hashString(basicRawBlockText),
+ renderConfigHash: "render-basic",
+ deck: "notes",
+ deckWarnings: [],
+ tagsHint: [],
+ lastSyncedAt: 1,
+ orphan: false,
+ },
+ "42": {
+ noteId: 42,
+ filePath: "notes/example.md",
+ heading: "Cloze",
+ backlinkHeadingText: "Cloze",
+ headingLevel: 5,
+ bodyMarkdown: "{{c1::Body}}",
+ cardType: "cloze",
+ blockStartOffset: 0,
+ blockEndOffset: clozeRawBlockText.length,
+ blockStartLine: 4,
+ bodyStartLine: 5,
+ blockEndLine: 5,
+ contentEndLine: 5,
+ rawBlockText: clozeRawBlockText,
+ rawBlockHash: hashString(clozeRawBlockText),
+ renderConfigHash: "render-cloze",
+ deck: "notes",
+ deckWarnings: [],
+ tagsHint: [],
+ lastSyncedAt: 1,
+ orphan: false,
+ },
+ },
+ pendingWriteBack: [],
+ },
+ }));
+
+ const loaded = await repository.load();
+
+ expect(Object.keys(loaded.cards)).toEqual(["41", "42"]);
+ expect(loaded.cards["41"]?.cardType).toBe("basic");
+ expect(loaded.cards["42"]?.cardType).toBe("cloze");
+ expect(loaded.files["notes/example.md"]?.noteIds).toEqual([41, 42]);
+ });
+});
+
+function createPendingWriteBackState(overrides: Partial