diff --git a/docs/folder-deck-mode-overrides-decisions.md b/docs/folder-deck-mode-overrides-decisions.md
new file mode 100644
index 0000000..d3b9b90
--- /dev/null
+++ b/docs/folder-deck-mode-overrides-decisions.md
@@ -0,0 +1,67 @@
+# 文件夹级牌组模式覆盖决策
+
+## 设置结构
+
+- 新增 `PluginSettings.alternateFolderDeckModeFolders: string[]`。
+- 默认值为 `[]`。
+- 缺失字段按旧数据迁移到 `[]`。
+- 校验规则与 `includeFolders` / `excludeFolders` 保持一致:必须是数组,且成员必须是字符串。
+- 不额外引入全局路径规范化或去重 helper;保持与现有配置层行为一致。
+
+## UI 放置位置
+
+- 在 `PluginSettingTab.renderFolderNode()` 内,仅对 include 模式且当前行已选中的文件夹渲染覆盖控件。
+- 新控件追加在文件夹标签后,不替换现有运行范围 checkbox。
+- 保留现有 dataset:`folderRow`、`folderToggle`、`folderPath`、`folderPathLabel`。
+- 新增 dataset 供测试使用:
+ - `folderDeckModeOverride`
+ - `folderDeckModeOverrideHint`
+
+## 圆形 checkbox 行为
+
+- 控件只读写 `alternateFolderDeckModeFolders`。
+- 不改动 include 勾选状态。
+- 点击时阻止冒泡,避免触发展开按钮或运行范围 checkbox 的副作用。
+- 使用原生 checkbox,CSS 改成圆形外观并保留 `aria-label` / `title`。
+
+## 提示文案
+
+- 全局 `folder-and-file` 时,启用提示显示:`本文件夹单独采用「文件夹」作为牌组名`。
+- 全局 `folder` 时,启用提示显示:`本文件夹单独采用「文件夹及文件名」作为牌组名`。
+- 全局 `off` 时不显示控件和提示,但保留已保存的覆盖列表。
+
+## 路径匹配规则
+
+- 覆盖匹配使用规范:`filePath === folderPath || filePath.startsWith(folderPath + "/")`。
+- 父文件夹覆盖对子孙路径全部生效。
+- 不支持“子文件夹反向恢复全局模式”的三态语义,本次仍是二元开关。
+
+## 覆盖模式解析
+
+- 全局 `folder-and-file` 时,命中覆盖文件夹改用 `folder`。
+- 全局 `folder` 时,命中覆盖文件夹改用 `folder-and-file`。
+- 全局 `off` 时,忽略覆盖列表。
+
+## include 取消勾选清理
+
+- 当 include 模式取消勾选某个文件夹时,清理该路径及所有后代路径在 `alternateFolderDeckModeFolders` 中的记录。
+- 该清理仅影响覆盖列表,不影响其他 include 选择。
+- exclude/all 模式下不显示控件,也不触发这套清理逻辑。
+
+## 牌组解析接入点
+
+- 在 `FolderDeckMappingService` 中增加“基于覆盖列表求实际 mode”的能力。
+- `DeckResolutionService.resolve()` 接收完整 `PluginSettings`,从而统一处理默认 deck、全局 mode 与覆盖列表。
+- 普通卡片通过 `RenderConfigService` 传入完整 settings。
+- QA Group 继续复用同一个 `DeckResolutionService`,保持优先级一致。
+
+## 指纹变更
+
+- `createDeckRulesFingerprint()` 纳入 `alternateFolderDeckModeFolders`。
+- 指纹版本升级到下一版。
+- 为减少纯顺序变动引起的无意义重算,指纹内部对覆盖文件夹列表做排序后再哈希;这只影响 fingerprint,不改变实际设置保存顺序。
+
+## 与原计划的偏差
+
+- 偏差 1:配置层不新增全局路径规范化/去重。原因:现有 `includeFolders` / `excludeFolders` 没有这样做,新字段若单独增强会造成配置行为不一致。
+- 偏差 2:DeckResolution 直接改为接收完整 `settings`。原因:当前普通卡片和 QA Group 都通过该服务决策 deck,把覆盖规则聚合在这里最小且一致,避免在多个调用点重复计算“反向 mode”。
\ No newline at end of file
diff --git a/docs/folder-deck-mode-overrides-gap-report.md b/docs/folder-deck-mode-overrides-gap-report.md
new file mode 100644
index 0000000..7f00b63
--- /dev/null
+++ b/docs/folder-deck-mode-overrides-gap-report.md
@@ -0,0 +1,65 @@
+# 文件夹级牌组模式覆盖 gap report
+
+## 当前运行范围文件树结构
+
+- 运行范围卡片由 `src/presentation/settings/PluginSettingTab.ts` 的 `renderScopeCard()` 渲染。
+- scope 模式切换保存在 `PluginSettings.scopeMode`,文件夹选择保存在 `includeFolders` / `excludeFolders`。
+- 文件树数据来自插件接口 `listFolderTree()`,在设置页内通过 `ensureFolderTreeLoaded()` 懒加载。
+- 文件树选择状态由 `src/presentation/settings/FolderScopeTree.ts` 的 `buildFolderTreeSelection()` 与 `toggleFolderTreeSelection()` 计算。
+- 单行 UI 由 `renderFolderNode()` 负责,现有 dataset 包括:`folderRow`、`folderToggle`、`folderPath`、`folderPathLabel`、`folderChildren`。
+- 现有文件夹行是三列 grid:展开按钮、运行范围 checkbox、文件夹标签。
+
+## 当前文件夹牌组映射流程
+
+- 全局设置字段是 `PluginSettings.folderDeckMode`,取值 `off | folder | folder-and-file`。
+- 普通卡片路径在 `src/application/services/RenderConfigService.ts` 中通过 `DeckResolutionService.resolve(card, defaultDeck, folderDeckMode)` 决定最终 deck。
+- QA Group 路径在 `src/application/services/QaGroupSyncService.ts` 中也直接调用同一个 `DeckResolutionService.resolve(...)`。
+- `src/domain/manual-sync/services/DeckResolutionService.ts` 当前优先顺序是:
+ 1. `card.deckHint`
+ 2. `FolderDeckMappingService.mapFilePathToDeck(filePath, folderDeckMode)`
+ 3. `defaultDeck`
+- `src/domain/manual-sync/services/FolderDeckMappingService.ts` 只按 `filePath` 和 `mode` 计算 deck,不知道运行范围或文件夹覆盖。
+
+## 当前牌组优先级实现
+
+- 文件级显式 deck hint 仍然是最高优先级,这一层已经在 `DeckResolutionService.resolve()` 最前面短路返回。
+- 文件夹映射层完全依赖 `FolderDeckMappingService`。
+- 只有当前两层都没有产出 deck 时才回退到默认牌组并附加 `deck_fallback_default` warning。
+
+## 覆盖设置应存放的位置
+
+- 新字段应加入 `src/application/config/PluginSettings.ts` 的 `PluginSettings` 接口与 `DEFAULT_SETTINGS`。
+- 加载/保存路径通过 `mergePluginSettings()` 与 `normalizePluginSettings()` 进入 `DataJsonPluginConfigRepository`。
+- 现有 include/exclude 文件夹列表只做“数组 + 字符串”校验,没有统一去重或路径规范化 helper;新字段应沿用同样约定,避免引入与仓库现状不一致的新归一化语义。
+
+## UI 应插入的位置
+
+- 只应插入到 `renderFolderNode()` 的 include 模式行内。
+- 行内现有顺序是 toggle、scope checkbox、label;新增控件应追加在 label 后侧,并保持现有 dataset 不变。
+- 仅当 `scopeMode === "include"`、当前行已选中、且全局 `folderDeckMode !== "off"` 时显示。
+- 取消 include 勾选的清理逻辑不应放在 UI 层分散处理,而应合并进 `updateFolderSelection()`,这样任何同一路径的取消动作都能统一清理子树覆盖记录。
+
+## 指纹更新位置
+
+- deck 规则指纹在 `src/application/services/FileIndexerService.ts` 的 `createDeckRulesFingerprint()` 中生成。
+- `indexVault()` 通过比较 `existingFileState.deckRulesFingerprint !== deckRulesFingerprint` 强制重读;因此覆盖规则必须进入该指纹。
+- 当前版本号为 `deck-rules-v4`,实现覆盖后需要升级版本。
+
+## 预计修改的测试文件
+
+- `src/application/config/PluginSettings.test.ts`
+- `src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts`
+- `src/presentation/settings/PluginSettingTab.test.ts`
+- `src/domain/manual-sync/services/FolderDeckMappingService.test.ts`
+- `src/domain/manual-sync/services/DeckResolutionService.test.ts`
+- `src/application/services/FileIndexerService.test.ts`
+- 可能还要更新 `src/test-support/manualSyncFakes.ts` 的默认设置工厂,以便新字段默认值在各类测试夹具中可用。
+
+## 风险点
+
+- `toggleFolderTreeSelection()` 会压缩选择结果;覆盖清理必须基于“被取消的目标路径及其后代”,不能误删其他分支。
+- 父文件夹覆盖应对子孙路径生效,但不能把 `notes` 误匹配到 `notes2`;应复用仓库里现有的 `path === folder || path.startsWith(folder + "/")` 规则。
+- `DeckResolutionService.resolve()` 当前签名被多处调用,若扩展参数需要同步更新 `RenderConfigService`、`QaGroupSyncService` 和直接单测。
+- 作用范围树的测试大量依赖现有 dataset;新增控件不能破坏 `folderRow`、`folderToggle`、`folderPath`、`folderPathLabel` 的查找方式。
+- 设置页行布局当前是固定 grid,新增圆形 checkbox 后需要补 CSS,避免标签挤压或破坏缩进对齐。
+- 当全局 `folderDeckMode` 为 `off` 时必须忽略覆盖列表,但不能清空已保存数据。
\ No newline at end of file
diff --git a/main.js b/main.js
index 3dcfef7..9976f6f 100644
--- a/main.js
+++ b/main.js
@@ -1,38 +1,38 @@
/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
*/
-"use strict";var Tn=Object.defineProperty;var ho=Object.getOwnPropertyDescriptor;var ko=Object.getOwnPropertyNames;var bo=Object.prototype.hasOwnProperty;var Qe=(e,t)=>{for(var n in t)Tn(e,n,{get:t[n],enumerable:!0})},yo=(e,t,n,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of ko(t))!bo.call(e,i)&&i!==n&&Tn(e,i,{get:()=>t[i],enumerable:!(r=ho(t,i))||r.enumerable});return e};var xo=e=>yo(Tn({},"__esModule",{value:!0}),e);var bl={};Qe(bl,{default:()=>kl});module.exports=xo(bl);var go=require("obsidian");function B(e,t){return`${e}:${t}`}function Dn(e){return e?.cardType==="basic"}function ei(e){return e?.cardType==="cloze"}function H(e){return e?.cardType==="qa-group"}var ti=require("obsidian");function se(e=(0,ti.getLanguage)()){let t=e.toLowerCase();return t==="zh"||t.startsWith("zh-")?"zh":"en"}function ce(e,t){return t.join(e==="zh"?"\u3001":", ")}var An={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: http://127.0.0.1:8765",placeholder:"Example: http://127.0.0.1:8765"},qaHeadingLevel:{name:"Heading level for question-and-answer cards",desc:"Default: H4"},clozeHeadingLevel:{name:"Cloze heading level",desc:"Default: 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:"List notes for question-and-answer groups",marker:{name:"Marker for question-and-answer groups",desc:"When a question-and-answer heading ends with this hashtag marker, the whole heading block syncs as one list note for that group.",placeholder:"Hashtag marker"},managedNoteType:"Current note type: {{modelName}}. This route syncs QA Group list notes using the field mapping shown below.",managedModelContract:"Current note type summary: {{fieldCount}} fields and {{templateCount}} templates are available for this route."},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 uses the default backlink label.",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 "#example #tag/case". Inline tags like "this is a #tag example" and lines with ordinary text like "tag label: #project" 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:"Basic and question-and-answer",description:"Choose the note type for question-and-answer cards, read its fields from Anki, then confirm the title and 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 d=0,l=r.length;d ${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 d=!1;for(let c=0,l=r.length;c ${t} ]*>.*?<\/a><\/p>/g,"").replace(/ne((2147483647-i)/c)&&ke("overflow"),i+=(s-r)*c,r=s;for(let d of e)if(d
${t}`:t,body:e.body};let r=`
]*>.*?<\/a>/g,"").trim()}function nl(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 rl(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 il="manual-render-v3",Ae=class{constructor(t=new Oe){this.deckResolutionService=t}resolve(t,n){let r=al(t.cardType,n),i=this.deckResolutionService.resolve(t,n.defaultDeck,n.folderDeckMode),a=i.resolvedDeck.value,o=n.noteFieldMappings[B(t.cardType,r)]??null,u=t.cardType==="cloze"&&t.clozeMode==="all"?"all":void 0,s={version:il,cardType:t.cardType,clozeMode:u,noteModel:r,mapping:o,addObsidianBacklink:n.addObsidianBacklink,obsidianBacklinkLabel:n.obsidianBacklinkLabel,obsidianBacklinkPlacement:n.obsidianBacklinkPlacement,convertHighlightsToCloze:n.convertHighlightsToCloze,keepPureTagLinesInCardBody:n.keepPureTagLinesInCardBody},c=Q(JSON.stringify(s));return{deck:a,noteModel:r,renderConfigHash:c,warnings:i.warnings}}};function al(e,t){if(Ei(e)){if(!t.clozeNoteType.trim())throw new S("errors.noteFieldMapping.noteTypeNotSelected.cloze");return t.clozeNoteType}if(!t.qaNoteType.trim())throw new S("errors.noteFieldMapping.noteTypeNotSelected.basic");return t.qaNoteType}var Zr=/`[^`\n]+`/g,Yr=/```[\s\S]*?```|~~~[\s\S]*?~~~/g,Sn=/==(.+?)==/g,Xr=/(?:(?0&&i.add(u)}for(let o of r.text.matchAll(Xr)){let u=o[1],s=u?Number(u):e.clozeMode==="all"?1:a++;Number.isInteger(s)&&s>0&&i.add(s)}return i}function $e(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)}}var Fn=class{constructor(t=new Ae){this.renderConfigService=t}plan(t,n,r,i){let a=new Map,o=new Set(n.pendingWriteBack.filter(x=>x.markerKind!=="group-gi").map(x=>Le(x.filePath,x.blockStartLine,x.rawBlockHash))),u=new Set,s=[],c=[],d=[],l=[],f=[],m=[],p=new Map,F=0;for(let x of t){if(a.has(x.syncKey))throw new Error(`Duplicate syncKey detected in current scan: ${x.syncKey}`);if(a.set(x.syncKey,x),x.noteId!==void 0){let R=z(x.noteId);if(u.has(R))throw new Error(`Duplicate noteId detected in current scan: ${x.noteId}`);u.add(R)}let C=x.noteId!==void 0?n.cards[z(x.noteId)]:void 0,h=this.renderConfigService.resolve(x,i);for(let R of h.warnings)p.set(We(R),R);let M=x.noteId,T=Le(x.filePath,x.blockStartLine,x.rawBlockHash),D={card:{...x,noteId:M},noteId:M,deck:h.deck,noteModel:h.noteModel,renderConfigHash:h.renderConfigHash};if(!M){s.push(D);continue}if(ul(C,x,i)){c.push(D);continue}if(l.push(D),(x.idMarkerState!=="present-valid"||x.noteIdSource!=="marker"||o.has(T))&&m.push(D),!C){d.push(D);continue}let L=C.rawBlockHash!==x.rawBlockHash||C.renderConfigHash!==h.renderConfigHash||!Vi(C.tagsHint,x.tagsHint)||C.orphan,A=C.deck!==h.deck;L&&d.push(D),A&&f.push(D),!L&&!A&&(F+=1)}let k=new Set(r),b=Object.values(n.cards).filter(x=>k.has(x.filePath)&&!u.has(z(x.noteId)));return{toCreate:s,toRebuild:c,toUpdate:d,toVerifyDeck:l,toChangeDeck:f,toRewriteMarker:m,toOrphan:b,unchangedCards:F,warnings:Array.from(p.values())}}};function ul(e,t,n){if(!e||e.cardType!=="cloze"||t.cardType!=="cloze")return!1;let r=Se(e.cardType,e.clozeMode);if(r!=="sequential"||t.clozeMode!=="all")return!1;let i=Jr({bodyMarkdown:e.bodyMarkdown,clozeMode:r,convertHighlightsToCloze:n.convertHighlightsToCloze}),a=Jr({bodyMarkdown:t.bodyMarkdown,clozeMode:t.clozeMode,convertHighlightsToCloze:n.convertHighlightsToCloze});return i.size>a.size}var fo=new ot({breaks:!0,html:!0,linkify:!0}),sl=/(?`\\[${k.trim()}\\]`),c=c.replace(cl,(F,k)=>`\\(${k.trim()}\\)`),i&&r.convertHighlightsToCloze&&(c=c.replace(Sn,"{$1}")),i&&(c=c.replace(Xr,(F,k,b)=>`{{c${k?Number(k):n.card.clozeMode==="all"?1:l++}::${b}}}`)),c=c.replace(ll,(F,k)=>{let b=r.resourceResolver.resolveEmbed(k,n.card.filePath);return b?(d.push({kind:b.kind,fileName:b.fileName,absolutePath:b.absolutePath,altText:b.altText}),b.kind==="audio"?`[sound:${b.fileName}]`:``):F}),c=c.replace(pl,(F,k)=>{let b=r.resourceResolver.resolveWikiLink(k,n.card.filePath);return b?`${st(b.displayText)}`:F}),c=c.replace(Sn,"$1");let f=$e(c,dl,"MATH");c=f.text,c=s.restore(c),c=u.restore(c);let m=(a?fo.renderInline(c):fo.render(c)).trim(),p=f.restore(m,st);return{html:a?p:bn(p),media:d}}};function fl(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 st(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}var ct=class extends S{constructor(t){super("errors.currentFileOutOfScope",{filePath:t}),this.name="CurrentFileOutOfScopeError"}},Ue=class extends S{constructor(){super("errors.runScopeNotConfigured"),this.name="RunScopeNotConfiguredError"}},wn=class{constructor(t,n,r,i=new Zt(t),a=new Fn,o=new Mn,u=new Ot(r),s=new Yt(t),c=new Ae,d=()=>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 Pe;if(this.qaGroupSyncService=new Cn(r,void 0,void 0,void 0,void 0,void 0,void 0,this.vaultGateway.createBacklink.bind(this.vaultGateway)),gl(c)){this.renderConfigService=c,this.now=d;return}this.renderConfigService=new Ae,this.now=c}async syncVault(t){pe(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(pe(n),this.assertRunScopeConfigured(n),!this.scanScopeService.isPathInScope(t,n.scopeMode,n.includeFolders,n.excludeFolders))throw new ct(t);let r=await this.pluginStateRepository.load(),i=await this.fileIndexerService.indexFile(t,n,r);return this.syncIndexedResult(i,r,n)}assertRunScopeConfigured(t){if(!gt(t.scopeMode,t.includeFolders))throw new Ue}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.toRebuild,...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,m=>Promise.resolve(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])),d=await this.markdownWriteBackService.write(u.markerWrites,c,s.markerWrites),l=ml(n,t.scopedFilePaths,s.syncedGroupBlocks),f=this.buildNextState(n,t.cards,t.indexedFiles,s.syncedGroupBlocks,r,new Set([...u.touchedSyncKeys,...s.touchedSyncKeys,...d.writtenSyncKeys]),u.resolvedNoteIds,i.toOrphan,l);if(f.pendingWriteBack=[...n.pendingWriteBack.filter(m=>!new Set(t.scopedFilePaths).has(m.filePath)),...d.pendingEntries],this.markFilesDirty(f,[...d.failureFiles.map(m=>m.filePath),...d.conflictFiles]),await this.pluginStateRepository.save(f),d.failureFiles.length>0)throw this.createWriteBackFailureError(d.failureFiles);return{scannedFiles:t.scannedFiles,scannedCards:t.cards.length+t.groupBlocks.length,created:u.created+s.created,rebuilt:u.rebuilt,updated:u.updated+s.updated,migratedNoteTypes:u.migratedNoteTypes+s.migratedNoteTypes,migratedDecks:u.migratedDecks+s.migratedDecks,orphaned:i.toOrphan.length+l.length,uploadedMedia:u.uploadedMedia,skippedUnchangedCards:t.skippedUnchangedCards,rewrittenMarkers:d.writtenSyncKeys.length,markerWriteConflictFiles:d.conflictFiles,warnings:hl(i.warnings,s.warnings)}}buildNextState(t,n,r,i,a,o,u,s,c){let d=this.now(),l=Un(a),f=new Set(r.filter(k=>k.content!==void 0).map(k=>k.filePath)),m=new Map;for(let k of i){let b=m.get(k.filePath);b?b.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,b]of Object.entries(p.cards))f.has(b.filePath)&&delete p.cards[k];for(let[k,b]of Object.entries(p.groupBlocks??{}))f.has(b.filePath)&&delete p.groupBlocks?.[k];let F=new Map;for(let k of n){let b=u.get(k.syncKey)??k.noteId;b!==void 0&&F.set(k.syncKey,b)}for(let k of r){if(k.content===void 0)continue;let b=Array.from(new Set(k.cards.map(x=>F.get(x.syncKey)).filter(x=>x!==void 0)));p.files[k.filePath]={filePath:k.filePath,fileHash:k.fileHash,fileStamp:k.fileStamp,deckRulesFingerprint:l,lastIndexedAt:d,noteIds:b,groupIds:(m.get(k.filePath)??[]).map(x=>x.groupId)}}for(let k of n){let b=F.get(k.syncKey);if(b===void 0)continue;let x=t.cards[z(b)]??(k.noteId!==void 0?t.cards[z(k.noteId)]:void 0),C=this.renderConfigService.resolve(k,a);p.cards[z(b)]={noteId:b,filePath:k.filePath,heading:k.heading,backlinkHeadingText:k.backlinkHeadingText,headingLevel:k.headingLevel,bodyMarkdown:k.bodyMarkdown,cardType:k.cardType,clozeMode:k.clozeMode,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:C.renderConfigHash,deck:C.deck,deckHint:k.deckHint,deckHintSource:k.deckHintSource,deckWarnings:[...k.deckWarnings],tagsHint:k.tagsHint,lastSyncedAt:o.has(k.syncKey)?d:x?.lastSyncedAt??0,orphan:!1}}for(let k of s)p.cards[z(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 S("errors.writeBack.summary",{fileCount:t.length},{failures:t})}};function gl(e){return typeof e=="object"&&e!==null&&"resolve"in e}function ml(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 hl(...e){let t=new Map;for(let n of e)for(let r of n)t.set(We(r),r);return[...t.values()]}var dt=class extends go.Plugin{constructor(){super(...arguments);this.settings=O;this.noticeService=new Nt;this.ankiGateway=new wt(()=>this.settings.ankiConnectUrl);this.deckTemplateInsertionService=new mt}async onload(){let n=new vt(this);this.pluginConfigRepository=new Et(n);let r=new It(n),i=new At(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=O,this.noticeService.error(g("notice.invalidSettingsRestored"))}let a=new wn(i,r,this.ankiGateway);this.syncCurrentFileUseCase=new Ft(a),this.syncVaultUseCase=new Mt(a),this.clearCurrentFileSyncedCardsUseCase=new St(r,this.ankiGateway,i),this.cleanupEmptyDecksUseCase=new ht(this.ankiGateway),Ai(this),this.addSettingTab(new Gt(this))}async updateSettings(n){if(!this.pluginConfigRepository)return;let r=Ce({...this.settings,...n});try{await this.pluginConfigRepository.save(r),this.settings=r}catch(i){this.noticeService.error(xe(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 ct||r instanceof Ue){this.noticeService.info(lt(r));return}console.error("Current file sync failed.",r),this.noticeService.error(xe(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 Ue){this.noticeService.info(lt(n));return}console.error("Vault sync failed.",n),this.noticeService.error(xe(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(xe(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(xe(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 Lt(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(xe(n,"notice.cleanupEmptyDecksFailed"))}}};var kl=dt;
+`;break;default:}return r};qe.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=[],d=[],c=[],l=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(x){let C=e.lineMax;if(x>=C||e.isEmpty(x))return null;let h=!1;if(e.sCount[x]-e.blkIndent>3&&(h=!0),e.sCount[x]<0&&(h=!0),!h){let D=e.md.block.ruler.getRules("reference"),I=e.parentType;e.parentType="reference";let A=!1;for(let R=0,y=D.length;R
ne((2147483647-i)/d)&&ke("overflow"),i+=(s-r)*d,r=s;for(let c of e)if(c
${t}`:t,body:e.body};let r=`
]*>.*?<\/a>/g,"").trim()}function sl(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 dl(e){let t=[];return{text:e.replace(Zc,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 cl="manual-render-v3",Ae=class{constructor(t=new Oe){this.deckResolutionService=t}resolve(t,n){let r=ll(t.cardType,n),i=this.deckResolutionService.resolve(t,n),a=i.resolvedDeck.value,o=n.noteFieldMappings[B(t.cardType,r)]??null,u=t.cardType==="cloze"&&t.clozeMode==="all"?"all":void 0,s={version:cl,cardType:t.cardType,clozeMode:u,noteModel:r,mapping:o,addObsidianBacklink:n.addObsidianBacklink,obsidianBacklinkLabel:n.obsidianBacklinkLabel,obsidianBacklinkPlacement:n.obsidianBacklinkPlacement,convertHighlightsToCloze:n.convertHighlightsToCloze,keepPureTagLinesInCardBody:n.keepPureTagLinesInCardBody},d=Q(JSON.stringify(s));return{deck:a,noteModel:r,renderConfigHash:d,warnings:i.warnings}}};function ll(e,t){if(Ei(e)){if(!t.clozeNoteType.trim())throw new S("errors.noteFieldMapping.noteTypeNotSelected.cloze");return t.clozeNoteType}if(!t.qaNoteType.trim())throw new S("errors.noteFieldMapping.noteTypeNotSelected.basic");return t.qaNoteType}var Yr=/`[^`\n]+`/g,Xr=/```[\s\S]*?```|~~~[\s\S]*?~~~/g,Sn=/==(.+?)==/g,Jr=/(?:(?0&&i.add(u)}for(let o of r.text.matchAll(Jr)){let u=o[1],s=u?Number(u):e.clozeMode==="all"?1:a++;Number.isInteger(s)&&s>0&&i.add(s)}return i}function $e(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,d)=>u.split(`@@${n}_${d}@@`).join(o(s)),a)}}var Fn=class{constructor(t=new Ae){this.renderConfigService=t}plan(t,n,r,i){let a=new Map,o=new Set(n.pendingWriteBack.filter(x=>x.markerKind!=="group-gi").map(x=>Ie(x.filePath,x.blockStartLine,x.rawBlockHash))),u=new Set,s=[],d=[],c=[],l=[],f=[],m=[],p=new Map,F=0;for(let x of t){if(a.has(x.syncKey))throw new Error(`Duplicate syncKey detected in current scan: ${x.syncKey}`);if(a.set(x.syncKey,x),x.noteId!==void 0){let R=z(x.noteId);if(u.has(R))throw new Error(`Duplicate noteId detected in current scan: ${x.noteId}`);u.add(R)}let C=x.noteId!==void 0?n.cards[z(x.noteId)]:void 0,h=this.renderConfigService.resolve(x,i);for(let R of h.warnings)p.set(We(R),R);let M=x.noteId,T=Ie(x.filePath,x.blockStartLine,x.rawBlockHash),D={card:{...x,noteId:M},noteId:M,deck:h.deck,noteModel:h.noteModel,renderConfigHash:h.renderConfigHash};if(!M){s.push(D);continue}if(fl(C,x,i)){d.push(D);continue}if(l.push(D),(x.idMarkerState!=="present-valid"||x.noteIdSource!=="marker"||o.has(T))&&m.push(D),!C){c.push(D);continue}let I=C.rawBlockHash!==x.rawBlockHash||C.renderConfigHash!==h.renderConfigHash||!ji(C.tagsHint,x.tagsHint)||C.orphan,A=C.deck!==h.deck;I&&c.push(D),A&&f.push(D),!I&&!A&&(F+=1)}let k=new Set(r),b=Object.values(n.cards).filter(x=>k.has(x.filePath)&&!u.has(z(x.noteId)));return{toCreate:s,toRebuild:d,toUpdate:c,toVerifyDeck:l,toChangeDeck:f,toRewriteMarker:m,toOrphan:b,unchangedCards:F,warnings:Array.from(p.values())}}};function fl(e,t,n){if(!e||e.cardType!=="cloze"||t.cardType!=="cloze")return!1;let r=Se(e.cardType,e.clozeMode);if(r!=="sequential"||t.clozeMode!=="all")return!1;let i=ei({bodyMarkdown:e.bodyMarkdown,clozeMode:r,convertHighlightsToCloze:n.convertHighlightsToCloze}),a=ei({bodyMarkdown:t.bodyMarkdown,clozeMode:t.clozeMode,convertHighlightsToCloze:n.convertHighlightsToCloze});return i.size>a.size}var mo=new ot({breaks:!0,html:!0,linkify:!0}),gl=/(?`\\[${k.trim()}\\]`),d=d.replace(ml,(F,k)=>`\\(${k.trim()}\\)`),i&&r.convertHighlightsToCloze&&(d=d.replace(Sn,"{$1}")),i&&(d=d.replace(Jr,(F,k,b)=>`{{c${k?Number(k):n.card.clozeMode==="all"?1:l++}::${b}}}`)),d=d.replace(kl,(F,k)=>{let b=r.resourceResolver.resolveEmbed(k,n.card.filePath);return b?(c.push({kind:b.kind,fileName:b.fileName,absolutePath:b.absolutePath,altText:b.altText}),b.kind==="audio"?`[sound:${b.fileName}]`:``):F}),d=d.replace(bl,(F,k)=>{let b=r.resourceResolver.resolveWikiLink(k,n.card.filePath);return b?`${st(b.displayText)}`:F}),d=d.replace(Sn,"$1");let f=$e(d,hl,"MATH");d=f.text,d=s.restore(d),d=u.restore(d);let m=(a?mo.renderInline(d):mo.render(d)).trim(),p=f.restore(m,st);return{html:a?p:bn(p),media:c}}};function yl(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 st(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}var dt=class extends S{constructor(t){super("errors.currentFileOutOfScope",{filePath:t}),this.name="CurrentFileOutOfScopeError"}},Ue=class extends S{constructor(){super("errors.runScopeNotConfigured"),this.name="RunScopeNotConfiguredError"}},wn=class{constructor(t,n,r,i=new Zt(t),a=new Fn,o=new Mn,u=new Ot(r),s=new Yt(t),d=new Ae,c=()=>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 Pe;if(this.qaGroupSyncService=new Cn(r,void 0,void 0,void 0,void 0,void 0,void 0,this.vaultGateway.createBacklink.bind(this.vaultGateway)),xl(d)){this.renderConfigService=d,this.now=c;return}this.renderConfigService=new Ae,this.now=d}async syncVault(t){pe(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(pe(n),this.assertRunScopeConfigured(n),!this.scanScopeService.isPathInScope(t,n.scopeMode,n.includeFolders,n.excludeFolders))throw new dt(t);let r=await this.pluginStateRepository.load(),i=await this.fileIndexerService.indexFile(t,n,r);return this.syncIndexedResult(i,r,n)}assertRunScopeConfigured(t){if(!gt(t.scopeMode,t.includeFolders))throw new Ue}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.toRebuild,...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,m=>Promise.resolve(this.renderer.render(m,a)),r.noteFieldMappings),s=await this.qaGroupSyncService.sync(t.groupBlocks,n,r),d=new Map(t.indexedFiles.map(m=>[m.filePath,m])),c=await this.markdownWriteBackService.write(u.markerWrites,d,s.markerWrites),l=Cl(n,t.scopedFilePaths,s.syncedGroupBlocks),f=this.buildNextState(n,t.cards,t.indexedFiles,s.syncedGroupBlocks,r,new Set([...u.touchedSyncKeys,...s.touchedSyncKeys,...c.writtenSyncKeys]),u.resolvedNoteIds,i.toOrphan,l);if(f.pendingWriteBack=[...n.pendingWriteBack.filter(m=>!new Set(t.scopedFilePaths).has(m.filePath)),...c.pendingEntries],this.markFilesDirty(f,[...c.failureFiles.map(m=>m.filePath),...c.conflictFiles]),await this.pluginStateRepository.save(f),c.failureFiles.length>0)throw this.createWriteBackFailureError(c.failureFiles);return{scannedFiles:t.scannedFiles,scannedCards:t.cards.length+t.groupBlocks.length,created:u.created+s.created,rebuilt:u.rebuilt,updated:u.updated+s.updated,migratedNoteTypes:u.migratedNoteTypes+s.migratedNoteTypes,migratedDecks:u.migratedDecks+s.migratedDecks,orphaned:i.toOrphan.length+l.length,uploadedMedia:u.uploadedMedia,skippedUnchangedCards:t.skippedUnchangedCards,rewrittenMarkers:c.writtenSyncKeys.length,markerWriteConflictFiles:c.conflictFiles,warnings:Sl(i.warnings,s.warnings)}}buildNextState(t,n,r,i,a,o,u,s,d){let c=this.now(),l=Qn(a),f=new Set(r.filter(k=>k.content!==void 0).map(k=>k.filePath)),m=new Map;for(let k of i){let b=m.get(k.filePath);b?b.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,b]of Object.entries(p.cards))f.has(b.filePath)&&delete p.cards[k];for(let[k,b]of Object.entries(p.groupBlocks??{}))f.has(b.filePath)&&delete p.groupBlocks?.[k];let F=new Map;for(let k of n){let b=u.get(k.syncKey)??k.noteId;b!==void 0&&F.set(k.syncKey,b)}for(let k of r){if(k.content===void 0)continue;let b=Array.from(new Set(k.cards.map(x=>F.get(x.syncKey)).filter(x=>x!==void 0)));p.files[k.filePath]={filePath:k.filePath,fileHash:k.fileHash,fileStamp:k.fileStamp,deckRulesFingerprint:l,lastIndexedAt:c,noteIds:b,groupIds:(m.get(k.filePath)??[]).map(x=>x.groupId)}}for(let k of n){let b=F.get(k.syncKey);if(b===void 0)continue;let x=t.cards[z(b)]??(k.noteId!==void 0?t.cards[z(k.noteId)]:void 0),C=this.renderConfigService.resolve(k,a);p.cards[z(b)]={noteId:b,filePath:k.filePath,heading:k.heading,backlinkHeadingText:k.backlinkHeadingText,headingLevel:k.headingLevel,bodyMarkdown:k.bodyMarkdown,cardType:k.cardType,clozeMode:k.clozeMode,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:C.renderConfigHash,deck:C.deck,deckHint:k.deckHint,deckHintSource:k.deckHintSource,deckWarnings:[...k.deckWarnings],tagsHint:k.tagsHint,lastSyncedAt:o.has(k.syncKey)?c:x?.lastSyncedAt??0,orphan:!1}}for(let k of s)p.cards[z(k.noteId)]={...k,orphan:!0};for(let k of i)p.groupBlocks??(p.groupBlocks={}),p.groupBlocks[k.groupId]=k;for(let k of d)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 S("errors.writeBack.summary",{fileCount:t.length},{failures:t})}};function xl(e){return typeof e=="object"&&e!==null&&"resolve"in e}function Cl(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 Sl(...e){let t=new Map;for(let n of e)for(let r of n)t.set(We(r),r);return[...t.values()]}var ct=class extends ho.Plugin{constructor(){super(...arguments);this.settings=H;this.noticeService=new Nt;this.ankiGateway=new wt(()=>this.settings.ankiConnectUrl);this.deckTemplateInsertionService=new mt}async onload(){let n=new vt(this);this.pluginConfigRepository=new Et(n);let r=new Lt(n),i=new At(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 wn(i,r,this.ankiGateway);this.syncCurrentFileUseCase=new Ft(a),this.syncVaultUseCase=new Mt(a),this.clearCurrentFileSyncedCardsUseCase=new St(r,this.ankiGateway,i),this.cleanupEmptyDecksUseCase=new ht(this.ankiGateway),Ai(this),this.addSettingTab(new Gt(this))}async updateSettings(n){if(!this.pluginConfigRepository)return;let r=Ce({...this.settings,...n});try{await this.pluginConfigRepository.save(r),this.settings=r}catch(i){this.noticeService.error(xe(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 dt||r instanceof Ue){this.noticeService.info(lt(r));return}console.error("Current file sync failed.",r),this.noticeService.error(xe(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 Ue){this.noticeService.info(lt(n));return}console.error("Vault sync failed.",n),this.noticeService.error(xe(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(xe(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(xe(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 It(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(xe(n,"notice.cleanupEmptyDecksFailed"))}}};var Fl=ct;
diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts
index 427d53d..e89ac4e 100644
--- a/src/application/config/PluginSettings.test.ts
+++ b/src/application/config/PluginSettings.test.ts
@@ -76,6 +76,7 @@ describe("PluginSettings", () => {
expect(DEFAULT_SETTINGS.folderDeckMode).toBe("folder-and-file");
expect(DEFAULT_SETTINGS.qaGroupMarker).toBe("#anki-list");
expect(DEFAULT_SETTINGS.cardAnswerCutoffMode).toBe("heading-block");
+ expect(DEFAULT_SETTINGS.alternateFolderDeckModeFolders).toEqual([]);
expect(DEFAULT_SETTINGS.obsidianBacklinkLabel).toBe(DEFAULT_OBSIDIAN_BACKLINK_LABEL);
expect(DEFAULT_SETTINGS.obsidianBacklinkPlacement).toBe("answer-last-line");
expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true);
@@ -111,6 +112,40 @@ describe("PluginSettings", () => {
});
});
+ it("defaults alternate folder deck mode override folders to an empty array when missing", () => {
+ const settings = mergePluginSettings({
+ defaultDeck: "Default",
+ });
+
+ expect(settings.alternateFolderDeckModeFolders).toEqual([]);
+ });
+
+ it("preserves alternate folder deck mode override folders using the current folder-list convention", () => {
+ const settings = mergePluginSettings({
+ alternateFolderDeckModeFolders: ["notes", "notes/sub", "notes"],
+ });
+
+ expect(settings.alternateFolderDeckModeFolders).toEqual(["notes", "notes/sub", "notes"]);
+ });
+
+ it("rejects non-array alternate folder deck mode override folders", () => {
+ expectPluginUserError(() => {
+ validatePluginSettings({
+ ...DEFAULT_SETTINGS,
+ alternateFolderDeckModeFolders: "notes" as never,
+ });
+ }, "errors.settings.alternateFolderDeckModeFoldersArray");
+ });
+
+ it("rejects non-string alternate folder deck mode override folder entries", () => {
+ expectPluginUserError(() => {
+ validatePluginSettings({
+ ...DEFAULT_SETTINGS,
+ alternateFolderDeckModeFolders: ["notes", 1] as never,
+ });
+ }, "errors.settings.alternateFolderDeckModeFoldersStrings");
+ });
+
it("normalizes cached Anki note types on load and save paths", () => {
const settings = mergePluginSettings({
ankiNoteTypeCache: [" Custom Basic ", "", "Cloze", "Custom Basic"],
diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts
index 6e5a58f..04706b5 100644
--- a/src/application/config/PluginSettings.ts
+++ b/src/application/config/PluginSettings.ts
@@ -60,6 +60,7 @@ export interface PluginSettings {
scopeMode: ScopeMode;
includeFolders: string[];
excludeFolders: string[];
+ alternateFolderDeckModeFolders: string[];
addObsidianBacklink: boolean;
obsidianBacklinkLabel: string;
obsidianBacklinkPlacement: ObsidianBacklinkPlacement;
@@ -89,6 +90,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
scopeMode: "include",
includeFolders: [],
excludeFolders: [],
+ alternateFolderDeckModeFolders: [],
addObsidianBacklink: true,
obsidianBacklinkLabel: DEFAULT_OBSIDIAN_BACKLINK_LABEL,
obsidianBacklinkPlacement: "answer-last-line",
@@ -144,6 +146,7 @@ export function mergePluginSettings(settings?: Partial