fix(markers): normalize spacing before and after synced ids

中文: 统一普通 QA、填空题、QA Group 与旧 AHS marker 的写回排版。现在 marker 会紧贴正文最后一行,并在下一个标题前固定保留两行空白,同时清理旧的多余空行。

English: Normalize write-back spacing for standard ID markers, QA Group GI markers, and legacy AHS markers. Markers now sit directly after the last content line and keep exactly two blank lines before the next heading while removing stale extra spacing.
This commit is contained in:
Dusk 2026-04-20 22:43:01 +08:00
parent 472011c32f
commit c8b7915c40
8 changed files with 124 additions and 39 deletions

View file

@ -12,11 +12,11 @@ describe("HeadingSyncMarkerService", () => {
const nextContent = service.apply(
{
filePath: "notes/example.md",
sourceContent: ["#### Prompt", "Answer", "", "### Next"].join("\n"),
sourceContent: ["#### Prompt", "Answer", "", "", "### Next"].join("\n"),
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
blockEndLine: 4,
contentEndLine: 2,
headingLevel: 4,
headingText: "Prompt",
@ -24,7 +24,7 @@ describe("HeadingSyncMarkerService", () => {
123,
);
expect(nextContent).toBe(["#### Prompt", "Answer", "<!-- AHS:123 -->", "", "### Next"].join("\n"));
expect(nextContent).toBe(["#### Prompt", "Answer", "<!-- AHS:123 -->", "", "", "### Next"].join("\n"));
});
it("writes the marker right after the heading when the block body is empty", () => {
@ -43,18 +43,18 @@ describe("HeadingSyncMarkerService", () => {
456,
);
expect(nextContent).toBe(["#### Prompt", "<!-- AHS:456 -->", "### Next"].join("\n"));
expect(nextContent).toBe(["#### Prompt", "<!-- AHS:456 -->", "", "", "### Next"].join("\n"));
});
it("replaces an existing marker without moving past the next heading", () => {
const nextContent = service.apply(
{
filePath: "notes/example.md",
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:123 -->", "### Next"].join("\n"),
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:123 -->", "", "", "### Next"].join("\n"),
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
blockEndLine: 5,
contentEndLine: 2,
markerLine: 3,
headingLevel: 4,
@ -63,7 +63,7 @@ describe("HeadingSyncMarkerService", () => {
789,
);
expect(nextContent).toBe(["#### Prompt", "Answer", "<!-- AHS:789 -->", "### Next"].join("\n"));
expect(nextContent).toBe(["#### Prompt", "Answer", "<!-- AHS:789 -->", "", "", "### Next"].join("\n"));
});
it("applies multiple writes in one file from bottom to top", () => {
@ -71,6 +71,7 @@ describe("HeadingSyncMarkerService", () => {
"#### Top",
"Top answer",
"",
"",
"#### Bottom",
"Bottom answer",
].join("\n");
@ -86,7 +87,7 @@ describe("HeadingSyncMarkerService", () => {
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
blockEndLine: 4,
contentEndLine: 2,
headingLevel: 4,
headingText: "Top",
@ -101,11 +102,11 @@ describe("HeadingSyncMarkerService", () => {
location: {
filePath: "notes/example.md",
sourceContent,
headingLine: 4,
blockStartLine: 4,
bodyStartLine: 5,
blockEndLine: 5,
contentEndLine: 5,
headingLine: 5,
blockStartLine: 5,
bodyStartLine: 6,
blockEndLine: 6,
contentEndLine: 6,
headingLevel: 4,
headingText: "Bottom",
},
@ -119,6 +120,7 @@ describe("HeadingSyncMarkerService", () => {
"Top answer",
"<!-- AHS:111 -->",
"",
"",
"#### Bottom",
"Bottom answer",
"<!-- AHS:222 -->",
@ -131,6 +133,7 @@ describe("HeadingSyncMarkerService", () => {
"Body 1",
"<!-- AHS:42 -->",
"",
"",
"#### Second",
"Body 2",
].join("\n");
@ -146,7 +149,7 @@ describe("HeadingSyncMarkerService", () => {
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
blockEndLine: 5,
contentEndLine: 2,
markerLine: 3,
headingLevel: 4,
@ -162,11 +165,11 @@ describe("HeadingSyncMarkerService", () => {
location: {
filePath: "notes/example.md",
sourceContent,
headingLine: 5,
blockStartLine: 5,
bodyStartLine: 6,
blockEndLine: 6,
contentEndLine: 6,
headingLine: 6,
blockStartLine: 6,
bodyStartLine: 7,
blockEndLine: 7,
contentEndLine: 7,
headingLevel: 4,
headingText: "Second",
},
@ -180,9 +183,10 @@ describe("HeadingSyncMarkerService", () => {
"Body 1",
"<!-- AHS:9001 -->",
"",
"",
"#### Second",
"Body 2",
"<!-- AHS:9002 -->",
].join("\n"));
});
});
});

View file

@ -93,11 +93,32 @@ export class HeadingSyncMarkerService {
throw new HeadingSyncMarkerBatchError(`Cannot write AHS marker outside the heading block in ${location.filePath}.`);
}
const removalIndexes = new Set<number>();
if (location.markerLine) {
lines.splice(location.markerLine - 1, 1);
removalIndexes.add(location.markerLine - 1);
}
lines.splice(location.contentEndLine, 0, this.create(noteId).raw);
for (let lineIndex = location.contentEndLine; lineIndex < location.blockEndLine; lineIndex += 1) {
if (!(lines[lineIndex] ?? "").trim()) {
removalIndexes.add(lineIndex);
}
}
const sortedRemovals = [...removalIndexes].sort((left, right) => right - left);
const insertionIndex = location.contentEndLine - [...removalIndexes].filter((lineIndex) => lineIndex < location.contentEndLine).length;
for (const lineIndex of sortedRemovals) {
lines.splice(lineIndex, 1);
}
lines.splice(insertionIndex, 0, this.create(noteId).raw);
while (insertionIndex + 1 < lines.length && !(lines[insertionIndex + 1] ?? "").trim()) {
lines.splice(insertionIndex + 1, 1);
}
if (insertionIndex + 1 < lines.length) {
lines.splice(insertionIndex + 1, 0, "", "");
}
}
create(noteId: number): HeadingSyncMarker {
@ -107,4 +128,4 @@ export class HeadingSyncMarkerService {
lineIndex: -1,
};
}
}
}

View file

@ -414,6 +414,7 @@ export class ManualSyncService {
syncKey: block.syncKey,
filePath: block.filePath,
blockStartLine: block.blockStartLine,
contentEndLine: block.contentEndLine,
blockEndLine: block.blockEndLine,
markerLine: block.markerLine,
markerIndent: block.markerIndent,

View file

@ -179,6 +179,7 @@ export class QaGroupSyncService {
syncKey: block.syncKey,
filePath: block.filePath,
blockStartLine: block.blockStartLine,
contentEndLine: block.contentEndLine,
blockEndLine: block.blockEndLine,
markerLine: block.markerLine,
markerIndent: block.markerIndent,

View file

@ -15,7 +15,7 @@ describe("CardMarkerService", () => {
it("writes multiple markers bottom-up in a single file", () => {
const service = new CardMarkerService();
const sourceContent = ["#### Top", "Body 1", "", "#### Bottom", "Body 2"].join("\n");
const sourceContent = ["#### Top", "Body 1", "", "", "#### Bottom", "Body 2"].join("\n");
const nextContent = service.applyBatch(sourceContent, [
{
@ -23,15 +23,15 @@ describe("CardMarkerService", () => {
noteId: 11,
blockStartLine: 1,
contentEndLine: 2,
blockEndLine: 3,
blockEndLine: 4,
sourceContent,
},
{
filePath: "notes/example.md",
noteId: 22,
blockStartLine: 4,
contentEndLine: 5,
blockEndLine: 5,
blockStartLine: 5,
contentEndLine: 6,
blockEndLine: 6,
sourceContent,
},
]);
@ -41,9 +41,10 @@ describe("CardMarkerService", () => {
"Body 1",
"<!--ID: 11-->",
"",
"",
"#### Bottom",
"Body 2",
"<!--ID: 22-->",
].join("\n"));
});
});
});

View file

@ -94,6 +94,10 @@ export class CardMarkerService {
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}.`);
}
@ -108,10 +112,31 @@ export class CardMarkerService {
throw new CardMarkerError(`Cannot write marker outside the heading block in ${write.filePath}.`);
}
const removalIndexes = new Set<number>();
if (write.markerLine !== undefined) {
lines.splice(write.markerLine - 1, 1);
removalIndexes.add(write.markerLine - 1);
}
lines.splice(write.contentEndLine, 0, `${write.markerIndent ?? ""}${this.create(write.noteId).raw}`);
for (let lineIndex = write.contentEndLine; lineIndex < write.blockEndLine; lineIndex += 1) {
if (!(lines[lineIndex] ?? "").trim()) {
removalIndexes.add(lineIndex);
}
}
const sortedRemovals = [...removalIndexes].sort((left, right) => right - left);
const insertionIndex = write.contentEndLine - [...removalIndexes].filter((lineIndex) => lineIndex < write.contentEndLine).length;
for (const lineIndex of sortedRemovals) {
lines.splice(lineIndex, 1);
}
lines.splice(insertionIndex, 0, `${write.markerIndent ?? ""}${this.create(write.noteId).raw}`);
while (insertionIndex + 1 < lines.length && !(lines[insertionIndex + 1] ?? "").trim()) {
lines.splice(insertionIndex + 1, 1);
}
if (insertionIndex + 1 < lines.length) {
lines.splice(insertionIndex + 1, 0, "", "");
}
}
}
}

View file

@ -12,15 +12,20 @@ describe("GroupMarkerService", () => {
" <!--ID: 42-->",
"- Beta",
" - Second answer",
"",
"",
"<!--GI:n=42;i=item_a:1;f=2,3,4,5,6,7,8,9,10,11,12-->",
"",
"#### Next heading",
].join("\n");
const nextContent = service.applyBatch(sourceContent, [{
syncKey: "notes/example.md\u0000group\u00001\u0000hash",
filePath: "notes/example.md",
blockStartLine: 1,
blockEndLine: 7,
markerLine: 7,
contentEndLine: 6,
blockEndLine: 9,
markerLine: 9,
noteId: 42,
itemToSlot: { item_a: 1, item_b: 2 },
freeSlots: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
@ -28,6 +33,16 @@ describe("GroupMarkerService", () => {
}]);
expect(nextContent).not.toContain("<!--ID: 42-->");
expect(nextContent.split("\n").at(-1)).toBe("<!--GI:n=42;i=item_a:1,item_b:2;f=3,4,5,6,7,8,9,10,11,12-->");
expect(nextContent).toBe([
"#### Concepts #anki-list",
"- Alpha",
" - First answer",
"- Beta",
" - Second answer",
"<!--GI:n=42;i=item_a:1,item_b:2;f=3,4,5,6,7,8,9,10,11,12-->",
"",
"",
"#### Next heading",
].join("\n"));
});
});
});

View file

@ -9,6 +9,7 @@ export interface GroupMarkerWriteRequest {
syncKey: string;
filePath: string;
blockStartLine: number;
contentEndLine: number;
blockEndLine: number;
markerLine?: number;
markerIndent?: string;
@ -114,6 +115,10 @@ export class GroupMarkerService {
throw new GroupMarkerError(`Cannot replace GI marker outside the heading block in ${write.filePath}.`);
}
if (write.contentEndLine < write.blockStartLine || write.contentEndLine > write.blockEndLine) {
throw new GroupMarkerError(`Cannot place GI marker outside the content range in ${write.filePath}.`);
}
if (seenBlocks.has(write.blockStartLine)) {
throw new GroupMarkerError(`Duplicate GI marker write detected for block ${write.blockStartLine} in ${write.filePath}.`);
}
@ -145,15 +150,27 @@ export class GroupMarkerService {
uniqueRemovals.add(write.markerLine - 1);
}
for (let lineIndex = write.contentEndLine; lineIndex < write.blockEndLine; lineIndex += 1) {
if (!(lines[lineIndex] ?? "").trim()) {
uniqueRemovals.add(lineIndex);
}
}
const removalIndexes = [...uniqueRemovals].sort((left, right) => right - left);
const removalsWithinBlock = [...uniqueRemovals].filter((lineIndex) => lineIndex <= write.blockEndLine - 1).length;
const insertionIndex = write.blockEndLine - removalsWithinBlock;
const insertionIndex = write.contentEndLine - [...uniqueRemovals].filter((lineIndex) => lineIndex < write.contentEndLine).length;
for (const lineIndex of removalIndexes) {
lines.splice(lineIndex, 1);
}
lines.splice(insertionIndex, 0, `${write.markerIndent ?? ""}${serializeGroupMarker(write.noteId, write.itemToSlot, write.freeSlots)}`);
while (insertionIndex + 1 < lines.length && !(lines[insertionIndex + 1] ?? "").trim()) {
lines.splice(insertionIndex + 1, 1);
}
if (insertionIndex + 1 < lines.length) {
lines.splice(insertionIndex + 1, 0, "", "");
}
}
}
@ -266,4 +283,4 @@ function collectLegacyIdMarkerLineIndexes(lines: string[], blockStartLine: numbe
function compareMarkerEntry(left: [string, number], right: [string, number]): number {
return left[1] - right[1] || left[0].localeCompare(right[0]);
}
}