From 63aaf5f43855c0724ad662a37509420e820a9649 Mon Sep 17 00:00:00 2001 From: Dusk Date: Sat, 18 Apr 2026 08:16:30 +0800 Subject: [PATCH] fix: stabilize batch AHS writeback --- docs/build-workflow.md | 16 ++ esbuild.config.mjs | 8 +- package.json | 6 +- scripts/obsidian-plugin-path.mjs | 2 + scripts/stage-plugin-dist.mjs | 26 ++- .../services/HeadingSyncMarkerService.test.ts | 125 +++++++++++- .../services/HeadingSyncMarkerService.ts | 76 ++++++- .../use-cases/ExecuteSyncPlanUseCase.test.ts | 191 ++++++++++++++++++ .../use-cases/ExecuteSyncPlanUseCase.ts | 130 +++++++++--- .../sync/services/SyncPlanningService.test.ts | 26 +++ .../sync/services/SyncPlanningService.ts | 15 +- 11 files changed, 563 insertions(+), 58 deletions(-) create mode 100644 docs/build-workflow.md create mode 100644 scripts/obsidian-plugin-path.mjs diff --git a/docs/build-workflow.md b/docs/build-workflow.md new file mode 100644 index 0000000..49cbf9f --- /dev/null +++ b/docs/build-workflow.md @@ -0,0 +1,16 @@ +# Build Workflow + +`npm run build` deploys the production plugin package directly into the local Obsidian plugin directory: + +- `/Users/panxiaorong/Library/Mobile Documents/iCloud~md~obsidian/Documents/obsidian/.obsidian/plugins/Anki Heading Sync` + +The build only overwrites plugin package files: + +- `main.js` +- `manifest.json` +- `versions.json` +- `README.md` + +The build does not delete or overwrite `data.json`. + +This path is intentionally hard-coded for this machine and is not a portable multi-machine build configuration. diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 8a72dc7..6f1fb6c 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,10 +1,14 @@ import esbuild from "esbuild"; import process from "node:process"; +import { join } from "node:path"; import builtins from "builtin-modules"; +import { OBSIDIAN_PLUGIN_DIR } from "./scripts/obsidian-plugin-path.mjs"; + const banner = `/*\nTHIS IS A GENERATED/BUNDLED FILE BY ESBUILD\n*/`; const production = process.argv[2] === "production"; +const outfile = production ? join(OBSIDIAN_PLUGIN_DIR, "main.js") : "dist/plugin/main.js"; const context = await esbuild.context({ banner: { js: banner }, @@ -29,7 +33,7 @@ const context = await esbuild.context({ format: "cjs", logLevel: "info", minify: production, - outfile: "dist/plugin/main.js", + outfile, sourcemap: production ? false : "inline", target: "es2020", treeShaking: true, @@ -40,4 +44,4 @@ if (production) { process.exit(0); } -await context.watch(); \ No newline at end of file +await context.watch(); diff --git a/package.json b/package.json index f290a8b..d321220 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "markdown-it": "^14.1.0" }, "scripts": { - "dev": "node scripts/stage-plugin-dist.mjs && node esbuild.config.mjs", - "build": "tsc --noEmit --skipLibCheck && node scripts/stage-plugin-dist.mjs && node esbuild.config.mjs production", + "dev": "node scripts/stage-plugin-dist.mjs dist && node esbuild.config.mjs", + "build": "tsc --noEmit --skipLibCheck && node scripts/stage-plugin-dist.mjs obsidian && node esbuild.config.mjs production", "lint": "eslint \"src/**/*.ts\" \"main.ts\" \"vitest.config.ts\" \"esbuild.config.mjs\" \"eslint.config.mjs\"", "test": "vitest run" }, @@ -32,4 +32,4 @@ "typescript-eslint": "^8.46.1", "vitest": "^3.2.4" } -} \ No newline at end of file +} diff --git a/scripts/obsidian-plugin-path.mjs b/scripts/obsidian-plugin-path.mjs new file mode 100644 index 0000000..c72925d --- /dev/null +++ b/scripts/obsidian-plugin-path.mjs @@ -0,0 +1,2 @@ +export const OBSIDIAN_PLUGIN_DIR = + "/Users/panxiaorong/Library/Mobile Documents/iCloud~md~obsidian/Documents/obsidian/.obsidian/plugins/Anki Heading Sync"; diff --git a/scripts/stage-plugin-dist.mjs b/scripts/stage-plugin-dist.mjs index 740112b..ffcaaab 100644 --- a/scripts/stage-plugin-dist.mjs +++ b/scripts/stage-plugin-dist.mjs @@ -1,26 +1,30 @@ -import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { OBSIDIAN_PLUGIN_DIR } from "./obsidian-plugin-path.mjs"; + const scriptDir = dirname(fileURLToPath(import.meta.url)); const rootDir = resolve(scriptDir, ".."); -const distDir = join(rootDir, "dist"); -const pluginDistDir = join(distDir, "plugin"); +const target = process.argv[2] === "obsidian" ? "obsidian" : "dist"; +const outputDir = target === "obsidian" ? OBSIDIAN_PLUGIN_DIR : join(rootDir, "dist", "plugin"); -await mkdir(pluginDistDir, { recursive: true }); -await rm(join(distDir, "main.js"), { force: true }); +await mkdir(outputDir, { recursive: true }); await Promise.all([ - copyFile(join(rootDir, "manifest.json"), join(pluginDistDir, "manifest.json")), - copyFile(join(rootDir, "versions.json"), join(pluginDistDir, "versions.json")), + copyFile(join(rootDir, "manifest.json"), join(outputDir, "manifest.json")), + copyFile(join(rootDir, "versions.json"), join(outputDir, "versions.json")), ]); await writeFile( - join(pluginDistDir, "README.md"), + join(outputDir, "README.md"), [ - "Anki Heading Sync — Obsidian plugin distribution package", + "Anki Heading Sync — Obsidian plugin package", "", - "Build output lives in this folder so it can be synced directly into an Obsidian plugin directory.", + target === "obsidian" + ? `npm run build deploys this plugin package directly to ${OBSIDIAN_PLUGIN_DIR}.` + : "Development output lives in this folder for local inspection.", + "This build flow only overwrites plugin package files and does not touch data.json.", "", "Included files:", "- manifest.json", @@ -28,4 +32,4 @@ await writeFile( "- versions.json", ].join("\n"), "utf8", -); \ No newline at end of file +); diff --git a/src/application/services/HeadingSyncMarkerService.test.ts b/src/application/services/HeadingSyncMarkerService.test.ts index 0a12ab8..2a6207b 100644 --- a/src/application/services/HeadingSyncMarkerService.test.ts +++ b/src/application/services/HeadingSyncMarkerService.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { HeadingSyncMarkerService } from "./HeadingSyncMarkerService"; +import { createCardKey } from "@/domain/card/value-objects/CardKey"; +import { createContentHash } from "@/domain/card/value-objects/ContentHash"; + +import { HeadingSyncMarkerService, type MarkerWriteRequest } from "./HeadingSyncMarkerService"; describe("HeadingSyncMarkerService", () => { const service = new HeadingSyncMarkerService(); @@ -62,4 +65,124 @@ describe("HeadingSyncMarkerService", () => { expect(nextContent).toBe(["#### Prompt", "Answer", "", "### Next"].join("\n")); }); + + it("applies multiple writes in one file from bottom to top", () => { + const sourceContent = [ + "#### Top", + "Top answer", + "", + "#### Bottom", + "Bottom answer", + ].join("\n"); + + const writes: MarkerWriteRequest[] = [ + { + cardKey: createCardKey("top"), + filePath: "notes/example.md", + noteId: 111, + location: { + filePath: "notes/example.md", + sourceContent, + headingLine: 1, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 3, + contentEndLine: 2, + headingLevel: 4, + headingText: "Top", + }, + mode: "insert", + sourceHash: createContentHash("hash-top"), + }, + { + cardKey: createCardKey("bottom"), + filePath: "notes/example.md", + noteId: 222, + location: { + filePath: "notes/example.md", + sourceContent, + headingLine: 4, + blockStartLine: 4, + bodyStartLine: 5, + blockEndLine: 5, + contentEndLine: 5, + headingLevel: 4, + headingText: "Bottom", + }, + mode: "insert", + sourceHash: createContentHash("hash-bottom"), + }, + ]; + + expect(service.applyBatch(sourceContent, writes)).toBe([ + "#### Top", + "Top answer", + "", + "", + "#### Bottom", + "Bottom answer", + "", + ].join("\n")); + }); + + it("can replace an old marker and insert a new one in the same batch", () => { + const sourceContent = [ + "#### First", + "Body 1", + "", + "", + "#### Second", + "Body 2", + ].join("\n"); + + const nextContent = service.applyBatch(sourceContent, [ + { + cardKey: createCardKey("first"), + filePath: "notes/example.md", + noteId: 9001, + location: { + filePath: "notes/example.md", + sourceContent, + headingLine: 1, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 3, + contentEndLine: 2, + markerLine: 3, + headingLevel: 4, + headingText: "First", + }, + mode: "replace", + sourceHash: createContentHash("hash-first"), + }, + { + cardKey: createCardKey("second"), + filePath: "notes/example.md", + noteId: 9002, + location: { + filePath: "notes/example.md", + sourceContent, + headingLine: 5, + blockStartLine: 5, + bodyStartLine: 6, + blockEndLine: 6, + contentEndLine: 6, + headingLevel: 4, + headingText: "Second", + }, + mode: "insert", + sourceHash: createContentHash("hash-second"), + }, + ]); + + expect(nextContent).toBe([ + "#### First", + "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 9d51632..9f72062 100644 --- a/src/application/services/HeadingSyncMarkerService.ts +++ b/src/application/services/HeadingSyncMarkerService.ts @@ -1,3 +1,5 @@ +import type { ContentHash } from "@/domain/card/value-objects/ContentHash"; +import type { CardKey } from "@/domain/card/value-objects/CardKey"; import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation"; export interface HeadingSyncMarker { @@ -6,15 +8,78 @@ export interface HeadingSyncMarker { lineIndex: number; } +export interface MarkerWriteRequest { + cardKey: CardKey; + filePath: string; + noteId: number; + location: SourceLocation; + mode: "insert" | "replace"; + sourceHash: ContentHash; +} + export class HeadingSyncMarkerService { apply(location: SourceLocation, noteId: number): string { if (!location.sourceContent) { throw new Error(`Missing scanned source content for ${location.filePath}.`); } - const lineEnding = location.sourceContent.includes("\r\n") ? "\r\n" : "\n"; - const lines = location.sourceContent.split(/\r?\n/); - const nextLines = [...lines]; + return this.applyBatch(location.sourceContent, [ + { + cardKey: "single-write" as CardKey, + filePath: location.filePath, + noteId, + location, + mode: location.markerLine ? "replace" : "insert", + sourceHash: "single-write" as ContentHash, + }, + ]); + } + + applyBatch(sourceContent: string, writes: MarkerWriteRequest[]): string { + if (writes.length === 0) { + return sourceContent; + } + + const filePath = writes[0]?.filePath; + const seenBlocks = new Set(); + + for (const write of writes) { + if (write.filePath !== filePath) { + throw new Error("Batch marker writes must belong to the same Markdown file."); + } + + if (write.location.filePath !== write.filePath) { + throw new Error(`Marker write path mismatch for ${write.filePath}.`); + } + + if (write.location.sourceContent !== sourceContent) { + throw new Error(`Marker writes for ${write.filePath} must share the scanned source content.`); + } + + if (write.mode === "replace" && !write.location.markerLine) { + throw new Error(`Cannot replace a missing AHS marker in ${write.filePath}.`); + } + + const blockKey = write.location.blockStartLine; + if (seenBlocks.has(blockKey)) { + throw new Error(`Duplicate marker write detected for block ${blockKey} in ${write.filePath}.`); + } + + seenBlocks.add(blockKey); + } + + const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n"; + const nextLines = sourceContent.split(/\r?\n/); + + const sortedWrites = [...writes].sort((left, right) => right.location.blockStartLine - left.location.blockStartLine); + for (const write of sortedWrites) { + this.applyToLines(nextLines, write.location, write.noteId); + } + + return nextLines.join(lineEnding); + } + + applyToLines(lines: string[], location: SourceLocation, noteId: number): void { const adjustedBlockEndLine = location.markerLine ? location.blockEndLine - 1 : location.blockEndLine; if (location.contentEndLine < location.headingLine || location.contentEndLine > adjustedBlockEndLine) { @@ -22,11 +87,10 @@ export class HeadingSyncMarkerService { } if (location.markerLine) { - nextLines.splice(location.markerLine - 1, 1); + lines.splice(location.markerLine - 1, 1); } - nextLines.splice(location.contentEndLine, 0, this.create(noteId).raw); - return nextLines.join(lineEnding); + lines.splice(location.contentEndLine, 0, this.create(noteId).raw); } create(noteId: number): HeadingSyncMarker { diff --git a/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts b/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts index 959f202..c4afd44 100644 --- a/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts +++ b/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts @@ -31,6 +31,7 @@ class InMemorySyncRegistryRepository implements SyncRegistryRepository { class FakeVaultGateway implements VaultGateway { public readonly files = new Map(); public failReplace = false; + public replaceCalls: Array<{ path: string; expectedContent: string; nextContent: string }> = []; constructor(initialFiles: Record = {}) { for (const [path, content] of Object.entries(initialFiles)) { @@ -60,6 +61,8 @@ class FakeVaultGateway implements VaultGateway { } async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise { + this.replaceCalls.push({ path, expectedContent, nextContent }); + if (this.failReplace) { throw new Error(`Markdown file changed before AHS write-back: ${path}`); } @@ -291,6 +294,194 @@ describe("ExecuteSyncPlanUseCase", () => { }); }); + it("writes multiple newly created cards in the same file with a single replace call", async () => { + const sourceContent = ["###### 试验的卡片12", "卡片1", "", "###### 试验233", "卡片2"].join("\n"); + const vaultGateway = new FakeVaultGateway({ + "notes/current.md": sourceContent, + }); + const ankiGateway = new FakeAnkiGateway(); + const repository = new InMemorySyncRegistryRepository(); + const firstCard = createCard({ + key: createCardKey("card-1"), + heading: "试验的卡片12", + bodyMarkdown: "卡片1", + renderedFields: { title: "试验的卡片12", body: "卡片1" }, + fields: { title: "试验的卡片12", body: "卡片1" }, + contentHash: createContentHash("hash-card-1"), + source: { + filePath: "notes/current.md", + sourceContent, + headingLine: 1, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 3, + contentEndLine: 2, + headingLevel: 6, + headingText: "试验的卡片12", + }, + }); + const secondCard = createCard({ + key: createCardKey("card-2"), + heading: "试验233", + bodyMarkdown: "卡片2", + renderedFields: { title: "试验233", body: "卡片2" }, + fields: { title: "试验233", body: "卡片2" }, + contentHash: createContentHash("hash-card-2"), + source: { + filePath: "notes/current.md", + sourceContent, + headingLine: 4, + blockStartLine: 4, + bodyStartLine: 5, + blockEndLine: 5, + contentEndLine: 5, + headingLevel: 6, + headingText: "试验233", + }, + }); + const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234); + + await useCase.execute(createResult({ + cards: [firstCard, secondCard], + plan: { + toCreateDecks: [createDeckName("Deck")], + toAdd: [firstCard, secondCard], + toUpdate: [], + toMarkOrphan: [], + }, + })); + + expect(ankiGateway.addedNotes).toHaveLength(2); + expect(vaultGateway.replaceCalls).toHaveLength(1); + expect(vaultGateway.files.get("notes/current.md")).toBe([ + "###### 试验的卡片12", + "卡片1", + "", + "", + "###### 试验233", + "卡片2", + "", + ].join("\n")); + expect(repository.savedRegistry?.findByNoteId(9001)).toMatchObject({ identityMode: "embedded-note-id" }); + expect(repository.savedRegistry?.findByNoteId(9002)).toMatchObject({ identityMode: "embedded-note-id" }); + expect(repository.savedRegistry?.list().some((record) => record.identityMode === "pending-note-id-write")).toBe(false); + }); + + it("marks all same-file writes pending when the batch replace fails", async () => { + const sourceContent = ["###### 卡片1", "正文1", "", "###### 卡片2", "正文2"].join("\n"); + const vaultGateway = new FakeVaultGateway({ "notes/current.md": sourceContent }); + vaultGateway.failReplace = true; + const ankiGateway = new FakeAnkiGateway(); + const repository = new InMemorySyncRegistryRepository(); + const firstCard = createCard({ + key: createCardKey("batch-fail-1"), + contentHash: createContentHash("hash-batch-fail-1"), + source: { + filePath: "notes/current.md", + sourceContent, + headingLine: 1, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 3, + contentEndLine: 2, + headingLevel: 6, + headingText: "卡片1", + }, + }); + const secondCard = createCard({ + key: createCardKey("batch-fail-2"), + contentHash: createContentHash("hash-batch-fail-2"), + source: { + filePath: "notes/current.md", + sourceContent, + headingLine: 4, + blockStartLine: 4, + bodyStartLine: 5, + blockEndLine: 5, + contentEndLine: 5, + headingLevel: 6, + headingText: "卡片2", + }, + }); + const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234); + + await useCase.execute(createResult({ + cards: [firstCard, secondCard], + plan: { + toCreateDecks: [createDeckName("Deck")], + toAdd: [firstCard, secondCard], + toUpdate: [], + toMarkOrphan: [], + }, + })); + + expect(ankiGateway.addedNotes).toHaveLength(2); + expect(vaultGateway.files.get("notes/current.md")).toBe(sourceContent); + expect(repository.savedRegistry?.get(firstCard.key)).toMatchObject({ identityMode: "pending-note-id-write", noteId: 9001 }); + expect(repository.savedRegistry?.get(secondCard.key)).toMatchObject({ identityMode: "pending-note-id-write", noteId: 9002 }); + }); + + it("flushes recreate-and-insert writes for the same file together", async () => { + const sourceContent = ["###### 卡片1", "正文1", "", "", "###### 卡片2", "正文2"].join("\n"); + const vaultGateway = new FakeVaultGateway({ "notes/current.md": sourceContent }); + const ankiGateway = new FakeAnkiGateway(); + const repository = new InMemorySyncRegistryRepository(); + const recreatedCard = createCard({ + key: createCardKey("recreate-card"), + embeddedNoteId: 42, + contentHash: createContentHash("hash-recreate"), + source: { + filePath: "notes/current.md", + sourceContent, + headingLine: 1, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 3, + contentEndLine: 2, + markerLine: 3, + headingLevel: 6, + headingText: "卡片1", + }, + }); + const newCard = createCard({ + key: createCardKey("new-card"), + contentHash: createContentHash("hash-new-card"), + source: { + filePath: "notes/current.md", + sourceContent, + headingLine: 5, + blockStartLine: 5, + bodyStartLine: 6, + blockEndLine: 6, + contentEndLine: 6, + headingLevel: 6, + headingText: "卡片2", + }, + }); + const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234); + + await useCase.execute(createResult({ + cards: [recreatedCard, newCard], + plan: { + toCreateDecks: [createDeckName("Deck")], + toAdd: [newCard], + toUpdate: [{ card: recreatedCard, noteId: 42 }], + toMarkOrphan: [], + }, + })); + + expect(vaultGateway.replaceCalls).toHaveLength(1); + expect(vaultGateway.files.get("notes/current.md")).toBe([ + "###### 卡片1", + "正文1", + "", + "", + "###### 卡片2", + "正文2", + "", + ].join("\n")); + }); + it.each([ { name: "heading change", diff --git a/src/application/use-cases/ExecuteSyncPlanUseCase.ts b/src/application/use-cases/ExecuteSyncPlanUseCase.ts index 383804a..fba4166 100644 --- a/src/application/use-cases/ExecuteSyncPlanUseCase.ts +++ b/src/application/use-cases/ExecuteSyncPlanUseCase.ts @@ -1,4 +1,4 @@ -import { HeadingSyncMarkerService } from "@/application/services/HeadingSyncMarkerService"; +import { HeadingSyncMarkerService, type MarkerWriteRequest } from "@/application/services/HeadingSyncMarkerService"; import type { AnkiGateway } from "@/application/ports/AnkiGateway"; import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository"; import type { VaultGateway } from "@/application/ports/VaultGateway"; @@ -24,6 +24,7 @@ export class ExecuteSyncPlanUseCase { const modelDetailsCache = new Map>>(); const noteSummaries = await this.loadNoteSummaries(scanAndPlanResult.plan.toUpdate.map((entry) => entry.noteId)); const timestamp = this.now(); + const markerWrites: MarkerWriteRequest[] = []; for (const card of scanAndPlanResult.cards) { const modelDetails = await this.getModelDetails(modelDetailsCache, card.noteModel); @@ -43,7 +44,7 @@ export class ExecuteSyncPlanUseCase { for (const card of scanAndPlanResult.plan.toAdd) { const noteId = await this.addNote(card, modelDetailsCache, scanAndPlanResult); - await this.writeBackNewMarker(card, noteId, syncRegistry, timestamp); + markerWrites.push(this.createMarkerWriteRequest(card, noteId, "insert")); } for (const entry of scanAndPlanResult.plan.toUpdate) { @@ -52,18 +53,39 @@ export class ExecuteSyncPlanUseCase { : syncRegistry.get(entry.card.key); if (entry.card.embeddedNoteId) { - await this.updateEmbeddedCard(entry.card, entry.noteId, noteSummaries.get(entry.noteId), syncRegistry, timestamp, modelDetailsCache, scanAndPlanResult); + const markerWrite = await this.updateEmbeddedCard( + entry.card, + entry.noteId, + noteSummaries.get(entry.noteId), + syncRegistry, + timestamp, + modelDetailsCache, + scanAndPlanResult, + ); + if (markerWrite) { + markerWrites.push(markerWrite); + } continue; } if (existingRecord?.identityMode === "pending-note-id-write") { - await this.retryPendingMarkerWrite(entry.card, existingRecord, noteSummaries.get(entry.noteId), syncRegistry, timestamp, modelDetailsCache, scanAndPlanResult); + markerWrites.push( + await this.retryPendingMarkerWrite( + entry.card, + existingRecord, + noteSummaries.get(entry.noteId), + modelDetailsCache, + scanAndPlanResult, + ), + ); continue; } await this.updateLegacyCard(entry.card, entry.noteId, existingRecord, syncRegistry, timestamp, modelDetailsCache, scanAndPlanResult); } + await this.flushMarkerWrites(markerWrites, syncRegistry, timestamp); + const mutatedCardKeys = new Set(syncCards.map((card) => card.key)); for (const card of scanAndPlanResult.cards) { if (mutatedCardKeys.has(card.key)) { @@ -123,28 +145,25 @@ export class ExecuteSyncPlanUseCase { timestamp: number, modelDetailsCache: Map>>, scanAndPlanResult: ScanAndPlanResult, - ): Promise { + ): Promise { if (!noteSummary) { const recreatedNoteId = await this.addNote(card, modelDetailsCache, scanAndPlanResult); - await this.writeMarker(card, recreatedNoteId); - syncRegistry.recordSync(this.createEmbeddedRecord(card, recreatedNoteId, timestamp)); - return; + return this.createMarkerWriteRequest(card, recreatedNoteId, "replace"); } this.assertModelMatches(card, noteSummary.modelName, noteId); await this.updateExistingNote(card, noteId, modelDetailsCache, scanAndPlanResult); syncRegistry.recordSync(this.createEmbeddedRecord(card, noteId, timestamp)); + return undefined; } private async retryPendingMarkerWrite( card: Card, existingRecord: SyncRecord, noteSummary: Awaited>[number] | undefined, - syncRegistry: SyncRegistry, - timestamp: number, modelDetailsCache: Map>>, scanAndPlanResult: ScanAndPlanResult, - ): Promise { + ): Promise { let noteId = existingRecord.noteId; if (!noteSummary) { @@ -153,12 +172,7 @@ export class ExecuteSyncPlanUseCase { await this.updateExistingNote(card, noteId, modelDetailsCache, scanAndPlanResult); } - try { - await this.writeMarker(card, noteId); - syncRegistry.recordSync(this.createEmbeddedRecord(card, noteId, timestamp)); - } catch { - syncRegistry.recordSync(this.createPendingRecord(card, noteId, timestamp)); - } + return this.createMarkerWriteRequest(card, noteId, card.source.markerLine ? "replace" : "insert"); } private async updateLegacyCard( @@ -183,15 +197,6 @@ export class ExecuteSyncPlanUseCase { }); } - private async writeBackNewMarker(card: Card, noteId: number, syncRegistry: SyncRegistry, timestamp: number): Promise { - try { - await this.writeMarker(card, noteId); - syncRegistry.recordSync(this.createEmbeddedRecord(card, noteId, timestamp)); - } catch { - syncRegistry.recordSync(this.createPendingRecord(card, noteId, timestamp)); - } - } - private createEmbeddedRecord(card: Card, noteId: number, timestamp: number): SyncRecord { return { cardKey: card.key, @@ -204,6 +209,18 @@ export class ExecuteSyncPlanUseCase { }; } + private createEmbeddedRecordFromWrite(write: MarkerWriteRequest, timestamp: number): SyncRecord { + return { + cardKey: write.cardKey, + identityMode: "embedded-note-id", + noteId: write.noteId, + filePath: write.filePath, + sourceHash: write.sourceHash, + lastSyncedAt: timestamp, + orphan: false, + }; + } + private createPendingRecord(card: Card, noteId: number, timestamp: number): SyncRecord { return { cardKey: card.key, @@ -217,6 +234,19 @@ export class ExecuteSyncPlanUseCase { }; } + private createPendingRecordFromWrite(write: MarkerWriteRequest, timestamp: number): SyncRecord { + return { + cardKey: write.cardKey, + identityMode: "pending-note-id-write", + legacyCardKey: write.cardKey, + noteId: write.noteId, + filePath: write.filePath, + sourceHash: write.sourceHash, + lastSyncedAt: timestamp, + orphan: false, + }; + } + private async addNote( card: Card, modelDetailsCache: Map>>, @@ -246,14 +276,50 @@ export class ExecuteSyncPlanUseCase { }); } - private async writeMarker(card: Card, noteId: number): Promise { - const expectedContent = card.source.sourceContent; - if (!expectedContent) { - throw new Error(`Missing scanned source content for ${card.source.filePath}.`); + private createMarkerWriteRequest(card: Card, noteId: number, mode: MarkerWriteRequest["mode"]): MarkerWriteRequest { + return { + cardKey: card.key, + filePath: card.source.filePath, + noteId, + location: card.source, + mode, + sourceHash: card.contentHash, + }; + } + + private async flushMarkerWrites(writes: MarkerWriteRequest[], syncRegistry: SyncRegistry, timestamp: number): Promise { + const writesByFile = new Map(); + + for (const write of writes) { + const fileWrites = writesByFile.get(write.filePath); + if (fileWrites) { + fileWrites.push(write); + continue; + } + + writesByFile.set(write.filePath, [write]); } - const nextContent = this.headingSyncMarkerService.apply(card.source, noteId); - await this.vaultGateway.replaceMarkdownFile(card.source.filePath, expectedContent, nextContent); + for (const [filePath, fileWrites] of writesByFile.entries()) { + const sourceContent = fileWrites[0]?.location.sourceContent; + if (!sourceContent) { + throw new Error(`Missing scanned source content for ${filePath}.`); + } + + const nextContent = this.headingSyncMarkerService.applyBatch(sourceContent, fileWrites); + + try { + await this.vaultGateway.replaceMarkdownFile(filePath, sourceContent, nextContent); + + for (const write of fileWrites) { + syncRegistry.recordSync(this.createEmbeddedRecordFromWrite(write, timestamp)); + } + } catch { + for (const write of fileWrites) { + syncRegistry.recordSync(this.createPendingRecordFromWrite(write, timestamp)); + } + } + } } private assertModelMatches(card: Card, actualModelName: string, noteId: number): void { diff --git a/src/domain/sync/services/SyncPlanningService.test.ts b/src/domain/sync/services/SyncPlanningService.test.ts index d4818cb..c5f8e74 100644 --- a/src/domain/sync/services/SyncPlanningService.test.ts +++ b/src/domain/sync/services/SyncPlanningService.test.ts @@ -166,4 +166,30 @@ describe("SyncPlanningService", () => { expect(plan.toUpdate).toEqual([{ card, noteId: 100 }]); }); + + it("prefers pending note-id-write over a stale embedded marker", () => { + const service = new SyncPlanningService(); + const card = createCard({ + embeddedNoteId: 42, + source: { + ...createCard().source, + sourceContent: ["#### Prompt", "Answer", ""].join("\n"), + blockEndLine: 3, + contentEndLine: 2, + markerLine: 3, + }, + }); + const registry = new SyncRegistry([ + createRecord({ + cardKey: card.key, + identityMode: "pending-note-id-write", + noteId: 9001, + legacyCardKey: card.key, + }), + ]); + + const plan = service.plan([card], registry, ["notes/example.md"]); + + expect(plan.toUpdate).toEqual([{ card, noteId: 9001 }]); + }); }); \ No newline at end of file diff --git a/src/domain/sync/services/SyncPlanningService.ts b/src/domain/sync/services/SyncPlanningService.ts index c7d0393..3512c29 100644 --- a/src/domain/sync/services/SyncPlanningService.ts +++ b/src/domain/sync/services/SyncPlanningService.ts @@ -23,6 +23,17 @@ export class SyncPlanningService { } seenEmbeddedNoteIds.add(card.embeddedNoteId); + } + + const existingRecord = registry.get(card.key); + + if (existingRecord?.identityMode === "pending-note-id-write") { + toUpdate.push({ card, noteId: existingRecord.noteId }); + createDecks.set(card.deck, card.deck); + continue; + } + + if (card.embeddedNoteId) { const existingEmbeddedRecord = registry.findByNoteId(card.embeddedNoteId); if (!existingEmbeddedRecord) { @@ -39,15 +50,13 @@ export class SyncPlanningService { continue; } - const existingRecord = registry.get(card.key); - if (!existingRecord) { toAdd.push(card); createDecks.set(card.deck, card.deck); continue; } - if (existingRecord.identityMode === "pending-note-id-write" || existingRecord.sourceHash !== card.contentHash || existingRecord.orphan) { + if (existingRecord.sourceHash !== card.contentHash || existingRecord.orphan) { toUpdate.push({ card, noteId: existingRecord.noteId }); createDecks.set(card.deck, card.deck); }