fix: stabilize batch AHS writeback

This commit is contained in:
Dusk 2026-04-18 08:16:30 +08:00
parent 7c2c6776bd
commit 63aaf5f438
11 changed files with 563 additions and 58 deletions

16
docs/build-workflow.md Normal file
View file

@ -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.

View file

@ -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();
await context.watch();

View file

@ -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"
}
}
}

View file

@ -0,0 +1,2 @@
export const OBSIDIAN_PLUGIN_DIR =
"/Users/panxiaorong/Library/Mobile Documents/iCloud~md~obsidian/Documents/obsidian/.obsidian/plugins/Anki Heading Sync";

View file

@ -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",
);
);

View file

@ -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", "<!-- AHS:789 -->", "### 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",
"<!-- AHS:111 -->",
"",
"#### Bottom",
"Bottom answer",
"<!-- AHS:222 -->",
].join("\n"));
});
it("can replace an old marker and insert a new one in the same batch", () => {
const sourceContent = [
"#### First",
"Body 1",
"<!-- AHS:42 -->",
"",
"#### 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",
"<!-- AHS:9001 -->",
"",
"#### Second",
"Body 2",
"<!-- AHS:9002 -->",
].join("\n"));
});
});

View file

@ -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<number>();
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 {

View file

@ -31,6 +31,7 @@ class InMemorySyncRegistryRepository implements SyncRegistryRepository {
class FakeVaultGateway implements VaultGateway {
public readonly files = new Map<string, string>();
public failReplace = false;
public replaceCalls: Array<{ path: string; expectedContent: string; nextContent: string }> = [];
constructor(initialFiles: Record<string, string> = {}) {
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<void> {
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",
"<!-- AHS:9001 -->",
"",
"###### 试验233",
"卡片2",
"<!-- AHS:9002 -->",
].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", "<!-- AHS:42 -->", "", "###### 卡片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",
"<!-- AHS:9002 -->",
"",
"###### 卡片2",
"正文2",
"<!-- AHS:9001 -->",
].join("\n"));
});
it.each([
{
name: "heading change",

View file

@ -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<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>();
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<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
scanAndPlanResult: ScanAndPlanResult,
): Promise<void> {
): Promise<MarkerWriteRequest | undefined> {
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<ReturnType<AnkiGateway["getNoteSummaries"]>>[number] | undefined,
syncRegistry: SyncRegistry,
timestamp: number,
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
scanAndPlanResult: ScanAndPlanResult,
): Promise<void> {
): Promise<MarkerWriteRequest> {
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<void> {
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<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
@ -246,14 +276,50 @@ export class ExecuteSyncPlanUseCase {
});
}
private async writeMarker(card: Card, noteId: number): Promise<void> {
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<void> {
const writesByFile = new Map<string, MarkerWriteRequest[]>();
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 {

View file

@ -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", "<!-- AHS:42 -->"].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 }]);
});
});

View file

@ -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);
}