mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
feat: Module2 — AHS 写回与相关测试
This commit is contained in:
parent
5099b39d3f
commit
7c2c6776bd
32 changed files with 1461 additions and 106 deletions
149
docs/2026-04-17PLAN3.md
Normal file
149
docs/2026-04-17PLAN3.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# 模块 2:将 Anki `noteId` 回写到标题块末尾,使用短标记 `<!-- AHS:123 -->`
|
||||
|
||||
## Summary
|
||||
|
||||
- 新增卡片同步成功后,把返回的 `Anki noteId` 写回对应标题块的块尾,格式固定为 `<!-- AHS:<noteId> -->`。
|
||||
- 以后该标题块再次同步时,优先按这个 `noteId` 更新原卡,不再依赖 `标题 + 行号 + 路径`。
|
||||
- 标记放在块尾而不是标题下方,用来明确卡片边界。
|
||||
- 老卡不迁移,只对以后新增的卡生效;老卡继续走当前 `cardKey` 逻辑。
|
||||
- 如果块尾 `noteId` 在 Anki 中不存在,自动重建新卡并替换为新的 `noteId`。
|
||||
- 如果同一标题块从 `basic` 切到 `cloze` 或反过来,直接报错并要求用户重建。
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. 标记语法与块尾规则
|
||||
|
||||
- 唯一支持的标记格式:
|
||||
- `<!-- AHS:<正整数> -->`
|
||||
- 标记位置固定:
|
||||
- 当前标题块最后一个非空内容行之后
|
||||
- 下一个同级或更高层级标题之前
|
||||
- 写回规则固定:
|
||||
- 有正文时:写在正文末尾
|
||||
- 无正文时:写在标题下一行
|
||||
- 标记本身视为块尾终止标记
|
||||
- 读取规则固定:
|
||||
- 只识别当前标题块块尾的最后一个合法 `AHS` 标记
|
||||
- 标记不进入 `bodyMarkdown`
|
||||
- 同一标题块多个合法 `AHS` 标记时报错
|
||||
- 非法 `AHS` 格式时报错,不静默忽略
|
||||
|
||||
### 2. 提取与渲染
|
||||
|
||||
- `CardDraft` 增加 `embeddedNoteId?: number`
|
||||
- `SourceLocation` 增加块尾定位信息,至少包括:
|
||||
- `blockEndLine`
|
||||
- `contentEndLine`
|
||||
- `markerLine?: number`
|
||||
- `CardExtractionService` 负责:
|
||||
- 识别块尾 `<!-- AHS:... -->`
|
||||
- 从正文中剔除该标记
|
||||
- 将 `embeddedNoteId` 放入 `CardDraft`
|
||||
- `CardRenderingService` 不处理身份标记,继续只处理标题和正文渲染
|
||||
|
||||
### 3. 同步身份与本地状态
|
||||
|
||||
- 同步身份模式固定为三种:
|
||||
- `embedded-note-id`
|
||||
- `legacy-card-key`
|
||||
- `pending-note-id-write`
|
||||
- `SyncRecord` 增加:
|
||||
- `identityMode: "embedded-note-id" | "legacy-card-key" | "pending-note-id-write"`
|
||||
- `legacyCardKey?: string`
|
||||
- `noteId: number`
|
||||
- `filePath`
|
||||
- `sourceHash`
|
||||
- `lastSyncedAt`
|
||||
- `orphan`
|
||||
- `SyncRegistry` 必须支持:
|
||||
- `findByNoteId(noteId)`
|
||||
- `get(cardKey)` 保留兼容
|
||||
- 新卡首次成功写回 `AHS` 标记后,记录保存为 `embedded-note-id`
|
||||
- 老卡无标记时,继续保存为 `legacy-card-key`
|
||||
- 建卡成功但标记写回失败时,保存为 `pending-note-id-write` 并保留 `legacyCardKey`
|
||||
|
||||
### 4. 规划逻辑
|
||||
|
||||
- 匹配优先级固定:
|
||||
1. 有 `embeddedNoteId` 时,按 `noteId`
|
||||
2. 无 `embeddedNoteId` 时,按当前 `legacy cardKey`
|
||||
- 对 `embeddedNoteId` 卡片:
|
||||
- registry 有同 `noteId` 记录:按 `sourceHash` 判断是否更新
|
||||
- registry 无记录:仍视为已有卡,可直接更新
|
||||
- orphan 判定拆成两套:
|
||||
- `embedded-note-id` 按本轮看到的 `noteId`
|
||||
- `legacy-card-key` 按本轮看到的 `cardKey`
|
||||
- 老卡不自动补写 `AHS`,只保留当前行为
|
||||
|
||||
### 5. 执行逻辑
|
||||
|
||||
- `AnkiGateway` 增加:
|
||||
- `getNoteSummaries(noteIds: number[]): Promise<Array<{ noteId: number; modelName: string }>>`
|
||||
- 执行前先校验所有 `embeddedNoteId`:
|
||||
- note 存在且模型一致:`updateNote`
|
||||
- note 不存在:自动重建
|
||||
- note 存在但模型不一致:报错并停止该卡同步
|
||||
- 自动重建流程固定:
|
||||
1. `addNote`
|
||||
2. 用新的 `noteId` 替换块尾旧 `AHS` 标记
|
||||
3. registry 更新为新的 `embedded-note-id`
|
||||
- 新卡首次同步流程固定:
|
||||
1. 无 `AHS` 标记,先 `addNote`
|
||||
2. 把返回的 `noteId` 写到块尾
|
||||
3. registry 保存为 `embedded-note-id`
|
||||
|
||||
### 6. Markdown 写回
|
||||
|
||||
- `VaultGateway` 增加:
|
||||
- `replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void>`
|
||||
- 写回采用 optimistic concurrency:
|
||||
- 文件内容仍等于扫描时内容才允许写回
|
||||
- 块尾写回算法固定:
|
||||
- 定位当前标题块结束边界
|
||||
- 移除旧 `AHS` 标记(若存在)
|
||||
- 将新标记写到块内最后一个非空内容行之后
|
||||
- 不允许落到下一个标题之后
|
||||
- 若 `addNote` 成功但写回失败:
|
||||
- 不删除新建 note
|
||||
- registry 记为 `pending-note-id-write`
|
||||
- 以后只要还能按 legacy key 命中,就继续尝试补写 `AHS`
|
||||
- 补写成功后,记录转为 `embedded-note-id`
|
||||
|
||||
## Public Interfaces / Types
|
||||
|
||||
- `CardDraft.embeddedNoteId?: number`
|
||||
- `SourceLocation.contentEndLine: number`
|
||||
- `SourceLocation.markerLine?: number`
|
||||
- `SyncRecord.identityMode`
|
||||
- `SyncRecord.legacyCardKey?: string`
|
||||
- `VaultGateway.replaceMarkdownFile(...)`
|
||||
- `AnkiGateway.getNoteSummaries(...)`
|
||||
- `HeadingSyncMarker`
|
||||
- `noteId: number`
|
||||
- `raw: string`
|
||||
- `lineIndex: number`
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 块尾 `<!-- AHS:123 -->` 能被正确识别
|
||||
- `AHS` 标记不会进入 `bodyMarkdown`
|
||||
- 同一标题块多个 `AHS` 标记时报错
|
||||
- 非法 `AHS` 格式时报错
|
||||
- 新卡创建后,`AHS` 被写到块尾
|
||||
- 空正文标题块时,`AHS` 写在标题下一行
|
||||
- 带 `AHS` 的卡在改标题、改正文、块内移动、文件移动后仍更新原 note
|
||||
- registry 丢失时,带 `AHS` 的卡仍能按 `noteId` 更新
|
||||
- `noteId` 在 Anki 不存在时会自动重建,并替换块尾 `AHS`
|
||||
- `basic/cloze` 类型切换时报错
|
||||
- 老卡无 `AHS` 时继续走 legacy `cardKey`
|
||||
- `addNote` 成功但写回失败时,生成 `pending-note-id-write`
|
||||
- 下次命中后可补写 `AHS` 并转正
|
||||
- orphan 检测对 `embedded-note-id` 和 `legacy-card-key` 都正确
|
||||
|
||||
## Assumptions
|
||||
|
||||
- 第一版只支持块尾短标记 `<!-- AHS:<noteId> -->`
|
||||
- 不兼容旧的 `ID:` 行、长注释格式、frontmatter、block ID
|
||||
- 老卡不迁移;只有以后新增的卡获得稳定 `noteId` 身份
|
||||
- `basic` 与 `cloze` 的跨类型切换第一版不自动处理
|
||||
- 允许插件改写 Markdown 文件,在标题块末尾插入或替换 `AHS` 标记
|
||||
61
docs/module-2-gap-report.md
Normal file
61
docs/module-2-gap-report.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Module 2 Gap Report
|
||||
|
||||
## Current State
|
||||
|
||||
- Card extraction only derives legacy card identity from heading structure and source position.
|
||||
- `CardDraft` has no `embeddedNoteId` field.
|
||||
- `SourceLocation` has no block-end marker positioning data beyond `blockEndLine`.
|
||||
- `SyncRecord` only stores legacy `cardKey -> noteId` mappings.
|
||||
- `SyncPlanningService` only matches cards by legacy `cardKey`.
|
||||
- `ExecuteSyncPlanUseCase` only supports add/update driven by plan entries and never writes back into Markdown.
|
||||
- `VaultGateway` cannot replace Markdown content with optimistic concurrency.
|
||||
- `AnkiGateway` cannot batch-check note existence/model via `noteId`.
|
||||
|
||||
## Already Correct
|
||||
|
||||
- Card rendering is separate from extraction and can stay identity-agnostic.
|
||||
- Sync registry persistence is already centralized in the shared plugin data snapshot.
|
||||
- Execution already validates field mappings before side effects.
|
||||
- Source scanning and sync execution are already split into `scan/plan` and `execute` phases.
|
||||
|
||||
## Missing For Module 2
|
||||
|
||||
### Extraction
|
||||
|
||||
- Parse block-end `<!-- AHS:<noteId> -->` markers.
|
||||
- Reject malformed or duplicated markers in the same heading block.
|
||||
- Exclude marker lines from `bodyMarkdown`.
|
||||
- Populate `embeddedNoteId` and marker positioning metadata.
|
||||
|
||||
### Identity And Registry
|
||||
|
||||
- Extend `SyncRecord` with `identityMode` and optional `legacyCardKey`.
|
||||
- Keep legacy `cardKey` lookup while adding embedded `noteId` lookup.
|
||||
- Preserve pending write-back state when Anki note creation succeeds but Markdown write-back fails.
|
||||
|
||||
### Planning
|
||||
|
||||
- Prefer embedded `noteId` over legacy `cardKey` when available.
|
||||
- Allow embedded-note-id cards to update even if the local registry entry is missing.
|
||||
- Detect orphans independently for embedded-note-id and legacy-card-key records.
|
||||
|
||||
### Execution
|
||||
|
||||
- Validate embedded `noteId` existence/model before update.
|
||||
- Recreate missing embedded-note-id notes and replace the block-end marker.
|
||||
- Write back a new marker for newly created notes.
|
||||
- Reject basic/cloze type switches for embedded-note-id cards.
|
||||
- Retry pending marker write-back on later legacy hits.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- Add `VaultGateway.replaceMarkdownFile(path, expectedContent, nextContent)`.
|
||||
- Implement block-end marker replacement in the Obsidian gateway.
|
||||
- Add `AnkiGateway.getNoteSummaries(noteIds)` and AnkiConnect implementation.
|
||||
|
||||
### Tests Missing
|
||||
|
||||
- Extraction tests for valid, duplicate, and malformed AHS markers.
|
||||
- Planning tests for embedded `noteId` precedence and mixed orphan detection.
|
||||
- Execution tests for write-back success/failure, recreation, and type-switch rejection.
|
||||
- Gateway tests for `getNoteSummaries` and optimistic Markdown replacement.
|
||||
|
|
@ -14,10 +14,16 @@ export interface UpdateAnkiNoteInput {
|
|||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AnkiNoteSummary {
|
||||
noteId: number;
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
export interface AnkiGateway {
|
||||
ensureDeckExists(deckName: string): Promise<void>;
|
||||
listNoteModels(): Promise<string[]>;
|
||||
getModelDetails(modelName: string): Promise<NoteModelDetails>;
|
||||
getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]>;
|
||||
addNote(input: AddAnkiNoteInput): Promise<number>;
|
||||
updateNote(input: UpdateAnkiNoteInput): Promise<void>;
|
||||
storeMedia(asset: MediaAsset): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceR
|
|||
export interface VaultGateway extends RenderResourceResolver {
|
||||
listMarkdownFiles(): Promise<SourceFile[]>;
|
||||
getMarkdownFile(path: string): Promise<SourceFile | null>;
|
||||
replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void>;
|
||||
}
|
||||
65
src/application/services/HeadingSyncMarkerService.test.ts
Normal file
65
src/application/services/HeadingSyncMarkerService.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { HeadingSyncMarkerService } from "./HeadingSyncMarkerService";
|
||||
|
||||
describe("HeadingSyncMarkerService", () => {
|
||||
const service = new HeadingSyncMarkerService();
|
||||
|
||||
it("writes a new marker at the end of the heading block", () => {
|
||||
const nextContent = service.apply(
|
||||
{
|
||||
filePath: "notes/example.md",
|
||||
sourceContent: ["#### Prompt", "Answer", "", "### Next"].join("\n"),
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
},
|
||||
123,
|
||||
);
|
||||
|
||||
expect(nextContent).toBe(["#### Prompt", "Answer", "<!-- AHS:123 -->", "", "### Next"].join("\n"));
|
||||
});
|
||||
|
||||
it("writes the marker right after the heading when the block body is empty", () => {
|
||||
const nextContent = service.apply(
|
||||
{
|
||||
filePath: "notes/example.md",
|
||||
sourceContent: ["#### Prompt", "### Next"].join("\n"),
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 1,
|
||||
contentEndLine: 1,
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
},
|
||||
456,
|
||||
);
|
||||
|
||||
expect(nextContent).toBe(["#### Prompt", "<!-- AHS:456 -->", "### Next"].join("\n"));
|
||||
});
|
||||
|
||||
it("replaces an existing marker without moving past the next heading", () => {
|
||||
const nextContent = service.apply(
|
||||
{
|
||||
filePath: "notes/example.md",
|
||||
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:123 -->", "### Next"].join("\n"),
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
},
|
||||
789,
|
||||
);
|
||||
|
||||
expect(nextContent).toBe(["#### Prompt", "Answer", "<!-- AHS:789 -->", "### Next"].join("\n"));
|
||||
});
|
||||
});
|
||||
39
src/application/services/HeadingSyncMarkerService.ts
Normal file
39
src/application/services/HeadingSyncMarkerService.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation";
|
||||
|
||||
export interface HeadingSyncMarker {
|
||||
noteId: number;
|
||||
raw: string;
|
||||
lineIndex: number;
|
||||
}
|
||||
|
||||
export class HeadingSyncMarkerService {
|
||||
apply(location: SourceLocation, noteId: number): string {
|
||||
if (!location.sourceContent) {
|
||||
throw new Error(`Missing scanned source content for ${location.filePath}.`);
|
||||
}
|
||||
|
||||
const lineEnding = location.sourceContent.includes("\r\n") ? "\r\n" : "\n";
|
||||
const lines = location.sourceContent.split(/\r?\n/);
|
||||
const nextLines = [...lines];
|
||||
const adjustedBlockEndLine = location.markerLine ? location.blockEndLine - 1 : location.blockEndLine;
|
||||
|
||||
if (location.contentEndLine < location.headingLine || location.contentEndLine > adjustedBlockEndLine) {
|
||||
throw new Error(`Cannot write AHS marker outside the heading block in ${location.filePath}.`);
|
||||
}
|
||||
|
||||
if (location.markerLine) {
|
||||
nextLines.splice(location.markerLine - 1, 1);
|
||||
}
|
||||
|
||||
nextLines.splice(location.contentEndLine, 0, this.create(noteId).raw);
|
||||
return nextLines.join(lineEnding);
|
||||
}
|
||||
|
||||
create(noteId: number): HeadingSyncMarker {
|
||||
return {
|
||||
noteId,
|
||||
raw: `<!-- AHS:${noteId} -->`,
|
||||
lineIndex: -1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ function createCard(overrides: Partial<Card>): Card {
|
|||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
|
||||
import type { AnkiGateway } from "@/application/ports/AnkiGateway";
|
||||
import type { AnkiGateway, AnkiNoteSummary } from "@/application/ports/AnkiGateway";
|
||||
import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository";
|
||||
import type { VaultGateway } from "@/application/ports/VaultGateway";
|
||||
import type { Card } from "@/domain/card/entities/Card";
|
||||
import { createCardKey } from "@/domain/card/value-objects/CardKey";
|
||||
import { createContentHash } from "@/domain/card/value-objects/ContentHash";
|
||||
|
|
@ -27,15 +28,74 @@ class InMemorySyncRegistryRepository implements SyncRegistryRepository {
|
|||
}
|
||||
}
|
||||
|
||||
class FakeVaultGateway implements VaultGateway {
|
||||
public readonly files = new Map<string, string>();
|
||||
public failReplace = false;
|
||||
|
||||
constructor(initialFiles: Record<string, string> = {}) {
|
||||
for (const [path, content] of Object.entries(initialFiles)) {
|
||||
this.files.set(path, content);
|
||||
}
|
||||
}
|
||||
|
||||
async listMarkdownFiles() {
|
||||
return Array.from(this.files.entries()).map(([path, content]) => ({
|
||||
path,
|
||||
basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
|
||||
content,
|
||||
}));
|
||||
}
|
||||
|
||||
async getMarkdownFile(path: string) {
|
||||
const content = this.files.get(path);
|
||||
if (content === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void> {
|
||||
if (this.failReplace) {
|
||||
throw new Error(`Markdown file changed before AHS write-back: ${path}`);
|
||||
}
|
||||
|
||||
const currentContent = this.files.get(path);
|
||||
if (currentContent !== expectedContent) {
|
||||
throw new Error(`Markdown file changed before AHS write-back: ${path}`);
|
||||
}
|
||||
|
||||
this.files.set(path, nextContent);
|
||||
}
|
||||
|
||||
resolveWikiLink(rawTarget: string) {
|
||||
return { url: `obsidian://open?vault=Vault&file=${rawTarget}`, displayText: rawTarget };
|
||||
}
|
||||
|
||||
resolveEmbed() {
|
||||
return null;
|
||||
}
|
||||
|
||||
createBacklink() {
|
||||
return "obsidian://open?vault=Vault&file=notes/current.md";
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAnkiGateway implements AnkiGateway {
|
||||
public ensuredDecks: string[] = [];
|
||||
public addedNotes: Array<{ deckName: string; modelName: string; fields: Record<string, string> }> = [];
|
||||
public updatedNotes: Array<{ noteId: number; deckName: string; fields: Record<string, string> }> = [];
|
||||
public storedMedia: string[] = [];
|
||||
public noteSummariesById = new Map<number, AnkiNoteSummary>();
|
||||
public modelDetailsByName: Record<string, { fieldNames: string[]; isCloze: boolean }> = {
|
||||
Basic: { fieldNames: ["Front", "Back"], isCloze: false },
|
||||
Cloze: { fieldNames: ["Text", "Extra"], isCloze: true },
|
||||
};
|
||||
private nextAddedNoteId = 9000;
|
||||
|
||||
async ensureDeckExists(deckName: string): Promise<void> {
|
||||
this.ensuredDecks.push(deckName);
|
||||
|
|
@ -49,9 +109,17 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false };
|
||||
}
|
||||
|
||||
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
return noteIds.flatMap((noteId) => {
|
||||
const summary = this.noteSummariesById.get(noteId);
|
||||
return summary ? [summary] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
|
||||
this.addedNotes.push(input);
|
||||
return 9001;
|
||||
this.nextAddedNoteId += 1;
|
||||
return this.nextAddedNoteId;
|
||||
}
|
||||
|
||||
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
|
||||
|
|
@ -64,20 +132,27 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
}
|
||||
|
||||
function createCard(overrides: Partial<Card> = {}): Card {
|
||||
const sourceContent = (overrides.source?.sourceContent as string | undefined) ?? ["#### Prompt", "Answer"].join("\n");
|
||||
const lineCount = sourceContent.split(/\r?\n/).length;
|
||||
|
||||
return {
|
||||
key: createCardKey("card-1"),
|
||||
source: {
|
||||
filePath: "notes/current.md",
|
||||
sourceContent,
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 3,
|
||||
blockEndLine: lineCount,
|
||||
contentEndLine: Math.min(lineCount, 2),
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
...overrides.source,
|
||||
},
|
||||
type: "basic",
|
||||
heading: "Prompt",
|
||||
bodyMarkdown: "Answer",
|
||||
embeddedNoteId: undefined,
|
||||
deck: createDeckName("Deck"),
|
||||
noteModel: createNoteModelName("Basic"),
|
||||
tags: [],
|
||||
|
|
@ -115,111 +190,487 @@ function createMappings() {
|
|||
};
|
||||
}
|
||||
|
||||
describe("ExecuteSyncPlanUseCase", () => {
|
||||
it("marks orphan records locally without any delete path", async () => {
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new InMemorySyncRegistryRepository(
|
||||
new SyncRegistry([
|
||||
{
|
||||
cardKey: createCardKey("orphan-card"),
|
||||
noteId: 42,
|
||||
filePath: "notes/orphan.md",
|
||||
sourceHash: createContentHash("old-hash"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
function createResult(overrides: Partial<ScanAndPlanResult> = {}): ScanAndPlanResult {
|
||||
return {
|
||||
cards: [],
|
||||
noteFieldMappings: createMappings(),
|
||||
registry: new SyncRegistry(),
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
scopedFilePaths: ["notes/current.md"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1234);
|
||||
const result: ScanAndPlanResult = {
|
||||
cards: [createCard()],
|
||||
noteFieldMappings: createMappings(),
|
||||
registry: new SyncRegistry([
|
||||
{
|
||||
cardKey: createCardKey("orphan-card"),
|
||||
noteId: 42,
|
||||
filePath: "notes/orphan.md",
|
||||
sourceHash: createContentHash("old-hash"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
},
|
||||
]),
|
||||
describe("ExecuteSyncPlanUseCase", () => {
|
||||
it("writes a new AHS marker to the end of the block for newly created cards", async () => {
|
||||
const vaultGateway = new FakeVaultGateway({
|
||||
"notes/current.md": ["#### Prompt", "Answer"].join("\n"),
|
||||
});
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const card = createCard();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [createCard()],
|
||||
toAdd: [card],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [
|
||||
{
|
||||
cardKey: createCardKey("orphan-card"),
|
||||
noteId: 42,
|
||||
filePath: "notes/orphan.md",
|
||||
sourceHash: createContentHash("old-hash"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
},
|
||||
],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
scopedFilePaths: ["notes/current.md", "notes/orphan.md"],
|
||||
};
|
||||
}));
|
||||
|
||||
const execution = await useCase.execute(result);
|
||||
expect(vaultGateway.files.get("notes/current.md")).toBe(["#### Prompt", "Answer", "<!-- AHS:9001 -->"].join("\n"));
|
||||
expect(repository.savedRegistry?.findByNoteId(9001)).toMatchObject({
|
||||
identityMode: "embedded-note-id",
|
||||
noteId: 9001,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes empty-body markers on the line after the heading", async () => {
|
||||
const vaultGateway = new FakeVaultGateway({
|
||||
"notes/current.md": "#### Prompt",
|
||||
});
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const templateCard = createCard();
|
||||
const card = createCard({
|
||||
bodyMarkdown: "",
|
||||
renderedFields: { title: "Prompt", body: "" },
|
||||
source: {
|
||||
...templateCard.source,
|
||||
sourceContent: "#### Prompt",
|
||||
blockEndLine: 1,
|
||||
contentEndLine: 1,
|
||||
},
|
||||
});
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [card],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(vaultGateway.files.get("notes/current.md")).toBe(["#### Prompt", "<!-- AHS:9001 -->"].join("\n"));
|
||||
});
|
||||
|
||||
it("stores pending-note-id-write when addNote succeeds but write-back fails", async () => {
|
||||
const vaultGateway = new FakeVaultGateway({
|
||||
"notes/current.md": ["#### Prompt", "Answer changed elsewhere"].join("\n"),
|
||||
});
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const card = createCard();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [card],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(repository.savedRegistry?.get(card.key)).toMatchObject({
|
||||
identityMode: "pending-note-id-write",
|
||||
legacyCardKey: card.key,
|
||||
noteId: 9001,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "heading change",
|
||||
card: createCard({
|
||||
key: createCardKey("card-heading-change"),
|
||||
heading: "Prompt changed",
|
||||
renderedFields: { title: "Prompt changed", body: "Answer" },
|
||||
contentHash: createContentHash("hash-heading"),
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...createCard().source,
|
||||
sourceContent: ["#### Prompt changed", "Answer", "<!-- AHS:42 -->"].join("\n"),
|
||||
headingText: "Prompt changed",
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "body change",
|
||||
card: createCard({
|
||||
key: createCardKey("card-body-change"),
|
||||
bodyMarkdown: "Answer changed",
|
||||
renderedFields: { title: "Prompt", body: "Answer changed" },
|
||||
contentHash: createContentHash("hash-body"),
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...createCard().source,
|
||||
sourceContent: ["#### Prompt", "Answer changed", "<!-- AHS:42 -->"].join("\n"),
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "file move",
|
||||
card: createCard({
|
||||
key: createCardKey("card-file-move"),
|
||||
contentHash: createContentHash("hash-move"),
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...createCard().source,
|
||||
filePath: "notes/moved.md",
|
||||
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"),
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
])("updates the original embedded note after $name", async ({ card }) => {
|
||||
const vaultGateway = new FakeVaultGateway({
|
||||
[card.source.filePath]: card.source.sourceContent ?? "",
|
||||
});
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic" });
|
||||
const existingRecord = {
|
||||
cardKey: createCardKey("legacy-embedded-key"),
|
||||
identityMode: "embedded-note-id" as const,
|
||||
noteId: 42,
|
||||
filePath: "notes/original.md",
|
||||
sourceHash: createContentHash("old-hash"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
};
|
||||
const repository = new InMemorySyncRegistryRepository(new SyncRegistry([existingRecord]));
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
registry: new SyncRegistry([existingRecord]),
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [],
|
||||
toUpdate: [{ card, noteId: 42 }],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(ankiGateway.updatedNotes).toHaveLength(1);
|
||||
expect(ankiGateway.updatedNotes[0]?.noteId).toBe(42);
|
||||
expect(repository.savedRegistry?.findByNoteId(42)).toMatchObject({
|
||||
cardKey: card.key,
|
||||
filePath: card.source.filePath,
|
||||
identityMode: "embedded-note-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes embedded-note-id cards after in-block movement without creating a new note", async () => {
|
||||
const templateCard = createCard();
|
||||
const card = createCard({
|
||||
key: createCardKey("card-moved-in-block"),
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...templateCard.source,
|
||||
blockStartLine: 10,
|
||||
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"),
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
});
|
||||
const existingRecord = {
|
||||
cardKey: createCardKey("old-block-key"),
|
||||
identityMode: "embedded-note-id" as const,
|
||||
noteId: 42,
|
||||
filePath: "notes/current.md",
|
||||
sourceHash: card.contentHash,
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
};
|
||||
const registry = new SyncRegistry([existingRecord]);
|
||||
const repository = new InMemorySyncRegistryRepository(registry);
|
||||
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
registry,
|
||||
plan: {
|
||||
toCreateDecks: [],
|
||||
toAdd: [],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(ankiGateway.addedNotes).toHaveLength(0);
|
||||
expect(ankiGateway.updatedNotes).toHaveLength(0);
|
||||
expect(repository.savedRegistry?.findByNoteId(42)?.cardKey).toBe(card.key);
|
||||
});
|
||||
|
||||
it("updates embedded-note-id cards even when the local registry entry is missing", async () => {
|
||||
const templateCard = createCard();
|
||||
const card = createCard({
|
||||
key: createCardKey("embedded-without-registry"),
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...templateCard.source,
|
||||
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"),
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
});
|
||||
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic" });
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [],
|
||||
toUpdate: [{ card, noteId: 42 }],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(ankiGateway.updatedNotes[0]?.noteId).toBe(42);
|
||||
expect(repository.savedRegistry?.findByNoteId(42)).toMatchObject({ identityMode: "embedded-note-id" });
|
||||
});
|
||||
|
||||
it("recreates a missing embedded note and replaces the block-end AHS marker", async () => {
|
||||
const templateCard = createCard();
|
||||
const card = createCard({
|
||||
key: createCardKey("embedded-missing-note"),
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...templateCard.source,
|
||||
sourceContent: ["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"),
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
});
|
||||
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [],
|
||||
toUpdate: [{ card, noteId: 42 }],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(ankiGateway.addedNotes).toHaveLength(1);
|
||||
expect(vaultGateway.files.get("notes/current.md")).toBe(["#### Prompt", "Answer", "<!-- AHS:9001 -->"].join("\n"));
|
||||
expect(repository.savedRegistry?.findByNoteId(9001)).toMatchObject({ identityMode: "embedded-note-id" });
|
||||
});
|
||||
|
||||
it("rejects basic/cloze type switching for embedded-note-id cards", async () => {
|
||||
const templateCard = createCard();
|
||||
const card = createCard({
|
||||
key: createCardKey("embedded-type-switch"),
|
||||
type: "cloze",
|
||||
noteModel: createNoteModelName("Cloze"),
|
||||
embeddedNoteId: 42,
|
||||
source: {
|
||||
...templateCard.source,
|
||||
sourceContent: ["##### Prompt", "{answer}", "<!-- AHS:42 -->"].join("\n"),
|
||||
headingLevel: 5,
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
markerLine: 3,
|
||||
},
|
||||
});
|
||||
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic" });
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await expect(
|
||||
useCase.execute(createResult({
|
||||
cards: [card],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [],
|
||||
toUpdate: [{ card, noteId: 42 }],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
})),
|
||||
).rejects.toThrow("Recreate the card instead of switching basic/cloze types");
|
||||
});
|
||||
|
||||
it("keeps legacy-card-key behavior for old cards without AHS markers", async () => {
|
||||
const card = createCard();
|
||||
const legacyRecord = {
|
||||
cardKey: card.key,
|
||||
identityMode: "legacy-card-key" as const,
|
||||
noteId: 77,
|
||||
filePath: card.source.filePath,
|
||||
sourceHash: createContentHash("old-hash"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
};
|
||||
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new InMemorySyncRegistryRepository(new SyncRegistry([legacyRecord]));
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
registry: new SyncRegistry([legacyRecord]),
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [],
|
||||
toUpdate: [{ card, noteId: 77 }],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(ankiGateway.updatedNotes[0]?.noteId).toBe(77);
|
||||
expect(vaultGateway.files.get("notes/current.md")).toBe(card.source.sourceContent);
|
||||
expect(repository.savedRegistry?.get(card.key)).toMatchObject({ identityMode: "legacy-card-key" });
|
||||
});
|
||||
|
||||
it("promotes pending-note-id-write records after a later successful marker retry", async () => {
|
||||
const card = createCard();
|
||||
const pendingRecord = {
|
||||
cardKey: card.key,
|
||||
identityMode: "pending-note-id-write" as const,
|
||||
legacyCardKey: card.key,
|
||||
noteId: 42,
|
||||
filePath: card.source.filePath,
|
||||
sourceHash: createContentHash("old-hash"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
};
|
||||
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic" });
|
||||
const repository = new InMemorySyncRegistryRepository(new SyncRegistry([pendingRecord]));
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
registry: new SyncRegistry([pendingRecord]),
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [],
|
||||
toUpdate: [{ card, noteId: 42 }],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(vaultGateway.files.get("notes/current.md")).toBe(["#### Prompt", "Answer", "<!-- AHS:42 -->"].join("\n"));
|
||||
expect(repository.savedRegistry?.findByNoteId(42)).toMatchObject({ identityMode: "embedded-note-id" });
|
||||
});
|
||||
|
||||
it("marks orphan records locally without any delete path", async () => {
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const vaultGateway = new FakeVaultGateway({
|
||||
"notes/current.md": ["#### Prompt", "Answer"].join("\n"),
|
||||
});
|
||||
const orphanRecord = {
|
||||
cardKey: createCardKey("orphan-card"),
|
||||
identityMode: "legacy-card-key" as const,
|
||||
noteId: 42,
|
||||
filePath: "notes/orphan.md",
|
||||
sourceHash: createContentHash("old-hash"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
};
|
||||
const repository = new InMemorySyncRegistryRepository(new SyncRegistry([orphanRecord]));
|
||||
const card = createCard();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
const execution = await useCase.execute(createResult({
|
||||
cards: [card],
|
||||
registry: new SyncRegistry([orphanRecord]),
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [card],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [orphanRecord],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(execution.created).toBe(1);
|
||||
expect(execution.markedOrphan).toBe(1);
|
||||
expect(ankiGateway.ensuredDecks).toEqual(["Deck"]);
|
||||
expect(ankiGateway.addedNotes).toHaveLength(1);
|
||||
expect(ankiGateway.updatedNotes).toHaveLength(0);
|
||||
expect(repository.savedRegistry?.get(createCardKey("orphan-card"))?.orphan).toBe(true);
|
||||
expect(repository.savedRegistry?.get(createCardKey("orphan-card"))?.noteId).toBe(42);
|
||||
});
|
||||
|
||||
it("blocks sync when a selected note type has no saved mapping", async () => {
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
const vaultGateway = new FakeVaultGateway({
|
||||
"notes/current.md": ["#### Prompt", "Answer"].join("\n"),
|
||||
});
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1234);
|
||||
const card = createCard();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await expect(
|
||||
useCase.execute({
|
||||
cards: [createCard()],
|
||||
...createResult(),
|
||||
cards: [card],
|
||||
noteFieldMappings: {},
|
||||
registry: new SyncRegistry(),
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [createCard()],
|
||||
toAdd: [card],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
scopedFilePaths: ["notes/current.md"],
|
||||
}),
|
||||
).rejects.toThrow("Open plugin settings and read fields from Anki first");
|
||||
|
||||
expect(ankiGateway.ensuredDecks).toHaveLength(0);
|
||||
expect(ankiGateway.addedNotes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("blocks sync when a saved mapping becomes stale", async () => {
|
||||
const ankiGateway = new FakeAnkiGateway();
|
||||
ankiGateway.modelDetailsByName.Basic = { fieldNames: ["Front", "Body"], isCloze: false };
|
||||
const vaultGateway = new FakeVaultGateway({
|
||||
"notes/current.md": ["#### Prompt", "Answer"].join("\n"),
|
||||
});
|
||||
const repository = new InMemorySyncRegistryRepository();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1234);
|
||||
const card = createCard();
|
||||
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
|
||||
|
||||
await expect(
|
||||
useCase.execute({
|
||||
cards: [createCard()],
|
||||
noteFieldMappings: createMappings(),
|
||||
registry: new SyncRegistry(),
|
||||
...createResult(),
|
||||
cards: [card],
|
||||
plan: {
|
||||
toCreateDecks: [createDeckName("Deck")],
|
||||
toAdd: [createCard()],
|
||||
toAdd: [card],
|
||||
toUpdate: [],
|
||||
toMarkOrphan: [],
|
||||
},
|
||||
scopedFilePaths: ["notes/current.md"],
|
||||
}),
|
||||
).rejects.toThrow("is stale because these fields no longer exist in Anki");
|
||||
|
||||
expect(ankiGateway.ensuredDecks).toHaveLength(0);
|
||||
expect(ankiGateway.addedNotes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { HeadingSyncMarkerService } from "@/application/services/HeadingSyncMarkerService";
|
||||
import type { AnkiGateway } from "@/application/ports/AnkiGateway";
|
||||
import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository";
|
||||
import type { VaultGateway } from "@/application/ports/VaultGateway";
|
||||
import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService";
|
||||
import { SyncRegistry } from "@/domain/sync/entities/SyncRegistry";
|
||||
import type { Card } from "@/domain/card/entities/Card";
|
||||
import type { SyncRecord } from "@/domain/sync/entities/SyncRecord";
|
||||
|
||||
import type { ExecuteSyncPlanResult, ScanAndPlanResult } from "./types";
|
||||
|
||||
|
|
@ -9,13 +13,16 @@ export class ExecuteSyncPlanUseCase {
|
|||
constructor(
|
||||
private readonly ankiGateway: AnkiGateway,
|
||||
private readonly syncRegistryRepository: SyncRegistryRepository,
|
||||
private readonly vaultGateway: VaultGateway,
|
||||
private readonly noteFieldMappingService = new NoteFieldMappingService(),
|
||||
private readonly now: () => number = () => Date.now(),
|
||||
private readonly headingSyncMarkerService = new HeadingSyncMarkerService(),
|
||||
) {}
|
||||
|
||||
async execute(scanAndPlanResult: ScanAndPlanResult): Promise<ExecuteSyncPlanResult> {
|
||||
const syncRegistry = new SyncRegistry(scanAndPlanResult.registry.list());
|
||||
const modelDetailsCache = new Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>();
|
||||
const noteSummaries = await this.loadNoteSummaries(scanAndPlanResult.plan.toUpdate.map((entry) => entry.noteId));
|
||||
const timestamp = this.now();
|
||||
|
||||
for (const card of scanAndPlanResult.cards) {
|
||||
|
|
@ -35,40 +42,26 @@ export class ExecuteSyncPlanUseCase {
|
|||
const uploadedMedia = await this.uploadMedia(syncCards);
|
||||
|
||||
for (const card of scanAndPlanResult.plan.toAdd) {
|
||||
const modelDetails = await this.getModelDetails(modelDetailsCache, card.noteModel);
|
||||
const noteId = await this.ankiGateway.addNote({
|
||||
deckName: card.deck,
|
||||
modelName: card.noteModel,
|
||||
fields: this.noteFieldMappingService.map(card, modelDetails, scanAndPlanResult.noteFieldMappings),
|
||||
tags: card.tags,
|
||||
});
|
||||
|
||||
syncRegistry.recordSync({
|
||||
cardKey: card.key,
|
||||
noteId,
|
||||
filePath: card.source.filePath,
|
||||
sourceHash: card.contentHash,
|
||||
lastSyncedAt: timestamp,
|
||||
orphan: false,
|
||||
});
|
||||
const noteId = await this.addNote(card, modelDetailsCache, scanAndPlanResult);
|
||||
await this.writeBackNewMarker(card, noteId, syncRegistry, timestamp);
|
||||
}
|
||||
|
||||
for (const entry of scanAndPlanResult.plan.toUpdate) {
|
||||
const modelDetails = await this.getModelDetails(modelDetailsCache, entry.card.noteModel);
|
||||
await this.ankiGateway.updateNote({
|
||||
noteId: entry.noteId,
|
||||
deckName: entry.card.deck,
|
||||
fields: this.noteFieldMappingService.map(entry.card, modelDetails, scanAndPlanResult.noteFieldMappings),
|
||||
});
|
||||
const existingRecord = entry.card.embeddedNoteId
|
||||
? syncRegistry.findByNoteId(entry.noteId)
|
||||
: syncRegistry.get(entry.card.key);
|
||||
|
||||
syncRegistry.recordSync({
|
||||
cardKey: entry.card.key,
|
||||
noteId: entry.noteId,
|
||||
filePath: entry.card.source.filePath,
|
||||
sourceHash: entry.card.contentHash,
|
||||
lastSyncedAt: timestamp,
|
||||
orphan: false,
|
||||
});
|
||||
if (entry.card.embeddedNoteId) {
|
||||
await this.updateEmbeddedCard(entry.card, entry.noteId, noteSummaries.get(entry.noteId), syncRegistry, timestamp, modelDetailsCache, scanAndPlanResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingRecord?.identityMode === "pending-note-id-write") {
|
||||
await this.retryPendingMarkerWrite(entry.card, existingRecord, noteSummaries.get(entry.noteId), syncRegistry, timestamp, modelDetailsCache, scanAndPlanResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.updateLegacyCard(entry.card, entry.noteId, existingRecord, syncRegistry, timestamp, modelDetailsCache, scanAndPlanResult);
|
||||
}
|
||||
|
||||
const mutatedCardKeys = new Set(syncCards.map((card) => card.key));
|
||||
|
|
@ -78,11 +71,28 @@ export class ExecuteSyncPlanUseCase {
|
|||
}
|
||||
|
||||
const existingRecord = syncRegistry.get(card.key);
|
||||
if (card.embeddedNoteId) {
|
||||
const embeddedRecord = syncRegistry.findByNoteId(card.embeddedNoteId);
|
||||
if (!embeddedRecord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
syncRegistry.recordSync(this.createEmbeddedRecord(card, embeddedRecord.noteId, timestamp));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!existingRecord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
syncRegistry.refresh(card.key, card.source.filePath, card.contentHash, timestamp);
|
||||
syncRegistry.recordSync({
|
||||
...existingRecord,
|
||||
cardKey: card.key,
|
||||
filePath: card.source.filePath,
|
||||
sourceHash: card.contentHash,
|
||||
lastSyncedAt: timestamp,
|
||||
orphan: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const orphanRecord of scanAndPlanResult.plan.toMarkOrphan) {
|
||||
|
|
@ -104,6 +114,163 @@ export class ExecuteSyncPlanUseCase {
|
|||
};
|
||||
}
|
||||
|
||||
|
||||
private async updateEmbeddedCard(
|
||||
card: Card,
|
||||
noteId: number,
|
||||
noteSummary: Awaited<ReturnType<AnkiGateway["getNoteSummaries"]>>[number] | undefined,
|
||||
syncRegistry: SyncRegistry,
|
||||
timestamp: number,
|
||||
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
|
||||
scanAndPlanResult: ScanAndPlanResult,
|
||||
): Promise<void> {
|
||||
if (!noteSummary) {
|
||||
const recreatedNoteId = await this.addNote(card, modelDetailsCache, scanAndPlanResult);
|
||||
await this.writeMarker(card, recreatedNoteId);
|
||||
syncRegistry.recordSync(this.createEmbeddedRecord(card, recreatedNoteId, timestamp));
|
||||
return;
|
||||
}
|
||||
|
||||
this.assertModelMatches(card, noteSummary.modelName, noteId);
|
||||
await this.updateExistingNote(card, noteId, modelDetailsCache, scanAndPlanResult);
|
||||
syncRegistry.recordSync(this.createEmbeddedRecord(card, noteId, timestamp));
|
||||
}
|
||||
|
||||
private async retryPendingMarkerWrite(
|
||||
card: Card,
|
||||
existingRecord: SyncRecord,
|
||||
noteSummary: Awaited<ReturnType<AnkiGateway["getNoteSummaries"]>>[number] | undefined,
|
||||
syncRegistry: SyncRegistry,
|
||||
timestamp: number,
|
||||
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
|
||||
scanAndPlanResult: ScanAndPlanResult,
|
||||
): Promise<void> {
|
||||
let noteId = existingRecord.noteId;
|
||||
|
||||
if (!noteSummary) {
|
||||
noteId = await this.addNote(card, modelDetailsCache, scanAndPlanResult);
|
||||
} else {
|
||||
await this.updateExistingNote(card, noteId, modelDetailsCache, scanAndPlanResult);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.writeMarker(card, noteId);
|
||||
syncRegistry.recordSync(this.createEmbeddedRecord(card, noteId, timestamp));
|
||||
} catch {
|
||||
syncRegistry.recordSync(this.createPendingRecord(card, noteId, timestamp));
|
||||
}
|
||||
}
|
||||
|
||||
private async updateLegacyCard(
|
||||
card: Card,
|
||||
noteId: number,
|
||||
existingRecord: SyncRecord | undefined,
|
||||
syncRegistry: SyncRegistry,
|
||||
timestamp: number,
|
||||
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
|
||||
scanAndPlanResult: ScanAndPlanResult,
|
||||
): Promise<void> {
|
||||
await this.updateExistingNote(card, noteId, modelDetailsCache, scanAndPlanResult);
|
||||
syncRegistry.recordSync({
|
||||
cardKey: card.key,
|
||||
identityMode: existingRecord?.identityMode ?? "legacy-card-key",
|
||||
legacyCardKey: existingRecord?.legacyCardKey,
|
||||
noteId,
|
||||
filePath: card.source.filePath,
|
||||
sourceHash: card.contentHash,
|
||||
lastSyncedAt: timestamp,
|
||||
orphan: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async writeBackNewMarker(card: Card, noteId: number, syncRegistry: SyncRegistry, timestamp: number): Promise<void> {
|
||||
try {
|
||||
await this.writeMarker(card, noteId);
|
||||
syncRegistry.recordSync(this.createEmbeddedRecord(card, noteId, timestamp));
|
||||
} catch {
|
||||
syncRegistry.recordSync(this.createPendingRecord(card, noteId, timestamp));
|
||||
}
|
||||
}
|
||||
|
||||
private createEmbeddedRecord(card: Card, noteId: number, timestamp: number): SyncRecord {
|
||||
return {
|
||||
cardKey: card.key,
|
||||
identityMode: "embedded-note-id",
|
||||
noteId,
|
||||
filePath: card.source.filePath,
|
||||
sourceHash: card.contentHash,
|
||||
lastSyncedAt: timestamp,
|
||||
orphan: false,
|
||||
};
|
||||
}
|
||||
|
||||
private createPendingRecord(card: Card, noteId: number, timestamp: number): SyncRecord {
|
||||
return {
|
||||
cardKey: card.key,
|
||||
identityMode: "pending-note-id-write",
|
||||
legacyCardKey: card.key,
|
||||
noteId,
|
||||
filePath: card.source.filePath,
|
||||
sourceHash: card.contentHash,
|
||||
lastSyncedAt: timestamp,
|
||||
orphan: false,
|
||||
};
|
||||
}
|
||||
|
||||
private async addNote(
|
||||
card: Card,
|
||||
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
|
||||
scanAndPlanResult: ScanAndPlanResult,
|
||||
): Promise<number> {
|
||||
const modelDetails = await this.getModelDetails(modelDetailsCache, card.noteModel);
|
||||
|
||||
return this.ankiGateway.addNote({
|
||||
deckName: card.deck,
|
||||
modelName: card.noteModel,
|
||||
fields: this.noteFieldMappingService.map(card, modelDetails, scanAndPlanResult.noteFieldMappings),
|
||||
tags: card.tags,
|
||||
});
|
||||
}
|
||||
|
||||
private async updateExistingNote(
|
||||
card: Card,
|
||||
noteId: number,
|
||||
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
|
||||
scanAndPlanResult: ScanAndPlanResult,
|
||||
): Promise<void> {
|
||||
const modelDetails = await this.getModelDetails(modelDetailsCache, card.noteModel);
|
||||
await this.ankiGateway.updateNote({
|
||||
noteId,
|
||||
deckName: card.deck,
|
||||
fields: this.noteFieldMappingService.map(card, modelDetails, scanAndPlanResult.noteFieldMappings),
|
||||
});
|
||||
}
|
||||
|
||||
private async writeMarker(card: Card, noteId: number): Promise<void> {
|
||||
const expectedContent = card.source.sourceContent;
|
||||
if (!expectedContent) {
|
||||
throw new Error(`Missing scanned source content for ${card.source.filePath}.`);
|
||||
}
|
||||
|
||||
const nextContent = this.headingSyncMarkerService.apply(card.source, noteId);
|
||||
await this.vaultGateway.replaceMarkdownFile(card.source.filePath, expectedContent, nextContent);
|
||||
}
|
||||
|
||||
private assertModelMatches(card: Card, actualModelName: string, noteId: number): void {
|
||||
if (actualModelName === card.noteModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Embedded Anki note ${noteId} uses model ${actualModelName}, but ${card.heading} now requires ${card.noteModel}. Recreate the card instead of switching basic/cloze types.`,
|
||||
);
|
||||
}
|
||||
|
||||
private async loadNoteSummaries(noteIds: number[]): Promise<Map<number, Awaited<ReturnType<AnkiGateway["getNoteSummaries"]>>[number]>> {
|
||||
const uniqueNoteIds = Array.from(new Set(noteIds));
|
||||
const resolved = await this.ankiGateway.getNoteSummaries(uniqueNoteIds);
|
||||
return new Map(resolved.map((summary) => [summary.noteId, summary]));
|
||||
}
|
||||
private async getModelDetails(
|
||||
modelDetailsCache: Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>,
|
||||
modelName: string,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,15 @@ class FakeVaultGateway implements VaultGateway {
|
|||
};
|
||||
}
|
||||
|
||||
async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string) {
|
||||
const file = this.files.find((candidate) => candidate.path === path);
|
||||
if (!file || file.content !== expectedContent) {
|
||||
throw new Error(`Markdown file changed before AHS write-back: ${path}`);
|
||||
}
|
||||
|
||||
file.content = nextContent;
|
||||
}
|
||||
|
||||
resolveWikiLink(rawTarget: string) {
|
||||
return { url: `obsidian://open?vault=Vault&file=${rawTarget}`, displayText: rawTarget.split("|")[1] ?? rawTarget };
|
||||
}
|
||||
|
|
@ -93,6 +102,10 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
: { fieldNames: ["Front", "Back"], isCloze: false };
|
||||
}
|
||||
|
||||
async getNoteSummaries(): Promise<Array<{ noteId: number; modelName: string }>> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
|
||||
this.addCalls.push(input);
|
||||
return 500 + this.addCalls.length;
|
||||
|
|
@ -169,7 +182,7 @@ describe("SyncCurrentFileUseCase", () => {
|
|||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new DataJsonSyncRegistryRepository(store);
|
||||
const scanUseCase = new ScanAndPlanSyncUseCase(vaultGateway, repository);
|
||||
const executeUseCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1000);
|
||||
const executeUseCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1000);
|
||||
const useCase = new SyncCurrentFileUseCase(scanUseCase, executeUseCase);
|
||||
|
||||
const result = await useCase.execute("notes/current.md", createSettings());
|
||||
|
|
|
|||
|
|
@ -53,6 +53,15 @@ class FakeVaultGateway implements VaultGateway {
|
|||
};
|
||||
}
|
||||
|
||||
async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string) {
|
||||
const file = this.files.find((candidate) => candidate.path === path);
|
||||
if (!file || file.content !== expectedContent) {
|
||||
throw new Error(`Markdown file changed before AHS write-back: ${path}`);
|
||||
}
|
||||
|
||||
file.content = nextContent;
|
||||
}
|
||||
|
||||
resolveWikiLink(rawTarget: string) {
|
||||
return { url: `obsidian://open?vault=Vault&file=${rawTarget}`, displayText: rawTarget.split("|")[1] ?? rawTarget };
|
||||
}
|
||||
|
|
@ -85,6 +94,10 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
: { fieldNames: ["Front", "Back"], isCloze: false };
|
||||
}
|
||||
|
||||
async getNoteSummaries(): Promise<Array<{ noteId: number; modelName: string }>> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
|
||||
this.addCalls.push(input);
|
||||
return 200 + this.addCalls.length;
|
||||
|
|
@ -142,7 +155,7 @@ describe("SyncVaultUseCase", () => {
|
|||
const ankiGateway = new FakeAnkiGateway();
|
||||
const repository = new DataJsonSyncRegistryRepository(store);
|
||||
const scanUseCase = new ScanAndPlanSyncUseCase(vaultGateway, repository);
|
||||
const executeUseCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 2000);
|
||||
const executeUseCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 2000);
|
||||
const useCase = new SyncVaultUseCase(scanUseCase, executeUseCase);
|
||||
|
||||
const result = await useCase.execute(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface Card {
|
|||
type: CardType;
|
||||
heading: string;
|
||||
bodyMarkdown: string;
|
||||
embeddedNoteId?: number;
|
||||
deck: DeckName;
|
||||
noteModel: NoteModelName;
|
||||
tags: string[];
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ export interface CardDraft {
|
|||
headingLevel: number;
|
||||
type: CardType;
|
||||
bodyMarkdown: string;
|
||||
embeddedNoteId?: number;
|
||||
deckHint?: DeckName;
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ describe("CardIdentityPolicy", () => {
|
|||
blockStartLine: 5,
|
||||
bodyStartLine: 6,
|
||||
blockEndLine: 8,
|
||||
contentEndLine: 7,
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
};
|
||||
|
|
@ -27,6 +28,7 @@ describe("CardIdentityPolicy", () => {
|
|||
blockStartLine: 5,
|
||||
bodyStartLine: 6,
|
||||
blockEndLine: 8,
|
||||
contentEndLine: 7,
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -67,6 +67,88 @@ describe("CardExtractionService", () => {
|
|||
expect(drafts[0].deckHint).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects a block-end AHS marker and excludes it from body markdown", () => {
|
||||
const sourceFile: SourceFile = {
|
||||
path: "notes/example.md",
|
||||
basename: "example",
|
||||
content: [
|
||||
"#### Prompt",
|
||||
"Answer line 1",
|
||||
"",
|
||||
"Answer line 2",
|
||||
"<!-- AHS:123 -->",
|
||||
"",
|
||||
"### Next heading",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
const drafts = new CardExtractionService().extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 });
|
||||
|
||||
expect(drafts[0]).toMatchObject({
|
||||
bodyMarkdown: ["Answer line 1", "", "Answer line 2"].join("\n"),
|
||||
embeddedNoteId: 123,
|
||||
});
|
||||
expect(drafts[0].source).toMatchObject({
|
||||
contentEndLine: 4,
|
||||
markerLine: 5,
|
||||
blockEndLine: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes empty-body marker metadata against the heading line", () => {
|
||||
const sourceFile: SourceFile = {
|
||||
path: "notes/example.md",
|
||||
basename: "example",
|
||||
content: [
|
||||
"#### Prompt",
|
||||
"<!-- AHS:456 -->",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
const [draft] = new CardExtractionService().extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 });
|
||||
|
||||
expect(draft.bodyMarkdown).toBe("");
|
||||
expect(draft.embeddedNoteId).toBe(456);
|
||||
expect(draft.source).toMatchObject({
|
||||
contentEndLine: 1,
|
||||
markerLine: 2,
|
||||
blockEndLine: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects multiple valid AHS markers in one heading block", () => {
|
||||
const sourceFile: SourceFile = {
|
||||
path: "notes/example.md",
|
||||
basename: "example",
|
||||
content: [
|
||||
"#### Prompt",
|
||||
"Answer",
|
||||
"<!-- AHS:123 -->",
|
||||
"<!-- AHS:456 -->",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
expect(() => new CardExtractionService().extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 })).toThrow(
|
||||
"Multiple AHS markers found in heading block",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid AHS marker syntax", () => {
|
||||
const sourceFile: SourceFile = {
|
||||
path: "notes/example.md",
|
||||
basename: "example",
|
||||
content: [
|
||||
"#### Prompt",
|
||||
"Answer",
|
||||
"<!-- AHS:not-a-number -->",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
expect(() => new CardExtractionService().extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 })).toThrow(
|
||||
"Invalid AHS marker found in heading block",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects equal heading levels", () => {
|
||||
expect(() => validateHeadingPolicy({ qaHeadingLevel: 4, clozeHeadingLevel: 4 })).toThrow(
|
||||
"QA and Cloze heading levels must not be equal.",
|
||||
|
|
|
|||
|
|
@ -13,8 +13,17 @@ interface HeadingMatch {
|
|||
lineIndex: number;
|
||||
}
|
||||
|
||||
interface ExtractedMarkerResult {
|
||||
bodyLines: string[];
|
||||
embeddedNoteId?: number;
|
||||
contentEndLine: number;
|
||||
markerLine?: number;
|
||||
}
|
||||
|
||||
const HEADING_REGEXP = /^(#{1,6})\s+(.*?)\s*$/;
|
||||
const TARGET_DECK_REGEXP = /^\s*TARGET DECK\s*:\s*(.+?)\s*$/i;
|
||||
const VALID_AHS_MARKER_REGEXP = /^\s*<!--\s*AHS:([1-9]\d*)\s*-->\s*$/;
|
||||
const AHS_MARKER_CANDIDATE_REGEXP = /<!--\s*AHS:/;
|
||||
|
||||
export class CardExtractionService {
|
||||
extract(sourceFile: SourceFile, headingPolicy: HeadingPolicy): CardDraft[] {
|
||||
|
|
@ -35,15 +44,19 @@ export class CardExtractionService {
|
|||
|
||||
const blockEndLineIndex = findBlockEndLineIndex(headings, headingIndex, lines.length);
|
||||
const bodyLines = lines.slice(heading.lineIndex + 1, blockEndLineIndex);
|
||||
const bodyMarkdown = trimBlankEdges(bodyLines).join("\n");
|
||||
const extractedMarker = extractEmbeddedMarker(bodyLines, heading.lineIndex + 2, heading.lineIndex + 1);
|
||||
const bodyMarkdown = trimBlankEdges(extractedMarker.bodyLines).join("\n");
|
||||
|
||||
drafts.push({
|
||||
source: {
|
||||
filePath: sourceFile.path,
|
||||
sourceContent: sourceFile.content,
|
||||
headingLine: heading.lineIndex + 1,
|
||||
blockStartLine: heading.lineIndex + 1,
|
||||
bodyStartLine: heading.lineIndex + 2,
|
||||
blockEndLine: blockEndLineIndex,
|
||||
contentEndLine: extractedMarker.contentEndLine,
|
||||
markerLine: extractedMarker.markerLine,
|
||||
headingLevel: heading.level,
|
||||
headingText: heading.text,
|
||||
},
|
||||
|
|
@ -51,6 +64,7 @@ export class CardExtractionService {
|
|||
headingLevel: heading.level,
|
||||
type: cardType,
|
||||
bodyMarkdown,
|
||||
embeddedNoteId: extractedMarker.embeddedNoteId,
|
||||
deckHint: targetDeck ? createDeckName(targetDeck) : undefined,
|
||||
});
|
||||
}
|
||||
|
|
@ -169,4 +183,75 @@ function trimBlankEdges(lines: string[]): string[] {
|
|||
}
|
||||
|
||||
return lines.slice(startIndex, endIndex);
|
||||
}
|
||||
|
||||
function extractEmbeddedMarker(bodyLines: string[], bodyStartLine: number, headingLine: number): ExtractedMarkerResult {
|
||||
const ahsCandidates = bodyLines
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter(({ line }) => AHS_MARKER_CANDIDATE_REGEXP.test(line));
|
||||
const validMarkers = ahsCandidates.filter(({ line }) => VALID_AHS_MARKER_REGEXP.test(line));
|
||||
|
||||
if (validMarkers.length > 1) {
|
||||
throw new Error(`Multiple AHS markers found in heading block at line ${headingLine}.`);
|
||||
}
|
||||
|
||||
let lastNonEmptyIndex = bodyLines.length - 1;
|
||||
while (lastNonEmptyIndex >= 0 && !bodyLines[lastNonEmptyIndex].trim()) {
|
||||
lastNonEmptyIndex -= 1;
|
||||
}
|
||||
|
||||
if (lastNonEmptyIndex < 0) {
|
||||
if (ahsCandidates.length > 0) {
|
||||
throw new Error(`Invalid AHS marker found in heading block at line ${headingLine}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
bodyLines,
|
||||
contentEndLine: headingLine,
|
||||
};
|
||||
}
|
||||
|
||||
const lastNonEmptyLine = bodyLines[lastNonEmptyIndex];
|
||||
const validMarkerMatch = lastNonEmptyLine.match(VALID_AHS_MARKER_REGEXP);
|
||||
|
||||
if (validMarkerMatch) {
|
||||
const markerIndex = lastNonEmptyIndex;
|
||||
|
||||
for (const candidate of ahsCandidates) {
|
||||
if (candidate.index === markerIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Multiple or misplaced AHS markers found in heading block at line ${headingLine}.`);
|
||||
}
|
||||
|
||||
const nextBodyLines = bodyLines.filter((_line, index) => index !== markerIndex);
|
||||
const contentEndLine = findContentEndLine(nextBodyLines, bodyStartLine, headingLine);
|
||||
|
||||
return {
|
||||
bodyLines: nextBodyLines,
|
||||
embeddedNoteId: Number(validMarkerMatch[1]),
|
||||
contentEndLine,
|
||||
markerLine: bodyStartLine + markerIndex,
|
||||
};
|
||||
}
|
||||
|
||||
if (AHS_MARKER_CANDIDATE_REGEXP.test(lastNonEmptyLine) || ahsCandidates.length > 0) {
|
||||
throw new Error(`Invalid AHS marker found in heading block at line ${headingLine}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
bodyLines,
|
||||
contentEndLine: findContentEndLine(bodyLines, bodyStartLine, headingLine),
|
||||
};
|
||||
}
|
||||
|
||||
function findContentEndLine(bodyLines: string[], bodyStartLine: number, headingLine: number): number {
|
||||
for (let index = bodyLines.length - 1; index >= 0; index -= 1) {
|
||||
if (bodyLines[index].trim()) {
|
||||
return bodyStartLine + index;
|
||||
}
|
||||
}
|
||||
|
||||
return headingLine;
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ function createDraft(overrides: Partial<CardDraft> = {}): CardDraft {
|
|||
blockStartLine: 3,
|
||||
bodyStartLine: 4,
|
||||
blockEndLine: 8,
|
||||
contentEndLine: 7,
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
},
|
||||
|
|
@ -93,6 +94,7 @@ describe("CardRenderingService", () => {
|
|||
blockStartLine: 5,
|
||||
bodyStartLine: 6,
|
||||
blockEndLine: 7,
|
||||
contentEndLine: 6,
|
||||
headingLevel: 5,
|
||||
headingText: "Context",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export class CardRenderingService {
|
|||
type: draft.type,
|
||||
heading: draft.heading,
|
||||
bodyMarkdown: draft.bodyMarkdown,
|
||||
embeddedNoteId: draft.embeddedNoteId,
|
||||
deck,
|
||||
noteModel,
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
export interface SourceLocation {
|
||||
filePath: string;
|
||||
sourceContent?: string;
|
||||
headingLine: number;
|
||||
blockStartLine: number;
|
||||
bodyStartLine: number;
|
||||
blockEndLine: number;
|
||||
contentEndLine: number;
|
||||
markerLine?: number;
|
||||
headingLevel: number;
|
||||
headingText: string;
|
||||
}
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import type { CardKey } from "../../card/value-objects/CardKey";
|
||||
import type { ContentHash } from "../../card/value-objects/ContentHash";
|
||||
|
||||
export type SyncIdentityMode = "embedded-note-id" | "legacy-card-key" | "pending-note-id-write";
|
||||
|
||||
export interface SyncRecord {
|
||||
cardKey: CardKey;
|
||||
identityMode: SyncIdentityMode;
|
||||
legacyCardKey?: CardKey;
|
||||
noteId: number;
|
||||
filePath: string;
|
||||
sourceHash: ContentHash;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ describe("SyncRegistry", () => {
|
|||
const registry = new SyncRegistry([
|
||||
{
|
||||
cardKey: createCardKey("card-1"),
|
||||
identityMode: "legacy-card-key",
|
||||
noteId: 101,
|
||||
filePath: "notes/old.md",
|
||||
sourceHash: createContentHash("old-hash"),
|
||||
|
|
@ -22,6 +23,7 @@ describe("SyncRegistry", () => {
|
|||
|
||||
expect(registry.get(createCardKey("card-1"))).toEqual({
|
||||
cardKey: createCardKey("card-1"),
|
||||
identityMode: "legacy-card-key",
|
||||
noteId: 101,
|
||||
filePath: "notes/new.md",
|
||||
sourceHash: createContentHash("new-hash"),
|
||||
|
|
@ -34,6 +36,7 @@ describe("SyncRegistry", () => {
|
|||
const registry = new SyncRegistry([
|
||||
{
|
||||
cardKey: createCardKey("card-1"),
|
||||
identityMode: "legacy-card-key",
|
||||
noteId: 101,
|
||||
filePath: "notes/example.md",
|
||||
sourceHash: createContentHash("hash"),
|
||||
|
|
@ -47,4 +50,32 @@ describe("SyncRegistry", () => {
|
|||
expect(registry.get(createCardKey("card-1"))?.orphan).toBe(true);
|
||||
expect(registry.get(createCardKey("card-1"))?.noteId).toBe(101);
|
||||
});
|
||||
|
||||
it("reassigns a note id to the latest card key", () => {
|
||||
const registry = new SyncRegistry([
|
||||
{
|
||||
cardKey: createCardKey("card-1"),
|
||||
identityMode: "embedded-note-id",
|
||||
noteId: 101,
|
||||
filePath: "notes/example.md",
|
||||
sourceHash: createContentHash("hash"),
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
},
|
||||
]);
|
||||
|
||||
registry.recordSync({
|
||||
cardKey: createCardKey("card-2"),
|
||||
identityMode: "embedded-note-id",
|
||||
noteId: 101,
|
||||
filePath: "notes/renamed.md",
|
||||
sourceHash: createContentHash("hash"),
|
||||
lastSyncedAt: 2,
|
||||
orphan: false,
|
||||
});
|
||||
|
||||
expect(registry.get(createCardKey("card-1"))).toBeUndefined();
|
||||
expect(registry.get(createCardKey("card-2"))?.noteId).toBe(101);
|
||||
expect(registry.findByNoteId(101)?.cardKey).toBe(createCardKey("card-2"));
|
||||
});
|
||||
});
|
||||
|
|
@ -58,7 +58,7 @@ export class SyncRegistry {
|
|||
const existingForNoteId = this.cardKeysByNoteId.get(record.noteId);
|
||||
|
||||
if (existingForNoteId && existingForNoteId !== record.cardKey) {
|
||||
throw new Error(`Note ${record.noteId} is already assigned to another card key.`);
|
||||
this.recordsByCardKey.delete(existingForNoteId);
|
||||
}
|
||||
|
||||
const previous = this.recordsByCardKey.get(record.cardKey);
|
||||
|
|
|
|||
|
|
@ -14,16 +14,19 @@ function createCard(overrides: Partial<Card> = {}): Card {
|
|||
key: createCardKey("card-a"),
|
||||
source: {
|
||||
filePath: "notes/example.md",
|
||||
sourceContent: ["#### Prompt", "Answer"].join("\n"),
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
headingLevel: 4,
|
||||
headingText: "Prompt",
|
||||
},
|
||||
type: "basic",
|
||||
heading: "Prompt",
|
||||
bodyMarkdown: "Answer",
|
||||
embeddedNoteId: undefined,
|
||||
deck: createDeckName("Deck"),
|
||||
noteModel: createNoteModelName("Basic"),
|
||||
tags: [],
|
||||
|
|
@ -44,6 +47,7 @@ function createCard(overrides: Partial<Card> = {}): Card {
|
|||
function createRecord(overrides: Partial<SyncRecord> = {}): SyncRecord {
|
||||
return {
|
||||
cardKey: createCardKey("card-a"),
|
||||
identityMode: "legacy-card-key",
|
||||
noteId: 100,
|
||||
filePath: "notes/example.md",
|
||||
sourceHash: createContentHash("hash-a"),
|
||||
|
|
@ -62,10 +66,12 @@ describe("SyncPlanningService", () => {
|
|||
contentHash: createContentHash("hash-c"),
|
||||
source: {
|
||||
filePath: "notes/second.md",
|
||||
sourceContent: ["#### Another", "Answer"].join("\n"),
|
||||
headingLine: 1,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 3,
|
||||
contentEndLine: 2,
|
||||
headingLevel: 4,
|
||||
headingText: "Another",
|
||||
},
|
||||
|
|
@ -97,4 +103,67 @@ describe("SyncPlanningService", () => {
|
|||
"Duplicate card key detected in current scan",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers embedded note id over legacy card key and tolerates registry loss", () => {
|
||||
const service = new SyncPlanningService();
|
||||
const embeddedCard = createCard({
|
||||
key: createCardKey("moved-card-key"),
|
||||
embeddedNoteId: 222,
|
||||
source: {
|
||||
...createCard().source,
|
||||
filePath: "notes/moved.md",
|
||||
},
|
||||
});
|
||||
|
||||
const planWithoutRegistry = service.plan([embeddedCard], new SyncRegistry(), ["notes/moved.md"]);
|
||||
|
||||
expect(planWithoutRegistry.toAdd).toHaveLength(0);
|
||||
expect(planWithoutRegistry.toUpdate).toEqual([{ card: embeddedCard, noteId: 222 }]);
|
||||
});
|
||||
|
||||
it("marks embedded and legacy records orphan using different identity modes", () => {
|
||||
const service = new SyncPlanningService();
|
||||
const registry = new SyncRegistry([
|
||||
createRecord({
|
||||
cardKey: createCardKey("legacy-card"),
|
||||
noteId: 100,
|
||||
filePath: "notes/legacy.md",
|
||||
}),
|
||||
createRecord({
|
||||
cardKey: createCardKey("embedded-card"),
|
||||
identityMode: "embedded-note-id",
|
||||
noteId: 200,
|
||||
filePath: "notes/embedded.md",
|
||||
}),
|
||||
]);
|
||||
|
||||
const embeddedCard = createCard({
|
||||
key: createCardKey("different-card-key"),
|
||||
embeddedNoteId: 200,
|
||||
source: {
|
||||
...createCard().source,
|
||||
filePath: "notes/embedded.md",
|
||||
},
|
||||
});
|
||||
|
||||
const plan = service.plan([embeddedCard], registry, ["notes/legacy.md", "notes/embedded.md"]);
|
||||
|
||||
expect(plan.toMarkOrphan.map((record) => record.noteId)).toEqual([100]);
|
||||
});
|
||||
|
||||
it("forces pending note-id-write records back through update flow", () => {
|
||||
const service = new SyncPlanningService();
|
||||
const card = createCard();
|
||||
const registry = new SyncRegistry([
|
||||
createRecord({
|
||||
cardKey: card.key,
|
||||
identityMode: "pending-note-id-write",
|
||||
legacyCardKey: card.key,
|
||||
}),
|
||||
]);
|
||||
|
||||
const plan = service.plan([card], registry, ["notes/example.md"]);
|
||||
|
||||
expect(plan.toUpdate).toEqual([{ card, noteId: 100 }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import type { SyncPlan } from "../value-objects/SyncPlan";
|
|||
export class SyncPlanningService {
|
||||
plan(cards: Card[], registry: SyncRegistry, scopedFilePaths: string[]): SyncPlan {
|
||||
const seenKeys = new Set<string>();
|
||||
const seenEmbeddedNoteIds = new Set<number>();
|
||||
const createDecks = new Map<string, Card["deck"]>();
|
||||
const toAdd: Card[] = [];
|
||||
const toUpdate: SyncPlan["toUpdate"] = [];
|
||||
|
|
@ -15,6 +16,29 @@ export class SyncPlanningService {
|
|||
}
|
||||
|
||||
seenKeys.add(card.key);
|
||||
|
||||
if (card.embeddedNoteId) {
|
||||
if (seenEmbeddedNoteIds.has(card.embeddedNoteId)) {
|
||||
throw new Error(`Duplicate embedded note id detected in current scan: ${card.embeddedNoteId}`);
|
||||
}
|
||||
|
||||
seenEmbeddedNoteIds.add(card.embeddedNoteId);
|
||||
const existingEmbeddedRecord = registry.findByNoteId(card.embeddedNoteId);
|
||||
|
||||
if (!existingEmbeddedRecord) {
|
||||
toUpdate.push({ card, noteId: card.embeddedNoteId });
|
||||
createDecks.set(card.deck, card.deck);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingEmbeddedRecord.sourceHash !== card.contentHash || existingEmbeddedRecord.orphan) {
|
||||
toUpdate.push({ card, noteId: card.embeddedNoteId });
|
||||
createDecks.set(card.deck, card.deck);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingRecord = registry.get(card.key);
|
||||
|
||||
if (!existingRecord) {
|
||||
|
|
@ -23,14 +47,24 @@ export class SyncPlanningService {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (existingRecord.sourceHash !== card.contentHash || existingRecord.orphan) {
|
||||
if (existingRecord.identityMode === "pending-note-id-write" || existingRecord.sourceHash !== card.contentHash || existingRecord.orphan) {
|
||||
toUpdate.push({ card, noteId: existingRecord.noteId });
|
||||
createDecks.set(card.deck, card.deck);
|
||||
}
|
||||
}
|
||||
|
||||
const scopedPaths = new Set(scopedFilePaths);
|
||||
const toMarkOrphan = registry.list().filter((record) => scopedPaths.has(record.filePath) && !record.orphan && !seenKeys.has(record.cardKey));
|
||||
const toMarkOrphan = registry.list().filter((record) => {
|
||||
if (!scopedPaths.has(record.filePath) || record.orphan) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.identityMode === "embedded-note-id") {
|
||||
return !seenEmbeddedNoteIds.has(record.noteId);
|
||||
}
|
||||
|
||||
return !seenKeys.has(record.cardKey);
|
||||
});
|
||||
|
||||
return {
|
||||
toCreateDecks: Array.from(createDecks.values()),
|
||||
|
|
|
|||
|
|
@ -59,4 +59,32 @@ describe("AnkiConnectGateway", () => {
|
|||
isCloze: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns note existence and model summaries", async () => {
|
||||
requestUrlMock.mockResolvedValue({
|
||||
json: {
|
||||
error: null,
|
||||
result: [
|
||||
{ noteId: 100, modelName: "Basic", cards: [1] },
|
||||
null,
|
||||
{ noteId: 102, modelName: "Cloze", cards: [2] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
|
||||
const summaries = await gateway.getNoteSummaries([100, 101, 102]);
|
||||
|
||||
expect(summaries).toEqual([
|
||||
{ noteId: 100, modelName: "Basic" },
|
||||
{ noteId: 102, modelName: "Cloze" },
|
||||
]);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
action: "notesInfo",
|
||||
version: 6,
|
||||
params: {
|
||||
notes: [100, 101, 102],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
|
||||
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
|
||||
import type { AddAnkiNoteInput, AnkiGateway, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
|
||||
import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
|
||||
import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
|
||||
|
||||
interface AnkiResponse<T> {
|
||||
|
|
@ -11,6 +11,8 @@ interface AnkiResponse<T> {
|
|||
|
||||
interface NoteInfo {
|
||||
cards: number[];
|
||||
modelName?: string;
|
||||
noteId?: number;
|
||||
}
|
||||
|
||||
type ModelTemplates = Record<string, unknown>;
|
||||
|
|
@ -43,6 +45,27 @@ export class AnkiConnectGateway implements AnkiGateway {
|
|||
};
|
||||
}
|
||||
|
||||
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const noteInfo = await this.invoke<Array<NoteInfo | null>>("notesInfo", {
|
||||
notes: noteIds,
|
||||
});
|
||||
|
||||
return noteInfo.flatMap((entry) => {
|
||||
if (!entry || typeof entry.noteId !== "number" || typeof entry.modelName !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
noteId: entry.noteId,
|
||||
modelName: entry.modelName,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
async addNote(input: AddAnkiNoteInput): Promise<number> {
|
||||
return this.invoke<number>("addNote", {
|
||||
note: {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,21 @@ export class ObsidianVaultGateway implements VaultGateway {
|
|||
return this.toSourceFile(abstractFile);
|
||||
}
|
||||
|
||||
async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string) {
|
||||
const abstractFile = this.app.vault.getAbstractFileByPath(path);
|
||||
|
||||
if (!(abstractFile instanceof TFile) || abstractFile.extension.toLowerCase() !== "md") {
|
||||
throw new Error(`Markdown file not found: ${path}`);
|
||||
}
|
||||
|
||||
const currentContent = await this.app.vault.cachedRead(abstractFile);
|
||||
if (currentContent !== expectedContent) {
|
||||
throw new Error(`Markdown file changed before AHS write-back: ${path}`);
|
||||
}
|
||||
|
||||
await this.app.vault.modify(abstractFile, nextContent);
|
||||
}
|
||||
|
||||
resolveWikiLink(rawTarget: string, sourcePath: string) {
|
||||
const { alias, linkPath } = parseLinkTarget(rawTarget);
|
||||
const destination = this.app.metadataCache.getFirstLinkpathDest(stripSubpath(linkPath), sourcePath);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ export interface PluginDataSnapshot {
|
|||
records: Array<{
|
||||
cardKey: string;
|
||||
filePath: string;
|
||||
identityMode?: "embedded-note-id" | "legacy-card-key" | "pending-note-id-write";
|
||||
lastSyncedAt: number;
|
||||
legacyCardKey?: string;
|
||||
noteId: number;
|
||||
orphan: boolean;
|
||||
sourceHash: string;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ describe("DataJsonSyncRegistryRepository", () => {
|
|||
const registry = new SyncRegistry([
|
||||
{
|
||||
cardKey: createCardKey("card-1"),
|
||||
identityMode: "embedded-note-id",
|
||||
legacyCardKey: createCardKey("legacy-1"),
|
||||
noteId: 101,
|
||||
filePath: "notes/example.md",
|
||||
sourceHash: createContentHash("hash-1"),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export class DataJsonSyncRegistryRepository implements SyncRegistryRepository {
|
|||
cardKey: createCardKey(record.cardKey),
|
||||
noteId: record.noteId,
|
||||
filePath: record.filePath,
|
||||
identityMode: record.identityMode ?? "legacy-card-key",
|
||||
legacyCardKey: record.legacyCardKey ? createCardKey(record.legacyCardKey) : undefined,
|
||||
sourceHash: createContentHash(record.sourceHash),
|
||||
lastSyncedAt: record.lastSyncedAt,
|
||||
orphan: record.orphan,
|
||||
|
|
@ -34,6 +36,8 @@ export class DataJsonSyncRegistryRepository implements SyncRegistryRepository {
|
|||
cardKey: record.cardKey,
|
||||
noteId: record.noteId,
|
||||
filePath: record.filePath,
|
||||
identityMode: record.identityMode,
|
||||
legacyCardKey: record.legacyCardKey,
|
||||
sourceHash: record.sourceHash,
|
||||
lastSyncedAt: record.lastSyncedAt,
|
||||
orphan: record.orphan,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
|
|||
}
|
||||
|
||||
const scanAndPlanSyncUseCase = new ScanAndPlanSyncUseCase(vaultGateway, syncRegistryRepository);
|
||||
const executeSyncPlanUseCase = new ExecuteSyncPlanUseCase(this.ankiGateway, syncRegistryRepository);
|
||||
const executeSyncPlanUseCase = new ExecuteSyncPlanUseCase(this.ankiGateway, syncRegistryRepository, vaultGateway);
|
||||
this.syncCurrentFileUseCase = new SyncCurrentFileUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase);
|
||||
this.syncVaultUseCase = new SyncVaultUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue