feat: 调整正文同步分组顺序并将中文 Cloze 改为填空题 / reorder sync-content sections and rename Chinese Cloze copy to 填空题

This commit is contained in:
Dusk 2026-04-26 00:20:01 +08:00
parent cb2873e379
commit 60ef462ad1
6 changed files with 277 additions and 27 deletions

View file

@ -0,0 +1,73 @@
# Sync Content Copy Order Decisions
## 1. 最终分组顺序
`renderSyncContentCard()` 的最终分组顺序固定为:
1. `1. 确定「卡片正文」范围`
2. `2. 确定是否增加回链,方便从 Anki「卡片级跳转」回 Obsidian`
3. `3. 确定是否读取「标签」`
4. `4. 「填空题」专项`
每组内的设置固定为:
1. 正文范围组:`卡片正文截止模式`
2. 回链组:`添加 Obsidian 回链`、`Obsidian 回链显示名称`、`Obsidian 回链放置位置`
3. 标签组:`同步 Obsidian 标签到 Anki`、`在卡片正文中保留纯标签行`
4. 填空题组:`高亮转填空题`
## 2. 英文分组标题
英文 locale 固定使用以下标题:
1. `1. Choose the card body range`
2. `2. Add backlinks for card-level jumps from Anki to Obsidian`
3. `3. Read tags`
4. `4. Cloze-specific options`
## 3. 中文 `Cloze` 文案替换范围
本次把中文用户可见 `Cloze` 统一替换为 `填空题`,覆盖范围包括:
1. 设置页同步内容卡片中的 `高亮转 Cloze` 与其描述
2. 中文设置项 `Cloze 标题层级`
3. 中文字段映射分区里的 `Cloze` 标题、描述和 `Cloze 主字段`
4. 中文设置校验错误里的 `Cloze` 文案
5. 中文字段映射错误里的 `Cloze` 文案
保留不变的部分:
1. 代码里的 `cloze` 标识
2. 配置字段 `convertHighlightsToCloze`
3. 类型名、枚举值、JSON schema、Anki 逻辑
4. 英文 locale 中的 `Cloze`
## 4. 兼容性边界
本次只改 UI copy 和显示顺序,不改变:
1. 设置保存字段名
2. TypeScript 类型名
3. `cloze` 内部标识
4. Anki note type / field mapping 行为
5. 同步逻辑和渲染逻辑
## 5. 范围锁定
本次刻意不改:
1. `settings.cards.syncContent.title`
2. 任何英文 `Cloze` 正文
3. 任何非中文、非用户可见的内部代码标识
原因是本轮合同只要求:
1. sync-content 小标题顺序与序号
2. 中文用户可见 `Cloze` 文案改成 `填空题`
## 6. 与计划的显式偏差
只有一个需要记录的偏差:
1. 未发现单独的计划文档文件
2. 因此按用户消息中的实施合同逐条执行

View file

@ -0,0 +1,100 @@
# Sync Content Copy Order Gap Report
## 审查范围
本次按真实仓库实现审查了以下位置:
1. [src/presentation/settings/PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts) 的 `renderSyncContentCard()`
2. [src/presentation/i18n/messages/zh.ts](src/presentation/i18n/messages/zh.ts)
3. [src/presentation/i18n/messages/en.ts](src/presentation/i18n/messages/en.ts)
4. [src/presentation/settings/PluginSettingTab.test.ts](src/presentation/settings/PluginSettingTab.test.ts)
## 当前实现确认
### 1. `renderSyncContentCard()` 还没有真正的分组结构
当前实现只是:
1. 先渲染一段描述文案
2. 直接连续创建 7 个 `Setting`
没有:
1. 分组标题
2. 分组容器
3. 序号标题
### 2. 当前顺序与目标顺序不一致
当前 `sync-content` 卡片中的设置顺序是:
1. 卡片正文截止模式
2. 添加 Obsidian 回链
3. Obsidian 回链显示名称
4. Obsidian 回链放置位置
5. 高亮转 Cloze
6. 同步 Obsidian 标签到 Anki
7. 在卡片正文中保留纯标签行
与本轮要求相比,问题是:
1. `高亮转 Cloze` 目前出现在标签设置之前
2. 没有 4 个带序号的小标题
### 3. `settings.cards.syncContent.sections.*` 还不存在
中英文 locale 当前都只有:
1. `settings.cards.syncContent.title`
2. `settings.cards.syncContent.desc`
还没有本轮要求的:
1. `settings.cards.syncContent.sections.bodyRange`
2. `settings.cards.syncContent.sections.backlink`
3. `settings.cards.syncContent.sections.tags`
4. `settings.cards.syncContent.sections.clozeSpecial`
### 4. 中文用户可见文案里仍有多处 `Cloze`
已确认当前中文 locale 中至少还有这些用户可见 `Cloze`
1. `Cloze 标题层级`
2. `高亮转 Cloze`
3. `把 ==highlight== 片段转换成 cloze 卡片使用的挖空格式。`
4. `Cloze`
5. `选择 Cloze 笔记类型,从 Anki 读取字段,然后确认主字段映射。`
6. `Cloze 主字段`
7. 多条设置校验和字段映射错误消息中的 `Cloze`
其中 `settings.cards.cardTypes.rows.cloze` 已经是 `填空题`,这部分不需要改。
### 5. 现有设置页测试还没覆盖 `sync-content` 的顺序和文案
当前 [src/presentation/settings/PluginSettingTab.test.ts](src/presentation/settings/PluginSettingTab.test.ts) 已覆盖:
1. 卡片默认折叠
2. sticky header
3. card-types 局部刷新与状态文案
4. deck / scope 等设置行为
但还没有专门验证:
1. `sync-content` 分组标题顺序
2. `高亮转填空题` 替代 `高亮转 Cloze`
3. 该 toggle 仍写回 `convertHighlightsToCloze`
## 建议的最小落点
1. 在 `renderSyncContentCard()` 中加入 4 个分组容器和标题
2. 按目标顺序把 7 个现有 `Setting` 重新分配到 4 个分组
3. 在 `zh.ts` / `en.ts` 增补 `settings.cards.syncContent.sections.*`
4. 把中文用户可见 `Cloze` 文案统一替换为 `填空题`
5. 在 `PluginSettingTab.test.ts` 补充 `sync-content` 顺序、文案和保存字段测试
## 与计划的显式偏差
有一个需要记录的仓库现实差异:
1. 仓库里没有发现单独可读的“attached plan document”文件路径
2. 本次实现以用户提示文本本身作为执行合同

View file

@ -276,6 +276,12 @@ export const en = {
syncContent: {
title: "Sync content",
desc: "These options affect rendered content only. Updating them refreshes this card only.",
sections: {
bodyRange: "1. Choose the card body range",
backlink: "2. Add backlinks for card-level jumps from Anki to Obsidian",
tags: "3. Read tags",
clozeSpecial: "4. Cloze-specific options",
},
},
scope: {
title: "Card sync scope",

View file

@ -20,7 +20,7 @@ export const zh = {
desc: "默认是 H4",
},
clozeHeadingLevel: {
name: "Cloze 标题层级",
name: "填空题标题层级",
desc: "默认是 H5",
},
cardAnswerCutoffMode: {
@ -74,8 +74,8 @@ export const zh = {
},
},
highlightsToCloze: {
name: "高亮转 Cloze",
desc: "把 ==highlight== 片段转换成 cloze 卡片使用的挖空格式。",
name: "高亮转填空题",
desc: "把 ==highlight== 片段转换成填空题使用的挖空格式。",
},
syncObsidianTagsToAnki: {
name: "同步 Obsidian 标签到 Anki",
@ -113,8 +113,8 @@ export const zh = {
description: "选择 QA 笔记类型,从 Anki 读取字段,然后确认标题/正文映射。",
},
cloze: {
title: "Cloze",
description: "选择 Cloze 笔记类型,从 Anki 读取字段,然后确认主字段映射。",
title: "填空题",
description: "选择填空题笔记类型,从 Anki 读取字段,然后确认主字段映射。",
},
semanticQa: {
title: "语义 QA",
@ -136,7 +136,7 @@ export const zh = {
bodyFieldLabel: "{{title}} 正文字段",
bodyFieldDesc: "选择接收正文片段的 Anki 字段。",
mainField: {
name: "Cloze 主字段",
name: "填空题主字段",
desc: "同步时,所选字段会接收 标题 + <br><br> + 正文。",
},
selectFieldPlaceholder: "-- 选择字段 --",
@ -274,6 +274,12 @@ export const zh = {
syncContent: {
title: "同步内容",
desc: "这些选项只影响卡片渲染内容,修改后仅刷新这一张卡片。",
sections: {
bodyRange: "1. 确定「卡片正文」范围",
backlink: "2. 确定是否增加回链,方便从 Anki「卡片级跳转」回 Obsidian",
tags: "3. 确定是否读取「标签」",
clozeSpecial: "4. 「填空题」专项",
},
},
scope: {
title: "卡片同步范围",
@ -351,7 +357,7 @@ export const zh = {
},
settings: {
headingLevelsRange: "标题层级必须是 1 到 6 之间的整数。",
headingLevelsDifferent: "QA 和 Cloze 的标题层级必须不同。",
headingLevelsDifferent: "QA 和填空题的标题层级必须不同。",
cardTypeConfigsObject: "卡片类型配置必须是对象。",
cardTypeEnabledBoolean: "每种卡片类型的启用状态必须是布尔值。",
cardTypeExtraMarkerString: "每种卡片类型的额外标记必须是字符串。",
@ -360,7 +366,7 @@ export const zh = {
qaNoteTypeRequired: "QA 笔记类型不能为空。",
qaGroupMarkerRequired: "QA Group 标记不能为空。",
qaGroupMarkerInvalid: "QA Group 标记必须是类似 #anki-list 的 hashtag 样式 token。",
clozeNoteTypeRequired: "Cloze 笔记类型不能为空。",
clozeNoteTypeRequired: "填空题笔记类型不能为空。",
semanticQaMarkerRequired: "语义 QA 标记不能为空。",
semanticQaMarkerInvalid: "语义 QA 标记必须是类似 #anki-list-qa 的 hashtag 样式 token。",
qaGroupMarkerConflict: "QA Group 标记必须和语义 QA 标记不同。",
@ -405,26 +411,26 @@ export const zh = {
noteFieldMapping: {
noteTypeNotSelected: {
basic: "基础卡尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
cloze: "Cloze 尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
cloze: "填空题尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
semanticQa: "语义 QA 尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
qaGroup: "问答题(多级列表)尚未选择 Anki 笔记模板。请先在设置页选择模板后再同步。",
},
missingSavedMapping: {
basic: "找不到基础卡笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
cloze: "找不到 Cloze 笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
cloze: "找不到填空题笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
semanticQa: "找不到语义 QA 笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
},
incompleteSavedMapping: {
basic: "基础卡笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存标题字段和正文字段。",
semanticQa: "语义 QA 笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存标题字段和正文字段。",
cloze: "Cloze 笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存主字段。",
cloze: "填空题笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存主字段。",
},
titleBodyMustDiffer: {
basic: "基础卡笔记类型 \"{{modelName}}\" 的标题字段和正文字段必须不同。",
semanticQa: "语义 QA 笔记类型 \"{{modelName}}\" 的标题字段和正文字段必须不同。",
},
stale: "笔记类型 \"{{modelName}}\" 的已保存字段映射已过期,因为这些字段在 Anki 中已不存在:{{fields}}。请重新从 Anki 读取字段。",
clozeIncompatible: "Cloze 笔记类型 \"{{modelName}}\" 在 Anki 中不是 cloze 兼容模型。",
clozeIncompatible: "填空题笔记类型 \"{{modelName}}\" 在 Anki 中不是填空题兼容模型。",
qaGroupMissingTitle: "问答题(多级列表)笔记模板 \"{{modelName}}\" 缺少可识别的题目字段。请使用 题目、标题、正面、Stem 或 Title 之一。",
qaGroupNoCompleteSlots: "问答题(多级列表)笔记模板 \"{{modelName}}\" 没有可用的完整问题/答案字段组。",
qaGroupNonContinuousSlots: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的第一组问题/答案字段不是从第 {{firstIndex}} 组开始连续编号。请从第 1 组开始。",

View file

@ -547,6 +547,10 @@ function collectTexts(container: QueryRoot): string[] {
.filter((text): text is string => Boolean(text));
}
function collectSettingNames(container: QueryRoot): string[] {
return asFakeContainer(container).settings.map((setting) => setting.name);
}
function collectOptionValues(selectEl: FakeElementInstance): string[] {
return selectEl.children
.filter((element) => element.tag === "option")
@ -735,6 +739,48 @@ describe("PluginSettingTab", () => {
expect(collectTexts(tab.containerEl)).not.toContain("直接编辑识别规则,修改后自动保存。");
});
it("renders sync-content section titles and settings in the new grouped order", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await expandCard(tab, "sync-content");
const sectionTitles = queryAllByDataset(tab.containerEl, "syncContentSectionTitle").map((element) => element.textContent || element.text);
expect(sectionTitles).toEqual([
"1. 确定「卡片正文」范围",
"2. 确定是否增加回链,方便从 Anki「卡片级跳转」回 Obsidian",
"3. 确定是否读取「标签」",
"4. 「填空题」专项",
]);
expect(collectSettingNames(tab.containerEl)).toEqual([
"卡片正文截止模式",
"添加 Obsidian 回链",
"Obsidian 回链显示名称",
"Obsidian 回链放置位置",
"同步 Obsidian 标签到 Anki",
"在卡片正文中保留纯标签行",
"高亮转填空题",
]);
expect(findSetting(tab.containerEl, "高亮转填空题")).toBeDefined();
expect(() => findSetting(tab.containerEl, "高亮转 Cloze")).toThrow("Setting not found");
});
it("keeps saving highlights-to-cloze through the same internal config field", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await expandCard(tab, "sync-content");
const highlightsSetting = findSetting(tab.containerEl, "高亮转填空题");
await getToggle(highlightsSetting).triggerChange(false);
expect(plugin.settings.convertHighlightsToCloze).toBe(false);
expect(plugin.updateCalls).toContainEqual({ convertHighlightsToCloze: false });
});
it("auto saves toggle, heading, note type and field mapping edits", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);

View file

@ -497,7 +497,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private renderSyncContentCard(containerEl: HTMLElement): void {
containerEl.createEl("p", { text: t("settings.cards.syncContent.desc") });
new Setting(containerEl)
const bodyRangeSection = this.createSyncContentSection(containerEl, "body-range", t("settings.cards.syncContent.sections.bodyRange"));
new Setting(bodyRangeSection)
.setName(t("settings.cardAnswerCutoffMode.name"))
.setDesc(t("settings.cardAnswerCutoffMode.desc"))
.addDropdown((dropdown) => {
@ -508,7 +509,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl)
const backlinkSection = this.createSyncContentSection(containerEl, "backlink", t("settings.cards.syncContent.sections.backlink"));
new Setting(backlinkSection)
.setName(t("settings.syncOptions.addObsidianBacklink.name"))
.setDesc(t("settings.syncOptions.addObsidianBacklink.desc"))
.addToggle((toggle) => {
@ -517,7 +519,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl)
new Setting(backlinkSection)
.setName(t("settings.syncOptions.obsidianBacklinkLabel.name"))
.setDesc(t("settings.syncOptions.obsidianBacklinkLabel.desc"))
.addText((text) => {
@ -535,7 +537,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl)
new Setting(backlinkSection)
.setName(t("settings.syncOptions.obsidianBacklinkPlacement.name"))
.setDesc(t("settings.syncOptions.obsidianBacklinkPlacement.desc"))
.addDropdown((dropdown) => {
@ -547,16 +549,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl)
.setName(t("settings.syncOptions.highlightsToCloze.name"))
.setDesc(t("settings.syncOptions.highlightsToCloze.desc"))
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.convertHighlightsToCloze).onChange((value) => {
void this.plugin.updateSettings({ convertHighlightsToCloze: value });
});
});
new Setting(containerEl)
const tagsSection = this.createSyncContentSection(containerEl, "tags", t("settings.cards.syncContent.sections.tags"));
new Setting(tagsSection)
.setName(t("settings.syncOptions.syncObsidianTagsToAnki.name"))
.setDesc(t("settings.syncOptions.syncObsidianTagsToAnki.desc"))
.addToggle((toggle) => {
@ -565,7 +559,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl)
new Setting(tagsSection)
.setName(t("settings.syncOptions.keepPureTagLinesInCardBody.name"))
.setDesc(t("settings.syncOptions.keepPureTagLinesInCardBody.desc"))
.addToggle((toggle) => {
@ -573,6 +567,31 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
void this.plugin.updateSettings({ keepPureTagLinesInCardBody: value });
});
});
const clozeSection = this.createSyncContentSection(containerEl, "cloze-special", t("settings.cards.syncContent.sections.clozeSpecial"));
new Setting(clozeSection)
.setName(t("settings.syncOptions.highlightsToCloze.name"))
.setDesc(t("settings.syncOptions.highlightsToCloze.desc"))
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.convertHighlightsToCloze).onChange((value) => {
void this.plugin.updateSettings({ convertHighlightsToCloze: value });
});
});
}
private createSyncContentSection(containerEl: HTMLElement, sectionId: string, title: string): HTMLElement {
const sectionEl = containerEl.createDiv();
sectionEl.dataset.syncContentSection = sectionId;
sectionEl.style.display = "flex";
sectionEl.style.flexDirection = "column";
sectionEl.style.gap = "8px";
sectionEl.style.marginTop = "16px";
const titleEl = sectionEl.createEl("h4", { text: title });
titleEl.dataset.syncContentSectionTitle = sectionId;
titleEl.style.margin = "0";
return sectionEl;
}
private renderScopeCard(containerEl: HTMLElement): void {