panatgithub_AnkiHeadingSync/src/application/services/QaGroupSyncService.ts
Dusk e45abb579f 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.
2026-04-23 22:13:24 +08:00

566 lines
20 KiB
TypeScript

import type { PluginSettings } from "@/application/config/PluginSettings";
import type { AnkiGroupGateway, AnkiNoteDetails } from "@/application/ports/AnkiGateway";
import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation";
import { buildGroupSrc, type GroupItem, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
import type { GroupBlockState, PluginState } from "@/domain/manual-sync/entities/PluginState";
import { GroupMarkerService, type GroupMarkerWriteRequest } from "@/domain/manual-sync/services/GroupMarkerService";
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 { 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";
import { QaGroupModelService } from "./QaGroupModelService";
export interface QaGroupSyncExecutionResult {
created: number;
updated: number;
migratedDecks: number;
markerWrites: GroupMarkerWriteRequest[];
resolvedNoteIds: Map<string, number>;
touchedSyncKeys: string[];
syncedGroupBlocks: GroupBlockState[];
warnings: DeckResolutionWarning[];
}
interface GroupStateIndex {
byGroupId: Map<string, GroupBlockState>;
byNoteId: Map<number, GroupBlockState>;
bySrc: Map<string, GroupBlockState[]>;
}
interface RecoveredGroupRecord {
noteId?: number;
groupId?: string;
items: GroupItem[];
freeSlots: number[];
noteDetails?: AnkiNoteDetails;
stateRecord?: GroupBlockState;
}
export class QaGroupSyncService {
private sequence = 0;
constructor(
private readonly ankiGateway: AnkiGroupGateway,
private readonly qaGroupModelService = new QaGroupModelService(ankiGateway),
private readonly deckResolutionService = new DeckResolutionService(),
private readonly groupMarkerService = new GroupMarkerService(),
private readonly now: () => number = () => Date.now(),
private readonly createGroupId: () => string = () => `g_${hashString(`${Date.now()}_${Math.random()}_${Date.now()}`).slice(0, 12)}`,
private readonly createItemId: () => string = () => {
this.sequence += 1;
return `i_${hashString(`${Date.now()}_${this.sequence}_${Math.random()}`).slice(0, 10)}`;
},
private readonly createBacklink?: (location: SourceLocation) => string,
) {}
async sync(blocks: IndexedGroupCardBlock[], state: PluginState, settings: PluginSettings): Promise<QaGroupSyncExecutionResult> {
if (blocks.length === 0) {
return {
created: 0,
updated: 0,
migratedDecks: 0,
markerWrites: [],
resolvedNoteIds: new Map(),
touchedSyncKeys: [],
syncedGroupBlocks: [],
warnings: [],
};
}
await this.qaGroupModelService.ensureModel();
const stateIndex = buildGroupStateIndex(state.groupBlocks ?? {});
const ensuredDecks = new Set<string>();
const warningMap = new Map<string, DeckResolutionWarning>();
const markerWrites: GroupMarkerWriteRequest[] = [];
const resolvedNoteIds = new Map<string, number>();
const touchedSyncKeys = new Set<string>();
const syncedGroupBlocks: GroupBlockState[] = [];
let created = 0;
let updated = 0;
let migratedDecks = 0;
for (const block of blocks) {
if (block.items.length > QA_GROUP_SLOT_COUNT) {
throw new Error(`QA Group block exceeds 12 items at ${block.filePath}:${block.blockStartLine}.`);
}
const recovered = await this.resolveRecoveredGroup(block, stateIndex);
const groupId = recovered.groupId ?? block.groupId ?? this.createGroupId();
const resolvedItems = reconcileGroupItems(block.items, recovered.items, this.createItemId);
if (resolvedItems.length > QA_GROUP_SLOT_COUNT) {
throw new Error(`QA Group block exceeds 12 items after recovery at ${block.filePath}:${block.blockStartLine}.`);
}
const freeSlots = buildFreeSlotsFromItems(resolvedItems);
const deckResolution = this.deckResolutionService.resolve({
filePath: block.filePath,
deckHint: block.deckHint,
deckHintSource: block.deckHintSource,
deckWarnings: block.deckWarnings,
} as never, settings.defaultDeck, settings.folderDeckMode);
for (const warning of deckResolution.warnings) {
warningMap.set(getDeckResolutionWarningKey(warning), warning);
}
const deck = deckResolution.resolvedDeck.value;
if (!ensuredDecks.has(deck)) {
await this.ankiGateway.ensureDeckExists(deck);
ensuredDecks.add(deck);
}
const renderedItems = resolvedItems.map((item) => ({
...item,
answer: renderObsidianTagChipsInText(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;
if (noteId === undefined) {
noteId = await this.ankiGateway.addNote({
deckName: deck,
modelName: QA_GROUP_MODEL_NAME,
fields,
tags: block.tagsHint ?? [],
});
created += 1;
touchedSyncKeys.add(block.syncKey);
} else {
existingNote ??= await this.tryLoadQaGroupNote(noteId, block);
if (!existingNote) {
noteId = await this.ankiGateway.addNote({
deckName: deck,
modelName: QA_GROUP_MODEL_NAME,
fields,
tags: block.tagsHint ?? [],
});
created += 1;
touchedSyncKeys.add(block.syncKey);
}
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);
}
}
resolvedNoteIds.set(block.syncKey, noteId);
const itemToSlot = Object.fromEntries(resolvedItems
.filter((item) => item.itemId && item.slot)
.map((item) => [item.itemId as string, item.slot as number]));
if (
block.markerState !== "present-valid"
|| !this.groupMarkerService.isEquivalent(block.groupMarker, noteId, itemToSlot, freeSlots)
|| hasLegacyInnerIdMarkers(block)
) {
markerWrites.push({
syncKey: block.syncKey,
filePath: block.filePath,
blockStartLine: block.blockStartLine,
contentEndLine: block.contentEndLine,
blockEndLine: block.blockEndLine,
markerLine: block.markerLine,
markerIndent: block.markerIndent,
noteId,
groupId,
itemToSlot,
freeSlots,
sourceContent: block.sourceContent ?? "",
});
}
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: touchedSyncKeys.has(block.syncKey) ? now : recovered.stateRecord?.lastSyncedAt ?? now,
orphan: false,
});
}
return {
created,
updated,
migratedDecks,
markerWrites,
resolvedNoteIds,
touchedSyncKeys: [...touchedSyncKeys],
syncedGroupBlocks,
warnings: [...warningMap.values()],
};
}
private async resolveRecoveredGroup(block: IndexedGroupCardBlock, stateIndex: GroupStateIndex): Promise<RecoveredGroupRecord> {
const stateRecord = (block.groupId ? stateIndex.byGroupId.get(block.groupId) : undefined)
?? (block.noteId !== undefined ? stateIndex.byNoteId.get(block.noteId) : undefined)
?? uniqueMatch(stateIndex.bySrc.get(block.src));
if (stateRecord) {
return {
noteId: stateRecord.noteId,
groupId: stateRecord.groupId,
items: stateRecord.items.map((item) => ({ ...item })),
freeSlots: [...stateRecord.freeSlots],
stateRecord,
};
}
if (block.groupId) {
const recoveredByGroupId = await this.findSingleQaGroupNote(`note:${quoteAnkiValue(QA_GROUP_MODEL_NAME)} GroupId:${quoteAnkiValue(block.groupId)}`, block);
if (recoveredByGroupId) {
return noteDetailsToRecoveredGroup(recoveredByGroupId, block.groupId);
}
}
if (block.noteId !== undefined) {
const note = await this.tryLoadQaGroupNote(block.noteId, block);
if (note) {
return noteDetailsToRecoveredGroup(note, block.groupId);
}
}
const recoveredBySrc = await this.findSingleQaGroupNote(`note:${quoteAnkiValue(QA_GROUP_MODEL_NAME)} Src:${quoteAnkiValue(block.src)}`, block);
if (recoveredBySrc) {
return noteDetailsToRecoveredGroup(recoveredBySrc, block.groupId);
}
return {
items: [],
freeSlots: Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1),
};
}
private async findSingleQaGroupNote(query: string, block: IndexedGroupCardBlock): Promise<AnkiNoteDetails | undefined> {
const noteIds = await this.ankiGateway.findNoteIds(query);
if (noteIds.length === 0) {
return undefined;
}
if (noteIds.length > 1) {
throw new Error(`QA Group recovery is ambiguous at ${block.filePath}:${block.blockStartLine}. Query: ${query}`);
}
return this.tryLoadQaGroupNote(noteIds[0], block);
}
private async tryLoadQaGroupNote(noteId: number, block: IndexedGroupCardBlock): Promise<AnkiNoteDetails | undefined> {
const note = (await this.ankiGateway.getNoteDetails([noteId]))[0];
if (!note) {
return undefined;
}
if (note.modelName !== QA_GROUP_MODEL_NAME) {
throw new Error(`Note ${noteId} for ${block.filePath}:${block.blockStartLine} is ${note.modelName}, expected ${QA_GROUP_MODEL_NAME}.`);
}
return note;
}
private buildGroupBacklink(block: IndexedGroupCardBlock, settings: PluginSettings): string {
if (!settings.addObsidianBacklink || !this.createBacklink) {
return "";
}
return this.createBacklink({
filePath: block.filePath,
sourceContent: block.sourceContent,
headingLine: block.blockStartLine,
blockStartLine: block.blockStartLine,
bodyStartLine: block.bodyStartLine,
blockEndLine: block.blockEndLine,
contentEndLine: block.contentEndLine,
markerLine: block.markerLine,
headingLevel: block.headingLevel,
headingText: block.backlinkHeadingText,
});
}
}
function buildGroupStateIndex(groupBlocks: Record<string, GroupBlockState>): GroupStateIndex {
const byGroupId = new Map<string, GroupBlockState>();
const byNoteId = new Map<number, GroupBlockState>();
const bySrc = new Map<string, GroupBlockState[]>();
for (const groupBlock of Object.values(groupBlocks)) {
if (groupBlock.orphan) {
continue;
}
byGroupId.set(groupBlock.groupId, groupBlock);
byNoteId.set(groupBlock.noteId, groupBlock);
const entries = bySrc.get(groupBlock.src);
if (entries) {
entries.push(groupBlock);
continue;
}
bySrc.set(groupBlock.src, [groupBlock]);
}
return {
byGroupId,
byNoteId,
bySrc,
};
}
function noteDetailsToRecoveredGroup(note: AnkiNoteDetails, fallbackGroupId?: string): RecoveredGroupRecord {
const items: GroupItem[] = [];
const occupiedSlots = new Set<number>();
for (let slot = 1; slot <= QA_GROUP_SLOT_COUNT; slot += 1) {
const slotId = formatQaGroupSlot(slot);
const itemId = (note.fields[`${slotId}_Id`] ?? "").trim() || `legacy_${slotId.toLowerCase()}`;
const title = (note.fields[`${slotId}_Q`] ?? "").trim();
const answer = (note.fields[`${slotId}_A`] ?? "").trim();
if (!title && !answer && !(note.fields[`${slotId}_Id`] ?? "").trim()) {
continue;
}
occupiedSlots.add(slot);
items.push({
itemId,
title,
answer,
slot,
ordinalInMarkdown: slot,
});
}
return {
noteId: note.noteId,
groupId: (note.fields.GroupId ?? "").trim() || fallbackGroupId,
items,
freeSlots: Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1).filter((slot) => !occupiedSlots.has(slot)),
noteDetails: note,
};
}
function reconcileGroupItems(currentItems: GroupItem[], recoveredItems: GroupItem[], createItemId: () => string): GroupItem[] {
const recoveredQueues = new Map<string, GroupItem[]>();
const unmatchedRecovered: GroupItem[] = [];
for (const item of recoveredItems) {
const key = createItemContentKey(item.title, item.answer);
const queue = recoveredQueues.get(key);
if (queue) {
queue.push(item);
} else {
recoveredQueues.set(key, [item]);
}
unmatchedRecovered.push(item);
}
const resolvedItems: GroupItem[] = [];
const unmatchedCurrentIndexes: number[] = [];
for (let index = 0; index < currentItems.length; index += 1) {
const currentItem = currentItems[index];
const queue = recoveredQueues.get(createItemContentKey(currentItem.title, currentItem.answer));
const matchedRecovered = queue?.shift();
if (!matchedRecovered) {
unmatchedCurrentIndexes.push(index);
continue;
}
removeRecoveredReference(unmatchedRecovered, matchedRecovered);
resolvedItems[index] = {
...currentItem,
itemId: matchedRecovered.itemId,
slot: matchedRecovered.slot,
};
}
const remainingRecovered = [...unmatchedRecovered].sort((left, right) => (left.ordinalInMarkdown - right.ordinalInMarkdown) || ((left.slot ?? 999) - (right.slot ?? 999)));
for (const currentIndex of unmatchedCurrentIndexes) {
const currentItem = currentItems[currentIndex];
const recoveredItem = remainingRecovered.shift();
if (recoveredItem) {
resolvedItems[currentIndex] = {
...currentItem,
itemId: recoveredItem.itemId,
slot: recoveredItem.slot,
};
continue;
}
resolvedItems[currentIndex] = {
...currentItem,
itemId: createItemId(),
};
}
const occupiedSlots = new Set<number>(resolvedItems.map((item) => item.slot).filter((slot): slot is number => typeof slot === "number"));
const freeSlots = Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1).filter((slot) => !occupiedSlots.has(slot));
for (const item of resolvedItems) {
if (item.slot !== undefined) {
continue;
}
const nextSlot = freeSlots.shift();
if (nextSlot === undefined) {
throw new Error("No free QA Group slots are available.");
}
item.slot = nextSlot;
}
return resolvedItems.map((item, index) => ({
...item,
ordinalInMarkdown: index + 1,
}));
}
function buildFreeSlotsFromItems(items: GroupItem[]): number[] {
const occupiedSlots = new Set<number>(items.map((item) => item.slot).filter((slot): slot is number => typeof slot === "number"));
return Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1).filter((slot) => !occupiedSlots.has(slot));
}
function createItemContentKey(title: string, answer: string): string {
return `${title}\u0000${answer}`;
}
function removeRecoveredReference(items: GroupItem[], target: GroupItem): void {
const index = items.findIndex((item) => item.itemId === target.itemId && item.slot === target.slot && item.title === target.title && item.answer === target.answer);
if (index >= 0) {
items.splice(index, 1);
}
}
function uniqueMatch<T>(items: T[] | undefined): T | undefined {
if (!items || items.length !== 1) {
return undefined;
}
return items[0];
}
function haveEqualFields(left: Record<string, string>, right: Record<string, string>): boolean {
const leftKeys = Object.keys(left).sort();
const rightKeys = Object.keys(right).sort();
if (leftKeys.length !== rightKeys.length) {
return false;
}
for (let index = 0; index < leftKeys.length; index += 1) {
if (leftKeys[index] !== rightKeys[index]) {
return false;
}
if ((left[leftKeys[index]] ?? "") !== (right[rightKeys[index]] ?? "")) {
return false;
}
}
return true;
}
function hasLegacyInnerIdMarkers(block: IndexedGroupCardBlock): boolean {
if (!block.sourceContent) {
return false;
}
const lines = block.sourceContent.split(/\r?\n/);
for (let lineIndex = block.blockStartLine; lineIndex < block.blockEndLine; lineIndex += 1) {
if (/^\s*<!--\s*ID:/i.test(lines[lineIndex] ?? "")) {
return true;
}
}
return false;
}
function quoteAnkiValue(value: string): string {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
}