From 7c2c6776bd14425e8a57cea18bb62c5a9a2cf605 Mon Sep 17 00:00:00 2001 From: Dusk Date: Sat, 18 Apr 2026 00:08:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Module2=20=E2=80=94=20AHS=20=E5=86=99?= =?UTF-8?q?=E5=9B=9E=E4=B8=8E=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/2026-04-17PLAN3.md | 149 +++++ .../anki-plugin-ddd-idea.md | 0 docs/module-2-gap-report.md | 61 ++ src/application/ports/AnkiGateway.ts | 6 + src/application/ports/VaultGateway.ts | 1 + .../services/HeadingSyncMarkerService.test.ts | 65 ++ .../services/HeadingSyncMarkerService.ts | 39 ++ .../services/NoteFieldMappingService.test.ts | 1 + .../use-cases/ExecuteSyncPlanUseCase.test.ts | 585 ++++++++++++++++-- .../use-cases/ExecuteSyncPlanUseCase.ts | 229 ++++++- .../use-cases/SyncCurrentFileUseCase.test.ts | 15 +- .../use-cases/SyncVaultUseCase.test.ts | 15 +- src/domain/card/entities/Card.ts | 1 + src/domain/card/entities/CardDraft.ts | 1 + .../card/policies/CardIdentityPolicy.test.ts | 2 + .../services/CardExtractionService.test.ts | 82 +++ .../card/services/CardExtractionService.ts | 87 ++- .../services/CardRenderingService.test.ts | 2 + .../card/services/CardRenderingService.ts | 1 + .../card/value-objects/SourceLocation.ts | 3 + src/domain/sync/entities/SyncRecord.ts | 4 + src/domain/sync/entities/SyncRegistry.test.ts | 31 + src/domain/sync/entities/SyncRegistry.ts | 2 +- .../sync/services/SyncPlanningService.test.ts | 69 +++ .../sync/services/SyncPlanningService.ts | 38 +- .../anki/AnkiConnectGateway.test.ts | 28 + src/infrastructure/anki/AnkiConnectGateway.ts | 25 +- .../obsidian/ObsidianVaultGateway.ts | 15 + .../DataJsonPluginConfigRepository.ts | 2 + .../DataJsonSyncRegistryRepository.test.ts | 2 + .../DataJsonSyncRegistryRepository.ts | 4 + src/presentation/AnkiHeadingSyncPlugin.ts | 2 +- 32 files changed, 1461 insertions(+), 106 deletions(-) create mode 100644 docs/2026-04-17PLAN3.md rename anki-plugin-ddd-idea.md => docs/anki-plugin-ddd-idea.md (100%) create mode 100644 docs/module-2-gap-report.md create mode 100644 src/application/services/HeadingSyncMarkerService.test.ts create mode 100644 src/application/services/HeadingSyncMarkerService.ts diff --git a/docs/2026-04-17PLAN3.md b/docs/2026-04-17PLAN3.md new file mode 100644 index 0000000..278d9f4 --- /dev/null +++ b/docs/2026-04-17PLAN3.md @@ -0,0 +1,149 @@ +# 模块 2:将 Anki `noteId` 回写到标题块末尾,使用短标记 `` + +## Summary + +- 新增卡片同步成功后,把返回的 `Anki noteId` 写回对应标题块的块尾,格式固定为 ``。 +- 以后该标题块再次同步时,优先按这个 `noteId` 更新原卡,不再依赖 `标题 + 行号 + 路径`。 +- 标记放在块尾而不是标题下方,用来明确卡片边界。 +- 老卡不迁移,只对以后新增的卡生效;老卡继续走当前 `cardKey` 逻辑。 +- 如果块尾 `noteId` 在 Anki 中不存在,自动重建新卡并替换为新的 `noteId`。 +- 如果同一标题块从 `basic` 切到 `cloze` 或反过来,直接报错并要求用户重建。 + +## Key Changes + +### 1. 标记语法与块尾规则 + +- 唯一支持的标记格式: + - `` +- 标记位置固定: + - 当前标题块最后一个非空内容行之后 + - 下一个同级或更高层级标题之前 +- 写回规则固定: + - 有正文时:写在正文末尾 + - 无正文时:写在标题下一行 + - 标记本身视为块尾终止标记 +- 读取规则固定: + - 只识别当前标题块块尾的最后一个合法 `AHS` 标记 + - 标记不进入 `bodyMarkdown` + - 同一标题块多个合法 `AHS` 标记时报错 + - 非法 `AHS` 格式时报错,不静默忽略 + +### 2. 提取与渲染 + +- `CardDraft` 增加 `embeddedNoteId?: number` +- `SourceLocation` 增加块尾定位信息,至少包括: + - `blockEndLine` + - `contentEndLine` + - `markerLine?: number` +- `CardExtractionService` 负责: + - 识别块尾 `` + - 从正文中剔除该标记 + - 将 `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>` +- 执行前先校验所有 `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` +- 写回采用 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` 标记不会进入 `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 + +- 第一版只支持块尾短标记 `` +- 不兼容旧的 `ID:` 行、长注释格式、frontmatter、block ID +- 老卡不迁移;只有以后新增的卡获得稳定 `noteId` 身份 +- `basic` 与 `cloze` 的跨类型切换第一版不自动处理 +- 允许插件改写 Markdown 文件,在标题块末尾插入或替换 `AHS` 标记 diff --git a/anki-plugin-ddd-idea.md b/docs/anki-plugin-ddd-idea.md similarity index 100% rename from anki-plugin-ddd-idea.md rename to docs/anki-plugin-ddd-idea.md diff --git a/docs/module-2-gap-report.md b/docs/module-2-gap-report.md new file mode 100644 index 0000000..939ff54 --- /dev/null +++ b/docs/module-2-gap-report.md @@ -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 `` 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. \ No newline at end of file diff --git a/src/application/ports/AnkiGateway.ts b/src/application/ports/AnkiGateway.ts index 21f2fee..e22ad1c 100644 --- a/src/application/ports/AnkiGateway.ts +++ b/src/application/ports/AnkiGateway.ts @@ -14,10 +14,16 @@ export interface UpdateAnkiNoteInput { fields: Record; } +export interface AnkiNoteSummary { + noteId: number; + modelName: string; +} + export interface AnkiGateway { ensureDeckExists(deckName: string): Promise; listNoteModels(): Promise; getModelDetails(modelName: string): Promise; + getNoteSummaries(noteIds: number[]): Promise; addNote(input: AddAnkiNoteInput): Promise; updateNote(input: UpdateAnkiNoteInput): Promise; storeMedia(asset: MediaAsset): Promise; diff --git a/src/application/ports/VaultGateway.ts b/src/application/ports/VaultGateway.ts index e654ed2..5c464f3 100644 --- a/src/application/ports/VaultGateway.ts +++ b/src/application/ports/VaultGateway.ts @@ -4,4 +4,5 @@ import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceR export interface VaultGateway extends RenderResourceResolver { listMarkdownFiles(): Promise; getMarkdownFile(path: string): Promise; + replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise; } \ No newline at end of file diff --git a/src/application/services/HeadingSyncMarkerService.test.ts b/src/application/services/HeadingSyncMarkerService.test.ts new file mode 100644 index 0000000..0a12ab8 --- /dev/null +++ b/src/application/services/HeadingSyncMarkerService.test.ts @@ -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", "", "", "### 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", "", "### 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", "", "### 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", "", "### Next"].join("\n")); + }); +}); \ No newline at end of file diff --git a/src/application/services/HeadingSyncMarkerService.ts b/src/application/services/HeadingSyncMarkerService.ts new file mode 100644 index 0000000..9d51632 --- /dev/null +++ b/src/application/services/HeadingSyncMarkerService.ts @@ -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: ``, + lineIndex: -1, + }; + } +} \ No newline at end of file diff --git a/src/application/services/NoteFieldMappingService.test.ts b/src/application/services/NoteFieldMappingService.test.ts index 2d096b0..5446e11 100644 --- a/src/application/services/NoteFieldMappingService.test.ts +++ b/src/application/services/NoteFieldMappingService.test.ts @@ -18,6 +18,7 @@ function createCard(overrides: Partial): Card { blockStartLine: 1, bodyStartLine: 2, blockEndLine: 3, + contentEndLine: 2, headingLevel: 4, headingText: "Prompt", }, diff --git a/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts b/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts index 36d7c3f..959f202 100644 --- a/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts +++ b/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts @@ -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(); + public failReplace = false; + + constructor(initialFiles: Record = {}) { + 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 { + 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 }> = []; public updatedNotes: Array<{ noteId: number; deckName: string; fields: Record }> = []; public storedMedia: string[] = []; + public noteSummariesById = new Map(); public modelDetailsByName: Record = { Basic: { fieldNames: ["Front", "Back"], isCloze: false }, Cloze: { fieldNames: ["Text", "Extra"], isCloze: true }, }; + private nextAddedNoteId = 9000; async ensureDeckExists(deckName: string): Promise { 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 { + return noteIds.flatMap((noteId) => { + const summary = this.noteSummariesById.get(noteId); + return summary ? [summary] : []; + }); + } + async addNote(input: { deckName: string; modelName: string; fields: Record }): Promise { this.addedNotes.push(input); - return 9001; + this.nextAddedNoteId += 1; + return this.nextAddedNoteId; } async updateNote(input: { noteId: number; deckName: string; fields: Record }): Promise { @@ -64,20 +132,27 @@ class FakeAnkiGateway implements AnkiGateway { } function createCard(overrides: Partial = {}): 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 { + 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", ""].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", ""].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", ""].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", ""].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", ""].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", ""].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", ""].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", ""].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", ""].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}", ""].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", ""].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); }); }); \ No newline at end of file diff --git a/src/application/use-cases/ExecuteSyncPlanUseCase.ts b/src/application/use-cases/ExecuteSyncPlanUseCase.ts index 3d56299..383804a 100644 --- a/src/application/use-cases/ExecuteSyncPlanUseCase.ts +++ b/src/application/use-cases/ExecuteSyncPlanUseCase.ts @@ -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 { const syncRegistry = new SyncRegistry(scanAndPlanResult.registry.list()); const modelDetailsCache = new Map>>(); + 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>[number] | undefined, + syncRegistry: SyncRegistry, + timestamp: number, + modelDetailsCache: Map>>, + scanAndPlanResult: ScanAndPlanResult, + ): Promise { + 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>[number] | undefined, + syncRegistry: SyncRegistry, + timestamp: number, + modelDetailsCache: Map>>, + scanAndPlanResult: ScanAndPlanResult, + ): Promise { + 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>>, + scanAndPlanResult: ScanAndPlanResult, + ): Promise { + 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 { + 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>>, + scanAndPlanResult: ScanAndPlanResult, + ): Promise { + 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>>, + scanAndPlanResult: ScanAndPlanResult, + ): Promise { + 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 { + 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>[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>>, modelName: string, diff --git a/src/application/use-cases/SyncCurrentFileUseCase.test.ts b/src/application/use-cases/SyncCurrentFileUseCase.test.ts index 8944c38..9d751ec 100644 --- a/src/application/use-cases/SyncCurrentFileUseCase.test.ts +++ b/src/application/use-cases/SyncCurrentFileUseCase.test.ts @@ -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> { + return []; + } + async addNote(input: { deckName: string; modelName: string; fields: Record }): Promise { 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()); diff --git a/src/application/use-cases/SyncVaultUseCase.test.ts b/src/application/use-cases/SyncVaultUseCase.test.ts index 5470d7c..85560e5 100644 --- a/src/application/use-cases/SyncVaultUseCase.test.ts +++ b/src/application/use-cases/SyncVaultUseCase.test.ts @@ -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> { + return []; + } + async addNote(input: { deckName: string; modelName: string; fields: Record }): Promise { 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( diff --git a/src/domain/card/entities/Card.ts b/src/domain/card/entities/Card.ts index 8a1a928..02c4439 100644 --- a/src/domain/card/entities/Card.ts +++ b/src/domain/card/entities/Card.ts @@ -11,6 +11,7 @@ export interface Card { type: CardType; heading: string; bodyMarkdown: string; + embeddedNoteId?: number; deck: DeckName; noteModel: NoteModelName; tags: string[]; diff --git a/src/domain/card/entities/CardDraft.ts b/src/domain/card/entities/CardDraft.ts index e6d2d78..78176a1 100644 --- a/src/domain/card/entities/CardDraft.ts +++ b/src/domain/card/entities/CardDraft.ts @@ -8,5 +8,6 @@ export interface CardDraft { headingLevel: number; type: CardType; bodyMarkdown: string; + embeddedNoteId?: number; deckHint?: DeckName; } \ No newline at end of file diff --git a/src/domain/card/policies/CardIdentityPolicy.test.ts b/src/domain/card/policies/CardIdentityPolicy.test.ts index f330300..2a70bf0 100644 --- a/src/domain/card/policies/CardIdentityPolicy.test.ts +++ b/src/domain/card/policies/CardIdentityPolicy.test.ts @@ -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", }; diff --git a/src/domain/card/services/CardExtractionService.test.ts b/src/domain/card/services/CardExtractionService.test.ts index bf4c3e0..67eedf1 100644 --- a/src/domain/card/services/CardExtractionService.test.ts +++ b/src/domain/card/services/CardExtractionService.test.ts @@ -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", + "", + "", + "### 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", + "", + ].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", + "", + "", + ].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", + "", + ].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.", diff --git a/src/domain/card/services/CardExtractionService.ts b/src/domain/card/services/CardExtractionService.ts index bcd01f2..f3b0f70 100644 --- a/src/domain/card/services/CardExtractionService.ts +++ b/src/domain/card/services/CardExtractionService.ts @@ -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*$/; +const AHS_MARKER_CANDIDATE_REGEXP = /