mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
fix: stabilize batch AHS writeback
This commit is contained in:
parent
7c2c6776bd
commit
63aaf5f438
11 changed files with 563 additions and 58 deletions
16
docs/build-workflow.md
Normal file
16
docs/build-workflow.md
Normal 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.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
scripts/obsidian-plugin-path.mjs
Normal file
2
scripts/obsidian-plugin-path.mjs
Normal 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";
|
||||
|
|
@ -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",
|
||||
);
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue