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 +}