diff --git a/docs/settings-page-cardization-decisions.md b/docs/settings-page-cardization-decisions.md new file mode 100644 index 0000000..74951fe --- /dev/null +++ b/docs/settings-page-cardization-decisions.md @@ -0,0 +1,151 @@ +# 设置页卡片化实现决策 + +本文锁定本轮“设置页卡片化 + 局部刷新 + 设置驱动卡片识别配置”任务的仓库兼容实现路径。 + +## 1. 新配置结构 + +- 在 `PluginSettings` 中新增统一识别配置:`cardTypeConfigs` +- 结构使用固定 key 的对象,而不是自由数组,key 固定为: + - `basic` + - `qa-group` + - `cloze` + - `semantic-qa` +- 每行配置至少包含: + - `enabled` + - `headingLevel` + - `extraMarker` + - `noteType` + +## 2. 与旧字段的兼容策略 + +- 旧字段继续保留在 `PluginSettings` 中,避免影响未改造完的调用点与历史数据读取: + - `qaHeadingLevel` + - `clozeHeadingLevel` + - `qaGroupMarker` + - `semanticQaMarker` + - `qaNoteType` + - `clozeNoteType` + - `semanticQaNoteType` +- 读取旧数据时: + - 若没有 `cardTypeConfigs`,则由旧字段即时构建默认的 4 行配置 +- 保存时: + - 以 `cardTypeConfigs` 为真源 + - 同步回写旧字段,作为兼容镜像 +- `noteFieldMappings` 继续保留并复用,不迁移成新的独立字段结构 + +## 3. QA Group 行的处理 + +- QA Group 12 继续走现有托管模型链路 +- 表格中该行显示托管 note type / 托管字段,不进入普通字段映射保存流程 +- `noteFieldMappings` 只继续服务: + - `basic` + - `cloze` + - `semantic-qa` + +## 4. 识别匹配规则 + +- 运行时不再直接依赖 `qaHeadingLevel/clozeHeadingLevel` 分派类型 +- 改为对当前标题按 `cardTypeConfigs` 做匹配: + - 仅考虑 `enabled = true` 且 `headingLevel` 命中的行 + - 先尝试匹配 `extraMarker` 非空且标题以该 marker 结尾的行 + - 没有 marker 命中时,再使用同级空 `extraMarker` 行作为默认回退 +- 若多个非空 marker 都命中: + - 采用“marker 更长者优先”,再按固定行顺序稳定决策 +- 固定顺序为: + - `qa-group` + - `semantic-qa` + - `cloze` + - `basic` +- 这样能保持现有“特殊类型优先于普通 QA”的直觉,并减少 marker 前后缀重叠时的误判。 + +## 5. 冲突校验 + +- 保存时校验:同一 `headingLevel` 下,启用行里最多只允许一个空 `extraMarker` +- 若冲突: + - 阻止这次保存 + - 第一张卡片内显示错误 +- 本轮不额外增加“同级相同非空 marker 禁止保存”的新产品规则,避免超出任务合同 + +## 6. 设置页渲染架构 + +- `display()` 只负责: + - 初始化页面标题 + - 创建 5 张卡片的稳定外壳 + - 首次渲染每张卡片 body +- 卡片外壳只创建一次;后续普通交互不再整页 `display()` +- 每张卡片独立拥有: + - header + - body 容器 + - render 方法 +- 卡片展开状态仅存于 `PluginSettingTab` 实例内 +- 默认展开: + - 卡片 1 + - 卡片 5 +- 默认折叠: + - 卡片 2 + - 卡片 3 + - 卡片 4 + +## 7. 局部刷新策略 + +- card 1:Anki 模板加载、表格行编辑、错误状态,仅刷新 card 1 body +- card 2:同步内容相关设置,仅刷新 card 2 body +- card 3:scope mode 切换刷新 card 3;文件夹展开/勾选优先只刷新 tree region +- card 4:deck 开关和 deck 文本输入,仅刷新 card 4 body +- card 5:纯展示,首次渲染后通常不刷新 +- 若必须做整页重建: + - 记录 scrollTop + - 重建后恢复 scrollTop + +## 8. debounce 策略 + +- 所有文本输入改为 debounce 自动保存 +- 统一目标为 500ms +- debounce 只用于文本输入: + - marker + - backlink label + - default deck + - file deck marker + - file deck template + - Anki URL 高级项 +- toggle 与 dropdown 继续即时保存 + +## 9. Anki 模板加载策略 + +- card 1 提供一个统一按钮:`手动读取 Anki 里的模板配置` +- 点击后: + - 加载全部 note models + - 再为当前 4 行里已选 note type 拉取字段详情(QA Group 行跳过) +- 加载状态仅作用于 card 1 +- 字段未加载前,字段列显示稳定占位项,不让表格高度大幅波动 + +## 10. 文件夹树策略 + +- 保留现有 folder tree 缓存字段 +- 首次只在以下任一条件成立时加载: + - card 3 被展开 + - scopeMode 从 `all` 切到 `include/exclude` +- 新增 card 3 的局部“刷新文件夹列表”按钮 +- 刷新按钮只清空 folder tree 缓存并重载 card 3,不触发整页重建 + +## 11. 命令说明卡片 + +- 直接展示当前命令注册中的 5 个命令名称与说明 +- 不在本轮新增或修改命令行为 + +## 12. 测试策略 + +- 设置页测试以“渲染壳层稳定 + body 局部刷新”为核心 +- 若真实滚动断言在当前 fake DOM 中不稳: + - 用 `display()` 调用次数 / root `empty()` 调用次数作为抗飘代理断言 +- 识别测试放在 `CardIndexingService` 与设置模型测试中: + - 禁用类型不识别 + - marker 优先 + - 空 marker 回退 + - 空 marker 冲突阻止保存 + - 旧设置迁移保持行为 + +## 13. 有意偏离点 + +- 任务合同要求“表格真正驱动运行时识别”,这里采用“新识别配置驱动运行时,旧字段仅做兼容镜像”的路径,而不是彻底删除旧字段。 +- 这是仓库兼容优先的选择:能在一轮内完成端到端改造,同时避免对尚未迁移的辅助链路造成连锁破坏。 \ No newline at end of file diff --git a/docs/settings-page-cardization-gap-report.md b/docs/settings-page-cardization-gap-report.md new file mode 100644 index 0000000..13bea3b --- /dev/null +++ b/docs/settings-page-cardization-gap-report.md @@ -0,0 +1,144 @@ +# 设置页卡片化差距审计 + +本文以本轮“设置页卡片化 + 局部刷新 + 设置驱动卡片识别配置”任务合同为唯一验收基准,对当前仓库做真实实现审计。 + +## 1. 当前设置页仍是单入口整页重建 + +- 设置页入口位于 `src/presentation/settings/PluginSettingTab.ts`。 +- `display()` 里直接执行 `containerEl.empty()`,随后把所有设置项重新创建。 +- 多个普通交互会再次调用 `this.display()`,包括: + - 语义 QA 标记变更 + - note type 切换 + - Anki 字段加载 + - 字段映射保存 + - 文件级 deck 开关 + - 范围模式切换 + - 文件夹树勾选 + - 文件夹树展开/收起 +- 这与“稳定卡片外壳 + 卡片内部局部刷新”的任务目标直接冲突,也是当前页面跳动和滚动漂移的根因。 + +## 2. 现有 UI 结构与目标 5 卡片结构不一致 + +- 当前设置页是按顺序平铺的 imperative render,没有稳定分区壳层。 +- 不存在折叠卡片,也没有设置页实例级展开状态。 +- deck、scope、mapping、semantic preview 等内容都混在一个 `display()` 流程里。 +- 不满足“5 张可折叠卡片、默认展开 1/5、默认折叠 2/3/4”的要求。 + +## 3. 第一张卡片的数据模型还没有成为识别真源 + +- 当前设置模型只保留旧的离散字段: + - `qaHeadingLevel` + - `clozeHeadingLevel` + - `qaGroupMarker` + - `semanticQaMarker` + - `qaNoteType` + - `clozeNoteType` + - `semanticQaNoteType` + - `noteFieldMappings` +- 运行时识别入口仍是 `src/domain/manual-sync/services/CardIndexingService.ts`: + - 先按 `qaHeadingLevel/clozeHeadingLevel` 把标题判成 `basic/cloze` + - 再仅在 `basic` 分支内部,用 `qaGroupMarker/semanticQaMarker` 特判 QA Group 与 Semantic QA +- 当前不存在统一的“4 行配置 -> 识别匹配规则 -> 运行时类型”的结构。 +- 因此即便只改 UI,也无法让表格真正驱动同步识别。 + +## 4. 当前识别规则不足以支持新任务合同 + +- `CardIndexingService` 只支持: + - 一个 QA/basic 标题级别 + - 一个 cloze 标题级别 + - basic 上的两个 marker 分流 +- 当前不支持: + - `enabled` 控制识别开关 + - 同一标题级别下多行配置的优先级匹配 + - 同级默认行(空 extra marker)回退 + - 保存时检测“同级多个启用默认行”并阻止保存 +- 现有 `validatePluginSettings()` 还显式禁止 `qaHeadingLevel === clozeHeadingLevel`,这与新合同允许同级依赖 extra marker 分流的方向冲突。 + +## 5. 现有字段映射能力可复用,但需要换接入方式 + +- `noteFieldMappings`、`NoteFieldMappingService`、Anki note model 读取接口、字段详情读取接口都已存在,可复用。 +- 但当前 UI 采用“每个 section 单独选择 note type、单独读取字段、再单独点保存映射”的流程。 +- 这与新的直接编辑表格、自动保存、按需 debounce 保存不一致。 +- QA Group 12 已经有独立的托管模型链路: + - `QaGroupModelDefinition` + - `QaGroupSyncService` +- 这说明 QA Group 行不应该被硬塞进普通 `noteFieldMappings` 保存流程;该行应显示为托管模型/托管字段。 + +## 6. 第二、三、四张卡片已有局部能力基础,但刷新策略不对 + +- “同步内容”相关设置已经存在,但散落在整页 render 中。 +- 范围设置已经具备: + - `scopeMode` + - 文件夹树纯函数 + - lazy load + - 缓存字段 `folderTree` / `hasLoadedFolderTree` / `folderTreeLoadPromise` +- deck 设置已经具备完整字段和按钮能力。 +- 但 scope、deck 的普通交互仍回到整页 `display()`,所以已有能力还没有被组织成局部刷新架构。 + +## 7. 文件夹树缓存已存在,但缺少局部刷新按钮 + +- 当前文件夹树只在 `scopeMode !== all` 时触发加载。 +- 已有缓存字段能支撑“按展开时加载”和“重复打开不重复遍历”。 +- 但没有局部“刷新文件夹列表”入口。 +- 加载完成后仍调用整页 `display()`,不满足“只刷新 card 3 / tree region”的目标。 + +## 8. Anki 模板加载路径存在,但还是 section 级旧交互 + +- 当前已有: + - `listNoteModels()` + - `getNoteModelDetails(modelName)` +- 但交互仍是:先刷新 note types,再按 section 读取字段。 +- 新合同要求 card 1 提供一个统一按钮,一次刷新模板列表,并补齐当前表格已选模板的字段选项。 +- 当前状态字段 `availableNoteModels`、`loadedModelDetails`、`draftMappings` 可部分复用,但需要调整成表格行驱动。 + +## 9. 迁移链路尚未实现 + +- `mergePluginSettings()` 目前只做默认值合并,不会从旧字段构建新识别配置。 +- `normalizePluginSettings()` 当前只处理 backlink label 标准化。 +- `DataJsonPluginConfigRepository.load()` / `save()` 会依赖 `mergePluginSettings()` 与 `validatePluginSettings()`,所以迁移和兼容必须落在设置模型层,而不是仅在 UI 层补丁。 + +## 10. 现有测试缺口 + +- 设置页测试目前主要覆盖: + - note type / field mapping + - scope tree 交互 + - deck 开关 +- 还没有覆盖: + - 5 卡片结构 + - 默认展开状态 + - 表格直编 + debounce 自动保存 + - 普通交互不触发整页重建 + - 新识别规则与冲突阻止保存 + - 旧设置迁移到新识别结构后行为保持不变 + +## 11. 可复用与必须替换 + +可复用: + +- `noteFieldMappings` 与 `NoteFieldMappingService` +- `listNoteModels()` / `getNoteModelDetails()` +- QA Group 托管模型链路 +- scope tree 纯函数与缓存字段 +- deck 设置字段与插入模板能力 +- `PluginSettingTab` 现有各段业务渲染逻辑中的具体设置项语义 + +必须替换或重组: + +- `PluginSettingTab.display()` 的整页重建模式 +- 运行时基于旧离散字段的识别分派方式 +- 设置模型里缺失的新识别配置与迁移逻辑 +- 设置页测试夹具中无法表达“局部清空/局部重绘”的部分 + +## 12. 结论 + +当前仓库具备完成本任务的大部分业务能力,但缺少三个关键骨架: + +1. 稳定卡片壳层与局部 render 机制 +2. 新的卡片识别配置模型及其向旧字段的兼容迁移 +3. 让运行时识别直接消费新配置的匹配逻辑 + +因此本轮实现应聚焦于: + +- 先在设置模型层建立新配置与迁移 +- 再改运行时识别入口 +- 最后将设置页重组为 5 张稳定卡片并接入局部刷新 \ No newline at end of file diff --git a/src/application/config/ManagedNoteModels.ts b/src/application/config/ManagedNoteModels.ts new file mode 100644 index 0000000..cdbacf0 --- /dev/null +++ b/src/application/config/ManagedNoteModels.ts @@ -0,0 +1 @@ +export const QA_GROUP_MODEL_NAME = "ObsiAnki QA Group 12"; \ No newline at end of file diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts index cb878ca..e2ea246 100644 --- a/src/application/config/PluginSettings.test.ts +++ b/src/application/config/PluginSettings.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels"; import { PluginUserError } from "@/application/errors/PluginUserError"; import { @@ -28,14 +29,34 @@ describe("PluginSettings", () => { expect(() => validatePluginSettings(DEFAULT_SETTINGS)).not.toThrow(); }); - it("rejects equal QA and Cloze heading levels", () => { + it("allows equal heading levels when marker and default rows remain unambiguous", () => { + expect(() => validatePluginSettings({ + ...DEFAULT_SETTINGS, + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + cloze: { + ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, + headingLevel: DEFAULT_SETTINGS.cardTypeConfigs.basic.headingLevel, + extraMarker: "#cloze", + }, + }, + })).not.toThrow(); + }); + + it("rejects multiple enabled default rows on the same heading", () => { expectPluginUserError(() => { validatePluginSettings({ ...DEFAULT_SETTINGS, - qaHeadingLevel: 4, - clozeHeadingLevel: 4, + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + cloze: { + ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, + headingLevel: DEFAULT_SETTINGS.cardTypeConfigs.basic.headingLevel, + extraMarker: "", + }, + }, }); - }, "errors.settings.headingLevelsDifferent"); + }, "errors.settings.cardTypeDefaultConflict"); }); it("rejects invalid scope modes", () => { @@ -59,6 +80,32 @@ describe("PluginSettings", () => { expect(DEFAULT_SETTINGS.obsidianBacklinkPlacement).toBe("answer-last-line"); expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true); expect(DEFAULT_SETTINGS.keepPureTagLinesInCardBody).toBe(true); + expect(DEFAULT_SETTINGS.cardTypeConfigs).toEqual({ + basic: { + enabled: true, + headingLevel: 4, + extraMarker: "", + noteType: "Basic", + }, + "qa-group": { + enabled: true, + headingLevel: 4, + extraMarker: "#anki-list", + noteType: QA_GROUP_MODEL_NAME, + }, + cloze: { + enabled: true, + headingLevel: 5, + extraMarker: "", + noteType: "Cloze", + }, + "semantic-qa": { + enabled: true, + headingLevel: 4, + extraMarker: "#anki-list-qa", + noteType: "Semantic QA", + }, + }); }); it("fills new backlink defaults when loading legacy snapshots", () => { @@ -69,6 +116,32 @@ describe("PluginSettings", () => { expect(settings.obsidianBacklinkLabel).toBe(DEFAULT_OBSIDIAN_BACKLINK_LABEL); expect(settings.obsidianBacklinkPlacement).toBe("answer-last-line"); + expect(settings.cardTypeConfigs).toEqual({ + basic: { + enabled: true, + headingLevel: 4, + extraMarker: "", + noteType: "Legacy Basic", + }, + "qa-group": { + enabled: true, + headingLevel: 4, + extraMarker: "#anki-list", + noteType: QA_GROUP_MODEL_NAME, + }, + cloze: { + enabled: true, + headingLevel: 5, + extraMarker: "", + noteType: "Legacy Cloze", + }, + "semantic-qa": { + enabled: true, + headingLevel: 4, + extraMarker: "#anki-list-qa", + noteType: "Semantic QA", + }, + }); }); it("rejects invalid backlink placement values", () => { @@ -110,19 +183,30 @@ describe("PluginSettings", () => { }); it("rejects invalid QA Group markers and semantic marker collisions", () => { - expectPluginUserError(() => { - validatePluginSettings({ - ...DEFAULT_SETTINGS, - qaGroupMarker: "anki-list", - }); - }, "errors.settings.qaGroupMarkerInvalid"); + expect(() => validatePluginSettings({ + ...DEFAULT_SETTINGS, + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + "qa-group": { + ...DEFAULT_SETTINGS.cardTypeConfigs["qa-group"], + extraMarker: "anki-list", + }, + }, + })).not.toThrow(); + }); - expectPluginUserError(() => { - validatePluginSettings({ - ...DEFAULT_SETTINGS, - qaGroupMarker: "#anki-list-qa", - }); - }, "errors.settings.qaGroupMarkerConflict"); + it("writes managed QA Group model back during normalization", () => { + const settings = mergePluginSettings({ + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + "qa-group": { + ...DEFAULT_SETTINGS.cardTypeConfigs["qa-group"], + noteType: "Custom QA Group", + }, + }, + }); + + expect(settings.cardTypeConfigs["qa-group"].noteType).toBe(QA_GROUP_MODEL_NAME); }); it("rejects invalid module 5 enum values", () => { diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts index a97ddc6..939a5b7 100644 --- a/src/application/config/PluginSettings.ts +++ b/src/application/config/PluginSettings.ts @@ -1,4 +1,5 @@ import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping"; +import { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels"; import { PluginUserError } from "@/application/errors/PluginUserError"; export type ScopeMode = "all" | "include" | "exclude"; @@ -6,9 +7,28 @@ export type FileDeckInsertLocation = "yaml" | "body"; export type FolderDeckMode = "off" | "folder" | "folder-and-file"; export type CardAnswerCutoffMode = "heading-block" | "double-blank-lines"; export type ObsidianBacklinkPlacement = "question-last-line" | "answer-first-line" | "answer-last-line"; +export const CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze", "semantic-qa"] as const; +export type CardTypeConfigId = (typeof CARD_TYPE_CONFIG_IDS)[number]; + +export interface CardTypeConfig { + enabled: boolean; + headingLevel: number; + extraMarker: string; + noteType: string; +} + +export type CardTypeConfigs = Record; export const DEFAULT_OBSIDIAN_BACKLINK_LABEL = "Open in Obsidian"; +const DEFAULT_BASIC_HEADING_LEVEL = 4; +const DEFAULT_CLOZE_HEADING_LEVEL = 5; +const DEFAULT_BASIC_NOTE_TYPE = "Basic"; +const DEFAULT_CLOZE_NOTE_TYPE = "Cloze"; +const DEFAULT_SEMANTIC_QA_NOTE_TYPE = "Semantic QA"; +const DEFAULT_QA_GROUP_MARKER = "#anki-list"; +const DEFAULT_SEMANTIC_QA_MARKER = "#anki-list-qa"; + export interface PluginSettings { qaHeadingLevel: number; clozeHeadingLevel: number; @@ -18,6 +38,7 @@ export interface PluginSettings { clozeNoteType: string; semanticQaMarker: string; semanticQaNoteType: string; + cardTypeConfigs: CardTypeConfigs; noteFieldMappings: Record; defaultDeck: string; fileDeckEnabled: boolean; @@ -38,14 +59,15 @@ export interface PluginSettings { } export const DEFAULT_SETTINGS: PluginSettings = { - qaHeadingLevel: 4, - clozeHeadingLevel: 5, + qaHeadingLevel: DEFAULT_BASIC_HEADING_LEVEL, + clozeHeadingLevel: DEFAULT_CLOZE_HEADING_LEVEL, cardAnswerCutoffMode: "heading-block", - qaGroupMarker: "#anki-list", - qaNoteType: "Basic", - clozeNoteType: "Cloze", - semanticQaMarker: "#anki-list-qa", - semanticQaNoteType: "Semantic QA", + qaGroupMarker: DEFAULT_QA_GROUP_MARKER, + qaNoteType: DEFAULT_BASIC_NOTE_TYPE, + clozeNoteType: DEFAULT_CLOZE_NOTE_TYPE, + semanticQaMarker: DEFAULT_SEMANTIC_QA_MARKER, + semanticQaNoteType: DEFAULT_SEMANTIC_QA_NOTE_TYPE, + cardTypeConfigs: createDefaultCardTypeConfigs(), noteFieldMappings: {}, defaultDeck: "Obsidian", fileDeckEnabled: false, @@ -85,18 +107,25 @@ export function normalizeObsidianBacklinkLabel(value: string | null | undefined) } export function normalizePluginSettings(settings: PluginSettings): PluginSettings { + const cardTypeConfigs = mergeCardTypeConfigs(settings.cardTypeConfigs, settings); + const legacySettings = deriveLegacySettings(cardTypeConfigs); + return { ...settings, + ...legacySettings, + cardTypeConfigs, obsidianBacklinkLabel: normalizeObsidianBacklinkLabel(settings.obsidianBacklinkLabel), }; } export function mergePluginSettings(settings?: Partial | null): PluginSettings { const partialSettings = settings ?? {}; + const cardTypeConfigs = mergeCardTypeConfigs(partialSettings.cardTypeConfigs, partialSettings); return normalizePluginSettings({ ...DEFAULT_SETTINGS, ...partialSettings, + cardTypeConfigs, noteFieldMappings: partialSettings.noteFieldMappings ?? DEFAULT_SETTINGS.noteFieldMappings, includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders, excludeFolders: partialSettings.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders, @@ -104,54 +133,12 @@ export function mergePluginSettings(settings?: Partial | null): } export function validatePluginSettings(settings: PluginSettings): void { - const headingLevels = [settings.qaHeadingLevel, settings.clozeHeadingLevel]; - - for (const level of headingLevels) { - if (!Number.isInteger(level) || level < 1 || level > 6) { - throw new PluginUserError("errors.settings.headingLevelsRange"); - } - } - - if (settings.qaHeadingLevel === settings.clozeHeadingLevel) { - throw new PluginUserError("errors.settings.headingLevelsDifferent"); - } + validateCardTypeConfigs(settings.cardTypeConfigs); if (settings.cardAnswerCutoffMode !== "heading-block" && settings.cardAnswerCutoffMode !== "double-blank-lines") { throw new PluginUserError("errors.settings.cardAnswerCutoffModeInvalid"); } - if (!settings.qaNoteType.trim()) { - throw new PluginUserError("errors.settings.qaNoteTypeRequired"); - } - - if (!settings.qaGroupMarker.trim()) { - throw new PluginUserError("errors.settings.qaGroupMarkerRequired"); - } - - if (!isValidHashtagMarker(settings.qaGroupMarker.trim())) { - throw new PluginUserError("errors.settings.qaGroupMarkerInvalid"); - } - - if (!settings.clozeNoteType.trim()) { - throw new PluginUserError("errors.settings.clozeNoteTypeRequired"); - } - - if (!settings.semanticQaMarker.trim()) { - throw new PluginUserError("errors.settings.semanticQaMarkerRequired"); - } - - if (!isValidSemanticQaMarker(settings.semanticQaMarker.trim())) { - throw new PluginUserError("errors.settings.semanticQaMarkerInvalid"); - } - - if (settings.qaGroupMarker.trim() === settings.semanticQaMarker.trim()) { - throw new PluginUserError("errors.settings.qaGroupMarkerConflict"); - } - - if (!settings.semanticQaNoteType.trim()) { - throw new PluginUserError("errors.settings.semanticQaNoteTypeRequired"); - } - validateNoteFieldMappings(settings.noteFieldMappings); if (!settings.defaultDeck.trim()) { @@ -218,6 +205,59 @@ function validateFolderList(folderList: string[], label: "include" | "exclude"): } } +export function validateCardTypeConfigs(cardTypeConfigs: CardTypeConfigs): void { + if (!cardTypeConfigs || typeof cardTypeConfigs !== "object" || Array.isArray(cardTypeConfigs)) { + throw new PluginUserError("errors.settings.cardTypeConfigsObject"); + } + + const emptyDefaultsByHeading = new Map(); + + for (const configId of CARD_TYPE_CONFIG_IDS) { + const config = (cardTypeConfigs as Partial)[configId]; + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new PluginUserError("errors.settings.cardTypeConfigsObject"); + } + + if (typeof config.enabled !== "boolean") { + throw new PluginUserError("errors.settings.cardTypeEnabledBoolean"); + } + + if (!Number.isInteger(config.headingLevel) || config.headingLevel < 1 || config.headingLevel > 6) { + throw new PluginUserError("errors.settings.headingLevelsRange"); + } + + if (typeof config.extraMarker !== "string") { + throw new PluginUserError("errors.settings.cardTypeExtraMarkerString"); + } + + if (configId === "basic" && !config.noteType.trim()) { + throw new PluginUserError("errors.settings.qaNoteTypeRequired"); + } + + if (configId === "cloze" && !config.noteType.trim()) { + throw new PluginUserError("errors.settings.clozeNoteTypeRequired"); + } + + if (configId === "semantic-qa" && !config.noteType.trim()) { + throw new PluginUserError("errors.settings.semanticQaNoteTypeRequired"); + } + + if (!config.enabled || config.extraMarker.trim().length > 0) { + continue; + } + + const defaults = emptyDefaultsByHeading.get(config.headingLevel) ?? []; + defaults.push(configId); + emptyDefaultsByHeading.set(config.headingLevel, defaults); + } + + for (const [headingLevel, defaults] of emptyDefaultsByHeading.entries()) { + if (defaults.length > 1) { + throw new PluginUserError("errors.settings.cardTypeDefaultConflict", { headingLevel }); + } + } +} + function validateNoteFieldMappings(noteFieldMappings: Record): void { if (!noteFieldMappings || typeof noteFieldMappings !== "object" || Array.isArray(noteFieldMappings)) { throw new PluginUserError("errors.settings.noteFieldMappingsObject"); @@ -240,4 +280,122 @@ function validateNoteFieldMappings(noteFieldMappings: Record): CardTypeConfigs { + const defaults = createDefaultCardTypeConfigs(); + + return { + basic: { + ...defaults.basic, + headingLevel: sanitizeHeadingLevel(settings.qaHeadingLevel, defaults.basic.headingLevel), + noteType: sanitizeNoteType(settings.qaNoteType, defaults.basic.noteType), + }, + "qa-group": { + ...defaults["qa-group"], + headingLevel: sanitizeHeadingLevel(settings.qaHeadingLevel, defaults["qa-group"].headingLevel), + extraMarker: sanitizeMarker(settings.qaGroupMarker, defaults["qa-group"].extraMarker), + noteType: QA_GROUP_MODEL_NAME, + }, + cloze: { + ...defaults.cloze, + headingLevel: sanitizeHeadingLevel(settings.clozeHeadingLevel, defaults.cloze.headingLevel), + noteType: sanitizeNoteType(settings.clozeNoteType, defaults.cloze.noteType), + }, + "semantic-qa": { + ...defaults["semantic-qa"], + headingLevel: sanitizeHeadingLevel(settings.qaHeadingLevel, defaults["semantic-qa"].headingLevel), + extraMarker: sanitizeMarker(settings.semanticQaMarker, defaults["semantic-qa"].extraMarker), + noteType: sanitizeNoteType(settings.semanticQaNoteType, defaults["semantic-qa"].noteType), + }, + }; +} + +function mergeCardTypeConfigs( + rawCardTypeConfigs: Partial>> | null | undefined, + legacySettings: Partial, +): CardTypeConfigs { + const legacyBackfilledConfigs = createLegacyBackfilledCardTypeConfigs(legacySettings); + const nextConfigs = {} as CardTypeConfigs; + + for (const configId of CARD_TYPE_CONFIG_IDS) { + const rawConfig = rawCardTypeConfigs?.[configId]; + const fallbackConfig = legacyBackfilledConfigs[configId]; + const mergedConfig = rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig) + ? { ...fallbackConfig, ...rawConfig } + : fallbackConfig; + + nextConfigs[configId] = { + enabled: typeof mergedConfig.enabled === "boolean" ? mergedConfig.enabled : fallbackConfig.enabled, + headingLevel: sanitizeHeadingLevel(mergedConfig.headingLevel, fallbackConfig.headingLevel), + extraMarker: sanitizeMarker(mergedConfig.extraMarker, fallbackConfig.extraMarker), + noteType: configId === "qa-group" + ? QA_GROUP_MODEL_NAME + : sanitizeNoteType(mergedConfig.noteType, fallbackConfig.noteType), + }; + } + + return nextConfigs; +} + +function deriveLegacySettings(cardTypeConfigs: CardTypeConfigs): Pick { + return { + qaHeadingLevel: cardTypeConfigs.basic.headingLevel, + clozeHeadingLevel: cardTypeConfigs.cloze.headingLevel, + qaGroupMarker: cardTypeConfigs["qa-group"].extraMarker, + qaNoteType: cardTypeConfigs.basic.noteType, + clozeNoteType: cardTypeConfigs.cloze.noteType, + semanticQaMarker: cardTypeConfigs["semantic-qa"].extraMarker, + semanticQaNoteType: cardTypeConfigs["semantic-qa"].noteType, + }; +} + +function sanitizeHeadingLevel(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 6 ? value : fallback; +} + +function sanitizeMarker(value: string | undefined, fallback: string): string { + if (typeof value !== "string") { + return fallback; + } + + return value.trim(); +} + +function sanitizeNoteType(value: string | undefined, fallback: string): string { + if (typeof value !== "string") { + return fallback; + } + + const trimmedValue = value.trim(); + return trimmedValue.length > 0 ? trimmedValue : fallback; } \ No newline at end of file diff --git a/src/application/services/FileIndexerService.ts b/src/application/services/FileIndexerService.ts index a08dfbc..fb21606 100644 --- a/src/application/services/FileIndexerService.ts +++ b/src/application/services/FileIndexerService.ts @@ -53,11 +53,8 @@ export class FileIndexerService { const fileStamp = reference ? createFileStamp(reference.mtime, reference.size) : `${Date.now()}:${sourceFile.content.length}`; const stateIndex = this.buildStateIndex(state); const indexedFile = this.cardIndexingService.index(sourceFile, { - qaHeadingLevel: settings.qaHeadingLevel, - clozeHeadingLevel: settings.clozeHeadingLevel, + cardTypeConfigs: settings.cardTypeConfigs, cardAnswerCutoffMode: settings.cardAnswerCutoffMode, - qaGroupMarker: settings.qaGroupMarker, - semanticQaMarker: settings.semanticQaMarker, syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki, fileStamp, knownCards: stateIndex.cardsByFilePath.get(filePath) ?? [], @@ -137,11 +134,8 @@ export class FileIndexerService { } const indexedFile = this.cardIndexingService.index(sourceFile, { - qaHeadingLevel: settings.qaHeadingLevel, - clozeHeadingLevel: settings.clozeHeadingLevel, + cardTypeConfigs: settings.cardTypeConfigs, cardAnswerCutoffMode: settings.cardAnswerCutoffMode, - qaGroupMarker: settings.qaGroupMarker, - semanticQaMarker: settings.semanticQaMarker, syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki, fileStamp, knownCards, @@ -279,11 +273,8 @@ const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v4"; export function createDeckRulesFingerprint(settings: PluginSettings): string { return hashString(JSON.stringify({ version: DECK_RULES_FINGERPRINT_VERSION, - qaHeadingLevel: settings.qaHeadingLevel, - clozeHeadingLevel: settings.clozeHeadingLevel, + cardTypeConfigs: settings.cardTypeConfigs, cardAnswerCutoffMode: settings.cardAnswerCutoffMode, - qaGroupMarker: settings.qaGroupMarker, - semanticQaMarker: settings.semanticQaMarker, defaultDeck: settings.defaultDeck, fileDeckEnabled: settings.fileDeckEnabled, fileDeckMarker: settings.fileDeckMarker, diff --git a/src/application/services/QaGroupModelDefinition.ts b/src/application/services/QaGroupModelDefinition.ts index 28b5e95..bfe8225 100644 --- a/src/application/services/QaGroupModelDefinition.ts +++ b/src/application/services/QaGroupModelDefinition.ts @@ -4,10 +4,10 @@ import { type ObsidianBacklinkPlacement, normalizeObsidianBacklinkLabel, } from "@/application/config/PluginSettings"; +import { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels"; import type { GroupItem } from "@/domain/manual-sync/entities/IndexedGroupCardBlock"; import { renderObsidianBacklinkAnchor } from "@/domain/shared/renderObsidianBacklink"; -export const QA_GROUP_MODEL_NAME = "ObsiAnki QA Group 12"; export const QA_GROUP_SLOT_COUNT = 12; interface QaGroupModelDefinitionOptions { @@ -15,6 +15,8 @@ interface QaGroupModelDefinitionOptions { obsidianBacklinkPlacement?: ObsidianBacklinkPlacement; } +export { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels"; + export function buildQaGroupModelDefinition(options: QaGroupModelDefinitionOptions = {}): CreateAnkiModelInput { const backlinkLabel = normalizeObsidianBacklinkLabel(options.obsidianBacklinkLabel ?? DEFAULT_OBSIDIAN_BACKLINK_LABEL); const backlinkPlacement = options.obsidianBacklinkPlacement ?? "answer-last-line"; diff --git a/src/domain/manual-sync/services/CardIndexingService.ts b/src/domain/manual-sync/services/CardIndexingService.ts index e32aea9..e142d97 100644 --- a/src/domain/manual-sync/services/CardIndexingService.ts +++ b/src/domain/manual-sync/services/CardIndexingService.ts @@ -1,4 +1,10 @@ -import type { CardAnswerCutoffMode } from "@/application/config/PluginSettings"; +import { + DEFAULT_SETTINGS, + type CardAnswerCutoffMode, + type CardTypeConfig, + type CardTypeConfigId, + type CardTypeConfigs, +} from "@/application/config/PluginSettings"; import type { SourceFile } from "@/domain/card/entities/SourceFile"; import { createIndexedCardSyncKey, type IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; import { buildGroupSrc, createIndexedGroupSyncKey, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock"; @@ -6,11 +12,11 @@ import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile"; import { createPendingWriteBackKey, type CardState, type GroupBlockState, type PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState"; import { hashString } from "@/domain/shared/hash"; +import { resolveAnswerBoundary } from "./AnswerBoundaryParser"; import { CardMarkerService } from "./CardMarkerService"; import { DeckExtractionService } from "./DeckExtractionService"; import { QaGroupBlockParser } from "./QaGroupBlockParser"; import { SemanticQaListParser } from "./SemanticQaListParser"; -import { resolveAnswerBoundary } from "./AnswerBoundaryParser"; interface HeadingMatch { level: number; @@ -18,9 +24,15 @@ interface HeadingMatch { lineIndex: number; } +interface ResolvedHeadingConfigMatch { + configId: CardTypeConfigId; + config: CardTypeConfig; +} + export interface CardIndexingContext { - qaHeadingLevel: number; - clozeHeadingLevel: number; + cardTypeConfigs?: CardTypeConfigs; + qaHeadingLevel?: number; + clozeHeadingLevel?: number; cardAnswerCutoffMode?: CardAnswerCutoffMode; qaGroupMarker?: string; semanticQaMarker?: string; @@ -34,6 +46,8 @@ export interface CardIndexingContext { } const HEADING_REGEXP = /^(#{1,6})\s+(.*?)\s*$/; +const CARD_TYPE_MATCH_ORDER: CardTypeConfigId[] = ["qa-group", "semantic-qa", "cloze", "basic"]; + export class CardIndexingService { constructor( private readonly markerService = new CardMarkerService(), @@ -43,7 +57,8 @@ export class CardIndexingService { ) {} index(sourceFile: SourceFile, context: CardIndexingContext): IndexedFile { - validateHeadingPolicy(context.qaHeadingLevel, context.clozeHeadingLevel); + const cardTypeConfigs = resolveCardTypeConfigs(context); + validateResolvedCardTypeConfigs(cardTypeConfigs); const lines = sourceFile.content.split(/\r?\n/); const lineStartOffsets = computeLineStartOffsets(sourceFile.content); @@ -62,16 +77,16 @@ export class CardIndexingService { for (let headingIndex = 0; headingIndex < headings.length; headingIndex += 1) { const heading = headings[headingIndex]; - const cardType = resolveCardType(heading.level, context.qaHeadingLevel, context.clozeHeadingLevel); - if (!cardType) { + const matchedConfig = resolveHeadingConfig(heading.text, heading.level, cardTypeConfigs); + if (!matchedConfig) { continue; } const blockEndLineIndex = findBlockEndLineIndex(headings, headingIndex, lines.length); const bodyLines = lines.slice(heading.lineIndex + 1, blockEndLineIndex); const cutoffMode = context.cardAnswerCutoffMode ?? "heading-block"; - const qaGroupMarker = context.qaGroupMarker ?? "#anki-list"; - if (cardType === "basic" && this.qaGroupBlockParser.isQaGroupHeading(heading.text, qaGroupMarker)) { + if (matchedConfig.configId === "qa-group") { + const qaGroupMarker = matchedConfig.config.extraMarker; const parsedGroupBlock = this.qaGroupBlockParser.parse({ parentHeadingText: heading.text, marker: qaGroupMarker, @@ -132,8 +147,8 @@ export class CardIndexingService { continue; } - const semanticQaMarker = context.semanticQaMarker ?? "#anki-list-qa"; - if (cardType === "basic" && this.semanticQaListParser.isSemanticQaHeading(heading.text, semanticQaMarker)) { + if (matchedConfig.configId === "semantic-qa") { + const semanticQaMarker = matchedConfig.config.extraMarker; for (const semanticCard of this.semanticQaListParser.parse({ parentHeadingText: heading.text, marker: semanticQaMarker, @@ -189,6 +204,8 @@ export class CardIndexingService { continue; } + const cardType = matchedConfig.configId === "cloze" ? "cloze" : "basic"; + const boundary = resolveAnswerBoundary({ lines: bodyLines, startLine: heading.lineIndex + 2, @@ -354,6 +371,76 @@ export class CardIndexingService { } } +function resolveCardTypeConfigs(context: CardIndexingContext): CardTypeConfigs { + if (context.cardTypeConfigs) { + return context.cardTypeConfigs; + } + + return { + basic: { + ...DEFAULT_SETTINGS.cardTypeConfigs.basic, + headingLevel: context.qaHeadingLevel ?? DEFAULT_SETTINGS.cardTypeConfigs.basic.headingLevel, + }, + "qa-group": { + ...DEFAULT_SETTINGS.cardTypeConfigs["qa-group"], + headingLevel: context.qaHeadingLevel ?? DEFAULT_SETTINGS.cardTypeConfigs["qa-group"].headingLevel, + extraMarker: context.qaGroupMarker ?? DEFAULT_SETTINGS.cardTypeConfigs["qa-group"].extraMarker, + }, + cloze: { + ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, + headingLevel: context.clozeHeadingLevel ?? DEFAULT_SETTINGS.cardTypeConfigs.cloze.headingLevel, + }, + "semantic-qa": { + ...DEFAULT_SETTINGS.cardTypeConfigs["semantic-qa"], + headingLevel: context.qaHeadingLevel ?? DEFAULT_SETTINGS.cardTypeConfigs["semantic-qa"].headingLevel, + extraMarker: context.semanticQaMarker ?? DEFAULT_SETTINGS.cardTypeConfigs["semantic-qa"].extraMarker, + }, + }; +} + +function validateResolvedCardTypeConfigs(cardTypeConfigs: CardTypeConfigs): void { + const enabledDefaultCountByHeading = new Map(); + + for (const config of Object.values(cardTypeConfigs)) { + if (!Number.isInteger(config.headingLevel) || config.headingLevel < 1 || config.headingLevel > 6) { + throw new Error("Card heading level must be an integer between 1 and 6."); + } + + if (!config.enabled || config.extraMarker.trim().length > 0) { + continue; + } + + enabledDefaultCountByHeading.set(config.headingLevel, (enabledDefaultCountByHeading.get(config.headingLevel) ?? 0) + 1); + } + + if ([...enabledDefaultCountByHeading.values()].some((count) => count > 1)) { + throw new Error("Each heading level can only have one enabled default card type."); + } +} + +function resolveHeadingConfig(headingText: string, level: number, cardTypeConfigs: CardTypeConfigs): ResolvedHeadingConfigMatch | null { + const candidateMatches = CARD_TYPE_MATCH_ORDER + .map((configId) => ({ configId, config: cardTypeConfigs[configId] })) + .filter(({ config }) => config.enabled && config.headingLevel === level); + + const markerMatches = candidateMatches + .filter(({ config }) => config.extraMarker.trim().length > 0 && headingText.trimEnd().endsWith(config.extraMarker.trim())) + .sort((left, right) => { + const markerLengthDifference = right.config.extraMarker.trim().length - left.config.extraMarker.trim().length; + if (markerLengthDifference !== 0) { + return markerLengthDifference; + } + + return CARD_TYPE_MATCH_ORDER.indexOf(left.configId) - CARD_TYPE_MATCH_ORDER.indexOf(right.configId); + }); + + if (markerMatches.length > 0) { + return markerMatches[0] ?? null; + } + + return candidateMatches.find(({ config }) => config.extraMarker.trim().length === 0) ?? null; +} + function groupKnownCardsByBlockKey(knownCards: CardState[]): Map { const grouped = new Map(); @@ -404,20 +491,6 @@ function resolveSourceFileTags(sourceFile: SourceFile, syncObsidianTagsToAnki: b return Array.isArray(sourceFile.tags) ? [...sourceFile.tags] : []; } -function validateHeadingPolicy(qaHeadingLevel: number, clozeHeadingLevel: number): void { - if (!Number.isInteger(qaHeadingLevel) || qaHeadingLevel < 1 || qaHeadingLevel > 6) { - throw new Error("QA heading level must be an integer between 1 and 6."); - } - - if (!Number.isInteger(clozeHeadingLevel) || clozeHeadingLevel < 1 || clozeHeadingLevel > 6) { - throw new Error("Cloze heading level must be an integer between 1 and 6."); - } - - if (qaHeadingLevel === clozeHeadingLevel) { - throw new Error("QA and Cloze heading levels must be different."); - } -} - function collectHeadings(lines: string[]): HeadingMatch[] { const headings: HeadingMatch[] = []; let fenceMarker: string | null = null; @@ -450,18 +523,6 @@ function collectHeadings(lines: string[]): HeadingMatch[] { return headings; } -function resolveCardType(level: number, qaHeadingLevel: number, clozeHeadingLevel: number): IndexedCard["cardType"] | null { - if (level === qaHeadingLevel) { - return "basic"; - } - - if (level === clozeHeadingLevel) { - return "cloze"; - } - - return null; -} - function findBlockEndLineIndex(headings: HeadingMatch[], currentHeadingIndex: number, totalLineCount: number): number { const currentHeading = headings[currentHeadingIndex]; diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts index beb0020..5f8e562 100644 --- a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts +++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts @@ -112,6 +112,14 @@ describe("DataJsonPluginConfigRepository", () => { cardAnswerCutoffMode: "double-blank-lines", semanticQaMarker: "#semantic-qa", semanticQaNoteType: "Semantic QA", + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + "semantic-qa": { + ...DEFAULT_SETTINGS.cardTypeConfigs["semantic-qa"], + extraMarker: "#semantic-qa", + noteType: "Semantic QA", + }, + }, noteFieldMappings: { "semantic-qa:Semantic QA": { cardType: "semantic-qa", diff --git a/src/presentation/i18n/messages/en.ts b/src/presentation/i18n/messages/en.ts index dcfd855..2da1760 100644 --- a/src/presentation/i18n/messages/en.ts +++ b/src/presentation/i18n/messages/en.ts @@ -212,6 +212,64 @@ export const en = { expandFolder: "Expand {{name}}", collapseFolder: "Collapse {{name}}", }, + cards: { + cardTypes: { + title: "Card types and basics", + desc: "Edit the recognition table directly. Changes save automatically and the table drives runtime recognition.", + advancedHint: "Advanced: keep AnkiConnect URL here without promoting it to a separate status card.", + markerPlaceholder: "/", + fieldsUnavailable: "-- Read fields first --", + autoManagedField: "Managed automatically", + autoComposedAnswer: "Auto composed", + qaGroupManagedNoteType: "{{modelName}} (managed automatically)", + loadedFieldsStatus: "Refreshed fields for {{count}} selected note types.", + failedLoad: "Failed to load note types or fields from Anki.", + failedSave: "Failed to save card type settings.", + loadAnki: { + button: "Read Anki note types and fields manually", + loading: "Loading Anki note types and fields...", + }, + columns: { + enabled: "Enabled", + type: "Card type", + headingLevel: "Heading marker", + extraMarker: "Extra marker", + noteType: "Anki note type", + questionField: "Question field", + answerField: "Answer field", + }, + rows: { + basic: "Q&A (regular paragraph)", + qaGroup: "Q&A (nested list)", + cloze: "Cloze", + semanticQa: "Semantic Q&A", + }, + }, + syncContent: { + title: "Sync content", + desc: "These options affect rendered content only. Updating them refreshes this card only.", + }, + scope: { + title: "Card sync scope", + desc: "Keep the existing scope modes and folder tree. The tree is loaded lazily and can be refreshed locally.", + refreshFolders: "Refresh folder list", + }, + deck: { + title: "Deck and deck rules", + desc: "Keep the current deck behavior, but refresh only this card when file-level deck mode changes.", + }, + commands: { + title: "Command guide", + desc: "These are the current commands exposed in the command palette.", + items: { + syncCurrentFile: "Sync only the active Markdown file to Anki.", + syncVault: "Sync all in-scope Markdown files in the vault to Anki.", + rebuildIndex: "Rebuild the local card index without creating or updating notes.", + clearCurrentFile: "Clear synced card markers and tracked sync state for the active file.", + cleanupDecks: "Delete selected empty decks from Anki.", + }, + }, + }, }, modal: { emptyDeck: { @@ -268,6 +326,10 @@ export const en = { settings: { headingLevelsRange: "Heading levels must be integers between 1 and 6.", headingLevelsDifferent: "QA and Cloze heading levels must be different.", + cardTypeConfigsObject: "Card type configs must be an object.", + cardTypeEnabledBoolean: "Each card type enabled flag must be a boolean.", + cardTypeExtraMarkerString: "Each card type extra marker must be a string.", + cardTypeDefaultConflict: "Only one enabled default card type is allowed at heading H{{headingLevel}}.", cardAnswerCutoffModeInvalid: "Card answer cutoff mode must be heading-block or double-blank-lines.", qaNoteTypeRequired: "QA note type is required.", qaGroupMarkerRequired: "QA Group marker is required.", diff --git a/src/presentation/i18n/messages/zh.ts b/src/presentation/i18n/messages/zh.ts index f3ec595..35fee9a 100644 --- a/src/presentation/i18n/messages/zh.ts +++ b/src/presentation/i18n/messages/zh.ts @@ -210,6 +210,64 @@ export const zh = { expandFolder: "展开 {{name}}", collapseFolder: "收起 {{name}}", }, + cards: { + cardTypes: { + title: "卡片类型及基础设置", + desc: "直接编辑识别表格。修改后自动保存,运行时识别也以这张表为准。", + advancedHint: "高级项:将 AnkiConnect URL 保留在这里,不单独做连接状态卡片。", + markerPlaceholder: "/", + fieldsUnavailable: "-- 请先读取字段 --", + autoManagedField: "自动维护", + autoComposedAnswer: "自动拼接", + qaGroupManagedNoteType: "{{modelName}}(自动维护)", + loadedFieldsStatus: "已刷新 {{count}} 个当前所选模板的字段。", + failedLoad: "从 Anki 读取模板或字段失败。", + failedSave: "保存卡片类型设置失败。", + loadAnki: { + button: "手动读取 Anki 里的模板配置", + loading: "正在读取 Anki 模板和字段...", + }, + columns: { + enabled: "启用", + type: "卡片类型", + headingLevel: "卡片标记", + extraMarker: "额外标记", + noteType: "Anki 笔记模板", + questionField: "问题字段", + answerField: "答案字段", + }, + rows: { + basic: "问答题(常规段落形式)", + qaGroup: "问答题(多级列表形式)", + cloze: "填空题", + semanticQa: "语义问答题", + }, + }, + syncContent: { + title: "同步内容", + desc: "这些选项只影响卡片渲染内容,修改后仅刷新这一张卡片。", + }, + scope: { + title: "卡片同步范围", + desc: "保留现有作用范围和文件夹树逻辑;文件夹树按需加载,并支持局部刷新。", + refreshFolders: "刷新文件夹列表", + }, + deck: { + title: "Deck 与牌组规则", + desc: "保留现有 deck 行为;切换文件级 deck 开关时只刷新这一张卡片。", + }, + commands: { + title: "命令说明", + desc: "下面展示当前命令面板中可用的插件命令。", + items: { + syncCurrentFile: "只把当前活动 Markdown 文件同步到 Anki。", + syncVault: "把全库中所有处于作用范围内的 Markdown 文件同步到 Anki。", + rebuildIndex: "只重建本地卡片索引,不创建或更新远端笔记。", + clearCurrentFile: "清空当前文件的已同步标记和本地跟踪状态。", + cleanupDecks: "从 Anki 删除所选空牌组。", + }, + }, + }, }, modal: { emptyDeck: { @@ -266,6 +324,10 @@ export const zh = { settings: { headingLevelsRange: "标题层级必须是 1 到 6 之间的整数。", headingLevelsDifferent: "QA 和 Cloze 的标题层级必须不同。", + cardTypeConfigsObject: "卡片类型配置必须是对象。", + cardTypeEnabledBoolean: "每种卡片类型的启用状态必须是布尔值。", + cardTypeExtraMarkerString: "每种卡片类型的额外标记必须是字符串。", + cardTypeDefaultConflict: "同一个 H{{headingLevel}} 只能有一个启用的默认卡片类型。", cardAnswerCutoffModeInvalid: "卡片正文截止模式只能是 heading-block 或 double-blank-lines。", qaNoteTypeRequired: "QA 笔记类型不能为空。", qaGroupMarkerRequired: "QA Group 标记不能为空。", diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index bb57c40..11b66f4 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -1,42 +1,44 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels"; import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping"; -import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings"; -import { QA_GROUP_MODEL_NAME } from "@/application/services/QaGroupModelDefinition"; +import { DEFAULT_SETTINGS, normalizePluginSettings } from "@/application/config/PluginSettings"; const { - FakeButtonComponent, - FakeDropdownComponent, FakeElement, - getLanguageMock, FakePluginSettingTab, FakeSetting, + getLanguageMock, } = vi.hoisted(() => { - const hoistedGetLanguage = vi.fn(() => "en"); + const hoistedGetLanguage = vi.fn(() => "zh"); class HoistedFakeElement { public readonly children: HoistedFakeElement[] = []; public readonly dataset: Record = {}; public readonly style: Record = {}; + public readonly ownedSettings: HoistedFakeSetting[] = []; public checked = false; public indeterminate = false; + public disabled = false; public type = ""; public value = ""; public text = ""; public textContent = ""; + public scrollTop = 0; private readonly listeners = new Map void | Promise>>(); constructor( public readonly root: HoistedFakeContainerEl, public readonly tag: string, + public readonly parent: HoistedFakeElement | null = null, ) {} createEl(tag: string, options?: { text?: string }): HoistedFakeElement { - const child = new HoistedFakeElement(this.root, tag); + const child = new HoistedFakeElement(this.root, tag, this); if (options?.text) { child.text = options.text; - this.root.textNodes.push(options.text); + child.textContent = options.text; } this.children.push(child); @@ -59,25 +61,43 @@ const { } } + empty(): void { + this.removeOwnedSettingsRecursively(); + this.children.length = 0; + this.text = ""; + this.textContent = ""; + } + setAttr(name: string, value: string | number): this { (this as Record)[name] = value; return this; } + + private removeOwnedSettingsRecursively(): void { + if (this.ownedSettings.length > 0) { + this.root.settings = this.root.settings.filter((setting) => !this.ownedSettings.includes(setting)); + this.ownedSettings.length = 0; + } + + for (const child of this.children) { + child.removeOwnedSettingsRecursively(); + } + } } class HoistedFakeContainerEl extends HoistedFakeElement { public settings: HoistedFakeSetting[] = []; - public textNodes: string[] = []; + public emptyCallCount = 0; constructor() { - super(undefined as never, "root"); + super(undefined as never, "root", null); (this as { root: HoistedFakeContainerEl }).root = this; } - empty(): void { + override empty(): void { + this.emptyCallCount += 1; + super.empty(); this.settings = []; - this.textNodes = []; - this.children.length = 0; } } @@ -178,8 +198,9 @@ const { HoistedFakeButtonComponent | HoistedFakeDropdownComponent | HoistedFakeTextComponent | HoistedFakeToggleComponent > = []; - constructor(containerEl: HoistedFakeContainerEl) { - containerEl.settings.push(this); + constructor(containerEl: HoistedFakeElement) { + containerEl.root.settings.push(this); + containerEl.ownedSettings.push(this); } setName(name: string): this { @@ -213,13 +234,6 @@ const { return this; } - addTextArea(callback: (text: HoistedFakeTextComponent) => void): this { - const text = new HoistedFakeTextComponent(); - this.controls.push(text); - callback(text); - return this; - } - addToggle(callback: (toggle: HoistedFakeToggleComponent) => void): this { const toggle = new HoistedFakeToggleComponent(); this.controls.push(toggle); @@ -245,9 +259,9 @@ const { FakeButtonComponent: HoistedFakeButtonComponent, FakeDropdownComponent: HoistedFakeDropdownComponent, FakeElement: HoistedFakeElement, - getLanguageMock: hoistedGetLanguage, FakePluginSettingTab: HoistedFakePluginSettingTab, FakeSetting: HoistedFakeSetting, + getLanguageMock: hoistedGetLanguage, }; }); @@ -259,29 +273,34 @@ vi.mock("obsidian", () => ({ import { AnkiHeadingSyncSettingTab } from "./PluginSettingTab"; -type FakeContainerElInstance = InstanceType["containerEl"]; +type FakeContainer = InstanceType["containerEl"]; type FakeSettingInstance = InstanceType; -type FakeButtonComponentInstance = InstanceType; -type FakeDropdownComponentInstance = InstanceType; -type FakeElementInstance = InstanceType; -type FakeTextComponentInstance = { - value: string; - triggerChange(value: string): Promise; -}; -type FakeToggleComponentInstance = { - value: boolean; - triggerChange(value: boolean): Promise; -}; +type FakeToggleInstance = { triggerChange(value: boolean): Promise }; +type QueryRoot = FakeContainer | HTMLElement; class FakePlugin { public readonly app = {}; + public readonly updateCalls: Array> = []; + public listNoteModelsCalls = 0; public listFolderTreeCalls = 0; public insertDeckTemplateCalls = 0; - public settings = { + public settings = normalizePluginSettings({ ...DEFAULT_SETTINGS, qaNoteType: "Custom Basic", + clozeNoteType: "Custom Cloze", + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + basic: { + ...DEFAULT_SETTINGS.cardTypeConfigs.basic, + noteType: "Custom Basic", + }, + cloze: { + ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, + noteType: "Custom Cloze", + }, + }, noteFieldMappings: {}, - }; + }); public folderTree = [ { path: "notes", @@ -299,28 +318,27 @@ class FakePlugin { }, ], }, - { - path: "empty", - name: "empty", - children: [], - }, ]; async updateSettings(partialSettings: Record): Promise { - this.settings = { + this.updateCalls.push(partialSettings); + this.settings = normalizePluginSettings({ ...this.settings, ...partialSettings, - }; + cardTypeConfigs: (partialSettings.cardTypeConfigs as typeof this.settings.cardTypeConfigs | undefined) ?? this.settings.cardTypeConfigs, + noteFieldMappings: (partialSettings.noteFieldMappings as typeof this.settings.noteFieldMappings | undefined) ?? this.settings.noteFieldMappings, + }); } async listNoteModels(): Promise { - return ["Basic", "Cloze", "Custom Basic", "Custom Cloze", "Semantic QA", QA_GROUP_MODEL_NAME]; + this.listNoteModelsCalls += 1; + return ["Basic", "Custom Basic", "Cloze", "Custom Cloze", "Semantic QA", QA_GROUP_MODEL_NAME]; } - async getNoteModelDetails(modelName: string) { - if (modelName === "Custom Cloze") { + async getNoteModelDetails(modelName: string): Promise<{ fieldNames: string[]; isCloze: boolean }> { + if (modelName.includes("Cloze")) { return { - fieldNames: ["Text", "Extra", "Context"], + fieldNames: ["Text", "Extra", "Hint"], isCloze: true, }; } @@ -338,7 +356,7 @@ class FakePlugin { }; } - async listFolderTree() { + async listFolderTree(): Promise { this.listFolderTreeCalls += 1; return this.folderTree; } @@ -348,739 +366,275 @@ class FakePlugin { } } -function findSetting(containerEl: FakeContainerElInstance, name: string): FakeSettingInstance { - const setting = containerEl.settings.find((candidate: FakeSettingInstance) => candidate.name === name); - - if (!setting) { - throw new Error(`Setting not found: ${name}`); - } - - return setting; +function asFakeContainer(container: QueryRoot): FakeContainer { + return container as unknown as FakeContainer; } -function querySetting(containerEl: FakeContainerElInstance, name: string): FakeSettingInstance | undefined { - return containerEl.settings.find((candidate: FakeSettingInstance) => candidate.name === name); -} - -function getButton(setting: FakeSettingInstance): FakeButtonComponentInstance { - const button = setting.controls.find((control: unknown) => control instanceof FakeButtonComponent); - - if (!button || !(button instanceof FakeButtonComponent)) { - throw new Error(`Button not found for setting: ${setting.name}`); - } - - return button; -} - -function getDropdown(setting: FakeSettingInstance): FakeDropdownComponentInstance { - const dropdown = setting.controls.find((control: unknown) => control instanceof FakeDropdownComponent); - - if (!dropdown || !(dropdown instanceof FakeDropdownComponent)) { - throw new Error(`Dropdown not found for setting: ${setting.name}`); - } - - return dropdown; -} - -function getText(setting: FakeSettingInstance): FakeTextComponentInstance { - const text = setting.controls.find((control: unknown) => typeof control === "object" && control !== null && "triggerChange" in (control as Record) && "value" in (control as Record) && !(control instanceof FakeDropdownComponent) && !(control instanceof FakeButtonComponent)); - - if (!text) { - throw new Error(`Text control not found for setting: ${setting.name}`); - } - - return text as FakeTextComponentInstance; -} - -function getToggle(setting: FakeSettingInstance): FakeToggleComponentInstance { - const toggle = setting.controls.find((control: unknown) => typeof control === "object" && control !== null && "triggerChange" in (control as Record) && "value" in (control as Record) && !(control instanceof FakeDropdownComponent) && !(control instanceof FakeButtonComponent) && typeof (control as { value?: unknown }).value === "boolean"); - - if (!toggle) { - throw new Error(`Toggle not found for setting: ${setting.name}`); - } - - return toggle as FakeToggleComponentInstance; -} - -function queryCheckboxByPath(containerEl: FakeContainerElInstance, folderPath: string): FakeElementInstance | undefined { - return findElement(containerEl, (element) => element.tag === "input" && element.dataset.folderPath === folderPath); -} - -function getCheckboxByPath(containerEl: FakeContainerElInstance, folderPath: string): FakeElementInstance { - const checkbox = queryCheckboxByPath(containerEl, folderPath); - if (!checkbox) { - throw new Error(`Checkbox not found for folder path: ${folderPath}`); - } - - return checkbox; -} - -function queryFolderToggle(containerEl: FakeContainerElInstance, folderPath: string): FakeElementInstance | undefined { - return findElement(containerEl, (element) => element.dataset.folderToggle === folderPath); -} - -function getFolderToggle(containerEl: FakeContainerElInstance, folderPath: string): FakeElementInstance { - const toggle = queryFolderToggle(containerEl, folderPath); - if (!toggle) { - throw new Error(`Toggle not found for folder path: ${folderPath}`); - } - - return toggle; -} - -function getFolderRow(containerEl: FakeContainerElInstance, folderPath: string): FakeElementInstance { - const row = findElement(containerEl, (element) => element.dataset.folderRow === folderPath); - if (!row) { - throw new Error(`Row not found for folder path: ${folderPath}`); - } - - return row; -} - -function findElement( - root: FakeElementInstance, - predicate: (element: FakeElementInstance) => boolean, -): FakeElementInstance | undefined { - if (predicate(root)) { - return root; - } - - for (const child of root.children) { - if (!(child instanceof FakeElement)) { +function findElement(container: QueryRoot, predicate: (element: InstanceType) => boolean): InstanceType | undefined { + const stack = [...asFakeContainer(container).children]; + while (stack.length > 0) { + const current = stack.shift(); + if (!current) { continue; } - const match = findElement(child as FakeElementInstance, predicate); - if (match) { - return match; + if (predicate(current)) { + return current; } + + stack.unshift(...current.children); } return undefined; } -async function flushAsync(): Promise { +function findElements(container: QueryRoot, predicate: (element: InstanceType) => boolean): InstanceType[] { + const matches: InstanceType[] = []; + const stack = [...asFakeContainer(container).children]; + while (stack.length > 0) { + const current = stack.shift(); + if (!current) { + continue; + } + + if (predicate(current)) { + matches.push(current); + } + + stack.unshift(...current.children); + } + + return matches; +} + +function queryByDataset(container: QueryRoot, key: string, value: string): InstanceType { + const element = findElement(container, (candidate) => candidate.dataset[key] === value); + if (!element) { + throw new Error(`Element not found for data-${key}=${value}`); + } + return element; +} + +function queryAllByDataset(container: QueryRoot, key: string): InstanceType[] { + return findElements(container, (candidate) => key in candidate.dataset); +} + +function findSetting(container: QueryRoot, name: string): FakeSettingInstance { + const setting = asFakeContainer(container).settings.find((candidate) => candidate.name === name); + if (!setting) { + throw new Error(`Setting not found: ${name}`); + } + return setting; +} + +function getToggle(setting: FakeSettingInstance): FakeToggleInstance { + const toggle = setting.controls.find((control) => typeof control === "object" && control !== null && "triggerChange" in control && "value" in control && typeof (control as { value?: unknown }).value === "boolean"); + if (!toggle) { + throw new Error(`Toggle not found for setting: ${setting.name}`); + } + return toggle as FakeToggleInstance; +} + +function collectTexts(container: QueryRoot): string[] { + return findElements(container, () => true) + .map((element) => element.textContent || element.text) + .filter((text): text is string => Boolean(text)); +} + +function getEmptyCallCount(container: QueryRoot): number { + return asFakeContainer(container).emptyCallCount; +} + +async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); } -describe("AnkiHeadingSyncSettingTab", () => { +describe("PluginSettingTab", () => { beforeEach(() => { - vi.clearAllMocks(); - getLanguageMock.mockReset(); - getLanguageMock.mockReturnValue("en"); - }); - - it("renders key settings labels, descriptions, and aria text in English", async () => { - const plugin = new FakePlugin(); - plugin.settings = { - ...plugin.settings, - scopeMode: "include", - }; - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - expect(container.textNodes).toContain("Anki Heading Sync"); - expect(findSetting(container, "AnkiConnect URL").desc).toBe("Default is http://127.0.0.1:8765"); - expect(findSetting(container, "Card answer cutoff mode").desc).toBe("A valid sync marker always wins. Without a valid marker, either keep the whole heading block or stop before the first 2+ consecutive blank lines."); - expect(findSetting(container, "Obsidian backlink label").desc).toBe("The text shown for the backlink. Blank input falls back to Open in Obsidian."); - expect(findSetting(container, "Obsidian backlink placement").desc).toBe("Choose whether the backlink is appended to the question field or placed at the start or end of the answer body."); - expect(findSetting(container, "Sync Obsidian tags to Anki").desc).toBe("Sync Obsidian-recognized tags from the current note into Anki note tags, including nested tags. On each sync, these tags overwrite the managed tags in Anki based on the current Obsidian content."); - expect(findSetting(container, "Keep pure tag lines in card body").desc).toBe("When turned off, remove every line in the card body that contains only tags, such as \"#ProjectA #重点/案例\". Inline tags like \"This is a #tag example\" and lines with ordinary text like \"标签:#项目A\" are kept. Extra blank lines created by removal are cleaned up automatically."); - expect(findSetting(container, "Run scope").desc).toBe("Only process Markdown files in the checked folders below"); - - await flushAsync(); - tab.display(); - - const toggle = getFolderToggle(container, "notes"); - expect((toggle as unknown as { [key: string]: string })["aria-label"]).toBe("Expand notes"); - - await toggle.trigger("click"); - expect((getFolderToggle(container, "notes") as unknown as { [key: string]: string })["aria-label"]).toBe("Collapse notes"); - }); - - it("renders key settings labels, descriptions, and aria text in Simplified Chinese", async () => { getLanguageMock.mockReturnValue("zh"); - const plugin = new FakePlugin(); - plugin.settings = { - ...plugin.settings, - scopeMode: "include", - }; - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - expect(findSetting(container, "QA 标题层级").desc).toBe("默认是 H4"); - expect(findSetting(container, "卡片正文截止模式").desc).toBe("有效同步标记始终优先。没有有效标记时,选择继续到整个标题块末尾,或在首个 2+ 连续空行前截止。"); - expect(findSetting(container, "Obsidian 回链显示名称").desc).toBe("设置回链显示的文字。留空或只填空格时会回退为 Open in Obsidian。"); - expect(findSetting(container, "Obsidian 回链放置位置").desc).toBe("选择把回链追加到问题栏最后一行,或放到答案正文的第一行 / 最后一行。"); - expect(findSetting(container, "同步 Obsidian 标签到 Anki").desc).toBe("将当前笔记中 Obsidian 识别到的标签同步为 Anki 笔记标签,支持嵌套标签。每次同步时,这些标签都会以当前 Obsidian 内容为准覆盖 Anki。"); - expect(findSetting(container, "在卡片正文中保留纯标签行").desc).toBe("关闭后,会删除正文中所有只包含标签的整行,例如 “#项目A #重点/案例”。像“这是 #标签 的案例”这类行内标签,或“标签:#项目A”这类带普通文字的行,不会被删除。删除后会自动清理多余空行。"); - expect(findSetting(container, "运行范围").desc).toBe("仅处理下方勾选文件夹中的 Markdown 文件"); - expect(findSetting(container, "默认牌组").desc).toBe("优先级最低:当文件级 deck 与文件夹映射都未命中时使用。"); - - await flushAsync(); - tab.display(); - - const toggle = getFolderToggle(container, "notes"); - expect((toggle as unknown as { [key: string]: string })["aria-label"]).toBe("展开 notes"); - - await toggle.trigger("click"); - expect((getFolderToggle(container, "notes") as unknown as { [key: string]: string })["aria-label"]).toBe("收起 notes"); + vi.useRealTimers(); }); - it("refreshes note type list from Anki into the dropdowns", async () => { - const plugin = new FakePlugin(); - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - await getButton(findSetting(container, "Refresh note types from Anki")).click(); - - const refreshSetting = findSetting(container, "Refresh note types from Anki"); - const dropdown = getDropdown(findSetting(container, "QA / Basic note type")); - expect(dropdown.options.map((option: { value: string }) => option.value)).toEqual(["Basic", "Cloze", "Custom Basic", "Custom Cloze", QA_GROUP_MODEL_NAME, "Semantic QA"]); - expect(refreshSetting.desc).toBe("Loaded 6 note types from Anki."); + afterEach(() => { + vi.useRealTimers(); }); - it("uses the same total note type count for status text and dropdown options", async () => { + it("renders five cards with the required default expansion state", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; tab.display(); - await getButton(findSetting(container, "Refresh note types from Anki")).click(); - const refreshSetting = findSetting(container, "Refresh note types from Anki"); - const basicDropdown = getDropdown(findSetting(container, "QA / Basic note type")); - const clozeDropdown = getDropdown(findSetting(container, "Cloze note type")); - const semanticDropdown = getDropdown(findSetting(container, "Semantic QA note type")); - - expect(refreshSetting.desc).toBe(`Loaded ${basicDropdown.options.length} note types from Anki.`); - expect(clozeDropdown.options).toHaveLength(basicDropdown.options.length); - expect(semanticDropdown.options).toHaveLength(basicDropdown.options.length); + const cards = queryAllByDataset(tab.containerEl, "settingsCard"); + expect(cards).toHaveLength(5); + expect(queryByDataset(tab.containerEl, "settingsCardBody", "card-types").style.display).toBe("block"); + expect(queryByDataset(tab.containerEl, "settingsCardBody", "commands").style.display).toBe("block"); + expect(queryByDataset(tab.containerEl, "settingsCardBody", "sync-content").style.display).toBe("none"); + expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none"); + expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none"); }); - it("shows QA Group model status and includes it in the field mapping dropdowns", async () => { + it("renders card 1 table with four rows and the required columns", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; tab.display(); - expect(container.textNodes).toContain(`Managed note type: ${QA_GROUP_MODEL_NAME}. QA Group sync writes Stem / GroupId / Src / S01..S12 directly. The same note type can also appear in the field mapping panels below if you want to reuse it for other routes.`); - expect(container.textNodes).toContain("Managed model contract: 39 fields and 12 templates are checked automatically during sync."); + const headerTexts = findElements(tab.containerEl, (element) => element.tag === "th").map((element) => element.textContent); + expect(headerTexts).toEqual(["启用", "卡片类型", "卡片标记", "额外标记", "Anki 笔记模板", "问题字段", "答案字段"]); - await getButton(findSetting(container, "Refresh note types from Anki")).click(); - - const semanticDropdown = getDropdown(findSetting(container, "Semantic QA note type")); - expect(semanticDropdown.options.map((option: { value: string }) => option.value)).toContain(QA_GROUP_MODEL_NAME); - }); - - it("allows selecting the QA Group model inside semantic QA mapping controls", async () => { - const plugin = new FakePlugin(); - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - await getButton(findSetting(container, "Refresh note types from Anki")).click(); - await getDropdown(findSetting(container, "Semantic QA note type")).triggerChange(QA_GROUP_MODEL_NAME); - - expect(plugin.settings.semanticQaNoteType).toBe(QA_GROUP_MODEL_NAME); - expect(container.textNodes).toContain("Selected ObsiAnki QA Group 12. Read fields from Anki to create or refresh its mapping."); - }); - - it("loads fields, applies suggestions, and saves a user-adjusted basic mapping", async () => { - const plugin = new FakePlugin(); - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - await getButton(findSetting(container, "Refresh note types from Anki")).click(); - await getButton(findSetting(container, "QA / Basic fields")).click(); - - const titleDropdown = getDropdown(findSetting(container, "QA / Basic title field")); - const bodyDropdown = getDropdown(findSetting(container, "QA / Basic body field")); - - expect(titleDropdown.value).toBe("Title"); - expect(bodyDropdown.value).toBe("Body"); - - await bodyDropdown.triggerChange("Hint"); - await getButton(findSetting(container, "QA / Basic mapping")).click(); - - expect(plugin.settings.noteFieldMappings).toEqual({ - [createNoteFieldMappingKey("basic", "Custom Basic")]: { - cardType: "basic", - modelName: "Custom Basic", - loadedFieldNames: ["Title", "Body", "Hint"], - titleField: "Title", - bodyField: "Hint", - loadedAt: expect.any(Number), - }, - }); - expect(container.textNodes).toContain("Saved mapping for Custom Basic."); - }); - - it("loads cloze fields and suggests the main field", async () => { - const plugin = new FakePlugin(); - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - await getButton(findSetting(container, "Refresh note types from Anki")).click(); - await getDropdown(findSetting(container, "Cloze note type")).triggerChange("Custom Cloze"); - await getButton(findSetting(container, "Cloze fields")).click(); - - const mainFieldDropdown = getDropdown(findSetting(container, "Cloze main field")); - expect(mainFieldDropdown.value).toBe("Text"); - }); - - it("shows semantic QA preview and saves a semantic QA mapping", async () => { - const plugin = new FakePlugin(); - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - - expect(container.textNodes).toContain("Trigger heading example: 城市更新 #anki-list-qa"); - expect(container.textNodes).toContain("Question preview: 城市更新
核心产品"); - expect(container.textNodes).toContain("Answer preview: 百人会、城市更新研习社、城市更新创投营。"); - - await getText(findSetting(container, "Semantic QA marker")).triggerChange("#semantic-qa"); - await getButton(findSetting(container, "Refresh note types from Anki")).click(); - await getButton(findSetting(container, "Semantic QA fields")).click(); - - const titleDropdown = getDropdown(findSetting(container, "Semantic QA title field")); - const bodyDropdown = getDropdown(findSetting(container, "Semantic QA body field")); - - expect(titleDropdown.value).toBe("Title"); - expect(bodyDropdown.value).toBe("Body"); - - await bodyDropdown.triggerChange("Source"); - await getButton(findSetting(container, "Semantic QA mapping")).click(); - - expect(plugin.settings.semanticQaMarker).toBe("#semantic-qa"); - expect(plugin.settings.noteFieldMappings).toEqual(expect.objectContaining({ - [createNoteFieldMappingKey("semantic-qa", "Semantic QA")]: { - cardType: "semantic-qa", - modelName: "Semantic QA", - loadedFieldNames: ["Title", "Body", "Source"], - titleField: "Title", - bodyField: "Source", - loadedAt: expect.any(Number), - }, - })); - expect(container.textNodes).toContain("Saved mapping for Semantic QA."); - expect(container.textNodes).toContain("Trigger heading example: 城市更新 #semantic-qa"); - }); - - it("saves the QA Group marker independently from the semantic QA marker", async () => { - const plugin = new FakePlugin(); - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - await getText(findSetting(container, "QA Group marker")).triggerChange("#anki-list-12"); - - expect(plugin.settings.qaGroupMarker).toBe("#anki-list-12"); - expect(plugin.settings.semanticQaMarker).toBe("#anki-list-qa"); - }); - - it("saves and rehydrates the card answer cutoff mode", async () => { - const plugin = new FakePlugin(); - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - - const cutoffDropdown = getDropdown(findSetting(container, "Card answer cutoff mode")); - expect(cutoffDropdown.value).toBe("heading-block"); - expect(cutoffDropdown.options.map((option: { value: string }) => option.value)).toEqual(["heading-block", "double-blank-lines"]); - - await cutoffDropdown.triggerChange("double-blank-lines"); - expect(plugin.settings.cardAnswerCutoffMode).toBe("double-blank-lines"); - - tab.display(); - expect(getDropdown(findSetting(container, "Card answer cutoff mode")).value).toBe("double-blank-lines"); - }); - - it("saves and rehydrates the new sync option toggles", async () => { - const plugin = new FakePlugin(); - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - - const syncTagsToggle = getToggle(findSetting(container, "Sync Obsidian tags to Anki")); - const keepPureTagLinesToggle = getToggle(findSetting(container, "Keep pure tag lines in card body")); - - expect(syncTagsToggle.value).toBe(true); - expect(keepPureTagLinesToggle.value).toBe(true); - - await syncTagsToggle.triggerChange(false); - await keepPureTagLinesToggle.triggerChange(false); - - expect(plugin.settings.syncObsidianTagsToAnki).toBe(false); - expect(plugin.settings.keepPureTagLinesInCardBody).toBe(false); - - tab.display(); - - expect(getToggle(findSetting(container, "Sync Obsidian tags to Anki")).value).toBe(false); - expect(getToggle(findSetting(container, "Keep pure tag lines in card body")).value).toBe(false); - }); - - it("trims the backlink label on save and falls back to the default label when blank", async () => { - const plugin = new FakePlugin(); - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - - const labelInput = getText(findSetting(container, "Obsidian backlink label")); - expect(labelInput.value).toBe("Open in Obsidian"); - - await labelInput.triggerChange(" Open note "); - expect(plugin.settings.obsidianBacklinkLabel).toBe("Open note"); - - await labelInput.triggerChange(" "); - expect(plugin.settings.obsidianBacklinkLabel).toBe("Open in Obsidian"); - }); - - it("saves and rehydrates the backlink placement dropdown", async () => { - const plugin = new FakePlugin(); - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - - const placementDropdown = getDropdown(findSetting(container, "Obsidian backlink placement")); - expect(placementDropdown.value).toBe("answer-last-line"); - expect(placementDropdown.options.map((option: { value: string }) => option.value)).toEqual([ - "question-last-line", - "answer-first-line", - "answer-last-line", + const rowLabels = findElements(tab.containerEl, (element) => element.dataset.cardTypeConfig !== undefined).map((row) => row.children[1]?.textContent); + expect(rowLabels).toEqual([ + "问答题(常规段落形式)", + "问答题(多级列表形式)", + "填空题", + "语义问答题", ]); - - await placementDropdown.triggerChange("question-last-line"); - expect(plugin.settings.obsidianBacklinkPlacement).toBe("question-last-line"); - - tab.display(); - expect(getDropdown(findSetting(container, "Obsidian backlink placement")).value).toBe("question-last-line"); }); - it("removes the old folder textareas and hides the folder tree in all mode", async () => { + it("auto saves toggle, heading, note type and field mapping edits", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; tab.display(); - await flushAsync(); + await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); + await flushPromises(); - expect(querySetting(container, "Include folders")).toBeUndefined(); - expect(querySetting(container, "Exclude folders")).toBeUndefined(); - expect(findSetting(container, "Run scope")).toBeDefined(); - expect(queryCheckboxByPath(container, "notes")).toBeUndefined(); + const basicToggle = queryByDataset(tab.containerEl, "cardTypeEnabled", "basic"); + basicToggle.checked = false; + await basicToggle.trigger("change"); + expect(plugin.settings.cardTypeConfigs.basic.enabled).toBe(false); + + const clozeHeading = queryByDataset(tab.containerEl, "cardTypeHeading", "cloze"); + clozeHeading.value = "6"; + await clozeHeading.trigger("change"); + expect(plugin.settings.cardTypeConfigs.cloze.headingLevel).toBe(6); + + const basicNoteType = queryByDataset(tab.containerEl, "cardTypeNoteType", "basic"); + basicNoteType.value = "Basic"; + await basicNoteType.trigger("change"); + expect(plugin.settings.cardTypeConfigs.basic.noteType).toBe("Basic"); + + await flushPromises(); + const basicQuestionField = queryByDataset(tab.containerEl, "cardTypeQuestionField", "basic"); + basicQuestionField.value = "Hint"; + await basicQuestionField.trigger("change"); + const basicAnswerField = queryByDataset(tab.containerEl, "cardTypeAnswerField", "basic"); + basicAnswerField.value = "Body"; + await basicAnswerField.trigger("change"); + + expect(plugin.settings.noteFieldMappings[createNoteFieldMappingKey("basic", plugin.settings.cardTypeConfigs.basic.noteType)]).toEqual(expect.objectContaining({ + titleField: "Hint", + bodyField: "Body", + })); }); - it("loads the current folder tree when the settings page first opens", async () => { + it("debounces text input saves instead of saving every keystroke", async () => { + vi.useFakeTimers(); const plugin = new FakePlugin(); - plugin.settings = { + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + + tab.display(); + + const markerInput = queryByDataset(tab.containerEl, "cardTypeMarker", "semantic-qa"); + markerInput.value = "#semantic-a"; + await markerInput.trigger("input"); + markerInput.value = "#semantic-ab"; + await markerInput.trigger("input"); + + expect(plugin.updateCalls).toHaveLength(0); + + vi.advanceTimersByTime(499); + await flushPromises(); + expect(plugin.updateCalls).toHaveLength(0); + + vi.advanceTimersByTime(1); + await flushPromises(); + expect(plugin.settings.cardTypeConfigs["semantic-qa"].extraMarker).toBe("#semantic-ab"); + expect(plugin.updateCalls).toHaveLength(1); + }); + + it("blocks conflicting default rows and shows the error in card 1", async () => { + const plugin = new FakePlugin(); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + + tab.display(); + + const clozeHeading = queryByDataset(tab.containerEl, "cardTypeHeading", "cloze"); + clozeHeading.value = "4"; + await clozeHeading.trigger("change"); + + expect(plugin.settings.cardTypeConfigs.cloze.headingLevel).toBe(5); + expect(collectTexts(tab.containerEl).some((text) => text.includes("同一个 H4 只能有一个启用的默认卡片类型"))).toBe(true); + }); + + it("expanding and collapsing cards does not rebuild the whole settings page", async () => { + const plugin = new FakePlugin(); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + + tab.display(); + const initialEmptyCount = getEmptyCallCount(tab.containerEl); + + await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); + expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); + + await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); + expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); + }); + + it("loading Anki config refreshes only card 1 instead of rebuilding the whole page", async () => { + const plugin = new FakePlugin(); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + + tab.display(); + const initialEmptyCount = getEmptyCallCount(tab.containerEl); + + await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); + await flushPromises(); + + expect(plugin.listNoteModelsCalls).toBe(1); + expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); + }); + + it("folder tree expand and check refresh only the scope card", async () => { + const plugin = new FakePlugin(); + plugin.settings = normalizePluginSettings({ ...plugin.settings, scopeMode: "include", - }; + }); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); - await flushAsync(); - tab.display(); + const initialEmptyCount = getEmptyCallCount(tab.containerEl); + await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); + await flushPromises(); expect(plugin.listFolderTreeCalls).toBe(1); - expect(getCheckboxByPath(tab.containerEl as unknown as FakeContainerElInstance, "notes")).toBeDefined(); - }); + expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); - it("shows the folder tree as a collapsed hierarchy and reveals children after expanding a parent", async () => { - const plugin = new FakePlugin(); - plugin.settings = { - ...plugin.settings, - scopeMode: "include", - includeFolders: ["notes/sub"], - }; - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; + await queryByDataset(tab.containerEl, "folderToggle", "notes").trigger("click"); + expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); - tab.display(); - await flushAsync(); - tab.display(); - - const parentCheckbox = getCheckboxByPath(container, "notes"); - - 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"); - - const childCheckbox = getCheckboxByPath(container, "notes/sub"); - const parentRow = getFolderRow(container, "notes"); - const childRow = getFolderRow(container, "notes/sub"); - - expect(childCheckbox.checked).toBe(true); - expect(parentRow.dataset.folderDepth).toBe("0"); - expect(childRow.dataset.folderDepth).toBe("1"); - expect(parentRow.style.paddingLeft).toBe("0px"); - expect(childRow.style.paddingLeft).toBe("18px"); - 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背诵/随感"); + const childCheckbox = queryByDataset(tab.containerEl, "folderPath", "notes/sub"); 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); + expect(plugin.settings.includeFolders).toContain("notes/sub"); + expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); }); - it("reloads folder tree after the settings tab is reopened and shows newly created folders", async () => { - const plugin = new FakePlugin(); - plugin.settings = { - ...plugin.settings, - scopeMode: "include", - }; - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - await flushAsync(); - tab.display(); - - expect(plugin.listFolderTreeCalls).toBe(1); - expect(queryCheckboxByPath(container, "notes/new-folder")).toBeUndefined(); - - plugin.folderTree = [ - { - path: "notes", - name: "notes", - children: [ - { - path: "notes/sub", - name: "sub", - children: [], - }, - { - path: "notes/other", - name: "other", - children: [], - }, - { - path: "notes/new-folder", - name: "new-folder", - children: [], - }, - ], - }, - { - path: "empty", - name: "empty", - children: [], - }, - ]; - - tab.hide(); - tab.display(); - await flushAsync(); - tab.display(); - - expect(plugin.listFolderTreeCalls).toBe(2); - - await getFolderToggle(container, "notes").trigger("click"); - - const newFolderCheckbox = getCheckboxByPath(container, "notes/new-folder"); - const newFolderRow = getFolderRow(container, "notes/new-folder"); - - expect(newFolderCheckbox.checked).toBe(false); - expect(newFolderRow.dataset.folderDepth).toBe("1"); - expect(newFolderRow.style.paddingLeft).toBe("18px"); - }); - - it("keeps existing folder selections after reloading the folder tree on reopen", async () => { - const plugin = new FakePlugin(); - plugin.settings = { - ...plugin.settings, - scopeMode: "include", - includeFolders: ["notes/sub"], - }; - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - await flushAsync(); - tab.display(); - await getFolderToggle(container, "notes").trigger("click"); - - expect(getCheckboxByPath(container, "notes/sub").checked).toBe(true); - expect(getCheckboxByPath(container, "notes").indeterminate).toBe(true); - - plugin.folderTree = [ - { - path: "notes", - name: "notes", - children: [ - { - path: "notes/sub", - name: "sub", - children: [], - }, - { - path: "notes/other", - name: "other", - children: [], - }, - { - path: "notes/new-folder", - name: "new-folder", - children: [], - }, - ], - }, - { - path: "empty", - name: "empty", - children: [], - }, - ]; - - tab.hide(); - tab.display(); - await flushAsync(); - tab.display(); - await getFolderToggle(container, "notes").trigger("click"); - - expect(plugin.settings.includeFolders).toEqual(["notes/sub"]); - expect(getCheckboxByPath(container, "notes/sub").checked).toBe(true); - expect(getCheckboxByPath(container, "notes").indeterminate).toBe(true); - expect(getCheckboxByPath(container, "notes/new-folder").checked).toBe(false); - }); - - it("switches scope mode and saves compressed folder selections from the tree", async () => { + it("toggling file deck mode refreshes only the deck card", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; tab.display(); - await flushAsync(); - await getDropdown(findSetting(container, "Run scope")).triggerChange("include"); - await flushAsync(); + const initialEmptyCount = getEmptyCallCount(tab.containerEl); - const parentCheckbox = getCheckboxByPath(container, "notes"); - parentCheckbox.checked = true; - await parentCheckbox.trigger("change"); - await flushAsync(); - - expect(plugin.settings.includeFolders).toEqual(["notes"]); - - await getDropdown(findSetting(container, "Run scope")).triggerChange("all"); - await flushAsync(); - - expect(queryCheckboxByPath(container, "notes")).toBeUndefined(); - }); - - it("saves and rehydrates module 5 deck settings", async () => { - const plugin = new FakePlugin(); - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - - expect(container.textNodes).toContain("Default deck"); - expect(container.textNodes).toContain("File-level custom deck"); - expect(container.textNodes).toContain("Advanced: folder mapping"); - expect(container.textNodes).toContain("Final priority"); - - await getToggle(findSetting(container, "Enable file-level custom deck")).triggerChange(true); - await flushAsync(); - - await getText(findSetting(container, "Deck marker name")).triggerChange("MY DECK"); - await getText(findSetting(container, "Default deck template")).triggerChange("vault::filename"); - await getDropdown(findSetting(container, "Template insert location")).triggerChange("yaml"); - await getDropdown(findSetting(container, "Folder mapping mode")).triggerChange("folder-and-file"); - await getText(findSetting(container, "Default deck")).triggerChange("Deck::Default"); - await flushAsync(); + await queryByDataset(tab.containerEl, "settingsCardToggle", "deck").trigger("click"); + const fileDeckSetting = findSetting(tab.containerEl, "开启文件级自定义牌组"); + await getToggle(fileDeckSetting).triggerChange(true); expect(plugin.settings.fileDeckEnabled).toBe(true); - expect(plugin.settings.fileDeckMarker).toBe("MY DECK"); - expect(plugin.settings.fileDeckTemplate).toBe("vault::filename"); - expect(plugin.settings.fileDeckInsertLocation).toBe("yaml"); - expect(plugin.settings.folderDeckMode).toBe("folder-and-file"); - expect(plugin.settings.defaultDeck).toBe("Deck::Default"); - - tab.display(); - - expect(getToggle(findSetting(container, "Enable file-level custom deck")).value).toBe(true); - expect(getText(findSetting(container, "Deck marker name")).value).toBe("MY DECK"); - expect(getText(findSetting(container, "Default deck template")).value).toBe("vault::filename"); - expect(getDropdown(findSetting(container, "Template insert location")).value).toBe("yaml"); - expect(getDropdown(findSetting(container, "Folder mapping mode")).value).toBe("folder-and-file"); - expect(getText(findSetting(container, "Default deck")).value).toBe("Deck::Default"); + expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); }); - - it("exposes the deck template insertion action when file deck mode is enabled", async () => { - const plugin = new FakePlugin(); - plugin.settings = { - ...plugin.settings, - fileDeckEnabled: true, - }; - const tab = new AnkiHeadingSyncSettingTab(plugin as never); - const container = tab.containerEl as unknown as FakeContainerElInstance; - - tab.display(); - await getButton(findSetting(container, "Insert deck template into the current file")).click(); - - expect(plugin.insertDeckTemplateCalls).toBe(1); - }); -}); +}); \ No newline at end of file diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index 46f1dbe..bea7563 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -1,23 +1,24 @@ import { PluginSettingTab, Setting } from "obsidian"; +import { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels"; import { + CARD_TYPE_CONFIG_IDS, DEFAULT_OBSIDIAN_BACKLINK_LABEL, - isValidHashtagMarker, - isValidSemanticQaMarker, + normalizePluginSettings, type CardAnswerCutoffMode, + type CardTypeConfigId, type FileDeckInsertLocation, type FolderDeckMode, type ObsidianBacklinkPlacement, type ScopeMode, + validatePluginSettings, } from "@/application/config/PluginSettings"; import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping"; import type { FolderTreeNode } from "@/application/dto/FolderTreeNode"; import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; -import { type UserFacingMessage, renderUserFacingMessage, toUserFacingMessage } from "@/application/errors/PluginUserError"; +import { renderUserFacingMessage, toUserFacingMessage, type UserFacingMessage } from "@/application/errors/PluginUserError"; import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService"; -import { buildQaGroupModelDefinition } from "@/application/services/QaGroupModelDefinition"; import type { CardType } from "@/domain/card/entities/RenderedFields"; -import { SemanticQaListParser } from "@/domain/manual-sync/services/SemanticQaListParser"; import { t } from "@/presentation/i18n"; import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin"; @@ -25,30 +26,35 @@ import { buildFolderTreeSelection, toggleFolderTreeSelection, type FolderTreeSel const NOTE_TYPE_STATUS_IDLE: UserFacingMessage = { key: "settings.mapping.status.idle" }; const FOLDER_TREE_STATUS_LOADING: UserFacingMessage = { key: "settings.scope.loading" }; +const TEXT_SAVE_DEBOUNCE_MS = 500; +const SETTINGS_CARD_ORDER = ["card-types", "sync-content", "scope", "deck", "commands"] as const; -interface MappingSectionConfig { - cardType: CardType; +type SettingsCardId = (typeof SETTINGS_CARD_ORDER)[number]; + +interface SettingsCardShell { + cardEl: HTMLElement; + headerEl: HTMLButtonElement; + bodyEl: HTMLElement; } -type SimpleDropdown = { - addOption(value: string, label: string): unknown; - setValue(value: string): unknown; - onChange(callback: (value: string) => void): unknown; -}; - export class AnkiHeadingSyncSettingTab extends PluginSettingTab { private readonly noteFieldMappingService = new NoteFieldMappingService(); - private readonly semanticQaListParser = new SemanticQaListParser(); - private availableNoteModels: string[] = []; - private noteTypeStatus: UserFacingMessage = NOTE_TYPE_STATUS_IDLE; + private readonly availableNoteModels: string[] = []; private readonly draftMappings: Record = {}; private readonly loadedModelDetails: Record = {}; - private readonly sectionStatuses: Partial> = {}; + private readonly debouncedTextSaves = new Map>(); + private readonly textDraftValues = new Map(); + private readonly cardShells = new Map(); + private readonly expandedCardIds = new Set(["card-types", "commands"]); + private readonly expandedFolderPaths = new Set(); + private folderTree: FolderTreeNode[] = []; private folderTreeStatus: UserFacingMessage = FOLDER_TREE_STATUS_LOADING; private folderTreeLoadPromise: Promise | null = null; private hasLoadedFolderTree = false; - private readonly expandedFolderPaths = new Set(); + private displayInitialized = false; + private cardTypeStatus: UserFacingMessage = NOTE_TYPE_STATUS_IDLE; + private ankiConfigLoading = false; constructor(plugin: AnkiHeadingSyncPlugin) { super(plugin.app, plugin); @@ -59,53 +65,260 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { hide(): void { super.hide(); - this.hasLoadedFolderTree = false; - this.expandedFolderPaths.clear(); + this.displayInitialized = false; + this.cardShells.clear(); + this.clearDebouncedTextSaves(); } display(): void { const { containerEl } = this; - const settings = this.plugin.settings; + const previousScrollTop = containerEl.scrollTop; - containerEl.empty(); - containerEl.createEl("h2", { text: t("settings.pluginTitle") }); + if (!this.displayInitialized) { + containerEl.empty(); + containerEl.createEl("h2", { text: t("settings.pluginTitle") }); + this.initializeCards(containerEl); + this.displayInitialized = true; + } + for (const cardId of SETTINGS_CARD_ORDER) { + this.renderCard(cardId); + } + + containerEl.scrollTop = previousScrollTop; + } + + private initializeCards(containerEl: HTMLElement): void { + this.cardShells.clear(); + + for (const cardId of SETTINGS_CARD_ORDER) { + const cardEl = containerEl.createDiv(); + cardEl.dataset.settingsCard = cardId; + + const headerEl = cardEl.createEl("button") as HTMLButtonElement; + headerEl.type = "button"; + headerEl.dataset.settingsCardToggle = cardId; + headerEl.addEventListener("click", () => { + this.toggleCard(cardId); + }); + + const bodyEl = cardEl.createDiv(); + bodyEl.dataset.settingsCardBody = cardId; + + this.cardShells.set(cardId, { + cardEl, + headerEl, + bodyEl, + }); + } + } + + private toggleCard(cardId: SettingsCardId): void { + if (this.expandedCardIds.has(cardId)) { + this.expandedCardIds.delete(cardId); + } else { + this.expandedCardIds.add(cardId); + } + + this.renderCard(cardId); + } + + private renderCard(cardId: SettingsCardId): void { + const shell = this.cardShells.get(cardId); + if (!shell) { + return; + } + + const expanded = this.expandedCardIds.has(cardId); + shell.headerEl.textContent = `${expanded ? "▾" : "▸"} ${this.getCardTitle(cardId)}`; + shell.headerEl.setAttr("aria-expanded", String(expanded)); + shell.bodyEl.style.display = expanded ? "block" : "none"; + shell.bodyEl.empty(); + + if (!expanded) { + return; + } + + if (cardId === "card-types") { + this.renderCardTypesCard(shell.bodyEl); + return; + } + + if (cardId === "sync-content") { + this.renderSyncContentCard(shell.bodyEl); + return; + } + + if (cardId === "scope") { + this.renderScopeCard(shell.bodyEl); + return; + } + + if (cardId === "deck") { + this.renderDeckCard(shell.bodyEl); + return; + } + + this.renderCommandsCard(shell.bodyEl); + } + + private renderCardTypesCard(containerEl: HTMLElement): void { + containerEl.createEl("p", { text: t("settings.cards.cardTypes.desc") }); + + const actionRow = containerEl.createDiv(); + const loadButton = actionRow.createEl("button", { + text: this.ankiConfigLoading + ? t("settings.cards.cardTypes.loadAnki.loading") + : t("settings.cards.cardTypes.loadAnki.button"), + }) as HTMLButtonElement; + loadButton.type = "button"; + loadButton.dataset.cardTypesRefresh = "true"; + loadButton.disabled = this.ankiConfigLoading; + loadButton.addEventListener("click", () => { + void this.loadAnkiCardTypeConfig(); + }); + actionRow.createEl("span", { text: renderUserFacingMessage(this.cardTypeStatus) }); + + const table = containerEl.createEl("table"); + table.dataset.cardTypeTable = "true"; + const headerRow = table.createEl("tr"); + for (const columnLabel of [ + t("settings.cards.cardTypes.columns.enabled"), + t("settings.cards.cardTypes.columns.type"), + t("settings.cards.cardTypes.columns.headingLevel"), + t("settings.cards.cardTypes.columns.extraMarker"), + t("settings.cards.cardTypes.columns.noteType"), + t("settings.cards.cardTypes.columns.questionField"), + t("settings.cards.cardTypes.columns.answerField"), + ]) { + headerRow.createEl("th", { text: columnLabel }); + } + + for (const configId of CARD_TYPE_CONFIG_IDS) { + this.renderCardTypeRow(table, configId); + } + + containerEl.createEl("p", { text: t("settings.cards.cardTypes.advancedHint") }); new Setting(containerEl) .setName(t("settings.ankiConnectUrl.name")) .setDesc(t("settings.ankiConnectUrl.desc")) .addText((text) => { - text.setPlaceholder(t("settings.ankiConnectUrl.placeholder")).setValue(settings.ankiConnectUrl).onChange((value) => { - void this.plugin.updateSettings({ ankiConnectUrl: value.trim() || settings.ankiConnectUrl }); - }); + text + .setPlaceholder(t("settings.ankiConnectUrl.placeholder")) + .setValue(this.getDraftValue("anki-connect-url", this.plugin.settings.ankiConnectUrl)) + .onChange((value) => { + this.scheduleDebouncedTextSave("anki-connect-url", value, async (draftValue) => { + const nextValue = draftValue.trim() || this.plugin.settings.ankiConnectUrl; + await this.plugin.updateSettings({ ankiConnectUrl: nextValue }); + this.textDraftValues.delete("anki-connect-url"); + }); + }); }); + } - this.renderDeckSection(containerEl, settings); + private renderCardTypeRow(tableEl: HTMLElement, configId: CardTypeConfigId): void { + const config = this.plugin.settings.cardTypeConfigs[configId]; + const row = tableEl.createEl("tr"); + row.dataset.cardTypeConfig = configId; - new Setting(containerEl) - .setName(t("settings.qaHeadingLevel.name")) - .setDesc(t("settings.qaHeadingLevel.desc")) - .addDropdown((dropdown) => { - for (let level = 1; level <= 6; level += 1) { - dropdown.addOption(String(level), `H${level}`); + const enabledCell = row.createEl("td"); + const enabledCheckbox = enabledCell.createEl("input") as HTMLInputElement; + enabledCheckbox.type = "checkbox"; + enabledCheckbox.checked = config.enabled; + enabledCheckbox.dataset.cardTypeEnabled = configId; + enabledCheckbox.addEventListener("change", () => { + void this.saveCardTypeConfig(configId, { enabled: enabledCheckbox.checked }); + }); + + row.createEl("td", { text: this.getCardTypeLabel(configId) }); + + const headingCell = row.createEl("td"); + const headingSelect = this.createSelect(headingCell, `card-type-heading:${configId}`); + headingSelect.dataset.cardTypeHeading = configId; + for (let level = 1; level <= 6; level += 1) { + this.appendOption(headingSelect, String(level), `H${level}`); + } + headingSelect.value = String(config.headingLevel); + headingSelect.addEventListener("change", () => { + void this.saveCardTypeConfig(configId, { headingLevel: Number(headingSelect.value) }); + }); + + const markerCell = row.createEl("td"); + const markerInput = markerCell.createEl("input") as HTMLInputElement; + markerInput.type = "text"; + markerInput.dataset.cardTypeMarker = configId; + markerInput.placeholder = t("settings.cards.cardTypes.markerPlaceholder"); + markerInput.value = this.getDraftValue(`card-type-marker:${configId}`, config.extraMarker); + markerInput.addEventListener("input", () => { + this.scheduleDebouncedTextSave(`card-type-marker:${configId}`, markerInput.value, async (draftValue) => { + const persisted = await this.saveCardTypeConfig(configId, { extraMarker: draftValue }); + if (persisted) { + this.textDraftValues.delete(`card-type-marker:${configId}`); } - - dropdown.setValue(String(settings.qaHeadingLevel)).onChange((value) => { - void this.plugin.updateSettings({ qaHeadingLevel: Number(value) }); - }); }); + }); - new Setting(containerEl) - .setName(t("settings.clozeHeadingLevel.name")) - .setDesc(t("settings.clozeHeadingLevel.desc")) - .addDropdown((dropdown) => { - for (let level = 1; level <= 6; level += 1) { - dropdown.addOption(String(level), `H${level}`); - } - - dropdown.setValue(String(settings.clozeHeadingLevel)).onChange((value) => { - void this.plugin.updateSettings({ clozeHeadingLevel: Number(value) }); - }); + const noteTypeCell = row.createEl("td"); + if (configId === "qa-group") { + const noteTypeText = noteTypeCell.createEl("span", { + text: t("settings.cards.cardTypes.qaGroupManagedNoteType", { modelName: QA_GROUP_MODEL_NAME }), }); + noteTypeText.dataset.cardTypeNoteType = configId; + } else { + const noteTypeSelect = this.createSelect(noteTypeCell, `card-type-note-type:${configId}`); + noteTypeSelect.dataset.cardTypeNoteType = configId; + for (const noteModel of this.getSelectableNoteModels(config.noteType)) { + this.appendOption(noteTypeSelect, noteModel, noteModel); + } + noteTypeSelect.value = config.noteType; + noteTypeSelect.disabled = this.ankiConfigLoading; + noteTypeSelect.addEventListener("change", () => { + void this.saveCardTypeConfig(configId, { noteType: noteTypeSelect.value }); + }); + } + + this.renderCardTypeFieldCells(row, configId); + } + + private renderCardTypeFieldCells(rowEl: HTMLElement, configId: CardTypeConfigId): void { + const questionCell = rowEl.createEl("td"); + const answerCell = rowEl.createEl("td"); + + if (configId === "qa-group") { + questionCell.createEl("span", { text: t("settings.cards.cardTypes.autoManagedField") }); + answerCell.createEl("span", { text: t("settings.cards.cardTypes.autoManagedField") }); + return; + } + + const mapping = this.getCurrentMappingForConfig(configId); + if (configId === "cloze") { + const mainFieldSelect = this.createFieldSelect(questionCell, `card-type-question-field:${configId}`); + mainFieldSelect.dataset.cardTypeQuestionField = configId; + this.populateFieldSelect(mainFieldSelect, mapping?.loadedFieldNames ?? [], mapping?.mainField); + mainFieldSelect.addEventListener("change", () => { + void this.saveFieldMapping(configId, { mainField: mainFieldSelect.value || undefined }); + }); + answerCell.createEl("span", { text: t("settings.cards.cardTypes.autoComposedAnswer") }); + return; + } + + const titleFieldSelect = this.createFieldSelect(questionCell, `card-type-question-field:${configId}`); + titleFieldSelect.dataset.cardTypeQuestionField = configId; + this.populateFieldSelect(titleFieldSelect, mapping?.loadedFieldNames ?? [], mapping?.titleField); + titleFieldSelect.addEventListener("change", () => { + void this.saveFieldMapping(configId, { titleField: titleFieldSelect.value || undefined }); + }); + + const bodyFieldSelect = this.createFieldSelect(answerCell, `card-type-answer-field:${configId}`); + bodyFieldSelect.dataset.cardTypeAnswerField = configId; + this.populateFieldSelect(bodyFieldSelect, mapping?.loadedFieldNames ?? [], mapping?.bodyField); + bodyFieldSelect.addEventListener("change", () => { + void this.saveFieldMapping(configId, { bodyField: bodyFieldSelect.value || undefined }); + }); + } + + private renderSyncContentCard(containerEl: HTMLElement): void { + containerEl.createEl("p", { text: t("settings.cards.syncContent.desc") }); new Setting(containerEl) .setName(t("settings.cardAnswerCutoffMode.name")) @@ -113,55 +326,16 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { .addDropdown((dropdown) => { dropdown.addOption("heading-block", t("settings.cardAnswerCutoffMode.options.headingBlock")); dropdown.addOption("double-blank-lines", t("settings.cardAnswerCutoffMode.options.doubleBlankLines")); - dropdown.setValue(settings.cardAnswerCutoffMode).onChange((value) => { + dropdown.setValue(this.plugin.settings.cardAnswerCutoffMode).onChange((value) => { void this.plugin.updateSettings({ cardAnswerCutoffMode: value as CardAnswerCutoffMode }); }); }); - containerEl.createEl("h3", { text: t("settings.qaGroup.title") }); - - new Setting(containerEl) - .setName(t("settings.qaGroup.marker.name")) - .setDesc(t("settings.qaGroup.marker.desc")) - .addText((text) => { - text.setPlaceholder(t("settings.qaGroup.marker.placeholder")).setValue(settings.qaGroupMarker).onChange(async (value) => { - const nextValue = value.trim(); - if (!nextValue || !isValidHashtagMarker(nextValue) || nextValue === settings.semanticQaMarker) { - return; - } - - await this.plugin.updateSettings({ qaGroupMarker: nextValue }); - }); - }); - - this.renderQaGroupModelStatus(containerEl); - - containerEl.createEl("h3", { text: t("settings.semanticQa.title") }); - - new Setting(containerEl) - .setName(t("settings.semanticQa.marker.name")) - .setDesc(t("settings.semanticQa.marker.desc")) - .addText((text) => { - text.setPlaceholder(t("settings.semanticQa.marker.placeholder")).setValue(settings.semanticQaMarker).onChange(async (value) => { - const nextValue = value.trim(); - if (!nextValue || !isValidSemanticQaMarker(nextValue)) { - return; - } - - await this.plugin.updateSettings({ semanticQaMarker: nextValue }); - this.display(); - }); - }); - - this.renderSemanticQaPreview(containerEl, settings.semanticQaMarker, settings.cardAnswerCutoffMode); - - this.renderScopeSection(containerEl, settings); - new Setting(containerEl) .setName(t("settings.syncOptions.addObsidianBacklink.name")) .setDesc(t("settings.syncOptions.addObsidianBacklink.desc")) .addToggle((toggle) => { - toggle.setValue(settings.addObsidianBacklink).onChange((value) => { + toggle.setValue(this.plugin.settings.addObsidianBacklink).onChange((value) => { void this.plugin.updateSettings({ addObsidianBacklink: value }); }); }); @@ -172,11 +346,14 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { .addText((text) => { text .setPlaceholder(t("settings.syncOptions.obsidianBacklinkLabel.placeholder")) - .setValue(settings.obsidianBacklinkLabel) + .setValue(this.getDraftValue("obsidian-backlink-label", this.plugin.settings.obsidianBacklinkLabel)) .onChange((value) => { - const nextValue = value.trim(); - void this.plugin.updateSettings({ - obsidianBacklinkLabel: nextValue || DEFAULT_OBSIDIAN_BACKLINK_LABEL, + this.scheduleDebouncedTextSave("obsidian-backlink-label", value, async (draftValue) => { + const nextValue = draftValue.trim(); + await this.plugin.updateSettings({ + obsidianBacklinkLabel: nextValue || DEFAULT_OBSIDIAN_BACKLINK_LABEL, + }); + this.textDraftValues.delete("obsidian-backlink-label"); }); }); }); @@ -188,7 +365,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { dropdown.addOption("question-last-line", t("settings.syncOptions.obsidianBacklinkPlacement.options.questionLastLine")); dropdown.addOption("answer-first-line", t("settings.syncOptions.obsidianBacklinkPlacement.options.answerFirstLine")); dropdown.addOption("answer-last-line", t("settings.syncOptions.obsidianBacklinkPlacement.options.answerLastLine")); - dropdown.setValue(settings.obsidianBacklinkPlacement).onChange((value) => { + dropdown.setValue(this.plugin.settings.obsidianBacklinkPlacement).onChange((value) => { void this.plugin.updateSettings({ obsidianBacklinkPlacement: value as ObsidianBacklinkPlacement }); }); }); @@ -197,7 +374,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { .setName(t("settings.syncOptions.highlightsToCloze.name")) .setDesc(t("settings.syncOptions.highlightsToCloze.desc")) .addToggle((toggle) => { - toggle.setValue(settings.convertHighlightsToCloze).onChange((value) => { + toggle.setValue(this.plugin.settings.convertHighlightsToCloze).onChange((value) => { void this.plugin.updateSettings({ convertHighlightsToCloze: value }); }); }); @@ -206,7 +383,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { .setName(t("settings.syncOptions.syncObsidianTagsToAnki.name")) .setDesc(t("settings.syncOptions.syncObsidianTagsToAnki.desc")) .addToggle((toggle) => { - toggle.setValue(settings.syncObsidianTagsToAnki).onChange((value) => { + toggle.setValue(this.plugin.settings.syncObsidianTagsToAnki).onChange((value) => { void this.plugin.updateSettings({ syncObsidianTagsToAnki: value }); }); }); @@ -215,374 +392,115 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { .setName(t("settings.syncOptions.keepPureTagLinesInCardBody.name")) .setDesc(t("settings.syncOptions.keepPureTagLinesInCardBody.desc")) .addToggle((toggle) => { - toggle.setValue(settings.keepPureTagLinesInCardBody).onChange((value) => { + toggle.setValue(this.plugin.settings.keepPureTagLinesInCardBody).onChange((value) => { void this.plugin.updateSettings({ keepPureTagLinesInCardBody: value }); }); }); - - containerEl.createEl("h3", { text: t("settings.mapping.title") }); - - new Setting(containerEl) - .setName(t("settings.mapping.refresh.name")) - .setDesc(renderUserFacingMessage(this.noteTypeStatus)) - .addButton((button) => { - button.setButtonText(t("settings.mapping.refresh.button")).onClick(() => { - void this.refreshNoteTypes(); - }); - }); - - this.renderMappingSection(containerEl, { cardType: "basic" }); - this.renderMappingSection(containerEl, { cardType: "cloze" }); - this.renderMappingSection(containerEl, { cardType: "semantic-qa" }); } - private renderMappingSection(containerEl: HTMLElement, config: MappingSectionConfig): void { - const sectionText = getMappingSectionText(config.cardType); - const sectionTitle = sectionText.title; - const selectedModelName = this.getSelectedMappingNoteType(config.cardType); - const mappingKey = createNoteFieldMappingKey(config.cardType, selectedModelName); - const currentMapping = this.getCurrentMapping(mappingKey); - - containerEl.createEl("h4", { text: sectionTitle }); - containerEl.createEl("p", { text: sectionText.description }); + private renderScopeCard(containerEl: HTMLElement): void { + containerEl.createEl("p", { text: t("settings.cards.scope.desc") }); new Setting(containerEl) - .setName(t("settings.mapping.noteTypeLabel", { title: sectionTitle })) - .setDesc(t("settings.mapping.noteTypeDesc")) + .setName(t("settings.scope.name")) + .setDesc(getScopeModeSummary(this.plugin.settings.scopeMode)) .addDropdown((dropdown) => { - for (const noteModel of this.getSelectableNoteModels(config.cardType, selectedModelName)) { - dropdown.addOption(noteModel, noteModel); - } + dropdown + .addOption("all", t("settings.scope.option.all")) + .addOption("include", t("settings.scope.option.include")) + .addOption("exclude", t("settings.scope.option.exclude")) + .setValue(this.plugin.settings.scopeMode) + .onChange((value) => { + if (value !== "all" && value !== "include" && value !== "exclude") { + return; + } - dropdown.setValue(selectedModelName).onChange((value) => { - void this.updateSelectedNoteType(config.cardType, value); - }); + void this.updateScopeMode(value); + }); }); - new Setting(containerEl) - .setName(t("settings.mapping.fieldsLabel", { title: sectionTitle })) - .setDesc(t("settings.mapping.fieldsDesc")) - .addButton((button) => { - button.setButtonText(t("settings.mapping.readFieldsButton")).onClick(() => { - void this.loadFieldsFromAnki(config.cardType); - }); - }); - - containerEl.createEl("p", { - text: - currentMapping?.loadedFieldNames.length - ? t("settings.mapping.loadedFields", { fields: currentMapping.loadedFieldNames }) - : t("settings.mapping.loadedFieldsNone"), - }); - - if (config.cardType === "cloze") { - this.renderClozeFieldSelector(containerEl, selectedModelName, currentMapping); - } else { - this.renderBasicFieldSelectors(containerEl, selectedModelName, currentMapping, config.cardType, sectionTitle); - } - - new Setting(containerEl) - .setName(t("settings.mapping.mappingLabel", { title: sectionTitle })) - .setDesc(t("settings.mapping.mappingDesc")) - .addButton((button) => { - button.setButtonText(t("settings.mapping.saveMappingButton")).onClick(() => { - void this.saveMapping(config.cardType); - }); - }); - - containerEl.createEl("p", { - text: - (this.sectionStatuses[config.cardType] ? renderUserFacingMessage(this.sectionStatuses[config.cardType] as UserFacingMessage) : undefined) ?? - (this.plugin.settings.noteFieldMappings[mappingKey] - ? t("settings.mapping.status.savedMappingReady", { title: sectionTitle }) - : t("settings.mapping.status.noSavedMapping", { title: sectionTitle })), - }); - } - - private renderBasicFieldSelectors( - containerEl: HTMLElement, - selectedModelName: string, - mapping: NoteModelFieldMapping | undefined, - cardType: Extract, - sectionTitle: string, - ): void { - const fieldNames = mapping?.loadedFieldNames ?? []; - - new Setting(containerEl) - .setName(t("settings.mapping.titleFieldLabel", { title: sectionTitle })) - .setDesc(t("settings.mapping.titleFieldDesc")) - .addDropdown((dropdown) => { - this.populateFieldDropdown(dropdown, fieldNames, mapping?.titleField); - dropdown.onChange((value) => { - this.updateDraftMapping(mapping?.cardType ?? cardType, selectedModelName, { titleField: value || undefined }); - }); - }); - - new Setting(containerEl) - .setName(t("settings.mapping.bodyFieldLabel", { title: sectionTitle })) - .setDesc(t("settings.mapping.bodyFieldDesc")) - .addDropdown((dropdown) => { - this.populateFieldDropdown(dropdown, fieldNames, mapping?.bodyField); - dropdown.onChange((value) => { - this.updateDraftMapping(mapping?.cardType ?? cardType, selectedModelName, { bodyField: value || undefined }); - }); - }); - } - - private renderClozeFieldSelector( - containerEl: HTMLElement, - selectedModelName: string, - mapping: NoteModelFieldMapping | undefined, - ): void { - const fieldNames = mapping?.loadedFieldNames ?? []; - - new Setting(containerEl) - .setName(t("settings.mapping.mainField.name")) - .setDesc(t("settings.mapping.mainField.desc")) - .addDropdown((dropdown) => { - this.populateFieldDropdown(dropdown, fieldNames, mapping?.mainField); - dropdown.onChange((value) => { - this.updateDraftMapping("cloze", selectedModelName, { mainField: value || undefined }); - }); - }); - } - - private populateFieldDropdown(dropdown: SimpleDropdown, fieldNames: string[], selectedValue: string | undefined): void { - dropdown.addOption("", t("settings.mapping.selectFieldPlaceholder")); - - for (const fieldName of fieldNames) { - dropdown.addOption(fieldName, fieldName); - } - - dropdown.setValue(selectedValue ?? ""); - } - - private async refreshNoteTypes(): Promise { - try { - const noteModels = await this.plugin.listNoteModels(); - this.availableNoteModels = [...noteModels].sort((left, right) => left.localeCompare(right)); - this.noteTypeStatus = - this.availableNoteModels.length > 0 - ? { key: "settings.mapping.status.loadedCount", params: { count: this.availableNoteModels.length } } - : { key: "settings.mapping.status.empty" }; - } catch (error) { - this.noteTypeStatus = toUserFacingMessage(error, "settings.mapping.status.failedLoadNoteTypes"); - } - - this.display(); - } - - private async updateSelectedNoteType(cardType: CardType, modelName: string): Promise { - if (cardType === "basic") { - await this.plugin.updateSettings({ qaNoteType: modelName }); - } else if (cardType === "cloze") { - await this.plugin.updateSettings({ clozeNoteType: modelName }); - } else { - await this.plugin.updateSettings({ semanticQaNoteType: modelName }); - } - - const mappingKey = createNoteFieldMappingKey(cardType, modelName); - this.sectionStatuses[cardType] = this.plugin.settings.noteFieldMappings[mappingKey] - ? { key: "settings.mapping.status.loadedSavedMapping", params: { modelName } } - : { key: "settings.mapping.status.selectedModel", params: { modelName } }; - this.display(); - } - - private async loadFieldsFromAnki(cardType: CardType): Promise { - const modelName = this.getSelectedMappingNoteType(cardType); - - try { - const modelDetails = await this.plugin.getNoteModelDetails(modelName); - const mapping = this.noteFieldMappingService.suggest(cardType, modelName, modelDetails.fieldNames); - const mappingKey = createNoteFieldMappingKey(cardType, modelName); - - this.draftMappings[mappingKey] = mapping; - this.loadedModelDetails[mappingKey] = modelDetails; - this.sectionStatuses[cardType] = { key: "settings.mapping.status.loadedFieldsForModel", params: { modelName } }; - } catch (error) { - this.sectionStatuses[cardType] = toUserFacingMessage(error, "settings.mapping.status.failedLoadFields", { modelName }); - } - - this.display(); - } - - private async saveMapping(cardType: CardType): Promise { - const modelName = this.getSelectedMappingNoteType(cardType); - const mappingKey = createNoteFieldMappingKey(cardType, modelName); - const mapping = this.getCurrentMapping(mappingKey); - - if (!mapping) { - this.sectionStatuses[cardType] = { key: "settings.mapping.status.noLoadedFields", params: { modelName } }; - this.display(); + if (this.plugin.settings.scopeMode === "all") { return; } - try { - this.noteFieldMappingService.validateMapping(mapping, this.loadedModelDetails[mappingKey] ?? { - fieldNames: mapping.loadedFieldNames, - isCloze: cardType === "cloze", - }); + const refreshRow = containerEl.createDiv(); + const refreshButton = refreshRow.createEl("button", { text: t("settings.cards.scope.refreshFolders") }) as HTMLButtonElement; + refreshButton.type = "button"; + refreshButton.dataset.scopeRefreshFolders = "true"; + refreshButton.disabled = Boolean(this.folderTreeLoadPromise); + refreshButton.addEventListener("click", () => { + void this.refreshFolderTree(); + }); - await this.plugin.updateSettings({ - noteFieldMappings: { - ...this.plugin.settings.noteFieldMappings, - [mappingKey]: { - ...mapping, - loadedFieldNames: [...mapping.loadedFieldNames], - }, - }, - }); + this.ensureFolderTreeLoaded(); + containerEl.createEl("p", { text: getScopeModeTreeDescription(this.plugin.settings.scopeMode) }); - this.sectionStatuses[cardType] = { key: "settings.mapping.status.savedMapping", params: { modelName } }; - } catch (error) { - this.sectionStatuses[cardType] = toUserFacingMessage(error, "settings.mapping.status.failedSaveMapping", { modelName }); - } - - this.display(); - } - - private getSelectableNoteModels(cardType: CardType, selectedModelName: string): string[] { - void cardType; - const noteModels = this.getSelectableMappingNoteModels(); - - if (!noteModels.includes(selectedModelName)) { - noteModels.unshift(selectedModelName); - } - - return noteModels; - } - - private getSelectableMappingNoteModels(): string[] { - return this.availableNoteModels.length > 0 ? [...this.availableNoteModels] : []; - } - - private updateDraftMapping( - cardType: CardType, - modelName: string, - partialMapping: Partial, - ): void { - const mappingKey = createNoteFieldMappingKey(cardType, modelName); - const currentMapping = this.getCurrentMapping(mappingKey); - - if (!currentMapping) { + if (this.folderTreeLoadPromise || this.folderTree.length === 0) { + containerEl.createEl("p", { text: renderUserFacingMessage(this.folderTreeStatus) }); return; } - this.draftMappings[mappingKey] = { - ...currentMapping, - ...partialMapping, - }; - } + const selectedFolders = this.plugin.settings.scopeMode === "include" + ? this.plugin.settings.includeFolders + : this.plugin.settings.excludeFolders; + const selectionTree = buildFolderTreeSelection(this.folderTree, selectedFolders); + const treeContainer = containerEl.createDiv(); + treeContainer.dataset.scopeTree = "true"; - private getCurrentMapping(mappingKey: string): NoteModelFieldMapping | undefined { - const mapping = this.draftMappings[mappingKey] ?? this.plugin.settings.noteFieldMappings[mappingKey]; - - if (!mapping) { - return undefined; + for (const node of selectionTree) { + this.renderFolderNode(treeContainer, node, this.plugin.settings.scopeMode, 0); } - - return { - ...mapping, - loadedFieldNames: [...mapping.loadedFieldNames], - }; } - private getSelectedNoteType(cardType: CardType): string { - if (cardType === "basic") { - return this.plugin.settings.qaNoteType; - } - - if (cardType === "cloze") { - return this.plugin.settings.clozeNoteType; - } - - return this.plugin.settings.semanticQaNoteType; - } - - private getSelectedMappingNoteType(cardType: CardType): string { - return this.getSelectedNoteType(cardType); - } - - private renderQaGroupModelStatus(containerEl: HTMLElement): void { - const definition = buildQaGroupModelDefinition(); - containerEl.createEl("p", { - text: t("settings.qaGroup.managedNoteType", { - modelName: definition.modelName, - }), - }); - containerEl.createEl("p", { - text: t("settings.qaGroup.managedModelContract", { - fieldCount: definition.fieldNames.length, - templateCount: definition.templates.length, - }), - }); - } - - private renderSemanticQaPreview(containerEl: HTMLElement, marker: string, cardAnswerCutoffMode: CardAnswerCutoffMode): void { - const sampleHeading = `城市更新 ${marker}`; - - containerEl.createEl("h4", { text: t("settings.semanticQa.previewTitle") }); - containerEl.createEl("p", { text: t("settings.semanticQa.triggerHeadingExample", { heading: sampleHeading }) }); - - const previewCards = this.semanticQaListParser.parse({ - parentHeadingText: sampleHeading, - marker, - bodyLines: [ - "- 核心产品", - " 百人会、城市更新研习社、城市更新创投营。", - "- 目标客户", - " 对城市更新有系统学习需求的从业者。", - ], - bodyStartLine: 2, - cardAnswerCutoffMode, - }); - - if (previewCards.length === 0) { - containerEl.createEl("p", { text: t("settings.semanticQa.previewUnavailable") }); - return; - } - - const firstPreview = previewCards[0]; - containerEl.createEl("p", { text: t("settings.semanticQa.questionPreview", { question: firstPreview.heading }) }); - containerEl.createEl("p", { text: t("settings.semanticQa.answerPreview", { answer: firstPreview.bodyMarkdown }) }); - } - - private renderDeckSection(containerEl: HTMLElement, settings: AnkiHeadingSyncPlugin["settings"]): void { - containerEl.createEl("h3", { text: t("settings.deck.defaultSectionTitle") }); + private renderDeckCard(containerEl: HTMLElement): void { + containerEl.createEl("p", { text: t("settings.cards.deck.desc") }); new Setting(containerEl) .setName(t("settings.deck.defaultDeck.name")) .setDesc(t("settings.deck.defaultDeck.desc")) .addText((text) => { - text.setValue(settings.defaultDeck).onChange((value) => { - void this.plugin.updateSettings({ defaultDeck: value }); + text.setValue(this.getDraftValue("default-deck", this.plugin.settings.defaultDeck)).onChange((value) => { + this.scheduleDebouncedTextSave("default-deck", value, async (draftValue) => { + const nextValue = draftValue.trim(); + if (!nextValue) { + this.textDraftValues.delete("default-deck"); + this.renderCard("deck"); + return; + } + + await this.plugin.updateSettings({ defaultDeck: nextValue }); + this.textDraftValues.delete("default-deck"); + }); }); }); - containerEl.createEl("h3", { text: t("settings.deck.fileDeckSectionTitle") }); - new Setting(containerEl) .setName(t("settings.deck.fileDeckEnabled.name")) .setDesc(t("settings.deck.fileDeckEnabled.desc")) .addToggle((toggle) => { - toggle.setValue(settings.fileDeckEnabled).onChange(async (value) => { + toggle.setValue(this.plugin.settings.fileDeckEnabled).onChange(async (value) => { await this.plugin.updateSettings({ fileDeckEnabled: value }); - this.display(); + this.renderCard("deck"); }); }); - if (settings.fileDeckEnabled) { + if (this.plugin.settings.fileDeckEnabled) { new Setting(containerEl) .setName(t("settings.deck.marker.name")) .setDesc(t("settings.deck.marker.desc")) .addText((text) => { - text.setValue(settings.fileDeckMarker).onChange((value) => { - const nextValue = value.trim(); - if (!nextValue) { - return; - } + text.setValue(this.getDraftValue("file-deck-marker", this.plugin.settings.fileDeckMarker)).onChange((value) => { + this.scheduleDebouncedTextSave("file-deck-marker", value, async (draftValue) => { + const nextValue = draftValue.trim(); + if (!nextValue) { + this.textDraftValues.delete("file-deck-marker"); + this.renderCard("deck"); + return; + } - void this.plugin.updateSettings({ fileDeckMarker: nextValue }); + await this.plugin.updateSettings({ fileDeckMarker: nextValue }); + this.textDraftValues.delete("file-deck-marker"); + }); }); }); @@ -590,13 +508,18 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { .setName(t("settings.deck.template.name")) .setDesc(t("settings.deck.template.desc")) .addText((text) => { - text.setValue(settings.fileDeckTemplate).onChange((value) => { - const nextValue = value.trim(); - if (!nextValue) { - return; - } + text.setValue(this.getDraftValue("file-deck-template", this.plugin.settings.fileDeckTemplate)).onChange((value) => { + this.scheduleDebouncedTextSave("file-deck-template", value, async (draftValue) => { + const nextValue = draftValue.trim(); + if (!nextValue) { + this.textDraftValues.delete("file-deck-template"); + this.renderCard("deck"); + return; + } - void this.plugin.updateSettings({ fileDeckTemplate: nextValue }); + await this.plugin.updateSettings({ fileDeckTemplate: nextValue }); + this.textDraftValues.delete("file-deck-template"); + }); }); }); @@ -604,13 +527,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { .setName(t("settings.deck.insertLocation.name")) .setDesc(t("settings.deck.insertLocation.desc")) .addDropdown((dropdown) => { - this.populateFileDeckInsertLocationDropdown(dropdown, settings.fileDeckInsertLocation); + this.populateFileDeckInsertLocationDropdown(dropdown, this.plugin.settings.fileDeckInsertLocation); dropdown.onChange((value) => { if (value !== "yaml" && value !== "body") { return; } - void this.plugin.updateSettings({ fileDeckInsertLocation: value }); + void this.plugin.updateSettings({ fileDeckInsertLocation: value as FileDeckInsertLocation }); }); }); @@ -624,159 +547,196 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { }); } - containerEl.createEl("h3", { text: t("settings.deck.folderMappingSectionTitle") }); - new Setting(containerEl) .setName(t("settings.deck.folderDeckMode.name")) .setDesc(t("settings.deck.folderDeckMode.desc")) .addDropdown((dropdown) => { - this.populateFolderDeckModeDropdown(dropdown, settings.folderDeckMode); + this.populateFolderDeckModeDropdown(dropdown, this.plugin.settings.folderDeckMode); dropdown.onChange((value) => { if (value !== "off" && value !== "folder" && value !== "folder-and-file") { return; } - void this.plugin.updateSettings({ folderDeckMode: value }); + void this.plugin.updateSettings({ folderDeckMode: value as FolderDeckMode }); }); }); containerEl.createEl("p", { text: t("settings.deck.folderExample") }); containerEl.createEl("p", { text: t("settings.deck.folderAndFileExample") }); + containerEl.createEl("p", { text: t("settings.deck.priorityDesc") }); + } - containerEl.createEl("h3", { text: t("settings.deck.priorityTitle") }); - containerEl.createEl("p", { - text: t("settings.deck.priorityDesc"), + private renderCommandsCard(containerEl: HTMLElement): void { + containerEl.createEl("p", { text: t("settings.cards.commands.desc") }); + const commandList = containerEl.createEl("ul"); + for (const [name, description] of [ + [t("commands.syncCurrentFileToAnki"), t("settings.cards.commands.items.syncCurrentFile")], + [t("commands.syncVaultToAnki"), t("settings.cards.commands.items.syncVault")], + [t("commands.rebuildCardIndex"), t("settings.cards.commands.items.rebuildIndex")], + [t("commands.clearCurrentFileSyncedCards"), t("settings.cards.commands.items.clearCurrentFile")], + [t("commands.cleanupEmptyDecks"), t("settings.cards.commands.items.cleanupDecks")], + ]) { + commandList.createEl("li", { text: `${name}: ${description}` }); + } + } + + private async saveCardTypeConfig(configId: CardTypeConfigId, partialConfig: Partial): Promise { + const nextCardTypeConfigs = { + ...this.plugin.settings.cardTypeConfigs, + [configId]: { + ...this.plugin.settings.cardTypeConfigs[configId], + ...partialConfig, + }, + }; + + try { + validatePluginSettings(normalizePluginSettings({ + ...this.plugin.settings, + cardTypeConfigs: nextCardTypeConfigs, + })); + } catch (error) { + this.cardTypeStatus = toUserFacingMessage(error, "settings.cards.cardTypes.failedSave"); + this.renderCard("card-types"); + return false; + } + + this.cardTypeStatus = NOTE_TYPE_STATUS_IDLE; + await this.plugin.updateSettings({ cardTypeConfigs: nextCardTypeConfigs }); + this.renderCard("card-types"); + return true; + } + + private async saveFieldMapping(configId: Exclude, partialMapping: Partial): Promise { + const mapping = this.getCurrentMappingForConfig(configId); + const runtimeCardType = this.getRuntimeCardType(configId); + const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType; + const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName); + const nextMapping = { + ...(mapping ?? this.createFallbackMapping(runtimeCardType, modelName)), + ...partialMapping, + }; + + this.draftMappings[mappingKey] = nextMapping; + await this.plugin.updateSettings({ + noteFieldMappings: { + ...this.plugin.settings.noteFieldMappings, + [mappingKey]: { + ...nextMapping, + loadedFieldNames: [...nextMapping.loadedFieldNames], + }, + }, }); + this.renderCard("card-types"); } - private populateFileDeckInsertLocationDropdown(dropdown: SimpleDropdown, selectedValue: FileDeckInsertLocation): void { - dropdown.addOption("body", t("settings.deck.insertLocation.options.body")); - dropdown.addOption("yaml", t("settings.deck.insertLocation.options.yaml")); - dropdown.setValue(selectedValue); + private async loadAnkiCardTypeConfig(): Promise { + this.ankiConfigLoading = true; + this.cardTypeStatus = { key: "settings.cards.cardTypes.loadAnki.loading" }; + this.renderCard("card-types"); + + try { + const noteModels = await this.plugin.listNoteModels(); + this.availableNoteModels.splice(0, this.availableNoteModels.length, ...[...noteModels].sort((left, right) => left.localeCompare(right))); + const selectedConfigs = CARD_TYPE_CONFIG_IDS.filter((configId) => configId !== "qa-group") as Array>; + + for (const configId of selectedConfigs) { + const runtimeCardType = this.getRuntimeCardType(configId); + const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType; + const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName); + const modelDetails = await this.plugin.getNoteModelDetails(modelName); + + this.loadedModelDetails[mappingKey] = modelDetails; + this.seedDraftMapping(runtimeCardType, modelName, modelDetails); + } + + this.cardTypeStatus = { + rawMessage: `${t("settings.mapping.status.loadedCount", { count: this.availableNoteModels.length })} ${t("settings.cards.cardTypes.loadedFieldsStatus", { count: selectedConfigs.length })}`, + }; + } catch (error) { + this.cardTypeStatus = toUserFacingMessage(error, "settings.cards.cardTypes.failedLoad"); + } finally { + this.ankiConfigLoading = false; + this.renderCard("card-types"); + } } - private populateFolderDeckModeDropdown(dropdown: SimpleDropdown, selectedValue: FolderDeckMode): void { - dropdown.addOption("off", t("settings.deck.folderDeckMode.options.off")); - dropdown.addOption("folder", t("settings.deck.folderDeckMode.options.folder")); - dropdown.addOption("folder-and-file", t("settings.deck.folderDeckMode.options.folderAndFile")); - dropdown.setValue(selectedValue); - } + private seedDraftMapping(runtimeCardType: CardType, modelName: string, modelDetails: NoteModelDetails): void { + const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName); + const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey]; - private renderScopeSection(containerEl: HTMLElement, settings: AnkiHeadingSyncPlugin["settings"]): void { - new Setting(containerEl) - .setName(t("settings.scope.name")) - .setDesc(getScopeModeSummary(settings.scopeMode)) - .addDropdown((dropdown) => { - dropdown - .addOption("all", t("settings.scope.option.all")) - .addOption("include", t("settings.scope.option.include")) - .addOption("exclude", t("settings.scope.option.exclude")) - .setValue(settings.scopeMode) - .onChange((value) => { - if (value !== "all" && value !== "include" && value !== "exclude") { - return; - } - - void this.updateScopeMode(value); - }); - }); - - if (settings.scopeMode === "all") { + if (currentMapping) { + this.draftMappings[mappingKey] = { + ...currentMapping, + loadedFieldNames: [...modelDetails.fieldNames], + loadedAt: Date.now(), + }; return; } - this.ensureFolderTreeLoaded(); - - const scopeContainer = containerEl.createDiv(); - scopeContainer.createEl("p", { text: getScopeModeTreeDescription(settings.scopeMode) }); - - if (this.folderTreeLoadPromise) { - scopeContainer.createEl("p", { text: renderUserFacingMessage(this.folderTreeStatus) }); - return; - } - - if (this.folderTree.length === 0) { - scopeContainer.createEl("p", { text: renderUserFacingMessage(this.folderTreeStatus) }); - return; - } - - const selectedFolders = settings.scopeMode === "include" ? settings.includeFolders : settings.excludeFolders; - const selectionTree = buildFolderTreeSelection(this.folderTree, selectedFolders); - const treeContainer = scopeContainer.createDiv(); - treeContainer.style.marginTop = "8px"; - treeContainer.style.display = "flex"; - treeContainer.style.flexDirection = "column"; - treeContainer.style.gap = "2px"; - - for (const node of selectionTree) { - this.renderFolderNode(treeContainer, node, settings.scopeMode, 0); - } + this.draftMappings[mappingKey] = this.noteFieldMappingService.suggest(runtimeCardType, modelName, modelDetails.fieldNames); } - private renderFolderNode(containerEl: HTMLElement, node: FolderTreeSelectionNode, scopeMode: ScopeMode, depth: number): void { - const row = containerEl.createDiv(); - row.dataset.folderRow = node.path; - row.dataset.folderDepth = String(depth); - row.style.display = "flex"; - row.style.alignItems = "center"; - row.style.gap = "6px"; - row.style.minHeight = "24px"; - row.style.paddingLeft = `${depth * 18}px`; - - const hasChildren = node.children.length > 0; - const expanded = hasChildren && this.expandedFolderPaths.has(node.path); - const toggleControl = row.createEl(hasChildren ? "button" : "span"); - toggleControl.dataset.folderToggle = node.path; - toggleControl.textContent = hasChildren ? (expanded ? "▾" : "▸") : ""; - toggleControl.style.width = "18px"; - toggleControl.style.display = "inline-flex"; - toggleControl.style.alignItems = "center"; - toggleControl.style.justifyContent = "center"; - toggleControl.style.flexShrink = "0"; - toggleControl.style.padding = "0"; - toggleControl.style.border = "0"; - toggleControl.style.background = "transparent"; - toggleControl.style.color = "var(--text-muted)"; - toggleControl.style.cursor = hasChildren ? "pointer" : "default"; - - if (hasChildren) { - toggleControl.setAttr("aria-label", expanded ? t("settings.scope.collapseFolder", { name: node.name }) : t("settings.scope.expandFolder", { name: node.name })); - toggleControl.setAttr("aria-expanded", String(expanded)); - toggleControl.addEventListener("click", () => { - this.toggleFolderExpanded(node.path); - }); + private getSelectableNoteModels(selectedModelName: string): string[] { + const noteModels = this.availableNoteModels.length > 0 ? [...this.availableNoteModels] : []; + if (!noteModels.includes(selectedModelName)) { + noteModels.unshift(selectedModelName); } - const checkbox = row.createEl("input") as HTMLInputElement; - 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", () => { - void this.updateFolderSelection(scopeMode, node.path, checkbox.checked); - }); - - const label = row.createEl("span", { text: node.name }); - label.dataset.folderPathLabel = node.path; - label.style.userSelect = "none"; - - if (!hasChildren || !expanded) { - return; - } - - const childrenContainer = containerEl.createDiv(); - childrenContainer.dataset.folderChildren = node.path; - childrenContainer.style.display = "flex"; - childrenContainer.style.flexDirection = "column"; - childrenContainer.style.gap = "2px"; - for (const child of node.children) { - this.renderFolderNode(childrenContainer, child, scopeMode, depth + 1); - } + return noteModels; } - private ensureFolderTreeLoaded(): void { + private getCurrentMappingForConfig(configId: Exclude): NoteModelFieldMapping | undefined { + const runtimeCardType = this.getRuntimeCardType(configId); + const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType; + const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName); + const mapping = this.draftMappings[mappingKey] ?? this.plugin.settings.noteFieldMappings[mappingKey]; + + if (mapping) { + return { + ...mapping, + loadedFieldNames: [...mapping.loadedFieldNames], + }; + } + + const modelDetails = this.loadedModelDetails[mappingKey]; + if (!modelDetails) { + return undefined; + } + + const suggestedMapping = this.noteFieldMappingService.suggest(runtimeCardType, modelName, modelDetails.fieldNames); + this.draftMappings[mappingKey] = suggestedMapping; + return { + ...suggestedMapping, + loadedFieldNames: [...suggestedMapping.loadedFieldNames], + }; + } + + private createFallbackMapping(runtimeCardType: CardType, modelName: string): NoteModelFieldMapping { + const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName); + const modelDetails = this.loadedModelDetails[mappingKey]; + + if (modelDetails) { + return this.noteFieldMappingService.suggest(runtimeCardType, modelName, modelDetails.fieldNames); + } + + return { + cardType: runtimeCardType, + modelName, + loadedFieldNames: [], + loadedAt: Date.now(), + }; + } + + private getRuntimeCardType(configId: Exclude): CardType { + return configId === "cloze" ? "cloze" : configId === "semantic-qa" ? "semantic-qa" : "basic"; + } + + private ensureFolderTreeLoaded(forceReload = false): void { + if (forceReload) { + this.hasLoadedFolderTree = false; + this.folderTree = []; + } + if (this.hasLoadedFolderTree || this.folderTreeLoadPromise) { return; } @@ -795,13 +755,22 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { .finally(() => { this.hasLoadedFolderTree = true; this.folderTreeLoadPromise = null; - this.display(); + this.renderCard("scope"); }); } + private async refreshFolderTree(): Promise { + this.expandedFolderPaths.clear(); + this.ensureFolderTreeLoaded(true); + this.renderCard("scope"); + } + private async updateScopeMode(scopeMode: ScopeMode): Promise { await this.plugin.updateSettings({ scopeMode }); - this.display(); + if (scopeMode !== "all") { + this.ensureFolderTreeLoaded(); + } + this.renderCard("scope"); } private async updateFolderSelection(scopeMode: ScopeMode, folderPath: string, checked: boolean): Promise { @@ -809,39 +778,166 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { const nextSelection = toggleFolderTreeSelection(this.folderTree, currentSelection, folderPath, checked); await this.plugin.updateSettings(scopeMode === "include" ? { includeFolders: nextSelection } : { excludeFolders: nextSelection }); - this.display(); + this.renderCard("scope"); } - private toggleFolderExpanded(folderPath: string): void { - if (this.expandedFolderPaths.has(folderPath)) { - this.expandedFolderPaths.delete(folderPath); - } else { - this.expandedFolderPaths.add(folderPath); + private renderFolderNode(containerEl: HTMLElement, node: FolderTreeSelectionNode, scopeMode: ScopeMode, depth: number): void { + const row = containerEl.createDiv(); + row.dataset.folderRow = node.path; + row.style.paddingLeft = `${depth * 18}px`; + + const hasChildren = node.children.length > 0; + const expanded = hasChildren && this.expandedFolderPaths.has(node.path); + const toggleControl = row.createEl(hasChildren ? "button" : "span"); + toggleControl.dataset.folderToggle = node.path; + toggleControl.textContent = hasChildren ? (expanded ? "▾" : "▸") : ""; + if (hasChildren) { + toggleControl.addEventListener("click", () => { + if (this.expandedFolderPaths.has(node.path)) { + this.expandedFolderPaths.delete(node.path); + } else { + this.expandedFolderPaths.add(node.path); + } + + this.renderCard("scope"); + }); } - this.display(); - } -} + const checkbox = row.createEl("input") as HTMLInputElement; + checkbox.type = "checkbox"; + checkbox.checked = node.checked; + checkbox.indeterminate = node.indeterminate; + checkbox.dataset.folderPath = node.path; + checkbox.addEventListener("change", () => { + void this.updateFolderSelection(scopeMode, node.path, checkbox.checked); + }); -function getMappingSectionText(cardType: CardType): { title: string; description: string } { - if (cardType === "cloze") { - return { - title: t("settings.mapping.section.cloze.title"), - description: t("settings.mapping.section.cloze.description"), - }; + row.createEl("span", { text: node.name }).dataset.folderPathLabel = node.path; + + if (!hasChildren || !expanded) { + return; + } + + const childrenContainer = containerEl.createDiv(); + childrenContainer.dataset.folderChildren = node.path; + for (const child of node.children) { + this.renderFolderNode(childrenContainer, child, scopeMode, depth + 1); + } } - if (cardType === "semantic-qa") { - return { - title: t("settings.mapping.section.semanticQa.title"), - description: t("settings.mapping.section.semanticQa.description"), - }; + private scheduleDebouncedTextSave(key: string, value: string, saveAction: (draftValue: string) => Promise): void { + this.textDraftValues.set(key, value); + + const pendingTimer = this.debouncedTextSaves.get(key); + if (pendingTimer) { + globalThis.clearTimeout(pendingTimer); + } + + const timer = globalThis.setTimeout(() => { + void saveAction(this.textDraftValues.get(key) ?? value).finally(() => { + this.debouncedTextSaves.delete(key); + }); + }, TEXT_SAVE_DEBOUNCE_MS); + + this.debouncedTextSaves.set(key, timer); } - return { - title: t("settings.mapping.section.basic.title"), - description: t("settings.mapping.section.basic.description"), - }; + private clearDebouncedTextSaves(): void { + for (const timer of this.debouncedTextSaves.values()) { + globalThis.clearTimeout(timer); + } + + this.debouncedTextSaves.clear(); + } + + private getDraftValue(key: string, persistedValue: string): string { + return this.textDraftValues.get(key) ?? persistedValue; + } + + private createSelect(containerEl: HTMLElement, datasetKey: string): HTMLSelectElement { + const selectEl = containerEl.createEl("select") as HTMLSelectElement; + selectEl.dataset.selectKey = datasetKey; + return selectEl; + } + + private createFieldSelect(containerEl: HTMLElement, datasetKey: string): HTMLSelectElement { + return this.createSelect(containerEl, datasetKey); + } + + private appendOption(selectEl: HTMLSelectElement, value: string, label: string): void { + const optionEl = selectEl.createEl("option", { text: label }) as HTMLOptionElement; + optionEl.value = value; + } + + private populateFieldSelect(selectEl: HTMLSelectElement, fieldNames: string[], selectedValue: string | undefined): void { + selectEl.empty(); + this.appendOption(selectEl, "", fieldNames.length > 0 ? t("settings.mapping.selectFieldPlaceholder") : t("settings.cards.cardTypes.fieldsUnavailable")); + for (const fieldName of fieldNames) { + this.appendOption(selectEl, fieldName, fieldName); + } + selectEl.value = selectedValue ?? ""; + } + + private populateFileDeckInsertLocationDropdown( + dropdown: { + addOption(value: string, label: string): unknown; + setValue(value: string): unknown; + }, + selectedValue: FileDeckInsertLocation, + ): void { + dropdown.addOption("body", t("settings.deck.insertLocation.options.body")); + dropdown.addOption("yaml", t("settings.deck.insertLocation.options.yaml")); + dropdown.setValue(selectedValue); + } + + private populateFolderDeckModeDropdown( + dropdown: { + addOption(value: string, label: string): unknown; + setValue(value: string): unknown; + }, + selectedValue: FolderDeckMode, + ): void { + dropdown.addOption("off", t("settings.deck.folderDeckMode.options.off")); + dropdown.addOption("folder", t("settings.deck.folderDeckMode.options.folder")); + dropdown.addOption("folder-and-file", t("settings.deck.folderDeckMode.options.folderAndFile")); + dropdown.setValue(selectedValue); + } + + private getCardTitle(cardId: SettingsCardId): string { + if (cardId === "card-types") { + return t("settings.cards.cardTypes.title"); + } + + if (cardId === "sync-content") { + return t("settings.cards.syncContent.title"); + } + + if (cardId === "scope") { + return t("settings.cards.scope.title"); + } + + if (cardId === "deck") { + return t("settings.cards.deck.title"); + } + + return t("settings.cards.commands.title"); + } + + private getCardTypeLabel(configId: CardTypeConfigId): string { + if (configId === "basic") { + return t("settings.cards.cardTypes.rows.basic"); + } + + if (configId === "qa-group") { + return t("settings.cards.cardTypes.rows.qaGroup"); + } + + if (configId === "cloze") { + return t("settings.cards.cardTypes.rows.cloze"); + } + + return t("settings.cards.cardTypes.rows.semanticQa"); + } } function getScopeModeSummary(scopeMode: ScopeMode): string { @@ -862,4 +958,4 @@ function getScopeModeTreeDescription(scopeMode: ScopeMode): string { } return t("settings.scope.treeDescription.exclude"); -} +} \ No newline at end of file