重构设置页为五卡片并统一识别配置 / 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:
Dusk 2026-04-24 12:12:10 +08:00
parent c367a772c7
commit 0fdf9c8ef3
13 changed files with 1788 additions and 1414 deletions

View 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 1Anki 模板加载、表格行编辑、错误状态,仅刷新 card 1 body
- card 2同步内容相关设置仅刷新 card 2 body
- card 3scope mode 切换刷新 card 3文件夹展开/勾选优先只刷新 tree region
- card 4deck 开关和 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. 有意偏离点
- 任务合同要求“表格真正驱动运行时识别”,这里采用“新识别配置驱动运行时,旧字段仅做兼容镜像”的路径,而不是彻底删除旧字段。
- 这是仓库兼容优先的选择:能在一轮内完成端到端改造,同时避免对尚未迁移的辅助链路造成连锁破坏。

View 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 张稳定卡片并接入局部刷新

View file

@ -0,0 +1 @@
export const QA_GROUP_MODEL_NAME = "ObsiAnki QA Group 12";

View file

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

View file

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

View file

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

View file

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

View file

@ -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];

View file

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

View file

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

View file

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