mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
重构设置页为五卡片并统一识别配置 / Cardize settings page and unify recognition config
重构设置页采用五个卡片外壳,提升布局稳定性和局部刷新体验;通过 cardTypeConfigs 驱动各卡片的识别配置,并完成初步的功能测试与验证。 / Refactored settings page into five cards for better stability and partial refresh; unified recognition configurations via cardTypeConfigs with initial testing and verification.
This commit is contained in:
parent
c367a772c7
commit
0fdf9c8ef3
13 changed files with 1788 additions and 1414 deletions
151
docs/settings-page-cardization-decisions.md
Normal file
151
docs/settings-page-cardization-decisions.md
Normal file
|
|
@ -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. 有意偏离点
|
||||
|
||||
- 任务合同要求“表格真正驱动运行时识别”,这里采用“新识别配置驱动运行时,旧字段仅做兼容镜像”的路径,而不是彻底删除旧字段。
|
||||
- 这是仓库兼容优先的选择:能在一轮内完成端到端改造,同时避免对尚未迁移的辅助链路造成连锁破坏。
|
||||
144
docs/settings-page-cardization-gap-report.md
Normal file
144
docs/settings-page-cardization-gap-report.md
Normal file
|
|
@ -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 张稳定卡片并接入局部刷新
|
||||
1
src/application/config/ManagedNoteModels.ts
Normal file
1
src/application/config/ManagedNoteModels.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const QA_GROUP_MODEL_NAME = "ObsiAnki QA Group 12";
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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<CardTypeConfigId, CardTypeConfig>;
|
||||
|
||||
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<string, NoteModelFieldMapping>;
|
||||
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<PluginSettings> | 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<PluginSettings> | 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<number, CardTypeConfigId[]>();
|
||||
|
||||
for (const configId of CARD_TYPE_CONFIG_IDS) {
|
||||
const config = (cardTypeConfigs as Partial<CardTypeConfigs>)[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<string, NoteModelFieldMapping>): void {
|
||||
if (!noteFieldMappings || typeof noteFieldMappings !== "object" || Array.isArray(noteFieldMappings)) {
|
||||
throw new PluginUserError("errors.settings.noteFieldMappingsObject");
|
||||
|
|
@ -240,4 +280,122 @@ function validateNoteFieldMappings(noteFieldMappings: Record<string, NoteModelFi
|
|||
throw new PluginUserError("errors.settings.noteFieldMappingsLoadedAt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultCardTypeConfigs(): CardTypeConfigs {
|
||||
return {
|
||||
basic: {
|
||||
enabled: true,
|
||||
headingLevel: DEFAULT_BASIC_HEADING_LEVEL,
|
||||
extraMarker: "",
|
||||
noteType: DEFAULT_BASIC_NOTE_TYPE,
|
||||
},
|
||||
"qa-group": {
|
||||
enabled: true,
|
||||
headingLevel: DEFAULT_BASIC_HEADING_LEVEL,
|
||||
extraMarker: DEFAULT_QA_GROUP_MARKER,
|
||||
noteType: QA_GROUP_MODEL_NAME,
|
||||
},
|
||||
cloze: {
|
||||
enabled: true,
|
||||
headingLevel: DEFAULT_CLOZE_HEADING_LEVEL,
|
||||
extraMarker: "",
|
||||
noteType: DEFAULT_CLOZE_NOTE_TYPE,
|
||||
},
|
||||
"semantic-qa": {
|
||||
enabled: true,
|
||||
headingLevel: DEFAULT_BASIC_HEADING_LEVEL,
|
||||
extraMarker: DEFAULT_SEMANTIC_QA_MARKER,
|
||||
noteType: DEFAULT_SEMANTIC_QA_NOTE_TYPE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createLegacyBackfilledCardTypeConfigs(settings: Partial<PluginSettings>): 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<Record<CardTypeConfigId, Partial<CardTypeConfig>>> | null | undefined,
|
||||
legacySettings: Partial<PluginSettings>,
|
||||
): 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<PluginSettings, "qaHeadingLevel" | "clozeHeadingLevel" | "qaGroupMarker" | "qaNoteType" | "clozeNoteType" | "semanticQaMarker" | "semanticQaNoteType"> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<number, number>();
|
||||
|
||||
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<string, CardState[]> {
|
||||
const grouped = new Map<string, CardState[]>();
|
||||
|
||||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 标记不能为空。",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue