mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
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:
parent
4052eff0e7
commit
579899f7c8
36 changed files with 1387 additions and 35 deletions
165
docs/tag-sync-and-pure-tag-line-decisions.md
Normal file
165
docs/tag-sync-and-pure-tag-line-decisions.md
Normal 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 渲染重构
|
||||
188
docs/tag-sync-and-pure-tag-line-gap-report.md
Normal file
188
docs/tag-sync-and-pure-tag-line-gap-report.md
Normal 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 的专用同步路径。
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export class RenderConfigService {
|
|||
mapping,
|
||||
addObsidianBacklink: settings.addObsidianBacklink,
|
||||
convertHighlightsToCloze: settings.convertHighlightsToCloze,
|
||||
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
|
||||
};
|
||||
const renderConfigHash = hashString(JSON.stringify(renderConfigPayload));
|
||||
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@ export interface SourceFile {
|
|||
path: string;
|
||||
basename: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ export interface IndexedGroupCardBlock {
|
|||
deckHint?: string;
|
||||
deckHintSource?: Extract<DeckResolutionSource, "frontmatter" | "body">;
|
||||
deckWarnings: DeckResolutionWarning[];
|
||||
tagsHint?: string[];
|
||||
items: GroupItem[];
|
||||
groupMarker?: GroupMarker;
|
||||
freeSlots: number[];
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export interface GroupBlockState {
|
|||
deckHint?: string;
|
||||
deckHintSource?: Extract<DeckResolutionSource, "frontmatter" | "body">;
|
||||
deckWarnings: DeckResolutionWarning[];
|
||||
tagsHint?: string[];
|
||||
items: GroupItem[];
|
||||
freeSlots: number[];
|
||||
lastSyncedAt: number;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
39
src/domain/manual-sync/services/tagSetUtils.ts
Normal file
39
src/domain/manual-sync/services/tagSetUtils.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
34
src/infrastructure/obsidian/normalizeObsidianTags.ts
Normal file
34
src/infrastructure/obsidian/normalizeObsidianTags.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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。",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue