From 579899f7c8011085c346d4b44403babf98106443 Mon Sep 17 00:00:00 2001 From: Dusk Date: Thu, 23 Apr 2026 16:43:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20sync=20Obsidian=20tags=20and=20pure=20t?= =?UTF-8?q?ag=20line=20cleanup=20/=20=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20Obsidian=20=E6=A0=87=E7=AD=BE=E5=B9=B6=E6=B8=85?= =?UTF-8?q?=E7=90=86=E7=BA=AF=E6=A0=87=E7=AD=BE=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EN: Implement one-way Obsidian to Anki tag sync, nested tag normalization, pure tag-line body cleanup, and QA Group coverage.\nZH: 实现 Obsidian 到 Anki 的单向标签覆盖、嵌套标签规范化、正文纯标签行清理,并补齐 QA Group 覆盖。 --- docs/tag-sync-and-pure-tag-line-decisions.md | 165 +++++++++++++++ docs/tag-sync-and-pure-tag-line-gap-report.md | 188 ++++++++++++++++++ src/application/config/PluginSettings.test.ts | 18 ++ src/application/config/PluginSettings.ts | 12 ++ src/application/ports/AnkiGateway.ts | 8 + .../services/AnkiBatchExecutor.test.ts | 95 +++++++++ src/application/services/AnkiBatchExecutor.ts | 25 +++ .../services/FileIndexerService.test.ts | 55 +++++ .../services/FileIndexerService.ts | 7 +- src/application/services/ManualSyncService.ts | 2 + .../services/QaGroupSyncService.test.ts | 97 +++++++++ .../services/QaGroupSyncService.ts | 88 ++++++-- .../services/RenderConfigService.ts | 1 + src/domain/card/entities/SourceFile.ts | 1 + .../entities/IndexedGroupCardBlock.ts | 1 + .../manual-sync/entities/PluginState.ts | 1 + .../services/CardIndexingService.test.ts | 88 ++++++++ .../services/CardIndexingService.ts | 15 +- .../services/DiffPlannerService.test.ts | 51 +++++ .../services/DiffPlannerService.ts | 3 + .../services/ManualCardRenderer.ts | 6 +- .../preprocessCardBodyMarkdown.test.ts | 54 +++++ .../services/preprocessCardBodyMarkdown.ts | 58 ++++++ .../manual-sync/services/tagSetUtils.ts | 39 ++++ .../anki/AnkiConnectGateway.test.ts | 61 +++++- src/infrastructure/anki/AnkiConnectGateway.ts | 33 ++- .../obsidian/ObsidianVaultGateway.test.ts | 64 ++++++ .../obsidian/ObsidianVaultGateway.ts | 17 +- .../obsidian/normalizeObsidianTags.ts | 34 ++++ .../DataJsonPluginStateRepository.ts | 1 + src/presentation/i18n/messages/en.ts | 10 + src/presentation/i18n/messages/zh.ts | 10 + .../settings/PluginSettingTab.test.ts | 29 +++ src/presentation/settings/PluginSettingTab.ts | 18 ++ src/test-support/manualSyncFakes.ts | 37 +++- src/test-support/obsidianStub.ts | 30 +++ 36 files changed, 1387 insertions(+), 35 deletions(-) create mode 100644 docs/tag-sync-and-pure-tag-line-decisions.md create mode 100644 docs/tag-sync-and-pure-tag-line-gap-report.md create mode 100644 src/domain/manual-sync/services/preprocessCardBodyMarkdown.test.ts create mode 100644 src/domain/manual-sync/services/preprocessCardBodyMarkdown.ts create mode 100644 src/domain/manual-sync/services/tagSetUtils.ts create mode 100644 src/infrastructure/obsidian/normalizeObsidianTags.ts diff --git a/docs/tag-sync-and-pure-tag-line-decisions.md b/docs/tag-sync-and-pure-tag-line-decisions.md new file mode 100644 index 0000000..6f553ea --- /dev/null +++ b/docs/tag-sync-and-pure-tag-line-decisions.md @@ -0,0 +1,165 @@ +# Tag Sync And Pure Tag Line Decisions + +## 1. 单向覆盖语义 + +本次实现统一遵守以下所有权规则: + +1. Obsidian 是唯一真源 +2. Anki 是被覆盖目标端 +3. 标题、正文、tags、deck、QA Group 字段都以当前 Obsidian 计算结果为准 +4. 不保留 Anki 手工 tags +5. 不做正文冲突合并 +6. 不给 QA Group 单独的所有权例外 + +## 2. 文件级标签来源 + +文件级标签统一从 Obsidian 官方 metadata cache 提取: + +1. `app.metadataCache.getFileCache(file)` +2. `getAllTags(cache)` + +如果 cache 不存在或没有 tags,则返回空数组。 + +## 3. 标签规范化规则 + +规范化规则固定为: + +1. 去掉前导 `#` +2. 以 `/` 分段 +3. 过滤空段 +4. 再以 `::` 拼接输出到 Anki +5. 去重并保留首次出现顺序 +6. 保留 emoji +7. 保留中文 +8. 保留原始大小写 + +示例固定为: + +1. `#3地区 -> 3地区` +2. `#📖/一人公司 -> 📖::一人公司` +3. `#a/b/c -> a::b::c` + +## 4. 纯标签行清理的接入位置 + +纯标签行清理不会放在 metadata tags 提取中,也不会写回源文件。 + +实现位置固定为共享正文预处理函数,输入输出都是 markdown 文本: + +1. 普通卡在 `ManualCardRenderer` 渲染 body 前调用 +2. QA Group 在构造 `Sxx_A` 前调用 + +这样可以满足: + +1. basic / cloze / semantic QA 复用同一规则 +2. QA Group 复用同一规则 +3. 关闭 tags 同步时仍可单独清理正文 + +## 5. 纯标签行清理规则 + +纯标签行定义固定为: + +1. 行首尾 trim 后 +2. 整行只包含一个或多个合法 tag token +3. token 之间允许空白 +4. 不允许其他普通文字或说明性标点 + +处理规则固定为: + +1. 删除全文所有纯标签行 +2. 不删除行内标签 +3. 不删除含普通文字的标签行 +4. 不影响标题 trailing hashtags +5. 删除后压缩因删除产生的多余连续空行,保留自然段间距 + +## 6. 设置与 fingerprint 策略 + +新增两个布尔设置: + +1. `syncObsidianTagsToAnki = true` +2. `keepPureTagLinesInCardBody = true` + +并把两者纳入 `createDeckRulesFingerprint()` 的 payload,同时提升 fingerprint version。 + +决策理由: + +1. 这两个设置都会影响同步产物 +2. 现有仓库已经用这个 fingerprint 触发文件重读 +3. 复用现有失效机制比引入第二套 fingerprint 更小更稳 + +## 7. 索引与状态策略 + +普通卡路径: + +1. `SourceFile.tags` 承载文件级 tags +2. `CardIndexingService` 为 basic / cloze / semantic QA 写入 `tagsHint` +3. 当 `syncObsidianTagsToAnki = false` 时统一写空数组 + +QA Group 路径: + +1. `IndexedGroupCardBlock` 增加 `tagsHint` +2. `GroupBlockState` 增加 `tagsHint` +3. QA Group 的 tags 也完全由文件级 tags 决定 + +## 8. Diff 策略 + +普通卡更新判定新增一条: + +1. `existingState.tagsHint` 与 `card.tagsHint` 不同,则进入 `toUpdate` + +同时继续依赖 `renderConfigHash` 覆盖纯标签行保留开关变化。 + +QA Group 更新判定不新增独立 planner,而是在 `QaGroupSyncService` 内部比较: + +1. 字段变化 +2. deck 变化 +3. tags 变化 + +只要任一变化存在,就执行覆盖更新。 + +## 9. Anki 标签同步策略 + +接口层新增: + +1. `AnkiNoteSummary.tags?: string[]` +2. `SyncAnkiNoteTagsInput` +3. `AnkiGateway.syncNoteTags()` + +执行规则固定为: + +1. 新建 note 继续在 `addNote/addNotes` 时直接写入 tags +2. 更新已有 note 时读取当前 tags +3. 计算 `removeTags` 与 `addTags` +4. 同一 note 固定先 remove 后 add +5. 无差异时不发额外请求 + +## 10. QA Group 仓库兼容决策 + +计划要求 QA Group 也应用纯标签行清理与 tags 覆盖。 + +基于当前仓库结构,本次不把 QA Group 重构进普通 renderer,而是: + +1. 保持 `QaGroupSyncService` 作为专用同步器 +2. 在其字段构造阶段调用共享正文预处理 +3. 在其 note 同步阶段调用共享 tag diff 逻辑 + +这是本次唯一的仓库兼容偏差,目的是最小改动完成同等产品语义。 + +## 11. 测试范围锁定 + +本次必须补齐以下测试: + +1. Obsidian metadata tag 提取与规范化 +2. pure-tag-line cleanup 工具 +3. CardIndexingService 的 `tagsHint` +4. DiffPlannerService 的 tag-only / setting-only 更新 +5. QA Group 的 tags 与正文清理 +6. AnkiConnectGateway 的 note tags 读写 +7. AnkiBatchExecutor 的新建 / 更新 tag 行为 +8. settings 持久化与设置页 toggle 文案 + +不扩展以下范围: + +1. 反向同步 +2. 正文 merge 策略 +3. 新的 notice 流程 +4. QA Group 的 markdown 渲染重构 \ No newline at end of file diff --git a/docs/tag-sync-and-pure-tag-line-gap-report.md b/docs/tag-sync-and-pure-tag-line-gap-report.md new file mode 100644 index 0000000..2a6ebe6 --- /dev/null +++ b/docs/tag-sync-and-pure-tag-line-gap-report.md @@ -0,0 +1,188 @@ +# Tag Sync And Pure Tag Line Gap Report + +## 审查范围 + +本次审查覆盖当前真实同步链路中的以下实现面: + +1. `PluginSettings`、默认值、校验、持久化、设置页、i18n +2. `SourceFile`、`IndexedCard`、`PluginState`、`FileState`、`GroupBlockState` +3. `ObsidianVaultGateway` +4. `CardIndexingService`、`FileIndexerService`、`DiffPlannerService` +5. `ManualCardRenderer` 与 `RenderConfigService` +6. `QaGroupBlockParser`、`QaGroupSyncService` +7. `AnkiGateway`、`AnkiConnectGateway`、`AnkiBatchExecutor` +8. 现有相关测试 + +## 当前可复用能力 + +### 1. 标签字段已经预留到卡片状态 + +当前仓库已经在以下对象中保留了 `tagsHint`: + +1. `IndexedCard` +2. `CardState` +3. `ManualSyncService.buildNextState()` +4. `DataJsonPluginStateRepository.migrateCardState()` +5. `AnkiBatchExecutor` 的新建 note 路径 + +这意味着普通卡的 tags 同步不需要新增整套 state 结构,只需要把真实文件级 tags 写进去,并让 diff / executor 真正使用。 + +### 2. deck fingerprint 已经承担“设置变化触发重读”职责 + +`FileIndexerService.createDeckRulesFingerprint()` 已经用于: + +1. 文件未改但设置变化时强制重读 +2. rebuild / normal sync 的增量失效 + +这可以直接复用来让“标签同步开关”和“纯标签行保留开关”升级后触发重新扫描。 + +### 3. QA Group 已有独立同步链路 + +`QaGroupSyncService` 已独立处理: + +1. note 创建 +2. note 更新 +3. deck 覆盖 +4. GI marker 写回 + +因此 QA Group 不是缺失同步能力,而是当前没有接入 tags,也没有接入正文纯标签行清理。 + +## 当前缺口 + +### 1. `SourceFile` 没有文件级 tags + +当前 `SourceFile` 只有: + +1. `path` +2. `basename` +3. `content` + +`ObsidianVaultGateway.toSourceFile()` 也只读取 `cachedRead()`,没有读取 `metadataCache.getFileCache(file)`,因此: + +1. frontmatter tags 不会进入同步链路 +2. inline tags 不会进入同步链路 +3. nested tags 不会进入同步链路 + +### 2. `CardIndexingService` 明确把 `tagsHint` 写死为空数组 + +当前 basic / cloze / semantic QA 三条路径都写: + +1. `tagsHint: []` + +这直接阻断了普通卡片的标签同步。 + +### 3. QA Group 创建和更新固定使用空 tags + +`QaGroupSyncService` 在 create 路径里固定传: + +1. `tags: []` + +更新路径只更新字段与 deck,不比较或同步现有 note tags,因此 QA Group 当前完全不覆盖标签。 + +### 4. `AnkiGateway` / `AnkiConnectGateway` 没有 note tags 读写能力 + +当前接口层缺少: + +1. `AnkiNoteSummary.tags` +2. `syncNoteTags()` +3. add / remove tag 的批量接口封装 + +`AnkiConnectGateway.getNoteDetails()` / `getNoteSummaries()` 也没有把 `notesInfo` 返回的 tags 映射出来。 + +这意味着更新已有 note 时,仓库没有能力做“以 Obsidian 为准”的 tag 覆盖。 + +### 5. `DiffPlannerService` 不把 tags 变化视为更新条件 + +当前 `fieldsChanged` 只比较: + +1. `rawBlockHash` +2. `renderConfigHash` +3. `orphan` + +没有比较 `tagsHint`。因此即使只改了标签,也不会进入 `toUpdate`。 + +### 6. 纯标签行清理尚未进入任何正文渲染链路 + +当前 `ManualCardRenderer.renderMarkdown()` 只处理: + +1. code fence / inline code 保护 +2. 数学 +3. cloze / highlight +4. embeds / wikilinks + +没有任何“纯标签行”删除逻辑。 + +### 7. `renderConfigHash` 不感知纯标签行保留开关 + +当前 `RenderConfigService` 的 hash 只包含: + +1. `cardType` +2. `noteModel` +3. `mapping` +4. `addObsidianBacklink` +5. `convertHighlightsToCloze` + +缺少“是否保留纯标签行”。因此即使切换该设置改变了最终正文,也不会触发更新。 + +### 8. QA Group 不走普通 renderer,是本次实现的仓库差异点 + +真实仓库中 QA Group 不通过 `ManualCardRenderer` 渲染,而是: + +1. parser 直接抽 `stem` / `item.title` / `item.answer` +2. `QaGroupSyncService` 直接组装 `Stem / Sxx_Q / Sxx_A` + +这与计划中“统一正文渲染链路”的表述存在实现差异。 + +如果只把纯标签行清理加进 `ManualCardRenderer`,QA Group 会成为遗漏路径。 + +### 9. 设置层缺少两个布尔字段的全链路支持 + +当前设置里不存在: + +1. `syncObsidianTagsToAnki` +2. `keepPureTagLinesInCardBody` + +因此默认值、校验、保存回填、设置页、i18n、settings tests 都需要补齐。 + +## 集成风险 + +### 1. 不能把 metadata tags 提取和正文纯标签行清理混成一个步骤 + +真实仓库里: + +1. tags 属于文件级 metadata +2. 正文渲染是卡片级文本处理 + +两者归属不同。混在一起会让“关闭标签同步但继续清理正文”无法成立。 + +### 2. 不能只在新建 note 时写 tags + +当前新建路径已经能把 `tagsHint` 传给 `addNote/addNotes`。但如果不补更新路径: + +1. 旧 note 标签不会被覆盖 +2. Anki 手工标签不会被删 +3. 单向覆盖语义会失效 + +### 3. 不能只靠 raw markdown 变化触发正文更新 + +纯标签行保留开关变化不会改源文件,只会改渲染结果。 + +如果不把该开关纳入 `renderConfigHash`,设置切换后不会有更新计划。 + +## 建议的最小落点 + +1. 在 `ObsidianVaultGateway` 扩展 `SourceFile.tags` +2. 在独立共享工具中实现 tag normalization +3. 在独立共享工具中实现 body pure-tag-line cleanup +4. 在 `CardIndexingService` 写入 basic / cloze / semantic QA 的 `tagsHint` +5. 在 `QaGroupSyncService` 接入 file-level tags 与 body cleanup +6. 在 `RenderConfigService` 与 `DiffPlannerService` 接入设置变化和 tag 变化 +7. 在 `AnkiGateway` / `AnkiConnectGateway` / `AnkiBatchExecutor` 完成已有 note 的 tag diff 覆盖 + +## 与计划的显式偏差 + +只有一个需要记录的仓库兼容偏差: + +1. QA Group 不会接入 `ManualCardRenderer` +2. 纯标签行清理会抽成共享文本预处理,并同时供普通卡 renderer 与 QA Group 字段构造复用 +这样既保持计划的产品语义一致,也尊重仓库当前 QA Group 的专用同步路径。 \ No newline at end of file diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts index b545de2..7bb598c 100644 --- a/src/application/config/PluginSettings.test.ts +++ b/src/application/config/PluginSettings.test.ts @@ -48,6 +48,24 @@ describe("PluginSettings", () => { expect(DEFAULT_SETTINGS.folderDeckMode).toBe("off"); expect(DEFAULT_SETTINGS.qaGroupMarker).toBe("#anki-list"); expect(DEFAULT_SETTINGS.cardAnswerCutoffMode).toBe("heading-block"); + expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true); + expect(DEFAULT_SETTINGS.keepPureTagLinesInCardBody).toBe(true); + }); + + it("rejects non-boolean tag sync settings", () => { + expectPluginUserError(() => { + validatePluginSettings({ + ...DEFAULT_SETTINGS, + syncObsidianTagsToAnki: "yes" as never, + }); + }, "errors.settings.syncObsidianTagsToAnkiBoolean"); + + expectPluginUserError(() => { + validatePluginSettings({ + ...DEFAULT_SETTINGS, + keepPureTagLinesInCardBody: 1 as never, + }); + }, "errors.settings.keepPureTagLinesInCardBodyBoolean"); }); it("rejects invalid QA Group markers and semantic marker collisions", () => { diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts index 7c82d67..e0e9a1a 100644 --- a/src/application/config/PluginSettings.ts +++ b/src/application/config/PluginSettings.ts @@ -27,6 +27,8 @@ export interface PluginSettings { excludeFolders: string[]; addObsidianBacklink: boolean; convertHighlightsToCloze: boolean; + syncObsidianTagsToAnki: boolean; + keepPureTagLinesInCardBody: boolean; ankiConnectUrl: string; } @@ -51,6 +53,8 @@ export const DEFAULT_SETTINGS: PluginSettings = { excludeFolders: [], addObsidianBacklink: true, convertHighlightsToCloze: true, + syncObsidianTagsToAnki: true, + keepPureTagLinesInCardBody: true, ankiConnectUrl: "http://127.0.0.1:8765", }; @@ -123,6 +127,14 @@ export function validatePluginSettings(settings: PluginSettings): void { throw new PluginUserError("errors.settings.fileDeckEnabledBoolean"); } + if (typeof settings.syncObsidianTagsToAnki !== "boolean") { + throw new PluginUserError("errors.settings.syncObsidianTagsToAnkiBoolean"); + } + + if (typeof settings.keepPureTagLinesInCardBody !== "boolean") { + throw new PluginUserError("errors.settings.keepPureTagLinesInCardBodyBoolean"); + } + if (typeof settings.fileDeckMarker !== "string") { throw new PluginUserError("errors.settings.fileDeckMarkerString"); } diff --git a/src/application/ports/AnkiGateway.ts b/src/application/ports/AnkiGateway.ts index e5cc652..9ae2620 100644 --- a/src/application/ports/AnkiGateway.ts +++ b/src/application/ports/AnkiGateway.ts @@ -14,11 +14,18 @@ export interface UpdateAnkiNoteInput { fields: Record; } +export interface SyncAnkiNoteTagsInput { + noteId: number; + addTags: string[]; + removeTags: string[]; +} + export interface AnkiNoteSummary { noteId: number; modelName: string; cardIds: number[]; deckNames?: string[]; + tags?: string[]; } export interface ChangeDeckInput { @@ -62,6 +69,7 @@ export interface AnkiGateway { deleteNotes(noteIds: number[]): Promise; updateNote(input: UpdateAnkiNoteInput): Promise; updateNotes(inputs: UpdateAnkiNoteInput[]): Promise; + syncNoteTags(inputs: SyncAnkiNoteTagsInput[]): Promise; changeDecks(inputs: ChangeDeckInput[]): Promise; deleteDecks(deckNames: string[]): Promise; storeMedia(asset: MediaAsset): Promise; diff --git a/src/application/services/AnkiBatchExecutor.test.ts b/src/application/services/AnkiBatchExecutor.test.ts index e1d9cbb..ed88d1d 100644 --- a/src/application/services/AnkiBatchExecutor.test.ts +++ b/src/application/services/AnkiBatchExecutor.test.ts @@ -98,6 +98,101 @@ describe("AnkiBatchExecutor", () => { expect(ankiGateway.addedNotes[0]?.deckName).toBe("Folder::Deck"); expect(ankiGateway.updatedNotes[0]?.deckName).toBeUndefined(); }); + + it("creates new notes with the indexed tag hint", async () => { + const ankiGateway = new CountingAnkiGateway(); + const executor = new AnkiBatchExecutor(ankiGateway); + const createCard = createPlannedCard("sync-tags"); + createCard.card.tagsHint = ["📖::一人公司", "3地区"]; + + await executor.execute( + { + toCreate: [createCard], + toUpdate: [], + toVerifyDeck: [], + toChangeDeck: [], + toRewriteMarker: [], + toOrphan: [], + unchangedCards: 0, + warnings: [], + }, + new Map([[createCard.card.syncKey, createRenderedSyncCard(createCard)]]), + async (plannedCard) => createRenderedSyncCard(plannedCard), + createModule3Settings().noteFieldMappings, + ); + + expect(ankiGateway.addedNotes[0]?.tags).toEqual(["📖::一人公司", "3地区"]); + }); + + it("syncs tag diffs for existing notes", async () => { + const ankiGateway = new CountingAnkiGateway(); + ankiGateway.noteSummariesById.set(300, { + noteId: 300, + modelName: "Basic", + cardIds: [700], + tags: ["old", "shared"], + }); + + const executor = new AnkiBatchExecutor(ankiGateway); + const updateCard = createPlannedCard("sync-update-tags", 300); + updateCard.card.tagsHint = ["shared", "fresh"]; + + await executor.execute( + { + toCreate: [], + toUpdate: [updateCard], + toVerifyDeck: [updateCard], + toChangeDeck: [], + toRewriteMarker: [], + toOrphan: [], + unchangedCards: 0, + warnings: [], + }, + new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]), + async (plannedCard) => createRenderedSyncCard(plannedCard), + createModule3Settings().noteFieldMappings, + ); + + expect(ankiGateway.syncedNoteTags).toEqual([ + { + noteId: 300, + addTags: ["fresh"], + removeTags: ["old"], + }, + ]); + }); + + it("does not sync note tags when the current and target tag sets already match", async () => { + const ankiGateway = new CountingAnkiGateway(); + ankiGateway.noteSummariesById.set(300, { + noteId: 300, + modelName: "Basic", + cardIds: [700], + tags: ["shared", "fresh"], + }); + + const executor = new AnkiBatchExecutor(ankiGateway); + const updateCard = createPlannedCard("sync-update-tags", 300); + updateCard.card.tagsHint = ["fresh", "shared"]; + + await executor.execute( + { + toCreate: [], + toUpdate: [updateCard], + toVerifyDeck: [updateCard], + toChangeDeck: [], + toRewriteMarker: [], + toOrphan: [], + unchangedCards: 0, + warnings: [], + }, + new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]), + async (plannedCard) => createRenderedSyncCard(plannedCard), + createModule3Settings().noteFieldMappings, + ); + + expect(ankiGateway.syncedNoteTags).toEqual([]); + }); }); function createPlannedCard(syncKey: string, noteId?: number, deck = "Obsidian"): PlannedCard { diff --git a/src/application/services/AnkiBatchExecutor.ts b/src/application/services/AnkiBatchExecutor.ts index dc2d267..13e19da 100644 --- a/src/application/services/AnkiBatchExecutor.ts +++ b/src/application/services/AnkiBatchExecutor.ts @@ -4,6 +4,7 @@ import { BatchScheduler } from "@/application/services/BatchScheduler"; import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService"; import type { MediaAsset } from "@/domain/card/entities/RenderedFields"; import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard"; +import { diffTagSets } from "@/domain/manual-sync/services/tagSetUtils"; import type { PlannedCard, ManualSyncPlan } from "@/domain/manual-sync/value-objects/ManualSyncPlan"; export interface AnkiBatchExecutionResult { @@ -149,6 +150,30 @@ export class AnkiBatchExecutor { ); }); + const tagSyncQueue = updateQueue.flatMap(({ plannedCard }) => { + if (!plannedCard.noteId) { + return []; + } + + const summary = noteSummariesById.get(plannedCard.noteId); + if (!summary) { + return []; + } + + const diff = diffTagSets(plannedCard.card.tagsHint, summary.tags); + if (diff.addTags.length === 0 && diff.removeTags.length === 0) { + return []; + } + + return [{ + noteId: plannedCard.noteId, + addTags: diff.addTags, + removeTags: diff.removeTags, + }]; + }); + + await this.batchScheduler.runVoidBatches(tagSyncQueue, 50, 1, (batch) => this.ankiGateway.syncNoteTags(batch)); + updated += updateQueue.length; for (const { plannedCard } of updateQueue) { if (plannedCard.noteId) { diff --git a/src/application/services/FileIndexerService.test.ts b/src/application/services/FileIndexerService.test.ts index 366fab0..dc10001 100644 --- a/src/application/services/FileIndexerService.test.ts +++ b/src/application/services/FileIndexerService.test.ts @@ -181,4 +181,59 @@ describe("FileIndexerService", () => { expect(result.skippedUnchangedFiles).toBe(0); expect(vaultGateway.readCalls).toEqual(["notes/one.md"]); }); + + it("changes the fingerprint and forces re-read when pure tag line cleanup setting changed", async () => { + const content = ["#### One", "#项目A #重点/案例", "", "Body"].join("\n"); + const oldSettings = createModule3Settings({ keepPureTagLinesInCardBody: true }); + const newSettings = createModule3Settings({ keepPureTagLinesInCardBody: false }); + const vaultGateway = new FakeManualSyncVaultGateway({ + "notes/one.md": content, + }); + const service = new FileIndexerService(vaultGateway); + const state = { + files: { + "notes/one.md": { + filePath: "notes/one.md", + fileHash: "hash-a", + fileStamp: `1:${content.length}`, + deckRulesFingerprint: createDeckRulesFingerprint(oldSettings), + lastIndexedAt: 1, + noteIds: [10], + }, + }, + cards: { + "10": { + noteId: 10, + filePath: "notes/one.md", + heading: "One", + backlinkHeadingText: "One", + headingLevel: 4, + bodyMarkdown: "#项目A #重点/案例\n\nBody", + cardType: "basic" as const, + blockStartOffset: 0, + blockEndOffset: 16, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 4, + contentEndLine: 4, + rawBlockText: content, + rawBlockHash: "hash-card", + renderConfigHash: "render-hash", + deck: "Obsidian", + deckWarnings: [], + tagsHint: [], + lastSyncedAt: 1, + orphan: false, + }, + }, + pendingWriteBack: [], + }; + + expect(createDeckRulesFingerprint(oldSettings)).not.toBe(createDeckRulesFingerprint(newSettings)); + + const result = await service.indexVault(newSettings, state); + + expect(result.skippedUnchangedFiles).toBe(0); + expect(vaultGateway.readCalls).toEqual(["notes/one.md"]); + }); }); \ No newline at end of file diff --git a/src/application/services/FileIndexerService.ts b/src/application/services/FileIndexerService.ts index 8ff14bf..f098cea 100644 --- a/src/application/services/FileIndexerService.ts +++ b/src/application/services/FileIndexerService.ts @@ -58,6 +58,7 @@ export class FileIndexerService { cardAnswerCutoffMode: settings.cardAnswerCutoffMode, qaGroupMarker: settings.qaGroupMarker, semanticQaMarker: settings.semanticQaMarker, + syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki, fileStamp, knownCards: stateIndex.cardsByFilePath.get(filePath) ?? [], knownGroupBlocks: stateIndex.groupBlocksByFilePath.get(filePath) ?? [], @@ -141,6 +142,7 @@ export class FileIndexerService { cardAnswerCutoffMode: settings.cardAnswerCutoffMode, qaGroupMarker: settings.qaGroupMarker, semanticQaMarker: settings.semanticQaMarker, + syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki, fileStamp, knownCards, knownGroupBlocks: stateIndex.groupBlocksByFilePath.get(ref.path) ?? [], @@ -262,6 +264,7 @@ function restoreIndexedGroupBlock(groupBlock: GroupBlockState): IndexedGroupCard deckHint: groupBlock.deckHint, deckHintSource: groupBlock.deckHintSource, deckWarnings: [...groupBlock.deckWarnings], + tagsHint: groupBlock.tagsHint ? [...groupBlock.tagsHint] : [], items: groupBlock.items.map((item) => ({ ...item })), freeSlots: [...groupBlock.freeSlots], }; @@ -271,7 +274,7 @@ export function createFileStamp(mtime: number, size: number): string { return `${mtime}:${size}`; } -const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v2"; +const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v3"; export function createDeckRulesFingerprint(settings: PluginSettings): string { return hashString(JSON.stringify({ @@ -285,5 +288,7 @@ export function createDeckRulesFingerprint(settings: PluginSettings): string { fileDeckEnabled: settings.fileDeckEnabled, fileDeckMarker: settings.fileDeckMarker, folderDeckMode: settings.folderDeckMode, + syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki, + keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody, })); } \ No newline at end of file diff --git a/src/application/services/ManualSyncService.ts b/src/application/services/ManualSyncService.ts index 7d21672..8402660 100644 --- a/src/application/services/ManualSyncService.ts +++ b/src/application/services/ManualSyncService.ts @@ -146,6 +146,7 @@ export class ManualSyncService { const renderContext: ManualCardRenderContext = { addObsidianBacklink: settings.addObsidianBacklink, convertHighlightsToCloze: settings.convertHighlightsToCloze, + keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody, resourceResolver: this.vaultGateway, }; const renderedCards = new Map(); @@ -398,6 +399,7 @@ export class ManualSyncService { deckHint: block.deckHint, deckHintSource: block.deckHintSource, deckWarnings: [...block.deckWarnings], + tagsHint: block.tagsHint ? [...block.tagsHint] : [], orphan: false, }); diff --git a/src/application/services/QaGroupSyncService.test.ts b/src/application/services/QaGroupSyncService.test.ts index 5f2d962..58042fe 100644 --- a/src/application/services/QaGroupSyncService.test.ts +++ b/src/application/services/QaGroupSyncService.test.ts @@ -183,6 +183,102 @@ describe("QaGroupSyncService", () => { expect(result.markerWrites[0]?.noteId).toBe(result.syncedGroupBlocks[0]?.noteId); expect(result.syncedGroupBlocks[0]?.noteId).not.toBe(42); }); + + it("applies pure tag line cleanup to QA Group answer fields when the setting is disabled", async () => { + const ankiGateway = new FakeManualSyncAnkiGateway(); + const vaultGateway = new FakeManualSyncVaultGateway(); + const existingState = createStoredGroupBlockState(); + ankiGateway.noteDetailsById.set(42, { + noteId: 42, + modelName: QA_GROUP_MODEL_NAME, + cardIds: [7001], + deckNames: ["notes"], + fields: buildQaGroupNoteFields(existingState.stem, existingState.groupId, existingState.src, [ + { itemId: "item-a", slot: 1, title: "Alpha", answer: "#项目A #重点/案例\n\n第一段", ordinalInMarkdown: 1 }, + { itemId: "item-b", slot: 3, title: "Beta", answer: "Second answer", ordinalInMarkdown: 2 }, + ]), + }); + const service = new QaGroupSyncService( + ankiGateway, + undefined, + undefined, + undefined, + () => 1234, + () => "group-x", + () => "item-new", + vaultGateway.createBacklink.bind(vaultGateway), + ); + + const result = await service.sync([ + createIndexedGroupBlock({ + noteId: 42, + groupId: "group-1", + rawBlockHash: existingState.rawBlockHash, + items: [ + { title: "Alpha", answer: "#项目A #重点/案例\n\n第一段", ordinalInMarkdown: 1 }, + { title: "Beta", answer: "Second answer", ordinalInMarkdown: 2 }, + ], + }), + ], { + ...createEmptyPluginState(), + groupBlocks: { + "group-1": existingState, + }, + }, createModule3Settings({ keepPureTagLinesInCardBody: false })); + + expect(result.updated).toBe(1); + expect(ankiGateway.updatedNotes[0]?.fields).toMatchObject({ + S01_A: "第一段", + }); + }); + + it("syncs QA Group note tags from file-level tag hints", async () => { + const ankiGateway = new FakeManualSyncAnkiGateway(); + const vaultGateway = new FakeManualSyncVaultGateway(); + const existingState = createStoredGroupBlockState(); + ankiGateway.noteDetailsById.set(42, { + noteId: 42, + modelName: QA_GROUP_MODEL_NAME, + cardIds: [7001], + deckNames: ["notes"], + tags: ["old", "shared"], + fields: buildQaGroupNoteFields(existingState.stem, existingState.groupId, existingState.src, existingState.items), + }); + const service = new QaGroupSyncService( + ankiGateway, + undefined, + undefined, + undefined, + () => 1234, + () => "group-x", + () => "item-new", + vaultGateway.createBacklink.bind(vaultGateway), + ); + + const result = await service.sync([ + createIndexedGroupBlock({ + noteId: 42, + groupId: "group-1", + rawBlockHash: existingState.rawBlockHash, + tagsHint: ["shared", "fresh"], + items: existingState.items.map(({ title, answer, ordinalInMarkdown }) => ({ title, answer, ordinalInMarkdown })), + }), + ], { + ...createEmptyPluginState(), + groupBlocks: { + "group-1": existingState, + }, + }, createModule3Settings()); + + expect(result.updated).toBe(1); + expect(ankiGateway.syncedNoteTags).toEqual([ + { + noteId: 42, + addTags: ["fresh"], + removeTags: ["old"], + }, + ]); + }); }); function createIndexedGroupBlock(overrides: Partial = {}): IndexedGroupCardBlock { @@ -206,6 +302,7 @@ function createIndexedGroupBlock(overrides: Partial = {}) rawBlockText: overrides.rawBlockText ?? "qa-group:Concepts", rawBlockHash: overrides.rawBlockHash ?? "hash-1", deckWarnings: overrides.deckWarnings ?? [], + tagsHint: overrides.tagsHint, items: overrides.items ?? [ { title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 }, { title: "Beta", answer: "Second answer", ordinalInMarkdown: 2 }, diff --git a/src/application/services/QaGroupSyncService.ts b/src/application/services/QaGroupSyncService.ts index 2069d74..d92a298 100644 --- a/src/application/services/QaGroupSyncService.ts +++ b/src/application/services/QaGroupSyncService.ts @@ -7,6 +7,8 @@ import { GroupMarkerService, type GroupMarkerWriteRequest } from "@/domain/manua import { DeckResolutionService } from "@/domain/manual-sync/services/DeckResolutionService"; import { getDeckResolutionWarningKey, type DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution"; import { hashString } from "@/domain/shared/hash"; +import { preprocessCardBodyMarkdown } from "@/domain/manual-sync/services/preprocessCardBodyMarkdown"; +import { diffTagSets } from "@/domain/manual-sync/services/tagSetUtils"; import { buildQaGroupNoteFields, formatQaGroupSlot, QA_GROUP_MODEL_NAME, QA_GROUP_SLOT_COUNT } from "./QaGroupModelDefinition"; import { QaGroupModelService } from "./QaGroupModelService"; @@ -110,7 +112,11 @@ export class QaGroupSyncService { ensuredDecks.add(deck); } - const fields = buildQaGroupNoteFields(block.stem, groupId, this.buildGroupBacklink(block, settings), resolvedItems); + const renderedItems = resolvedItems.map((item) => ({ + ...item, + answer: preprocessCardBodyMarkdown(item.answer, settings.keepPureTagLinesInCardBody), + })); + const fields = buildQaGroupNoteFields(block.stem, groupId, this.buildGroupBacklink(block, settings), renderedItems); let noteId = recovered.noteId ?? block.noteId; let existingNote = recovered.noteDetails; @@ -119,46 +125,87 @@ export class QaGroupSyncService { deckName: deck, modelName: QA_GROUP_MODEL_NAME, fields, - tags: [], + tags: block.tagsHint ?? [], }); created += 1; touchedSyncKeys.add(block.syncKey); } else { - const stateUnchanged = recovered.stateRecord - && recovered.stateRecord.rawBlockHash === block.rawBlockHash - && recovered.stateRecord.deck === deck - && !recovered.stateRecord.orphan; - - let fieldsChanged = !stateUnchanged; - let deckChanged = recovered.stateRecord ? recovered.stateRecord.deck !== deck : false; - existingNote ??= await this.tryLoadQaGroupNote(noteId, block); if (!existingNote) { noteId = await this.ankiGateway.addNote({ deckName: deck, modelName: QA_GROUP_MODEL_NAME, fields, - tags: [], + tags: block.tagsHint ?? [], }); created += 1; touchedSyncKeys.add(block.syncKey); - } else if (!stateUnchanged) { - fieldsChanged = !haveEqualFields(existingNote.fields, fields); - deckChanged = !(existingNote.deckNames ?? []).includes(deck); } - if (existingNote && (fieldsChanged || deckChanged)) { - await this.ankiGateway.updateNote({ - noteId, - fields, - deckName: deckChanged ? deck : undefined, - }); + if (existingNote) { + const fieldsChanged = !haveEqualFields(existingNote.fields, fields); + const deckChanged = !(existingNote.deckNames ?? []).includes(deck); + const tagDiff = diffTagSets(block.tagsHint, existingNote.tags); + const tagsChanged = tagDiff.addTags.length > 0 || tagDiff.removeTags.length > 0; + + if (!fieldsChanged && !deckChanged && !tagsChanged) { + resolvedNoteIds.set(block.syncKey, noteId); + const now = this.now(); + syncedGroupBlocks.push({ + groupId, + noteId, + filePath: block.filePath, + headingText: block.headingText, + backlinkHeadingText: block.backlinkHeadingText, + headingLevel: block.headingLevel, + stem: block.stem, + src: block.src || buildGroupSrc(block.filePath, block.backlinkHeadingText), + blockStartOffset: block.blockStartOffset, + blockEndOffset: block.blockEndOffset, + blockStartLine: block.blockStartLine, + bodyStartLine: block.bodyStartLine, + blockEndLine: block.blockEndLine, + contentEndLine: block.contentEndLine, + markerLine: block.markerLine, + markerIndent: block.markerIndent, + rawBlockText: block.rawBlockText, + rawBlockHash: block.rawBlockHash, + deck, + deckHint: block.deckHint, + deckHintSource: block.deckHintSource, + deckWarnings: [...deckResolution.warnings], + tagsHint: block.tagsHint ? [...block.tagsHint] : [], + items: resolvedItems, + freeSlots, + lastSyncedAt: recovered.stateRecord?.lastSyncedAt ?? now, + orphan: false, + }); + continue; + } + + if (fieldsChanged || deckChanged) { + await this.ankiGateway.updateNote({ + noteId, + fields, + deckName: deckChanged ? deck : undefined, + }); + } if (fieldsChanged) { updated += 1; } + if (!fieldsChanged && tagsChanged) { + updated += 1; + } if (deckChanged) { migratedDecks += 1; } + if (tagsChanged) { + await this.ankiGateway.syncNoteTags([{ + noteId, + addTags: tagDiff.addTags, + removeTags: tagDiff.removeTags, + }]); + } touchedSyncKeys.add(block.syncKey); } } @@ -213,6 +260,7 @@ export class QaGroupSyncService { deckHint: block.deckHint, deckHintSource: block.deckHintSource, deckWarnings: [...deckResolution.warnings], + tagsHint: block.tagsHint ? [...block.tagsHint] : [], items: resolvedItems, freeSlots, lastSyncedAt: touchedSyncKeys.has(block.syncKey) ? now : recovered.stateRecord?.lastSyncedAt ?? now, diff --git a/src/application/services/RenderConfigService.ts b/src/application/services/RenderConfigService.ts index 06b9fae..448915f 100644 --- a/src/application/services/RenderConfigService.ts +++ b/src/application/services/RenderConfigService.ts @@ -27,6 +27,7 @@ export class RenderConfigService { mapping, addObsidianBacklink: settings.addObsidianBacklink, convertHighlightsToCloze: settings.convertHighlightsToCloze, + keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody, }; const renderConfigHash = hashString(JSON.stringify(renderConfigPayload)); diff --git a/src/domain/card/entities/SourceFile.ts b/src/domain/card/entities/SourceFile.ts index 83c9d1e..3e5700f 100644 --- a/src/domain/card/entities/SourceFile.ts +++ b/src/domain/card/entities/SourceFile.ts @@ -2,4 +2,5 @@ export interface SourceFile { path: string; basename: string; content: string; + tags: string[]; } \ No newline at end of file diff --git a/src/domain/manual-sync/entities/IndexedGroupCardBlock.ts b/src/domain/manual-sync/entities/IndexedGroupCardBlock.ts index b7a1d97..ec131ef 100644 --- a/src/domain/manual-sync/entities/IndexedGroupCardBlock.ts +++ b/src/domain/manual-sync/entities/IndexedGroupCardBlock.ts @@ -42,6 +42,7 @@ export interface IndexedGroupCardBlock { deckHint?: string; deckHintSource?: Extract; deckWarnings: DeckResolutionWarning[]; + tagsHint?: string[]; items: GroupItem[]; groupMarker?: GroupMarker; freeSlots: number[]; diff --git a/src/domain/manual-sync/entities/PluginState.ts b/src/domain/manual-sync/entities/PluginState.ts index 2dcd00a..240a2d6 100644 --- a/src/domain/manual-sync/entities/PluginState.ts +++ b/src/domain/manual-sync/entities/PluginState.ts @@ -73,6 +73,7 @@ export interface GroupBlockState { deckHint?: string; deckHintSource?: Extract; deckWarnings: DeckResolutionWarning[]; + tagsHint?: string[]; items: GroupItem[]; freeSlots: number[]; lastSyncedAt: number; diff --git a/src/domain/manual-sync/services/CardIndexingService.test.ts b/src/domain/manual-sync/services/CardIndexingService.test.ts index 609808f..41405d0 100644 --- a/src/domain/manual-sync/services/CardIndexingService.test.ts +++ b/src/domain/manual-sync/services/CardIndexingService.test.ts @@ -468,6 +468,94 @@ describe("CardIndexingService", () => { }); }); + it("writes file-level tags into basic, cloze, and semantic QA cards", () => { + const service = new CardIndexingService(); + const indexedFile = service.index( + { + path: "notes/example.md", + basename: "example", + content: [ + "#### Basic", + "Answer", + "", + "##### Cloze", + "{{c1::Body}}", + "", + "#### Concepts #anki-list-qa", + "- Alpha", + " First answer", + ].join("\n"), + tags: ["📖::一人公司", "3地区"], + }, + { + qaHeadingLevel: 4, + clozeHeadingLevel: 5, + semanticQaMarker: "#anki-list-qa", + syncObsidianTagsToAnki: true, + fileStamp: "1:1", + knownCards: [], + pendingWriteBack: [], + }, + ); + + expect(indexedFile.cards.map((card) => card.tagsHint)).toEqual([ + ["📖::一人公司", "3地区"], + ["📖::一人公司", "3地区"], + ["📖::一人公司", "3地区"], + ]); + }); + + it("writes empty tags when Obsidian tag sync is disabled and applies file-level tags to QA Group when enabled", () => { + const service = new CardIndexingService(); + const disabledFile = service.index( + { + path: "notes/example.md", + basename: "example", + content: [ + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + ].join("\n"), + tags: ["📖::一人公司", "3地区"], + }, + { + qaHeadingLevel: 4, + clozeHeadingLevel: 5, + qaGroupMarker: "#anki-list", + syncObsidianTagsToAnki: false, + fileStamp: "1:1", + knownCards: [], + pendingWriteBack: [], + }, + ); + + expect(disabledFile.groupBlocks?.[0]?.tagsHint).toEqual([]); + + const enabledFile = service.index( + { + path: "notes/example.md", + basename: "example", + content: [ + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + ].join("\n"), + tags: ["📖::一人公司", "3地区"], + }, + { + qaHeadingLevel: 4, + clozeHeadingLevel: 5, + qaGroupMarker: "#anki-list", + syncObsidianTagsToAnki: true, + fileStamp: "1:1", + knownCards: [], + pendingWriteBack: [], + }, + ); + + expect(enabledFile.groupBlocks?.[0]?.tagsHint).toEqual(["📖::一人公司", "3地区"]); + }); + it("skips explicit deck extraction when file-level deck mode is disabled", () => { const service = new CardIndexingService(); const indexedFile = service.index( diff --git a/src/domain/manual-sync/services/CardIndexingService.ts b/src/domain/manual-sync/services/CardIndexingService.ts index af0ab29..e32aea9 100644 --- a/src/domain/manual-sync/services/CardIndexingService.ts +++ b/src/domain/manual-sync/services/CardIndexingService.ts @@ -24,6 +24,7 @@ export interface CardIndexingContext { cardAnswerCutoffMode?: CardAnswerCutoffMode; qaGroupMarker?: string; semanticQaMarker?: string; + syncObsidianTagsToAnki?: boolean; fileStamp: string; knownCards: CardState[]; knownGroupBlocks?: GroupBlockState[]; @@ -50,6 +51,7 @@ export class CardIndexingService { const extractedDeck = context.fileDeckEnabled ? this.deckExtractionService.extract(sourceFile, context.fileDeckMarker ?? "TARGET DECK") : { warnings: [] }; + const fileTags = resolveSourceFileTags(sourceFile, context.syncObsidianTagsToAnki); const cards: IndexedCard[] = []; const groupBlocks: IndexedGroupCardBlock[] = []; const knownCardsByBlockKey = groupKnownCardsByBlockKey(context.knownCards); @@ -120,6 +122,7 @@ export class CardIndexingService { deckHint: extractedDeck.explicitDeckHint, deckHintSource: extractedDeck.explicitDeckSource, deckWarnings: [...extractedDeck.warnings], + tagsHint: [...fileTags], items: parsedGroupBlock.items, groupMarker: parsedGroupBlock.groupMarker, freeSlots: parsedGroupBlock.groupMarker?.freeSlots ?? resolvedGroupIdentity.freeSlots, @@ -178,7 +181,7 @@ export class CardIndexingService { deckHint: extractedDeck.explicitDeckHint, deckHintSource: extractedDeck.explicitDeckSource, deckWarnings: [...extractedDeck.warnings], - tagsHint: [], + tagsHint: [...fileTags], sourceContent: sourceFile.content, }); } @@ -235,7 +238,7 @@ export class CardIndexingService { deckHint: extractedDeck.explicitDeckHint, deckHintSource: extractedDeck.explicitDeckSource, deckWarnings: [...extractedDeck.warnings], - tagsHint: [], + tagsHint: [...fileTags], sourceContent: sourceFile.content, }); } @@ -393,6 +396,14 @@ function createKnownCardBlockKey(filePath: string, rawBlockHash: string): string return `${filePath}\u0000${rawBlockHash}`; } +function resolveSourceFileTags(sourceFile: SourceFile, syncObsidianTagsToAnki: boolean | undefined): string[] { + if (syncObsidianTagsToAnki === false) { + return []; + } + + 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."); diff --git a/src/domain/manual-sync/services/DiffPlannerService.test.ts b/src/domain/manual-sync/services/DiffPlannerService.test.ts index eed1873..125df02 100644 --- a/src/domain/manual-sync/services/DiffPlannerService.test.ts +++ b/src/domain/manual-sync/services/DiffPlannerService.test.ts @@ -159,6 +159,57 @@ describe("DiffPlannerService", () => { expect(plan.toUpdate).toHaveLength(1); expect(plan.toChangeDeck).toHaveLength(0); }); + + it("schedules an update when only the tag set changed", () => { + const service = new DiffPlannerService(); + const settings = createModule3Settings(); + const card = createIndexedCard({ + noteId: 42, + tagsHint: ["fresh", "shared"], + idMarkerState: "present-valid", + noteIdSource: "marker", + }); + const renderPlan = new RenderConfigService().resolve(card, settings); + const state = { + files: {}, + cards: { + "42": createCardState({ noteId: 42, renderConfigHash: renderPlan.renderConfigHash, deck: renderPlan.deck, tagsHint: ["old", "shared"] }), + }, + pendingWriteBack: [], + }; + + const plan = service.plan([card], state, ["notes/example.md"], settings); + + expect(plan.toUpdate).toHaveLength(1); + expect(plan.toChangeDeck).toHaveLength(0); + }); + + it("schedules an update when only the pure tag line setting changes rendered output", () => { + const service = new DiffPlannerService(); + const oldSettings = createModule3Settings({ keepPureTagLinesInCardBody: true }); + const newSettings = createModule3Settings({ keepPureTagLinesInCardBody: false }); + const card = createIndexedCard({ + noteId: 42, + bodyMarkdown: "#项目A #重点/案例\n\nBody", + rawBlockText: ["#### Prompt", "#项目A #重点/案例", "", "Body"].join("\n"), + rawBlockHash: "same-hash", + idMarkerState: "present-valid", + noteIdSource: "marker", + }); + const oldRenderPlan = new RenderConfigService().resolve(card, oldSettings); + const state = { + files: {}, + cards: { + "42": createCardState({ noteId: 42, rawBlockHash: "same-hash", renderConfigHash: oldRenderPlan.renderConfigHash, deck: oldRenderPlan.deck, tagsHint: [] }), + }, + pendingWriteBack: [], + }; + + const plan = service.plan([card], state, ["notes/example.md"], newSettings); + + expect(plan.toUpdate).toHaveLength(1); + expect(plan.toChangeDeck).toHaveLength(0); + }); }); function createIndexedCard(overrides: Partial = {}): IndexedCard { diff --git a/src/domain/manual-sync/services/DiffPlannerService.ts b/src/domain/manual-sync/services/DiffPlannerService.ts index a7bc13a..4498859 100644 --- a/src/domain/manual-sync/services/DiffPlannerService.ts +++ b/src/domain/manual-sync/services/DiffPlannerService.ts @@ -5,6 +5,8 @@ import { createPendingWriteBackKey, toNoteIdKey, type PluginState } from "@/doma import { getDeckResolutionWarningKey, type DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution"; import type { ManualSyncPlan, PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan"; +import { areTagSetsEqual } from "./tagSetUtils"; + export class DiffPlannerService { constructor(private readonly renderConfigService = new RenderConfigService()) {} @@ -74,6 +76,7 @@ export class DiffPlannerService { const fieldsChanged = existingState.rawBlockHash !== card.rawBlockHash || existingState.renderConfigHash !== renderPlan.renderConfigHash || + !areTagSetsEqual(existingState.tagsHint, card.tagsHint) || existingState.orphan; const deckChanged = existingState.deck !== renderPlan.deck; diff --git a/src/domain/manual-sync/services/ManualCardRenderer.ts b/src/domain/manual-sync/services/ManualCardRenderer.ts index b454f72..d43a0d8 100644 --- a/src/domain/manual-sync/services/ManualCardRenderer.ts +++ b/src/domain/manual-sync/services/ManualCardRenderer.ts @@ -5,6 +5,8 @@ import type { PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncP import type { MediaAsset } from "@/domain/card/entities/RenderedFields"; import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard"; +import { preprocessCardBodyMarkdown } from "./preprocessCardBodyMarkdown"; + const markdown = new MarkdownIt({ breaks: true, html: true, @@ -24,6 +26,7 @@ const WIKILINK_PATTERN = /(? { + it("deletes pure tag lines anywhere in the body and merges extra blank lines", () => { + expect(preprocessCardBodyMarkdown([ + "第一段", + "", + "#项目A #重点/案例", + "", + "", + "第二段", + "#3地区", + "", + "第三段", + ].join("\n"), false)).toBe([ + "第一段", + "", + "第二段", + "", + "第三段", + ].join("\n")); + }); + + it("preserves inline tags and prose lines with tags", () => { + expect(preprocessCardBodyMarkdown([ + "这是 #3地区 的案例", + "标签:#项目A", + "## 标题 #项目A", + ].join("\n"), false)).toBe([ + "这是 #3地区 的案例", + "标签:#项目A", + "## 标题 #项目A", + ].join("\n")); + }); + + it("keeps the original body when the setting is enabled", () => { + const markdown = [ + "#项目A #重点/案例", + "", + "正文", + ].join("\n"); + + expect(preprocessCardBodyMarkdown(markdown, true)).toBe(markdown); + }); + + it("recognizes only whole pure-tag lines", () => { + expect(isPureTagLine(" #3地区 #📖/一人公司 ")).toBe(true); + expect(isPureTagLine("标签:#3地区")).toBe(false); + expect(isPureTagLine("这是 #3地区 的案例")).toBe(false); + expect(isPureTagLine("#3地区,#案例")).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/domain/manual-sync/services/preprocessCardBodyMarkdown.ts b/src/domain/manual-sync/services/preprocessCardBodyMarkdown.ts new file mode 100644 index 0000000..69ea347 --- /dev/null +++ b/src/domain/manual-sync/services/preprocessCardBodyMarkdown.ts @@ -0,0 +1,58 @@ +const PURE_TAG_TOKEN_REGEXP = /^#[^\s#.,,。;;::!?!?()[\]{}<>]+(?:\/[^\s#.,,。;;::!?!?()[\]{}<>]+)*$/u; + +export function preprocessCardBodyMarkdown(markdownText: string, keepPureTagLines: boolean): string { + if (keepPureTagLines || markdownText.length === 0) { + return markdownText; + } + + const lines = markdownText.split(/\r?\n/); + const filteredLines = lines.filter((line) => !isPureTagLine(line)); + + if (filteredLines.length === lines.length) { + return markdownText; + } + + return collapseBlankLines(trimBlankEdges(filteredLines)).join("\n"); +} + +export function isPureTagLine(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) { + return false; + } + + return trimmed.split(/\s+/).every((token) => PURE_TAG_TOKEN_REGEXP.test(token)); +} + +function trimBlankEdges(lines: string[]): string[] { + let startIndex = 0; + let endIndex = lines.length; + + while (startIndex < endIndex && !lines[startIndex].trim()) { + startIndex += 1; + } + + while (endIndex > startIndex && !lines[endIndex - 1].trim()) { + endIndex -= 1; + } + + return lines.slice(startIndex, endIndex); +} + +function collapseBlankLines(lines: string[]): string[] { + const normalizedLines: string[] = []; + + for (const line of lines) { + const isBlank = line.trim().length === 0; + const previousLine = normalizedLines[normalizedLines.length - 1]; + const previousBlank = previousLine !== undefined && previousLine.trim().length === 0; + + if (isBlank && previousBlank) { + continue; + } + + normalizedLines.push(isBlank ? "" : line); + } + + return normalizedLines; +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/tagSetUtils.ts b/src/domain/manual-sync/services/tagSetUtils.ts new file mode 100644 index 0000000..bce9883 --- /dev/null +++ b/src/domain/manual-sync/services/tagSetUtils.ts @@ -0,0 +1,39 @@ +function uniqueNonEmptyTags(tags: string[] | undefined): string[] { + const uniqueTags: string[] = []; + const seen = new Set(); + + for (const tag of tags ?? []) { + if (typeof tag !== "string" || tag.length === 0 || seen.has(tag)) { + continue; + } + + seen.add(tag); + uniqueTags.push(tag); + } + + return uniqueTags; +} + +export function areTagSetsEqual(left: string[] | undefined, right: string[] | undefined): boolean { + const leftTags = uniqueNonEmptyTags(left); + const rightTags = uniqueNonEmptyTags(right); + + if (leftTags.length !== rightTags.length) { + return false; + } + + const rightSet = new Set(rightTags); + return leftTags.every((tag) => rightSet.has(tag)); +} + +export function diffTagSets(target: string[] | undefined, current: string[] | undefined): { addTags: string[]; removeTags: string[] } { + const targetTags = uniqueNonEmptyTags(target); + const currentTags = uniqueNonEmptyTags(current); + const targetSet = new Set(targetTags); + const currentSet = new Set(currentTags); + + return { + addTags: targetTags.filter((tag) => !currentSet.has(tag)), + removeTags: currentTags.filter((tag) => !targetSet.has(tag)), + }; +} \ No newline at end of file diff --git a/src/infrastructure/anki/AnkiConnectGateway.test.ts b/src/infrastructure/anki/AnkiConnectGateway.test.ts index c48b2d5..63d75b8 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.test.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.test.ts @@ -233,9 +233,9 @@ describe("AnkiConnectGateway", () => { json: { error: null, result: [ - { noteId: 100, modelName: "Basic", cards: [1] }, + { noteId: 100, modelName: "Basic", cards: [1], tags: ["tag-a"] }, null, - { noteId: 102, modelName: "Cloze", cards: [2] }, + { noteId: 102, modelName: "Cloze", cards: [2], tags: ["tag-b", "tag-c"] }, ], }, }) @@ -253,8 +253,8 @@ describe("AnkiConnectGateway", () => { const summaries = await gateway.getNoteSummaries([100, 101, 102]); expect(summaries).toEqual([ - { noteId: 100, modelName: "Basic", cardIds: [1], deckNames: ["Deck::One"] }, - { noteId: 102, modelName: "Cloze", cardIds: [2], deckNames: ["Deck::Two"] }, + { noteId: 100, modelName: "Basic", cardIds: [1], deckNames: ["Deck::One"], tags: ["tag-a"] }, + { noteId: 102, modelName: "Cloze", cardIds: [2], deckNames: ["Deck::Two"], tags: ["tag-b", "tag-c"] }, ]); expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ action: "notesInfo", @@ -272,6 +272,59 @@ describe("AnkiConnectGateway", () => { }); }); + it("syncs note tags by removing obsolete tags before adding current tags", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: null, + result: [null, null, null], + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + await gateway.syncNoteTags([ + { + noteId: 100, + removeTags: ["old", "stale"], + addTags: ["fresh", "nested::tag"], + }, + { + noteId: 101, + removeTags: [], + addTags: ["only-add"], + }, + ]); + + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "multi", + version: 6, + params: { + actions: [ + { + action: "removeTags", + params: { + notes: [100], + tags: "old stale", + }, + }, + { + action: "addTags", + params: { + notes: [100], + tags: "fresh nested::tag", + }, + }, + { + action: "addTags", + params: { + notes: [101], + tags: "only-add", + }, + }, + ], + }, + }); + }); + it("maps deck stats into empty-deck detection shape", async () => { requestUrlMock .mockResolvedValueOnce({ diff --git a/src/infrastructure/anki/AnkiConnectGateway.ts b/src/infrastructure/anki/AnkiConnectGateway.ts index 228a585..4b4f24f 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.ts @@ -1,7 +1,7 @@ import { requestUrl } from "obsidian"; import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; -import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; +import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, SyncAnkiNoteTagsInput, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; import type { MediaAsset } from "@/domain/card/entities/RenderedFields"; interface AnkiResponse { @@ -14,6 +14,7 @@ interface NoteInfo { fields?: Record; modelName?: string; noteId?: number; + tags?: string[]; } interface CardInfo { @@ -178,6 +179,7 @@ export class AnkiConnectGateway implements AnkiGroupGateway { deckNames: Array.from(new Set(cardIds .map((cardId) => cardDeckNamesByCardId.get(cardId)) .filter((deckName): deckName is string => typeof deckName === "string"))), + tags: Array.isArray(entry.tags) ? entry.tags.filter((tag): tag is string => typeof tag === "string") : [], fields, }]; }); @@ -222,6 +224,7 @@ export class AnkiConnectGateway implements AnkiGroupGateway { modelName: note.modelName, cardIds: note.cardIds, deckNames: note.deckNames, + tags: note.tags, })); } @@ -305,6 +308,34 @@ export class AnkiConnectGateway implements AnkiGroupGateway { }))); } + async syncNoteTags(inputs: SyncAnkiNoteTagsInput[]): Promise { + const actions: Array<{ action: string; params: Record }> = []; + + for (const input of inputs) { + if (input.removeTags.length > 0) { + actions.push({ + action: "removeTags", + params: { + notes: [input.noteId], + tags: input.removeTags.join(" "), + }, + }); + } + + if (input.addTags.length > 0) { + actions.push({ + action: "addTags", + params: { + notes: [input.noteId], + tags: input.addTags.join(" "), + }, + }); + } + } + + await this.invokeMulti(actions); + } + async changeDecks(inputs: ChangeDeckInput[]): Promise { await this.invokeMulti(inputs.filter((input) => input.cardIds.length > 0).map((input) => ({ action: "changeDeck", diff --git a/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts b/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts index 66b40f6..540116c 100644 --- a/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts +++ b/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts @@ -2,8 +2,55 @@ import { describe, expect, it } from "vitest"; import { TFile, TFolder } from "obsidian"; import { ObsidianVaultGateway } from "./ObsidianVaultGateway"; +import { normalizeObsidianTags } from "./normalizeObsidianTags"; describe("ObsidianVaultGateway", () => { + it("reads metadata cache tags and normalizes nested, emoji, and Chinese tags", async () => { + const file = new TFile("notes/example.md"); + const gateway = new ObsidianVaultGateway({ + vault: { + getAbstractFileByPath: () => file, + cachedRead: async () => "# Title\nBody", + }, + metadataCache: { + getFileCache: () => ({ + frontmatter: { + tags: ["#📖/一人公司", "#3地区"], + }, + tags: [ + { tag: "#3地区" }, + { tag: "#a/b/c" }, + { tag: "#📖/一人公司" }, + ], + }), + }, + } as never); + + await expect(gateway.readMarkdownFile("notes/example.md")).resolves.toMatchObject({ + path: "notes/example.md", + basename: "example", + content: "# Title\nBody", + tags: ["3地区", "a::b::c", "📖::一人公司"], + }); + }); + + it("returns an empty tag list when metadata cache is missing or has no tags", async () => { + const file = new TFile("notes/example.md"); + const gateway = new ObsidianVaultGateway({ + vault: { + getAbstractFileByPath: () => file, + cachedRead: async () => "# Title\nBody", + }, + metadataCache: { + getFileCache: () => null, + }, + } as never); + + await expect(gateway.readMarkdownFile("notes/example.md")).resolves.toMatchObject({ + tags: [], + }); + }); + it("lists vault folders as a tree without exposing the root folder", async () => { const FolderCtor = TFolder as unknown as new (path: string, children?: Array) => TFolder; const FileCtor = TFile as unknown as new (path: string) => TFile; @@ -104,4 +151,21 @@ describe("ObsidianVaultGateway", () => { "obsidian://open?vault=Vault&file=notes%2Fexample.md%23%E4%BD%AC%E6%8B%93%5B%5B%E5%8D%A1%E7%89%87%E8%AF%95%E9%AA%8C%5D%5D", ); }); + + it("normalizes tag tokens by removing hashes, converting nesting, filtering empties, and deduplicating", () => { + expect(normalizeObsidianTags([ + "#3地区", + "#📖/一人公司", + "#a/b/c", + "#a//b/", + "#3地区", + "#", + "", + ])).toEqual([ + "3地区", + "📖::一人公司", + "a::b::c", + "a::b", + ]); + }); }); diff --git a/src/infrastructure/obsidian/ObsidianVaultGateway.ts b/src/infrastructure/obsidian/ObsidianVaultGateway.ts index 98f9540..15534c7 100644 --- a/src/infrastructure/obsidian/ObsidianVaultGateway.ts +++ b/src/infrastructure/obsidian/ObsidianVaultGateway.ts @@ -1,5 +1,5 @@ -import { TFile, TFolder } from "obsidian"; -import type { App } from "obsidian"; +import { TFile, TFolder, getAllTags } from "obsidian"; +import type { App, CachedMetadata } from "obsidian"; import type { FolderTreeNode } from "@/application/dto/FolderTreeNode"; import type { MarkdownFileReference, ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; @@ -7,6 +7,8 @@ import { MarkdownFileNotFoundError, MarkdownWriteConflictError, type VaultGatewa import { hashString } from "@/domain/shared/hash"; import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation"; +import { normalizeObsidianTags } from "./normalizeObsidianTags"; + const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "tiff"]); const AUDIO_EXTENSIONS = new Set(["wav", "m4a", "flac", "mp3", "wma", "aac", "webm", "ogg"]); @@ -110,10 +112,13 @@ export class ObsidianVaultGateway implements VaultGateway, ManualSyncVaultGatewa } private async toSourceFile(file: TFile) { + const cache = this.app.metadataCache.getFileCache(file); + return { path: file.path, basename: file.basename, content: await this.app.vault.cachedRead(file), + tags: extractSourceFileTags(cache), }; } @@ -162,3 +167,11 @@ function getFullPath(app: App, vaultPath: string): string { function normalizeHeadingForBacklink(headingText: string): string { return headingText.replace(/(?:\s+#\S+)+$/g, (trailingTags) => trailingTags.replace(/(^|\s)#(\S+)/g, "$1$2")); } + +function extractSourceFileTags(cache: CachedMetadata | null | undefined): string[] { + if (!cache) { + return []; + } + + return normalizeObsidianTags(getAllTags(cache)); +} diff --git a/src/infrastructure/obsidian/normalizeObsidianTags.ts b/src/infrastructure/obsidian/normalizeObsidianTags.ts new file mode 100644 index 0000000..24120b8 --- /dev/null +++ b/src/infrastructure/obsidian/normalizeObsidianTags.ts @@ -0,0 +1,34 @@ +export function normalizeObsidianTags(rawTags: Iterable | null | undefined): string[] { + if (!rawTags) { + return []; + } + + const normalizedTags: string[] = []; + const seen = new Set(); + + for (const rawTag of rawTags) { + if (typeof rawTag !== "string") { + continue; + } + + const withoutPrefix = rawTag.startsWith("#") ? rawTag.slice(1) : rawTag; + const segments = withoutPrefix + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + + if (segments.length === 0) { + continue; + } + + const normalized = segments.join("::"); + if (seen.has(normalized)) { + continue; + } + + seen.add(normalized); + normalizedTags.push(normalized); + } + + return normalizedTags; +} \ No newline at end of file diff --git a/src/infrastructure/persistence/DataJsonPluginStateRepository.ts b/src/infrastructure/persistence/DataJsonPluginStateRepository.ts index 57e5f9f..e458ef5 100644 --- a/src/infrastructure/persistence/DataJsonPluginStateRepository.ts +++ b/src/infrastructure/persistence/DataJsonPluginStateRepository.ts @@ -173,6 +173,7 @@ function migrateGroupBlockState(rawGroupBlock: LegacyGroupBlockState, groupId: s deckHint: typeof rawGroupBlock.deckHint === "string" ? rawGroupBlock.deckHint : undefined, deckHintSource: rawGroupBlock.deckHintSource === "frontmatter" || rawGroupBlock.deckHintSource === "body" ? rawGroupBlock.deckHintSource : undefined, deckWarnings: Array.isArray(rawGroupBlock.deckWarnings) ? [...rawGroupBlock.deckWarnings] : [], + tagsHint: Array.isArray(rawGroupBlock.tagsHint) ? rawGroupBlock.tagsHint.filter((tag): tag is string => typeof tag === "string") : [], items: Array.isArray(rawGroupBlock.items) ? rawGroupBlock.items.flatMap((item) => { if (!item || typeof item !== "object") { diff --git a/src/presentation/i18n/messages/en.ts b/src/presentation/i18n/messages/en.ts index 938667d..156f3d4 100644 --- a/src/presentation/i18n/messages/en.ts +++ b/src/presentation/i18n/messages/en.ts @@ -65,6 +65,14 @@ export const en = { name: "Highlights to Cloze", desc: "Convert ==highlight== segments into cloze deletions for cloze cards.", }, + syncObsidianTagsToAnki: { + name: "Sync Obsidian tags to Anki", + desc: "Sync Obsidian-recognized tags from the current note into Anki note tags, including nested tags. On each sync, these tags overwrite the managed tags in Anki based on the current Obsidian content.", + }, + keepPureTagLinesInCardBody: { + name: "Keep pure tag lines in card body", + desc: "When turned off, remove every line in the card body that contains only tags, such as \"#ProjectA #重点/案例\". Inline tags like \"This is a #tag example\" and lines with ordinary text like \"标签:#项目A\" are kept. Extra blank lines created by removal are cleaned up automatically.", + }, }, mapping: { title: "Note type field mappings", @@ -257,6 +265,8 @@ export const en = { semanticQaNoteTypeRequired: "Semantic QA note type is required.", defaultDeckRequired: "Default deck is required.", fileDeckEnabledBoolean: "File deck enabled must be a boolean.", + syncObsidianTagsToAnkiBoolean: "Sync Obsidian tags to Anki must be a boolean.", + keepPureTagLinesInCardBodyBoolean: "Keep pure tag lines in card body must be a boolean.", fileDeckMarkerString: "File deck marker must be a string.", fileDeckTemplateString: "File deck template must be a string.", fileDeckInsertLocationInvalid: "File deck insert location must be yaml or body.", diff --git a/src/presentation/i18n/messages/zh.ts b/src/presentation/i18n/messages/zh.ts index 4fb58e2..f83dd2e 100644 --- a/src/presentation/i18n/messages/zh.ts +++ b/src/presentation/i18n/messages/zh.ts @@ -63,6 +63,14 @@ export const zh = { name: "高亮转 Cloze", desc: "把 ==highlight== 片段转换成 cloze 卡片使用的挖空格式。", }, + syncObsidianTagsToAnki: { + name: "同步 Obsidian 标签到 Anki", + desc: "将当前笔记中 Obsidian 识别到的标签同步为 Anki 笔记标签,支持嵌套标签。每次同步时,这些标签都会以当前 Obsidian 内容为准覆盖 Anki。", + }, + keepPureTagLinesInCardBody: { + name: "在卡片正文中保留纯标签行", + desc: "关闭后,会删除正文中所有只包含标签的整行,例如 “#项目A #重点/案例”。像“这是 #标签 的案例”这类行内标签,或“标签:#项目A”这类带普通文字的行,不会被删除。删除后会自动清理多余空行。", + }, }, mapping: { title: "笔记类型字段映射", @@ -255,6 +263,8 @@ export const zh = { semanticQaNoteTypeRequired: "语义 QA 笔记类型不能为空。", defaultDeckRequired: "默认牌组不能为空。", fileDeckEnabledBoolean: "文件级 deck 开关必须是布尔值。", + syncObsidianTagsToAnkiBoolean: "同步 Obsidian 标签到 Anki 开关必须是布尔值。", + keepPureTagLinesInCardBodyBoolean: "保留正文纯标签行开关必须是布尔值。", fileDeckMarkerString: "文件级 deck marker 必须是字符串。", fileDeckTemplateString: "文件级 deck 模板必须是字符串。", fileDeckInsertLocationInvalid: "文件级 deck 模板插入位置只能是 yaml 或 body。", diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index 0693a56..01ee001 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -484,6 +484,8 @@ describe("AnkiHeadingSyncSettingTab", () => { expect(container.textNodes).toContain("Anki Heading Sync"); expect(findSetting(container, "AnkiConnect URL").desc).toBe("Default is http://127.0.0.1:8765"); expect(findSetting(container, "Card answer cutoff mode").desc).toBe("A valid sync marker always wins. Without a valid marker, either keep the whole heading block or stop before the first 2+ consecutive blank lines."); + expect(findSetting(container, "Sync Obsidian tags to Anki").desc).toBe("Sync Obsidian-recognized tags from the current note into Anki note tags, including nested tags. On each sync, these tags overwrite the managed tags in Anki based on the current Obsidian content."); + expect(findSetting(container, "Keep pure tag lines in card body").desc).toBe("When turned off, remove every line in the card body that contains only tags, such as \"#ProjectA #重点/案例\". Inline tags like \"This is a #tag example\" and lines with ordinary text like \"标签:#项目A\" are kept. Extra blank lines created by removal are cleaned up automatically."); expect(findSetting(container, "Run scope").desc).toBe("Only process Markdown files in the checked folders below"); await flushAsync(); @@ -509,6 +511,8 @@ describe("AnkiHeadingSyncSettingTab", () => { tab.display(); expect(findSetting(container, "QA 标题层级").desc).toBe("默认是 H4"); expect(findSetting(container, "卡片正文截止模式").desc).toBe("有效同步标记始终优先。没有有效标记时,选择继续到整个标题块末尾,或在首个 2+ 连续空行前截止。"); + expect(findSetting(container, "同步 Obsidian 标签到 Anki").desc).toBe("将当前笔记中 Obsidian 识别到的标签同步为 Anki 笔记标签,支持嵌套标签。每次同步时,这些标签都会以当前 Obsidian 内容为准覆盖 Anki。"); + expect(findSetting(container, "在卡片正文中保留纯标签行").desc).toBe("关闭后,会删除正文中所有只包含标签的整行,例如 “#项目A #重点/案例”。像“这是 #标签 的案例”这类行内标签,或“标签:#项目A”这类带普通文字的行,不会被删除。删除后会自动清理多余空行。"); expect(findSetting(container, "运行范围").desc).toBe("仅处理下方勾选文件夹中的 Markdown 文件"); expect(findSetting(container, "默认牌组").desc).toBe("优先级最低:当文件级 deck 与文件夹映射都未命中时使用。"); @@ -697,6 +701,31 @@ describe("AnkiHeadingSyncSettingTab", () => { expect(getDropdown(findSetting(container, "Card answer cutoff mode")).value).toBe("double-blank-lines"); }); + it("saves and rehydrates the new sync option toggles", async () => { + const plugin = new FakePlugin(); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + const container = tab.containerEl as unknown as FakeContainerElInstance; + + tab.display(); + + const syncTagsToggle = getToggle(findSetting(container, "Sync Obsidian tags to Anki")); + const keepPureTagLinesToggle = getToggle(findSetting(container, "Keep pure tag lines in card body")); + + expect(syncTagsToggle.value).toBe(true); + expect(keepPureTagLinesToggle.value).toBe(true); + + await syncTagsToggle.triggerChange(false); + await keepPureTagLinesToggle.triggerChange(false); + + expect(plugin.settings.syncObsidianTagsToAnki).toBe(false); + expect(plugin.settings.keepPureTagLinesInCardBody).toBe(false); + + tab.display(); + + expect(getToggle(findSetting(container, "Sync Obsidian tags to Anki")).value).toBe(false); + expect(getToggle(findSetting(container, "Keep pure tag lines in card body")).value).toBe(false); + }); + it("removes the old folder textareas and hides the folder tree in all mode", 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 e197cb3..f5279db 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -166,6 +166,24 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { }); }); + new Setting(containerEl) + .setName(t("settings.syncOptions.syncObsidianTagsToAnki.name")) + .setDesc(t("settings.syncOptions.syncObsidianTagsToAnki.desc")) + .addToggle((toggle) => { + toggle.setValue(settings.syncObsidianTagsToAnki).onChange((value) => { + void this.plugin.updateSettings({ syncObsidianTagsToAnki: value }); + }); + }); + + new Setting(containerEl) + .setName(t("settings.syncOptions.keepPureTagLinesInCardBody.name")) + .setDesc(t("settings.syncOptions.keepPureTagLinesInCardBody.desc")) + .addToggle((toggle) => { + toggle.setValue(settings.keepPureTagLinesInCardBody).onChange((value) => { + void this.plugin.updateSettings({ keepPureTagLinesInCardBody: value }); + }); + }); + containerEl.createEl("h3", { text: t("settings.mapping.title") }); new Setting(containerEl) diff --git a/src/test-support/manualSyncFakes.ts b/src/test-support/manualSyncFakes.ts index 0c8113d..a06f2a1 100644 --- a/src/test-support/manualSyncFakes.ts +++ b/src/test-support/manualSyncFakes.ts @@ -1,7 +1,7 @@ import type { PluginSettings } from "@/application/config/PluginSettings"; import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings"; import type { FolderTreeNode } from "@/application/dto/FolderTreeNode"; -import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; +import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, SyncAnkiNoteTagsInput, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; import type { PluginStateRepository } from "@/application/ports/PluginStateRepository"; import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway"; @@ -91,6 +91,7 @@ export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway { path, basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path, content: file.content, + tags: [], }; } @@ -139,6 +140,7 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway { public addedNotes: AddAnkiNoteInput[] = []; public deletedNotes: number[][] = []; public updatedNotes: UpdateAnkiNoteInput[] = []; + public syncedNoteTags: SyncAnkiNoteTagsInput[] = []; public changedDecks: ChangeDeckInput[] = []; public deletedDecks: string[][] = []; public storedMedia: MediaAsset[] = []; @@ -241,11 +243,11 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway { return noteIds.flatMap((noteId) => { const detail = this.noteDetailsById.get(noteId); if (detail) { - return [{ ...detail, cardIds: [...detail.cardIds], deckNames: detail.deckNames ? [...detail.deckNames] : undefined, fields: { ...detail.fields } }]; + return [{ ...detail, cardIds: [...detail.cardIds], deckNames: detail.deckNames ? [...detail.deckNames] : undefined, tags: detail.tags ? [...detail.tags] : undefined, fields: { ...detail.fields } }]; } const summary = this.noteSummariesById.get(noteId); - return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, fields: {} }] : []; + return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, tags: summary.tags ? [...summary.tags] : undefined, fields: {} }] : []; }); } @@ -256,7 +258,7 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway { async getNoteSummaries(noteIds: number[]): Promise { return noteIds.flatMap((noteId) => { const summary = this.noteSummariesById.get(noteId); - return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined }] : []; + return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, tags: summary.tags ? [...summary.tags] : undefined }] : []; }); } @@ -285,6 +287,33 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway { this.updatedNotes.push(...inputs); } + async syncNoteTags(inputs: SyncAnkiNoteTagsInput[]): Promise { + this.syncedNoteTags.push(...inputs); + + for (const input of inputs) { + const summary = this.noteSummariesById.get(input.noteId); + const detail = this.noteDetailsById.get(input.noteId); + const currentTags = new Set([...(detail?.tags ?? summary?.tags ?? [])]); + + for (const tag of input.removeTags) { + currentTags.delete(tag); + } + + for (const tag of input.addTags) { + currentTags.add(tag); + } + + const nextTags = Array.from(currentTags); + if (summary) { + this.noteSummariesById.set(input.noteId, { ...summary, tags: nextTags }); + } + + if (detail) { + this.noteDetailsById.set(input.noteId, { ...detail, tags: nextTags }); + } + } + } + async changeDecks(inputs: ChangeDeckInput[]): Promise { this.changedDecks.push(...inputs); } diff --git a/src/test-support/obsidianStub.ts b/src/test-support/obsidianStub.ts index 91a895b..7acbabe 100644 --- a/src/test-support/obsidianStub.ts +++ b/src/test-support/obsidianStub.ts @@ -2,6 +2,36 @@ export async function requestUrl(): Promise { throw new Error("obsidian.requestUrl stub was called without a test mock."); } +export function getAllTags(cache: { + tags?: Array<{ tag?: string }>; + frontmatter?: { tags?: string | string[] }; +} | null | undefined): string[] | null { + if (!cache) { + return null; + } + + const tags: string[] = []; + + for (const tagCache of cache.tags ?? []) { + if (typeof tagCache?.tag === "string") { + tags.push(tagCache.tag); + } + } + + const frontmatterTags = cache.frontmatter?.tags; + if (typeof frontmatterTags === "string") { + tags.push(frontmatterTags); + } else if (Array.isArray(frontmatterTags)) { + for (const tag of frontmatterTags) { + if (typeof tag === "string") { + tags.push(tag); + } + } + } + + return tags.length > 0 ? tags : null; +} + export function getLanguage(): string { return "en"; }