panatgithub_AnkiHeadingSync/src/domain/manual-sync/services/CardMarkerService.ts
Dusk 4052eff0e7 feat: 新增 cardAnswerCutoffMode 卡片正文截止模式,支持 heading-block / double-blank-lines,并统一 marker/GI 写回归整规则
feat: add cardAnswerCutoffMode with heading-block / double-blank-lines and unify marker/GI writeback normalization.
2026-04-23 10:16:41 +08:00

125 lines
3.9 KiB
TypeScript

import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import type { IdMarker } from "@/domain/manual-sync/entities/IdMarker";
import { applyNormalizedMarkerWrite } from "./MarkerWritebackNormalization";
export interface MarkerWriteRequest {
filePath: string;
noteId: number;
blockStartLine: number;
contentEndLine: number;
blockEndLine: number;
markerLine?: number;
markerIndent?: string;
sourceContent: string;
}
export class CardMarkerError extends Error {
constructor(message: string) {
super(message);
this.name = "CardMarkerError";
}
}
const ID_MARKER_CANDIDATE_REGEXP = /<!--\s*ID:/;
const ID_MARKER_REGEXP = /^\s*<!--\s*ID:\s*([1-9]\d*)\s*-->\s*$/;
export class CardMarkerService {
isCandidate(line: string): boolean {
return ID_MARKER_CANDIDATE_REGEXP.test(line);
}
parse(line: string, lineIndex: number): IdMarker | null {
const match = line.match(ID_MARKER_REGEXP);
if (!match) {
return null;
}
return {
noteId: Number(match[1]),
raw: match[0].trim(),
lineIndex,
};
}
create(noteId: number): IdMarker {
return {
noteId,
raw: `<!--ID: ${noteId}-->`,
lineIndex: -1,
};
}
createForCard(card: IndexedCard): IdMarker {
if (card.noteId === undefined) {
throw new CardMarkerError("Cannot create an ID marker without a noteId.");
}
return this.create(card.noteId);
}
applyBatch(sourceContent: string, writes: MarkerWriteRequest[]): string {
if (writes.length === 0) {
return sourceContent;
}
this.validateBatch(sourceContent, writes);
const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n";
const lines = sourceContent.split(/\r?\n/);
const sortedWrites = [...writes].sort((left, right) => right.blockStartLine - left.blockStartLine);
for (const write of sortedWrites) {
this.applyWrite(lines, write);
}
return lines.join(lineEnding);
}
validateBatch(sourceContent: string, writes: MarkerWriteRequest[]): void {
const filePath = writes[0]?.filePath;
const seenBlocks = new Set<number>();
for (const write of writes) {
if (write.filePath !== filePath) {
throw new CardMarkerError("Batch marker writes must belong to the same Markdown file.");
}
if (write.sourceContent !== sourceContent) {
throw new CardMarkerError(`Marker writes for ${write.filePath} must share the scanned source content.`);
}
if (write.markerLine === undefined && write.contentEndLine > write.blockEndLine) {
throw new CardMarkerError(`Cannot insert marker outside the heading block in ${write.filePath}.`);
}
if (write.markerLine !== undefined && write.markerLine < write.blockStartLine) {
throw new CardMarkerError(`Cannot replace marker outside the heading block in ${write.filePath}.`);
}
if (write.contentEndLine < write.blockStartLine || write.contentEndLine > write.blockEndLine) {
throw new CardMarkerError(`Cannot write marker outside the content range in ${write.filePath}.`);
}
if (seenBlocks.has(write.blockStartLine)) {
throw new CardMarkerError(`Duplicate marker write detected for block ${write.blockStartLine} in ${write.filePath}.`);
}
seenBlocks.add(write.blockStartLine);
}
}
applyWrite(lines: string[], write: MarkerWriteRequest): void {
const adjustedBlockEndLine = write.markerLine !== undefined ? write.blockEndLine - 1 : write.blockEndLine;
if (write.contentEndLine < write.blockStartLine || write.contentEndLine > adjustedBlockEndLine) {
throw new CardMarkerError(`Cannot write marker outside the heading block in ${write.filePath}.`);
}
applyNormalizedMarkerWrite(lines, {
contentEndLine: write.contentEndLine,
blockEndLine: write.blockEndLine,
markerLine: write.markerLine,
markerIndent: write.markerIndent,
renderedMarker: this.create(write.noteId).raw,
});
}
}