mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71e36c25c4 | ||
|
|
0dc2d7cb5d | ||
|
|
befe28cd77 | ||
|
|
13b5685fe5 | ||
|
|
387f977dca | ||
|
|
0f8d5eefcb | ||
|
|
80d6020512 | ||
|
|
a898aa80e9 | ||
|
|
488bd3d91f |
25 changed files with 1159 additions and 96 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -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`
|
||||
|
||||
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.
|
||||
|
|
|
|||
67
docs/folder-deck-mode-overrides-decisions.md
Normal file
67
docs/folder-deck-mode-overrides-decisions.md
Normal 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 的副作用。
|
||||
- 使用原生 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”。
|
||||
65
docs/folder-deck-mode-overrides-gap-report.md
Normal file
65
docs/folder-deck-mode-overrides-gap-report.md
Normal 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` 时必须忽略覆盖列表,但不能清空已保存数据。
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "node:process";
|
||||
import builtins from "builtin-modules";
|
||||
import { builtinModules } from "node:module";
|
||||
import { copyFile } from "node:fs/promises";
|
||||
|
||||
const banner = `/*\nTHIS IS A GENERATED/BUNDLED FILE BY ESBUILD\n*/`;
|
||||
|
||||
const production = process.argv[2] === "production";
|
||||
const outfile = "dist/plugin/main.js";
|
||||
const builtins = [...new Set(builtinModules.flatMap((name) => [name, `node:${name}`]))];
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: { js: banner },
|
||||
|
|
|
|||
48
main.js
48
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "anki-heading-sync",
|
||||
"name": "Anki Heading Sync",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.4",
|
||||
"minAppVersion": "1.8.7",
|
||||
"description": "Focused heading-based Markdown to Anki sync plugin.",
|
||||
"author": "Dusk",
|
||||
|
|
|
|||
24
package-lock.json
generated
24
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-anki-heading-sync",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-anki-heading-sync",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"markdown-it": "^14.1.0"
|
||||
|
|
@ -17,10 +17,9 @@
|
|||
"@types/node": "^22.15.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.1",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-obsidianmd": "^0.2.8",
|
||||
"eslint-plugin-obsidianmd": "^0.2.9",
|
||||
"globals": "^16.4.0",
|
||||
"obsidian": "1.12.3",
|
||||
"typescript": "^5.8.3",
|
||||
|
|
@ -1052,17 +1051,6 @@
|
|||
"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": {
|
||||
"version": "6.7.14",
|
||||
"dev": true,
|
||||
|
|
@ -2487,9 +2475,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-obsidianmd": {
|
||||
"version": "0.2.8",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.2.8.tgz",
|
||||
"integrity": "sha512-BttDFIqh2f4sDurRgNZ8psjtgnYC7Y8toLaXgyqgmI9CXEE2dBEWXaG+p9vnmJLJQJWURTZL6LUXUNY4IGgBNw==",
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.2.9.tgz",
|
||||
"integrity": "sha512-+dF5Zz5T6/j0QYGu+wHbY3UZb45Kh+QFkFdfvkVk05o4YIIVqHMlrTFrlRVhuBd6Htu8QxcFOwzeMTN4aysVTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-anki-heading-sync",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.4",
|
||||
"description": "Focused heading-based Markdown to Anki sync plugin.",
|
||||
"author": "Dusk",
|
||||
"main": "dist/plugin/main.js",
|
||||
|
|
@ -26,10 +26,9 @@
|
|||
"@types/node": "^22.15.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.1",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-obsidianmd": "^0.2.8",
|
||||
"eslint-plugin-obsidianmd": "^0.2.9",
|
||||
"globals": "^16.4.0",
|
||||
"obsidian": "1.12.3",
|
||||
"typescript": "^5.8.3",
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ 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.standaloneParentDeckFolders).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 +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", () => {
|
||||
const settings = mergePluginSettings({
|
||||
ankiNoteTypeCache: [" Custom Basic ", "", "Cloze", "Custom Basic"],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ export interface PluginSettings {
|
|||
scopeMode: ScopeMode;
|
||||
includeFolders: string[];
|
||||
excludeFolders: string[];
|
||||
alternateFolderDeckModeFolders: string[];
|
||||
standaloneParentDeckFolders: string[];
|
||||
addObsidianBacklink: boolean;
|
||||
obsidianBacklinkLabel: string;
|
||||
obsidianBacklinkPlacement: ObsidianBacklinkPlacement;
|
||||
|
|
@ -89,6 +91,8 @@ export const DEFAULT_SETTINGS: PluginSettings = {
|
|||
scopeMode: "include",
|
||||
includeFolders: [],
|
||||
excludeFolders: [],
|
||||
alternateFolderDeckModeFolders: [],
|
||||
standaloneParentDeckFolders: [],
|
||||
addObsidianBacklink: true,
|
||||
obsidianBacklinkLabel: DEFAULT_OBSIDIAN_BACKLINK_LABEL,
|
||||
obsidianBacklinkPlacement: "answer-last-line",
|
||||
|
|
@ -144,6 +148,8 @@ export function mergePluginSettings(settings?: Partial<PluginSettings> | null):
|
|||
ankiModelFieldCache: partialSettings.ankiModelFieldCache ?? DEFAULT_SETTINGS.ankiModelFieldCache,
|
||||
includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
|
||||
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.excludeFolders, "exclude");
|
||||
validateFolderList(settings.alternateFolderDeckModeFolders, "alternateFolderDeckMode");
|
||||
validateFolderList(settings.standaloneParentDeckFolders, "standaloneParentDeck");
|
||||
|
||||
if (!settings.ankiConnectUrl.trim()) {
|
||||
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)) {
|
||||
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) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,6 +299,144 @@ describe("FileIndexerService", () => {
|
|||
expect(result.skippedUnchangedFiles).toBe(0);
|
||||
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] = {}) {
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ export function createFileStamp(mtime: number, size: number): string {
|
|||
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 {
|
||||
return hashString(JSON.stringify({
|
||||
|
|
@ -302,6 +302,8 @@ export function createDeckRulesFingerprint(settings: PluginSettings): string {
|
|||
fileDeckEnabled: settings.fileDeckEnabled,
|
||||
fileDeckMarker: settings.fileDeckMarker,
|
||||
folderDeckMode: settings.folderDeckMode,
|
||||
alternateFolderDeckModeFolders: [...settings.alternateFolderDeckModeFolders].sort(),
|
||||
standaloneParentDeckFolders: [...settings.standaloneParentDeckFolders].sort(),
|
||||
syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki,
|
||||
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export class QaGroupSyncService {
|
|||
deckHint: block.deckHint,
|
||||
deckHintSource: block.deckHintSource,
|
||||
deckWarnings: block.deckWarnings,
|
||||
} as never, settings.defaultDeck, settings.folderDeckMode);
|
||||
} as never, settings);
|
||||
for (const warning of deckResolution.warnings) {
|
||||
warningMap.set(getDeckResolutionWarningKey(warning), warning);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export class RenderConfigService {
|
|||
|
||||
resolve(card: IndexedCard, settings: PluginSettings): RenderPlan {
|
||||
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 mapping = settings.noteFieldMappings[createNoteFieldMappingKey(card.cardType, noteModel)] ?? null;
|
||||
const clozeMode = card.cardType === "cloze" && card.clozeMode === "all"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PluginSettings } from "@/application/config/PluginSettings";
|
||||
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
|
||||
|
||||
import { DeckResolutionService } from "./DeckResolutionService";
|
||||
|
|
@ -12,7 +13,7 @@ describe("DeckResolutionService", () => {
|
|||
filePath: "课程/数学/第一章/导数.md",
|
||||
deckHint: "显式/Deck",
|
||||
deckHintSource: "frontmatter",
|
||||
}), "Default", "folder");
|
||||
}), createDeckSettings({ folderDeckMode: "folder" }));
|
||||
|
||||
expect(result.resolvedDeck).toEqual({
|
||||
value: "显式::Deck",
|
||||
|
|
@ -24,7 +25,7 @@ describe("DeckResolutionService", () => {
|
|||
it("uses folder mapping when no explicit deck exists", () => {
|
||||
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({
|
||||
value: "课程::数学::第一章",
|
||||
|
|
@ -35,7 +36,7 @@ describe("DeckResolutionService", () => {
|
|||
it("supports folder-and-file mapping mode", () => {
|
||||
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({
|
||||
value: "课程::数学::第一章::导数",
|
||||
|
|
@ -46,7 +47,10 @@ describe("DeckResolutionService", () => {
|
|||
it("falls back to default deck for root-level files and emits a warning", () => {
|
||||
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({
|
||||
value: "Default::Deck",
|
||||
|
|
@ -58,7 +62,7 @@ describe("DeckResolutionService", () => {
|
|||
it("warns and falls back to default deck when folder mapping is invalid", () => {
|
||||
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.warnings.map((warning) => warning.code)).toEqual([
|
||||
|
|
@ -70,10 +74,50 @@ describe("DeckResolutionService", () => {
|
|||
filePath: "课程::非法/导数.md",
|
||||
deckHint: "显式::Deck",
|
||||
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 {
|
||||
return {
|
||||
noteId: overrides.noteId,
|
||||
|
|
|
|||
|
|
@ -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 { DeckResolutionResult } from "@/domain/manual-sync/value-objects/DeckResolution";
|
||||
|
||||
|
|
@ -11,7 +11,10 @@ export class DeckResolutionService {
|
|||
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) {
|
||||
return {
|
||||
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) {
|
||||
return {
|
||||
resolvedDeck: {
|
||||
|
|
@ -35,7 +43,7 @@ export class DeckResolutionService {
|
|||
|
||||
return {
|
||||
resolvedDeck: {
|
||||
value: this.deckNormalizationService.normalize(defaultDeck),
|
||||
value: this.deckNormalizationService.normalize(settings.defaultDeck),
|
||||
source: "default",
|
||||
},
|
||||
warnings: [
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
const service = new FolderDeckMappingService();
|
||||
|
||||
|
|
|
|||
|
|
@ -11,24 +11,32 @@ export interface FolderDeckMappingResult {
|
|||
export class FolderDeckMappingService {
|
||||
constructor(private readonly deckNormalizationService = new DeckNormalizationService()) {}
|
||||
|
||||
mapFilePathToDeck(filePath: string, mode: FolderDeckMode): FolderDeckMappingResult {
|
||||
if (mode === "off") {
|
||||
mapFilePathToDeck(
|
||||
filePath: string,
|
||||
mode: FolderDeckMode,
|
||||
alternateFolderDeckModeFolders: string[] = [],
|
||||
standaloneParentDeckFolders: string[] = [],
|
||||
): FolderDeckMappingResult {
|
||||
const effectiveMode = resolveEffectiveFolderDeckMode(filePath, mode, alternateFolderDeckModeFolders);
|
||||
|
||||
if (effectiveMode === "off") {
|
||||
return { warnings: [] };
|
||||
}
|
||||
|
||||
const lastSlash = filePath.lastIndexOf("/");
|
||||
const normalizedFilePath = normalizeFilePath(filePath);
|
||||
const lastSlash = normalizedFilePath.lastIndexOf("/");
|
||||
if (lastSlash < 0) {
|
||||
return { warnings: [] };
|
||||
}
|
||||
|
||||
const folderPath = filePath.slice(0, lastSlash);
|
||||
const folderPath = normalizedFilePath.slice(0, lastSlash);
|
||||
if (!folderPath.trim()) {
|
||||
return { warnings: [] };
|
||||
}
|
||||
|
||||
const segments = folderPath.split("/").filter(Boolean);
|
||||
if (mode === "folder-and-file") {
|
||||
const fileName = filePath.slice(lastSlash + 1).replace(/\.[^.]+$/, "").trim();
|
||||
const segments = resolveDeckSegments(folderPath, standaloneParentDeckFolders);
|
||||
if (effectiveMode === "folder-and-file") {
|
||||
const fileName = normalizedFilePath.slice(lastSlash + 1).replace(/\.[^.]+$/, "").trim();
|
||||
if (fileName) {
|
||||
segments.push(fileName);
|
||||
}
|
||||
|
|
@ -48,4 +56,63 @@ export class FolderDeckMappingService {
|
|||
warnings: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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}/`);
|
||||
}
|
||||
|
|
@ -63,6 +63,34 @@ describe("DataJsonPluginConfigRepository", () => {
|
|||
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 () => {
|
||||
const store = new InMemoryPluginDataStore({
|
||||
settings: {
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ export const en = {
|
|||
},
|
||||
qaHeadingLevel: {
|
||||
name: "Heading level for question-and-answer cards",
|
||||
desc: "Default: H4",
|
||||
desc: "Default question-and-answer heading level is 4.",
|
||||
},
|
||||
clozeHeadingLevel: {
|
||||
name: "Cloze heading level",
|
||||
desc: "Default: H5",
|
||||
desc: "Default cloze heading level is 5.",
|
||||
},
|
||||
cardAnswerCutoffMode: {
|
||||
name: "Card answer cutoff mode",
|
||||
|
|
@ -196,6 +196,22 @@ export const en = {
|
|||
failedLoad: "Failed to load folders from the current vault.",
|
||||
expandFolder: "Expand {{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: {
|
||||
cardTypes: {
|
||||
|
|
@ -377,6 +393,10 @@ export const en = {
|
|||
includeFoldersStrings: "Include folders must only contain strings.",
|
||||
excludeFoldersArray: "Exclude folders must be an array.",
|
||||
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.",
|
||||
ankiNoteTypeCacheArray: "Anki note type cache must be an array.",
|
||||
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.",
|
||||
noteFieldMappingsQaGroupAcceptedWarningsStrings: "Question-and-answer group field mapping acceptedWarnings can only contain strings.",
|
||||
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.",
|
||||
noteFieldMappingsQaGroupDerivationFirstAnswerField: "Question-and-answer group field mapping derivation.firstAnswerField must be a string.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -194,6 +194,22 @@ export const zh = {
|
|||
failedLoad: "读取当前 vault 文件夹失败。",
|
||||
expandFolder: "展开 {{name}}",
|
||||
collapseFolder: "收起 {{name}}",
|
||||
folderDeckModeOverride: {
|
||||
ariaLabel: "切换 {{name}} 的文件夹牌组映射模式",
|
||||
hint: {
|
||||
folder: "本文件夹单独采用「文件夹」作为牌组名",
|
||||
folderAndFile: "本文件夹单独采用「文件夹及文件名」作为牌组名",
|
||||
},
|
||||
title: {
|
||||
folder: "当前全局为「文件夹及文件名」,勾选后此文件夹改用「文件夹」作为牌组名",
|
||||
folderAndFile: "当前全局为「文件夹」,勾选后此文件夹改用「文件夹及文件名」作为牌组名",
|
||||
},
|
||||
},
|
||||
standaloneParentDeck: {
|
||||
ariaLabel: "将 {{name}} 指定为单独的父牌组",
|
||||
hint: "本文件夹指定为「单独的父牌组」",
|
||||
title: "勾选后,此文件夹在 Anki 中会作为单独的父牌组起点",
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
cardTypes: {
|
||||
|
|
@ -375,6 +391,10 @@ export const zh = {
|
|||
includeFoldersStrings: "包含文件夹列表中只能包含字符串。",
|
||||
excludeFoldersArray: "排除文件夹必须是数组。",
|
||||
excludeFoldersStrings: "排除文件夹列表中只能包含字符串。",
|
||||
alternateFolderDeckModeFoldersArray: "文件夹牌组模式覆盖列表必须是数组。",
|
||||
alternateFolderDeckModeFoldersStrings: "文件夹牌组模式覆盖列表中只能包含字符串。",
|
||||
standaloneParentDeckFoldersArray: "单独父牌组文件夹列表必须是数组。",
|
||||
standaloneParentDeckFoldersStrings: "单独父牌组文件夹列表中只能包含字符串。",
|
||||
ankiConnectUrlRequired: "AnkiConnect URL 不能为空。",
|
||||
ankiNoteTypeCacheArray: "Anki 笔记模板缓存必须是数组。",
|
||||
ankiNoteTypeCacheStrings: "Anki 笔记模板缓存中只能包含字符串。",
|
||||
|
|
|
|||
|
|
@ -700,8 +700,8 @@ function isCardExpanded(tab: AnkiHeadingSyncSettingTab, cardId: string): boolean
|
|||
}
|
||||
|
||||
type TestActiveWindow = {
|
||||
setTimeout: (callback: () => void, delay?: number) => ReturnType<typeof setTimeout>;
|
||||
clearTimeout: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
setTimeout: (callback: () => void, delay?: number) => number;
|
||||
clearTimeout: (timer: number) => void;
|
||||
};
|
||||
|
||||
type TestWindow = {
|
||||
|
|
@ -709,12 +709,75 @@ type TestWindow = {
|
|||
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 {
|
||||
const timerController = createTestTimerController();
|
||||
testTimerController = timerController;
|
||||
|
||||
const activeWindow: TestActiveWindow = {
|
||||
// eslint-disable-next-line obsidianmd/prefer-active-window-timers
|
||||
setTimeout: (callback, delay) => setTimeout(callback, delay),
|
||||
// eslint-disable-next-line obsidianmd/prefer-active-window-timers
|
||||
clearTimeout: (timer) => clearTimeout(timer),
|
||||
setTimeout: (callback, delay) => timerController.setTimeout(callback, delay),
|
||||
clearTimeout: (timer) => timerController.clearTimeout(timer),
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -736,6 +799,7 @@ describe("PluginSettingTab", () => {
|
|||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
testTimerController = undefined;
|
||||
});
|
||||
|
||||
it("renders five cards collapsed by default", () => {
|
||||
|
|
@ -1043,7 +1107,6 @@ describe("PluginSettingTab", () => {
|
|||
});
|
||||
|
||||
it("debounces text input saves instead of saving every keystroke", async () => {
|
||||
vi.useFakeTimers();
|
||||
const plugin = new FakePlugin();
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
|
||||
|
|
@ -1058,18 +1121,17 @@ describe("PluginSettingTab", () => {
|
|||
|
||||
expect(plugin.updateCalls).toHaveLength(0);
|
||||
|
||||
vi.advanceTimersByTime(499);
|
||||
advanceTestTimersByTime(499);
|
||||
await flushPromises();
|
||||
expect(plugin.updateCalls).toHaveLength(0);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
advanceTestTimersByTime(1);
|
||||
await flushPromises();
|
||||
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-ab");
|
||||
expect(plugin.updateCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("flushes pending text saves when the settings tab hides", async () => {
|
||||
vi.useFakeTimers();
|
||||
const plugin = new FakePlugin();
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
|
||||
|
|
@ -1088,7 +1150,7 @@ describe("PluginSettingTab", () => {
|
|||
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-hidden");
|
||||
expect(plugin.updateCalls).toHaveLength(1);
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
advanceTestTimersByTime(500);
|
||||
await flushPromises();
|
||||
|
||||
expect(plugin.updateCalls).toHaveLength(1);
|
||||
|
|
@ -1105,13 +1167,11 @@ describe("PluginSettingTab", () => {
|
|||
clozeHeading.value = "4";
|
||||
await clozeHeading.trigger("change");
|
||||
|
||||
vi.useFakeTimers();
|
||||
const clozeMarker = queryByDataset(tab.containerEl, "cardTypeMarker", "cloze");
|
||||
clozeMarker.value = "";
|
||||
await clozeMarker.trigger("input");
|
||||
vi.advanceTimersByTime(500);
|
||||
advanceTestTimersByTime(500);
|
||||
await flushPromises();
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(plugin.settings.cardTypeConfigs.cloze.headingLevel).toBe(4);
|
||||
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#anki-cloze");
|
||||
|
|
@ -1658,6 +1718,202 @@ describe("PluginSettingTab", () => {
|
|||
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 () => {
|
||||
const plugin = new FakePlugin();
|
||||
plugin.settings = normalizePluginSettings({
|
||||
|
|
|
|||
|
|
@ -1627,7 +1627,49 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const currentSelection = scopeMode === "include" ? this.plugin.settings.includeFolders : this.plugin.settings.excludeFolders;
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
@ -1670,11 +1712,69 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
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;
|
||||
addClasses(labelEl, "ahs-settings-folder-label");
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1697,17 +1797,53 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
this.scopeNeedsSelectedAncestorExpansion = false;
|
||||
for (const folderPath of this.getSelectedFoldersForScopeMode(scopeMode)) {
|
||||
for (const folderPath of this.getFoldersForAncestorExpansion(scopeMode)) {
|
||||
for (const ancestorPath of getAncestorFolderPaths(folderPath)) {
|
||||
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[] {
|
||||
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 {
|
||||
const groupEl = containerEl.createEl("label");
|
||||
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 {
|
||||
if (scopeMode === "include") {
|
||||
return t("settings.scope.summary.include");
|
||||
|
|
|
|||
64
styles.css
64
styles.css
|
|
@ -118,8 +118,7 @@
|
|||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.75fr) minmax(220px, 0.95fr);
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
row-gap: 8px;
|
||||
gap: 8px 16px;
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -129,8 +128,7 @@
|
|||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
row-gap: 8px;
|
||||
gap: 8px 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +200,7 @@
|
|||
display: grid;
|
||||
grid-template-columns: 24px 24px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: 0;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
box-sizing: border-box;
|
||||
|
|
@ -238,6 +236,56 @@
|
|||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -282,6 +330,12 @@
|
|||
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 input.ahs-settings-fluid-control {
|
||||
box-sizing: border-box;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue