mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
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:
parent
579899f7c8
commit
e45abb579f
9 changed files with 343 additions and 7 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ export interface SourceFile {
|
|||
path: string;
|
||||
basename: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
}
|
||||
tags?: string[];
|
||||
}
|
||||
|
|
|
|||
109
src/domain/manual-sync/services/ManualCardRenderer.test.ts
Normal file
109
src/domain/manual-sync/services/ManualCardRenderer.test.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
169
src/domain/manual-sync/services/renderObsidianTagChips.ts
Normal file
169
src/domain/manual-sync/services/renderObsidianTagChips.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue