fix(settings): preserve explicit folder selections

中文: 修复设置页文件夹树半选逻辑,子文件夹选择不再自动提升为父文件夹;补充 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.
This commit is contained in:
Dusk 2026-04-24 10:10:34 +08:00
parent 17e22fbaa5
commit c367a772c7
5 changed files with 131 additions and 21 deletions

9
AGENTS.md Normal file
View file

@ -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`.

View file

@ -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"]);
});
});
});

View file

@ -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<string>,
): { 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}/`);
}
}

View file

@ -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 = {

View file

@ -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", () => {