diff --git a/docs/2026-04-17PLAN3.md b/docs/2026-04-17PLAN3.md index 278d9f4..c9946a8 100644 --- a/docs/2026-04-17PLAN3.md +++ b/docs/2026-04-17PLAN3.md @@ -1,5 +1,8 @@ # 模块 2:将 Anki `noteId` 回写到标题块末尾,使用短标记 `` +> 归档说明(2026-04-21):本文描述的是已移除的 legacy syncRegistry 主链方案。 +> 当前保留的生产同步链路是 `ManualSyncService` 所在的 manual-sync 体系。 + ## Summary - 新增卡片同步成功后,把返回的 `Anki noteId` 写回对应标题块的块尾,格式固定为 ``。 diff --git a/docs/anki-plugin-ddd-idea.md b/docs/anki-plugin-ddd-idea.md index 0f5501a..3882de9 100644 --- a/docs/anki-plugin-ddd-idea.md +++ b/docs/anki-plugin-ddd-idea.md @@ -1,5 +1,8 @@ # Obsidian Anki 新插件设计备忘(DDD 讨论稿) +> 归档说明(2026-04-21):本文包含已移除的 legacy sync / syncRegistry 架构讨论。 +> 仓库当前有效实现以 `ManualSyncService` manual-sync 主链为准。 + ## 文档目的 这不是一个立即开工的实现文档,而是一份面向未来的设计备忘。 diff --git a/docs/legacy-sync-cleanup-decisions.md b/docs/legacy-sync-cleanup-decisions.md new file mode 100644 index 0000000..8cbe7a2 --- /dev/null +++ b/docs/legacy-sync-cleanup-decisions.md @@ -0,0 +1,64 @@ +# Legacy Sync Cleanup Decisions + +## Runtime truth + +- The only supported sync runtime after this cleanup is the `ManualSyncService` path. +- No legacy fallback will be preserved. +- Old `syncRegistry` data in data.json will be ignored and no migration will be implemented. + +## Files safe to delete directly once imports are removed + +- `src/application/use-cases/ScanAndPlanSyncUseCase.ts` +- `src/application/use-cases/ExecuteSyncPlanUseCase.ts` +- `src/application/use-cases/SyncCurrentFileUseCase.ts` +- `src/application/use-cases/SyncVaultUseCase.ts` +- `src/application/use-cases/types.ts` +- `src/application/services/HeadingSyncMarkerService.ts` +- `src/application/ports/SyncRegistryRepository.ts` +- `src/infrastructure/persistence/DataJsonSyncRegistryRepository.ts` +- `src/domain/sync/**` +- `src/domain/card/services/CardExtractionService.ts` +- legacy-only tests for the modules above + +## Shared types to preserve + +- Preserve these modules or exported types because current manual-sync still uses them: + - `RenderedFields` + - `MediaAsset` + - `SourceFile` + - `SourceLocation` + - `CardType` + - `RenderResourceResolver` + - `CardKey` + - `ContentHash` + +## Mixed modules requiring partial extraction or reduction + +- `src/infrastructure/persistence/DataJsonPluginConfigRepository.ts` + - Remove `PluginDataSnapshot.syncRegistry` entirely. + - Keep `settings` and `pluginState` support intact. +- `src/application/services/NoteFieldMappingService.ts` + - Stop importing `Card` from `domain/card/entities/Card`. + - Use the existing local render-shape input plus `CardType` directly. +- `src/domain/card/entities/Card.ts` + - Delete only if a post-decoupling search shows no remaining production usage. + +## Symbols that must disappear from production code + +- `ScanAndPlanSyncUseCase` +- `ExecuteSyncPlanUseCase` +- `SyncCurrentFileUseCase` +- `SyncVaultUseCase` +- `SyncRegistry` +- `SyncRegistryRepository` +- `DataJsonSyncRegistryRepository` +- `HeadingSyncMarkerService` +- `legacy-card-key` +- `pending-note-id-write` + +## Test and doc policy + +- Delete tests that only validate the removed legacy chain. +- Keep and repair tests covering the current manual-sync runtime. +- Update or remove docs that still present the legacy chain as active implementation. +- Historical docs may remain only if they clearly read as archival context and no longer imply active runtime ownership. \ No newline at end of file diff --git a/docs/legacy-sync-cleanup-gap-report.md b/docs/legacy-sync-cleanup-gap-report.md new file mode 100644 index 0000000..148a911 --- /dev/null +++ b/docs/legacy-sync-cleanup-gap-report.md @@ -0,0 +1,79 @@ +# Legacy Sync Cleanup Gap Report + +## Scope audited + +- Runtime entry: `src/presentation/AnkiHeadingSyncPlugin.ts` +- Current manual-sync path: `ManualSyncService`, `ManualSyncCurrentFileUseCase`, `ManualSyncVaultUseCase`, `RebuildCardIndexUseCase` +- Legacy sync path: `ScanAndPlanSyncUseCase`, `ExecuteSyncPlanUseCase`, `SyncCurrentFileUseCase`, `SyncVaultUseCase`, `SyncRegistryRepository`, `DataJsonSyncRegistryRepository`, `HeadingSyncMarkerService`, `src/domain/sync/**`, `CardExtractionService` +- Mixed-use boundary: `DataJsonPluginConfigRepository`, `NoteFieldMappingService`, `domain/card` shared types + +## Findings + +### 1. Current production runtime is already manual-sync only + +- `src/presentation/AnkiHeadingSyncPlugin.ts` instantiates only: + - `ManualSyncService` + - `ManualSyncCurrentFileUseCase` + - `ManualSyncVaultUseCase` + - `RebuildCardIndexUseCase` + - management use cases built on `pluginState` +- No production initialization path constructs or invokes the legacy sync chain. + +### 2. Legacy sync chain is isolated from the current runtime + +- Production references to legacy use cases are confined to the legacy chain itself. +- `ScanAndPlanSyncUseCase` depends on `CardExtractionService`, `SyncPlanningService`, and `SyncRegistryRepository`. +- `ExecuteSyncPlanUseCase` depends on `HeadingSyncMarkerService`, `SyncRegistryRepository`, `SyncRegistry`, `SyncRecord`, and legacy sync result types. +- `SyncCurrentFileUseCase` and `SyncVaultUseCase` are thin wrappers over the two legacy use cases. +- Current manual-sync code does not import `src/domain/sync/**` or `DataJsonSyncRegistryRepository`. + +### 3. `syncRegistry` is legacy-only compatibility residue + +- `DataJsonPluginConfigRepository` still carries `PluginDataSnapshot.syncRegistry`, but the repository only loads and saves `settings`. +- Current runtime persistence uses `DataJsonPluginStateRepository` and `pluginState`; it does not consume `syncRegistry`. +- The remaining `syncRegistry` production references are inside the legacy chain and its repository. + +### 4. Legacy identity modes are not part of current manual-sync state + +- `embedded-note-id`, `legacy-card-key`, and `pending-note-id-write` are only present in legacy sync types, repository code, tests, and compatibility docs. +- Current manual-sync persistence uses `pluginState.cards`, `pluginState.files`, `groupBlocks`, and `pendingWriteBack`; it does not persist legacy identity modes. + +### 5. Shared card-domain types must stay + +- Confirmed current non-legacy usage exists for: + - `RenderedFields` + - `MediaAsset` + - `SourceFile` + - `SourceLocation` + - `CardType` + - `RenderResourceResolver` + - `CardKey` + - `ContentHash` +- These are used by current manual-sync services, vault/Anki gateways, rendering, write-back, and test support. + +### 6. `Card.ts` is only kept alive by legacy code plus one current type import + +- `NoteFieldMappingService` imports `Card` only for method signatures and `Card["type"]`. +- The service already has a smaller `RenderedCardInput` shape, so current code can be decoupled from `domain/card/entities/Card.ts`. +- After decoupling and legacy deletion, `Card.ts` can be removed if no remaining references exist. + +### 7. Legacy-only tests and docs remain + +- Legacy-only tests currently cover: + - `ExecuteSyncPlanUseCase` + - `SyncCurrentFileUseCase` + - `SyncVaultUseCase` + - `HeadingSyncMarkerService` + - `DataJsonSyncRegistryRepository` + - `src/domain/sync/**` + - `CardExtractionService` +- Several docs still describe the legacy chain as an implementation surface rather than archived context. + +## Cleanup implications + +- Safe direct deletion candidates: legacy use cases, legacy repository port/implementation, marker service, `src/domain/sync/**`, and legacy-only tests. +- Mixed files requiring edits before or during deletion: + - `src/infrastructure/persistence/DataJsonPluginConfigRepository.ts` + - `src/application/services/NoteFieldMappingService.ts` + - docs that still present the old chain as active +- Shared type modules under `src/domain/card` must be preserved unless a concrete post-cleanup search shows zero remaining references. \ No newline at end of file diff --git a/docs/module-2-gap-report.md b/docs/module-2-gap-report.md index 939ff54..f233caf 100644 --- a/docs/module-2-gap-report.md +++ b/docs/module-2-gap-report.md @@ -1,5 +1,9 @@ # Module 2 Gap Report +> Archived on 2026-04-21. +> This document describes the removed legacy syncRegistry-based sync chain. +> The active runtime path is now the ManualSyncService manual-sync pipeline. + ## Current State - Card extraction only derives legacy card identity from heading structure and source position. diff --git a/src/application/ports/SyncRegistryRepository.ts b/src/application/ports/SyncRegistryRepository.ts deleted file mode 100644 index 38119f2..0000000 --- a/src/application/ports/SyncRegistryRepository.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { SyncRegistry } from "@/domain/sync/entities/SyncRegistry"; - -export interface SyncRegistryRepository { - load(): Promise; - save(registry: SyncRegistry): Promise; -} \ No newline at end of file diff --git a/src/application/services/HeadingSyncMarkerService.test.ts b/src/application/services/HeadingSyncMarkerService.test.ts deleted file mode 100644 index 3a995fe..0000000 --- a/src/application/services/HeadingSyncMarkerService.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { createCardKey } from "@/domain/card/value-objects/CardKey"; -import { createContentHash } from "@/domain/card/value-objects/ContentHash"; - -import { HeadingSyncMarkerService, type MarkerWriteRequest } from "./HeadingSyncMarkerService"; - -describe("HeadingSyncMarkerService", () => { - const service = new HeadingSyncMarkerService(); - - 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: 4, - 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: 5, - contentEndLine: 2, - markerLine: 3, - headingLevel: 4, - headingText: "Prompt", - }, - 789, - ); - - expect(nextContent).toBe(["#### Prompt", "Answer", "", "", "", "### Next"].join("\n")); - }); - - it("applies multiple writes in one file from bottom to top", () => { - const sourceContent = [ - "#### Top", - "Top answer", - "", - "", - "#### Bottom", - "Bottom answer", - ].join("\n"); - - const writes: MarkerWriteRequest[] = [ - { - cardKey: createCardKey("top"), - filePath: "notes/example.md", - noteId: 111, - location: { - filePath: "notes/example.md", - sourceContent, - headingLine: 1, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 4, - contentEndLine: 2, - headingLevel: 4, - headingText: "Top", - }, - mode: "insert", - sourceHash: createContentHash("hash-top"), - }, - { - cardKey: createCardKey("bottom"), - filePath: "notes/example.md", - noteId: 222, - location: { - filePath: "notes/example.md", - sourceContent, - headingLine: 5, - blockStartLine: 5, - bodyStartLine: 6, - blockEndLine: 6, - contentEndLine: 6, - headingLevel: 4, - headingText: "Bottom", - }, - mode: "insert", - sourceHash: createContentHash("hash-bottom"), - }, - ]; - - expect(service.applyBatch(sourceContent, writes)).toBe([ - "#### Top", - "Top answer", - "", - "", - "", - "#### Bottom", - "Bottom answer", - "", - ].join("\n")); - }); - - it("can replace an old marker and insert a new one in the same batch", () => { - const sourceContent = [ - "#### First", - "Body 1", - "", - "", - "", - "#### Second", - "Body 2", - ].join("\n"); - - const nextContent = service.applyBatch(sourceContent, [ - { - cardKey: createCardKey("first"), - filePath: "notes/example.md", - noteId: 9001, - location: { - filePath: "notes/example.md", - sourceContent, - headingLine: 1, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 5, - contentEndLine: 2, - markerLine: 3, - headingLevel: 4, - headingText: "First", - }, - mode: "replace", - sourceHash: createContentHash("hash-first"), - }, - { - cardKey: createCardKey("second"), - filePath: "notes/example.md", - noteId: 9002, - location: { - filePath: "notes/example.md", - sourceContent, - headingLine: 6, - blockStartLine: 6, - bodyStartLine: 7, - blockEndLine: 7, - contentEndLine: 7, - headingLevel: 4, - headingText: "Second", - }, - mode: "insert", - sourceHash: createContentHash("hash-second"), - }, - ]); - - expect(nextContent).toBe([ - "#### First", - "Body 1", - "", - "", - "", - "#### Second", - "Body 2", - "", - ].join("\n")); - }); -}); diff --git a/src/application/services/HeadingSyncMarkerService.ts b/src/application/services/HeadingSyncMarkerService.ts deleted file mode 100644 index b85980b..0000000 --- a/src/application/services/HeadingSyncMarkerService.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { ContentHash } from "@/domain/card/value-objects/ContentHash"; -import type { CardKey } from "@/domain/card/value-objects/CardKey"; -import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation"; - -export interface HeadingSyncMarker { - noteId: number; - raw: string; - lineIndex: number; -} - -export interface MarkerWriteRequest { - cardKey: CardKey; - filePath: string; - noteId: number; - location: SourceLocation; - mode: "insert" | "replace"; - sourceHash: ContentHash; -} - -export class HeadingSyncMarkerBatchError extends Error { - constructor(message: string) { - super(message); - this.name = "HeadingSyncMarkerBatchError"; - } -} - -export class HeadingSyncMarkerService { - apply(location: SourceLocation, noteId: number): string { - if (!location.sourceContent) { - throw new Error(`Missing scanned source content for ${location.filePath}.`); - } - - return this.applyBatch(location.sourceContent, [ - { - cardKey: "single-write" as CardKey, - filePath: location.filePath, - noteId, - location, - mode: location.markerLine ? "replace" : "insert", - sourceHash: "single-write" as ContentHash, - }, - ]); - } - - applyBatch(sourceContent: string, writes: MarkerWriteRequest[]): string { - if (writes.length === 0) { - return sourceContent; - } - - const filePath = writes[0]?.filePath; - const seenBlocks = new Set(); - - for (const write of writes) { - if (write.filePath !== filePath) { - throw new HeadingSyncMarkerBatchError("Batch marker writes must belong to the same Markdown file."); - } - - if (write.location.filePath !== write.filePath) { - throw new HeadingSyncMarkerBatchError(`Marker write path mismatch for ${write.filePath}.`); - } - - if (write.location.sourceContent !== sourceContent) { - throw new HeadingSyncMarkerBatchError(`Marker writes for ${write.filePath} must share the scanned source content.`); - } - - if (write.mode === "replace" && !write.location.markerLine) { - throw new HeadingSyncMarkerBatchError(`Cannot replace a missing AHS marker in ${write.filePath}.`); - } - - const blockKey = write.location.blockStartLine; - if (seenBlocks.has(blockKey)) { - throw new HeadingSyncMarkerBatchError(`Duplicate marker write detected for block ${blockKey} in ${write.filePath}.`); - } - - seenBlocks.add(blockKey); - } - - const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n"; - const nextLines = sourceContent.split(/\r?\n/); - - const sortedWrites = [...writes].sort((left, right) => right.location.blockStartLine - left.location.blockStartLine); - for (const write of sortedWrites) { - this.applyToLines(nextLines, write.location, write.noteId); - } - - return nextLines.join(lineEnding); - } - - applyToLines(lines: string[], location: SourceLocation, noteId: number): void { - const adjustedBlockEndLine = location.markerLine ? location.blockEndLine - 1 : location.blockEndLine; - - if (location.contentEndLine < location.headingLine || location.contentEndLine > adjustedBlockEndLine) { - throw new HeadingSyncMarkerBatchError(`Cannot write AHS marker outside the heading block in ${location.filePath}.`); - } - - const removalIndexes = new Set(); - if (location.markerLine) { - removalIndexes.add(location.markerLine - 1); - } - - for (let lineIndex = location.contentEndLine; lineIndex < location.blockEndLine; lineIndex += 1) { - if (!(lines[lineIndex] ?? "").trim()) { - removalIndexes.add(lineIndex); - } - } - - const sortedRemovals = [...removalIndexes].sort((left, right) => right - left); - const insertionIndex = location.contentEndLine - [...removalIndexes].filter((lineIndex) => lineIndex < location.contentEndLine).length; - - for (const lineIndex of sortedRemovals) { - lines.splice(lineIndex, 1); - } - - lines.splice(insertionIndex, 0, this.create(noteId).raw); - while (insertionIndex + 1 < lines.length && !(lines[insertionIndex + 1] ?? "").trim()) { - lines.splice(insertionIndex + 1, 1); - } - - if (insertionIndex + 1 < lines.length) { - lines.splice(insertionIndex + 1, 0, "", ""); - } - } - - create(noteId: number): HeadingSyncMarker { - return { - noteId, - raw: ``, - lineIndex: -1, - }; - } -} diff --git a/src/application/services/NoteFieldMappingService.test.ts b/src/application/services/NoteFieldMappingService.test.ts index 5446e11..d865d3a 100644 --- a/src/application/services/NoteFieldMappingService.test.ts +++ b/src/application/services/NoteFieldMappingService.test.ts @@ -1,43 +1,27 @@ import { describe, expect, it } from "vitest"; import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping"; -import type { Card } from "@/domain/card/entities/Card"; -import { createCardKey } from "@/domain/card/value-objects/CardKey"; -import { createContentHash } from "@/domain/card/value-objects/ContentHash"; -import { createDeckName } from "@/domain/card/value-objects/DeckName"; -import { createNoteModelName } from "@/domain/card/value-objects/NoteModelName"; +import type { CardType } from "@/domain/card/entities/RenderedFields"; import { NoteFieldMappingService } from "./NoteFieldMappingService"; -function createCard(overrides: Partial): Card { +interface RenderedCardInput { + type: CardType; + noteModel: string; + renderedFields: { + title: string; + body: string; + }; +} + +function createCard(overrides: Partial): RenderedCardInput { return { - key: createCardKey("card-key"), - source: { - filePath: "notes/example.md", - headingLine: 1, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 3, - contentEndLine: 2, - headingLevel: 4, - headingText: "Prompt", - }, type: "basic", - heading: "Prompt", - bodyMarkdown: "Answer", - deck: createDeckName("Deck"), - noteModel: createNoteModelName("Basic"), - tags: [], + noteModel: "Basic", renderedFields: { title: "Prompt", body: "Answer", }, - fields: { - title: "Prompt", - body: "Answer", - }, - contentHash: createContentHash("hash"), - media: [], ...overrides, }; } @@ -72,15 +56,11 @@ describe("NoteFieldMappingService", () => { const fields = service.map( createCard({ type: "cloze", - noteModel: createNoteModelName("Cloze"), + noteModel: "Cloze", renderedFields: { title: "Context", body: "{{c1::answer}}", }, - fields: { - title: "Context", - body: "{{c1::answer}}", - }, }), { fieldNames: ["Text", "Extra"], diff --git a/src/application/services/NoteFieldMappingService.ts b/src/application/services/NoteFieldMappingService.ts index 2c95a4f..c2950ce 100644 --- a/src/application/services/NoteFieldMappingService.ts +++ b/src/application/services/NoteFieldMappingService.ts @@ -1,4 +1,3 @@ -import type { Card } from "@/domain/card/entities/Card"; import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping"; import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; import { isBasicLikeCardType, type CardType, type RenderedFields } from "@/domain/card/entities/RenderedFields"; @@ -11,7 +10,7 @@ interface RenderedCardInput { export class NoteFieldMappingService { map( - card: Card, + card: RenderedCardInput, noteModelDetails: NoteModelDetails, noteFieldMappings: Record, ): Record { @@ -33,7 +32,7 @@ export class NoteFieldMappingService { return this.mapCloze(card, noteModelDetails, mapping); } - suggest(cardType: Card["type"], modelName: string, fieldNames: string[], loadedAt = Date.now()): NoteModelFieldMapping { + suggest(cardType: CardType, modelName: string, fieldNames: string[], loadedAt = Date.now()): NoteModelFieldMapping { return isBasicLikeCardType(cardType) ? { cardType, diff --git a/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts b/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts deleted file mode 100644 index b5d4f5c..0000000 --- a/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts +++ /dev/null @@ -1,985 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping"; -import type { AnkiGateway, AnkiNoteSummary } from "@/application/ports/AnkiGateway"; -import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository"; -import { MarkdownWriteConflictError, 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"; -import { createDeckName } from "@/domain/card/value-objects/DeckName"; -import { createNoteModelName } from "@/domain/card/value-objects/NoteModelName"; -import { SyncRegistry } from "@/domain/sync/entities/SyncRegistry"; - -import { ExecuteSyncPlanUseCase } from "./ExecuteSyncPlanUseCase"; -import type { ScanAndPlanResult } from "./types"; - -class InMemorySyncRegistryRepository implements SyncRegistryRepository { - public savedRegistry: SyncRegistry | null = null; - - constructor(private readonly registry = new SyncRegistry()) {} - - async load(): Promise { - return this.registry; - } - - async save(registry: SyncRegistry): Promise { - this.savedRegistry = registry; - } -} - -class FakeVaultGateway implements VaultGateway { - public readonly files = new Map(); - public failReplace = false; - public replaceError: Error | null = null; - public replaceCalls: Array<{ path: string; expectedContent: string; nextContent: string }> = []; - - 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 { - this.replaceCalls.push({ path, expectedContent, nextContent }); - - if (this.replaceError) { - throw this.replaceError; - } - - if (this.failReplace) { - throw new MarkdownWriteConflictError(path); - } - - const currentContent = this.files.get(path); - if (currentContent !== expectedContent) { - throw new MarkdownWriteConflictError(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); - } - - async ensureDecks(deckNames: string[]): Promise { - this.ensuredDecks.push(...deckNames); - } - - async listNoteModels(): Promise { - return Object.keys(this.modelDetailsByName); - } - - async listDeckNames(): Promise { - return []; - } - - async getModelDetails(modelName: string) { - return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false }; - } - - async getDeckStats(): Promise> { - return []; - } - - 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); - this.nextAddedNoteId += 1; - return this.nextAddedNoteId; - } - - async addNotes(inputs: Array<{ deckName: string; modelName: string; fields: Record; tags: string[] }>): Promise { - return Promise.all(inputs.map((input) => this.addNote(input))); - } - - async deleteNotes(): Promise {} - - async updateNote(input: { noteId: number; deckName: string; fields: Record }): Promise { - this.updatedNotes.push(input); - } - - async updateNotes(inputs: Array<{ noteId: number; deckName: string; fields: Record }>): Promise { - for (const input of inputs) { - await this.updateNote(input); - } - } - - async changeDecks(): Promise {} - - async deleteDecks(): Promise {} - - async storeMedia(asset: { fileName: string }): Promise { - this.storedMedia.push(asset.fileName); - } - - async storeMediaFiles(assets: Array<{ fileName: string }>): Promise { - for (const asset of assets) { - await this.storeMedia(asset); - } - } -} - -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: 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: [], - renderedFields: { - title: "Prompt", - body: "Answer", - }, - fields: { - title: "Prompt", - body: "Answer", - }, - contentHash: createContentHash("hash-1"), - media: [], - ...overrides, - }; -} - -function createMappings() { - return { - [createNoteFieldMappingKey("basic", "Basic")]: { - cardType: "basic" as const, - modelName: "Basic", - loadedFieldNames: ["Front", "Back"], - titleField: "Front", - bodyField: "Back", - loadedAt: 1, - }, - [createNoteFieldMappingKey("cloze", "Cloze")]: { - cardType: "cloze" as const, - modelName: "Cloze", - loadedFieldNames: ["Text", "Extra"], - mainField: "Text", - loadedAt: 1, - }, - }; -} - -function createResult(overrides: Partial = {}): ScanAndPlanResult { - return { - cards: [], - noteFieldMappings: createMappings(), - registry: new SyncRegistry(), - plan: { - toCreateDecks: [createDeckName("Deck")], - toAdd: [], - toUpdate: [], - toMarkOrphan: [], - }, - scopedFilePaths: ["notes/current.md"], - ...overrides, - }; -} - -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: [card], - toUpdate: [], - toMarkOrphan: [], - }, - })); - - 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("writes multiple newly created cards in the same file with a single replace call", async () => { - const sourceContent = ["###### 试验的卡片12", "卡片1", "", "###### 试验233", "卡片2"].join("\n"); - const vaultGateway = new FakeVaultGateway({ - "notes/current.md": sourceContent, - }); - const ankiGateway = new FakeAnkiGateway(); - const repository = new InMemorySyncRegistryRepository(); - const firstCard = createCard({ - key: createCardKey("card-1"), - heading: "试验的卡片12", - bodyMarkdown: "卡片1", - renderedFields: { title: "试验的卡片12", body: "卡片1" }, - fields: { title: "试验的卡片12", body: "卡片1" }, - contentHash: createContentHash("hash-card-1"), - source: { - filePath: "notes/current.md", - sourceContent, - headingLine: 1, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 3, - contentEndLine: 2, - headingLevel: 6, - headingText: "试验的卡片12", - }, - }); - const secondCard = createCard({ - key: createCardKey("card-2"), - heading: "试验233", - bodyMarkdown: "卡片2", - renderedFields: { title: "试验233", body: "卡片2" }, - fields: { title: "试验233", body: "卡片2" }, - contentHash: createContentHash("hash-card-2"), - source: { - filePath: "notes/current.md", - sourceContent, - headingLine: 4, - blockStartLine: 4, - bodyStartLine: 5, - blockEndLine: 5, - contentEndLine: 5, - headingLevel: 6, - headingText: "试验233", - }, - }); - const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234); - - await useCase.execute(createResult({ - cards: [firstCard, secondCard], - plan: { - toCreateDecks: [createDeckName("Deck")], - toAdd: [firstCard, secondCard], - toUpdate: [], - toMarkOrphan: [], - }, - })); - - expect(ankiGateway.addedNotes).toHaveLength(2); - expect(vaultGateway.replaceCalls).toHaveLength(1); - expect(vaultGateway.files.get("notes/current.md")).toBe([ - "###### 试验的卡片12", - "卡片1", - "", - "", - "###### 试验233", - "卡片2", - "", - ].join("\n")); - expect(repository.savedRegistry?.findByNoteId(9001)).toMatchObject({ identityMode: "embedded-note-id" }); - expect(repository.savedRegistry?.findByNoteId(9002)).toMatchObject({ identityMode: "embedded-note-id" }); - expect(repository.savedRegistry?.list().some((record) => record.identityMode === "pending-note-id-write")).toBe(false); - }); - - it("marks all same-file writes pending when the batch replace fails", async () => { - const sourceContent = ["###### 卡片1", "正文1", "", "###### 卡片2", "正文2"].join("\n"); - const vaultGateway = new FakeVaultGateway({ "notes/current.md": sourceContent }); - vaultGateway.failReplace = true; - const ankiGateway = new FakeAnkiGateway(); - const repository = new InMemorySyncRegistryRepository(); - const firstCard = createCard({ - key: createCardKey("batch-fail-1"), - contentHash: createContentHash("hash-batch-fail-1"), - source: { - filePath: "notes/current.md", - sourceContent, - headingLine: 1, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 3, - contentEndLine: 2, - headingLevel: 6, - headingText: "卡片1", - }, - }); - const secondCard = createCard({ - key: createCardKey("batch-fail-2"), - contentHash: createContentHash("hash-batch-fail-2"), - source: { - filePath: "notes/current.md", - sourceContent, - headingLine: 4, - blockStartLine: 4, - bodyStartLine: 5, - blockEndLine: 5, - contentEndLine: 5, - headingLevel: 6, - headingText: "卡片2", - }, - }); - const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234); - - await useCase.execute(createResult({ - cards: [firstCard, secondCard], - plan: { - toCreateDecks: [createDeckName("Deck")], - toAdd: [firstCard, secondCard], - toUpdate: [], - toMarkOrphan: [], - }, - })); - - expect(ankiGateway.addedNotes).toHaveLength(2); - expect(vaultGateway.files.get("notes/current.md")).toBe(sourceContent); - expect(repository.savedRegistry?.get(firstCard.key)).toMatchObject({ identityMode: "pending-note-id-write", noteId: 9001 }); - expect(repository.savedRegistry?.get(secondCard.key)).toMatchObject({ identityMode: "pending-note-id-write", noteId: 9002 }); - }); - - it("persists pending records before surfacing applyBatch validation failures", async () => { - const sourceContent = ["###### 卡片1", "正文1"].join("\n"); - const vaultGateway = new FakeVaultGateway({ "notes/current.md": sourceContent }); - const ankiGateway = new FakeAnkiGateway(); - const repository = new InMemorySyncRegistryRepository(); - const firstCard = createCard({ - key: createCardKey("duplicate-block-1"), - contentHash: createContentHash("hash-duplicate-1"), - source: { - filePath: "notes/current.md", - sourceContent, - headingLine: 1, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 2, - contentEndLine: 2, - headingLevel: 6, - headingText: "卡片1", - }, - }); - const secondCard = createCard({ - key: createCardKey("duplicate-block-2"), - contentHash: createContentHash("hash-duplicate-2"), - source: { - filePath: "notes/current.md", - sourceContent, - headingLine: 1, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 2, - contentEndLine: 2, - headingLevel: 6, - headingText: "卡片1", - }, - }); - const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234); - - await expect(useCase.execute(createResult({ - cards: [firstCard, secondCard], - plan: { - toCreateDecks: [createDeckName("Deck")], - toAdd: [firstCard, secondCard], - toUpdate: [], - toMarkOrphan: [], - }, - }))).rejects.toThrow("Duplicate marker write detected"); - - expect(ankiGateway.addedNotes).toHaveLength(2); - expect(vaultGateway.replaceCalls).toHaveLength(0); - expect(repository.savedRegistry?.get(firstCard.key)).toMatchObject({ identityMode: "pending-note-id-write", noteId: 9001 }); - expect(repository.savedRegistry?.get(secondCard.key)).toMatchObject({ identityMode: "pending-note-id-write", noteId: 9002 }); - }); - - it("persists pending records and surfaces unexpected file write errors", async () => { - const vaultGateway = new FakeVaultGateway({ - "notes/current.md": ["#### Prompt", "Answer"].join("\n"), - }); - vaultGateway.replaceError = new Error("permission denied"); - const ankiGateway = new FakeAnkiGateway(); - const repository = new InMemorySyncRegistryRepository(); - const card = createCard(); - const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234); - - await expect(useCase.execute(createResult({ - cards: [card], - plan: { - toCreateDecks: [createDeckName("Deck")], - toAdd: [card], - toUpdate: [], - toMarkOrphan: [], - }, - }))).rejects.toThrow("permission denied"); - - expect(repository.savedRegistry?.get(card.key)).toMatchObject({ - identityMode: "pending-note-id-write", - noteId: 9001, - }); - }); - - it("flushes recreate-and-insert writes for the same file together", async () => { - const sourceContent = ["###### 卡片1", "正文1", "", "", "###### 卡片2", "正文2"].join("\n"); - const vaultGateway = new FakeVaultGateway({ "notes/current.md": sourceContent }); - const ankiGateway = new FakeAnkiGateway(); - const repository = new InMemorySyncRegistryRepository(); - const recreatedCard = createCard({ - key: createCardKey("recreate-card"), - embeddedNoteId: 42, - contentHash: createContentHash("hash-recreate"), - source: { - filePath: "notes/current.md", - sourceContent, - headingLine: 1, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 3, - contentEndLine: 2, - markerLine: 3, - headingLevel: 6, - headingText: "卡片1", - }, - }); - const newCard = createCard({ - key: createCardKey("new-card"), - contentHash: createContentHash("hash-new-card"), - source: { - filePath: "notes/current.md", - sourceContent, - headingLine: 5, - blockStartLine: 5, - bodyStartLine: 6, - blockEndLine: 6, - contentEndLine: 6, - headingLevel: 6, - headingText: "卡片2", - }, - }); - const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234); - - await useCase.execute(createResult({ - cards: [recreatedCard, newCard], - plan: { - toCreateDecks: [createDeckName("Deck")], - toAdd: [newCard], - toUpdate: [{ card: recreatedCard, noteId: 42 }], - toMarkOrphan: [], - }, - })); - - expect(vaultGateway.replaceCalls).toHaveLength(1); - expect(vaultGateway.files.get("notes/current.md")).toBe([ - "###### 卡片1", - "正文1", - "", - "", - "###### 卡片2", - "正文2", - "", - ].join("\n")); - }); - - it.each([ - { - name: "heading change", - 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", cardIds: [1] }); - 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", cardIds: [1] }); - 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", cardIds: [1] }); - 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", cardIds: [1] }); - 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(repository.savedRegistry?.get(createCardKey("orphan-card"))?.orphan).toBe(true); - }); - - 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 card = createCard(); - const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234); - - await expect( - useCase.execute({ - ...createResult(), - cards: [card], - noteFieldMappings: {}, - plan: { - toCreateDecks: [createDeckName("Deck")], - toAdd: [card], - toUpdate: [], - toMarkOrphan: [], - }, - }), - ).rejects.toThrow("Open plugin settings and read fields from Anki first"); - }); - - 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 card = createCard(); - const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234); - - await expect( - useCase.execute({ - ...createResult(), - cards: [card], - plan: { - toCreateDecks: [createDeckName("Deck")], - toAdd: [card], - toUpdate: [], - toMarkOrphan: [], - }, - }), - ).rejects.toThrow("is stale because these fields no longer exist in Anki"); - }); -}); \ No newline at end of file diff --git a/src/application/use-cases/ExecuteSyncPlanUseCase.ts b/src/application/use-cases/ExecuteSyncPlanUseCase.ts deleted file mode 100644 index 949b724..0000000 --- a/src/application/use-cases/ExecuteSyncPlanUseCase.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { - HeadingSyncMarkerBatchError, - HeadingSyncMarkerService, - type MarkerWriteRequest, -} from "@/application/services/HeadingSyncMarkerService"; -import type { AnkiGateway } from "@/application/ports/AnkiGateway"; -import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository"; -import { MarkdownWriteConflictError, 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"; - -interface MarkerWriteFailure { - filePath: string; - message: string; -} - -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(); - const markerWrites: MarkerWriteRequest[] = []; - - for (const card of scanAndPlanResult.cards) { - const modelDetails = await this.getModelDetails(modelDetailsCache, card.noteModel); - this.noteFieldMappingService.map(card, modelDetails, scanAndPlanResult.noteFieldMappings); - } - - const syncCards = [ - ...scanAndPlanResult.plan.toAdd, - ...scanAndPlanResult.plan.toUpdate.map((entry) => entry.card), - ]; - - for (const deckName of scanAndPlanResult.plan.toCreateDecks) { - await this.ankiGateway.ensureDeckExists(deckName); - } - - const uploadedMedia = await this.uploadMedia(syncCards); - - for (const card of scanAndPlanResult.plan.toAdd) { - const noteId = await this.addNote(card, modelDetailsCache, scanAndPlanResult); - markerWrites.push(this.createMarkerWriteRequest(card, noteId, "insert")); - } - - for (const entry of scanAndPlanResult.plan.toUpdate) { - const existingRecord = entry.card.embeddedNoteId - ? syncRegistry.findByNoteId(entry.noteId) - : syncRegistry.get(entry.card.key); - - if (entry.card.embeddedNoteId) { - const markerWrite = await this.updateEmbeddedCard( - entry.card, - entry.noteId, - noteSummaries.get(entry.noteId), - syncRegistry, - timestamp, - modelDetailsCache, - scanAndPlanResult, - ); - if (markerWrite) { - markerWrites.push(markerWrite); - } - continue; - } - - if (existingRecord?.identityMode === "pending-note-id-write") { - markerWrites.push( - await this.retryPendingMarkerWrite( - entry.card, - existingRecord, - noteSummaries.get(entry.noteId), - modelDetailsCache, - scanAndPlanResult, - ), - ); - continue; - } - - await this.updateLegacyCard(entry.card, entry.noteId, existingRecord, syncRegistry, timestamp, modelDetailsCache, scanAndPlanResult); - } - - const markerWriteFailures = await this.flushMarkerWrites(markerWrites, syncRegistry, timestamp); - - const mutatedCardKeys = new Set(syncCards.map((card) => card.key)); - for (const card of scanAndPlanResult.cards) { - if (mutatedCardKeys.has(card.key)) { - continue; - } - - 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.recordSync({ - ...existingRecord, - cardKey: card.key, - filePath: card.source.filePath, - sourceHash: card.contentHash, - lastSyncedAt: timestamp, - orphan: false, - }); - } - - for (const orphanRecord of scanAndPlanResult.plan.toMarkOrphan) { - syncRegistry.markOrphan(orphanRecord.cardKey, timestamp); - } - - await this.syncRegistryRepository.save(syncRegistry); - - if (markerWriteFailures.length > 0) { - throw this.createMarkerWriteFailureError(markerWriteFailures); - } - - return { - created: scanAndPlanResult.plan.toAdd.length, - updated: scanAndPlanResult.plan.toUpdate.length, - markedOrphan: scanAndPlanResult.plan.toMarkOrphan.length, - uploadedMedia, - scanned: scanAndPlanResult.cards.length, - unchanged: - scanAndPlanResult.cards.length - - scanAndPlanResult.plan.toAdd.length - - scanAndPlanResult.plan.toUpdate.length, - }; - } - - - 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); - return this.createMarkerWriteRequest(card, recreatedNoteId, "replace"); - } - - this.assertModelMatches(card, noteSummary.modelName, noteId); - await this.updateExistingNote(card, noteId, modelDetailsCache, scanAndPlanResult); - syncRegistry.recordSync(this.createEmbeddedRecord(card, noteId, timestamp)); - return undefined; - } - - private async retryPendingMarkerWrite( - card: Card, - existingRecord: SyncRecord, - noteSummary: Awaited>[number] | undefined, - 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); - } - - return this.createMarkerWriteRequest(card, noteId, card.source.markerLine ? "replace" : "insert"); - } - - 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 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 createEmbeddedRecordFromWrite(write: MarkerWriteRequest, timestamp: number): SyncRecord { - return { - cardKey: write.cardKey, - identityMode: "embedded-note-id", - noteId: write.noteId, - filePath: write.filePath, - sourceHash: write.sourceHash, - lastSyncedAt: timestamp, - orphan: false, - }; - } - - private createPendingRecord(card: Card, noteId: number, timestamp: number): SyncRecord { - return { - cardKey: card.key, - identityMode: "pending-note-id-write", - legacyCardKey: card.key, - noteId, - filePath: card.source.filePath, - sourceHash: card.contentHash, - lastSyncedAt: timestamp, - orphan: false, - }; - } - - private createPendingRecordFromWrite(write: MarkerWriteRequest, timestamp: number): SyncRecord { - return { - cardKey: write.cardKey, - identityMode: "pending-note-id-write", - legacyCardKey: write.cardKey, - noteId: write.noteId, - filePath: write.filePath, - sourceHash: write.sourceHash, - lastSyncedAt: timestamp, - orphan: false, - }; - } - - private async addNote( - card: Card, - modelDetailsCache: Map>>, - 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 createMarkerWriteRequest(card: Card, noteId: number, mode: MarkerWriteRequest["mode"]): MarkerWriteRequest { - return { - cardKey: card.key, - filePath: card.source.filePath, - noteId, - location: card.source, - mode, - sourceHash: card.contentHash, - }; - } - - private async flushMarkerWrites(writes: MarkerWriteRequest[], syncRegistry: SyncRegistry, timestamp: number): Promise { - const failures: MarkerWriteFailure[] = []; - const writesByFile = new Map(); - - for (const write of writes) { - const fileWrites = writesByFile.get(write.filePath); - if (fileWrites) { - fileWrites.push(write); - continue; - } - - writesByFile.set(write.filePath, [write]); - } - - for (const [filePath, fileWrites] of writesByFile.entries()) { - try { - const sourceContent = this.requireSourceContent(filePath, fileWrites); - const nextContent = this.headingSyncMarkerService.applyBatch(sourceContent, fileWrites); - await this.vaultGateway.replaceMarkdownFile(filePath, sourceContent, nextContent); - - for (const write of fileWrites) { - syncRegistry.recordSync(this.createEmbeddedRecordFromWrite(write, timestamp)); - } - } catch (error) { - for (const write of fileWrites) { - syncRegistry.recordSync(this.createPendingRecordFromWrite(write, timestamp)); - } - - if (!this.isRecoverableMarkerWriteError(error)) { - failures.push({ - filePath, - message: error instanceof Error ? error.message : String(error), - }); - } - } - } - - return failures; - } - - private requireSourceContent(filePath: string, writes: MarkerWriteRequest[]): string { - const sourceContent = writes[0]?.location.sourceContent; - if (!sourceContent) { - throw new HeadingSyncMarkerBatchError(`Missing scanned source content for ${filePath}.`); - } - - return sourceContent; - } - - private isRecoverableMarkerWriteError(error: unknown): boolean { - return error instanceof MarkdownWriteConflictError; - } - - private createMarkerWriteFailureError(failures: MarkerWriteFailure[]): Error { - const details = failures.map((failure) => `${failure.filePath}: ${failure.message}`).join("\n"); - return new Error(`Failed to persist AHS markers for ${failures.length} file(s).\n${details}`); - } - - 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, - ): Promise>> { - const cached = modelDetailsCache.get(modelName); - if (cached) { - return cached; - } - - const resolved = await this.ankiGateway.getModelDetails(modelName); - modelDetailsCache.set(modelName, resolved); - return resolved; - } - - private async uploadMedia(cards: ScanAndPlanResult["cards"]): Promise { - const uniqueMedia = new Map(); - - for (const card of cards) { - for (const asset of card.media) { - uniqueMedia.set(`${asset.kind}:${asset.absolutePath}:${asset.fileName}`, asset); - } - } - - for (const asset of uniqueMedia.values()) { - await this.ankiGateway.storeMedia(asset); - } - - return uniqueMedia.size; - } -} \ No newline at end of file diff --git a/src/application/use-cases/ScanAndPlanSyncUseCase.ts b/src/application/use-cases/ScanAndPlanSyncUseCase.ts deleted file mode 100644 index bb7a2ca..0000000 --- a/src/application/use-cases/ScanAndPlanSyncUseCase.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { PluginSettings } from "@/application/config/PluginSettings"; -import { validatePluginSettings } from "@/application/config/PluginSettings"; -import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository"; -import type { VaultGateway } from "@/application/ports/VaultGateway"; -import { ScanScopeService } from "@/application/services/ScanScopeService"; -import { CardExtractionService } from "@/domain/card/services/CardExtractionService"; -import { CardRenderingService } from "@/domain/card/services/CardRenderingService"; -import { SyncPlanningService } from "@/domain/sync/services/SyncPlanningService"; - -import type { ScanAndPlanResult } from "./types"; - -export class ScanAndPlanSyncUseCase { - constructor( - private readonly vaultGateway: VaultGateway, - private readonly syncRegistryRepository: SyncRegistryRepository, - private readonly scanScopeService = new ScanScopeService(), - private readonly cardExtractionService = new CardExtractionService(), - private readonly cardRenderingService = new CardRenderingService(), - private readonly syncPlanningService = new SyncPlanningService(), - ) {} - - async executeForVault(settings: PluginSettings): Promise { - validatePluginSettings(settings); - - const markdownFiles = await this.vaultGateway.listMarkdownFiles(); - const scopedFiles = this.scanScopeService.filter(markdownFiles, settings.scopeMode, settings.includeFolders, settings.excludeFolders); - - return this.scanFiles(scopedFiles, settings); - } - - async executeForFile(filePath: string, settings: PluginSettings): Promise { - validatePluginSettings(settings); - - const sourceFile = await this.vaultGateway.getMarkdownFile(filePath); - if (!sourceFile) { - throw new Error(`Markdown file not found: ${filePath}`); - } - - return this.scanFiles([sourceFile], settings); - } - - private async scanFiles(files: Awaited>, settings: PluginSettings): Promise { - const cards = files.flatMap((file) => - this.cardExtractionService - .extract(file, { - qaHeadingLevel: settings.qaHeadingLevel, - clozeHeadingLevel: settings.clozeHeadingLevel, - }) - .map((draft) => - this.cardRenderingService.render(draft, { - defaultDeck: settings.defaultDeck, - qaNoteType: settings.qaNoteType, - clozeNoteType: settings.clozeNoteType, - addObsidianBacklink: settings.addObsidianBacklink, - convertHighlightsToCloze: settings.convertHighlightsToCloze, - resourceResolver: this.vaultGateway, - }), - ), - ); - - const registry = await this.syncRegistryRepository.load(); - const scopedFilePaths = files.map((file) => file.path); - const plan = this.syncPlanningService.plan(cards, registry, scopedFilePaths); - - return { - cards, - noteFieldMappings: settings.noteFieldMappings, - registry, - plan, - scopedFilePaths, - }; - } -} \ No newline at end of file diff --git a/src/application/use-cases/SyncCurrentFileUseCase.test.ts b/src/application/use-cases/SyncCurrentFileUseCase.test.ts deleted file mode 100644 index 1d8b770..0000000 --- a/src/application/use-cases/SyncCurrentFileUseCase.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping"; -import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings"; -import type { AnkiGateway } from "@/application/ports/AnkiGateway"; -import type { PluginDataStore } from "@/application/ports/PluginDataStore"; -import type { VaultGateway } from "@/application/ports/VaultGateway"; -import { createCardKey } from "@/domain/card/value-objects/CardKey"; -import { createContentHash } from "@/domain/card/value-objects/ContentHash"; -import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation"; -import { DataJsonSyncRegistryRepository } from "@/infrastructure/persistence/DataJsonSyncRegistryRepository"; -import type { PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository"; - -import { ExecuteSyncPlanUseCase } from "./ExecuteSyncPlanUseCase"; -import { ScanAndPlanSyncUseCase } from "./ScanAndPlanSyncUseCase"; -import { SyncCurrentFileUseCase } from "./SyncCurrentFileUseCase"; - -class InMemoryPluginDataStore implements PluginDataStore { - constructor(private snapshot: PluginDataSnapshot | null = null) {} - - async load(): Promise { - return this.snapshot; - } - - async save(data: PluginDataSnapshot): Promise { - this.snapshot = data; - } - - readSnapshot(): PluginDataSnapshot | null { - return this.snapshot; - } -} - -class FakeVaultGateway implements VaultGateway { - constructor(private readonly files: Array<{ path: string; content: string }>) {} - - async listMarkdownFiles() { - return this.files.map((file) => ({ - path: file.path, - basename: file.path.split("/").pop()?.replace(/\.md$/i, "") ?? file.path, - content: file.content, - })); - } - - async getMarkdownFile(path: string) { - const file = this.files.find((candidate) => candidate.path === path); - if (!file) { - return null; - } - - return { - path: file.path, - basename: file.path.split("/").pop()?.replace(/\.md$/i, "") ?? file.path, - content: file.content, - }; - } - - 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 }; - } - - resolveEmbed(rawTarget: string) { - if (rawTarget.includes(".png")) { - return { kind: "image" as const, fileName: "diagram.png", absolutePath: "/vault/diagram.png" }; - } - - return null; - } - - createBacklink(location: SourceLocation) { - return `obsidian://open?vault=Vault&file=${encodeURIComponent(location.filePath)}`; - } -} - -class FakeAnkiGateway implements AnkiGateway { - public readonly addCalls: Array<{ deckName: string; modelName: string; fields: Record }> = []; - public readonly updateCalls: Array<{ noteId: number; deckName: string; fields: Record }> = []; - public readonly ensureDeckCalls: string[] = []; - public readonly storedMedia: string[] = []; - public deleteCalls = 0; - - async ensureDeckExists(deckName: string): Promise { - this.ensureDeckCalls.push(deckName); - } - - async ensureDecks(deckNames: string[]): Promise { - this.ensureDeckCalls.push(...deckNames); - } - - async listNoteModels(): Promise { - return ["Basic", "Cloze"]; - } - - async listDeckNames(): Promise { - return []; - } - - async getModelDetails(modelName: string) { - return modelName === "Cloze" - ? { fieldNames: ["Text", "Extra"], isCloze: true } - : { fieldNames: ["Front", "Back"], isCloze: false }; - } - - async getNoteSummaries(): Promise> { - return []; - } - - async getDeckStats(): Promise> { - return []; - } - - async addNote(input: { deckName: string; modelName: string; fields: Record }): Promise { - this.addCalls.push(input); - return 500 + this.addCalls.length; - } - - async addNotes(inputs: Array<{ deckName: string; modelName: string; fields: Record; tags: string[] }>): Promise { - return Promise.all(inputs.map((input) => this.addNote(input))); - } - - async deleteNotes(): Promise {} - - async updateNote(input: { noteId: number; deckName: string; fields: Record }): Promise { - this.updateCalls.push(input); - } - - async updateNotes(inputs: Array<{ noteId: number; deckName: string; fields: Record }>): Promise { - for (const input of inputs) { - await this.updateNote(input); - } - } - - async changeDecks(): Promise {} - - async deleteDecks(): Promise {} - - async storeMedia(asset: { fileName: string }): Promise { - this.storedMedia.push(asset.fileName); - } - - async storeMediaFiles(assets: Array<{ fileName: string }>): Promise { - for (const asset of assets) { - await this.storeMedia(asset); - } - } -} - -function createSettings(overrides: Partial = {}): PluginSettings { - return { - ...DEFAULT_SETTINGS, - addObsidianBacklink: false, - noteFieldMappings: { - [createNoteFieldMappingKey("basic", "Basic")]: { - cardType: "basic", - modelName: "Basic", - loadedFieldNames: ["Front", "Back"], - titleField: "Front", - bodyField: "Back", - loadedAt: 1, - }, - [createNoteFieldMappingKey("cloze", "Cloze")]: { - cardType: "cloze", - modelName: "Cloze", - loadedFieldNames: ["Text", "Extra"], - mainField: "Text", - loadedAt: 1, - }, - }, - ...overrides, - }; -} - -describe("SyncCurrentFileUseCase", () => { - it("syncs only the requested file and marks only in-scope orphans", async () => { - const store = new InMemoryPluginDataStore({ - syncRegistry: { - records: [ - { - cardKey: createCardKey("legacy-current"), - noteId: 101, - filePath: "notes/current.md", - sourceHash: createContentHash("old-hash"), - lastSyncedAt: 1, - orphan: false, - }, - { - cardKey: createCardKey("other-file"), - noteId: 102, - filePath: "notes/other.md", - sourceHash: createContentHash("other-hash"), - lastSyncedAt: 1, - orphan: false, - }, - ], - }, - }); - const vaultGateway = new FakeVaultGateway([ - { - path: "notes/current.md", - content: ["#### Prompt", "Answer with ![[diagram.png]]"].join("\n"), - }, - { - path: "notes/other.md", - content: ["#### Other", "Other answer"].join("\n"), - }, - ]); - const ankiGateway = new FakeAnkiGateway(); - const repository = new DataJsonSyncRegistryRepository(store); - const scanUseCase = new ScanAndPlanSyncUseCase(vaultGateway, repository); - const executeUseCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1000); - const useCase = new SyncCurrentFileUseCase(scanUseCase, executeUseCase); - - const result = await useCase.execute("notes/current.md", createSettings()); - const snapshot = store.readSnapshot(); - - expect(result.created).toBe(1); - expect(result.markedOrphan).toBe(1); - expect(result.uploadedMedia).toBe(1); - expect(ankiGateway.addCalls).toHaveLength(1); - expect(ankiGateway.updateCalls).toHaveLength(0); - expect(ankiGateway.deleteCalls).toBe(0); - expect(snapshot?.syncRegistry?.records.find((record) => record.filePath === "notes/current.md" && record.orphan)).toBeTruthy(); - expect(snapshot?.syncRegistry?.records.find((record) => record.filePath === "notes/other.md" && record.orphan)).toBeFalsy(); - }); -}); \ No newline at end of file diff --git a/src/application/use-cases/SyncCurrentFileUseCase.ts b/src/application/use-cases/SyncCurrentFileUseCase.ts deleted file mode 100644 index 8a0e6f6..0000000 --- a/src/application/use-cases/SyncCurrentFileUseCase.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { PluginSettings } from "@/application/config/PluginSettings"; - -import type { ExecuteSyncPlanUseCase } from "./ExecuteSyncPlanUseCase"; -import type { ScanAndPlanSyncUseCase } from "./ScanAndPlanSyncUseCase"; -import type { ExecuteSyncPlanResult } from "./types"; - -export class SyncCurrentFileUseCase { - constructor( - private readonly scanAndPlanSyncUseCase: ScanAndPlanSyncUseCase, - private readonly executeSyncPlanUseCase: ExecuteSyncPlanUseCase, - ) {} - - async execute(filePath: string, settings: PluginSettings): Promise { - const scanResult = await this.scanAndPlanSyncUseCase.executeForFile(filePath, settings); - return this.executeSyncPlanUseCase.execute(scanResult); - } -} \ No newline at end of file diff --git a/src/application/use-cases/SyncVaultUseCase.test.ts b/src/application/use-cases/SyncVaultUseCase.test.ts deleted file mode 100644 index 0e27f30..0000000 --- a/src/application/use-cases/SyncVaultUseCase.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping"; -import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings"; -import type { AnkiGateway } from "@/application/ports/AnkiGateway"; -import type { PluginDataStore } from "@/application/ports/PluginDataStore"; -import type { VaultGateway } from "@/application/ports/VaultGateway"; -import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation"; -import { DataJsonSyncRegistryRepository } from "@/infrastructure/persistence/DataJsonSyncRegistryRepository"; -import type { PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository"; - -import { ExecuteSyncPlanUseCase } from "./ExecuteSyncPlanUseCase"; -import { ScanAndPlanSyncUseCase } from "./ScanAndPlanSyncUseCase"; -import { SyncVaultUseCase } from "./SyncVaultUseCase"; - -class InMemoryPluginDataStore implements PluginDataStore { - constructor(private snapshot: PluginDataSnapshot | null = null) {} - - async load(): Promise { - return this.snapshot; - } - - async save(data: PluginDataSnapshot): Promise { - this.snapshot = data; - } - - readSnapshot(): PluginDataSnapshot | null { - return this.snapshot; - } -} - -class FakeVaultGateway implements VaultGateway { - constructor(private readonly files: Array<{ path: string; content: string }>) {} - - async listMarkdownFiles() { - return this.files.map((file) => ({ - path: file.path, - basename: file.path.split("/").pop()?.replace(/\.md$/i, "") ?? file.path, - content: file.content, - })); - } - - async getMarkdownFile(path: string) { - const file = this.files.find((candidate) => candidate.path === path); - if (!file) { - return null; - } - - return { - path: file.path, - basename: file.path.split("/").pop()?.replace(/\.md$/i, "") ?? file.path, - content: file.content, - }; - } - - 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 }; - } - - resolveEmbed() { - return null; - } - - createBacklink(location: SourceLocation) { - return `obsidian://open?vault=Vault&file=${encodeURIComponent(location.filePath)}`; - } -} - -class FakeAnkiGateway implements AnkiGateway { - public readonly addCalls: Array<{ deckName: string; modelName: string; fields: Record }> = []; - public readonly updateCalls: Array<{ noteId: number; deckName: string; fields: Record }> = []; - public readonly ensureDeckCalls: string[] = []; - - async ensureDeckExists(deckName: string): Promise { - this.ensureDeckCalls.push(deckName); - } - - async ensureDecks(deckNames: string[]): Promise { - this.ensureDeckCalls.push(...deckNames); - } - - async listNoteModels(): Promise { - return ["Basic", "Cloze"]; - } - - async listDeckNames(): Promise { - return []; - } - - async getModelDetails(modelName: string) { - return modelName === "Cloze" - ? { fieldNames: ["Text", "Extra"], isCloze: true } - : { fieldNames: ["Front", "Back"], isCloze: false }; - } - - async getNoteSummaries(): Promise> { - return []; - } - - async getDeckStats(): Promise> { - return []; - } - - async addNote(input: { deckName: string; modelName: string; fields: Record }): Promise { - this.addCalls.push(input); - return 200 + this.addCalls.length; - } - - async addNotes(inputs: Array<{ deckName: string; modelName: string; fields: Record; tags: string[] }>): Promise { - return Promise.all(inputs.map((input) => this.addNote(input))); - } - - async deleteNotes(): Promise {} - - async updateNote(input: { noteId: number; deckName: string; fields: Record }): Promise { - this.updateCalls.push(input); - } - - async updateNotes(inputs: Array<{ noteId: number; deckName: string; fields: Record }>): Promise { - for (const input of inputs) { - await this.updateNote(input); - } - } - - async changeDecks(): Promise {} - - async deleteDecks(): Promise {} - - async storeMedia(): Promise {} - - async storeMediaFiles(): Promise {} -} - -function createSettings(overrides: Partial = {}): PluginSettings { - return { - ...DEFAULT_SETTINGS, - addObsidianBacklink: false, - noteFieldMappings: { - [createNoteFieldMappingKey("basic", "Basic")]: { - cardType: "basic", - modelName: "Basic", - loadedFieldNames: ["Front", "Back"], - titleField: "Front", - bodyField: "Back", - loadedAt: 1, - }, - [createNoteFieldMappingKey("cloze", "Cloze")]: { - cardType: "cloze", - modelName: "Cloze", - loadedFieldNames: ["Text", "Extra"], - mainField: "Text", - loadedAt: 1, - }, - }, - ...overrides, - }; -} - -describe("SyncVaultUseCase", () => { - it("scans the configured scope and syncs vault cards", async () => { - const store = new InMemoryPluginDataStore(); - const vaultGateway = new FakeVaultGateway([ - { - path: "cards/qa.md", - content: ["TARGET DECK: Scoped::Deck", "", "#### Prompt", "Answer"].join("\n"), - }, - { - path: "cards/skip/ignored.md", - content: ["#### Ignored", "Ignored answer"].join("\n"), - }, - { - path: "outside/out.md", - content: ["#### Outside", "Outside answer"].join("\n"), - }, - ]); - const ankiGateway = new FakeAnkiGateway(); - const repository = new DataJsonSyncRegistryRepository(store); - const scanUseCase = new ScanAndPlanSyncUseCase(vaultGateway, repository); - const executeUseCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 2000); - const useCase = new SyncVaultUseCase(scanUseCase, executeUseCase); - - const result = await useCase.execute( - createSettings({ - scopeMode: "exclude", - excludeFolders: ["cards/skip", "outside"], - }), - ); - const snapshot = store.readSnapshot(); - - expect(result.scanned).toBe(1); - expect(result.created).toBe(1); - expect(result.updated).toBe(0); - expect(ankiGateway.ensureDeckCalls).toEqual(["Scoped::Deck"]); - expect(ankiGateway.addCalls[0]?.deckName).toBe("Scoped::Deck"); - expect(snapshot?.syncRegistry?.records).toHaveLength(1); - expect(snapshot?.syncRegistry?.records[0]?.filePath).toBe("cards/qa.md"); - }); -}); \ No newline at end of file diff --git a/src/application/use-cases/SyncVaultUseCase.ts b/src/application/use-cases/SyncVaultUseCase.ts deleted file mode 100644 index 524bbd4..0000000 --- a/src/application/use-cases/SyncVaultUseCase.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { PluginSettings } from "@/application/config/PluginSettings"; - -import type { ExecuteSyncPlanUseCase } from "./ExecuteSyncPlanUseCase"; -import type { ScanAndPlanSyncUseCase } from "./ScanAndPlanSyncUseCase"; -import type { ExecuteSyncPlanResult } from "./types"; - -export class SyncVaultUseCase { - constructor( - private readonly scanAndPlanSyncUseCase: ScanAndPlanSyncUseCase, - private readonly executeSyncPlanUseCase: ExecuteSyncPlanUseCase, - ) {} - - async execute(settings: PluginSettings): Promise { - const scanResult = await this.scanAndPlanSyncUseCase.executeForVault(settings); - return this.executeSyncPlanUseCase.execute(scanResult); - } -} \ No newline at end of file diff --git a/src/application/use-cases/types.ts b/src/application/use-cases/types.ts deleted file mode 100644 index ad89927..0000000 --- a/src/application/use-cases/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping"; -import type { Card } from "@/domain/card/entities/Card"; -import type { SyncRegistry } from "@/domain/sync/entities/SyncRegistry"; -import type { SyncPlan } from "@/domain/sync/value-objects/SyncPlan"; - -export interface ScanAndPlanResult { - cards: Card[]; - noteFieldMappings: Record; - registry: SyncRegistry; - plan: SyncPlan; - scopedFilePaths: string[]; -} - -export interface ExecuteSyncPlanResult { - created: number; - markedOrphan: number; - scanned: number; - unchanged: number; - updated: number; - uploadedMedia: number; -} \ No newline at end of file diff --git a/src/domain/card/entities/Card.ts b/src/domain/card/entities/Card.ts deleted file mode 100644 index 02c4439..0000000 --- a/src/domain/card/entities/Card.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { CardKey } from "../value-objects/CardKey"; -import type { ContentHash } from "../value-objects/ContentHash"; -import type { DeckName } from "../value-objects/DeckName"; -import type { NoteModelName } from "../value-objects/NoteModelName"; -import type { SourceLocation } from "../value-objects/SourceLocation"; -import type { CardType, MediaAsset, RenderedFields } from "./RenderedFields"; - -export interface Card { - key: CardKey; - source: SourceLocation; - type: CardType; - heading: string; - bodyMarkdown: string; - embeddedNoteId?: number; - deck: DeckName; - noteModel: NoteModelName; - tags: string[]; - renderedFields: RenderedFields; - fields: Record; - contentHash: ContentHash; - media: MediaAsset[]; -} \ No newline at end of file diff --git a/src/domain/card/entities/CardDraft.ts b/src/domain/card/entities/CardDraft.ts deleted file mode 100644 index 78176a1..0000000 --- a/src/domain/card/entities/CardDraft.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { CardType } from "./RenderedFields"; -import type { DeckName } from "../value-objects/DeckName"; -import type { SourceLocation } from "../value-objects/SourceLocation"; - -export interface CardDraft { - source: SourceLocation; - heading: string; - 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 deleted file mode 100644 index 2a70bf0..0000000 --- a/src/domain/card/policies/CardIdentityPolicy.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { CardIdentityPolicy } from "./CardIdentityPolicy"; -import type { SourceLocation } from "../value-objects/SourceLocation"; - -describe("CardIdentityPolicy", () => { - it("creates a stable key for the same source location and type", () => { - const policy = new CardIdentityPolicy(); - const location: SourceLocation = { - filePath: "notes/example.md", - headingLine: 5, - blockStartLine: 5, - bodyStartLine: 6, - blockEndLine: 8, - contentEndLine: 7, - headingLevel: 4, - headingText: "Prompt", - }; - - expect(policy.create(location, "basic")).toBe(policy.create(location, "basic")); - }); - - it("changes the key when a participating identity component changes", () => { - const policy = new CardIdentityPolicy(); - const location: SourceLocation = { - filePath: "notes/example.md", - headingLine: 5, - blockStartLine: 5, - bodyStartLine: 6, - blockEndLine: 8, - contentEndLine: 7, - headingLevel: 4, - headingText: "Prompt", - }; - - const movedLocation: SourceLocation = { - ...location, - blockStartLine: 7, - }; - - expect(policy.create(location, "basic")).not.toBe(policy.create(movedLocation, "basic")); - }); -}); \ No newline at end of file diff --git a/src/domain/card/policies/CardIdentityPolicy.ts b/src/domain/card/policies/CardIdentityPolicy.ts deleted file mode 100644 index 992b219..0000000 --- a/src/domain/card/policies/CardIdentityPolicy.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { CardType } from "../entities/RenderedFields"; -import type { SourceLocation } from "../value-objects/SourceLocation"; -import { createCardKey, type CardKey } from "../value-objects/CardKey"; -import { hashString } from "../../shared/hash"; - -export class CardIdentityPolicy { - create(location: SourceLocation, cardType: CardType): CardKey { - return createCardKey( - hashString( - [ - location.filePath, - String(location.headingLevel), - location.headingText, - String(location.blockStartLine), - cardType, - ].join("|"), - ), - ); - } -} \ No newline at end of file diff --git a/src/domain/card/services/CardExtractionService.test.ts b/src/domain/card/services/CardExtractionService.test.ts deleted file mode 100644 index 67eedf1..0000000 --- a/src/domain/card/services/CardExtractionService.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { CardExtractionService, validateHeadingPolicy } from "./CardExtractionService"; -import type { SourceFile } from "../entities/SourceFile"; - -describe("CardExtractionService", () => { - it("extracts heading blocks using configured QA and Cloze levels", () => { - const sourceFile: SourceFile = { - path: "notes/example.md", - basename: "example", - content: [ - "TARGET DECK: Coding::Deck", - "", - "#### What is DDD?", - "Domain-driven design keeps the model central.", - "", - "##### Fill the gap", - "Use ==ubiquitous language== across the team.", - "", - "#### Another card", - "Another answer.", - ].join("\n"), - }; - - const service = new CardExtractionService(); - const drafts = service.extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 }); - - expect(drafts).toHaveLength(3); - expect(drafts[0]).toMatchObject({ - heading: "What is DDD?", - type: "basic", - bodyMarkdown: [ - "Domain-driven design keeps the model central.", - "", - "##### Fill the gap", - "Use ==ubiquitous language== across the team.", - ].join("\n"), - }); - expect(drafts[1]).toMatchObject({ - heading: "Fill the gap", - type: "cloze", - bodyMarkdown: "Use ==ubiquitous language== across the team.", - }); - expect(drafts[2].deckHint).toBe("Coding::Deck"); - }); - - it("ignores headings and target deck lines inside fenced code blocks", () => { - const sourceFile: SourceFile = { - path: "notes/example.md", - basename: "example", - content: [ - "```md", - "#### Not a card", - "TARGET DECK: Fake", - "```", - "", - "#### Real card", - "Answer", - ].join("\n"), - }; - - const service = new CardExtractionService(); - const drafts = service.extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 }); - - expect(drafts).toHaveLength(1); - expect(drafts[0].heading).toBe("Real card"); - 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.", - ); - }); -}); \ No newline at end of file diff --git a/src/domain/card/services/CardExtractionService.ts b/src/domain/card/services/CardExtractionService.ts deleted file mode 100644 index f3b0f70..0000000 --- a/src/domain/card/services/CardExtractionService.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { SourceFile } from "../entities/SourceFile"; -import type { CardDraft } from "../entities/CardDraft"; -import { createDeckName } from "../value-objects/DeckName"; - -export interface HeadingPolicy { - qaHeadingLevel: number; - clozeHeadingLevel: number; -} - -interface HeadingMatch { - level: number; - text: string; - 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 = /"].join("\n"), - blockEndLine: 3, - contentEndLine: 2, - markerLine: 3, - }, - }); - const registry = new SyncRegistry([ - createRecord({ - cardKey: card.key, - identityMode: "pending-note-id-write", - noteId: 9001, - legacyCardKey: card.key, - }), - ]); - - const plan = service.plan([card], registry, ["notes/example.md"]); - - expect(plan.toUpdate).toEqual([{ card, noteId: 9001 }]); - }); -}); \ No newline at end of file diff --git a/src/domain/sync/services/SyncPlanningService.ts b/src/domain/sync/services/SyncPlanningService.ts deleted file mode 100644 index 3512c29..0000000 --- a/src/domain/sync/services/SyncPlanningService.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { Card } from "../../card/entities/Card"; -import type { SyncRegistry } from "../entities/SyncRegistry"; -import type { SyncPlan } from "../value-objects/SyncPlan"; - -export class SyncPlanningService { - plan(cards: Card[], registry: SyncRegistry, scopedFilePaths: string[]): SyncPlan { - const seenKeys = new Set(); - const seenEmbeddedNoteIds = new Set(); - const createDecks = new Map(); - const toAdd: Card[] = []; - const toUpdate: SyncPlan["toUpdate"] = []; - - for (const card of cards) { - if (seenKeys.has(card.key)) { - throw new Error(`Duplicate card key detected in current scan: ${card.key}`); - } - - 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 existingRecord = registry.get(card.key); - - if (existingRecord?.identityMode === "pending-note-id-write") { - toUpdate.push({ card, noteId: existingRecord.noteId }); - createDecks.set(card.deck, card.deck); - continue; - } - - if (card.embeddedNoteId) { - const existingEmbeddedRecord = registry.findByNoteId(card.embeddedNoteId); - - if (!existingEmbeddedRecord) { - 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; - } - - if (!existingRecord) { - toAdd.push(card); - createDecks.set(card.deck, card.deck); - continue; - } - - if (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) => { - 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()), - toAdd, - toUpdate, - toMarkOrphan, - }; - } -} \ No newline at end of file diff --git a/src/domain/sync/value-objects/SyncPlan.ts b/src/domain/sync/value-objects/SyncPlan.ts deleted file mode 100644 index 8ffd24d..0000000 --- a/src/domain/sync/value-objects/SyncPlan.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Card } from "../../card/entities/Card"; -import type { DeckName } from "../../card/value-objects/DeckName"; -import type { SyncRecord } from "../entities/SyncRecord"; - -export interface SyncPlanUpdate { - card: Card; - noteId: number; -} - -export interface SyncPlan { - toCreateDecks: DeckName[]; - toAdd: Card[]; - toUpdate: SyncPlanUpdate[]; - toMarkOrphan: SyncRecord[]; -} \ No newline at end of file diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts index 0d76195..08b0c6f 100644 --- a/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts +++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts @@ -6,18 +6,6 @@ import type { PluginState } from "@/domain/manual-sync/entities/PluginState"; export interface PluginDataSnapshot { settings?: Partial; pluginState?: PluginState; - syncRegistry?: { - 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; - }>; - }; } export class DataJsonPluginConfigRepository implements PluginConfigRepository { diff --git a/src/infrastructure/persistence/DataJsonSyncRegistryRepository.test.ts b/src/infrastructure/persistence/DataJsonSyncRegistryRepository.test.ts deleted file mode 100644 index 537c127..0000000 --- a/src/infrastructure/persistence/DataJsonSyncRegistryRepository.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { PluginDataStore } from "@/application/ports/PluginDataStore"; -import { createCardKey } from "@/domain/card/value-objects/CardKey"; -import { createContentHash } from "@/domain/card/value-objects/ContentHash"; -import { SyncRegistry } from "@/domain/sync/entities/SyncRegistry"; - -import { DataJsonSyncRegistryRepository } from "./DataJsonSyncRegistryRepository"; -import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository"; - -class InMemoryPluginDataStore implements PluginDataStore { - private snapshot: PluginDataSnapshot | null = null; - - async load(): Promise { - return this.snapshot; - } - - async save(data: PluginDataSnapshot): Promise { - this.snapshot = data; - } -} - -describe("DataJsonSyncRegistryRepository", () => { - it("persists and restores sync records", async () => { - const store = new InMemoryPluginDataStore(); - const repository = new DataJsonSyncRegistryRepository(store); - 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"), - lastSyncedAt: 10, - orphan: false, - }, - ]); - - await repository.save(registry); - const reloaded = await repository.load(); - - expect(reloaded.list()).toEqual(registry.list()); - }); -}); \ No newline at end of file diff --git a/src/infrastructure/persistence/DataJsonSyncRegistryRepository.ts b/src/infrastructure/persistence/DataJsonSyncRegistryRepository.ts deleted file mode 100644 index c6e6605..0000000 --- a/src/infrastructure/persistence/DataJsonSyncRegistryRepository.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository"; -import type { PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository"; -import type { PluginDataStore } from "@/application/ports/PluginDataStore"; -import { createCardKey } from "@/domain/card/value-objects/CardKey"; -import { createContentHash } from "@/domain/card/value-objects/ContentHash"; -import { SyncRegistry } from "@/domain/sync/entities/SyncRegistry"; - -export class DataJsonSyncRegistryRepository implements SyncRegistryRepository { - constructor(private readonly pluginDataStore: PluginDataStore) {} - - async load(): Promise { - const snapshot = await this.pluginDataStore.load(); - const records = snapshot?.syncRegistry?.records ?? []; - - return new SyncRegistry( - records.map((record) => ({ - 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, - })), - ); - } - - async save(registry: SyncRegistry): Promise { - const snapshot = (await this.pluginDataStore.load()) ?? {}; - - await this.pluginDataStore.save({ - ...snapshot, - syncRegistry: { - records: registry.list().map((record) => ({ - cardKey: record.cardKey, - noteId: record.noteId, - filePath: record.filePath, - identityMode: record.identityMode, - legacyCardKey: record.legacyCardKey, - sourceHash: record.sourceHash, - lastSyncedAt: record.lastSyncedAt, - orphan: record.orphan, - })), - }, - }); - } -} \ No newline at end of file