Release 1.0.3

This commit is contained in:
Dusk 2026-05-11 21:13:50 +08:00
parent befe28cd77
commit 0dc2d7cb5d
19 changed files with 438 additions and 50 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`
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.

42
main.js

File diff suppressed because one or more lines are too long

View file

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

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-anki-heading-sync",
"version": "1.0.2",
"version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-anki-heading-sync",
"version": "1.0.2",
"version": "1.0.3",
"license": "MIT",
"dependencies": {
"markdown-it": "^14.1.0"

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-anki-heading-sync",
"version": "1.0.2",
"version": "1.0.3",
"description": "Focused heading-based Markdown to Anki sync plugin.",
"author": "Dusk",
"main": "dist/plugin/main.js",

View file

@ -77,6 +77,7 @@ describe("PluginSettings", () => {
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);
@ -120,6 +121,14 @@ describe("PluginSettings", () => {
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"],
@ -128,6 +137,14 @@ describe("PluginSettings", () => {
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({
@ -146,6 +163,24 @@ describe("PluginSettings", () => {
}, "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"],

View file

@ -61,6 +61,7 @@ export interface PluginSettings {
includeFolders: string[];
excludeFolders: string[];
alternateFolderDeckModeFolders: string[];
standaloneParentDeckFolders: string[];
addObsidianBacklink: boolean;
obsidianBacklinkLabel: string;
obsidianBacklinkPlacement: ObsidianBacklinkPlacement;
@ -91,6 +92,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
includeFolders: [],
excludeFolders: [],
alternateFolderDeckModeFolders: [],
standaloneParentDeckFolders: [],
addObsidianBacklink: true,
obsidianBacklinkLabel: DEFAULT_OBSIDIAN_BACKLINK_LABEL,
obsidianBacklinkPlacement: "answer-last-line",
@ -147,6 +149,7 @@ export function mergePluginSettings(settings?: Partial<PluginSettings> | null):
includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
excludeFolders: partialSettings.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders,
alternateFolderDeckModeFolders: partialSettings.alternateFolderDeckModeFolders ?? DEFAULT_SETTINGS.alternateFolderDeckModeFolders,
standaloneParentDeckFolders: partialSettings.standaloneParentDeckFolders ?? DEFAULT_SETTINGS.standaloneParentDeckFolders,
});
}
@ -208,6 +211,7 @@ 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");
@ -260,30 +264,32 @@ function validateAnkiModelFieldCache(ankiModelFieldCache: AnkiModelFieldCache):
}
}
function validateFolderList(folderList: string[], label: "include" | "exclude" | "alternateFolderDeckMode"): void {
function validateFolderList(folderList: string[], label: "include" | "exclude" | "alternateFolderDeckMode" | "standaloneParentDeck"): void {
if (!Array.isArray(folderList)) {
if (label === "include") {
throw new PluginUserError("errors.settings.includeFoldersArray");
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");
}
if (label === "exclude") {
throw new PluginUserError("errors.settings.excludeFoldersArray");
}
throw new PluginUserError("errors.settings.alternateFolderDeckModeFoldersArray");
}
for (const folder of folderList) {
if (typeof folder !== "string") {
if (label === "include") {
throw new PluginUserError("errors.settings.includeFoldersStrings");
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");
}
if (label === "exclude") {
throw new PluginUserError("errors.settings.excludeFoldersStrings");
}
throw new PluginUserError("errors.settings.alternateFolderDeckModeFoldersStrings");
}
}
}

View file

@ -368,6 +368,75 @@ describe("FileIndexerService", () => {
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] = {}) {

View file

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

View file

@ -90,15 +90,30 @@ describe("DeckResolutionService", () => {
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">> = {},
): Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders"> {
overrides: Partial<Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders" | "standaloneParentDeckFolders">> = {},
): Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders" | "standaloneParentDeckFolders"> {
return {
defaultDeck: "Default",
folderDeckMode: "folder-and-file",
alternateFolderDeckModeFolders: [],
standaloneParentDeckFolders: [],
...overrides,
};
}

View file

@ -11,7 +11,10 @@ export class DeckResolutionService {
private readonly deckNormalizationService = new DeckNormalizationService(),
) {}
resolve(card: IndexedCard, settings: Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders">): DeckResolutionResult {
resolve(
card: IndexedCard,
settings: Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders" | "standaloneParentDeckFolders">,
): DeckResolutionResult {
if (card.deckHint) {
return {
resolvedDeck: {
@ -26,6 +29,7 @@ export class DeckResolutionService {
card.filePath,
settings.folderDeckMode,
settings.alternateFolderDeckModeFolders,
settings.standaloneParentDeckFolders,
);
if (folderMapping.deck) {
return {

View file

@ -40,6 +40,32 @@ describe("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();

View file

@ -11,7 +11,12 @@ export interface FolderDeckMappingResult {
export class FolderDeckMappingService {
constructor(private readonly deckNormalizationService = new DeckNormalizationService()) {}
mapFilePathToDeck(filePath: string, mode: FolderDeckMode, alternateFolderDeckModeFolders: string[] = []): FolderDeckMappingResult {
mapFilePathToDeck(
filePath: string,
mode: FolderDeckMode,
alternateFolderDeckModeFolders: string[] = [],
standaloneParentDeckFolders: string[] = [],
): FolderDeckMappingResult {
const effectiveMode = resolveEffectiveFolderDeckMode(filePath, mode, alternateFolderDeckModeFolders);
if (effectiveMode === "off") {
@ -29,7 +34,7 @@ export class FolderDeckMappingService {
return { warnings: [] };
}
const segments = folderPath.split("/").filter(Boolean);
const segments = resolveDeckSegments(folderPath, standaloneParentDeckFolders);
if (effectiveMode === "folder-and-file") {
const fileName = normalizedFilePath.slice(lastSlash + 1).replace(/\.[^.]+$/, "").trim();
if (fileName) {
@ -75,6 +80,35 @@ 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(/^\/+/, "");
}

View file

@ -77,6 +77,20 @@ describe("DataJsonPluginConfigRepository", () => {
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: {

View file

@ -207,6 +207,11 @@ export const en = {
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: {
@ -390,6 +395,8 @@ export const en = {
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.",

View file

@ -205,6 +205,11 @@ export const zh = {
folderAndFile: "当前全局为「文件夹」,勾选后此文件夹改用「文件夹及文件名」作为牌组名",
},
},
standaloneParentDeck: {
ariaLabel: "将 {{name}} 指定为单独的父牌组",
hint: "本文件夹指定为「单独的父牌组」",
title: "勾选后,此文件夹在 Anki 中会作为单独的父牌组起点",
},
},
cards: {
cardTypes: {
@ -388,6 +393,8 @@ export const zh = {
excludeFoldersStrings: "排除文件夹列表中只能包含字符串。",
alternateFolderDeckModeFoldersArray: "文件夹牌组模式覆盖列表必须是数组。",
alternateFolderDeckModeFoldersStrings: "文件夹牌组模式覆盖列表中只能包含字符串。",
standaloneParentDeckFoldersArray: "单独父牌组文件夹列表必须是数组。",
standaloneParentDeckFoldersStrings: "单独父牌组文件夹列表中只能包含字符串。",
ankiConnectUrlRequired: "AnkiConnect URL 不能为空。",
ankiNoteTypeCacheArray: "Anki 笔记模板缓存必须是数组。",
ankiNoteTypeCacheStrings: "Anki 笔记模板缓存中只能包含字符串。",

View file

@ -1733,17 +1733,24 @@ describe("PluginSettingTab", () => {
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 () => {
@ -1771,6 +1778,7 @@ describe("PluginSettingTab", () => {
});
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);
@ -1778,9 +1786,69 @@ describe("PluginSettingTab", () => {
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({
@ -1789,6 +1857,7 @@ describe("PluginSettingTab", () => {
includeFolders: ["notes"],
folderDeckMode: "folder",
alternateFolderDeckModeFolders: ["notes", "notes/sub"],
standaloneParentDeckFolders: ["notes", "notes/sub"],
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
@ -1802,6 +1871,25 @@ describe("PluginSettingTab", () => {
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 () => {
@ -1812,6 +1900,7 @@ describe("PluginSettingTab", () => {
includeFolders: ["notes/sub"],
folderDeckMode: "off",
alternateFolderDeckModeFolders: ["notes/sub"],
standaloneParentDeckFolders: ["notes/sub"],
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
@ -1821,6 +1910,8 @@ describe("PluginSettingTab", () => {
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 () => {

View file

@ -1631,10 +1631,14 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
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 });
@ -1656,6 +1660,19 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
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");
}
private renderFolderNode(containerEl: HTMLElement, node: FolderTreeSelectionNode, scopeMode: ScopeMode, depth: number): void {
const row = containerEl.createDiv();
row.dataset.folderRow = node.path;
@ -1730,6 +1747,32 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
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) {
@ -1754,13 +1797,25 @@ 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;
}
@ -1781,6 +1836,14 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
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");

View file

@ -1,5 +1,6 @@
{
"1.0.0": "1.8.7",
"1.0.1": "1.8.7",
"1.0.2": "1.8.7"
"1.0.2": "1.8.7",
"1.0.3": "1.8.7"
}