mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
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:
parent
472011c32f
commit
c8b7915c40
8 changed files with 124 additions and 39 deletions
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, "", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue