mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
清理 legacy sync 链与旧 syncRegistry 残留,保留 current manual-sync 主链 / Remove legacy sync chain and old syncRegistry remnants while preserving the current manual-sync path
删除已失效的 legacy sync use case、syncRegistry 持久化与旧 card pipeline,运行入口保持 ManualSyncService 主链。 Remove the obsolete legacy sync use cases, syncRegistry persistence, and old card pipeline while keeping the runtime on ManualSyncService.
This commit is contained in:
parent
5023e8a4bb
commit
fbb289850a
37 changed files with 168 additions and 3847 deletions
|
|
@ -1,5 +1,8 @@
|
|||
# 模块 2:将 Anki `noteId` 回写到标题块末尾,使用短标记 `<!-- AHS:123 -->`
|
||||
|
||||
> 归档说明(2026-04-21):本文描述的是已移除的 legacy syncRegistry 主链方案。
|
||||
> 当前保留的生产同步链路是 `ManualSyncService` 所在的 manual-sync 体系。
|
||||
|
||||
## Summary
|
||||
|
||||
- 新增卡片同步成功后,把返回的 `Anki noteId` 写回对应标题块的块尾,格式固定为 `<!-- AHS:<noteId> -->`。
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
# Obsidian Anki 新插件设计备忘(DDD 讨论稿)
|
||||
|
||||
> 归档说明(2026-04-21):本文包含已移除的 legacy sync / syncRegistry 架构讨论。
|
||||
> 仓库当前有效实现以 `ManualSyncService` manual-sync 主链为准。
|
||||
|
||||
## 文档目的
|
||||
|
||||
这不是一个立即开工的实现文档,而是一份面向未来的设计备忘。
|
||||
|
|
|
|||
64
docs/legacy-sync-cleanup-decisions.md
Normal file
64
docs/legacy-sync-cleanup-decisions.md
Normal file
|
|
@ -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.
|
||||
79
docs/legacy-sync-cleanup-gap-report.md
Normal file
79
docs/legacy-sync-cleanup-gap-report.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
import type { SyncRegistry } from "@/domain/sync/entities/SyncRegistry";
|
||||
|
||||
export interface SyncRegistryRepository {
|
||||
load(): Promise<SyncRegistry>;
|
||||
save(registry: SyncRegistry): Promise<void>;
|
||||
}
|
||||
|
|
@ -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", "<!-- AHS:123 -->", "", "", "### Next"].join("\n"));
|
||||
});
|
||||
|
||||
it("writes the marker right after the heading when the block body is empty", () => {
|
||||
const nextContent = service.apply(
|
||||
{
|
||||
filePath: "notes/example.md",
|
||||
sourceContent: ["#### Prompt", "### Next"].join("\n"),
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 1,
|
||||
contentEndLine: 1,
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
},
|
||||
456,
|
||||
);
|
||||
|
||||
expect(nextContent).toBe(["#### Prompt", "<!-- AHS:456 -->", "", "", "### Next"].join("\n"));
|
||||
});
|
||||
|
||||
it("replaces an existing marker without moving past the next heading", () => {
|
||||
const nextContent = service.apply(
|
||||
{
|
||||
filePath: "notes/example.md",
|
||||
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:123 -->", "", "", "### Next"].join("\n"),
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 5,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
},
|
||||
789,
|
||||
);
|
||||
|
||||
expect(nextContent).toBe(["#### Prompt", "Answer", "<!-- AHS:789 -->", "", "", "### Next"].join("\n"));
|
||||
});
|
||||
|
||||
it("applies multiple writes in one file from bottom to top", () => {
|
||||
const sourceContent = [
|
||||
"#### Top",
|
||||
"Top answer",
|
||||
"",
|
||||
"",
|
||||
"#### Bottom",
|
||||
"Bottom answer",
|
||||
].join("\n");
|
||||
|
||||
const writes: MarkerWriteRequest[] = [
|
||||
{
|
||||
cardKey: createCardKey("top"),
|
||||
filePath: "notes/example.md",
|
||||
noteId: 111,
|
||||
location: {
|
||||
filePath: "notes/example.md",
|
||||
sourceContent,
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 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",
|
||||
"<!-- AHS:111 -->",
|
||||
"",
|
||||
"",
|
||||
"#### Bottom",
|
||||
"Bottom answer",
|
||||
"<!-- AHS:222 -->",
|
||||
].join("\n"));
|
||||
});
|
||||
|
||||
it("can replace an old marker and insert a new one in the same batch", () => {
|
||||
const sourceContent = [
|
||||
"#### First",
|
||||
"Body 1",
|
||||
"<!-- AHS:42 -->",
|
||||
"",
|
||||
"",
|
||||
"#### Second",
|
||||
"Body 2",
|
||||
].join("\n");
|
||||
|
||||
const nextContent = service.applyBatch(sourceContent, [
|
||||
{
|
||||
cardKey: createCardKey("first"),
|
||||
filePath: "notes/example.md",
|
||||
noteId: 9001,
|
||||
location: {
|
||||
filePath: "notes/example.md",
|
||||
sourceContent,
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 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",
|
||||
"<!-- AHS:9001 -->",
|
||||
"",
|
||||
"",
|
||||
"#### Second",
|
||||
"Body 2",
|
||||
"<!-- AHS:9002 -->",
|
||||
].join("\n"));
|
||||
});
|
||||
});
|
||||
|
|
@ -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<number>();
|
||||
|
||||
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<number>();
|
||||
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: `<!-- AHS:${noteId} -->`,
|
||||
lineIndex: -1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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>): Card {
|
||||
interface RenderedCardInput {
|
||||
type: CardType;
|
||||
noteModel: string;
|
||||
renderedFields: {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
}
|
||||
|
||||
function createCard(overrides: Partial<RenderedCardInput>): 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"],
|
||||
|
|
|
|||
|
|
@ -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<string, NoteModelFieldMapping>,
|
||||
): Record<string, string> {
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<SyncRegistry> {
|
||||
return this.registry;
|
||||
}
|
||||
|
||||
async save(registry: SyncRegistry): Promise<void> {
|
||||
this.savedRegistry = registry;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeVaultGateway implements VaultGateway {
|
||||
public readonly files = new Map<string, string>();
|
||||
public failReplace = false;
|
||||
public replaceError: Error | null = null;
|
||||
public replaceCalls: Array<{ path: string; expectedContent: string; nextContent: string }> = [];
|
||||
|
||||
constructor(initialFiles: Record<string, string> = {}) {
|
||||
for (const [path, content] of Object.entries(initialFiles)) {
|
||||
this.files.set(path, content);
|
||||
}
|
||||
}
|
||||
|
||||
async listMarkdownFiles() {
|
||||
return Array.from(this.files.entries()).map(([path, content]) => ({
|
||||
path,
|
||||
basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
|
||||
content,
|
||||
}));
|
||||
}
|
||||
|
||||
async getMarkdownFile(path: string) {
|
||||
const content = this.files.get(path);
|
||||
if (content === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void> {
|
||||
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<string, string> }> = [];
|
||||
public updatedNotes: Array<{ noteId: number; deckName: string; fields: Record<string, string> }> = [];
|
||||
public storedMedia: string[] = [];
|
||||
public noteSummariesById = new Map<number, AnkiNoteSummary>();
|
||||
public modelDetailsByName: Record<string, { fieldNames: string[]; isCloze: boolean }> = {
|
||||
Basic: { fieldNames: ["Front", "Back"], isCloze: false },
|
||||
Cloze: { fieldNames: ["Text", "Extra"], isCloze: true },
|
||||
};
|
||||
private nextAddedNoteId = 9000;
|
||||
|
||||
async ensureDeckExists(deckName: string): Promise<void> {
|
||||
this.ensuredDecks.push(deckName);
|
||||
}
|
||||
|
||||
async ensureDecks(deckNames: string[]): Promise<void> {
|
||||
this.ensuredDecks.push(...deckNames);
|
||||
}
|
||||
|
||||
async listNoteModels(): Promise<string[]> {
|
||||
return Object.keys(this.modelDetailsByName);
|
||||
}
|
||||
|
||||
async listDeckNames(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getModelDetails(modelName: string) {
|
||||
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false };
|
||||
}
|
||||
|
||||
async getDeckStats(): Promise<Array<{ deckName: string; noteCount: number }>> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
return noteIds.flatMap((noteId) => {
|
||||
const summary = this.noteSummariesById.get(noteId);
|
||||
return summary ? [summary] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
|
||||
this.addedNotes.push(input);
|
||||
this.nextAddedNoteId += 1;
|
||||
return this.nextAddedNoteId;
|
||||
}
|
||||
|
||||
async addNotes(inputs: Array<{ deckName: string; modelName: string; fields: Record<string, string>; tags: string[] }>): Promise<number[]> {
|
||||
return Promise.all(inputs.map((input) => this.addNote(input)));
|
||||
}
|
||||
|
||||
async deleteNotes(): Promise<void> {}
|
||||
|
||||
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
|
||||
this.updatedNotes.push(input);
|
||||
}
|
||||
|
||||
async updateNotes(inputs: Array<{ noteId: number; deckName: string; fields: Record<string, string> }>): Promise<void> {
|
||||
for (const input of inputs) {
|
||||
await this.updateNote(input);
|
||||
}
|
||||
}
|
||||
|
||||
async changeDecks(): Promise<void> {}
|
||||
|
||||
async deleteDecks(): Promise<void> {}
|
||||
|
||||
async storeMedia(asset: { fileName: string }): Promise<void> {
|
||||
this.storedMedia.push(asset.fileName);
|
||||
}
|
||||
|
||||
async storeMediaFiles(assets: Array<{ fileName: string }>): Promise<void> {
|
||||
for (const asset of assets) {
|
||||
await this.storeMedia(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createCard(overrides: Partial<Card> = {}): Card {
|
||||
const sourceContent = (overrides.source?.sourceContent as string | undefined) ?? ["#### Prompt", "Answer"].join("\n");
|
||||
const lineCount = sourceContent.split(/\r?\n/).length;
|
||||
|
||||
return {
|
||||
key: createCardKey("card-1"),
|
||||
source: {
|
||||
filePath: "notes/current.md",
|
||||
sourceContent,
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 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> = {}): 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", "<!-- AHS:9001 -->"].join("\n"));
|
||||
expect(repository.savedRegistry?.findByNoteId(9001)).toMatchObject({
|
||||
identityMode: "embedded-note-id",
|
||||
noteId: 9001,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes empty-body markers on the line after the heading", async () => {
|
||||
const vaultGateway = new FakeVaultGateway({
|
||||
"notes/current.md": "#### Prompt",
|
||||
});
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const templateCard = createCard();
|
||||
const card = createCard({
|
||||
bodyMarkdown: "",
|
||||
renderedFields: { title: "Prompt", body: "" },
|
||||
source: {
|
||||
...templateCard.source,
|
||||
sourceContent: "#### Prompt",
|
||||
blockEndLine: 1,
|
||||
contentEndLine: 1,
|
||||
},
|
||||
});
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [card],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(vaultGateway.files.get("notes/current.md")).toBe(["#### Prompt", "<!-- AHS:9001 -->"].join("\n"));
|
||||
});
|
||||
|
||||
it("stores pending-note-id-write when addNote succeeds but write-back fails", async () => {
|
||||
const vaultGateway = new FakeVaultGateway({
|
||||
"notes/current.md": ["#### Prompt", "Answer changed elsewhere"].join("\n"),
|
||||
});
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const card = createCard();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [card],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(repository.savedRegistry?.get(card.key)).toMatchObject({
|
||||
identityMode: "pending-note-id-write",
|
||||
legacyCardKey: card.key,
|
||||
noteId: 9001,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes multiple newly created cards in the same file with a single replace call", async () => {
|
||||
const sourceContent = ["###### 试验的卡片12", "卡片1", "", "###### 试验233", "卡片2"].join("\n");
|
||||
const vaultGateway = new FakeVaultGateway({
|
||||
"notes/current.md": sourceContent,
|
||||
});
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const firstCard = createCard({
|
||||
key: createCardKey("card-1"),
|
||||
heading: "试验的卡片12",
|
||||
bodyMarkdown: "卡片1",
|
||||
renderedFields: { title: "试验的卡片12", body: "卡片1" },
|
||||
fields: { title: "试验的卡片12", body: "卡片1" },
|
||||
contentHash: createContentHash("hash-card-1"),
|
||||
source: {
|
||||
filePath: "notes/current.md",
|
||||
sourceContent,
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
headingLevel: 6,
|
||||
headingText: "试验的卡片12",
|
||||
},
|
||||
});
|
||||
const secondCard = createCard({
|
||||
key: createCardKey("card-2"),
|
||||
heading: "试验233",
|
||||
bodyMarkdown: "卡片2",
|
||||
renderedFields: { title: "试验233", body: "卡片2" },
|
||||
fields: { title: "试验233", body: "卡片2" },
|
||||
contentHash: createContentHash("hash-card-2"),
|
||||
source: {
|
||||
filePath: "notes/current.md",
|
||||
sourceContent,
|
||||
headingLine: 4,
|
||||
blockStartLine: 4,
|
||||
bodyStartLine: 5,
|
||||
blockEndLine: 5,
|
||||
contentEndLine: 5,
|
||||
headingLevel: 6,
|
||||
headingText: "试验233",
|
||||
},
|
||||
});
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [firstCard, secondCard],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [firstCard, secondCard],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(ankiGateway.addedNotes).toHaveLength(2);
|
||||
expect(vaultGateway.replaceCalls).toHaveLength(1);
|
||||
expect(vaultGateway.files.get("notes/current.md")).toBe([
|
||||
"###### 试验的卡片12",
|
||||
"卡片1",
|
||||
"<!-- AHS:9001 -->",
|
||||
"",
|
||||
"###### 试验233",
|
||||
"卡片2",
|
||||
"<!-- AHS:9002 -->",
|
||||
].join("\n"));
|
||||
expect(repository.savedRegistry?.findByNoteId(9001)).toMatchObject({ identityMode: "embedded-note-id" });
|
||||
expect(repository.savedRegistry?.findByNoteId(9002)).toMatchObject({ identityMode: "embedded-note-id" });
|
||||
expect(repository.savedRegistry?.list().some((record) => record.identityMode === "pending-note-id-write")).toBe(false);
|
||||
});
|
||||
|
||||
it("marks all same-file writes pending when the batch replace fails", async () => {
|
||||
const sourceContent = ["###### 卡片1", "正文1", "", "###### 卡片2", "正文2"].join("\n");
|
||||
const vaultGateway = new FakeVaultGateway({ "notes/current.md": sourceContent });
|
||||
vaultGateway.failReplace = true;
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const firstCard = createCard({
|
||||
key: createCardKey("batch-fail-1"),
|
||||
contentHash: createContentHash("hash-batch-fail-1"),
|
||||
source: {
|
||||
filePath: "notes/current.md",
|
||||
sourceContent,
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
headingLevel: 6,
|
||||
headingText: "卡片1",
|
||||
},
|
||||
});
|
||||
const secondCard = createCard({
|
||||
key: createCardKey("batch-fail-2"),
|
||||
contentHash: createContentHash("hash-batch-fail-2"),
|
||||
source: {
|
||||
filePath: "notes/current.md",
|
||||
sourceContent,
|
||||
headingLine: 4,
|
||||
blockStartLine: 4,
|
||||
bodyStartLine: 5,
|
||||
blockEndLine: 5,
|
||||
contentEndLine: 5,
|
||||
headingLevel: 6,
|
||||
headingText: "卡片2",
|
||||
},
|
||||
});
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [firstCard, secondCard],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [firstCard, secondCard],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(ankiGateway.addedNotes).toHaveLength(2);
|
||||
expect(vaultGateway.files.get("notes/current.md")).toBe(sourceContent);
|
||||
expect(repository.savedRegistry?.get(firstCard.key)).toMatchObject({ identityMode: "pending-note-id-write", noteId: 9001 });
|
||||
expect(repository.savedRegistry?.get(secondCard.key)).toMatchObject({ identityMode: "pending-note-id-write", noteId: 9002 });
|
||||
});
|
||||
|
||||
it("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", "<!-- AHS:42 -->", "", "###### 卡片2", "正文2"].join("\n");
|
||||
const vaultGateway = new FakeVaultGateway({ "notes/current.md": sourceContent });
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const recreatedCard = createCard({
|
||||
key: createCardKey("recreate-card"),
|
||||
embeddedNoteId: 42,
|
||||
contentHash: createContentHash("hash-recreate"),
|
||||
source: {
|
||||
filePath: "notes/current.md",
|
||||
sourceContent,
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
headingLevel: 6,
|
||||
headingText: "卡片1",
|
||||
},
|
||||
});
|
||||
const newCard = createCard({
|
||||
key: createCardKey("new-card"),
|
||||
contentHash: createContentHash("hash-new-card"),
|
||||
source: {
|
||||
filePath: "notes/current.md",
|
||||
sourceContent,
|
||||
headingLine: 5,
|
||||
blockStartLine: 5,
|
||||
bodyStartLine: 6,
|
||||
blockEndLine: 6,
|
||||
contentEndLine: 6,
|
||||
headingLevel: 6,
|
||||
headingText: "卡片2",
|
||||
},
|
||||
});
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [recreatedCard, newCard],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [newCard],
|
||||
toUpdate: [{ card: recreatedCard, noteId: 42 }],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(vaultGateway.replaceCalls).toHaveLength(1);
|
||||
expect(vaultGateway.files.get("notes/current.md")).toBe([
|
||||
"###### 卡片1",
|
||||
"正文1",
|
||||
"<!-- AHS:9002 -->",
|
||||
"",
|
||||
"###### 卡片2",
|
||||
"正文2",
|
||||
"<!-- AHS:9001 -->",
|
||||
].join("\n"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "heading change",
|
||||
card: createCard({
|
||||
key: createCardKey("card-heading-change"),
|
||||
heading: "Prompt changed",
|
||||
renderedFields: { title: "Prompt changed", body: "Answer" },
|
||||
contentHash: createContentHash("hash-heading"),
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...createCard().source,
|
||||
sourceContent: ["#### Prompt changed", "Answer", "<!-- AHS:42 -->"].join("\n"),
|
||||
headingText: "Prompt changed",
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "body change",
|
||||
card: createCard({
|
||||
key: createCardKey("card-body-change"),
|
||||
bodyMarkdown: "Answer changed",
|
||||
renderedFields: { title: "Prompt", body: "Answer changed" },
|
||||
contentHash: createContentHash("hash-body"),
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...createCard().source,
|
||||
sourceContent: ["#### Prompt", "Answer changed", "<!-- AHS:42 -->"].join("\n"),
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "file move",
|
||||
card: createCard({
|
||||
key: createCardKey("card-file-move"),
|
||||
contentHash: createContentHash("hash-move"),
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...createCard().source,
|
||||
filePath: "notes/moved.md",
|
||||
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"),
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
])("updates the original embedded note after $name", async ({ card }) => {
|
||||
const vaultGateway = new FakeVaultGateway({
|
||||
[card.source.filePath]: card.source.sourceContent ?? "",
|
||||
});
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic", 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", "<!-- AHS:42 -->"].join("\n"),
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
});
|
||||
const existingRecord = {
|
||||
cardKey: createCardKey("old-block-key"),
|
||||
identityMode: "embedded-note-id" as const,
|
||||
noteId: 42,
|
||||
filePath: "notes/current.md",
|
||||
sourceHash: card.contentHash,
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
};
|
||||
const registry = new SyncRegistry([existingRecord]);
|
||||
const repository = new InMemorySyncRegistryRepository(registry);
|
||||
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
registry,
|
||||
plan: {
|
||||
toCreateDecks: [],
|
||||
toAdd: [],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(ankiGateway.addedNotes).toHaveLength(0);
|
||||
expect(ankiGateway.updatedNotes).toHaveLength(0);
|
||||
expect(repository.savedRegistry?.findByNoteId(42)?.cardKey).toBe(card.key);
|
||||
});
|
||||
|
||||
it("updates embedded-note-id cards even when the local registry entry is missing", async () => {
|
||||
const templateCard = createCard();
|
||||
const card = createCard({
|
||||
key: createCardKey("embedded-without-registry"),
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...templateCard.source,
|
||||
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"),
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
});
|
||||
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic", 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", "<!-- AHS:42 -->"].join("\n"),
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
});
|
||||
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [],
|
||||
toUpdate: [{ card, noteId: 42 }],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(ankiGateway.addedNotes).toHaveLength(1);
|
||||
expect(vaultGateway.files.get("notes/current.md")).toBe(["#### Prompt", "Answer", "<!-- AHS:9001 -->"].join("\n"));
|
||||
expect(repository.savedRegistry?.findByNoteId(9001)).toMatchObject({ identityMode: "embedded-note-id" });
|
||||
});
|
||||
|
||||
it("rejects basic/cloze type switching for embedded-note-id cards", async () => {
|
||||
const templateCard = createCard();
|
||||
const card = createCard({
|
||||
key: createCardKey("embedded-type-switch"),
|
||||
type: "cloze",
|
||||
noteModel: createNoteModelName("Cloze"),
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...templateCard.source,
|
||||
sourceContent: ["##### Prompt", "{answer}", "<!-- AHS:42 -->"].join("\n"),
|
||||
headingLevel: 5,
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
});
|
||||
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic", 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", "<!-- AHS:42 -->"].join("\n"));
|
||||
expect(repository.savedRegistry?.findByNoteId(42)).toMatchObject({ identityMode: "embedded-note-id" });
|
||||
});
|
||||
|
||||
it("marks orphan records locally without any delete path", async () => {
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const vaultGateway = new FakeVaultGateway({
|
||||
"notes/current.md": ["#### Prompt", "Answer"].join("\n"),
|
||||
});
|
||||
const orphanRecord = {
|
||||
cardKey: createCardKey("orphan-card"),
|
||||
identityMode: "legacy-card-key" as const,
|
||||
noteId: 42,
|
||||
filePath: "notes/orphan.md",
|
||||
sourceHash: createContentHash("old-hash"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
};
|
||||
const repository = new InMemorySyncRegistryRepository(new SyncRegistry([orphanRecord]));
|
||||
const card = createCard();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
const execution = await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
registry: new SyncRegistry([orphanRecord]),
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [card],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [orphanRecord],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(execution.created).toBe(1);
|
||||
expect(execution.markedOrphan).toBe(1);
|
||||
expect(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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ExecuteSyncPlanResult> {
|
||||
const syncRegistry = new SyncRegistry(scanAndPlanResult.registry.list());
|
||||
const modelDetailsCache = new Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>();
|
||||
const noteSummaries = await this.loadNoteSummaries(scanAndPlanResult.plan.toUpdate.map((entry) => entry.noteId));
|
||||
const timestamp = this.now();
|
||||
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<ReturnType<AnkiGateway["getNoteSummaries"]>>[number] | undefined,
|
||||
syncRegistry: SyncRegistry,
|
||||
timestamp: number,
|
||||
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
|
||||
scanAndPlanResult: ScanAndPlanResult,
|
||||
): Promise<MarkerWriteRequest | undefined> {
|
||||
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<ReturnType<AnkiGateway["getNoteSummaries"]>>[number] | undefined,
|
||||
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
|
||||
scanAndPlanResult: ScanAndPlanResult,
|
||||
): Promise<MarkerWriteRequest> {
|
||||
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<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
|
||||
scanAndPlanResult: ScanAndPlanResult,
|
||||
): Promise<void> {
|
||||
await this.updateExistingNote(card, noteId, modelDetailsCache, scanAndPlanResult);
|
||||
syncRegistry.recordSync({
|
||||
cardKey: card.key,
|
||||
identityMode: existingRecord?.identityMode ?? "legacy-card-key",
|
||||
legacyCardKey: existingRecord?.legacyCardKey,
|
||||
noteId,
|
||||
filePath: card.source.filePath,
|
||||
sourceHash: card.contentHash,
|
||||
lastSyncedAt: timestamp,
|
||||
orphan: false,
|
||||
});
|
||||
}
|
||||
|
||||
private 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<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
|
||||
scanAndPlanResult: ScanAndPlanResult,
|
||||
): Promise<number> {
|
||||
const modelDetails = await this.getModelDetails(modelDetailsCache, card.noteModel);
|
||||
|
||||
return this.ankiGateway.addNote({
|
||||
deckName: card.deck,
|
||||
modelName: card.noteModel,
|
||||
fields: this.noteFieldMappingService.map(card, modelDetails, scanAndPlanResult.noteFieldMappings),
|
||||
tags: card.tags,
|
||||
});
|
||||
}
|
||||
|
||||
private async updateExistingNote(
|
||||
card: Card,
|
||||
noteId: number,
|
||||
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
|
||||
scanAndPlanResult: ScanAndPlanResult,
|
||||
): Promise<void> {
|
||||
const modelDetails = await this.getModelDetails(modelDetailsCache, card.noteModel);
|
||||
await this.ankiGateway.updateNote({
|
||||
noteId,
|
||||
deckName: card.deck,
|
||||
fields: this.noteFieldMappingService.map(card, modelDetails, scanAndPlanResult.noteFieldMappings),
|
||||
});
|
||||
}
|
||||
|
||||
private 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<MarkerWriteFailure[]> {
|
||||
const failures: MarkerWriteFailure[] = [];
|
||||
const writesByFile = new Map<string, MarkerWriteRequest[]>();
|
||||
|
||||
for (const write of writes) {
|
||||
const fileWrites = writesByFile.get(write.filePath);
|
||||
if (fileWrites) {
|
||||
fileWrites.push(write);
|
||||
continue;
|
||||
}
|
||||
|
||||
writesByFile.set(write.filePath, [write]);
|
||||
}
|
||||
|
||||
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<Map<number, Awaited<ReturnType<AnkiGateway["getNoteSummaries"]>>[number]>> {
|
||||
const uniqueNoteIds = Array.from(new Set(noteIds));
|
||||
const resolved = await this.ankiGateway.getNoteSummaries(uniqueNoteIds);
|
||||
return new Map(resolved.map((summary) => [summary.noteId, summary]));
|
||||
}
|
||||
private async getModelDetails(
|
||||
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
|
||||
modelName: string,
|
||||
): Promise<Awaited<ReturnType<AnkiGateway["getModelDetails"]>>> {
|
||||
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<number> {
|
||||
const uniqueMedia = new Map<string, ScanAndPlanResult["cards"][number]["media"][number]>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ScanAndPlanResult> {
|
||||
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<ScanAndPlanResult> {
|
||||
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<ReturnType<VaultGateway["listMarkdownFiles"]>>, settings: PluginSettings): Promise<ScanAndPlanResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PluginDataSnapshot> {
|
||||
constructor(private snapshot: PluginDataSnapshot | null = null) {}
|
||||
|
||||
async load(): Promise<PluginDataSnapshot | null> {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
async save(data: PluginDataSnapshot): Promise<void> {
|
||||
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<string, string> }> = [];
|
||||
public readonly updateCalls: Array<{ noteId: number; deckName: string; fields: Record<string, string> }> = [];
|
||||
public readonly ensureDeckCalls: string[] = [];
|
||||
public readonly storedMedia: string[] = [];
|
||||
public deleteCalls = 0;
|
||||
|
||||
async ensureDeckExists(deckName: string): Promise<void> {
|
||||
this.ensureDeckCalls.push(deckName);
|
||||
}
|
||||
|
||||
async ensureDecks(deckNames: string[]): Promise<void> {
|
||||
this.ensureDeckCalls.push(...deckNames);
|
||||
}
|
||||
|
||||
async listNoteModels(): Promise<string[]> {
|
||||
return ["Basic", "Cloze"];
|
||||
}
|
||||
|
||||
async listDeckNames(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getModelDetails(modelName: string) {
|
||||
return modelName === "Cloze"
|
||||
? { fieldNames: ["Text", "Extra"], isCloze: true }
|
||||
: { fieldNames: ["Front", "Back"], isCloze: false };
|
||||
}
|
||||
|
||||
async getNoteSummaries(): Promise<Array<{ noteId: number; modelName: string; cardIds: number[] }>> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getDeckStats(): Promise<Array<{ deckName: string; noteCount: number }>> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
|
||||
this.addCalls.push(input);
|
||||
return 500 + this.addCalls.length;
|
||||
}
|
||||
|
||||
async addNotes(inputs: Array<{ deckName: string; modelName: string; fields: Record<string, string>; tags: string[] }>): Promise<number[]> {
|
||||
return Promise.all(inputs.map((input) => this.addNote(input)));
|
||||
}
|
||||
|
||||
async deleteNotes(): Promise<void> {}
|
||||
|
||||
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
|
||||
this.updateCalls.push(input);
|
||||
}
|
||||
|
||||
async updateNotes(inputs: Array<{ noteId: number; deckName: string; fields: Record<string, string> }>): Promise<void> {
|
||||
for (const input of inputs) {
|
||||
await this.updateNote(input);
|
||||
}
|
||||
}
|
||||
|
||||
async changeDecks(): Promise<void> {}
|
||||
|
||||
async deleteDecks(): Promise<void> {}
|
||||
|
||||
async storeMedia(asset: { fileName: string }): Promise<void> {
|
||||
this.storedMedia.push(asset.fileName);
|
||||
}
|
||||
|
||||
async storeMediaFiles(assets: Array<{ fileName: string }>): Promise<void> {
|
||||
for (const asset of assets) {
|
||||
await this.storeMedia(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createSettings(overrides: Partial<PluginSettings> = {}): 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ExecuteSyncPlanResult> {
|
||||
const scanResult = await this.scanAndPlanSyncUseCase.executeForFile(filePath, settings);
|
||||
return this.executeSyncPlanUseCase.execute(scanResult);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PluginDataSnapshot> {
|
||||
constructor(private snapshot: PluginDataSnapshot | null = null) {}
|
||||
|
||||
async load(): Promise<PluginDataSnapshot | null> {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
async save(data: PluginDataSnapshot): Promise<void> {
|
||||
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<string, string> }> = [];
|
||||
public readonly updateCalls: Array<{ noteId: number; deckName: string; fields: Record<string, string> }> = [];
|
||||
public readonly ensureDeckCalls: string[] = [];
|
||||
|
||||
async ensureDeckExists(deckName: string): Promise<void> {
|
||||
this.ensureDeckCalls.push(deckName);
|
||||
}
|
||||
|
||||
async ensureDecks(deckNames: string[]): Promise<void> {
|
||||
this.ensureDeckCalls.push(...deckNames);
|
||||
}
|
||||
|
||||
async listNoteModels(): Promise<string[]> {
|
||||
return ["Basic", "Cloze"];
|
||||
}
|
||||
|
||||
async listDeckNames(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getModelDetails(modelName: string) {
|
||||
return modelName === "Cloze"
|
||||
? { fieldNames: ["Text", "Extra"], isCloze: true }
|
||||
: { fieldNames: ["Front", "Back"], isCloze: false };
|
||||
}
|
||||
|
||||
async getNoteSummaries(): Promise<Array<{ noteId: number; modelName: string; cardIds: number[] }>> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getDeckStats(): Promise<Array<{ deckName: string; noteCount: number }>> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
|
||||
this.addCalls.push(input);
|
||||
return 200 + this.addCalls.length;
|
||||
}
|
||||
|
||||
async addNotes(inputs: Array<{ deckName: string; modelName: string; fields: Record<string, string>; tags: string[] }>): Promise<number[]> {
|
||||
return Promise.all(inputs.map((input) => this.addNote(input)));
|
||||
}
|
||||
|
||||
async deleteNotes(): Promise<void> {}
|
||||
|
||||
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
|
||||
this.updateCalls.push(input);
|
||||
}
|
||||
|
||||
async updateNotes(inputs: Array<{ noteId: number; deckName: string; fields: Record<string, string> }>): Promise<void> {
|
||||
for (const input of inputs) {
|
||||
await this.updateNote(input);
|
||||
}
|
||||
}
|
||||
|
||||
async changeDecks(): Promise<void> {}
|
||||
|
||||
async deleteDecks(): Promise<void> {}
|
||||
|
||||
async storeMedia(): Promise<void> {}
|
||||
|
||||
async storeMediaFiles(): Promise<void> {}
|
||||
}
|
||||
|
||||
function createSettings(overrides: Partial<PluginSettings> = {}): 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ExecuteSyncPlanResult> {
|
||||
const scanResult = await this.scanAndPlanSyncUseCase.executeForVault(settings);
|
||||
return this.executeSyncPlanUseCase.execute(scanResult);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, NoteModelFieldMapping>;
|
||||
registry: SyncRegistry;
|
||||
plan: SyncPlan;
|
||||
scopedFilePaths: string[];
|
||||
}
|
||||
|
||||
export interface ExecuteSyncPlanResult {
|
||||
created: number;
|
||||
markedOrphan: number;
|
||||
scanned: number;
|
||||
unchanged: number;
|
||||
updated: number;
|
||||
uploadedMedia: number;
|
||||
}
|
||||
|
|
@ -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<string, string>;
|
||||
contentHash: ContentHash;
|
||||
media: MediaAsset[];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
@ -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("|"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
"<!-- AHS:123 -->",
|
||||
"",
|
||||
"### Next heading",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
const drafts = new CardExtractionService().extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 });
|
||||
|
||||
expect(drafts[0]).toMatchObject({
|
||||
bodyMarkdown: ["Answer line 1", "", "Answer line 2"].join("\n"),
|
||||
embeddedNoteId: 123,
|
||||
});
|
||||
expect(drafts[0].source).toMatchObject({
|
||||
contentEndLine: 4,
|
||||
markerLine: 5,
|
||||
blockEndLine: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes empty-body marker metadata against the heading line", () => {
|
||||
const sourceFile: SourceFile = {
|
||||
path: "notes/example.md",
|
||||
basename: "example",
|
||||
content: [
|
||||
"#### Prompt",
|
||||
"<!-- AHS:456 -->",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
const [draft] = new CardExtractionService().extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 });
|
||||
|
||||
expect(draft.bodyMarkdown).toBe("");
|
||||
expect(draft.embeddedNoteId).toBe(456);
|
||||
expect(draft.source).toMatchObject({
|
||||
contentEndLine: 1,
|
||||
markerLine: 2,
|
||||
blockEndLine: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects multiple valid AHS markers in one heading block", () => {
|
||||
const sourceFile: SourceFile = {
|
||||
path: "notes/example.md",
|
||||
basename: "example",
|
||||
content: [
|
||||
"#### Prompt",
|
||||
"Answer",
|
||||
"<!-- AHS:123 -->",
|
||||
"<!-- AHS:456 -->",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
expect(() => new CardExtractionService().extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 })).toThrow(
|
||||
"Multiple AHS markers found in heading block",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid AHS marker syntax", () => {
|
||||
const sourceFile: SourceFile = {
|
||||
path: "notes/example.md",
|
||||
basename: "example",
|
||||
content: [
|
||||
"#### Prompt",
|
||||
"Answer",
|
||||
"<!-- AHS:not-a-number -->",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
expect(() => new CardExtractionService().extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 })).toThrow(
|
||||
"Invalid AHS marker found in heading block",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects equal heading levels", () => {
|
||||
expect(() => validateHeadingPolicy({ qaHeadingLevel: 4, clozeHeadingLevel: 4 })).toThrow(
|
||||
"QA and Cloze heading levels must not be equal.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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*AHS:([1-9]\d*)\s*-->\s*$/;
|
||||
const AHS_MARKER_CANDIDATE_REGEXP = /<!--\s*AHS:/;
|
||||
|
||||
export class CardExtractionService {
|
||||
extract(sourceFile: SourceFile, headingPolicy: HeadingPolicy): CardDraft[] {
|
||||
validateHeadingPolicy(headingPolicy);
|
||||
|
||||
const lines = sourceFile.content.split(/\r?\n/);
|
||||
const headings = collectHeadings(lines);
|
||||
const targetDeck = extractTargetDeck(lines);
|
||||
const drafts: CardDraft[] = [];
|
||||
|
||||
for (let headingIndex = 0; headingIndex < headings.length; headingIndex += 1) {
|
||||
const heading = headings[headingIndex];
|
||||
const cardType = getCardTypeForHeading(heading.level, headingPolicy);
|
||||
|
||||
if (!cardType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockEndLineIndex = findBlockEndLineIndex(headings, headingIndex, lines.length);
|
||||
const bodyLines = lines.slice(heading.lineIndex + 1, blockEndLineIndex);
|
||||
const extractedMarker = extractEmbeddedMarker(bodyLines, heading.lineIndex + 2, heading.lineIndex + 1);
|
||||
const bodyMarkdown = trimBlankEdges(extractedMarker.bodyLines).join("\n");
|
||||
|
||||
drafts.push({
|
||||
source: {
|
||||
filePath: sourceFile.path,
|
||||
sourceContent: sourceFile.content,
|
||||
headingLine: heading.lineIndex + 1,
|
||||
blockStartLine: heading.lineIndex + 1,
|
||||
bodyStartLine: heading.lineIndex + 2,
|
||||
blockEndLine: blockEndLineIndex,
|
||||
contentEndLine: extractedMarker.contentEndLine,
|
||||
markerLine: extractedMarker.markerLine,
|
||||
headingLevel: heading.level,
|
||||
headingText: heading.text,
|
||||
},
|
||||
heading: heading.text,
|
||||
headingLevel: heading.level,
|
||||
type: cardType,
|
||||
bodyMarkdown,
|
||||
embeddedNoteId: extractedMarker.embeddedNoteId,
|
||||
deckHint: targetDeck ? createDeckName(targetDeck) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return drafts;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateHeadingPolicy(headingPolicy: HeadingPolicy): void {
|
||||
const { qaHeadingLevel, clozeHeadingLevel } = headingPolicy;
|
||||
|
||||
if (!Number.isInteger(qaHeadingLevel) || qaHeadingLevel < 1 || qaHeadingLevel > 6) {
|
||||
throw new Error("QA heading level must be an integer between 1 and 6.");
|
||||
}
|
||||
|
||||
if (!Number.isInteger(clozeHeadingLevel) || clozeHeadingLevel < 1 || clozeHeadingLevel > 6) {
|
||||
throw new Error("Cloze heading level must be an integer between 1 and 6.");
|
||||
}
|
||||
|
||||
if (qaHeadingLevel === clozeHeadingLevel) {
|
||||
throw new Error("QA and Cloze heading levels must not be equal.");
|
||||
}
|
||||
}
|
||||
|
||||
function collectHeadings(lines: string[]): HeadingMatch[] {
|
||||
const headings: HeadingMatch[] = [];
|
||||
let fenceMarker: string | null = null;
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
||||
const line = lines[lineIndex];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
|
||||
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fenceMarker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(HEADING_REGEXP);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
headings.push({
|
||||
level: match[1].length,
|
||||
text: match[2].replace(/\s+#+\s*$/, "").trim(),
|
||||
lineIndex,
|
||||
});
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
function extractTargetDeck(lines: string[]): string | undefined {
|
||||
let fenceMarker: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
|
||||
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fenceMarker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(TARGET_DECK_REGEXP);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getCardTypeForHeading(level: number, headingPolicy: HeadingPolicy): "basic" | "cloze" | null {
|
||||
if (level === headingPolicy.qaHeadingLevel) {
|
||||
return "basic";
|
||||
}
|
||||
|
||||
if (level === headingPolicy.clozeHeadingLevel) {
|
||||
return "cloze";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findBlockEndLineIndex(headings: HeadingMatch[], currentHeadingIndex: number, totalLineCount: number): number {
|
||||
const currentHeading = headings[currentHeadingIndex];
|
||||
|
||||
for (let headingIndex = currentHeadingIndex + 1; headingIndex < headings.length; headingIndex += 1) {
|
||||
const nextHeading = headings[headingIndex];
|
||||
if (nextHeading.level <= currentHeading.level) {
|
||||
return nextHeading.lineIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return totalLineCount;
|
||||
}
|
||||
|
||||
function trimBlankEdges(lines: string[]): string[] {
|
||||
let startIndex = 0;
|
||||
let endIndex = lines.length;
|
||||
|
||||
while (startIndex < endIndex && !lines[startIndex].trim()) {
|
||||
startIndex += 1;
|
||||
}
|
||||
|
||||
while (endIndex > startIndex && !lines[endIndex - 1].trim()) {
|
||||
endIndex -= 1;
|
||||
}
|
||||
|
||||
return lines.slice(startIndex, endIndex);
|
||||
}
|
||||
|
||||
function extractEmbeddedMarker(bodyLines: string[], bodyStartLine: number, headingLine: number): ExtractedMarkerResult {
|
||||
const ahsCandidates = bodyLines
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter(({ line }) => AHS_MARKER_CANDIDATE_REGEXP.test(line));
|
||||
const validMarkers = ahsCandidates.filter(({ line }) => VALID_AHS_MARKER_REGEXP.test(line));
|
||||
|
||||
if (validMarkers.length > 1) {
|
||||
throw new Error(`Multiple AHS markers found in heading block at line ${headingLine}.`);
|
||||
}
|
||||
|
||||
let lastNonEmptyIndex = bodyLines.length - 1;
|
||||
while (lastNonEmptyIndex >= 0 && !bodyLines[lastNonEmptyIndex].trim()) {
|
||||
lastNonEmptyIndex -= 1;
|
||||
}
|
||||
|
||||
if (lastNonEmptyIndex < 0) {
|
||||
if (ahsCandidates.length > 0) {
|
||||
throw new Error(`Invalid AHS marker found in heading block at line ${headingLine}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
bodyLines,
|
||||
contentEndLine: headingLine,
|
||||
};
|
||||
}
|
||||
|
||||
const lastNonEmptyLine = bodyLines[lastNonEmptyIndex];
|
||||
const validMarkerMatch = lastNonEmptyLine.match(VALID_AHS_MARKER_REGEXP);
|
||||
|
||||
if (validMarkerMatch) {
|
||||
const markerIndex = lastNonEmptyIndex;
|
||||
|
||||
for (const candidate of ahsCandidates) {
|
||||
if (candidate.index === markerIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Multiple or misplaced AHS markers found in heading block at line ${headingLine}.`);
|
||||
}
|
||||
|
||||
const nextBodyLines = bodyLines.filter((_line, index) => index !== markerIndex);
|
||||
const contentEndLine = findContentEndLine(nextBodyLines, bodyStartLine, headingLine);
|
||||
|
||||
return {
|
||||
bodyLines: nextBodyLines,
|
||||
embeddedNoteId: Number(validMarkerMatch[1]),
|
||||
contentEndLine,
|
||||
markerLine: bodyStartLine + markerIndex,
|
||||
};
|
||||
}
|
||||
|
||||
if (AHS_MARKER_CANDIDATE_REGEXP.test(lastNonEmptyLine) || ahsCandidates.length > 0) {
|
||||
throw new Error(`Invalid AHS marker found in heading block at line ${headingLine}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
bodyLines,
|
||||
contentEndLine: findContentEndLine(bodyLines, bodyStartLine, headingLine),
|
||||
};
|
||||
}
|
||||
|
||||
function findContentEndLine(bodyLines: string[], bodyStartLine: number, headingLine: number): number {
|
||||
for (let index = bodyLines.length - 1; index >= 0; index -= 1) {
|
||||
if (bodyLines[index].trim()) {
|
||||
return bodyStartLine + index;
|
||||
}
|
||||
}
|
||||
|
||||
return headingLine;
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceResolver";
|
||||
|
||||
import type { CardDraft } from "../entities/CardDraft";
|
||||
import { CardRenderingService } from "./CardRenderingService";
|
||||
|
||||
const resolver: RenderResourceResolver = {
|
||||
resolveWikiLink(rawTarget) {
|
||||
if (rawTarget.startsWith("Concept")) {
|
||||
return {
|
||||
url: "obsidian://open?vault=Vault&file=Concept",
|
||||
displayText: rawTarget.includes("|") ? rawTarget.split("|")[1] : "Concept",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
resolveEmbed(rawTarget) {
|
||||
if (rawTarget.startsWith("diagram.png")) {
|
||||
return {
|
||||
kind: "image",
|
||||
fileName: "diagram.png",
|
||||
absolutePath: "/vault/diagram.png",
|
||||
altText: "diagram.png",
|
||||
};
|
||||
}
|
||||
|
||||
if (rawTarget.startsWith("clip.mp3")) {
|
||||
return {
|
||||
kind: "audio",
|
||||
fileName: "clip.mp3",
|
||||
absolutePath: "/vault/clip.mp3",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
createBacklink(location) {
|
||||
return `obsidian://open?vault=Vault&file=${encodeURIComponent(location.filePath)}`;
|
||||
},
|
||||
};
|
||||
|
||||
function createDraft(overrides: Partial<CardDraft> = {}): CardDraft {
|
||||
return {
|
||||
source: {
|
||||
filePath: "notes/example.md",
|
||||
headingLine: 3,
|
||||
blockStartLine: 3,
|
||||
bodyStartLine: 4,
|
||||
blockEndLine: 8,
|
||||
contentEndLine: 7,
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
},
|
||||
heading: "Prompt",
|
||||
headingLevel: 4,
|
||||
type: "basic",
|
||||
bodyMarkdown: "Answer",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CardRenderingService", () => {
|
||||
it("renders a basic card with default deck, note model, and backlink", () => {
|
||||
const service = new CardRenderingService();
|
||||
const card = service.render(createDraft(), {
|
||||
defaultDeck: "Default",
|
||||
qaNoteType: "Basic",
|
||||
clozeNoteType: "Cloze",
|
||||
addObsidianBacklink: true,
|
||||
convertHighlightsToCloze: true,
|
||||
resourceResolver: resolver,
|
||||
});
|
||||
|
||||
expect(card.deck).toBe("Default");
|
||||
expect(card.noteModel).toBe("Basic");
|
||||
expect(card.renderedFields.title).toContain("Prompt");
|
||||
expect(card.renderedFields.body).toContain("Open in Obsidian");
|
||||
expect(card.fields.title).toContain("Prompt");
|
||||
expect(card.fields.body).toContain("Open in Obsidian");
|
||||
expect(card.contentHash).toMatch(/^[0-9a-f]{8}$/);
|
||||
});
|
||||
|
||||
it("keeps the raw heading text in the rendered title when the heading ends with a tag", () => {
|
||||
const service = new CardRenderingService();
|
||||
const card = service.render(
|
||||
createDraft({
|
||||
source: {
|
||||
filePath: "notes/example.md",
|
||||
headingLine: 3,
|
||||
blockStartLine: 3,
|
||||
bodyStartLine: 4,
|
||||
blockEndLine: 8,
|
||||
contentEndLine: 7,
|
||||
headingLevel: 4,
|
||||
headingText: "什么的会人? #3地区",
|
||||
},
|
||||
heading: "什么的会人? #3地区",
|
||||
}),
|
||||
{
|
||||
defaultDeck: "Default",
|
||||
qaNoteType: "Basic",
|
||||
clozeNoteType: "Cloze",
|
||||
addObsidianBacklink: true,
|
||||
convertHighlightsToCloze: true,
|
||||
resourceResolver: resolver,
|
||||
},
|
||||
);
|
||||
|
||||
expect(card.renderedFields.title).toContain("什么的会人? #3地区");
|
||||
expect(card.fields.title).toContain("什么的会人? #3地区");
|
||||
expect(card.fields.body).toContain("Open in Obsidian");
|
||||
});
|
||||
|
||||
it("renders a cloze card as title/body fragments before mapping", () => {
|
||||
const service = new CardRenderingService();
|
||||
const card = service.render(
|
||||
createDraft({
|
||||
type: "cloze",
|
||||
headingLevel: 5,
|
||||
source: {
|
||||
filePath: "notes/example.md",
|
||||
headingLine: 5,
|
||||
blockStartLine: 5,
|
||||
bodyStartLine: 6,
|
||||
blockEndLine: 7,
|
||||
contentEndLine: 6,
|
||||
headingLevel: 5,
|
||||
headingText: "Context",
|
||||
},
|
||||
heading: "Context",
|
||||
bodyMarkdown: "Use ==ubiquitous language== in the team.",
|
||||
}),
|
||||
{
|
||||
defaultDeck: "Default",
|
||||
qaNoteType: "Basic",
|
||||
clozeNoteType: "Cloze",
|
||||
addObsidianBacklink: true,
|
||||
convertHighlightsToCloze: true,
|
||||
resourceResolver: resolver,
|
||||
},
|
||||
);
|
||||
|
||||
expect(card.noteModel).toBe("Cloze");
|
||||
expect(card.renderedFields.title).toContain("Context");
|
||||
expect(card.renderedFields.body).toContain("{{c1::ubiquitous language}}");
|
||||
expect(card.renderedFields.body).toContain("Open in Obsidian");
|
||||
});
|
||||
|
||||
it("preserves markdown fidelity for math, code, links, and media where feasible", () => {
|
||||
const service = new CardRenderingService();
|
||||
const card = service.render(
|
||||
createDraft({
|
||||
bodyMarkdown: [
|
||||
"Inline math $a+b$ and display:",
|
||||
"",
|
||||
"$$",
|
||||
"x^2",
|
||||
"$$",
|
||||
"",
|
||||
"`const value = 1`",
|
||||
"",
|
||||
"```ts",
|
||||
"const value = 1;",
|
||||
"```",
|
||||
"",
|
||||
"[[Concept|Read more]]",
|
||||
"",
|
||||
"![[diagram.png]]",
|
||||
"",
|
||||
"![[clip.mp3]]",
|
||||
].join("\n"),
|
||||
}),
|
||||
{
|
||||
defaultDeck: "Default",
|
||||
qaNoteType: "Basic",
|
||||
clozeNoteType: "Cloze",
|
||||
addObsidianBacklink: false,
|
||||
convertHighlightsToCloze: true,
|
||||
resourceResolver: resolver,
|
||||
},
|
||||
);
|
||||
|
||||
expect(card.fields.body).toContain("\\(a+b\\)");
|
||||
expect(card.fields.body).toContain("\\[x^2\\]");
|
||||
expect(card.fields.body).toContain("<code>const value = 1</code>");
|
||||
expect(card.fields.body).toContain("language-ts");
|
||||
expect(card.fields.body).toContain("obsidian://open?vault=Vault&file=Concept");
|
||||
expect(card.fields.body).toContain("<img src=\"diagram.png\" alt=\"diagram.png\">");
|
||||
expect(card.fields.body).toContain("[sound:clip.mp3]");
|
||||
expect(card.media).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import MarkdownIt from "markdown-it";
|
||||
|
||||
import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceResolver";
|
||||
import { isClozeCardType } from "@/domain/card/entities/RenderedFields";
|
||||
import { hashString } from "@/domain/shared/hash";
|
||||
|
||||
import type { Card } from "../entities/Card";
|
||||
import type { CardDraft } from "../entities/CardDraft";
|
||||
import type { MediaAsset, RenderedFields } from "../entities/RenderedFields";
|
||||
import { CardIdentityPolicy } from "../policies/CardIdentityPolicy";
|
||||
import { createContentHash } from "../value-objects/ContentHash";
|
||||
import { createNoteModelName } from "../value-objects/NoteModelName";
|
||||
import { DeckResolutionService } from "./DeckResolutionService";
|
||||
|
||||
const markdown = new MarkdownIt({
|
||||
breaks: true,
|
||||
html: true,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
const INLINE_CODE_PATTERN = /`[^`\n]+`/g;
|
||||
const FENCED_CODE_PATTERN = /```[\s\S]*?```|~~~[\s\S]*?~~~/g;
|
||||
const DISPLAY_MATH_PATTERN = /(?<!\\)\$\$([\s\S]+?)(?<!\\)\$\$/g;
|
||||
const INLINE_MATH_PATTERN = /(?<!\\)\$(?!\s)([^$\n]+?)(?<!\s)\$/g;
|
||||
const ANKI_MATH_PATTERN = /(\\\[[\s\S]*?\\\])|(\\\([\s\S]*?\\\))/g;
|
||||
const HIGHLIGHT_PATTERN = /==(.+?)==/g;
|
||||
const CLOZE_PATTERN = /(?:(?<!{){(?:c?(\d+)[:|])?(?!{))((?:[^\n][\n]?)+?)(?:(?<!})}(?!}))/g;
|
||||
const EMBED_PATTERN = /!\[\[([^\]]+)\]\]/g;
|
||||
const WIKILINK_PATTERN = /(?<!!)\[\[([^\]]+)\]\]/g;
|
||||
|
||||
export interface CardRenderingContext {
|
||||
defaultDeck: string;
|
||||
qaNoteType: string;
|
||||
clozeNoteType: string;
|
||||
addObsidianBacklink: boolean;
|
||||
convertHighlightsToCloze: boolean;
|
||||
resourceResolver: RenderResourceResolver;
|
||||
}
|
||||
|
||||
interface RenderResult {
|
||||
html: string;
|
||||
media: MediaAsset[];
|
||||
}
|
||||
|
||||
export class CardRenderingService {
|
||||
constructor(
|
||||
private readonly identityPolicy = new CardIdentityPolicy(),
|
||||
private readonly deckResolutionService = new DeckResolutionService(),
|
||||
) {}
|
||||
|
||||
render(draft: CardDraft, context: CardRenderingContext): Card {
|
||||
const deck = this.deckResolutionService.resolve(draft.deckHint, context.defaultDeck);
|
||||
const noteModel = createNoteModelName(isClozeCardType(draft.type) ? context.clozeNoteType : context.qaNoteType);
|
||||
const headingResult = this.renderMarkdown(draft.heading, draft, context, false, true);
|
||||
const bodyResult = this.renderMarkdown(draft.bodyMarkdown, draft, context, isClozeCardType(draft.type), false);
|
||||
const backlinkHtml = context.addObsidianBacklink
|
||||
? `<p><a class="anki-heading-sync-backlink" href="${escapeHtml(context.resourceResolver.createBacklink(draft.source))}">Open in Obsidian</a></p>`
|
||||
: "";
|
||||
|
||||
const renderedFields: RenderedFields = {
|
||||
title: headingResult.html,
|
||||
body: bodyResult.html + backlinkHtml,
|
||||
};
|
||||
|
||||
const fields = { ...renderedFields };
|
||||
const contentHash = createContentHash(
|
||||
hashString(
|
||||
JSON.stringify({
|
||||
deck,
|
||||
fields,
|
||||
heading: draft.heading,
|
||||
noteModel,
|
||||
sourcePath: draft.source.filePath,
|
||||
type: draft.type,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
key: this.identityPolicy.create(draft.source, draft.type),
|
||||
source: draft.source,
|
||||
type: draft.type,
|
||||
heading: draft.heading,
|
||||
bodyMarkdown: draft.bodyMarkdown,
|
||||
embeddedNoteId: draft.embeddedNoteId,
|
||||
deck,
|
||||
noteModel,
|
||||
tags: [],
|
||||
renderedFields,
|
||||
fields,
|
||||
contentHash,
|
||||
media: dedupeMedia([...headingResult.media, ...bodyResult.media]),
|
||||
};
|
||||
}
|
||||
|
||||
private renderMarkdown(
|
||||
markdownText: string,
|
||||
draft: CardDraft,
|
||||
context: CardRenderingContext,
|
||||
cloze: boolean,
|
||||
inline: boolean,
|
||||
): RenderResult {
|
||||
const protectedBlocks = protectSegments(markdownText, FENCED_CODE_PATTERN, "FENCED_CODE");
|
||||
const protectedInline = protectSegments(protectedBlocks.text, INLINE_CODE_PATTERN, "INLINE_CODE");
|
||||
let transformed = protectedInline.text;
|
||||
const media: MediaAsset[] = [];
|
||||
let nextClozeIndex = 1;
|
||||
|
||||
transformed = transformed.replace(DISPLAY_MATH_PATTERN, (_match, content: string) => `\\[${content.trim()}\\]`);
|
||||
transformed = transformed.replace(INLINE_MATH_PATTERN, (_match, content: string) => `\\(${content.trim()}\\)`);
|
||||
|
||||
if (cloze && context.convertHighlightsToCloze) {
|
||||
transformed = transformed.replace(HIGHLIGHT_PATTERN, "{$1}");
|
||||
}
|
||||
|
||||
if (cloze) {
|
||||
transformed = transformed.replace(CLOZE_PATTERN, (_match, explicitIndex: string | undefined, content: string) => {
|
||||
const clozeIndex = explicitIndex ? Number(explicitIndex) : nextClozeIndex++;
|
||||
return `{{c${clozeIndex}::${content}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
transformed = transformed.replace(EMBED_PATTERN, (_match, rawTarget: string) => {
|
||||
const resolvedEmbed = context.resourceResolver.resolveEmbed(rawTarget, draft.source.filePath);
|
||||
|
||||
if (!resolvedEmbed) {
|
||||
return _match;
|
||||
}
|
||||
|
||||
media.push({
|
||||
kind: resolvedEmbed.kind,
|
||||
fileName: resolvedEmbed.fileName,
|
||||
absolutePath: resolvedEmbed.absolutePath,
|
||||
altText: resolvedEmbed.altText,
|
||||
});
|
||||
|
||||
if (resolvedEmbed.kind === "audio") {
|
||||
return `[sound:${resolvedEmbed.fileName}]`;
|
||||
}
|
||||
|
||||
return `<img src="${escapeHtml(resolvedEmbed.fileName)}" alt="${escapeHtml(resolvedEmbed.altText ?? resolvedEmbed.fileName)}">`;
|
||||
});
|
||||
|
||||
transformed = transformed.replace(WIKILINK_PATTERN, (_match, rawTarget: string) => {
|
||||
const resolvedLink = context.resourceResolver.resolveWikiLink(rawTarget, draft.source.filePath);
|
||||
|
||||
if (!resolvedLink) {
|
||||
return _match;
|
||||
}
|
||||
|
||||
return `<a href="${escapeHtml(resolvedLink.url)}">${escapeHtml(resolvedLink.displayText)}</a>`;
|
||||
});
|
||||
|
||||
transformed = transformed.replace(HIGHLIGHT_PATTERN, "<mark>$1</mark>");
|
||||
const protectedMath = protectSegments(transformed, ANKI_MATH_PATTERN, "MATH");
|
||||
transformed = protectedMath.text;
|
||||
transformed = protectedInline.restore(transformed);
|
||||
transformed = protectedBlocks.restore(transformed);
|
||||
|
||||
const renderedHtml = (inline ? markdown.renderInline(transformed) : markdown.render(transformed)).trim();
|
||||
|
||||
return {
|
||||
html: protectedMath.restore(renderedHtml, escapeHtml),
|
||||
media,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function protectSegments(
|
||||
text: string,
|
||||
pattern: RegExp,
|
||||
prefix: string,
|
||||
): { text: string; restore: (value: string, formatter?: (segment: string) => string) => string } {
|
||||
const matches: string[] = [];
|
||||
const nextText = text.replace(pattern, (segment) => {
|
||||
const token = `@@${prefix}_${matches.length}@@`;
|
||||
matches.push(segment);
|
||||
return token;
|
||||
});
|
||||
|
||||
return {
|
||||
text: nextText,
|
||||
restore: (value: string, formatter = (segment: string) => segment) =>
|
||||
matches.reduce((current, segment, index) => current.split(`@@${prefix}_${index}@@`).join(formatter(segment)), value),
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeMedia(media: MediaAsset[]): MediaAsset[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return media.filter((asset) => {
|
||||
const key = `${asset.kind}:${asset.absolutePath}:${asset.fileName}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DeckResolutionService } from "./DeckResolutionService";
|
||||
|
||||
describe("DeckResolutionService", () => {
|
||||
it("prefers the file deck hint", () => {
|
||||
const service = new DeckResolutionService();
|
||||
|
||||
expect(service.resolve("Scoped::Deck", "Default")).toBe("Scoped::Deck");
|
||||
});
|
||||
|
||||
it("falls back to the default deck", () => {
|
||||
const service = new DeckResolutionService();
|
||||
|
||||
expect(service.resolve(undefined, "Default")).toBe("Default");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { createDeckName, type DeckName } from "../value-objects/DeckName";
|
||||
|
||||
export class DeckResolutionService {
|
||||
resolve(deckHint: string | undefined, defaultDeck: string): DeckName {
|
||||
const preferredDeck = deckHint?.trim() || defaultDeck.trim();
|
||||
|
||||
if (!preferredDeck) {
|
||||
throw new Error("A card deck could not be resolved.");
|
||||
}
|
||||
|
||||
return createDeckName(preferredDeck);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import type { CardKey } from "../../card/value-objects/CardKey";
|
||||
import type { ContentHash } from "../../card/value-objects/ContentHash";
|
||||
|
||||
export type SyncIdentityMode = "embedded-note-id" | "legacy-card-key" | "pending-note-id-write";
|
||||
|
||||
export interface SyncRecord {
|
||||
cardKey: CardKey;
|
||||
identityMode: SyncIdentityMode;
|
||||
legacyCardKey?: CardKey;
|
||||
noteId: number;
|
||||
filePath: string;
|
||||
sourceHash: ContentHash;
|
||||
lastSyncedAt: number;
|
||||
orphan: boolean;
|
||||
}
|
||||
|
|
@ -1,81 +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 { SyncRegistry } from "./SyncRegistry";
|
||||
|
||||
describe("SyncRegistry", () => {
|
||||
it("refreshes an existing record and clears orphan state", () => {
|
||||
const registry = new SyncRegistry([
|
||||
{
|
||||
cardKey: createCardKey("card-1"),
|
||||
identityMode: "legacy-card-key",
|
||||
noteId: 101,
|
||||
filePath: "notes/old.md",
|
||||
sourceHash: createContentHash("old-hash"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: true,
|
||||
},
|
||||
]);
|
||||
|
||||
registry.refresh(createCardKey("card-1"), "notes/new.md", createContentHash("new-hash"), 10);
|
||||
|
||||
expect(registry.get(createCardKey("card-1"))).toEqual({
|
||||
cardKey: createCardKey("card-1"),
|
||||
identityMode: "legacy-card-key",
|
||||
noteId: 101,
|
||||
filePath: "notes/new.md",
|
||||
sourceHash: createContentHash("new-hash"),
|
||||
lastSyncedAt: 10,
|
||||
orphan: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks an existing record as orphan without deleting it", () => {
|
||||
const registry = new SyncRegistry([
|
||||
{
|
||||
cardKey: createCardKey("card-1"),
|
||||
identityMode: "legacy-card-key",
|
||||
noteId: 101,
|
||||
filePath: "notes/example.md",
|
||||
sourceHash: createContentHash("hash"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
},
|
||||
]);
|
||||
|
||||
registry.markOrphan(createCardKey("card-1"), 20);
|
||||
|
||||
expect(registry.get(createCardKey("card-1"))?.orphan).toBe(true);
|
||||
expect(registry.get(createCardKey("card-1"))?.noteId).toBe(101);
|
||||
});
|
||||
|
||||
it("reassigns a note id to the latest card key", () => {
|
||||
const registry = new SyncRegistry([
|
||||
{
|
||||
cardKey: createCardKey("card-1"),
|
||||
identityMode: "embedded-note-id",
|
||||
noteId: 101,
|
||||
filePath: "notes/example.md",
|
||||
sourceHash: createContentHash("hash"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
},
|
||||
]);
|
||||
|
||||
registry.recordSync({
|
||||
cardKey: createCardKey("card-2"),
|
||||
identityMode: "embedded-note-id",
|
||||
noteId: 101,
|
||||
filePath: "notes/renamed.md",
|
||||
sourceHash: createContentHash("hash"),
|
||||
lastSyncedAt: 2,
|
||||
orphan: false,
|
||||
});
|
||||
|
||||
expect(registry.get(createCardKey("card-1"))).toBeUndefined();
|
||||
expect(registry.get(createCardKey("card-2"))?.noteId).toBe(101);
|
||||
expect(registry.findByNoteId(101)?.cardKey).toBe(createCardKey("card-2"));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import type { CardKey } from "../../card/value-objects/CardKey";
|
||||
import type { ContentHash } from "../../card/value-objects/ContentHash";
|
||||
import type { SyncRecord } from "./SyncRecord";
|
||||
|
||||
export class SyncRegistry {
|
||||
private readonly recordsByCardKey = new Map<CardKey, SyncRecord>();
|
||||
private readonly cardKeysByNoteId = new Map<number, CardKey>();
|
||||
|
||||
constructor(records: SyncRecord[] = []) {
|
||||
for (const record of records) {
|
||||
this.upsert(record);
|
||||
}
|
||||
}
|
||||
|
||||
get(cardKey: CardKey): SyncRecord | undefined {
|
||||
return this.recordsByCardKey.get(cardKey);
|
||||
}
|
||||
|
||||
findByNoteId(noteId: number): SyncRecord | undefined {
|
||||
const cardKey = this.cardKeysByNoteId.get(noteId);
|
||||
return cardKey ? this.recordsByCardKey.get(cardKey) : undefined;
|
||||
}
|
||||
|
||||
list(): SyncRecord[] {
|
||||
return Array.from(this.recordsByCardKey.values());
|
||||
}
|
||||
|
||||
recordSync(record: SyncRecord): void {
|
||||
this.upsert({
|
||||
...record,
|
||||
orphan: false,
|
||||
});
|
||||
}
|
||||
|
||||
refresh(cardKey: CardKey, filePath: string, sourceHash: ContentHash, lastSyncedAt: number): void {
|
||||
const existing = this.requireRecord(cardKey);
|
||||
|
||||
this.upsert({
|
||||
...existing,
|
||||
filePath,
|
||||
sourceHash,
|
||||
lastSyncedAt,
|
||||
orphan: false,
|
||||
});
|
||||
}
|
||||
|
||||
markOrphan(cardKey: CardKey, lastSyncedAt: number): void {
|
||||
const existing = this.requireRecord(cardKey);
|
||||
|
||||
this.upsert({
|
||||
...existing,
|
||||
lastSyncedAt,
|
||||
orphan: true,
|
||||
});
|
||||
}
|
||||
|
||||
upsert(record: SyncRecord): void {
|
||||
const existingForNoteId = this.cardKeysByNoteId.get(record.noteId);
|
||||
|
||||
if (existingForNoteId && existingForNoteId !== record.cardKey) {
|
||||
this.recordsByCardKey.delete(existingForNoteId);
|
||||
}
|
||||
|
||||
const previous = this.recordsByCardKey.get(record.cardKey);
|
||||
if (previous && previous.noteId !== record.noteId) {
|
||||
this.cardKeysByNoteId.delete(previous.noteId);
|
||||
}
|
||||
|
||||
this.recordsByCardKey.set(record.cardKey, record);
|
||||
this.cardKeysByNoteId.set(record.noteId, record.cardKey);
|
||||
}
|
||||
|
||||
private requireRecord(cardKey: CardKey): SyncRecord {
|
||||
const existing = this.recordsByCardKey.get(cardKey);
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(`Sync record not found for card key ${cardKey}.`);
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Card } from "../../card/entities/Card";
|
||||
import { createCardKey } from "../../card/value-objects/CardKey";
|
||||
import { createContentHash } from "../../card/value-objects/ContentHash";
|
||||
import { createDeckName } from "../../card/value-objects/DeckName";
|
||||
import { createNoteModelName } from "../../card/value-objects/NoteModelName";
|
||||
import { SyncRegistry } from "../entities/SyncRegistry";
|
||||
import type { SyncRecord } from "../entities/SyncRecord";
|
||||
import { SyncPlanningService } from "./SyncPlanningService";
|
||||
|
||||
function createCard(overrides: Partial<Card> = {}): Card {
|
||||
return {
|
||||
key: createCardKey("card-a"),
|
||||
source: {
|
||||
filePath: "notes/example.md",
|
||||
sourceContent: ["#### Prompt", "Answer"].join("\n"),
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
},
|
||||
type: "basic",
|
||||
heading: "Prompt",
|
||||
bodyMarkdown: "Answer",
|
||||
embeddedNoteId: undefined,
|
||||
deck: createDeckName("Deck"),
|
||||
noteModel: createNoteModelName("Basic"),
|
||||
tags: [],
|
||||
renderedFields: {
|
||||
title: "Prompt",
|
||||
body: "Answer",
|
||||
},
|
||||
fields: {
|
||||
title: "Prompt",
|
||||
body: "Answer",
|
||||
},
|
||||
contentHash: createContentHash("hash-a"),
|
||||
media: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRecord(overrides: Partial<SyncRecord> = {}): SyncRecord {
|
||||
return {
|
||||
cardKey: createCardKey("card-a"),
|
||||
identityMode: "legacy-card-key",
|
||||
noteId: 100,
|
||||
filePath: "notes/example.md",
|
||||
sourceHash: createContentHash("hash-a"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SyncPlanningService", () => {
|
||||
it("plans adds, updates, and orphan marking", () => {
|
||||
const service = new SyncPlanningService();
|
||||
const changedCard = createCard({ contentHash: createContentHash("hash-b") });
|
||||
const newCard = createCard({
|
||||
key: createCardKey("card-b"),
|
||||
contentHash: createContentHash("hash-c"),
|
||||
source: {
|
||||
filePath: "notes/second.md",
|
||||
sourceContent: ["#### Another", "Answer"].join("\n"),
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
headingLevel: 4,
|
||||
headingText: "Another",
|
||||
},
|
||||
heading: "Another",
|
||||
});
|
||||
const registry = new SyncRegistry([
|
||||
createRecord(),
|
||||
createRecord({
|
||||
cardKey: createCardKey("orphan-card"),
|
||||
noteId: 101,
|
||||
filePath: "notes/orphan.md",
|
||||
sourceHash: createContentHash("old"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const plan = service.plan([changedCard, newCard], registry, ["notes/example.md", "notes/second.md", "notes/orphan.md"]);
|
||||
|
||||
expect(plan.toAdd).toHaveLength(1);
|
||||
expect(plan.toUpdate).toHaveLength(1);
|
||||
expect(plan.toMarkOrphan).toHaveLength(1);
|
||||
expect(plan.toCreateDecks).toEqual([createDeckName("Deck")]);
|
||||
});
|
||||
|
||||
it("rejects duplicate card keys in one scan", () => {
|
||||
const service = new SyncPlanningService();
|
||||
const card = createCard();
|
||||
|
||||
expect(() => service.plan([card, card], new SyncRegistry(), ["notes/example.md"])).toThrow(
|
||||
"Duplicate card key detected in current scan",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers embedded note id over legacy card key and tolerates registry loss", () => {
|
||||
const service = new SyncPlanningService();
|
||||
const embeddedCard = createCard({
|
||||
key: createCardKey("moved-card-key"),
|
||||
embeddedNoteId: 222,
|
||||
source: {
|
||||
...createCard().source,
|
||||
filePath: "notes/moved.md",
|
||||
},
|
||||
});
|
||||
|
||||
const planWithoutRegistry = service.plan([embeddedCard], new SyncRegistry(), ["notes/moved.md"]);
|
||||
|
||||
expect(planWithoutRegistry.toAdd).toHaveLength(0);
|
||||
expect(planWithoutRegistry.toUpdate).toEqual([{ card: embeddedCard, noteId: 222 }]);
|
||||
});
|
||||
|
||||
it("marks embedded and legacy records orphan using different identity modes", () => {
|
||||
const service = new SyncPlanningService();
|
||||
const registry = new SyncRegistry([
|
||||
createRecord({
|
||||
cardKey: createCardKey("legacy-card"),
|
||||
noteId: 100,
|
||||
filePath: "notes/legacy.md",
|
||||
}),
|
||||
createRecord({
|
||||
cardKey: createCardKey("embedded-card"),
|
||||
identityMode: "embedded-note-id",
|
||||
noteId: 200,
|
||||
filePath: "notes/embedded.md",
|
||||
}),
|
||||
]);
|
||||
|
||||
const embeddedCard = createCard({
|
||||
key: createCardKey("different-card-key"),
|
||||
embeddedNoteId: 200,
|
||||
source: {
|
||||
...createCard().source,
|
||||
filePath: "notes/embedded.md",
|
||||
},
|
||||
});
|
||||
|
||||
const plan = service.plan([embeddedCard], registry, ["notes/legacy.md", "notes/embedded.md"]);
|
||||
|
||||
expect(plan.toMarkOrphan.map((record) => record.noteId)).toEqual([100]);
|
||||
});
|
||||
|
||||
it("forces pending note-id-write records back through update flow", () => {
|
||||
const service = new SyncPlanningService();
|
||||
const card = createCard();
|
||||
const registry = new SyncRegistry([
|
||||
createRecord({
|
||||
cardKey: card.key,
|
||||
identityMode: "pending-note-id-write",
|
||||
legacyCardKey: card.key,
|
||||
}),
|
||||
]);
|
||||
|
||||
const plan = service.plan([card], registry, ["notes/example.md"]);
|
||||
|
||||
expect(plan.toUpdate).toEqual([{ card, noteId: 100 }]);
|
||||
});
|
||||
|
||||
it("prefers pending note-id-write over a stale embedded marker", () => {
|
||||
const service = new SyncPlanningService();
|
||||
const card = createCard({
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...createCard().source,
|
||||
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"),
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
});
|
||||
const registry = new SyncRegistry([
|
||||
createRecord({
|
||||
cardKey: card.key,
|
||||
identityMode: "pending-note-id-write",
|
||||
noteId: 9001,
|
||||
legacyCardKey: card.key,
|
||||
}),
|
||||
]);
|
||||
|
||||
const plan = service.plan([card], registry, ["notes/example.md"]);
|
||||
|
||||
expect(plan.toUpdate).toEqual([{ card, noteId: 9001 }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string>();
|
||||
const seenEmbeddedNoteIds = new Set<number>();
|
||||
const createDecks = new Map<string, Card["deck"]>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -6,18 +6,6 @@ import type { PluginState } from "@/domain/manual-sync/entities/PluginState";
|
|||
export interface PluginDataSnapshot {
|
||||
settings?: Partial<PluginSettings>;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<PluginDataSnapshot> {
|
||||
private snapshot: PluginDataSnapshot | null = null;
|
||||
|
||||
async load(): Promise<PluginDataSnapshot | null> {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
async save(data: PluginDataSnapshot): Promise<void> {
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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<PluginDataSnapshot>) {}
|
||||
|
||||
async load(): Promise<SyncRegistry> {
|
||||
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<void> {
|
||||
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,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue