From e45abb579fed0f22664c8aea23ed2888c3e2e388 Mon Sep 17 00:00:00 2001 From: Dusk Date: Thu, 23 Apr 2026 22:13:24 +0800 Subject: [PATCH] feat(sync): render Obsidian tags in Anki cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文: 增加 Obsidian 标签在 Anki 正文中的标签样式渲染,并覆盖 QA Group 同步链路,确保渲染配置变更会触发重同步。 English: Render retained Obsidian tag tokens as styled chips in Anki card bodies, extend the behavior to QA Group sync, and bump render/index fingerprints so existing cards resync. --- .../services/FileIndexerService.ts | 2 +- .../services/QaGroupSyncService.test.ts | 50 ++++++ .../services/QaGroupSyncService.ts | 3 +- .../services/RenderConfigService.ts | 3 + src/domain/card/entities/SourceFile.ts | 4 +- .../services/ManualCardRenderer.test.ts | 109 +++++++++++ .../services/ManualCardRenderer.ts | 4 +- .../services/renderObsidianTagChips.ts | 169 ++++++++++++++++++ .../obsidian/ObsidianVaultGateway.test.ts | 6 +- 9 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 src/domain/manual-sync/services/ManualCardRenderer.test.ts create mode 100644 src/domain/manual-sync/services/renderObsidianTagChips.ts diff --git a/src/application/services/FileIndexerService.ts b/src/application/services/FileIndexerService.ts index f098cea..a08dfbc 100644 --- a/src/application/services/FileIndexerService.ts +++ b/src/application/services/FileIndexerService.ts @@ -274,7 +274,7 @@ export function createFileStamp(mtime: number, size: number): string { return `${mtime}:${size}`; } -const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v3"; +const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v4"; export function createDeckRulesFingerprint(settings: PluginSettings): string { return hashString(JSON.stringify({ diff --git a/src/application/services/QaGroupSyncService.test.ts b/src/application/services/QaGroupSyncService.test.ts index 58042fe..8081ab8 100644 --- a/src/application/services/QaGroupSyncService.test.ts +++ b/src/application/services/QaGroupSyncService.test.ts @@ -232,6 +232,56 @@ describe("QaGroupSyncService", () => { }); }); + it("renders remaining inline tags in QA Group answers as chips after cleanup", 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: "旧答案", 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这是 #3地区 的案例\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.S01_A).toContain('class="ahs-ob-tag"'); + expect(ankiGateway.updatedNotes[0]?.fields.S01_A).toContain('data-tag="3地区"'); + expect(ankiGateway.updatedNotes[0]?.fields.S01_A).not.toContain('data-tag="项目A"'); + expect(ankiGateway.updatedNotes[0]?.fields.S01_A).not.toContain('data-tag="重点::案例"'); + expect(ankiGateway.updatedNotes[0]?.fields.S01_A).not.toContain('data-tag="代码"'); + }); + it("syncs QA Group note tags from file-level tag hints", async () => { const ankiGateway = new FakeManualSyncAnkiGateway(); const vaultGateway = new FakeManualSyncVaultGateway(); diff --git a/src/application/services/QaGroupSyncService.ts b/src/application/services/QaGroupSyncService.ts index d92a298..16eedae 100644 --- a/src/application/services/QaGroupSyncService.ts +++ b/src/application/services/QaGroupSyncService.ts @@ -8,6 +8,7 @@ import { DeckResolutionService } from "@/domain/manual-sync/services/DeckResolut 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 { renderObsidianTagChipsInText } from "@/domain/manual-sync/services/renderObsidianTagChips"; import { diffTagSets } from "@/domain/manual-sync/services/tagSetUtils"; import { buildQaGroupNoteFields, formatQaGroupSlot, QA_GROUP_MODEL_NAME, QA_GROUP_SLOT_COUNT } from "./QaGroupModelDefinition"; @@ -114,7 +115,7 @@ export class QaGroupSyncService { const renderedItems = resolvedItems.map((item) => ({ ...item, - answer: preprocessCardBodyMarkdown(item.answer, settings.keepPureTagLinesInCardBody), + answer: renderObsidianTagChipsInText(preprocessCardBodyMarkdown(item.answer, settings.keepPureTagLinesInCardBody)), })); const fields = buildQaGroupNoteFields(block.stem, groupId, this.buildGroupBacklink(block, settings), renderedItems); let noteId = recovered.noteId ?? block.noteId; diff --git a/src/application/services/RenderConfigService.ts b/src/application/services/RenderConfigService.ts index 448915f..6627be6 100644 --- a/src/application/services/RenderConfigService.ts +++ b/src/application/services/RenderConfigService.ts @@ -13,6 +13,8 @@ export interface RenderPlan { warnings: DeckResolutionWarning[]; } +const MANUAL_RENDER_CONFIG_VERSION = "manual-render-v2"; + export class RenderConfigService { constructor(private readonly deckResolutionService = new DeckResolutionService()) {} @@ -22,6 +24,7 @@ export class RenderConfigService { const deck = deckResolution.resolvedDeck.value; const mapping = settings.noteFieldMappings[createNoteFieldMappingKey(card.cardType, noteModel)] ?? null; const renderConfigPayload = { + version: MANUAL_RENDER_CONFIG_VERSION, cardType: card.cardType, noteModel, mapping, diff --git a/src/domain/card/entities/SourceFile.ts b/src/domain/card/entities/SourceFile.ts index 3e5700f..83522a9 100644 --- a/src/domain/card/entities/SourceFile.ts +++ b/src/domain/card/entities/SourceFile.ts @@ -2,5 +2,5 @@ export interface SourceFile { path: string; basename: string; content: string; - tags: string[]; -} \ No newline at end of file + tags?: string[]; +} diff --git a/src/domain/manual-sync/services/ManualCardRenderer.test.ts b/src/domain/manual-sync/services/ManualCardRenderer.test.ts new file mode 100644 index 0000000..dbdf5fe --- /dev/null +++ b/src/domain/manual-sync/services/ManualCardRenderer.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { FakeManualSyncVaultGateway } from "@/test-support/manualSyncFakes"; +import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; +import type { PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan"; + +import { ManualCardRenderer, type ManualCardRenderContext } from "./ManualCardRenderer"; + +describe("ManualCardRenderer", () => { + const renderer = new ManualCardRenderer(); + const resourceResolver = new FakeManualSyncVaultGateway(); + const baseContext: ManualCardRenderContext = { + addObsidianBacklink: false, + convertHighlightsToCloze: false, + keepPureTagLinesInCardBody: true, + resourceResolver, + }; + + it("wraps body tags as chips without touching the title field", () => { + const rendered = renderer.render(createPlannedCard({ + heading: "标题 #标题标签", + bodyMarkdown: [ + "第一段 #项目A", + "#📖/一人公司 #3地区", + ].join("\n"), + }), baseContext); + + expect(rendered.renderedFields.title).toContain("标题 #标题标签"); + expect(rendered.renderedFields.title).not.toContain("ahs-ob-tag"); + expect(rendered.renderedFields.body).toContain('class="ahs-ob-tag"'); + expect(rendered.renderedFields.body).toContain('data-tag="项目A"'); + expect(rendered.renderedFields.body).toContain('data-tag="📖::一人公司"'); + expect(rendered.renderedFields.body).toContain('data-tag="3地区"'); + }); + + it("deletes pure tag lines before rendering remaining inline tags as chips", () => { + const rendered = renderer.render(createPlannedCard({ + bodyMarkdown: [ + "#项目A #重点/案例", + "", + "这是 #3地区 的案例", + ].join("\n"), + }), { + ...baseContext, + keepPureTagLinesInCardBody: false, + }); + + expect(rendered.renderedFields.body).not.toContain('data-tag="项目A"'); + expect(rendered.renderedFields.body).not.toContain('data-tag="重点::案例"'); + expect(rendered.renderedFields.body).toContain('data-tag="3地区"'); + expect(rendered.renderedFields.body).toContain("这是"); + }); + + it("does not wrap tags inside inline code or fenced code blocks", () => { + const rendered = renderer.render(createPlannedCard({ + bodyMarkdown: [ + "`#行内代码`", + "", + "```ts", + "#代码块", + "```", + "", + "正常 #标签", + ].join("\n"), + }), baseContext); + + expect(rendered.renderedFields.body).toContain("#行内代码"); + expect(rendered.renderedFields.body).toContain("#代码块"); + expect(rendered.renderedFields.body).toContain('data-tag="标签"'); + expect(rendered.renderedFields.body).not.toContain('data-tag="行内代码"'); + expect(rendered.renderedFields.body).not.toContain('data-tag="代码块"'); + }); +}); + +function createPlannedCard(overrides: Partial = {}): PlannedCard { + return { + card: { + noteId: overrides.noteId, + syncKey: overrides.syncKey ?? "notes/example.md\u00001\u0000hash-1", + idMarkerState: overrides.idMarkerState ?? "missing", + noteIdSource: overrides.noteIdSource, + filePath: overrides.filePath ?? "notes/example.md", + cardType: overrides.cardType ?? "basic", + heading: overrides.heading ?? "标题", + backlinkHeadingText: overrides.backlinkHeadingText ?? overrides.heading ?? "标题", + headingLevel: overrides.headingLevel ?? 2, + bodyMarkdown: overrides.bodyMarkdown ?? "正文", + blockStartOffset: overrides.blockStartOffset ?? 0, + blockEndOffset: overrides.blockEndOffset ?? 20, + blockStartLine: overrides.blockStartLine ?? 1, + bodyStartLine: overrides.bodyStartLine ?? 2, + blockEndLine: overrides.blockEndLine ?? 3, + contentEndLine: overrides.contentEndLine ?? 3, + markerLine: overrides.markerLine, + markerIndent: overrides.markerIndent, + rawBlockText: overrides.rawBlockText ?? "## 标题\n正文", + rawBlockHash: overrides.rawBlockHash ?? "hash-1", + deckHint: overrides.deckHint, + deckHintSource: overrides.deckHintSource, + deckWarnings: overrides.deckWarnings ?? [], + tagsHint: overrides.tagsHint ?? [], + sourceContent: overrides.sourceContent ?? "## 标题\n正文", + }, + noteId: overrides.noteId, + deck: "Default", + noteModel: "Basic", + renderConfigHash: "render-config", + }; +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/ManualCardRenderer.ts b/src/domain/manual-sync/services/ManualCardRenderer.ts index d43a0d8..9c0ad1e 100644 --- a/src/domain/manual-sync/services/ManualCardRenderer.ts +++ b/src/domain/manual-sync/services/ManualCardRenderer.ts @@ -6,6 +6,7 @@ import type { MediaAsset } from "@/domain/card/entities/RenderedFields"; import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard"; import { preprocessCardBodyMarkdown } from "./preprocessCardBodyMarkdown"; +import { renderObsidianTagChipsInHtml } from "./renderObsidianTagChips"; const markdown = new MarkdownIt({ breaks: true, @@ -140,9 +141,10 @@ export class ManualCardRenderer { transformed = protectedBlocks.restore(transformed); const renderedHtml = (inline ? markdown.renderInline(transformed) : markdown.render(transformed)).trim(); + const restoredHtml = protectedMath.restore(renderedHtml, escapeHtml); return { - html: protectedMath.restore(renderedHtml, escapeHtml), + html: inline ? restoredHtml : renderObsidianTagChipsInHtml(restoredHtml), media, }; } diff --git a/src/domain/manual-sync/services/renderObsidianTagChips.ts b/src/domain/manual-sync/services/renderObsidianTagChips.ts new file mode 100644 index 0000000..506a0bc --- /dev/null +++ b/src/domain/manual-sync/services/renderObsidianTagChips.ts @@ -0,0 +1,169 @@ +import { normalizeObsidianTags } from "@/infrastructure/obsidian/normalizeObsidianTags"; + +const TAG_TOKEN_SOURCE = "#[^\\s#.,,。;;::!?!?()[\\]{}<>]+(?:\\/[^\\s#.,,。;;::!?!?()[\\]{}<>]+)*"; +const INLINE_CODE_PATTERN = /`[^`\n]+`/g; +const FENCED_CODE_PATTERN = /```[\s\S]*?```|~~~[\s\S]*?~~~/g; +const PRECEDING_TAG_TEXT_PATTERN = /[\p{L}\p{N}_/#]/u; +const EXCLUDED_HTML_TAGS = new Set(["code", "pre"]); + +const TAG_CHIP_CLASS = "ahs-ob-tag"; +const TAG_CHIP_STYLE = [ + "display:inline-block", + "padding:1px 8px", + "border-radius:999px", + "background:rgba(90,120,255,.12)", + "color:#5865d6", + "font-weight:500", + "line-height:1.5", + "margin:0 4px 2px 0", +].join(";"); + +export function renderObsidianTagChipsInText(text: string): string { + if (!text || !text.includes("#")) { + return text; + } + + const protectedBlocks = protectSegments(text, FENCED_CODE_PATTERN, "FENCED_CODE"); + const protectedInline = protectSegments(protectedBlocks.text, INLINE_CODE_PATTERN, "INLINE_CODE"); + const transformed = replaceTagTokens(protectedInline.text); + + return protectedBlocks.restore(protectedInline.restore(transformed)); +} + +export function renderObsidianTagChipsInHtml(html: string): string { + if (!html || !html.includes("#")) { + return html; + } + + let result = ""; + let index = 0; + const excludedTagStack: string[] = []; + + while (index < html.length) { + const nextTagIndex = html.indexOf("<", index); + const textEnd = nextTagIndex === -1 ? html.length : nextTagIndex; + + if (textEnd > index) { + const segment = html.slice(index, textEnd); + result += excludedTagStack.length === 0 ? replaceTagTokens(segment) : segment; + index = textEnd; + } + + if (index >= html.length) { + break; + } + + const tagEnd = html.indexOf(">", index); + if (tagEnd === -1) { + const remainder = html.slice(index); + result += excludedTagStack.length === 0 ? replaceTagTokens(remainder) : remainder; + break; + } + + const tagSource = html.slice(index, tagEnd + 1); + updateExcludedTagStack(tagSource, excludedTagStack); + result += tagSource; + index = tagEnd + 1; + } + + return result; +} + +function replaceTagTokens(text: string): string { + const pattern = new RegExp(TAG_TOKEN_SOURCE, "gu"); + let nextText = ""; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(text)) !== null) { + const rawTag = match[0]; + const startIndex = match.index; + const endIndex = startIndex + rawTag.length; + + if (!hasValidTagBoundary(text, startIndex)) { + continue; + } + + const normalizedTag = normalizeObsidianTags([rawTag])[0]; + if (!normalizedTag) { + continue; + } + + nextText += text.slice(lastIndex, startIndex); + nextText += buildTagChip(rawTag, normalizedTag); + lastIndex = endIndex; + } + + if (lastIndex === 0) { + return text; + } + + return nextText + text.slice(lastIndex); +} + +function hasValidTagBoundary(text: string, startIndex: number): boolean { + if (startIndex === 0) { + return true; + } + + return !PRECEDING_TAG_TEXT_PATTERN.test(text[startIndex - 1] ?? ""); +} + +function buildTagChip(rawTag: string, normalizedTag: string): string { + return `${escapeHtml(rawTag)}`; +} + +function updateExcludedTagStack(tagSource: string, excludedTagStack: string[]): void { + const match = /^<\s*(\/)?\s*([a-zA-Z0-9-]+)/.exec(tagSource); + if (!match) { + return; + } + + const isClosingTag = Boolean(match[1]); + const tagName = match[2]?.toLowerCase(); + if (!tagName || !EXCLUDED_HTML_TAGS.has(tagName)) { + return; + } + + if (isClosingTag) { + for (let index = excludedTagStack.length - 1; index >= 0; index -= 1) { + if (excludedTagStack[index] === tagName) { + excludedTagStack.splice(index, 1); + break; + } + } + return; + } + + if (!/\/\s*>$/.test(tagSource)) { + excludedTagStack.push(tagName); + } +} + +function protectSegments( + text: string, + pattern: RegExp, + prefix: string, +): { text: string; restore: (value: string) => string } { + const matches: string[] = []; + const nextText = text.replace(pattern, (segment) => { + const token = `@@${prefix}_${matches.length}@@`; + matches.push(segment); + return token; + }); + + return { + text: nextText, + restore: (value: string) => + matches.reduce((current, segment, index) => current.split(`@@${prefix}_${index}@@`).join(segment), value), + }; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts b/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts index 540116c..6941777 100644 --- a/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts +++ b/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts @@ -6,7 +6,8 @@ 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 FileCtor = TFile as unknown as new (path: string) => TFile; + const file = new FileCtor("notes/example.md"); const gateway = new ObsidianVaultGateway({ vault: { getAbstractFileByPath: () => file, @@ -35,7 +36,8 @@ describe("ObsidianVaultGateway", () => { }); it("returns an empty tag list when metadata cache is missing or has no tags", async () => { - const file = new TFile("notes/example.md"); + const FileCtor = TFile as unknown as new (path: string) => TFile; + const file = new FileCtor("notes/example.md"); const gateway = new ObsidianVaultGateway({ vault: { getAbstractFileByPath: () => file,