feat: sync Obsidian tags and pure tag line cleanup / 功能:同步 Obsidian 标签并清理纯标签行

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 覆盖。
This commit is contained in:
Dusk 2026-04-23 16:43:06 +08:00
parent 4052eff0e7
commit 579899f7c8
36 changed files with 1387 additions and 35 deletions

View file

@ -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 渲染重构

View file

@ -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 的专用同步路径。

View file

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

View file

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

View file

@ -14,11 +14,18 @@ export interface UpdateAnkiNoteInput {
fields: Record<string, string>;
}
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<void>;
updateNote(input: UpdateAnkiNoteInput): Promise<void>;
updateNotes(inputs: UpdateAnkiNoteInput[]): Promise<void>;
syncNoteTags(inputs: SyncAnkiNoteTagsInput[]): Promise<void>;
changeDecks(inputs: ChangeDeckInput[]): Promise<void>;
deleteDecks(deckNames: string[]): Promise<void>;
storeMedia(asset: MediaAsset): Promise<void>;

View file

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

View file

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

View file

@ -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"]);
});
});

View file

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

View file

@ -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<string, RenderedSyncCard>();
@ -398,6 +399,7 @@ export class ManualSyncService {
deckHint: block.deckHint,
deckHintSource: block.deckHintSource,
deckWarnings: [...block.deckWarnings],
tagsHint: block.tagsHint ? [...block.tagsHint] : [],
orphan: false,
});

View file

@ -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> = {}): IndexedGroupCardBlock {
@ -206,6 +302,7 @@ function createIndexedGroupBlock(overrides: Partial<IndexedGroupCardBlock> = {})
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 },

View file

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

View file

@ -27,6 +27,7 @@ export class RenderConfigService {
mapping,
addObsidianBacklink: settings.addObsidianBacklink,
convertHighlightsToCloze: settings.convertHighlightsToCloze,
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
};
const renderConfigHash = hashString(JSON.stringify(renderConfigPayload));

View file

@ -2,4 +2,5 @@ export interface SourceFile {
path: string;
basename: string;
content: string;
tags: string[];
}

View file

@ -42,6 +42,7 @@ export interface IndexedGroupCardBlock {
deckHint?: string;
deckHintSource?: Extract<DeckResolutionSource, "frontmatter" | "body">;
deckWarnings: DeckResolutionWarning[];
tagsHint?: string[];
items: GroupItem[];
groupMarker?: GroupMarker;
freeSlots: number[];

View file

@ -73,6 +73,7 @@ export interface GroupBlockState {
deckHint?: string;
deckHintSource?: Extract<DeckResolutionSource, "frontmatter" | "body">;
deckWarnings: DeckResolutionWarning[];
tagsHint?: string[];
items: GroupItem[];
freeSlots: number[];
lastSyncedAt: number;

View file

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

View file

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

View file

@ -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> = {}): IndexedCard {

View file

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

View file

@ -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 = /(?<!!)\[\[([^\]]+)\]\]/g;
export interface ManualCardRenderContext {
addObsidianBacklink: boolean;
convertHighlightsToCloze: boolean;
keepPureTagLinesInCardBody: boolean;
resourceResolver: ManualSyncVaultGateway;
}
@ -78,7 +81,8 @@ export class ManualCardRenderer {
cloze: boolean,
inline: boolean,
): RenderResult {
const protectedBlocks = protectSegments(markdownText, FENCED_CODE_PATTERN, "FENCED_CODE");
const sourceMarkdown = inline ? markdownText : preprocessCardBodyMarkdown(markdownText, context.keepPureTagLinesInCardBody);
const protectedBlocks = protectSegments(sourceMarkdown, FENCED_CODE_PATTERN, "FENCED_CODE");
const protectedInline = protectSegments(protectedBlocks.text, INLINE_CODE_PATTERN, "INLINE_CODE");
let transformed = protectedInline.text;
const media: MediaAsset[] = [];

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { isPureTagLine, preprocessCardBodyMarkdown } from "./preprocessCardBodyMarkdown";
describe("preprocessCardBodyMarkdown", () => {
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);
});
});

View file

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

View file

@ -0,0 +1,39 @@
function uniqueNonEmptyTags(tags: string[] | undefined): string[] {
const uniqueTags: string[] = [];
const seen = new Set<string>();
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)),
};
}

View file

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

View file

@ -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<T> {
@ -14,6 +14,7 @@ interface NoteInfo {
fields?: Record<string, { value?: string }>;
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<void> {
const actions: Array<{ action: string; params: Record<string, unknown> }> = [];
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<void>(actions);
}
async changeDecks(inputs: ChangeDeckInput[]): Promise<void> {
await this.invokeMulti<void>(inputs.filter((input) => input.cardIds.length > 0).map((input) => ({
action: "changeDeck",

View file

@ -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 | TFile>) => 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",
]);
});
});

View file

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

View file

@ -0,0 +1,34 @@
export function normalizeObsidianTags(rawTags: Iterable<string> | null | undefined): string[] {
if (!rawTags) {
return [];
}
const normalizedTags: string[] = [];
const seen = new Set<string>();
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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<AnkiNoteSummary[]> {
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<void> {
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<void> {
this.changedDecks.push(...inputs);
}

View file

@ -2,6 +2,36 @@ export async function requestUrl(): Promise<never> {
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";
}