feat: Module2 — AHS 写回与相关测试

This commit is contained in:
Dusk 2026-04-18 00:08:46 +08:00
parent 5099b39d3f
commit 7c2c6776bd
32 changed files with 1461 additions and 106 deletions

149
docs/2026-04-17PLAN3.md Normal file
View file

@ -0,0 +1,149 @@
# 模块 2将 Anki `noteId` 回写到标题块末尾,使用短标记 `<!-- AHS:123 -->`
## Summary
- 新增卡片同步成功后,把返回的 `Anki noteId` 写回对应标题块的块尾,格式固定为 `<!-- AHS:<noteId> -->`
- 以后该标题块再次同步时,优先按这个 `noteId` 更新原卡,不再依赖 `标题 + 行号 + 路径`
- 标记放在块尾而不是标题下方,用来明确卡片边界。
- 老卡不迁移,只对以后新增的卡生效;老卡继续走当前 `cardKey` 逻辑。
- 如果块尾 `noteId` 在 Anki 中不存在,自动重建新卡并替换为新的 `noteId`
- 如果同一标题块从 `basic` 切到 `cloze` 或反过来,直接报错并要求用户重建。
## Key Changes
### 1. 标记语法与块尾规则
- 唯一支持的标记格式:
- `<!-- AHS:<正整数> -->`
- 标记位置固定:
- 当前标题块最后一个非空内容行之后
- 下一个同级或更高层级标题之前
- 写回规则固定:
- 有正文时:写在正文末尾
- 无正文时:写在标题下一行
- 标记本身视为块尾终止标记
- 读取规则固定:
- 只识别当前标题块块尾的最后一个合法 `AHS` 标记
- 标记不进入 `bodyMarkdown`
- 同一标题块多个合法 `AHS` 标记时报错
- 非法 `AHS` 格式时报错,不静默忽略
### 2. 提取与渲染
- `CardDraft` 增加 `embeddedNoteId?: number`
- `SourceLocation` 增加块尾定位信息,至少包括:
- `blockEndLine`
- `contentEndLine`
- `markerLine?: number`
- `CardExtractionService` 负责:
- 识别块尾 `<!-- AHS:... -->`
- 从正文中剔除该标记
- 将 `embeddedNoteId` 放入 `CardDraft`
- `CardRenderingService` 不处理身份标记,继续只处理标题和正文渲染
### 3. 同步身份与本地状态
- 同步身份模式固定为三种:
- `embedded-note-id`
- `legacy-card-key`
- `pending-note-id-write`
- `SyncRecord` 增加:
- `identityMode: "embedded-note-id" | "legacy-card-key" | "pending-note-id-write"`
- `legacyCardKey?: string`
- `noteId: number`
- `filePath`
- `sourceHash`
- `lastSyncedAt`
- `orphan`
- `SyncRegistry` 必须支持:
- `findByNoteId(noteId)`
- `get(cardKey)` 保留兼容
- 新卡首次成功写回 `AHS` 标记后,记录保存为 `embedded-note-id`
- 老卡无标记时,继续保存为 `legacy-card-key`
- 建卡成功但标记写回失败时,保存为 `pending-note-id-write` 并保留 `legacyCardKey`
### 4. 规划逻辑
- 匹配优先级固定:
1. 有 `embeddedNoteId` 时,按 `noteId`
2. 无 `embeddedNoteId` 时,按当前 `legacy cardKey`
- 对 `embeddedNoteId` 卡片:
- registry 有同 `noteId` 记录:按 `sourceHash` 判断是否更新
- registry 无记录:仍视为已有卡,可直接更新
- orphan 判定拆成两套:
- `embedded-note-id` 按本轮看到的 `noteId`
- `legacy-card-key` 按本轮看到的 `cardKey`
- 老卡不自动补写 `AHS`,只保留当前行为
### 5. 执行逻辑
- `AnkiGateway` 增加:
- `getNoteSummaries(noteIds: number[]): Promise<Array<{ noteId: number; modelName: string }>>`
- 执行前先校验所有 `embeddedNoteId`
- note 存在且模型一致:`updateNote`
- note 不存在:自动重建
- note 存在但模型不一致:报错并停止该卡同步
- 自动重建流程固定:
1. `addNote`
2. 用新的 `noteId` 替换块尾旧 `AHS` 标记
3. registry 更新为新的 `embedded-note-id`
- 新卡首次同步流程固定:
1. 无 `AHS` 标记,先 `addNote`
2. 把返回的 `noteId` 写到块尾
3. registry 保存为 `embedded-note-id`
### 6. Markdown 写回
- `VaultGateway` 增加:
- `replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void>`
- 写回采用 optimistic concurrency
- 文件内容仍等于扫描时内容才允许写回
- 块尾写回算法固定:
- 定位当前标题块结束边界
- 移除旧 `AHS` 标记(若存在)
- 将新标记写到块内最后一个非空内容行之后
- 不允许落到下一个标题之后
- 若 `addNote` 成功但写回失败:
- 不删除新建 note
- registry 记为 `pending-note-id-write`
- 以后只要还能按 legacy key 命中,就继续尝试补写 `AHS`
- 补写成功后,记录转为 `embedded-note-id`
## Public Interfaces / Types
- `CardDraft.embeddedNoteId?: number`
- `SourceLocation.contentEndLine: number`
- `SourceLocation.markerLine?: number`
- `SyncRecord.identityMode`
- `SyncRecord.legacyCardKey?: string`
- `VaultGateway.replaceMarkdownFile(...)`
- `AnkiGateway.getNoteSummaries(...)`
- `HeadingSyncMarker`
- `noteId: number`
- `raw: string`
- `lineIndex: number`
## Test Plan
- 块尾 `<!-- AHS:123 -->` 能被正确识别
- `AHS` 标记不会进入 `bodyMarkdown`
- 同一标题块多个 `AHS` 标记时报错
- 非法 `AHS` 格式时报错
- 新卡创建后,`AHS` 被写到块尾
- 空正文标题块时,`AHS` 写在标题下一行
- 带 `AHS` 的卡在改标题、改正文、块内移动、文件移动后仍更新原 note
- registry 丢失时,带 `AHS` 的卡仍能按 `noteId` 更新
- `noteId` 在 Anki 不存在时会自动重建,并替换块尾 `AHS`
- `basic/cloze` 类型切换时报错
- 老卡无 `AHS` 时继续走 legacy `cardKey`
- `addNote` 成功但写回失败时,生成 `pending-note-id-write`
- 下次命中后可补写 `AHS` 并转正
- orphan 检测对 `embedded-note-id``legacy-card-key` 都正确
## Assumptions
- 第一版只支持块尾短标记 `<!-- AHS:<noteId> -->`
- 不兼容旧的 `ID:` 行、长注释格式、frontmatter、block ID
- 老卡不迁移;只有以后新增的卡获得稳定 `noteId` 身份
- `basic``cloze` 的跨类型切换第一版不自动处理
- 允许插件改写 Markdown 文件,在标题块末尾插入或替换 `AHS` 标记

View file

@ -0,0 +1,61 @@
# Module 2 Gap Report
## Current State
- Card extraction only derives legacy card identity from heading structure and source position.
- `CardDraft` has no `embeddedNoteId` field.
- `SourceLocation` has no block-end marker positioning data beyond `blockEndLine`.
- `SyncRecord` only stores legacy `cardKey -> noteId` mappings.
- `SyncPlanningService` only matches cards by legacy `cardKey`.
- `ExecuteSyncPlanUseCase` only supports add/update driven by plan entries and never writes back into Markdown.
- `VaultGateway` cannot replace Markdown content with optimistic concurrency.
- `AnkiGateway` cannot batch-check note existence/model via `noteId`.
## Already Correct
- Card rendering is separate from extraction and can stay identity-agnostic.
- Sync registry persistence is already centralized in the shared plugin data snapshot.
- Execution already validates field mappings before side effects.
- Source scanning and sync execution are already split into `scan/plan` and `execute` phases.
## Missing For Module 2
### Extraction
- Parse block-end `<!-- AHS:<noteId> -->` markers.
- Reject malformed or duplicated markers in the same heading block.
- Exclude marker lines from `bodyMarkdown`.
- Populate `embeddedNoteId` and marker positioning metadata.
### Identity And Registry
- Extend `SyncRecord` with `identityMode` and optional `legacyCardKey`.
- Keep legacy `cardKey` lookup while adding embedded `noteId` lookup.
- Preserve pending write-back state when Anki note creation succeeds but Markdown write-back fails.
### Planning
- Prefer embedded `noteId` over legacy `cardKey` when available.
- Allow embedded-note-id cards to update even if the local registry entry is missing.
- Detect orphans independently for embedded-note-id and legacy-card-key records.
### Execution
- Validate embedded `noteId` existence/model before update.
- Recreate missing embedded-note-id notes and replace the block-end marker.
- Write back a new marker for newly created notes.
- Reject basic/cloze type switches for embedded-note-id cards.
- Retry pending marker write-back on later legacy hits.
### Infrastructure
- Add `VaultGateway.replaceMarkdownFile(path, expectedContent, nextContent)`.
- Implement block-end marker replacement in the Obsidian gateway.
- Add `AnkiGateway.getNoteSummaries(noteIds)` and AnkiConnect implementation.
### Tests Missing
- Extraction tests for valid, duplicate, and malformed AHS markers.
- Planning tests for embedded `noteId` precedence and mixed orphan detection.
- Execution tests for write-back success/failure, recreation, and type-switch rejection.
- Gateway tests for `getNoteSummaries` and optimistic Markdown replacement.

View file

@ -14,10 +14,16 @@ export interface UpdateAnkiNoteInput {
fields: Record<string, string>;
}
export interface AnkiNoteSummary {
noteId: number;
modelName: string;
}
export interface AnkiGateway {
ensureDeckExists(deckName: string): Promise<void>;
listNoteModels(): Promise<string[]>;
getModelDetails(modelName: string): Promise<NoteModelDetails>;
getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]>;
addNote(input: AddAnkiNoteInput): Promise<number>;
updateNote(input: UpdateAnkiNoteInput): Promise<void>;
storeMedia(asset: MediaAsset): Promise<void>;

View file

@ -4,4 +4,5 @@ import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceR
export interface VaultGateway extends RenderResourceResolver {
listMarkdownFiles(): Promise<SourceFile[]>;
getMarkdownFile(path: string): Promise<SourceFile | null>;
replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void>;
}

View file

@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { HeadingSyncMarkerService } from "./HeadingSyncMarkerService";
describe("HeadingSyncMarkerService", () => {
const service = new HeadingSyncMarkerService();
it("writes a new marker at the end of the heading block", () => {
const nextContent = service.apply(
{
filePath: "notes/example.md",
sourceContent: ["#### Prompt", "Answer", "", "### Next"].join("\n"),
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
contentEndLine: 2,
headingLevel: 4,
headingText: "Prompt",
},
123,
);
expect(nextContent).toBe(["#### Prompt", "Answer", "<!-- AHS:123 -->", "", "### Next"].join("\n"));
});
it("writes the marker right after the heading when the block body is empty", () => {
const nextContent = service.apply(
{
filePath: "notes/example.md",
sourceContent: ["#### Prompt", "### Next"].join("\n"),
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 1,
contentEndLine: 1,
headingLevel: 4,
headingText: "Prompt",
},
456,
);
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"),
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
contentEndLine: 2,
markerLine: 3,
headingLevel: 4,
headingText: "Prompt",
},
789,
);
expect(nextContent).toBe(["#### Prompt", "Answer", "<!-- AHS:789 -->", "### Next"].join("\n"));
});
});

View file

@ -0,0 +1,39 @@
import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation";
export interface HeadingSyncMarker {
noteId: number;
raw: string;
lineIndex: number;
}
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];
const adjustedBlockEndLine = location.markerLine ? location.blockEndLine - 1 : location.blockEndLine;
if (location.contentEndLine < location.headingLine || location.contentEndLine > adjustedBlockEndLine) {
throw new Error(`Cannot write AHS marker outside the heading block in ${location.filePath}.`);
}
if (location.markerLine) {
nextLines.splice(location.markerLine - 1, 1);
}
nextLines.splice(location.contentEndLine, 0, this.create(noteId).raw);
return nextLines.join(lineEnding);
}
create(noteId: number): HeadingSyncMarker {
return {
noteId,
raw: `<!-- AHS:${noteId} -->`,
lineIndex: -1,
};
}
}

View file

@ -18,6 +18,7 @@ function createCard(overrides: Partial<Card>): Card {
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
contentEndLine: 2,
headingLevel: 4,
headingText: "Prompt",
},

View file

@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
import type { AnkiGateway } from "@/application/ports/AnkiGateway";
import type { AnkiGateway, AnkiNoteSummary } from "@/application/ports/AnkiGateway";
import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository";
import type { VaultGateway } from "@/application/ports/VaultGateway";
import type { Card } from "@/domain/card/entities/Card";
import { createCardKey } from "@/domain/card/value-objects/CardKey";
import { createContentHash } from "@/domain/card/value-objects/ContentHash";
@ -27,15 +28,74 @@ class InMemorySyncRegistryRepository implements SyncRegistryRepository {
}
}
class FakeVaultGateway implements VaultGateway {
public readonly files = new Map<string, string>();
public failReplace = false;
constructor(initialFiles: Record<string, string> = {}) {
for (const [path, content] of Object.entries(initialFiles)) {
this.files.set(path, content);
}
}
async listMarkdownFiles() {
return Array.from(this.files.entries()).map(([path, content]) => ({
path,
basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
content,
}));
}
async getMarkdownFile(path: string) {
const content = this.files.get(path);
if (content === undefined) {
return null;
}
return {
path,
basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
content,
};
}
async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void> {
if (this.failReplace) {
throw new Error(`Markdown file changed before AHS write-back: ${path}`);
}
const currentContent = this.files.get(path);
if (currentContent !== expectedContent) {
throw new Error(`Markdown file changed before AHS write-back: ${path}`);
}
this.files.set(path, nextContent);
}
resolveWikiLink(rawTarget: string) {
return { url: `obsidian://open?vault=Vault&file=${rawTarget}`, displayText: rawTarget };
}
resolveEmbed() {
return null;
}
createBacklink() {
return "obsidian://open?vault=Vault&file=notes/current.md";
}
}
class FakeAnkiGateway implements AnkiGateway {
public ensuredDecks: string[] = [];
public addedNotes: Array<{ deckName: string; modelName: string; fields: Record<string, string> }> = [];
public updatedNotes: Array<{ noteId: number; deckName: string; fields: Record<string, string> }> = [];
public storedMedia: string[] = [];
public noteSummariesById = new Map<number, AnkiNoteSummary>();
public modelDetailsByName: Record<string, { fieldNames: string[]; isCloze: boolean }> = {
Basic: { fieldNames: ["Front", "Back"], isCloze: false },
Cloze: { fieldNames: ["Text", "Extra"], isCloze: true },
};
private nextAddedNoteId = 9000;
async ensureDeckExists(deckName: string): Promise<void> {
this.ensuredDecks.push(deckName);
@ -49,9 +109,17 @@ class FakeAnkiGateway implements AnkiGateway {
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false };
}
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
return noteIds.flatMap((noteId) => {
const summary = this.noteSummariesById.get(noteId);
return summary ? [summary] : [];
});
}
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
this.addedNotes.push(input);
return 9001;
this.nextAddedNoteId += 1;
return this.nextAddedNoteId;
}
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
@ -64,20 +132,27 @@ class FakeAnkiGateway implements AnkiGateway {
}
function createCard(overrides: Partial<Card> = {}): Card {
const sourceContent = (overrides.source?.sourceContent as string | undefined) ?? ["#### Prompt", "Answer"].join("\n");
const lineCount = sourceContent.split(/\r?\n/).length;
return {
key: createCardKey("card-1"),
source: {
filePath: "notes/current.md",
sourceContent,
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
blockEndLine: lineCount,
contentEndLine: Math.min(lineCount, 2),
headingLevel: 4,
headingText: "Prompt",
...overrides.source,
},
type: "basic",
heading: "Prompt",
bodyMarkdown: "Answer",
embeddedNoteId: undefined,
deck: createDeckName("Deck"),
noteModel: createNoteModelName("Basic"),
tags: [],
@ -115,111 +190,487 @@ function createMappings() {
};
}
describe("ExecuteSyncPlanUseCase", () => {
it("marks orphan records locally without any delete path", async () => {
const ankiGateway = new FakeAnkiGateway();
const repository = new InMemorySyncRegistryRepository(
new SyncRegistry([
{
cardKey: createCardKey("orphan-card"),
noteId: 42,
filePath: "notes/orphan.md",
sourceHash: createContentHash("old-hash"),
lastSyncedAt: 1,
orphan: false,
},
]),
);
function createResult(overrides: Partial<ScanAndPlanResult> = {}): ScanAndPlanResult {
return {
cards: [],
noteFieldMappings: createMappings(),
registry: new SyncRegistry(),
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [],
toUpdate: [],
toMarkOrphan: [],
},
scopedFilePaths: ["notes/current.md"],
...overrides,
};
}
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1234);
const result: ScanAndPlanResult = {
cards: [createCard()],
noteFieldMappings: createMappings(),
registry: new SyncRegistry([
{
cardKey: createCardKey("orphan-card"),
noteId: 42,
filePath: "notes/orphan.md",
sourceHash: createContentHash("old-hash"),
lastSyncedAt: 1,
orphan: false,
},
]),
describe("ExecuteSyncPlanUseCase", () => {
it("writes a new AHS marker to the end of the block for newly created cards", async () => {
const vaultGateway = new FakeVaultGateway({
"notes/current.md": ["#### Prompt", "Answer"].join("\n"),
});
const ankiGateway = new FakeAnkiGateway();
const repository = new InMemorySyncRegistryRepository();
const card = createCard();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
await useCase.execute(createResult({
cards: [card],
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [createCard()],
toAdd: [card],
toUpdate: [],
toMarkOrphan: [
{
cardKey: createCardKey("orphan-card"),
noteId: 42,
filePath: "notes/orphan.md",
sourceHash: createContentHash("old-hash"),
lastSyncedAt: 1,
orphan: false,
},
],
toMarkOrphan: [],
},
scopedFilePaths: ["notes/current.md", "notes/orphan.md"],
};
}));
const execution = await useCase.execute(result);
expect(vaultGateway.files.get("notes/current.md")).toBe(["#### Prompt", "Answer", "<!-- AHS:9001 -->"].join("\n"));
expect(repository.savedRegistry?.findByNoteId(9001)).toMatchObject({
identityMode: "embedded-note-id",
noteId: 9001,
});
});
it("writes empty-body markers on the line after the heading", async () => {
const vaultGateway = new FakeVaultGateway({
"notes/current.md": "#### Prompt",
});
const ankiGateway = new FakeAnkiGateway();
const repository = new InMemorySyncRegistryRepository();
const templateCard = createCard();
const card = createCard({
bodyMarkdown: "",
renderedFields: { title: "Prompt", body: "" },
source: {
...templateCard.source,
sourceContent: "#### Prompt",
blockEndLine: 1,
contentEndLine: 1,
},
});
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
await useCase.execute(createResult({
cards: [card],
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [card],
toUpdate: [],
toMarkOrphan: [],
},
}));
expect(vaultGateway.files.get("notes/current.md")).toBe(["#### Prompt", "<!-- AHS:9001 -->"].join("\n"));
});
it("stores pending-note-id-write when addNote succeeds but write-back fails", async () => {
const vaultGateway = new FakeVaultGateway({
"notes/current.md": ["#### Prompt", "Answer changed elsewhere"].join("\n"),
});
const ankiGateway = new FakeAnkiGateway();
const repository = new InMemorySyncRegistryRepository();
const card = createCard();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
await useCase.execute(createResult({
cards: [card],
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [card],
toUpdate: [],
toMarkOrphan: [],
},
}));
expect(repository.savedRegistry?.get(card.key)).toMatchObject({
identityMode: "pending-note-id-write",
legacyCardKey: card.key,
noteId: 9001,
});
});
it.each([
{
name: "heading change",
card: createCard({
key: createCardKey("card-heading-change"),
heading: "Prompt changed",
renderedFields: { title: "Prompt changed", body: "Answer" },
contentHash: createContentHash("hash-heading"),
embeddedNoteId: 42,
source: {
...createCard().source,
sourceContent: ["#### Prompt changed", "Answer", "<!-- AHS:42 -->"].join("\n"),
headingText: "Prompt changed",
blockEndLine: 3,
contentEndLine: 2,
markerLine: 3,
},
}),
},
{
name: "body change",
card: createCard({
key: createCardKey("card-body-change"),
bodyMarkdown: "Answer changed",
renderedFields: { title: "Prompt", body: "Answer changed" },
contentHash: createContentHash("hash-body"),
embeddedNoteId: 42,
source: {
...createCard().source,
sourceContent: ["#### Prompt", "Answer changed", "<!-- AHS:42 -->"].join("\n"),
blockEndLine: 3,
contentEndLine: 2,
markerLine: 3,
},
}),
},
{
name: "file move",
card: createCard({
key: createCardKey("card-file-move"),
contentHash: createContentHash("hash-move"),
embeddedNoteId: 42,
source: {
...createCard().source,
filePath: "notes/moved.md",
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"),
blockEndLine: 3,
contentEndLine: 2,
markerLine: 3,
},
}),
},
])("updates the original embedded note after $name", async ({ card }) => {
const vaultGateway = new FakeVaultGateway({
[card.source.filePath]: card.source.sourceContent ?? "",
});
const ankiGateway = new FakeAnkiGateway();
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic" });
const existingRecord = {
cardKey: createCardKey("legacy-embedded-key"),
identityMode: "embedded-note-id" as const,
noteId: 42,
filePath: "notes/original.md",
sourceHash: createContentHash("old-hash"),
lastSyncedAt: 1,
orphan: false,
};
const repository = new InMemorySyncRegistryRepository(new SyncRegistry([existingRecord]));
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
await useCase.execute(createResult({
cards: [card],
registry: new SyncRegistry([existingRecord]),
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [],
toUpdate: [{ card, noteId: 42 }],
toMarkOrphan: [],
},
}));
expect(ankiGateway.updatedNotes).toHaveLength(1);
expect(ankiGateway.updatedNotes[0]?.noteId).toBe(42);
expect(repository.savedRegistry?.findByNoteId(42)).toMatchObject({
cardKey: card.key,
filePath: card.source.filePath,
identityMode: "embedded-note-id",
});
});
it("refreshes embedded-note-id cards after in-block movement without creating a new note", async () => {
const templateCard = createCard();
const card = createCard({
key: createCardKey("card-moved-in-block"),
embeddedNoteId: 42,
source: {
...templateCard.source,
blockStartLine: 10,
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"),
blockEndLine: 3,
contentEndLine: 2,
markerLine: 3,
},
});
const existingRecord = {
cardKey: createCardKey("old-block-key"),
identityMode: "embedded-note-id" as const,
noteId: 42,
filePath: "notes/current.md",
sourceHash: card.contentHash,
lastSyncedAt: 1,
orphan: false,
};
const registry = new SyncRegistry([existingRecord]);
const repository = new InMemorySyncRegistryRepository(registry);
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
const ankiGateway = new FakeAnkiGateway();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
await useCase.execute(createResult({
cards: [card],
registry,
plan: {
toCreateDecks: [],
toAdd: [],
toUpdate: [],
toMarkOrphan: [],
},
}));
expect(ankiGateway.addedNotes).toHaveLength(0);
expect(ankiGateway.updatedNotes).toHaveLength(0);
expect(repository.savedRegistry?.findByNoteId(42)?.cardKey).toBe(card.key);
});
it("updates embedded-note-id cards even when the local registry entry is missing", async () => {
const templateCard = createCard();
const card = createCard({
key: createCardKey("embedded-without-registry"),
embeddedNoteId: 42,
source: {
...templateCard.source,
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"),
blockEndLine: 3,
contentEndLine: 2,
markerLine: 3,
},
});
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
const ankiGateway = new FakeAnkiGateway();
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic" });
const repository = new InMemorySyncRegistryRepository();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
await useCase.execute(createResult({
cards: [card],
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [],
toUpdate: [{ card, noteId: 42 }],
toMarkOrphan: [],
},
}));
expect(ankiGateway.updatedNotes[0]?.noteId).toBe(42);
expect(repository.savedRegistry?.findByNoteId(42)).toMatchObject({ identityMode: "embedded-note-id" });
});
it("recreates a missing embedded note and replaces the block-end AHS marker", async () => {
const templateCard = createCard();
const card = createCard({
key: createCardKey("embedded-missing-note"),
embeddedNoteId: 42,
source: {
...templateCard.source,
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"),
blockEndLine: 3,
contentEndLine: 2,
markerLine: 3,
},
});
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
const ankiGateway = new FakeAnkiGateway();
const repository = new InMemorySyncRegistryRepository();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
await useCase.execute(createResult({
cards: [card],
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [],
toUpdate: [{ card, noteId: 42 }],
toMarkOrphan: [],
},
}));
expect(ankiGateway.addedNotes).toHaveLength(1);
expect(vaultGateway.files.get("notes/current.md")).toBe(["#### Prompt", "Answer", "<!-- AHS:9001 -->"].join("\n"));
expect(repository.savedRegistry?.findByNoteId(9001)).toMatchObject({ identityMode: "embedded-note-id" });
});
it("rejects basic/cloze type switching for embedded-note-id cards", async () => {
const templateCard = createCard();
const card = createCard({
key: createCardKey("embedded-type-switch"),
type: "cloze",
noteModel: createNoteModelName("Cloze"),
embeddedNoteId: 42,
source: {
...templateCard.source,
sourceContent: ["##### Prompt", "{answer}", "<!-- AHS:42 -->"].join("\n"),
headingLevel: 5,
blockEndLine: 3,
contentEndLine: 2,
markerLine: 3,
},
});
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
const ankiGateway = new FakeAnkiGateway();
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic" });
const repository = new InMemorySyncRegistryRepository();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
await expect(
useCase.execute(createResult({
cards: [card],
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [],
toUpdate: [{ card, noteId: 42 }],
toMarkOrphan: [],
},
})),
).rejects.toThrow("Recreate the card instead of switching basic/cloze types");
});
it("keeps legacy-card-key behavior for old cards without AHS markers", async () => {
const card = createCard();
const legacyRecord = {
cardKey: card.key,
identityMode: "legacy-card-key" as const,
noteId: 77,
filePath: card.source.filePath,
sourceHash: createContentHash("old-hash"),
lastSyncedAt: 1,
orphan: false,
};
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
const ankiGateway = new FakeAnkiGateway();
const repository = new InMemorySyncRegistryRepository(new SyncRegistry([legacyRecord]));
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
await useCase.execute(createResult({
cards: [card],
registry: new SyncRegistry([legacyRecord]),
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [],
toUpdate: [{ card, noteId: 77 }],
toMarkOrphan: [],
},
}));
expect(ankiGateway.updatedNotes[0]?.noteId).toBe(77);
expect(vaultGateway.files.get("notes/current.md")).toBe(card.source.sourceContent);
expect(repository.savedRegistry?.get(card.key)).toMatchObject({ identityMode: "legacy-card-key" });
});
it("promotes pending-note-id-write records after a later successful marker retry", async () => {
const card = createCard();
const pendingRecord = {
cardKey: card.key,
identityMode: "pending-note-id-write" as const,
legacyCardKey: card.key,
noteId: 42,
filePath: card.source.filePath,
sourceHash: createContentHash("old-hash"),
lastSyncedAt: 1,
orphan: false,
};
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
const ankiGateway = new FakeAnkiGateway();
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic" });
const repository = new InMemorySyncRegistryRepository(new SyncRegistry([pendingRecord]));
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
await useCase.execute(createResult({
cards: [card],
registry: new SyncRegistry([pendingRecord]),
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [],
toUpdate: [{ card, noteId: 42 }],
toMarkOrphan: [],
},
}));
expect(vaultGateway.files.get("notes/current.md")).toBe(["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"));
expect(repository.savedRegistry?.findByNoteId(42)).toMatchObject({ identityMode: "embedded-note-id" });
});
it("marks orphan records locally without any delete path", async () => {
const ankiGateway = new FakeAnkiGateway();
const vaultGateway = new FakeVaultGateway({
"notes/current.md": ["#### Prompt", "Answer"].join("\n"),
});
const orphanRecord = {
cardKey: createCardKey("orphan-card"),
identityMode: "legacy-card-key" as const,
noteId: 42,
filePath: "notes/orphan.md",
sourceHash: createContentHash("old-hash"),
lastSyncedAt: 1,
orphan: false,
};
const repository = new InMemorySyncRegistryRepository(new SyncRegistry([orphanRecord]));
const card = createCard();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
const execution = await useCase.execute(createResult({
cards: [card],
registry: new SyncRegistry([orphanRecord]),
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [card],
toUpdate: [],
toMarkOrphan: [orphanRecord],
},
}));
expect(execution.created).toBe(1);
expect(execution.markedOrphan).toBe(1);
expect(ankiGateway.ensuredDecks).toEqual(["Deck"]);
expect(ankiGateway.addedNotes).toHaveLength(1);
expect(ankiGateway.updatedNotes).toHaveLength(0);
expect(repository.savedRegistry?.get(createCardKey("orphan-card"))?.orphan).toBe(true);
expect(repository.savedRegistry?.get(createCardKey("orphan-card"))?.noteId).toBe(42);
});
it("blocks sync when a selected note type has no saved mapping", async () => {
const ankiGateway = new FakeAnkiGateway();
const vaultGateway = new FakeVaultGateway({
"notes/current.md": ["#### Prompt", "Answer"].join("\n"),
});
const repository = new InMemorySyncRegistryRepository();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1234);
const card = createCard();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
await expect(
useCase.execute({
cards: [createCard()],
...createResult(),
cards: [card],
noteFieldMappings: {},
registry: new SyncRegistry(),
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [createCard()],
toAdd: [card],
toUpdate: [],
toMarkOrphan: [],
},
scopedFilePaths: ["notes/current.md"],
}),
).rejects.toThrow("Open plugin settings and read fields from Anki first");
expect(ankiGateway.ensuredDecks).toHaveLength(0);
expect(ankiGateway.addedNotes).toHaveLength(0);
});
it("blocks sync when a saved mapping becomes stale", async () => {
const ankiGateway = new FakeAnkiGateway();
ankiGateway.modelDetailsByName.Basic = { fieldNames: ["Front", "Body"], isCloze: false };
const vaultGateway = new FakeVaultGateway({
"notes/current.md": ["#### Prompt", "Answer"].join("\n"),
});
const repository = new InMemorySyncRegistryRepository();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1234);
const card = createCard();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
await expect(
useCase.execute({
cards: [createCard()],
noteFieldMappings: createMappings(),
registry: new SyncRegistry(),
...createResult(),
cards: [card],
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [createCard()],
toAdd: [card],
toUpdate: [],
toMarkOrphan: [],
},
scopedFilePaths: ["notes/current.md"],
}),
).rejects.toThrow("is stale because these fields no longer exist in Anki");
expect(ankiGateway.ensuredDecks).toHaveLength(0);
expect(ankiGateway.addedNotes).toHaveLength(0);
});
});

View file

@ -1,7 +1,11 @@
import { HeadingSyncMarkerService } 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";
import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService";
import { SyncRegistry } from "@/domain/sync/entities/SyncRegistry";
import type { Card } from "@/domain/card/entities/Card";
import type { SyncRecord } from "@/domain/sync/entities/SyncRecord";
import type { ExecuteSyncPlanResult, ScanAndPlanResult } from "./types";
@ -9,13 +13,16 @@ export class ExecuteSyncPlanUseCase {
constructor(
private readonly ankiGateway: AnkiGateway,
private readonly syncRegistryRepository: SyncRegistryRepository,
private readonly vaultGateway: VaultGateway,
private readonly noteFieldMappingService = new NoteFieldMappingService(),
private readonly now: () => number = () => Date.now(),
private readonly headingSyncMarkerService = new HeadingSyncMarkerService(),
) {}
async execute(scanAndPlanResult: ScanAndPlanResult): Promise<ExecuteSyncPlanResult> {
const syncRegistry = new SyncRegistry(scanAndPlanResult.registry.list());
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();
for (const card of scanAndPlanResult.cards) {
@ -35,40 +42,26 @@ export class ExecuteSyncPlanUseCase {
const uploadedMedia = await this.uploadMedia(syncCards);
for (const card of scanAndPlanResult.plan.toAdd) {
const modelDetails = await this.getModelDetails(modelDetailsCache, card.noteModel);
const noteId = await this.ankiGateway.addNote({
deckName: card.deck,
modelName: card.noteModel,
fields: this.noteFieldMappingService.map(card, modelDetails, scanAndPlanResult.noteFieldMappings),
tags: card.tags,
});
syncRegistry.recordSync({
cardKey: card.key,
noteId,
filePath: card.source.filePath,
sourceHash: card.contentHash,
lastSyncedAt: timestamp,
orphan: false,
});
const noteId = await this.addNote(card, modelDetailsCache, scanAndPlanResult);
await this.writeBackNewMarker(card, noteId, syncRegistry, timestamp);
}
for (const entry of scanAndPlanResult.plan.toUpdate) {
const modelDetails = await this.getModelDetails(modelDetailsCache, entry.card.noteModel);
await this.ankiGateway.updateNote({
noteId: entry.noteId,
deckName: entry.card.deck,
fields: this.noteFieldMappingService.map(entry.card, modelDetails, scanAndPlanResult.noteFieldMappings),
});
const existingRecord = entry.card.embeddedNoteId
? syncRegistry.findByNoteId(entry.noteId)
: syncRegistry.get(entry.card.key);
syncRegistry.recordSync({
cardKey: entry.card.key,
noteId: entry.noteId,
filePath: entry.card.source.filePath,
sourceHash: entry.card.contentHash,
lastSyncedAt: timestamp,
orphan: false,
});
if (entry.card.embeddedNoteId) {
await this.updateEmbeddedCard(entry.card, entry.noteId, noteSummaries.get(entry.noteId), syncRegistry, timestamp, modelDetailsCache, scanAndPlanResult);
continue;
}
if (existingRecord?.identityMode === "pending-note-id-write") {
await this.retryPendingMarkerWrite(entry.card, existingRecord, noteSummaries.get(entry.noteId), syncRegistry, timestamp, modelDetailsCache, scanAndPlanResult);
continue;
}
await this.updateLegacyCard(entry.card, entry.noteId, existingRecord, syncRegistry, timestamp, modelDetailsCache, scanAndPlanResult);
}
const mutatedCardKeys = new Set(syncCards.map((card) => card.key));
@ -78,11 +71,28 @@ export class ExecuteSyncPlanUseCase {
}
const existingRecord = syncRegistry.get(card.key);
if (card.embeddedNoteId) {
const embeddedRecord = syncRegistry.findByNoteId(card.embeddedNoteId);
if (!embeddedRecord) {
continue;
}
syncRegistry.recordSync(this.createEmbeddedRecord(card, embeddedRecord.noteId, timestamp));
continue;
}
if (!existingRecord) {
continue;
}
syncRegistry.refresh(card.key, card.source.filePath, card.contentHash, timestamp);
syncRegistry.recordSync({
...existingRecord,
cardKey: card.key,
filePath: card.source.filePath,
sourceHash: card.contentHash,
lastSyncedAt: timestamp,
orphan: false,
});
}
for (const orphanRecord of scanAndPlanResult.plan.toMarkOrphan) {
@ -104,6 +114,163 @@ export class ExecuteSyncPlanUseCase {
};
}
private async updateEmbeddedCard(
card: Card,
noteId: number,
noteSummary: Awaited<ReturnType<AnkiGateway["getNoteSummaries"]>>[number] | undefined,
syncRegistry: SyncRegistry,
timestamp: number,
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
scanAndPlanResult: ScanAndPlanResult,
): Promise<void> {
if (!noteSummary) {
const recreatedNoteId = await this.addNote(card, modelDetailsCache, scanAndPlanResult);
await this.writeMarker(card, recreatedNoteId);
syncRegistry.recordSync(this.createEmbeddedRecord(card, recreatedNoteId, timestamp));
return;
}
this.assertModelMatches(card, noteSummary.modelName, noteId);
await this.updateExistingNote(card, noteId, modelDetailsCache, scanAndPlanResult);
syncRegistry.recordSync(this.createEmbeddedRecord(card, noteId, timestamp));
}
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> {
let noteId = existingRecord.noteId;
if (!noteSummary) {
noteId = await this.addNote(card, modelDetailsCache, scanAndPlanResult);
} else {
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));
}
}
private async updateLegacyCard(
card: Card,
noteId: number,
existingRecord: SyncRecord | undefined,
syncRegistry: SyncRegistry,
timestamp: number,
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
scanAndPlanResult: ScanAndPlanResult,
): Promise<void> {
await this.updateExistingNote(card, noteId, modelDetailsCache, scanAndPlanResult);
syncRegistry.recordSync({
cardKey: card.key,
identityMode: existingRecord?.identityMode ?? "legacy-card-key",
legacyCardKey: existingRecord?.legacyCardKey,
noteId,
filePath: card.source.filePath,
sourceHash: card.contentHash,
lastSyncedAt: timestamp,
orphan: false,
});
}
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,
identityMode: "embedded-note-id",
noteId,
filePath: card.source.filePath,
sourceHash: card.contentHash,
lastSyncedAt: timestamp,
orphan: false,
};
}
private createPendingRecord(card: Card, noteId: number, timestamp: number): SyncRecord {
return {
cardKey: card.key,
identityMode: "pending-note-id-write",
legacyCardKey: card.key,
noteId,
filePath: card.source.filePath,
sourceHash: card.contentHash,
lastSyncedAt: timestamp,
orphan: false,
};
}
private async addNote(
card: Card,
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
scanAndPlanResult: ScanAndPlanResult,
): Promise<number> {
const modelDetails = await this.getModelDetails(modelDetailsCache, card.noteModel);
return this.ankiGateway.addNote({
deckName: card.deck,
modelName: card.noteModel,
fields: this.noteFieldMappingService.map(card, modelDetails, scanAndPlanResult.noteFieldMappings),
tags: card.tags,
});
}
private async updateExistingNote(
card: Card,
noteId: number,
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
scanAndPlanResult: ScanAndPlanResult,
): Promise<void> {
const modelDetails = await this.getModelDetails(modelDetailsCache, card.noteModel);
await this.ankiGateway.updateNote({
noteId,
deckName: card.deck,
fields: this.noteFieldMappingService.map(card, modelDetails, scanAndPlanResult.noteFieldMappings),
});
}
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}.`);
}
const nextContent = this.headingSyncMarkerService.apply(card.source, noteId);
await this.vaultGateway.replaceMarkdownFile(card.source.filePath, expectedContent, nextContent);
}
private assertModelMatches(card: Card, actualModelName: string, noteId: number): void {
if (actualModelName === card.noteModel) {
return;
}
throw new Error(
`Embedded Anki note ${noteId} uses model ${actualModelName}, but ${card.heading} now requires ${card.noteModel}. Recreate the card instead of switching basic/cloze types.`,
);
}
private async loadNoteSummaries(noteIds: number[]): Promise<Map<number, Awaited<ReturnType<AnkiGateway["getNoteSummaries"]>>[number]>> {
const uniqueNoteIds = Array.from(new Set(noteIds));
const resolved = await this.ankiGateway.getNoteSummaries(uniqueNoteIds);
return new Map(resolved.map((summary) => [summary.noteId, summary]));
}
private async getModelDetails(
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
modelName: string,

View file

@ -55,6 +55,15 @@ class FakeVaultGateway implements VaultGateway {
};
}
async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string) {
const file = this.files.find((candidate) => candidate.path === path);
if (!file || file.content !== expectedContent) {
throw new Error(`Markdown file changed before AHS write-back: ${path}`);
}
file.content = nextContent;
}
resolveWikiLink(rawTarget: string) {
return { url: `obsidian://open?vault=Vault&file=${rawTarget}`, displayText: rawTarget.split("|")[1] ?? rawTarget };
}
@ -93,6 +102,10 @@ class FakeAnkiGateway implements AnkiGateway {
: { fieldNames: ["Front", "Back"], isCloze: false };
}
async getNoteSummaries(): Promise<Array<{ noteId: number; modelName: string }>> {
return [];
}
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
this.addCalls.push(input);
return 500 + this.addCalls.length;
@ -169,7 +182,7 @@ describe("SyncCurrentFileUseCase", () => {
const ankiGateway = new FakeAnkiGateway();
const repository = new DataJsonSyncRegistryRepository(store);
const scanUseCase = new ScanAndPlanSyncUseCase(vaultGateway, repository);
const executeUseCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1000);
const executeUseCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1000);
const useCase = new SyncCurrentFileUseCase(scanUseCase, executeUseCase);
const result = await useCase.execute("notes/current.md", createSettings());

View file

@ -53,6 +53,15 @@ class FakeVaultGateway implements VaultGateway {
};
}
async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string) {
const file = this.files.find((candidate) => candidate.path === path);
if (!file || file.content !== expectedContent) {
throw new Error(`Markdown file changed before AHS write-back: ${path}`);
}
file.content = nextContent;
}
resolveWikiLink(rawTarget: string) {
return { url: `obsidian://open?vault=Vault&file=${rawTarget}`, displayText: rawTarget.split("|")[1] ?? rawTarget };
}
@ -85,6 +94,10 @@ class FakeAnkiGateway implements AnkiGateway {
: { fieldNames: ["Front", "Back"], isCloze: false };
}
async getNoteSummaries(): Promise<Array<{ noteId: number; modelName: string }>> {
return [];
}
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
this.addCalls.push(input);
return 200 + this.addCalls.length;
@ -142,7 +155,7 @@ describe("SyncVaultUseCase", () => {
const ankiGateway = new FakeAnkiGateway();
const repository = new DataJsonSyncRegistryRepository(store);
const scanUseCase = new ScanAndPlanSyncUseCase(vaultGateway, repository);
const executeUseCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 2000);
const executeUseCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 2000);
const useCase = new SyncVaultUseCase(scanUseCase, executeUseCase);
const result = await useCase.execute(

View file

@ -11,6 +11,7 @@ export interface Card {
type: CardType;
heading: string;
bodyMarkdown: string;
embeddedNoteId?: number;
deck: DeckName;
noteModel: NoteModelName;
tags: string[];

View file

@ -8,5 +8,6 @@ export interface CardDraft {
headingLevel: number;
type: CardType;
bodyMarkdown: string;
embeddedNoteId?: number;
deckHint?: DeckName;
}

View file

@ -12,6 +12,7 @@ describe("CardIdentityPolicy", () => {
blockStartLine: 5,
bodyStartLine: 6,
blockEndLine: 8,
contentEndLine: 7,
headingLevel: 4,
headingText: "Prompt",
};
@ -27,6 +28,7 @@ describe("CardIdentityPolicy", () => {
blockStartLine: 5,
bodyStartLine: 6,
blockEndLine: 8,
contentEndLine: 7,
headingLevel: 4,
headingText: "Prompt",
};

View file

@ -67,6 +67,88 @@ describe("CardExtractionService", () => {
expect(drafts[0].deckHint).toBeUndefined();
});
it("detects a block-end AHS marker and excludes it from body markdown", () => {
const sourceFile: SourceFile = {
path: "notes/example.md",
basename: "example",
content: [
"#### Prompt",
"Answer line 1",
"",
"Answer line 2",
"<!-- AHS:123 -->",
"",
"### Next heading",
].join("\n"),
};
const drafts = new CardExtractionService().extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 });
expect(drafts[0]).toMatchObject({
bodyMarkdown: ["Answer line 1", "", "Answer line 2"].join("\n"),
embeddedNoteId: 123,
});
expect(drafts[0].source).toMatchObject({
contentEndLine: 4,
markerLine: 5,
blockEndLine: 6,
});
});
it("writes empty-body marker metadata against the heading line", () => {
const sourceFile: SourceFile = {
path: "notes/example.md",
basename: "example",
content: [
"#### Prompt",
"<!-- AHS:456 -->",
].join("\n"),
};
const [draft] = new CardExtractionService().extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 });
expect(draft.bodyMarkdown).toBe("");
expect(draft.embeddedNoteId).toBe(456);
expect(draft.source).toMatchObject({
contentEndLine: 1,
markerLine: 2,
blockEndLine: 2,
});
});
it("rejects multiple valid AHS markers in one heading block", () => {
const sourceFile: SourceFile = {
path: "notes/example.md",
basename: "example",
content: [
"#### Prompt",
"Answer",
"<!-- AHS:123 -->",
"<!-- AHS:456 -->",
].join("\n"),
};
expect(() => new CardExtractionService().extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 })).toThrow(
"Multiple AHS markers found in heading block",
);
});
it("rejects invalid AHS marker syntax", () => {
const sourceFile: SourceFile = {
path: "notes/example.md",
basename: "example",
content: [
"#### Prompt",
"Answer",
"<!-- AHS:not-a-number -->",
].join("\n"),
};
expect(() => new CardExtractionService().extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 })).toThrow(
"Invalid AHS marker found in heading block",
);
});
it("rejects equal heading levels", () => {
expect(() => validateHeadingPolicy({ qaHeadingLevel: 4, clozeHeadingLevel: 4 })).toThrow(
"QA and Cloze heading levels must not be equal.",

View file

@ -13,8 +13,17 @@ interface HeadingMatch {
lineIndex: number;
}
interface ExtractedMarkerResult {
bodyLines: string[];
embeddedNoteId?: number;
contentEndLine: number;
markerLine?: number;
}
const HEADING_REGEXP = /^(#{1,6})\s+(.*?)\s*$/;
const TARGET_DECK_REGEXP = /^\s*TARGET DECK\s*:\s*(.+?)\s*$/i;
const VALID_AHS_MARKER_REGEXP = /^\s*<!--\s*AHS:([1-9]\d*)\s*-->\s*$/;
const AHS_MARKER_CANDIDATE_REGEXP = /<!--\s*AHS:/;
export class CardExtractionService {
extract(sourceFile: SourceFile, headingPolicy: HeadingPolicy): CardDraft[] {
@ -35,15 +44,19 @@ export class CardExtractionService {
const blockEndLineIndex = findBlockEndLineIndex(headings, headingIndex, lines.length);
const bodyLines = lines.slice(heading.lineIndex + 1, blockEndLineIndex);
const bodyMarkdown = trimBlankEdges(bodyLines).join("\n");
const extractedMarker = extractEmbeddedMarker(bodyLines, heading.lineIndex + 2, heading.lineIndex + 1);
const bodyMarkdown = trimBlankEdges(extractedMarker.bodyLines).join("\n");
drafts.push({
source: {
filePath: sourceFile.path,
sourceContent: sourceFile.content,
headingLine: heading.lineIndex + 1,
blockStartLine: heading.lineIndex + 1,
bodyStartLine: heading.lineIndex + 2,
blockEndLine: blockEndLineIndex,
contentEndLine: extractedMarker.contentEndLine,
markerLine: extractedMarker.markerLine,
headingLevel: heading.level,
headingText: heading.text,
},
@ -51,6 +64,7 @@ export class CardExtractionService {
headingLevel: heading.level,
type: cardType,
bodyMarkdown,
embeddedNoteId: extractedMarker.embeddedNoteId,
deckHint: targetDeck ? createDeckName(targetDeck) : undefined,
});
}
@ -169,4 +183,75 @@ function trimBlankEdges(lines: string[]): string[] {
}
return lines.slice(startIndex, endIndex);
}
function extractEmbeddedMarker(bodyLines: string[], bodyStartLine: number, headingLine: number): ExtractedMarkerResult {
const ahsCandidates = bodyLines
.map((line, index) => ({ line, index }))
.filter(({ line }) => AHS_MARKER_CANDIDATE_REGEXP.test(line));
const validMarkers = ahsCandidates.filter(({ line }) => VALID_AHS_MARKER_REGEXP.test(line));
if (validMarkers.length > 1) {
throw new Error(`Multiple AHS markers found in heading block at line ${headingLine}.`);
}
let lastNonEmptyIndex = bodyLines.length - 1;
while (lastNonEmptyIndex >= 0 && !bodyLines[lastNonEmptyIndex].trim()) {
lastNonEmptyIndex -= 1;
}
if (lastNonEmptyIndex < 0) {
if (ahsCandidates.length > 0) {
throw new Error(`Invalid AHS marker found in heading block at line ${headingLine}.`);
}
return {
bodyLines,
contentEndLine: headingLine,
};
}
const lastNonEmptyLine = bodyLines[lastNonEmptyIndex];
const validMarkerMatch = lastNonEmptyLine.match(VALID_AHS_MARKER_REGEXP);
if (validMarkerMatch) {
const markerIndex = lastNonEmptyIndex;
for (const candidate of ahsCandidates) {
if (candidate.index === markerIndex) {
continue;
}
throw new Error(`Multiple or misplaced AHS markers found in heading block at line ${headingLine}.`);
}
const nextBodyLines = bodyLines.filter((_line, index) => index !== markerIndex);
const contentEndLine = findContentEndLine(nextBodyLines, bodyStartLine, headingLine);
return {
bodyLines: nextBodyLines,
embeddedNoteId: Number(validMarkerMatch[1]),
contentEndLine,
markerLine: bodyStartLine + markerIndex,
};
}
if (AHS_MARKER_CANDIDATE_REGEXP.test(lastNonEmptyLine) || ahsCandidates.length > 0) {
throw new Error(`Invalid AHS marker found in heading block at line ${headingLine}.`);
}
return {
bodyLines,
contentEndLine: findContentEndLine(bodyLines, bodyStartLine, headingLine),
};
}
function findContentEndLine(bodyLines: string[], bodyStartLine: number, headingLine: number): number {
for (let index = bodyLines.length - 1; index >= 0; index -= 1) {
if (bodyLines[index].trim()) {
return bodyStartLine + index;
}
}
return headingLine;
}

View file

@ -49,6 +49,7 @@ function createDraft(overrides: Partial<CardDraft> = {}): CardDraft {
blockStartLine: 3,
bodyStartLine: 4,
blockEndLine: 8,
contentEndLine: 7,
headingLevel: 4,
headingText: "Prompt",
},
@ -93,6 +94,7 @@ describe("CardRenderingService", () => {
blockStartLine: 5,
bodyStartLine: 6,
blockEndLine: 7,
contentEndLine: 6,
headingLevel: 5,
headingText: "Context",
},

View file

@ -81,6 +81,7 @@ export class CardRenderingService {
type: draft.type,
heading: draft.heading,
bodyMarkdown: draft.bodyMarkdown,
embeddedNoteId: draft.embeddedNoteId,
deck,
noteModel,
tags: [],

View file

@ -1,9 +1,12 @@
export interface SourceLocation {
filePath: string;
sourceContent?: string;
headingLine: number;
blockStartLine: number;
bodyStartLine: number;
blockEndLine: number;
contentEndLine: number;
markerLine?: number;
headingLevel: number;
headingText: string;
}

View file

@ -1,8 +1,12 @@
import type { CardKey } from "../../card/value-objects/CardKey";
import type { ContentHash } from "../../card/value-objects/ContentHash";
export type SyncIdentityMode = "embedded-note-id" | "legacy-card-key" | "pending-note-id-write";
export interface SyncRecord {
cardKey: CardKey;
identityMode: SyncIdentityMode;
legacyCardKey?: CardKey;
noteId: number;
filePath: string;
sourceHash: ContentHash;

View file

@ -10,6 +10,7 @@ describe("SyncRegistry", () => {
const registry = new SyncRegistry([
{
cardKey: createCardKey("card-1"),
identityMode: "legacy-card-key",
noteId: 101,
filePath: "notes/old.md",
sourceHash: createContentHash("old-hash"),
@ -22,6 +23,7 @@ describe("SyncRegistry", () => {
expect(registry.get(createCardKey("card-1"))).toEqual({
cardKey: createCardKey("card-1"),
identityMode: "legacy-card-key",
noteId: 101,
filePath: "notes/new.md",
sourceHash: createContentHash("new-hash"),
@ -34,6 +36,7 @@ describe("SyncRegistry", () => {
const registry = new SyncRegistry([
{
cardKey: createCardKey("card-1"),
identityMode: "legacy-card-key",
noteId: 101,
filePath: "notes/example.md",
sourceHash: createContentHash("hash"),
@ -47,4 +50,32 @@ describe("SyncRegistry", () => {
expect(registry.get(createCardKey("card-1"))?.orphan).toBe(true);
expect(registry.get(createCardKey("card-1"))?.noteId).toBe(101);
});
it("reassigns a note id to the latest card key", () => {
const registry = new SyncRegistry([
{
cardKey: createCardKey("card-1"),
identityMode: "embedded-note-id",
noteId: 101,
filePath: "notes/example.md",
sourceHash: createContentHash("hash"),
lastSyncedAt: 1,
orphan: false,
},
]);
registry.recordSync({
cardKey: createCardKey("card-2"),
identityMode: "embedded-note-id",
noteId: 101,
filePath: "notes/renamed.md",
sourceHash: createContentHash("hash"),
lastSyncedAt: 2,
orphan: false,
});
expect(registry.get(createCardKey("card-1"))).toBeUndefined();
expect(registry.get(createCardKey("card-2"))?.noteId).toBe(101);
expect(registry.findByNoteId(101)?.cardKey).toBe(createCardKey("card-2"));
});
});

View file

@ -58,7 +58,7 @@ export class SyncRegistry {
const existingForNoteId = this.cardKeysByNoteId.get(record.noteId);
if (existingForNoteId && existingForNoteId !== record.cardKey) {
throw new Error(`Note ${record.noteId} is already assigned to another card key.`);
this.recordsByCardKey.delete(existingForNoteId);
}
const previous = this.recordsByCardKey.get(record.cardKey);

View file

@ -14,16 +14,19 @@ function createCard(overrides: Partial<Card> = {}): Card {
key: createCardKey("card-a"),
source: {
filePath: "notes/example.md",
sourceContent: ["#### Prompt", "Answer"].join("\n"),
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
contentEndLine: 2,
headingLevel: 4,
headingText: "Prompt",
},
type: "basic",
heading: "Prompt",
bodyMarkdown: "Answer",
embeddedNoteId: undefined,
deck: createDeckName("Deck"),
noteModel: createNoteModelName("Basic"),
tags: [],
@ -44,6 +47,7 @@ function createCard(overrides: Partial<Card> = {}): Card {
function createRecord(overrides: Partial<SyncRecord> = {}): SyncRecord {
return {
cardKey: createCardKey("card-a"),
identityMode: "legacy-card-key",
noteId: 100,
filePath: "notes/example.md",
sourceHash: createContentHash("hash-a"),
@ -62,10 +66,12 @@ describe("SyncPlanningService", () => {
contentHash: createContentHash("hash-c"),
source: {
filePath: "notes/second.md",
sourceContent: ["#### Another", "Answer"].join("\n"),
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
contentEndLine: 2,
headingLevel: 4,
headingText: "Another",
},
@ -97,4 +103,67 @@ describe("SyncPlanningService", () => {
"Duplicate card key detected in current scan",
);
});
it("prefers embedded note id over legacy card key and tolerates registry loss", () => {
const service = new SyncPlanningService();
const embeddedCard = createCard({
key: createCardKey("moved-card-key"),
embeddedNoteId: 222,
source: {
...createCard().source,
filePath: "notes/moved.md",
},
});
const planWithoutRegistry = service.plan([embeddedCard], new SyncRegistry(), ["notes/moved.md"]);
expect(planWithoutRegistry.toAdd).toHaveLength(0);
expect(planWithoutRegistry.toUpdate).toEqual([{ card: embeddedCard, noteId: 222 }]);
});
it("marks embedded and legacy records orphan using different identity modes", () => {
const service = new SyncPlanningService();
const registry = new SyncRegistry([
createRecord({
cardKey: createCardKey("legacy-card"),
noteId: 100,
filePath: "notes/legacy.md",
}),
createRecord({
cardKey: createCardKey("embedded-card"),
identityMode: "embedded-note-id",
noteId: 200,
filePath: "notes/embedded.md",
}),
]);
const embeddedCard = createCard({
key: createCardKey("different-card-key"),
embeddedNoteId: 200,
source: {
...createCard().source,
filePath: "notes/embedded.md",
},
});
const plan = service.plan([embeddedCard], registry, ["notes/legacy.md", "notes/embedded.md"]);
expect(plan.toMarkOrphan.map((record) => record.noteId)).toEqual([100]);
});
it("forces pending note-id-write records back through update flow", () => {
const service = new SyncPlanningService();
const card = createCard();
const registry = new SyncRegistry([
createRecord({
cardKey: card.key,
identityMode: "pending-note-id-write",
legacyCardKey: card.key,
}),
]);
const plan = service.plan([card], registry, ["notes/example.md"]);
expect(plan.toUpdate).toEqual([{ card, noteId: 100 }]);
});
});

View file

@ -5,6 +5,7 @@ import type { SyncPlan } from "../value-objects/SyncPlan";
export class SyncPlanningService {
plan(cards: Card[], registry: SyncRegistry, scopedFilePaths: string[]): SyncPlan {
const seenKeys = new Set<string>();
const seenEmbeddedNoteIds = new Set<number>();
const createDecks = new Map<string, Card["deck"]>();
const toAdd: Card[] = [];
const toUpdate: SyncPlan["toUpdate"] = [];
@ -15,6 +16,29 @@ export class SyncPlanningService {
}
seenKeys.add(card.key);
if (card.embeddedNoteId) {
if (seenEmbeddedNoteIds.has(card.embeddedNoteId)) {
throw new Error(`Duplicate embedded note id detected in current scan: ${card.embeddedNoteId}`);
}
seenEmbeddedNoteIds.add(card.embeddedNoteId);
const existingEmbeddedRecord = registry.findByNoteId(card.embeddedNoteId);
if (!existingEmbeddedRecord) {
toUpdate.push({ card, noteId: card.embeddedNoteId });
createDecks.set(card.deck, card.deck);
continue;
}
if (existingEmbeddedRecord.sourceHash !== card.contentHash || existingEmbeddedRecord.orphan) {
toUpdate.push({ card, noteId: card.embeddedNoteId });
createDecks.set(card.deck, card.deck);
}
continue;
}
const existingRecord = registry.get(card.key);
if (!existingRecord) {
@ -23,14 +47,24 @@ export class SyncPlanningService {
continue;
}
if (existingRecord.sourceHash !== card.contentHash || existingRecord.orphan) {
if (existingRecord.identityMode === "pending-note-id-write" || existingRecord.sourceHash !== card.contentHash || existingRecord.orphan) {
toUpdate.push({ card, noteId: existingRecord.noteId });
createDecks.set(card.deck, card.deck);
}
}
const scopedPaths = new Set(scopedFilePaths);
const toMarkOrphan = registry.list().filter((record) => scopedPaths.has(record.filePath) && !record.orphan && !seenKeys.has(record.cardKey));
const toMarkOrphan = registry.list().filter((record) => {
if (!scopedPaths.has(record.filePath) || record.orphan) {
return false;
}
if (record.identityMode === "embedded-note-id") {
return !seenEmbeddedNoteIds.has(record.noteId);
}
return !seenKeys.has(record.cardKey);
});
return {
toCreateDecks: Array.from(createDecks.values()),

View file

@ -59,4 +59,32 @@ describe("AnkiConnectGateway", () => {
isCloze: true,
});
});
it("returns note existence and model summaries", async () => {
requestUrlMock.mockResolvedValue({
json: {
error: null,
result: [
{ noteId: 100, modelName: "Basic", cards: [1] },
null,
{ noteId: 102, modelName: "Cloze", cards: [2] },
],
},
});
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
const summaries = await gateway.getNoteSummaries([100, 101, 102]);
expect(summaries).toEqual([
{ noteId: 100, modelName: "Basic" },
{ noteId: 102, modelName: "Cloze" },
]);
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
action: "notesInfo",
version: 6,
params: {
notes: [100, 101, 102],
},
});
});
});

View file

@ -1,7 +1,7 @@
import { requestUrl } from "obsidian";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import type { AddAnkiNoteInput, AnkiGateway, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
interface AnkiResponse<T> {
@ -11,6 +11,8 @@ interface AnkiResponse<T> {
interface NoteInfo {
cards: number[];
modelName?: string;
noteId?: number;
}
type ModelTemplates = Record<string, unknown>;
@ -43,6 +45,27 @@ export class AnkiConnectGateway implements AnkiGateway {
};
}
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
if (noteIds.length === 0) {
return [];
}
const noteInfo = await this.invoke<Array<NoteInfo | null>>("notesInfo", {
notes: noteIds,
});
return noteInfo.flatMap((entry) => {
if (!entry || typeof entry.noteId !== "number" || typeof entry.modelName !== "string") {
return [];
}
return [{
noteId: entry.noteId,
modelName: entry.modelName,
}];
});
}
async addNote(input: AddAnkiNoteInput): Promise<number> {
return this.invoke<number>("addNote", {
note: {

View file

@ -27,6 +27,21 @@ export class ObsidianVaultGateway implements VaultGateway {
return this.toSourceFile(abstractFile);
}
async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string) {
const abstractFile = this.app.vault.getAbstractFileByPath(path);
if (!(abstractFile instanceof TFile) || abstractFile.extension.toLowerCase() !== "md") {
throw new Error(`Markdown file not found: ${path}`);
}
const currentContent = await this.app.vault.cachedRead(abstractFile);
if (currentContent !== expectedContent) {
throw new Error(`Markdown file changed before AHS write-back: ${path}`);
}
await this.app.vault.modify(abstractFile, nextContent);
}
resolveWikiLink(rawTarget: string, sourcePath: string) {
const { alias, linkPath } = parseLinkTarget(rawTarget);
const destination = this.app.metadataCache.getFirstLinkpathDest(stripSubpath(linkPath), sourcePath);

View file

@ -8,7 +8,9 @@ export interface PluginDataSnapshot {
records: Array<{
cardKey: string;
filePath: string;
identityMode?: "embedded-note-id" | "legacy-card-key" | "pending-note-id-write";
lastSyncedAt: number;
legacyCardKey?: string;
noteId: number;
orphan: boolean;
sourceHash: string;

View file

@ -27,6 +27,8 @@ describe("DataJsonSyncRegistryRepository", () => {
const registry = new SyncRegistry([
{
cardKey: createCardKey("card-1"),
identityMode: "embedded-note-id",
legacyCardKey: createCardKey("legacy-1"),
noteId: 101,
filePath: "notes/example.md",
sourceHash: createContentHash("hash-1"),

View file

@ -17,6 +17,8 @@ export class DataJsonSyncRegistryRepository implements SyncRegistryRepository {
cardKey: createCardKey(record.cardKey),
noteId: record.noteId,
filePath: record.filePath,
identityMode: record.identityMode ?? "legacy-card-key",
legacyCardKey: record.legacyCardKey ? createCardKey(record.legacyCardKey) : undefined,
sourceHash: createContentHash(record.sourceHash),
lastSyncedAt: record.lastSyncedAt,
orphan: record.orphan,
@ -34,6 +36,8 @@ export class DataJsonSyncRegistryRepository implements SyncRegistryRepository {
cardKey: record.cardKey,
noteId: record.noteId,
filePath: record.filePath,
identityMode: record.identityMode,
legacyCardKey: record.legacyCardKey,
sourceHash: record.sourceHash,
lastSyncedAt: record.lastSyncedAt,
orphan: record.orphan,

View file

@ -40,7 +40,7 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
}
const scanAndPlanSyncUseCase = new ScanAndPlanSyncUseCase(vaultGateway, syncRegistryRepository);
const executeSyncPlanUseCase = new ExecuteSyncPlanUseCase(this.ankiGateway, syncRegistryRepository);
const executeSyncPlanUseCase = new ExecuteSyncPlanUseCase(this.ankiGateway, syncRegistryRepository, vaultGateway);
this.syncCurrentFileUseCase = new SyncCurrentFileUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase);
this.syncVaultUseCase = new SyncVaultUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase);