feat(deck): add folder-level deck mode overrides

在 include 模式下允许选中文件夹改用与全局 folderDeckMode 相反的映射规则

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

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

Clears override records for deselected folders and descendants, and wires the rules into the settings UI and deck fingerprint
This commit is contained in:
Dusk 2026-04-30 19:18:15 +08:00
parent 3e5ce2c1d2
commit 488bd3d91f
19 changed files with 642 additions and 49 deletions

View file

@ -0,0 +1,67 @@
# 文件夹级牌组模式覆盖决策
## 设置结构
- 新增 `PluginSettings.alternateFolderDeckModeFolders: string[]`
- 默认值为 `[]`
- 缺失字段按旧数据迁移到 `[]`
- 校验规则与 `includeFolders` / `excludeFolders` 保持一致:必须是数组,且成员必须是字符串。
- 不额外引入全局路径规范化或去重 helper保持与现有配置层行为一致。
## UI 放置位置
- 在 `PluginSettingTab.renderFolderNode()` 内,仅对 include 模式且当前行已选中的文件夹渲染覆盖控件。
- 新控件追加在文件夹标签后,不替换现有运行范围 checkbox。
- 保留现有 dataset`folderRow`、`folderToggle`、`folderPath`、`folderPathLabel`。
- 新增 dataset 供测试使用:
- `folderDeckModeOverride`
- `folderDeckModeOverrideHint`
## 圆形 checkbox 行为
- 控件只读写 `alternateFolderDeckModeFolders`
- 不改动 include 勾选状态。
- 点击时阻止冒泡,避免触发展开按钮或运行范围 checkbox 的副作用。
- 使用原生 checkboxCSS 改成圆形外观并保留 `aria-label` / `title`
## 提示文案
- 全局 `folder-and-file` 时,启用提示显示:`本文件夹单独采用「文件夹」作为牌组名`。
- 全局 `folder` 时,启用提示显示:`本文件夹单独采用「文件夹及文件名」作为牌组名`。
- 全局 `off` 时不显示控件和提示,但保留已保存的覆盖列表。
## 路径匹配规则
- 覆盖匹配使用规范:`filePath === folderPath || filePath.startsWith(folderPath + "/")`。
- 父文件夹覆盖对子孙路径全部生效。
- 不支持“子文件夹反向恢复全局模式”的三态语义,本次仍是二元开关。
## 覆盖模式解析
- 全局 `folder-and-file` 时,命中覆盖文件夹改用 `folder`
- 全局 `folder` 时,命中覆盖文件夹改用 `folder-and-file`
- 全局 `off` 时,忽略覆盖列表。
## include 取消勾选清理
- 当 include 模式取消勾选某个文件夹时,清理该路径及所有后代路径在 `alternateFolderDeckModeFolders` 中的记录。
- 该清理仅影响覆盖列表,不影响其他 include 选择。
- exclude/all 模式下不显示控件,也不触发这套清理逻辑。
## 牌组解析接入点
- 在 `FolderDeckMappingService` 中增加“基于覆盖列表求实际 mode”的能力。
- `DeckResolutionService.resolve()` 接收完整 `PluginSettings`,从而统一处理默认 deck、全局 mode 与覆盖列表。
- 普通卡片通过 `RenderConfigService` 传入完整 settings。
- QA Group 继续复用同一个 `DeckResolutionService`,保持优先级一致。
## 指纹变更
- `createDeckRulesFingerprint()` 纳入 `alternateFolderDeckModeFolders`
- 指纹版本升级到下一版。
- 为减少纯顺序变动引起的无意义重算,指纹内部对覆盖文件夹列表做排序后再哈希;这只影响 fingerprint不改变实际设置保存顺序。
## 与原计划的偏差
- 偏差 1配置层不新增全局路径规范化/去重。原因:现有 `includeFolders` / `excludeFolders` 没有这样做,新字段若单独增强会造成配置行为不一致。
- 偏差 2DeckResolution 直接改为接收完整 `settings`。原因:当前普通卡片和 QA Group 都通过该服务决策 deck把覆盖规则聚合在这里最小且一致避免在多个调用点重复计算“反向 mode”。

View file

@ -0,0 +1,65 @@
# 文件夹级牌组模式覆盖 gap report
## 当前运行范围文件树结构
- 运行范围卡片由 `src/presentation/settings/PluginSettingTab.ts``renderScopeCard()` 渲染。
- scope 模式切换保存在 `PluginSettings.scopeMode`,文件夹选择保存在 `includeFolders` / `excludeFolders`
- 文件树数据来自插件接口 `listFolderTree()`,在设置页内通过 `ensureFolderTreeLoaded()` 懒加载。
- 文件树选择状态由 `src/presentation/settings/FolderScopeTree.ts``buildFolderTreeSelection()``toggleFolderTreeSelection()` 计算。
- 单行 UI 由 `renderFolderNode()` 负责,现有 dataset 包括:`folderRow`、`folderToggle`、`folderPath`、`folderPathLabel`、`folderChildren`。
- 现有文件夹行是三列 grid展开按钮、运行范围 checkbox、文件夹标签。
## 当前文件夹牌组映射流程
- 全局设置字段是 `PluginSettings.folderDeckMode`,取值 `off | folder | folder-and-file`
- 普通卡片路径在 `src/application/services/RenderConfigService.ts` 中通过 `DeckResolutionService.resolve(card, defaultDeck, folderDeckMode)` 决定最终 deck。
- QA Group 路径在 `src/application/services/QaGroupSyncService.ts` 中也直接调用同一个 `DeckResolutionService.resolve(...)`
- `src/domain/manual-sync/services/DeckResolutionService.ts` 当前优先顺序是:
1. `card.deckHint`
2. `FolderDeckMappingService.mapFilePathToDeck(filePath, folderDeckMode)`
3. `defaultDeck`
- `src/domain/manual-sync/services/FolderDeckMappingService.ts` 只按 `filePath``mode` 计算 deck不知道运行范围或文件夹覆盖。
## 当前牌组优先级实现
- 文件级显式 deck hint 仍然是最高优先级,这一层已经在 `DeckResolutionService.resolve()` 最前面短路返回。
- 文件夹映射层完全依赖 `FolderDeckMappingService`
- 只有当前两层都没有产出 deck 时才回退到默认牌组并附加 `deck_fallback_default` warning。
## 覆盖设置应存放的位置
- 新字段应加入 `src/application/config/PluginSettings.ts``PluginSettings` 接口与 `DEFAULT_SETTINGS`
- 加载/保存路径通过 `mergePluginSettings()``normalizePluginSettings()` 进入 `DataJsonPluginConfigRepository`
- 现有 include/exclude 文件夹列表只做“数组 + 字符串”校验,没有统一去重或路径规范化 helper新字段应沿用同样约定避免引入与仓库现状不一致的新归一化语义。
## UI 应插入的位置
- 只应插入到 `renderFolderNode()` 的 include 模式行内。
- 行内现有顺序是 toggle、scope checkbox、label新增控件应追加在 label 后侧,并保持现有 dataset 不变。
- 仅当 `scopeMode === "include"`、当前行已选中、且全局 `folderDeckMode !== "off"` 时显示。
- 取消 include 勾选的清理逻辑不应放在 UI 层分散处理,而应合并进 `updateFolderSelection()`,这样任何同一路径的取消动作都能统一清理子树覆盖记录。
## 指纹更新位置
- deck 规则指纹在 `src/application/services/FileIndexerService.ts``createDeckRulesFingerprint()` 中生成。
- `indexVault()` 通过比较 `existingFileState.deckRulesFingerprint !== deckRulesFingerprint` 强制重读;因此覆盖规则必须进入该指纹。
- 当前版本号为 `deck-rules-v4`,实现覆盖后需要升级版本。
## 预计修改的测试文件
- `src/application/config/PluginSettings.test.ts`
- `src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts`
- `src/presentation/settings/PluginSettingTab.test.ts`
- `src/domain/manual-sync/services/FolderDeckMappingService.test.ts`
- `src/domain/manual-sync/services/DeckResolutionService.test.ts`
- `src/application/services/FileIndexerService.test.ts`
- 可能还要更新 `src/test-support/manualSyncFakes.ts` 的默认设置工厂,以便新字段默认值在各类测试夹具中可用。
## 风险点
- `toggleFolderTreeSelection()` 会压缩选择结果;覆盖清理必须基于“被取消的目标路径及其后代”,不能误删其他分支。
- 父文件夹覆盖应对子孙路径生效,但不能把 `notes` 误匹配到 `notes2`;应复用仓库里现有的 `path === folder || path.startsWith(folder + "/")` 规则。
- `DeckResolutionService.resolve()` 当前签名被多处调用,若扩展参数需要同步更新 `RenderConfigService`、`QaGroupSyncService` 和直接单测。
- 作用范围树的测试大量依赖现有 dataset新增控件不能破坏 `folderRow`、`folderToggle`、`folderPath`、`folderPathLabel` 的查找方式。
- 设置页行布局当前是固定 grid新增圆形 checkbox 后需要补 CSS避免标签挤压或破坏缩进对齐。
- 当全局 `folderDeckMode``off` 时必须忽略覆盖列表,但不能清空已保存数据。

48
main.js

File diff suppressed because one or more lines are too long

View file

@ -76,6 +76,7 @@ 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.obsidianBacklinkLabel).toBe(DEFAULT_OBSIDIAN_BACKLINK_LABEL);
expect(DEFAULT_SETTINGS.obsidianBacklinkPlacement).toBe("answer-last-line");
expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true);
@ -111,6 +112,40 @@ 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("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("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("normalizes cached Anki note types on load and save paths", () => {
const settings = mergePluginSettings({
ankiNoteTypeCache: [" Custom Basic ", "", "Cloze", "Custom Basic"],

View file

@ -60,6 +60,7 @@ export interface PluginSettings {
scopeMode: ScopeMode;
includeFolders: string[];
excludeFolders: string[];
alternateFolderDeckModeFolders: string[];
addObsidianBacklink: boolean;
obsidianBacklinkLabel: string;
obsidianBacklinkPlacement: ObsidianBacklinkPlacement;
@ -89,6 +90,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
scopeMode: "include",
includeFolders: [],
excludeFolders: [],
alternateFolderDeckModeFolders: [],
addObsidianBacklink: true,
obsidianBacklinkLabel: DEFAULT_OBSIDIAN_BACKLINK_LABEL,
obsidianBacklinkPlacement: "answer-last-line",
@ -144,6 +146,7 @@ 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,
});
}
@ -204,6 +207,7 @@ export function validatePluginSettings(settings: PluginSettings): void {
validateFolderList(settings.includeFolders, "include");
validateFolderList(settings.excludeFolders, "exclude");
validateFolderList(settings.alternateFolderDeckModeFolders, "alternateFolderDeckMode");
if (!settings.ankiConnectUrl.trim()) {
throw new PluginUserError("errors.settings.ankiConnectUrlRequired");
@ -256,14 +260,30 @@ function validateAnkiModelFieldCache(ankiModelFieldCache: AnkiModelFieldCache):
}
}
function validateFolderList(folderList: string[], label: "include" | "exclude"): void {
function validateFolderList(folderList: string[], label: "include" | "exclude" | "alternateFolderDeckMode"): void {
if (!Array.isArray(folderList)) {
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersArray" : "errors.settings.excludeFoldersArray");
if (label === "include") {
throw new PluginUserError("errors.settings.includeFoldersArray");
}
if (label === "exclude") {
throw new PluginUserError("errors.settings.excludeFoldersArray");
}
throw new PluginUserError("errors.settings.alternateFolderDeckModeFoldersArray");
}
for (const folder of folderList) {
if (typeof folder !== "string") {
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersStrings" : "errors.settings.excludeFoldersStrings");
if (label === "include") {
throw new PluginUserError("errors.settings.includeFoldersStrings");
}
if (label === "exclude") {
throw new PluginUserError("errors.settings.excludeFoldersStrings");
}
throw new PluginUserError("errors.settings.alternateFolderDeckModeFoldersStrings");
}
}
}

View file

@ -299,6 +299,75 @@ 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));
});
});
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-v4";
const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v5";
export function createDeckRulesFingerprint(settings: PluginSettings): string {
return hashString(JSON.stringify({
@ -302,6 +302,7 @@ export function createDeckRulesFingerprint(settings: PluginSettings): string {
fileDeckEnabled: settings.fileDeckEnabled,
fileDeckMarker: settings.fileDeckMarker,
folderDeckMode: settings.folderDeckMode,
alternateFolderDeckModeFolders: [...settings.alternateFolderDeckModeFolders].sort(),
syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki,
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
}));

View file

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

View file

@ -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"

View file

@ -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,35 @@ 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",
});
});
});
function createDeckSettings(
overrides: Partial<Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders">> = {},
): Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders"> {
return {
defaultDeck: "Default",
folderDeckMode: "folder-and-file",
alternateFolderDeckModeFolders: [],
...overrides,
};
}
function createIndexedCard(overrides: Partial<IndexedCard> = {}): IndexedCard {
return {
noteId: overrides.noteId,

View file

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

View file

@ -20,6 +20,26 @@ 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("returns empty for root-level files", () => {
const service = new FolderDeckMappingService();

View file

@ -11,24 +11,27 @@ 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[] = []): 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();
if (effectiveMode === "folder-and-file") {
const fileName = normalizedFilePath.slice(lastSlash + 1).replace(/\.[^.]+$/, "").trim();
if (fileName) {
segments.push(fileName);
}
@ -48,4 +51,34 @@ 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 normalizeFilePath(filePath: string): string {
return filePath.trim().replace(/\\/g, "/").replace(/^\/+/, "");
}
function isPathInsideFolder(filePath: string, folderPath: string): boolean {
return filePath === folderPath || filePath.startsWith(`${folderPath}/`);
}

View file

@ -63,6 +63,20 @@ 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("normalizes blank backlink labels on load and save", async () => {
const store = new InMemoryPluginDataStore({
settings: {

View file

@ -196,6 +196,17 @@ 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",
},
},
},
cards: {
cardTypes: {
@ -377,6 +388,8 @@ 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.",
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

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

View file

@ -1658,6 +1658,98 @@ 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 overrideAttributes = overrideCheckbox as unknown as { title?: string; "aria-label"?: string };
expect(overrideCheckbox.classList.contains("ahs-settings-folder-override-checkbox")).toBe(true);
expect(overrideAttributes.title).toBe("当前全局为「文件夹」,勾选后此文件夹改用「文件夹及文件名」作为牌组名");
expect(overrideAttributes["aria-label"]).toBe("切换 sub 的文件夹牌组映射模式");
expect(() => queryByDataset(tab.containerEl, "folderDeckModeOverrideHint", "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 hint = queryByDataset(tab.containerEl, "folderDeckModeOverrideHint", "notes/sub");
expect(hint.classList.contains("ahs-settings-folder-override-hint")).toBe(true);
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"],
});
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([]);
});
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"],
});
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"]);
});
it("shows an explicit warning when include mode has no selected folders", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({

View file

@ -1627,7 +1627,32 @@ 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));
await this.plugin.updateSettings({
includeFolders: nextSelection,
alternateFolderDeckModeFolders: nextAlternateFolderDeckModeFolders,
});
} 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");
}
@ -1670,11 +1695,40 @@ 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 labelEl = contentEl.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 = row.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 = contentEl.createDiv({ text: this.getFolderDeckModeOverrideHint() });
hintEl.dataset.folderDeckModeOverrideHint = node.path;
addClasses(hintEl, "ahs-settings-folder-override-hint");
}
}
if (!hasChildren || !expanded) {
return;
}
@ -1708,6 +1762,22 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
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 createInlineControlGroup(containerEl: HTMLElement, label: string): HTMLElement {
const groupEl = containerEl.createEl("label");
addClasses(groupEl, "ahs-settings-inline-control-group");
@ -1875,6 +1945,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");

View file

@ -200,7 +200,7 @@
.anki-heading-sync-settings .ahs-settings-folder-row {
display: grid;
grid-template-columns: 24px 24px minmax(0, 1fr);
grid-template-columns: 24px 24px minmax(0, 1fr) auto;
align-items: center;
column-gap: 0;
width: 100%;
@ -238,6 +238,44 @@
min-width: 0;
}
.anki-heading-sync-settings .ahs-settings-folder-label-stack {
display: flex;
flex-direction: column;
min-width: 0;
gap: 2px;
}
.anki-heading-sync-settings .ahs-settings-folder-override-checkbox {
justify-self: end;
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 {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
line-height: 1.35;
}
.anki-heading-sync-settings .ahs-settings-inline-control-group {
display: inline-flex;
align-items: center;