feat(sync): render Obsidian tags in Anki cards

中文: 增加 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.
This commit is contained in:
Dusk 2026-04-23 22:13:24 +08:00
parent 579899f7c8
commit e45abb579f
9 changed files with 343 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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("<code>#行内代码</code>");
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<IndexedCard> = {}): 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",
};
}

View file

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

View file

@ -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 `<span class="${TAG_CHIP_CLASS}" data-tag="${escapeHtml(normalizedTag)}" style="${TAG_CHIP_STYLE}">${escapeHtml(rawTag)}</span>`;
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

View file

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