From c367a772c79648987db73286638122f80650342c Mon Sep 17 00:00:00 2001 From: Dusk Date: Fri, 24 Apr 2026 10:10:34 +0800 Subject: [PATCH] fix(settings): preserve explicit folder selections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文: 修复设置页文件夹树半选逻辑,子文件夹选择不再自动提升为父文件夹;补充 Anki Heading Sync 插件同步路径说明。 English: Keep child folder selections explicit in the settings tree, render parent folders as indeterminate, and document the Anki Heading Sync plugin sync target. --- AGENTS.md | 9 +++ .../settings/FolderScopeTree.test.ts | 69 +++++++++++++++++-- src/presentation/settings/FolderScopeTree.ts | 20 ++---- .../settings/PluginSettingTab.test.ts | 53 ++++++++++++++ src/presentation/settings/PluginSettingTab.ts | 1 + 5 files changed, 131 insertions(+), 21 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..553743c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Repository Notes + +## Obsidian Plugin Sync Target + +When syncing this plugin into the user's Obsidian vault, use this exact plugin directory: + +`/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`. diff --git a/src/presentation/settings/FolderScopeTree.test.ts b/src/presentation/settings/FolderScopeTree.test.ts index 36cda74..86b8894 100644 --- a/src/presentation/settings/FolderScopeTree.test.ts +++ b/src/presentation/settings/FolderScopeTree.test.ts @@ -28,6 +28,20 @@ const TREE: FolderTreeNode[] = [ }, ]; +const SINGLE_CHILD_TREE: FolderTreeNode[] = [ + { + path: "9Anki背诵", + name: "9Anki背诵", + children: [ + { + path: "9Anki背诵/随感", + name: "随感", + children: [], + }, + ], + }, +]; + describe("FolderScopeTree", () => { it("selecting a parent folder saves the minimal parent-only selection", () => { expect(toggleFolderTreeSelection(TREE, [], "notes", true)).toEqual(["notes"]); @@ -52,11 +66,56 @@ describe("FolderScopeTree", () => { }); }); + it("keeps a single selected child explicit and marks its parent indeterminate", () => { + expect(toggleFolderTreeSelection(SINGLE_CHILD_TREE, [], "9Anki背诵/随感", true)).toEqual(["9Anki背诵/随感"]); + + const selection = buildFolderTreeSelection(SINGLE_CHILD_TREE, ["9Anki背诵/随感"]); + + expect(selection[0]).toMatchObject({ + path: "9Anki背诵", + checked: false, + indeterminate: true, + }); + expect(selection[0]?.children[0]).toMatchObject({ + path: "9Anki背诵/随感", + checked: true, + indeterminate: false, + }); + }); + + it("keeps fully selected sibling folders explicit instead of promoting them to the parent", () => { + expect(compressFolderSelections(TREE, ["notes/daily", "notes/projects"])).toEqual(["notes/daily", "notes/projects"]); + + const selection = buildFolderTreeSelection(TREE, ["notes/daily", "notes/projects"]); + + expect(selection[0]).toMatchObject({ + path: "notes", + checked: false, + indeterminate: true, + }); + }); + + it("shows descendants as selected when their parent is explicitly selected", () => { + const selection = buildFolderTreeSelection(TREE, ["notes"]); + + expect(selection[0]).toMatchObject({ + path: "notes", + checked: true, + indeterminate: false, + }); + expect(selection[0]?.children[0]).toMatchObject({ + path: "notes/daily", + checked: true, + indeterminate: false, + }); + expect(selection[0]?.children[1]).toMatchObject({ + path: "notes/projects", + checked: true, + indeterminate: false, + }); + }); + it("expands a selected parent when one child subtree is unchecked", () => { expect(toggleFolderTreeSelection(TREE, ["notes"], "notes/daily", false)).toEqual(["notes/projects"]); }); - - it("compresses fully selected sibling folders back to their parent", () => { - expect(compressFolderSelections(TREE, ["notes/daily", "notes/projects"])).toEqual(["notes"]); - }); -}); \ No newline at end of file +}); diff --git a/src/presentation/settings/FolderScopeTree.ts b/src/presentation/settings/FolderScopeTree.ts index 1bb7044..53e62c5 100644 --- a/src/presentation/settings/FolderScopeTree.ts +++ b/src/presentation/settings/FolderScopeTree.ts @@ -74,13 +74,12 @@ function buildSelectionNode( }; } - const allChildrenChecked = children.length > 0 && children.every((child) => child.checked && !child.indeterminate); const someChildrenChecked = children.some((child) => child.checked || child.indeterminate); return { ...node, - checked: allChildrenChecked, - indeterminate: someChildrenChecked && !allChildrenChecked, + checked: false, + indeterminate: someChildrenChecked, children, }; } @@ -88,33 +87,22 @@ function buildSelectionNode( function compressSelectionNode( node: FolderTreeNode, explicitSelections: Set, -): { fullySelected: boolean; paths: string[] } { +): { paths: string[] } { if (explicitSelections.has(node.path)) { return { - fullySelected: true, paths: [node.path], }; } if (node.children.length === 0) { return { - fullySelected: false, paths: [], }; } const childResults = node.children.map((child) => compressSelectionNode(child, explicitSelections)); - const allChildrenSelected = childResults.length > 0 && childResults.every((child) => child.fullySelected); - - if (allChildrenSelected) { - return { - fullySelected: true, - paths: [node.path], - }; - } return { - fullySelected: false, paths: childResults.flatMap((child) => child.paths), }; } @@ -204,4 +192,4 @@ function normalizePath(path: string): string { function isPathInsideFolder(filePath: string, folderPath: string): boolean { return filePath === folderPath || filePath.startsWith(`${folderPath}/`); -} \ No newline at end of file +} diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index 2829423..bb57c40 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -817,6 +817,7 @@ describe("AnkiHeadingSyncSettingTab", () => { expect(parentCheckbox.checked).toBe(false); expect(parentCheckbox.indeterminate).toBe(true); + expect((parentCheckbox as unknown as { [key: string]: string })["aria-checked"]).toBe("mixed"); expect(queryCheckboxByPath(container, "notes/sub")).toBeUndefined(); await getFolderToggle(container, "notes").trigger("click"); @@ -833,6 +834,58 @@ describe("AnkiHeadingSyncSettingTab", () => { expect(container.textNodes).toContain("empty"); }); + it("keeps a single selected child folder from being promoted to its parent", async () => { + const plugin = new FakePlugin(); + plugin.settings = { + ...plugin.settings, + scopeMode: "include", + }; + plugin.folderTree = [ + { + path: "9Anki背诵", + name: "9Anki背诵", + children: [ + { + path: "9Anki背诵/随感", + name: "随感", + children: [], + }, + ], + }, + ]; + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + const container = tab.containerEl as unknown as FakeContainerElInstance; + + tab.display(); + await flushAsync(); + tab.display(); + await getFolderToggle(container, "9Anki背诵").trigger("click"); + + const childCheckbox = getCheckboxByPath(container, "9Anki背诵/随感"); + childCheckbox.checked = true; + await childCheckbox.trigger("change"); + await flushAsync(); + + expect(plugin.settings.includeFolders).toEqual(["9Anki背诵/随感"]); + expect(getCheckboxByPath(container, "9Anki背诵/随感").checked).toBe(true); + expect(getCheckboxByPath(container, "9Anki背诵").checked).toBe(false); + expect(getCheckboxByPath(container, "9Anki背诵").indeterminate).toBe(true); + expect((getCheckboxByPath(container, "9Anki背诵") as unknown as { [key: string]: string })["aria-checked"]).toBe("mixed"); + + tab.hide(); + tab.display(); + await flushAsync(); + tab.display(); + + expect(plugin.settings.includeFolders).toEqual(["9Anki背诵/随感"]); + expect(getCheckboxByPath(container, "9Anki背诵").checked).toBe(false); + expect(getCheckboxByPath(container, "9Anki背诵").indeterminate).toBe(true); + + await getFolderToggle(container, "9Anki背诵").trigger("click"); + + expect(getCheckboxByPath(container, "9Anki背诵/随感").checked).toBe(true); + }); + it("reloads folder tree after the settings tab is reopened and shows newly created folders", async () => { const plugin = new FakePlugin(); plugin.settings = { diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index 817bcf7..46f1dbe 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -751,6 +751,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { checkbox.type = "checkbox"; checkbox.checked = node.checked; checkbox.indeterminate = node.indeterminate; + checkbox.setAttr("aria-checked", node.indeterminate ? "mixed" : String(node.checked)); checkbox.dataset.folderPath = node.path; checkbox.style.margin = "0"; checkbox.addEventListener("change", () => {