Compare commits

...

9 commits

Author SHA1 Message Date
Dusk
71e36c25c4 chore(release): prepare 1.0.4 scorecard fixes
- replace builtin-modules with Node builtinModules in the build config

- avoid column-gap warnings in release CSS
2026-05-13 20:42:25 +08:00
Dusk
0dc2d7cb5d Release 1.0.3 2026-05-11 21:13:50 +08:00
Dusk
befe28cd77 test(settings): remove global timer shim
中文: 将设置页测试的 activeWindow 计时器改为测试专用控制器,避免裸全局计时器引用和 Obsidian 规则误判。

English: Replace the settings tab test timer shim with a local activeWindow timer controller so the test no longer relies on bare global timer references.
2026-05-04 15:20:19 +08:00
Dusk
13b5685fe5 fix(review): satisfy Obsidian review bot lint
中文: 修复 Obsidian Review Bot 指出的英文文案 sentence case 问题,并调整设置页测试中的计时器类型,避免禁用 prefer-active-window-timers 规则。

English: Fix the remaining Obsidian Review Bot sentence-case findings and update the settings tab test timer shim so no prefer-active-window-timers disable comment is needed.
2026-05-03 20:00:24 +08:00
Dusk
387f977dca Update built plugin bundle 2026-05-03 19:13:38 +08:00
Dusk
0f8d5eefcb Fix review bot lint issues 2026-05-03 19:12:56 +08:00
Dusk
80d6020512 chore: trigger Obsidian plugin rescan 2026-05-03 09:37:57 +08:00
Dusk
a898aa80e9 chore(release): bump version to 1.0.2
- bump package.json/manifest.json to 1.0.2

- add versions.json compatibility entry for 1.0.2

- include latest scope-row inline hint layout adjustments
2026-04-30 21:24:51 +08:00
Dusk
488bd3d91f feat(deck): add folder-level deck mode overrides
在 include 模式下允许选中文件夹改用与全局 folderDeckMode 相反的映射规则

Add include-scope folder overrides that apply the opposite global folderDeckMode mapping

取消勾选文件夹时会清理该路径及其子树的覆盖记录,并把规则接入设置页与 deck fingerprint

Clears override records for deselected folders and descendants, and wires the rules into the settings UI and deck fingerprint
2026-04-30 19:18:15 +08:00
25 changed files with 1159 additions and 96 deletions

View file

@ -7,3 +7,18 @@ When syncing this plugin into the user's Obsidian vault, use this exact plugin d
`/Users/panxiaorong/Library/Mobile Documents/iCloud~md~obsidian/Documents/obsidian/.obsidian/plugins/Anki Heading Sync` `/Users/panxiaorong/Library/Mobile Documents/iCloud~md~obsidian/Documents/obsidian/.obsidian/plugins/Anki Heading Sync`
Copy only the built plugin files (`main.js`, `manifest.json`, `styles.css`) into that directory. Do not delete or overwrite `data.json`. Copy only the built plugin files (`main.js`, `manifest.json`, `styles.css`) into that directory. Do not delete or overwrite `data.json`.
## GitHub Release Assets
When creating or updating a GitHub release, upload the runtime plugin assets:
- `main.js`
- `manifest.json`
- `styles.css`
Also upload the example files from `dist/`:
- `dist/dead-sea-example.md`
- `dist/dead-sea-example.apkg`
The local Chinese-named example files duplicate the English `dead-sea-example.*` files. The English names are the ones referenced by `README.md`; keep only those two example assets attached to every release that includes downloadable assets.

View file

@ -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 的副作用。
- 使用原生 checkboxCSS 改成圆形外观并保留 `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` 没有这样做,新字段若单独增强会造成配置行为不一致。
- 偏差 2DeckResolution 直接改为接收完整 `settings`。原因:当前普通卡片和 QA Group 都通过该服务决策 deck把覆盖规则聚合在这里最小且一致避免在多个调用点重复计算“反向 mode”。

View file

@ -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` 时必须忽略覆盖列表,但不能清空已保存数据。

View file

@ -1,12 +1,13 @@
import esbuild from "esbuild"; import esbuild from "esbuild";
import process from "node:process"; import process from "node:process";
import builtins from "builtin-modules"; import { builtinModules } from "node:module";
import { copyFile } from "node:fs/promises"; import { copyFile } from "node:fs/promises";
const banner = `/*\nTHIS IS A GENERATED/BUNDLED FILE BY ESBUILD\n*/`; const banner = `/*\nTHIS IS A GENERATED/BUNDLED FILE BY ESBUILD\n*/`;
const production = process.argv[2] === "production"; const production = process.argv[2] === "production";
const outfile = "dist/plugin/main.js"; const outfile = "dist/plugin/main.js";
const builtins = [...new Set(builtinModules.flatMap((name) => [name, `node:${name}`]))];
const context = await esbuild.context({ const context = await esbuild.context({
banner: { js: banner }, banner: { js: banner },

48
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{ {
"id": "anki-heading-sync", "id": "anki-heading-sync",
"name": "Anki Heading Sync", "name": "Anki Heading Sync",
"version": "1.0.1", "version": "1.0.4",
"minAppVersion": "1.8.7", "minAppVersion": "1.8.7",
"description": "Focused heading-based Markdown to Anki sync plugin.", "description": "Focused heading-based Markdown to Anki sync plugin.",
"author": "Dusk", "author": "Dusk",

24
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "obsidian-anki-heading-sync", "name": "obsidian-anki-heading-sync",
"version": "1.0.1", "version": "1.0.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-anki-heading-sync", "name": "obsidian-anki-heading-sync",
"version": "1.0.1", "version": "1.0.4",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"markdown-it": "^14.1.0" "markdown-it": "^14.1.0"
@ -17,10 +17,9 @@
"@types/node": "^22.15.3", "@types/node": "^22.15.3",
"@typescript-eslint/eslint-plugin": "^8.46.1", "@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1", "@typescript-eslint/parser": "^8.46.1",
"builtin-modules": "^4.0.0",
"esbuild": "^0.25.3", "esbuild": "^0.25.3",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-obsidianmd": "^0.2.8", "eslint-plugin-obsidianmd": "^0.2.9",
"globals": "^16.4.0", "globals": "^16.4.0",
"obsidian": "1.12.3", "obsidian": "1.12.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
@ -1052,17 +1051,6 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/builtin-modules": {
"version": "4.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cac": { "node_modules/cac": {
"version": "6.7.14", "version": "6.7.14",
"dev": true, "dev": true,
@ -2487,9 +2475,9 @@
} }
}, },
"node_modules/eslint-plugin-obsidianmd": { "node_modules/eslint-plugin-obsidianmd": {
"version": "0.2.8", "version": "0.2.9",
"resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.2.8.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.2.9.tgz",
"integrity": "sha512-BttDFIqh2f4sDurRgNZ8psjtgnYC7Y8toLaXgyqgmI9CXEE2dBEWXaG+p9vnmJLJQJWURTZL6LUXUNY4IGgBNw==", "integrity": "sha512-+dF5Zz5T6/j0QYGu+wHbY3UZb45Kh+QFkFdfvkVk05o4YIIVqHMlrTFrlRVhuBd6Htu8QxcFOwzeMTN4aysVTA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View file

@ -1,6 +1,6 @@
{ {
"name": "obsidian-anki-heading-sync", "name": "obsidian-anki-heading-sync",
"version": "1.0.1", "version": "1.0.4",
"description": "Focused heading-based Markdown to Anki sync plugin.", "description": "Focused heading-based Markdown to Anki sync plugin.",
"author": "Dusk", "author": "Dusk",
"main": "dist/plugin/main.js", "main": "dist/plugin/main.js",
@ -26,10 +26,9 @@
"@types/node": "^22.15.3", "@types/node": "^22.15.3",
"@typescript-eslint/eslint-plugin": "^8.46.1", "@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1", "@typescript-eslint/parser": "^8.46.1",
"builtin-modules": "^4.0.0",
"esbuild": "^0.25.3", "esbuild": "^0.25.3",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-obsidianmd": "^0.2.8", "eslint-plugin-obsidianmd": "^0.2.9",
"globals": "^16.4.0", "globals": "^16.4.0",
"obsidian": "1.12.3", "obsidian": "1.12.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",

View file

@ -76,6 +76,8 @@ describe("PluginSettings", () => {
expect(DEFAULT_SETTINGS.folderDeckMode).toBe("folder-and-file"); expect(DEFAULT_SETTINGS.folderDeckMode).toBe("folder-and-file");
expect(DEFAULT_SETTINGS.qaGroupMarker).toBe("#anki-list"); expect(DEFAULT_SETTINGS.qaGroupMarker).toBe("#anki-list");
expect(DEFAULT_SETTINGS.cardAnswerCutoffMode).toBe("heading-block"); expect(DEFAULT_SETTINGS.cardAnswerCutoffMode).toBe("heading-block");
expect(DEFAULT_SETTINGS.alternateFolderDeckModeFolders).toEqual([]);
expect(DEFAULT_SETTINGS.standaloneParentDeckFolders).toEqual([]);
expect(DEFAULT_SETTINGS.obsidianBacklinkLabel).toBe(DEFAULT_OBSIDIAN_BACKLINK_LABEL); expect(DEFAULT_SETTINGS.obsidianBacklinkLabel).toBe(DEFAULT_OBSIDIAN_BACKLINK_LABEL);
expect(DEFAULT_SETTINGS.obsidianBacklinkPlacement).toBe("answer-last-line"); expect(DEFAULT_SETTINGS.obsidianBacklinkPlacement).toBe("answer-last-line");
expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true); expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true);
@ -111,6 +113,74 @@ 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("defaults standalone parent deck folders to an empty array when missing", () => {
const settings = mergePluginSettings({
defaultDeck: "Default",
});
expect(settings.standaloneParentDeckFolders).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("preserves standalone parent deck folders using the current folder-list convention", () => {
const settings = mergePluginSettings({
standaloneParentDeckFolders: ["notes/sub", "notes/other", "notes/sub"],
});
expect(settings.standaloneParentDeckFolders).toEqual(["notes/sub", "notes/other", "notes/sub"]);
});
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("rejects non-array standalone parent deck folders", () => {
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
standaloneParentDeckFolders: "notes/sub" as never,
});
}, "errors.settings.standaloneParentDeckFoldersArray");
});
it("rejects non-string standalone parent deck folder entries", () => {
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
standaloneParentDeckFolders: ["notes/sub", 1] as never,
});
}, "errors.settings.standaloneParentDeckFoldersStrings");
});
it("normalizes cached Anki note types on load and save paths", () => { it("normalizes cached Anki note types on load and save paths", () => {
const settings = mergePluginSettings({ const settings = mergePluginSettings({
ankiNoteTypeCache: [" Custom Basic ", "", "Cloze", "Custom Basic"], ankiNoteTypeCache: [" Custom Basic ", "", "Cloze", "Custom Basic"],

View file

@ -60,6 +60,8 @@ export interface PluginSettings {
scopeMode: ScopeMode; scopeMode: ScopeMode;
includeFolders: string[]; includeFolders: string[];
excludeFolders: string[]; excludeFolders: string[];
alternateFolderDeckModeFolders: string[];
standaloneParentDeckFolders: string[];
addObsidianBacklink: boolean; addObsidianBacklink: boolean;
obsidianBacklinkLabel: string; obsidianBacklinkLabel: string;
obsidianBacklinkPlacement: ObsidianBacklinkPlacement; obsidianBacklinkPlacement: ObsidianBacklinkPlacement;
@ -89,6 +91,8 @@ export const DEFAULT_SETTINGS: PluginSettings = {
scopeMode: "include", scopeMode: "include",
includeFolders: [], includeFolders: [],
excludeFolders: [], excludeFolders: [],
alternateFolderDeckModeFolders: [],
standaloneParentDeckFolders: [],
addObsidianBacklink: true, addObsidianBacklink: true,
obsidianBacklinkLabel: DEFAULT_OBSIDIAN_BACKLINK_LABEL, obsidianBacklinkLabel: DEFAULT_OBSIDIAN_BACKLINK_LABEL,
obsidianBacklinkPlacement: "answer-last-line", obsidianBacklinkPlacement: "answer-last-line",
@ -144,6 +148,8 @@ export function mergePluginSettings(settings?: Partial<PluginSettings> | null):
ankiModelFieldCache: partialSettings.ankiModelFieldCache ?? DEFAULT_SETTINGS.ankiModelFieldCache, ankiModelFieldCache: partialSettings.ankiModelFieldCache ?? DEFAULT_SETTINGS.ankiModelFieldCache,
includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders, includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
excludeFolders: partialSettings.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders, excludeFolders: partialSettings.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders,
alternateFolderDeckModeFolders: partialSettings.alternateFolderDeckModeFolders ?? DEFAULT_SETTINGS.alternateFolderDeckModeFolders,
standaloneParentDeckFolders: partialSettings.standaloneParentDeckFolders ?? DEFAULT_SETTINGS.standaloneParentDeckFolders,
}); });
} }
@ -204,6 +210,8 @@ export function validatePluginSettings(settings: PluginSettings): void {
validateFolderList(settings.includeFolders, "include"); validateFolderList(settings.includeFolders, "include");
validateFolderList(settings.excludeFolders, "exclude"); validateFolderList(settings.excludeFolders, "exclude");
validateFolderList(settings.alternateFolderDeckModeFolders, "alternateFolderDeckMode");
validateFolderList(settings.standaloneParentDeckFolders, "standaloneParentDeck");
if (!settings.ankiConnectUrl.trim()) { if (!settings.ankiConnectUrl.trim()) {
throw new PluginUserError("errors.settings.ankiConnectUrlRequired"); throw new PluginUserError("errors.settings.ankiConnectUrlRequired");
@ -256,14 +264,32 @@ function validateAnkiModelFieldCache(ankiModelFieldCache: AnkiModelFieldCache):
} }
} }
function validateFolderList(folderList: string[], label: "include" | "exclude"): void { function validateFolderList(folderList: string[], label: "include" | "exclude" | "alternateFolderDeckMode" | "standaloneParentDeck"): void {
if (!Array.isArray(folderList)) { if (!Array.isArray(folderList)) {
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersArray" : "errors.settings.excludeFoldersArray"); switch (label) {
case "include":
throw new PluginUserError("errors.settings.includeFoldersArray");
case "exclude":
throw new PluginUserError("errors.settings.excludeFoldersArray");
case "alternateFolderDeckMode":
throw new PluginUserError("errors.settings.alternateFolderDeckModeFoldersArray");
case "standaloneParentDeck":
throw new PluginUserError("errors.settings.standaloneParentDeckFoldersArray");
}
} }
for (const folder of folderList) { for (const folder of folderList) {
if (typeof folder !== "string") { if (typeof folder !== "string") {
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersStrings" : "errors.settings.excludeFoldersStrings"); switch (label) {
case "include":
throw new PluginUserError("errors.settings.includeFoldersStrings");
case "exclude":
throw new PluginUserError("errors.settings.excludeFoldersStrings");
case "alternateFolderDeckMode":
throw new PluginUserError("errors.settings.alternateFolderDeckModeFoldersStrings");
case "standaloneParentDeck":
throw new PluginUserError("errors.settings.standaloneParentDeckFoldersStrings");
}
} }
} }
} }

View file

@ -299,6 +299,144 @@ describe("FileIndexerService", () => {
expect(result.skippedUnchangedFiles).toBe(0); expect(result.skippedUnchangedFiles).toBe(0);
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]); expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
}); });
it("changes the fingerprint and forces re-read when alternate folder deck mode overrides changed", async () => {
const content = ["#### One", "Body"].join("\n");
const oldSettings = createIndexSettingsForPath("notes/one.md", { folderDeckMode: "folder" });
const newSettings = createIndexSettingsForPath("notes/one.md", {
folderDeckMode: "folder",
alternateFolderDeckModeFolders: ["notes"],
});
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/one.md": content,
});
const service = new FileIndexerService(vaultGateway);
const state = {
files: {
"notes/one.md": {
filePath: "notes/one.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(oldSettings),
lastIndexedAt: 1,
noteIds: [10],
},
},
cards: {
"10": {
noteId: 10,
filePath: "notes/one.md",
heading: "One",
backlinkHeadingText: "One",
headingLevel: 4,
bodyMarkdown: "Body",
cardType: "basic" as const,
blockStartOffset: 0,
blockEndOffset: 10,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 2,
contentEndLine: 2,
rawBlockText: content,
rawBlockHash: "hash-card",
renderConfigHash: "render-hash",
deck: "notes",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
},
},
pendingWriteBack: [],
};
expect(createDeckRulesFingerprint(oldSettings)).not.toBe(createDeckRulesFingerprint(newSettings));
const result = await service.indexVault(newSettings, state);
expect(result.skippedUnchangedFiles).toBe(0);
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
});
it("normalizes alternate folder deck mode override ordering inside the fingerprint", () => {
const first = createIndexSettingsForPath("notes/one.md", {
alternateFolderDeckModeFolders: ["notes/sub", "notes"],
});
const second = createIndexSettingsForPath("notes/one.md", {
alternateFolderDeckModeFolders: ["notes", "notes/sub"],
});
expect(createDeckRulesFingerprint(first)).toBe(createDeckRulesFingerprint(second));
});
it("changes the fingerprint and forces re-read when standalone parent deck folders changed", async () => {
const content = ["#### One", "Body"].join("\n");
const oldSettings = createIndexSettingsForPath("notes/one.md", { folderDeckMode: "folder" });
const newSettings = createIndexSettingsForPath("notes/one.md", {
folderDeckMode: "folder",
standaloneParentDeckFolders: ["notes"],
});
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/one.md": content,
});
const service = new FileIndexerService(vaultGateway);
const state = {
files: {
"notes/one.md": {
filePath: "notes/one.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(oldSettings),
lastIndexedAt: 1,
noteIds: [10],
},
},
cards: {
"10": {
noteId: 10,
filePath: "notes/one.md",
heading: "One",
backlinkHeadingText: "One",
headingLevel: 4,
bodyMarkdown: "Body",
cardType: "basic" as const,
blockStartOffset: 0,
blockEndOffset: 10,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 2,
contentEndLine: 2,
rawBlockText: content,
rawBlockHash: "hash-card",
renderConfigHash: "render-hash",
deck: "notes",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
},
},
pendingWriteBack: [],
};
expect(createDeckRulesFingerprint(oldSettings)).not.toBe(createDeckRulesFingerprint(newSettings));
const result = await service.indexVault(newSettings, state);
expect(result.skippedUnchangedFiles).toBe(0);
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
});
it("normalizes standalone parent deck folder ordering inside the fingerprint", () => {
const first = createIndexSettingsForPath("notes/one.md", {
standaloneParentDeckFolders: ["notes/sub", "notes"],
});
const second = createIndexSettingsForPath("notes/one.md", {
standaloneParentDeckFolders: ["notes", "notes/sub"],
});
expect(createDeckRulesFingerprint(first)).toBe(createDeckRulesFingerprint(second));
});
}); });
function createIndexSettingsForPath(filePath: string, overrides: Parameters<typeof createModule3Settings>[0] = {}) { function createIndexSettingsForPath(filePath: string, overrides: Parameters<typeof createModule3Settings>[0] = {}) {

View file

@ -291,7 +291,7 @@ export function createFileStamp(mtime: number, size: number): string {
return `${mtime}:${size}`; return `${mtime}:${size}`;
} }
const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v4"; const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v6";
export function createDeckRulesFingerprint(settings: PluginSettings): string { export function createDeckRulesFingerprint(settings: PluginSettings): string {
return hashString(JSON.stringify({ return hashString(JSON.stringify({
@ -302,6 +302,8 @@ export function createDeckRulesFingerprint(settings: PluginSettings): string {
fileDeckEnabled: settings.fileDeckEnabled, fileDeckEnabled: settings.fileDeckEnabled,
fileDeckMarker: settings.fileDeckMarker, fileDeckMarker: settings.fileDeckMarker,
folderDeckMode: settings.folderDeckMode, folderDeckMode: settings.folderDeckMode,
alternateFolderDeckModeFolders: [...settings.alternateFolderDeckModeFolders].sort(),
standaloneParentDeckFolders: [...settings.standaloneParentDeckFolders].sort(),
syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki, syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki,
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody, keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
})); }));

View file

@ -138,7 +138,7 @@ export class QaGroupSyncService {
deckHint: block.deckHint, deckHint: block.deckHint,
deckHintSource: block.deckHintSource, deckHintSource: block.deckHintSource,
deckWarnings: block.deckWarnings, deckWarnings: block.deckWarnings,
} as never, settings.defaultDeck, settings.folderDeckMode); } as never, settings);
for (const warning of deckResolution.warnings) { for (const warning of deckResolution.warnings) {
warningMap.set(getDeckResolutionWarningKey(warning), warning); warningMap.set(getDeckResolutionWarningKey(warning), warning);
} }

View file

@ -21,7 +21,7 @@ export class RenderConfigService {
resolve(card: IndexedCard, settings: PluginSettings): RenderPlan { resolve(card: IndexedCard, settings: PluginSettings): RenderPlan {
const noteModel = resolveNoteModel(card.cardType, settings); const noteModel = resolveNoteModel(card.cardType, settings);
const deckResolution = this.deckResolutionService.resolve(card, settings.defaultDeck, settings.folderDeckMode); const deckResolution = this.deckResolutionService.resolve(card, settings);
const deck = deckResolution.resolvedDeck.value; const deck = deckResolution.resolvedDeck.value;
const mapping = settings.noteFieldMappings[createNoteFieldMappingKey(card.cardType, noteModel)] ?? null; const mapping = settings.noteFieldMappings[createNoteFieldMappingKey(card.cardType, noteModel)] ?? null;
const clozeMode = card.cardType === "cloze" && card.clozeMode === "all" const clozeMode = card.cardType === "cloze" && card.clozeMode === "all"

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { PluginSettings } from "@/application/config/PluginSettings";
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import { DeckResolutionService } from "./DeckResolutionService"; import { DeckResolutionService } from "./DeckResolutionService";
@ -12,7 +13,7 @@ describe("DeckResolutionService", () => {
filePath: "课程/数学/第一章/导数.md", filePath: "课程/数学/第一章/导数.md",
deckHint: "显式/Deck", deckHint: "显式/Deck",
deckHintSource: "frontmatter", deckHintSource: "frontmatter",
}), "Default", "folder"); }), createDeckSettings({ folderDeckMode: "folder" }));
expect(result.resolvedDeck).toEqual({ expect(result.resolvedDeck).toEqual({
value: "显式::Deck", value: "显式::Deck",
@ -24,7 +25,7 @@ describe("DeckResolutionService", () => {
it("uses folder mapping when no explicit deck exists", () => { it("uses folder mapping when no explicit deck exists", () => {
const service = new DeckResolutionService(); const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "课程/数学/第一章/导数.md" }), "Default", "folder"); const result = service.resolve(createIndexedCard({ filePath: "课程/数学/第一章/导数.md" }), createDeckSettings({ folderDeckMode: "folder" }));
expect(result.resolvedDeck).toEqual({ expect(result.resolvedDeck).toEqual({
value: "课程::数学::第一章", value: "课程::数学::第一章",
@ -35,7 +36,7 @@ describe("DeckResolutionService", () => {
it("supports folder-and-file mapping mode", () => { it("supports folder-and-file mapping mode", () => {
const service = new DeckResolutionService(); const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "课程/数学/第一章/导数.md" }), "Default", "folder-and-file"); const result = service.resolve(createIndexedCard({ filePath: "课程/数学/第一章/导数.md" }), createDeckSettings({ folderDeckMode: "folder-and-file" }));
expect(result.resolvedDeck).toEqual({ expect(result.resolvedDeck).toEqual({
value: "课程::数学::第一章::导数", value: "课程::数学::第一章::导数",
@ -46,7 +47,10 @@ describe("DeckResolutionService", () => {
it("falls back to default deck for root-level files and emits a warning", () => { it("falls back to default deck for root-level files and emits a warning", () => {
const service = new DeckResolutionService(); const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "导数.md" }), " Default/Deck ", "folder-and-file"); const result = service.resolve(createIndexedCard({ filePath: "导数.md" }), createDeckSettings({
defaultDeck: " Default/Deck ",
folderDeckMode: "folder-and-file",
}));
expect(result.resolvedDeck).toEqual({ expect(result.resolvedDeck).toEqual({
value: "Default::Deck", value: "Default::Deck",
@ -58,7 +62,7 @@ describe("DeckResolutionService", () => {
it("warns and falls back to default deck when folder mapping is invalid", () => { it("warns and falls back to default deck when folder mapping is invalid", () => {
const service = new DeckResolutionService(); const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "课程::非法/导数.md" }), "Default", "folder"); const result = service.resolve(createIndexedCard({ filePath: "课程::非法/导数.md" }), createDeckSettings({ folderDeckMode: "folder" }));
expect(result.resolvedDeck.value).toBe("Default"); expect(result.resolvedDeck.value).toBe("Default");
expect(result.warnings.map((warning) => warning.code)).toEqual([ expect(result.warnings.map((warning) => warning.code)).toEqual([
@ -70,10 +74,50 @@ describe("DeckResolutionService", () => {
filePath: "课程::非法/导数.md", filePath: "课程::非法/导数.md",
deckHint: "显式::Deck", deckHint: "显式::Deck",
deckHintSource: "body", deckHintSource: "body",
}), "Default", "folder").resolvedDeck.value).toBe("显式::Deck"); }), createDeckSettings({ folderDeckMode: "folder" })).resolvedDeck.value).toBe("显式::Deck");
});
it("uses the opposite folder mapping mode when the file path hits an alternate override folder", () => {
const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "notes/sub/topic.md" }), createDeckSettings({
folderDeckMode: "folder",
alternateFolderDeckModeFolders: ["notes/sub"],
}));
expect(result.resolvedDeck).toEqual({
value: "notes::sub::topic",
source: "folder",
});
});
it("starts folder mapping from a standalone parent deck folder when configured", () => {
const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "3Resources/Books/BookA/第1章.md" }), createDeckSettings({
folderDeckMode: "folder",
standaloneParentDeckFolders: ["3Resources/Books"],
}));
expect(result.resolvedDeck).toEqual({
value: "Books::BookA",
source: "folder",
});
}); });
}); });
function createDeckSettings(
overrides: Partial<Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders" | "standaloneParentDeckFolders">> = {},
): Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders" | "standaloneParentDeckFolders"> {
return {
defaultDeck: "Default",
folderDeckMode: "folder-and-file",
alternateFolderDeckModeFolders: [],
standaloneParentDeckFolders: [],
...overrides,
};
}
function createIndexedCard(overrides: Partial<IndexedCard> = {}): IndexedCard { function createIndexedCard(overrides: Partial<IndexedCard> = {}): IndexedCard {
return { return {
noteId: overrides.noteId, noteId: overrides.noteId,

View file

@ -1,4 +1,4 @@
import type { FolderDeckMode } from "@/application/config/PluginSettings"; import type { PluginSettings } from "@/application/config/PluginSettings";
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import type { DeckResolutionResult } from "@/domain/manual-sync/value-objects/DeckResolution"; import type { DeckResolutionResult } from "@/domain/manual-sync/value-objects/DeckResolution";
@ -11,7 +11,10 @@ export class DeckResolutionService {
private readonly deckNormalizationService = new DeckNormalizationService(), private readonly deckNormalizationService = new DeckNormalizationService(),
) {} ) {}
resolve(card: IndexedCard, defaultDeck: string, folderDeckMode: FolderDeckMode): DeckResolutionResult { resolve(
card: IndexedCard,
settings: Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders" | "standaloneParentDeckFolders">,
): DeckResolutionResult {
if (card.deckHint) { if (card.deckHint) {
return { return {
resolvedDeck: { resolvedDeck: {
@ -22,7 +25,12 @@ export class DeckResolutionService {
}; };
} }
const folderMapping = this.folderDeckMappingService.mapFilePathToDeck(card.filePath, folderDeckMode); const folderMapping = this.folderDeckMappingService.mapFilePathToDeck(
card.filePath,
settings.folderDeckMode,
settings.alternateFolderDeckModeFolders,
settings.standaloneParentDeckFolders,
);
if (folderMapping.deck) { if (folderMapping.deck) {
return { return {
resolvedDeck: { resolvedDeck: {
@ -35,7 +43,7 @@ export class DeckResolutionService {
return { return {
resolvedDeck: { resolvedDeck: {
value: this.deckNormalizationService.normalize(defaultDeck), value: this.deckNormalizationService.normalize(settings.defaultDeck),
source: "default", source: "default",
}, },
warnings: [ warnings: [

View file

@ -20,6 +20,52 @@ describe("FolderDeckMappingService", () => {
}); });
}); });
it("uses the opposite folder deck mode inside alternate override folders", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("notes/sub/topic.md", "folder", ["notes/sub"]).deck).toBe("notes::sub::topic");
expect(service.mapFilePathToDeck("notes/sub/topic.md", "folder-and-file", ["notes/sub"]).deck).toBe("notes::sub");
});
it("applies alternate overrides to descendants without matching sibling prefixes", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("notes/sub/topic.md", "folder", ["notes"]).deck).toBe("notes::sub::topic");
expect(service.mapFilePathToDeck("notes2/sub/topic.md", "folder", ["notes"]).deck).toBe("notes2::sub");
});
it("ignores alternate overrides when folder deck mode is off", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("notes/sub/topic.md", "off", ["notes/sub"])).toEqual({ warnings: [] });
});
it("starts deck paths from a standalone parent deck folder", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("3Resources/Books/BookA/第1章.md", "folder", [], ["3Resources/Books"])).toEqual({
deck: "Books::BookA",
warnings: [],
});
expect(service.mapFilePathToDeck("3Resources/Books/BookA/第1章.md", "folder-and-file", [], ["3Resources/Books"])).toEqual({
deck: "Books::BookA::第1章",
warnings: [],
});
expect(service.mapFilePathToDeck("3Resources/Books/a.md", "folder", [], ["3Resources/Books"])).toEqual({
deck: "Books",
warnings: [],
});
});
it("prefers the deepest standalone parent deck folder", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("3Resources/Books/Sub/a.md", "folder-and-file", [], ["3Resources", "3Resources/Books"])).toEqual({
deck: "Books::Sub::a",
warnings: [],
});
});
it("returns empty for root-level files", () => { it("returns empty for root-level files", () => {
const service = new FolderDeckMappingService(); const service = new FolderDeckMappingService();

View file

@ -11,24 +11,32 @@ export interface FolderDeckMappingResult {
export class FolderDeckMappingService { export class FolderDeckMappingService {
constructor(private readonly deckNormalizationService = new DeckNormalizationService()) {} constructor(private readonly deckNormalizationService = new DeckNormalizationService()) {}
mapFilePathToDeck(filePath: string, mode: FolderDeckMode): FolderDeckMappingResult { mapFilePathToDeck(
if (mode === "off") { filePath: string,
mode: FolderDeckMode,
alternateFolderDeckModeFolders: string[] = [],
standaloneParentDeckFolders: string[] = [],
): FolderDeckMappingResult {
const effectiveMode = resolveEffectiveFolderDeckMode(filePath, mode, alternateFolderDeckModeFolders);
if (effectiveMode === "off") {
return { warnings: [] }; return { warnings: [] };
} }
const lastSlash = filePath.lastIndexOf("/"); const normalizedFilePath = normalizeFilePath(filePath);
const lastSlash = normalizedFilePath.lastIndexOf("/");
if (lastSlash < 0) { if (lastSlash < 0) {
return { warnings: [] }; return { warnings: [] };
} }
const folderPath = filePath.slice(0, lastSlash); const folderPath = normalizedFilePath.slice(0, lastSlash);
if (!folderPath.trim()) { if (!folderPath.trim()) {
return { warnings: [] }; return { warnings: [] };
} }
const segments = folderPath.split("/").filter(Boolean); const segments = resolveDeckSegments(folderPath, standaloneParentDeckFolders);
if (mode === "folder-and-file") { if (effectiveMode === "folder-and-file") {
const fileName = filePath.slice(lastSlash + 1).replace(/\.[^.]+$/, "").trim(); const fileName = normalizedFilePath.slice(lastSlash + 1).replace(/\.[^.]+$/, "").trim();
if (fileName) { if (fileName) {
segments.push(fileName); segments.push(fileName);
} }
@ -49,3 +57,62 @@ export class FolderDeckMappingService {
}; };
} }
} }
function resolveEffectiveFolderDeckMode(filePath: string, mode: FolderDeckMode, alternateFolderDeckModeFolders: string[]): FolderDeckMode {
if (mode === "off") {
return "off";
}
const normalizedFilePath = normalizeFilePath(filePath);
const hasAlternateOverride = alternateFolderDeckModeFolders.some((folderPath) => {
const normalizedFolderPath = normalizeFolderPath(folderPath);
return normalizedFolderPath.length > 0 && isPathInsideFolder(normalizedFilePath, normalizedFolderPath);
});
if (!hasAlternateOverride) {
return mode;
}
return mode === "folder" ? "folder-and-file" : "folder";
}
function normalizeFolderPath(folderPath: string): string {
return folderPath.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
}
function resolveDeckSegments(folderPath: string, standaloneParentDeckFolders: string[]): string[] {
const allSegments = folderPath.split("/").filter(Boolean);
const standaloneParentDeckFolder = resolveStandaloneParentDeckFolder(folderPath, standaloneParentDeckFolders);
if (!standaloneParentDeckFolder) {
return allSegments;
}
const standaloneSegments = standaloneParentDeckFolder.split("/").filter(Boolean);
const startIndex = Math.max(standaloneSegments.length - 1, 0);
return allSegments.slice(startIndex);
}
function resolveStandaloneParentDeckFolder(folderPath: string, standaloneParentDeckFolders: string[]): string | undefined {
let matchedFolder: string | undefined;
for (const folder of standaloneParentDeckFolders) {
const normalizedFolderPath = normalizeFolderPath(folder);
if (!normalizedFolderPath || !isPathInsideFolder(folderPath, normalizedFolderPath)) {
continue;
}
if (!matchedFolder || normalizedFolderPath.length > matchedFolder.length) {
matchedFolder = normalizedFolderPath;
}
}
return matchedFolder;
}
function normalizeFilePath(filePath: string): string {
return filePath.trim().replace(/\\/g, "/").replace(/^\/+/, "");
}
function isPathInsideFolder(filePath: string, folderPath: string): boolean {
return filePath === folderPath || filePath.startsWith(`${folderPath}/`);
}

View file

@ -63,6 +63,34 @@ describe("DataJsonPluginConfigRepository", () => {
expect(settings.folderDeckMode).toBe("off"); expect(settings.folderDeckMode).toBe("off");
}); });
it("persists alternate folder deck mode overrides across save and reload", async () => {
const store = new InMemoryPluginDataStore();
const repository = new DataJsonPluginConfigRepository(store);
await repository.save({
...DEFAULT_SETTINGS,
alternateFolderDeckModeFolders: ["notes", "notes/sub"],
});
const reloaded = await repository.load();
expect(reloaded.alternateFolderDeckModeFolders).toEqual(["notes", "notes/sub"]);
});
it("persists standalone parent deck folders across save and reload", async () => {
const store = new InMemoryPluginDataStore();
const repository = new DataJsonPluginConfigRepository(store);
await repository.save({
...DEFAULT_SETTINGS,
standaloneParentDeckFolders: ["notes/sub", "notes/other"],
});
const reloaded = await repository.load();
expect(reloaded.standaloneParentDeckFolders).toEqual(["notes/sub", "notes/other"]);
});
it("normalizes blank backlink labels on load and save", async () => { it("normalizes blank backlink labels on load and save", async () => {
const store = new InMemoryPluginDataStore({ const store = new InMemoryPluginDataStore({
settings: { settings: {

View file

@ -18,11 +18,11 @@ export const en = {
}, },
qaHeadingLevel: { qaHeadingLevel: {
name: "Heading level for question-and-answer cards", name: "Heading level for question-and-answer cards",
desc: "Default: H4", desc: "Default question-and-answer heading level is 4.",
}, },
clozeHeadingLevel: { clozeHeadingLevel: {
name: "Cloze heading level", name: "Cloze heading level",
desc: "Default: H5", desc: "Default cloze heading level is 5.",
}, },
cardAnswerCutoffMode: { cardAnswerCutoffMode: {
name: "Card answer cutoff mode", name: "Card answer cutoff mode",
@ -196,6 +196,22 @@ export const en = {
failedLoad: "Failed to load folders from the current vault.", failedLoad: "Failed to load folders from the current vault.",
expandFolder: "Expand {{name}}", expandFolder: "Expand {{name}}",
collapseFolder: "Collapse {{name}}", collapseFolder: "Collapse {{name}}",
folderDeckModeOverride: {
ariaLabel: "Toggle the alternate folder deck mapping mode for {{name}}",
hint: {
folder: "This folder uses the folder-only deck name by itself",
folderAndFile: "This folder uses the folder-and-file deck name by itself",
},
title: {
folder: "Global mode is folder-and-file; enable this to use folder-only deck names for this folder",
folderAndFile: "Global mode is folder-only; enable this to use folder-and-file deck names for this folder",
},
},
standaloneParentDeck: {
ariaLabel: "Use {{name}} as a standalone parent deck root",
hint: "This folder is used as a standalone parent deck",
title: "Enable this to treat this folder as a standalone parent deck root in Anki",
},
}, },
cards: { cards: {
cardTypes: { cardTypes: {
@ -377,6 +393,10 @@ export const en = {
includeFoldersStrings: "Include folders must only contain strings.", includeFoldersStrings: "Include folders must only contain strings.",
excludeFoldersArray: "Exclude folders must be an array.", excludeFoldersArray: "Exclude folders must be an array.",
excludeFoldersStrings: "Exclude folders must only contain strings.", excludeFoldersStrings: "Exclude folders must only contain strings.",
alternateFolderDeckModeFoldersArray: "Alternate folder deck mode folders must be an array.",
alternateFolderDeckModeFoldersStrings: "Alternate folder deck mode folders must only contain strings.",
standaloneParentDeckFoldersArray: "Standalone parent deck folders must be an array.",
standaloneParentDeckFoldersStrings: "Standalone parent deck folders must only contain strings.",
ankiConnectUrlRequired: "AnkiConnect URL is required.", ankiConnectUrlRequired: "AnkiConnect URL is required.",
ankiNoteTypeCacheArray: "Anki note type cache must be an array.", ankiNoteTypeCacheArray: "Anki note type cache must be an array.",
ankiNoteTypeCacheStrings: "Anki note type cache can only contain strings.", ankiNoteTypeCacheStrings: "Anki note type cache can only contain strings.",
@ -400,7 +420,7 @@ export const en = {
noteFieldMappingsQaGroupAcceptedWarningsArray: "Question-and-answer group field mapping acceptedWarnings must be an array.", noteFieldMappingsQaGroupAcceptedWarningsArray: "Question-and-answer group field mapping acceptedWarnings must be an array.",
noteFieldMappingsQaGroupAcceptedWarningsStrings: "Question-and-answer group field mapping acceptedWarnings can only contain strings.", noteFieldMappingsQaGroupAcceptedWarningsStrings: "Question-and-answer group field mapping acceptedWarnings can only contain strings.",
noteFieldMappingsQaGroupDerivationObject: "Question-and-answer group field mapping derivation must be an object.", noteFieldMappingsQaGroupDerivationObject: "Question-and-answer group field mapping derivation must be an object.",
noteFieldMappingsQaGroupDerivationMode: "QA Group field mapping derivation.mode must be first-pair.", noteFieldMappingsQaGroupDerivationMode: "Question-and-answer group field mapping derivation mode must be first-pair.",
noteFieldMappingsQaGroupDerivationFirstQuestionField: "Question-and-answer group field mapping derivation.firstQuestionField must be a string.", noteFieldMappingsQaGroupDerivationFirstQuestionField: "Question-and-answer group field mapping derivation.firstQuestionField must be a string.",
noteFieldMappingsQaGroupDerivationFirstAnswerField: "Question-and-answer group field mapping derivation.firstAnswerField must be a string.", noteFieldMappingsQaGroupDerivationFirstAnswerField: "Question-and-answer group field mapping derivation.firstAnswerField must be a string.",
}, },

View file

@ -194,6 +194,22 @@ export const zh = {
failedLoad: "读取当前 vault 文件夹失败。", failedLoad: "读取当前 vault 文件夹失败。",
expandFolder: "展开 {{name}}", expandFolder: "展开 {{name}}",
collapseFolder: "收起 {{name}}", collapseFolder: "收起 {{name}}",
folderDeckModeOverride: {
ariaLabel: "切换 {{name}} 的文件夹牌组映射模式",
hint: {
folder: "本文件夹单独采用「文件夹」作为牌组名",
folderAndFile: "本文件夹单独采用「文件夹及文件名」作为牌组名",
},
title: {
folder: "当前全局为「文件夹及文件名」,勾选后此文件夹改用「文件夹」作为牌组名",
folderAndFile: "当前全局为「文件夹」,勾选后此文件夹改用「文件夹及文件名」作为牌组名",
},
},
standaloneParentDeck: {
ariaLabel: "将 {{name}} 指定为单独的父牌组",
hint: "本文件夹指定为「单独的父牌组」",
title: "勾选后,此文件夹在 Anki 中会作为单独的父牌组起点",
},
}, },
cards: { cards: {
cardTypes: { cardTypes: {
@ -375,6 +391,10 @@ export const zh = {
includeFoldersStrings: "包含文件夹列表中只能包含字符串。", includeFoldersStrings: "包含文件夹列表中只能包含字符串。",
excludeFoldersArray: "排除文件夹必须是数组。", excludeFoldersArray: "排除文件夹必须是数组。",
excludeFoldersStrings: "排除文件夹列表中只能包含字符串。", excludeFoldersStrings: "排除文件夹列表中只能包含字符串。",
alternateFolderDeckModeFoldersArray: "文件夹牌组模式覆盖列表必须是数组。",
alternateFolderDeckModeFoldersStrings: "文件夹牌组模式覆盖列表中只能包含字符串。",
standaloneParentDeckFoldersArray: "单独父牌组文件夹列表必须是数组。",
standaloneParentDeckFoldersStrings: "单独父牌组文件夹列表中只能包含字符串。",
ankiConnectUrlRequired: "AnkiConnect URL 不能为空。", ankiConnectUrlRequired: "AnkiConnect URL 不能为空。",
ankiNoteTypeCacheArray: "Anki 笔记模板缓存必须是数组。", ankiNoteTypeCacheArray: "Anki 笔记模板缓存必须是数组。",
ankiNoteTypeCacheStrings: "Anki 笔记模板缓存中只能包含字符串。", ankiNoteTypeCacheStrings: "Anki 笔记模板缓存中只能包含字符串。",

View file

@ -700,8 +700,8 @@ function isCardExpanded(tab: AnkiHeadingSyncSettingTab, cardId: string): boolean
} }
type TestActiveWindow = { type TestActiveWindow = {
setTimeout: (callback: () => void, delay?: number) => ReturnType<typeof setTimeout>; setTimeout: (callback: () => void, delay?: number) => number;
clearTimeout: (timer: ReturnType<typeof setTimeout>) => void; clearTimeout: (timer: number) => void;
}; };
type TestWindow = { type TestWindow = {
@ -709,12 +709,75 @@ type TestWindow = {
ResizeObserver: typeof ResizeObserver; ResizeObserver: typeof ResizeObserver;
}; };
type ScheduledTestTimer = {
callback: () => void;
dueAt: number;
};
type TestTimerController = {
setTimeout: (callback: () => void, delay?: number) => number;
clearTimeout: (timer: number) => void;
advanceBy: (duration: number) => void;
};
let testTimerController: TestTimerController | undefined;
function createTestTimerController(): TestTimerController {
let currentTime = 0;
let nextTimer = 1;
const timers = new Map<number, ScheduledTestTimer>();
return {
setTimeout(callback, delay = 0): number {
const timer = nextTimer;
nextTimer += 1;
timers.set(timer, {
callback,
dueAt: currentTime + delay,
});
return timer;
},
clearTimeout(timer): void {
timers.delete(timer);
},
advanceBy(duration): void {
const targetTime = currentTime + duration;
while (true) {
const nextDueTimer = [...timers.entries()]
.filter(([, scheduledTimer]) => scheduledTimer.dueAt <= targetTime)
.sort(([, left], [, right]) => left.dueAt - right.dueAt)[0];
if (!nextDueTimer) {
break;
}
const [timer, scheduledTimer] = nextDueTimer;
timers.delete(timer);
currentTime = scheduledTimer.dueAt;
scheduledTimer.callback();
}
currentTime = targetTime;
},
};
}
function advanceTestTimersByTime(duration: number): void {
if (!testTimerController) {
throw new Error("Test timer controller is not initialized.");
}
testTimerController.advanceBy(duration);
}
function createTestWindow(): TestWindow { function createTestWindow(): TestWindow {
const timerController = createTestTimerController();
testTimerController = timerController;
const activeWindow: TestActiveWindow = { const activeWindow: TestActiveWindow = {
// eslint-disable-next-line obsidianmd/prefer-active-window-timers setTimeout: (callback, delay) => timerController.setTimeout(callback, delay),
setTimeout: (callback, delay) => setTimeout(callback, delay), clearTimeout: (timer) => timerController.clearTimeout(timer),
// eslint-disable-next-line obsidianmd/prefer-active-window-timers
clearTimeout: (timer) => clearTimeout(timer),
}; };
return { return {
@ -736,6 +799,7 @@ describe("PluginSettingTab", () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
testTimerController = undefined;
}); });
it("renders five cards collapsed by default", () => { it("renders five cards collapsed by default", () => {
@ -1043,7 +1107,6 @@ describe("PluginSettingTab", () => {
}); });
it("debounces text input saves instead of saving every keystroke", async () => { it("debounces text input saves instead of saving every keystroke", async () => {
vi.useFakeTimers();
const plugin = new FakePlugin(); const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never); const tab = new AnkiHeadingSyncSettingTab(plugin as never);
@ -1058,18 +1121,17 @@ describe("PluginSettingTab", () => {
expect(plugin.updateCalls).toHaveLength(0); expect(plugin.updateCalls).toHaveLength(0);
vi.advanceTimersByTime(499); advanceTestTimersByTime(499);
await flushPromises(); await flushPromises();
expect(plugin.updateCalls).toHaveLength(0); expect(plugin.updateCalls).toHaveLength(0);
vi.advanceTimersByTime(1); advanceTestTimersByTime(1);
await flushPromises(); await flushPromises();
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-ab"); expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-ab");
expect(plugin.updateCalls).toHaveLength(1); expect(plugin.updateCalls).toHaveLength(1);
}); });
it("flushes pending text saves when the settings tab hides", async () => { it("flushes pending text saves when the settings tab hides", async () => {
vi.useFakeTimers();
const plugin = new FakePlugin(); const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never); const tab = new AnkiHeadingSyncSettingTab(plugin as never);
@ -1088,7 +1150,7 @@ describe("PluginSettingTab", () => {
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-hidden"); expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-hidden");
expect(plugin.updateCalls).toHaveLength(1); expect(plugin.updateCalls).toHaveLength(1);
vi.runOnlyPendingTimers(); advanceTestTimersByTime(500);
await flushPromises(); await flushPromises();
expect(plugin.updateCalls).toHaveLength(1); expect(plugin.updateCalls).toHaveLength(1);
@ -1105,13 +1167,11 @@ describe("PluginSettingTab", () => {
clozeHeading.value = "4"; clozeHeading.value = "4";
await clozeHeading.trigger("change"); await clozeHeading.trigger("change");
vi.useFakeTimers();
const clozeMarker = queryByDataset(tab.containerEl, "cardTypeMarker", "cloze"); const clozeMarker = queryByDataset(tab.containerEl, "cardTypeMarker", "cloze");
clozeMarker.value = ""; clozeMarker.value = "";
await clozeMarker.trigger("input"); await clozeMarker.trigger("input");
vi.advanceTimersByTime(500); advanceTestTimersByTime(500);
await flushPromises(); await flushPromises();
vi.useRealTimers();
expect(plugin.settings.cardTypeConfigs.cloze.headingLevel).toBe(4); expect(plugin.settings.cardTypeConfigs.cloze.headingLevel).toBe(4);
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#anki-cloze"); expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#anki-cloze");
@ -1658,6 +1718,202 @@ describe("PluginSettingTab", () => {
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
}); });
it("renders folder deck mode override controls for checked include rows when folder mapping is enabled", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes/sub"],
folderDeckMode: "folder",
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
const overrideCheckbox = queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub");
const standaloneParentDeckCheckbox = queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub");
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
const overrideAttributes = overrideCheckbox as unknown as { title?: string; "aria-label"?: string };
const standaloneParentDeckAttributes = standaloneParentDeckCheckbox as unknown as { title?: string; "aria-label"?: string };
const labelParent = (childLabel as unknown as { parent?: { children?: unknown[] } }).parent;
const overrideParent = (overrideCheckbox as unknown as { parent?: { children?: unknown[] } }).parent;
expect(overrideCheckbox.classList.contains("ahs-settings-folder-override-checkbox")).toBe(true);
expect(standaloneParentDeckCheckbox.classList.contains("ahs-settings-folder-override-checkbox")).toBe(true);
expect(overrideAttributes.title).toBe("当前全局为「文件夹」,勾选后此文件夹改用「文件夹及文件名」作为牌组名");
expect(overrideAttributes["aria-label"]).toBe("切换 sub 的文件夹牌组映射模式");
expect(standaloneParentDeckAttributes.title).toBe("勾选后,此文件夹在 Anki 中会作为单独的父牌组起点");
expect(standaloneParentDeckAttributes["aria-label"]).toBe("将 sub 指定为单独的父牌组");
expect(overrideParent).toBe(labelParent);
expect(labelParent?.children?.[0]).toBe(childLabel);
expect(labelParent?.children?.[1]).toBe(overrideCheckbox);
expect(labelParent?.children?.[2]).toBe(standaloneParentDeckCheckbox);
expect(() => queryByDataset(tab.containerEl, "folderDeckModeOverrideHint", "notes/sub")).toThrow();
expect(() => queryByDataset(tab.containerEl, "folderStandaloneParentDeckHint", "notes/sub")).toThrow();
});
it("toggling a folder deck mode override only updates alternateFolderDeckModeFolders and shows the hint", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes/sub"],
folderDeckMode: "folder",
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
const overrideCheckbox = queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub");
overrideCheckbox.checked = true;
await overrideCheckbox.trigger("change");
expect(plugin.settings.includeFolders).toEqual(["notes/sub"]);
expect(plugin.settings.alternateFolderDeckModeFolders).toEqual(["notes/sub"]);
expect(plugin.updateCalls).toContainEqual({
alternateFolderDeckModeFolders: ["notes/sub"],
});
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
const rerenderedOverrideCheckbox = queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub");
const standaloneParentDeckCheckbox = queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub");
const hint = queryByDataset(tab.containerEl, "folderDeckModeOverrideHint", "notes/sub");
const inlineParent = (childLabel as unknown as { parent?: { children?: unknown[] } }).parent;
expect(hint.classList.contains("ahs-settings-folder-override-hint")).toBe(true);
expect((hint as unknown as { parent?: unknown }).parent).toBe(inlineParent);
expect(inlineParent?.children?.[0]).toBe(childLabel);
expect(inlineParent?.children?.[1]).toBe(rerenderedOverrideCheckbox);
expect(inlineParent?.children?.[2]).toBe(hint);
expect(inlineParent?.children?.[3]).toBe(standaloneParentDeckCheckbox);
expect(hint.textContent).toContain("文件夹及文件名");
});
it("renders standalone parent deck controls for inherited checked non-root rows and hides them for top-level rows", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes"],
folderDeckMode: "folder",
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
expect(() => queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes")).toThrow();
await queryByDataset(tab.containerEl, "folderToggle", "notes").trigger("click");
expect(queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub")).toBeDefined();
});
it("toggling a standalone parent deck only updates standaloneParentDeckFolders and shows the hint", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes/sub"],
folderDeckMode: "folder",
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
const standaloneParentDeckCheckbox = queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub");
standaloneParentDeckCheckbox.checked = true;
await standaloneParentDeckCheckbox.trigger("change");
expect(plugin.settings.includeFolders).toEqual(["notes/sub"]);
expect(plugin.settings.alternateFolderDeckModeFolders).toEqual([]);
expect(plugin.settings.standaloneParentDeckFolders).toEqual(["notes/sub"]);
expect(plugin.updateCalls).toContainEqual({
standaloneParentDeckFolders: ["notes/sub"],
});
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
const rerenderedOverrideCheckbox = queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub");
const rerenderedStandaloneParentDeckCheckbox = queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub");
const hint = queryByDataset(tab.containerEl, "folderStandaloneParentDeckHint", "notes/sub");
const inlineParent = (childLabel as unknown as { parent?: { children?: unknown[] } }).parent;
expect(hint.classList.contains("ahs-settings-folder-override-hint")).toBe(true);
expect((hint as unknown as { parent?: unknown }).parent).toBe(inlineParent);
expect(inlineParent?.children?.[0]).toBe(childLabel);
expect(inlineParent?.children?.[1]).toBe(rerenderedOverrideCheckbox);
expect(inlineParent?.children?.[2]).toBe(rerenderedStandaloneParentDeckCheckbox);
expect(inlineParent?.children?.[3]).toBe(hint);
expect(hint.textContent).toContain("单独的父牌组");
});
it("cleans folder deck mode overrides for an unchecked include folder and its descendants", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes"],
folderDeckMode: "folder",
alternateFolderDeckModeFolders: ["notes", "notes/sub"],
standaloneParentDeckFolders: ["notes", "notes/sub"],
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
const notesCheckbox = queryByDataset(tab.containerEl, "folderPath", "notes");
notesCheckbox.checked = false;
await notesCheckbox.trigger("change");
expect(plugin.settings.includeFolders).toEqual([]);
expect(plugin.settings.alternateFolderDeckModeFolders).toEqual([]);
expect(plugin.settings.standaloneParentDeckFolders).toEqual([]);
});
it("auto expands ancestors for saved standalone parent deck folders", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes"],
folderDeckMode: "folder",
standaloneParentDeckFolders: ["notes/sub"],
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
expect(queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub")).toBeDefined();
});
it("hides folder deck mode override controls when folder mapping is off", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes/sub"],
folderDeckMode: "off",
alternateFolderDeckModeFolders: ["notes/sub"],
standaloneParentDeckFolders: ["notes/sub"],
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
expect(() => queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub")).toThrow();
expect(plugin.settings.alternateFolderDeckModeFolders).toEqual(["notes/sub"]);
expect(() => queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub")).toThrow();
expect(plugin.settings.standaloneParentDeckFolders).toEqual(["notes/sub"]);
});
it("shows an explicit warning when include mode has no selected folders", async () => { it("shows an explicit warning when include mode has no selected folders", async () => {
const plugin = new FakePlugin(); const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({ plugin.settings = normalizePluginSettings({

View file

@ -1627,7 +1627,49 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const currentSelection = scopeMode === "include" ? this.plugin.settings.includeFolders : this.plugin.settings.excludeFolders; const currentSelection = scopeMode === "include" ? this.plugin.settings.includeFolders : this.plugin.settings.excludeFolders;
const nextSelection = toggleFolderTreeSelection(this.folderTree, currentSelection, folderPath, checked); const nextSelection = toggleFolderTreeSelection(this.folderTree, currentSelection, folderPath, checked);
await this.plugin.updateSettings(scopeMode === "include" ? { includeFolders: nextSelection } : { excludeFolders: nextSelection }); if (scopeMode === "include") {
const nextAlternateFolderDeckModeFolders = checked
? this.plugin.settings.alternateFolderDeckModeFolders
: this.plugin.settings.alternateFolderDeckModeFolders.filter((path) => !isPathInsideFolder(path, folderPath));
const nextStandaloneParentDeckFolders = checked
? this.plugin.settings.standaloneParentDeckFolders
: this.plugin.settings.standaloneParentDeckFolders.filter((path) => !isPathInsideFolder(path, folderPath));
await this.plugin.updateSettings({
includeFolders: nextSelection,
alternateFolderDeckModeFolders: nextAlternateFolderDeckModeFolders,
standaloneParentDeckFolders: nextStandaloneParentDeckFolders,
});
} else {
await this.plugin.updateSettings({ excludeFolders: nextSelection });
}
this.renderCard("scope");
}
private async updateAlternateFolderDeckModeFolder(folderPath: string, checked: boolean): Promise<void> {
const nextAlternateFolderDeckModeFolders = checked
? (this.plugin.settings.alternateFolderDeckModeFolders.includes(folderPath)
? [...this.plugin.settings.alternateFolderDeckModeFolders]
: [...this.plugin.settings.alternateFolderDeckModeFolders, folderPath])
: this.plugin.settings.alternateFolderDeckModeFolders.filter((path) => path !== folderPath);
await this.plugin.updateSettings({
alternateFolderDeckModeFolders: nextAlternateFolderDeckModeFolders,
});
this.renderCard("scope");
}
private async updateStandaloneParentDeckFolder(folderPath: string, checked: boolean): Promise<void> {
const nextStandaloneParentDeckFolders = checked
? (this.plugin.settings.standaloneParentDeckFolders.includes(folderPath)
? [...this.plugin.settings.standaloneParentDeckFolders]
: [...this.plugin.settings.standaloneParentDeckFolders, folderPath])
: this.plugin.settings.standaloneParentDeckFolders.filter((path) => path !== folderPath);
await this.plugin.updateSettings({
standaloneParentDeckFolders: nextStandaloneParentDeckFolders,
});
this.renderCard("scope"); this.renderCard("scope");
} }
@ -1670,11 +1712,69 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
void this.updateFolderSelection(scopeMode, node.path, checkbox.checked); void this.updateFolderSelection(scopeMode, node.path, checkbox.checked);
}); });
const labelEl = row.createSpan({ text: node.name }); const contentEl = row.createDiv();
addClasses(contentEl, "ahs-settings-folder-label-stack");
const inlineLabelEl = contentEl.createDiv();
addClasses(inlineLabelEl, "ahs-settings-folder-label-inline");
const labelEl = inlineLabelEl.createSpan({ text: node.name });
labelEl.dataset.folderPathLabel = node.path; labelEl.dataset.folderPathLabel = node.path;
addClasses(labelEl, "ahs-settings-folder-label"); addClasses(labelEl, "ahs-settings-folder-label");
this.applyFluidEllipsis(labelEl); this.applyFluidEllipsis(labelEl);
if (scopeMode === "include" && node.checked && this.plugin.settings.folderDeckMode !== "off") {
const overrideCheckbox = inlineLabelEl.createEl("input");
const overrideLabel = t("settings.scope.folderDeckModeOverride.ariaLabel", { name: node.name });
const overrideTitle = this.getFolderDeckModeOverrideTitle();
const overrideChecked = this.plugin.settings.alternateFolderDeckModeFolders.includes(node.path);
overrideCheckbox.type = "checkbox";
overrideCheckbox.checked = overrideChecked;
overrideCheckbox.dataset.folderDeckModeOverride = node.path;
overrideCheckbox.setAttr("aria-label", overrideLabel);
overrideCheckbox.setAttr("title", overrideTitle);
addClasses(overrideCheckbox, "ahs-settings-folder-override-checkbox");
overrideCheckbox.addEventListener("click", (event?: Event) => {
event?.stopPropagation();
});
overrideCheckbox.addEventListener("change", () => {
void this.updateAlternateFolderDeckModeFolder(node.path, overrideCheckbox.checked);
});
if (overrideChecked) {
const hintEl = inlineLabelEl.createSpan({ text: this.getFolderDeckModeOverrideHint() });
hintEl.dataset.folderDeckModeOverrideHint = node.path;
addClasses(hintEl, "ahs-settings-folder-override-hint");
}
if (depth > 0) {
const standaloneParentDeckCheckbox = inlineLabelEl.createEl("input");
const standaloneParentDeckLabel = t("settings.scope.standaloneParentDeck.ariaLabel", { name: node.name });
const standaloneParentDeckTitle = this.getStandaloneParentDeckTitle();
const standaloneParentDeckChecked = this.plugin.settings.standaloneParentDeckFolders.includes(node.path);
standaloneParentDeckCheckbox.type = "checkbox";
standaloneParentDeckCheckbox.checked = standaloneParentDeckChecked;
standaloneParentDeckCheckbox.dataset.folderStandaloneParentDeck = node.path;
standaloneParentDeckCheckbox.setAttr("aria-label", standaloneParentDeckLabel);
standaloneParentDeckCheckbox.setAttr("title", standaloneParentDeckTitle);
addClasses(standaloneParentDeckCheckbox, "ahs-settings-folder-override-checkbox");
standaloneParentDeckCheckbox.addEventListener("click", (event?: Event) => {
event?.stopPropagation();
});
standaloneParentDeckCheckbox.addEventListener("change", () => {
void this.updateStandaloneParentDeckFolder(node.path, standaloneParentDeckCheckbox.checked);
});
if (standaloneParentDeckChecked) {
const hintEl = inlineLabelEl.createSpan({ text: this.getStandaloneParentDeckHint() });
hintEl.dataset.folderStandaloneParentDeckHint = node.path;
addClasses(hintEl, "ahs-settings-folder-override-hint");
}
}
}
if (!hasChildren || !expanded) { if (!hasChildren || !expanded) {
return; return;
} }
@ -1697,17 +1797,53 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
} }
this.scopeNeedsSelectedAncestorExpansion = false; this.scopeNeedsSelectedAncestorExpansion = false;
for (const folderPath of this.getSelectedFoldersForScopeMode(scopeMode)) { for (const folderPath of this.getFoldersForAncestorExpansion(scopeMode)) {
for (const ancestorPath of getAncestorFolderPaths(folderPath)) { for (const ancestorPath of getAncestorFolderPaths(folderPath)) {
this.expandedFolderPaths.add(ancestorPath); this.expandedFolderPaths.add(ancestorPath);
} }
} }
} }
private getFoldersForAncestorExpansion(scopeMode: ScopeMode): string[] {
if (scopeMode !== "include") {
return this.getSelectedFoldersForScopeMode(scopeMode);
}
return Array.from(new Set([
...this.plugin.settings.includeFolders,
...this.plugin.settings.alternateFolderDeckModeFolders,
...this.plugin.settings.standaloneParentDeckFolders,
]));
}
private getSelectedFoldersForScopeMode(scopeMode: ScopeMode): string[] { private getSelectedFoldersForScopeMode(scopeMode: ScopeMode): string[] {
return scopeMode === "include" ? this.plugin.settings.includeFolders : this.plugin.settings.excludeFolders; return scopeMode === "include" ? this.plugin.settings.includeFolders : this.plugin.settings.excludeFolders;
} }
private getFolderDeckModeOverrideHint(): string {
if (this.plugin.settings.folderDeckMode === "folder") {
return t("settings.scope.folderDeckModeOverride.hint.folderAndFile");
}
return t("settings.scope.folderDeckModeOverride.hint.folder");
}
private getFolderDeckModeOverrideTitle(): string {
if (this.plugin.settings.folderDeckMode === "folder") {
return t("settings.scope.folderDeckModeOverride.title.folderAndFile");
}
return t("settings.scope.folderDeckModeOverride.title.folder");
}
private getStandaloneParentDeckHint(): string {
return t("settings.scope.standaloneParentDeck.hint");
}
private getStandaloneParentDeckTitle(): string {
return t("settings.scope.standaloneParentDeck.title");
}
private createInlineControlGroup(containerEl: HTMLElement, label: string): HTMLElement { private createInlineControlGroup(containerEl: HTMLElement, label: string): HTMLElement {
const groupEl = containerEl.createEl("label"); const groupEl = containerEl.createEl("label");
addClasses(groupEl, "ahs-settings-inline-control-group"); addClasses(groupEl, "ahs-settings-inline-control-group");
@ -1875,6 +2011,16 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
} }
} }
function normalizeFolderScopePath(path: string): string {
return path.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
}
function isPathInsideFolder(filePath: string, folderPath: string): boolean {
const normalizedFilePath = normalizeFolderScopePath(filePath);
const normalizedFolderPath = normalizeFolderScopePath(folderPath);
return normalizedFilePath === normalizedFolderPath || normalizedFilePath.startsWith(`${normalizedFolderPath}/`);
}
function getScopeModeSummary(scopeMode: ScopeMode): string { function getScopeModeSummary(scopeMode: ScopeMode): string {
if (scopeMode === "include") { if (scopeMode === "include") {
return t("settings.scope.summary.include"); return t("settings.scope.summary.include");

View file

@ -118,8 +118,7 @@
display: grid; display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.75fr) minmax(220px, 0.95fr); grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.75fr) minmax(220px, 0.95fr);
align-items: center; align-items: center;
column-gap: 16px; gap: 8px 16px;
row-gap: 8px;
overflow: visible; overflow: visible;
width: 100%; width: 100%;
} }
@ -129,8 +128,7 @@
grid-column: 1 / -1; grid-column: 1 / -1;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) max-content; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) max-content;
align-items: center; align-items: center;
column-gap: 16px; gap: 8px 16px;
row-gap: 8px;
width: 100%; width: 100%;
} }
@ -202,7 +200,7 @@
display: grid; display: grid;
grid-template-columns: 24px 24px minmax(0, 1fr); grid-template-columns: 24px 24px minmax(0, 1fr);
align-items: center; align-items: center;
column-gap: 0; gap: 0;
width: 100%; width: 100%;
min-height: 28px; min-height: 28px;
box-sizing: border-box; box-sizing: border-box;
@ -238,6 +236,56 @@
min-width: 0; min-width: 0;
} }
.anki-heading-sync-settings .ahs-settings-folder-label-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
gap: 2px;
}
.anki-heading-sync-settings .ahs-settings-folder-label-inline {
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: 8px;
min-width: 0;
max-width: 100%;
}
.anki-heading-sync-settings .ahs-settings-folder-override-checkbox {
flex: none;
width: 16px;
height: 16px;
margin: 0;
appearance: none;
border: 1.5px solid var(--background-modifier-border);
border-radius: 999px;
background: var(--background-primary);
box-shadow: none;
cursor: pointer;
}
.anki-heading-sync-settings .ahs-settings-folder-override-checkbox:checked {
border-color: var(--interactive-accent);
background-image: radial-gradient(circle, var(--interactive-accent) 0 4px, transparent 4px);
background-repeat: no-repeat;
background-position: center;
}
.anki-heading-sync-settings .ahs-settings-folder-override-checkbox:focus-visible {
outline: 2px solid var(--interactive-accent);
outline-offset: 2px;
}
.anki-heading-sync-settings .ahs-settings-folder-override-hint {
flex: none;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
line-height: 1.35;
white-space: nowrap;
}
.anki-heading-sync-settings .ahs-settings-inline-control-group { .anki-heading-sync-settings .ahs-settings-inline-control-group {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -282,6 +330,12 @@
overflow: hidden; overflow: hidden;
} }
.anki-heading-sync-settings .ahs-settings-folder-label.ahs-settings-fluid-ellipsis {
flex: 0 1 auto;
width: auto;
max-width: 100%;
}
.anki-heading-sync-settings select.ahs-settings-fluid-control, .anki-heading-sync-settings select.ahs-settings-fluid-control,
.anki-heading-sync-settings input.ahs-settings-fluid-control { .anki-heading-sync-settings input.ahs-settings-fluid-control {
box-sizing: border-box; box-sizing: border-box;

View file

@ -1,4 +1,7 @@
{ {
"1.0.0": "1.8.7", "1.0.0": "1.8.7",
"1.0.1": "1.8.7" "1.0.1": "1.8.7",
"1.0.2": "1.8.7",
"1.0.3": "1.8.7",
"1.0.4": "1.8.7"
} }