From c8b7915c400346e2d54e402d267704312659ea7d Mon Sep 17 00:00:00 2001 From: Dusk Date: Mon, 20 Apr 2026 22:43:01 +0800 Subject: [PATCH] fix(markers): normalize spacing before and after synced ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文: 统一普通 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. --- .../services/HeadingSyncMarkerService.test.ts | 44 ++++++++++--------- .../services/HeadingSyncMarkerService.ts | 27 ++++++++++-- src/application/services/ManualSyncService.ts | 1 + .../services/QaGroupSyncService.ts | 1 + .../services/CardMarkerService.test.ts | 13 +++--- .../manual-sync/services/CardMarkerService.ts | 31 +++++++++++-- .../services/GroupMarkerService.test.ts | 23 ++++++++-- .../services/GroupMarkerService.ts | 23 ++++++++-- 8 files changed, 124 insertions(+), 39 deletions(-) diff --git a/src/application/services/HeadingSyncMarkerService.test.ts b/src/application/services/HeadingSyncMarkerService.test.ts index 2a6207b..3a995fe 100644 --- a/src/application/services/HeadingSyncMarkerService.test.ts +++ b/src/application/services/HeadingSyncMarkerService.test.ts @@ -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", "", "", "### Next"].join("\n")); + expect(nextContent).toBe(["#### Prompt", "Answer", "", "", "", "### 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", "", "### Next"].join("\n")); + expect(nextContent).toBe(["#### Prompt", "", "", "", "### 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", "", "### Next"].join("\n"), + sourceContent: ["#### Prompt", "Answer", "", "", "", "### 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", "", "### Next"].join("\n")); + expect(nextContent).toBe(["#### Prompt", "Answer", "", "", "", "### 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", "", "", + "", "#### Bottom", "Bottom answer", "", @@ -131,6 +133,7 @@ describe("HeadingSyncMarkerService", () => { "Body 1", "", "", + "", "#### 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", "", "", + "", "#### Second", "Body 2", "", ].join("\n")); }); -}); \ No newline at end of file +}); diff --git a/src/application/services/HeadingSyncMarkerService.ts b/src/application/services/HeadingSyncMarkerService.ts index 4bceef4..b85980b 100644 --- a/src/application/services/HeadingSyncMarkerService.ts +++ b/src/application/services/HeadingSyncMarkerService.ts @@ -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(); 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, }; } -} \ No newline at end of file +} diff --git a/src/application/services/ManualSyncService.ts b/src/application/services/ManualSyncService.ts index a217b8d..2a4a1c2 100644 --- a/src/application/services/ManualSyncService.ts +++ b/src/application/services/ManualSyncService.ts @@ -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, diff --git a/src/application/services/QaGroupSyncService.ts b/src/application/services/QaGroupSyncService.ts index f785b9d..f36df06 100644 --- a/src/application/services/QaGroupSyncService.ts +++ b/src/application/services/QaGroupSyncService.ts @@ -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, diff --git a/src/domain/manual-sync/services/CardMarkerService.test.ts b/src/domain/manual-sync/services/CardMarkerService.test.ts index a8fae88..309de7f 100644 --- a/src/domain/manual-sync/services/CardMarkerService.test.ts +++ b/src/domain/manual-sync/services/CardMarkerService.test.ts @@ -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", "", "", + "", "#### Bottom", "Body 2", "", ].join("\n")); }); -}); \ No newline at end of file +}); diff --git a/src/domain/manual-sync/services/CardMarkerService.ts b/src/domain/manual-sync/services/CardMarkerService.ts index b95fc84..1834269 100644 --- a/src/domain/manual-sync/services/CardMarkerService.ts +++ b/src/domain/manual-sync/services/CardMarkerService.ts @@ -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(); 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, "", ""); + } } -} \ No newline at end of file +} diff --git a/src/domain/manual-sync/services/GroupMarkerService.test.ts b/src/domain/manual-sync/services/GroupMarkerService.test.ts index a02d8d7..0e6ebb8 100644 --- a/src/domain/manual-sync/services/GroupMarkerService.test.ts +++ b/src/domain/manual-sync/services/GroupMarkerService.test.ts @@ -12,15 +12,20 @@ describe("GroupMarkerService", () => { " ", "- Beta", " - Second answer", + "", + "", "", + "", + "#### 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(""); - expect(nextContent.split("\n").at(-1)).toBe(""); + expect(nextContent).toBe([ + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + "- Beta", + " - Second answer", + "", + "", + "", + "#### Next heading", + ].join("\n")); }); -}); \ No newline at end of file +}); diff --git a/src/domain/manual-sync/services/GroupMarkerService.ts b/src/domain/manual-sync/services/GroupMarkerService.ts index a3e2f8f..6e494e5 100644 --- a/src/domain/manual-sync/services/GroupMarkerService.ts +++ b/src/domain/manual-sync/services/GroupMarkerService.ts @@ -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]); -} \ No newline at end of file +}