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,