diff --git a/docs/sync-content-copy-order-decisions.md b/docs/sync-content-copy-order-decisions.md new file mode 100644 index 0000000..90d3325 --- /dev/null +++ b/docs/sync-content-copy-order-decisions.md @@ -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. 因此按用户消息中的实施合同逐条执行 \ No newline at end of file diff --git a/docs/sync-content-copy-order-gap-report.md b/docs/sync-content-copy-order-gap-report.md new file mode 100644 index 0000000..a512c97 --- /dev/null +++ b/docs/sync-content-copy-order-gap-report.md @@ -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. 本次实现以用户提示文本本身作为执行合同 \ No newline at end of file diff --git a/src/presentation/i18n/messages/en.ts b/src/presentation/i18n/messages/en.ts index d0e1d5b..889add5 100644 --- a/src/presentation/i18n/messages/en.ts +++ b/src/presentation/i18n/messages/en.ts @@ -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", diff --git a/src/presentation/i18n/messages/zh.ts b/src/presentation/i18n/messages/zh.ts index 269619e..b11c3db 100644 --- a/src/presentation/i18n/messages/zh.ts +++ b/src/presentation/i18n/messages/zh.ts @@ -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: "同步时,所选字段会接收 标题 +

+ 正文。", }, 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 组开始。", diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index 0b32a53..ff564cb 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -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); diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index c29a4b5..11d772b 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -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 {