chore: rebuild dist and sync

This commit is contained in:
Dusk 2026-04-16 18:02:02 +08:00
commit f854a3b581
72 changed files with 8071 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
dist/
node_modules/

1287
anki-plugin-ddd-idea.md Normal file

File diff suppressed because it is too large Load diff

111
docs/v1-decisions.md Normal file
View file

@ -0,0 +1,111 @@
# V1 Decisions
This file is the authoritative lock for V1 implementation behavior.
If an implementation detail conflicts with this file, this file wins.
## Product Boundary
- Obsidian is the only source of truth.
- Sync direction is only Obsidian -> Anki.
- Card authoring is heading-paragraph based only.
- V1 is intentionally narrow and does not become a generic syntax platform.
## Heading Policy
- Default QA heading level is H4.
- Default Cloze heading level is H5.
- QA and Cloze heading levels are configurable.
- QA and Cloze heading levels must not be equal.
## Card Identity
- `cardKey` is computed as:
`hash(filePath + headingLevel + headingText + blockStartLine + cardType)`
- V1 accepts instability caused by:
- heading rename
- file move
- line-number shifts
- V1 does not introduce:
- hidden source ids
- anchor-based identity
- persistent block ids
## Field Mapping
### Basic
- Front = heading
- Back = body
### Cloze
- Text = body
- Extra = heading
### Model Resolution
- QA note type is configurable and defaults to `Basic`.
- Cloze note type is configurable and defaults to `Cloze`.
- V1 uses queried Anki model field names plus small heuristics.
- V1 does not introduce a full user-facing field-mapping UI.
## Deck Resolution
Deck resolution order is:
1. file-level `TARGET DECK`
2. plugin default deck
V1 does not implement:
- folder -> deck mapping
- path-derived deck rules
- per-heading deck overrides
## Scan Scope
- `includeFolders` empty => scan all markdown files in the vault
- `includeFolders` non-empty => scan only files under included folders
- `excludeFolders` always applies
- only markdown files are candidates
- no glob DSL is supported in V1
## Sync Behavior
- V1 supports deck creation.
- V1 supports note add.
- V1 supports note update.
- V1 supports media upload.
- V1 supports incremental sync through local sync records.
- V1 supports orphan detection.
### Orphan Handling
- detect and mark only
- never auto-delete in V1
- deletion remains out of scope unless a future explicit manual workflow is added
## Rendering Target
- prioritize readability and reviewability in Anki
- preserve math where practical
- preserve code blocks and inline code where practical
- preserve links and media where practical
- preserve Obsidian backlinks where enabled
- do not chase pixel-perfect Obsidian rendering fidelity
## Explicitly Out Of Scope For V1
- bidirectional sync
- Anki -> Obsidian writeback
- Python tooling or sidecar CLI
- folder -> deck mapping
- folder -> tag mapping
- generic regex syntax systems
- inline note systems
- begin/end note block systems
- automatic orphan deletion
- complex glob or scan DSLs

110
docs/v1-gap-report.md Normal file
View file

@ -0,0 +1,110 @@
# V1 Gap Report
## Audit Summary
The current implementation is already close to the memo's intended V1 surface.
It passes its existing tests, build, and lint, and it already supports the main heading-paragraph workflow.
The remaining problems are not a full-rewrite problem.
They are a focused repair problem:
1. a few DDD boundaries are still leaking,
2. some V1 product decisions are implemented only implicitly,
3. the build pipeline does not yet produce a ready-to-sync plugin package by itself,
4. a few important V1 behaviors are not directly protected by tests.
## What Already Aligns With The Memo
- Obsidian is treated as the only source of truth.
- Sync direction is one-way only: Obsidian -> Anki.
- Card authoring is heading-paragraph based.
- Default heading levels are H4 for QA and H5 for Cloze.
- Equal QA and Cloze heading levels are rejected.
- `TARGET DECK` is supported as a file-level override.
- Default deck, QA note type, and Cloze note type are configurable.
- Include/exclude folder scanning exists and is markdown-only.
- Add, update, deck creation, media upload, incremental sync, and orphan marking are implemented.
- Auto-delete for orphan cards is not implemented.
- Rendering already covers markdown, math, code, wikilinks, image embeds, audio embeds, and Obsidian backlinks.
## V1 Scope Drift Or Delivery Gaps
### 1. Build output was not fully productized
The bundle was emitted to a build folder, but the full plugin package still depended on a manual follow-up step to copy:
- `manifest.json`
- `versions.json`
- a small distribution README
That is a delivery gap for a plugin project because the build should produce a ready-to-sync package folder directly.
### 2. V1 decisions were not locked in a dedicated authoritative document
The repository had an implementation plan, but not a dedicated V1 decisions document that explicitly locks:
- cardKey strategy
- Basic field mapping
- Cloze field mapping
- orphan behavior
- scan scope behavior
- explicit out-of-scope items
That makes future edits more likely to drift.
## DDD Boundary Violations
### 1. Domain rendering depended on an application-layer port
`CardRenderingService` imported `RenderResourceResolver` from `application/ports`.
This is a direct layering leak because the rendering contract is part of the domain-facing rendering boundary and should be owned below, or at least alongside, the domain rather than above it.
### 2. Anki port depended on a service-owned DTO
`AnkiGateway` depended on `NoteModelDetails` exported from `NoteFieldMappingService`.
That couples an application port to a concrete service file rather than to a neutral DTO/contract.
### 3. Sync registry lifecycle was application-heavy
The registry entity already existed, but the execution use case still assembled several lifecycle transitions directly with raw object spreads.
That is acceptable for a quick implementation, but it under-expresses the memo's intent that sync lifecycle should be a first-class domain concern.
## Missing Or Weakly Protected Tests
The current suite is good, but two V1-critical areas were still under-protected:
1. plugin-level heading validation was not directly tested through `PluginSettings`
2. execute-time orphan marking behavior was not directly tested in the execution use case itself
## Decisions Implemented Implicitly But Not Clearly Locked
These decisions already existed in code, but were not written down as authoritative V1 decisions:
- card identity uses `hash(filePath + headingLevel + headingText + blockStartLine + cardType)`
- Basic cards map heading to Front and body to Back
- Cloze cards map body to Text and heading to Extra
- orphan handling marks records only and never auto-deletes
- scan scope is include-first, exclude-always, markdown-only
- rendering targets readability and reviewability rather than pixel-perfect Obsidian fidelity
- model field mapping uses minimal heuristics instead of a full field-mapping UI
## Repair Plan
1. Add `docs/v1-decisions.md` and lock V1 behavior explicitly.
2. Move rendering resource contracts to a domain-owned boundary.
3. Move note model details into a neutral DTO contract.
4. Tighten sync registry lifecycle so execution uses domain methods instead of ad hoc object rewrites.
5. Make the build emit a ready-to-sync plugin package in one folder.
6. Add targeted tests for plugin settings validation and execute-time orphan handling.
## Non-Goals For This Repair Pass
- No bidirectional sync.
- No folder-to-deck or folder-to-tag mapping.
- No generic syntax platform.
- No Python tooling.
- No automatic orphan deletion.
- No anchor-based or hidden-source identity scheme.

View file

@ -0,0 +1,46 @@
# V1 Implementation Plan
This file locks the concrete V1 implementation decisions before major coding.
## Scope
- Direction is only Obsidian -> Anki.
- Obsidian content is the only source of truth.
- Card authoring is heading-paragraph based only.
- V1 supports only Basic and Cloze cards.
- V1 supports add, update, media upload, deck creation, incremental sync, and orphan marking.
- V1 does not support bidirectional sync, Python tooling, generic syntax platforms, or orphan auto-delete.
## Concrete V1 Decisions
- Project is implemented in TypeScript only.
- Architecture is split into domain, application, infrastructure, and presentation layers.
- Default heading levels are H4 for Basic and H5 for Cloze.
- Validation rejects equal QA and Cloze heading levels.
- Card identity is generated as a hash of file path, heading level, heading text, block start line, and card type.
- File-level deck override is parsed from a literal TARGET DECK line.
- Scan scope is folder include/exclude only. No glob DSL is supported.
- When includeFolders is empty, the whole vault is scanned for Markdown files.
- Exclude folders always filter candidates.
- Cloze cards render body content into the cloze field and heading into an auxiliary context field.
- Orphan handling only marks local records as orphan. It never deletes Anki notes.
## Rendering Decisions
- Markdown rendering uses a focused pipeline tuned for Anki output rather than trying to reproduce the entire Obsidian renderer.
- Obsidian math is preserved as MathJax-compatible inline and display markup.
- Wikilinks become obsidian:// backlinks where possible.
- Image embeds render to HTML img tags after media upload.
- Audio embeds render to Anki sound tags after media upload.
- Inline code and fenced code blocks are preserved through markdown rendering.
## Note Model Assumptions
- Basic note type defaults to Basic and maps to front/back style fields.
- Cloze note type defaults to Cloze and maps body into a text field and heading into an auxiliary field such as Extra.
- V1 resolves model field names by querying AnkiConnect and applying small heuristics rather than exposing a full field mapping system.
## Validation Plan
- Unit tests cover extraction, identity, deck resolution, rendering, sync planning, scan scope behavior, persistence, and application use cases.
- Final validation runs npm test, npm run build, and npm run lint.

43
esbuild.config.mjs Normal file
View file

@ -0,0 +1,43 @@
import esbuild from "esbuild";
import process from "node:process";
import builtins from "builtin-modules";
const banner = `/*\nTHIS IS A GENERATED/BUNDLED FILE BY ESBUILD\n*/`;
const production = process.argv[2] === "production";
const context = await esbuild.context({
banner: { js: banner },
bundle: true,
entryPoints: ["main.ts"],
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
],
format: "cjs",
logLevel: "info",
minify: production,
outfile: "dist/plugin/main.js",
sourcemap: production ? false : "inline",
target: "es2020",
treeShaking: true,
});
if (production) {
await context.rebuild();
process.exit(0);
}
await context.watch();

30
eslint.config.mjs Normal file
View file

@ -0,0 +1,30 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["main.js", "node_modules/**", "coverage/**"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.ts"],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
project: "./tsconfig.json",
},
},
rules: {
"@typescript-eslint/consistent-type-imports": [
"error",
{ "prefer": "type-imports" }
],
"@typescript-eslint/no-explicit-any": "error"
},
},
);

20
main.js Normal file

File diff suppressed because one or more lines are too long

3
main.ts Normal file
View file

@ -0,0 +1,3 @@
import AnkiHeadingSyncPlugin from "./src/presentation/AnkiHeadingSyncPlugin";
export default AnkiHeadingSyncPlugin;

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "anki-heading-sync",
"name": "Anki Heading Sync",
"version": "1.0.0",
"minAppVersion": "1.5.0",
"description": "Focused heading-based Obsidian to Anki sync plugin.",
"author": "GitHub Copilot",
"authorUrl": "https://github.com/github/copilot",
"isDesktopOnly": true
}

3428
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "obsidian-anki-heading-sync",
"version": "1.0.0",
"description": "Focused Obsidian to Anki heading-based sync plugin.",
"main": "dist/plugin/main.js",
"dependencies": {
"markdown-it": "^14.1.0"
},
"scripts": {
"dev": "node scripts/stage-plugin-dist.mjs && node esbuild.config.mjs",
"build": "tsc --noEmit --skipLibCheck && node scripts/stage-plugin-dist.mjs && node esbuild.config.mjs production",
"lint": "eslint \"src/**/*.ts\" \"main.ts\" \"vitest.config.ts\" \"esbuild.config.mjs\" \"eslint.config.mjs\"",
"test": "vitest run"
},
"keywords": [
"obsidian",
"anki",
"plugin"
],
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^22.15.3",
"@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1",
"builtin-modules": "^4.0.0",
"esbuild": "^0.25.3",
"eslint": "^9.39.1",
"globals": "^16.4.0",
"obsidian": "latest",
"typescript": "^5.8.3",
"typescript-eslint": "^8.46.1",
"vitest": "^3.2.4"
}
}

View file

@ -0,0 +1,31 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const rootDir = resolve(scriptDir, "..");
const distDir = join(rootDir, "dist");
const pluginDistDir = join(distDir, "plugin");
await mkdir(pluginDistDir, { recursive: true });
await rm(join(distDir, "main.js"), { force: true });
await Promise.all([
copyFile(join(rootDir, "manifest.json"), join(pluginDistDir, "manifest.json")),
copyFile(join(rootDir, "versions.json"), join(pluginDistDir, "versions.json")),
]);
await writeFile(
join(pluginDistDir, "README.md"),
[
"Anki Heading Sync — Obsidian plugin distribution package",
"",
"Build output lives in this folder so it can be synced directly into an Obsidian plugin directory.",
"",
"Included files:",
"- manifest.json",
"- main.js",
"- versions.json",
].join("\n"),
"utf8",
);

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, validatePluginSettings } from "./PluginSettings";
describe("PluginSettings", () => {
it("accepts the default V1 settings", () => {
expect(() => validatePluginSettings(DEFAULT_SETTINGS)).not.toThrow();
});
it("rejects equal QA and Cloze heading levels", () => {
expect(() =>
validatePluginSettings({
...DEFAULT_SETTINGS,
qaHeadingLevel: 4,
clozeHeadingLevel: 4,
}),
).toThrow("QA and Cloze heading levels must be different.");
});
});

View file

@ -0,0 +1,55 @@
export interface PluginSettings {
qaHeadingLevel: number;
clozeHeadingLevel: number;
qaNoteType: string;
clozeNoteType: string;
defaultDeck: string;
includeFolders: string[];
excludeFolders: string[];
addObsidianBacklink: boolean;
convertHighlightsToCloze: boolean;
ankiConnectUrl: string;
}
export const DEFAULT_SETTINGS: PluginSettings = {
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
qaNoteType: "Basic",
clozeNoteType: "Cloze",
defaultDeck: "Obsidian",
includeFolders: [],
excludeFolders: [],
addObsidianBacklink: true,
convertHighlightsToCloze: true,
ankiConnectUrl: "http://127.0.0.1:8765",
};
export function validatePluginSettings(settings: PluginSettings): void {
const headingLevels = [settings.qaHeadingLevel, settings.clozeHeadingLevel];
for (const level of headingLevels) {
if (!Number.isInteger(level) || level < 1 || level > 6) {
throw new Error("Heading levels must be integers between 1 and 6.");
}
}
if (settings.qaHeadingLevel === settings.clozeHeadingLevel) {
throw new Error("QA and Cloze heading levels must be different.");
}
if (!settings.qaNoteType.trim()) {
throw new Error("QA note type is required.");
}
if (!settings.clozeNoteType.trim()) {
throw new Error("Cloze note type is required.");
}
if (!settings.defaultDeck.trim()) {
throw new Error("Default deck is required.");
}
if (!settings.ankiConnectUrl.trim()) {
throw new Error("AnkiConnect URL is required.");
}
}

View file

@ -0,0 +1,4 @@
export interface NoteModelDetails {
fieldNames: string[];
isCloze: boolean;
}

View file

@ -0,0 +1,23 @@
import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
export interface AddAnkiNoteInput {
deckName: string;
modelName: string;
fields: Record<string, string>;
tags: string[];
}
export interface UpdateAnkiNoteInput {
noteId: number;
deckName: string;
fields: Record<string, string>;
}
export interface AnkiGateway {
ensureDeckExists(deckName: string): Promise<void>;
getModelDetails(modelName: string): Promise<NoteModelDetails>;
addNote(input: AddAnkiNoteInput): Promise<number>;
updateNote(input: UpdateAnkiNoteInput): Promise<void>;
storeMedia(asset: MediaAsset): Promise<void>;
}

View file

@ -0,0 +1,6 @@
import type { PluginSettings } from "../config/PluginSettings";
export interface PluginConfigRepository {
load(): Promise<PluginSettings>;
save(settings: PluginSettings): Promise<void>;
}

View file

@ -0,0 +1,4 @@
export interface PluginDataStore<TData> {
load(): Promise<TData | null>;
save(data: TData): Promise<void>;
}

View file

@ -0,0 +1,5 @@
export type {
RenderResourceResolver,
ResolvedEmbed,
ResolvedWikiLink,
} from "@/domain/card/ports/RenderResourceResolver";

View file

@ -0,0 +1,6 @@
import type { SyncRegistry } from "@/domain/sync/entities/SyncRegistry";
export interface SyncRegistryRepository {
load(): Promise<SyncRegistry>;
save(registry: SyncRegistry): Promise<void>;
}

View file

@ -0,0 +1,7 @@
import type { SourceFile } from "@/domain/card/entities/SourceFile";
import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceResolver";
export interface VaultGateway extends RenderResourceResolver {
listMarkdownFiles(): Promise<SourceFile[]>;
getMarkdownFile(path: string): Promise<SourceFile | null>;
}

View file

@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
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 { NoteFieldMappingService } from "./NoteFieldMappingService";
function createCard(overrides: Partial<Card>): Card {
return {
key: createCardKey("card-key"),
source: {
filePath: "notes/example.md",
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
headingLevel: 4,
headingText: "Prompt",
},
type: "basic",
heading: "Prompt",
bodyMarkdown: "Answer",
deck: createDeckName("Deck"),
noteModel: createNoteModelName("Basic"),
tags: [],
renderedFields: {
kind: "basic",
values: {
front: "Prompt",
back: "Answer",
},
},
fields: {
front: "Prompt",
back: "Answer",
},
contentHash: createContentHash("hash"),
media: [],
...overrides,
};
}
describe("NoteFieldMappingService", () => {
it("maps basic semantic fields onto a basic model", () => {
const service = new NoteFieldMappingService();
const fields = service.map(createCard({}), {
fieldNames: ["Front", "Back"],
isCloze: false,
});
expect(fields).toEqual({ Front: "Prompt", Back: "Answer" });
});
it("maps cloze semantic fields onto text and extra fields", () => {
const service = new NoteFieldMappingService();
const fields = service.map(
createCard({
type: "cloze",
noteModel: createNoteModelName("Cloze"),
renderedFields: {
kind: "cloze",
values: {
text: "{{c1::answer}}",
extra: "Context",
},
},
fields: {
text: "{{c1::answer}}",
extra: "Context",
},
}),
{
fieldNames: ["Text", "Extra"],
isCloze: true,
},
);
expect(fields).toEqual({ Text: "{{c1::answer}}", Extra: "Context" });
});
it("rejects a non-cloze model for a cloze card", () => {
const service = new NoteFieldMappingService();
expect(() =>
service.map(
createCard({
type: "cloze",
noteModel: createNoteModelName("WrongModel"),
renderedFields: {
kind: "cloze",
values: {
text: "{{c1::answer}}",
extra: "Context",
},
},
fields: {
text: "{{c1::answer}}",
extra: "Context",
},
}),
{
fieldNames: ["Front", "Back"],
isCloze: false,
},
),
).toThrow("must target a cloze-compatible note model");
});
});

View file

@ -0,0 +1,49 @@
import type { Card } from "@/domain/card/entities/Card";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
export class NoteFieldMappingService {
map(card: Card, noteModelDetails: NoteModelDetails): Record<string, string> {
if (card.type === "basic") {
return this.mapBasic(card, noteModelDetails.fieldNames);
}
return this.mapCloze(card, noteModelDetails);
}
private mapBasic(card: Card, fieldNames: string[]): Record<string, string> {
const frontFieldName = findFieldName(fieldNames, ["Front"]) ?? fieldNames[0];
const backFieldName = findFieldName(fieldNames, ["Back"]) ?? fieldNames[1];
if (!frontFieldName || !backFieldName) {
throw new Error(`Basic note model ${card.noteModel} must expose at least two fields.`);
}
return {
[frontFieldName]: card.fields.front,
[backFieldName]: card.fields.back,
};
}
private mapCloze(card: Card, noteModelDetails: NoteModelDetails): Record<string, string> {
if (!noteModelDetails.isCloze) {
throw new Error(`Cloze card ${card.key} must target a cloze-compatible note model.`);
}
const textFieldName = findFieldName(noteModelDetails.fieldNames, ["Text"]) ?? noteModelDetails.fieldNames[0];
const extraFieldName =
findFieldName(noteModelDetails.fieldNames, ["Extra", "Context"]) ?? noteModelDetails.fieldNames[1];
if (!textFieldName || !extraFieldName) {
throw new Error(`Cloze note model ${card.noteModel} must expose text and auxiliary fields.`);
}
return {
[textFieldName]: card.fields.text,
[extraFieldName]: card.fields.extra,
};
}
}
function findFieldName(fieldNames: string[], preferredNames: string[]): string | undefined {
return fieldNames.find((fieldName) => preferredNames.some((preferredName) => preferredName.toLowerCase() === fieldName.toLowerCase()));
}

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { ScanScopeService } from "./ScanScopeService";
describe("ScanScopeService", () => {
it("scans the whole vault when includeFolders is empty", () => {
const service = new ScanScopeService();
const files = service.filter(
[
{ path: "notes/one.md", basename: "one", content: "" },
{ path: "notes/two.md", basename: "two", content: "" },
{ path: "notes/three.txt", basename: "three", content: "" },
],
[],
[],
);
expect(files.map((file) => file.path)).toEqual(["notes/one.md", "notes/two.md"]);
});
it("applies includeFolders and excludeFolders together", () => {
const service = new ScanScopeService();
const files = service.filter(
[
{ path: "cards/a.md", basename: "a", content: "" },
{ path: "cards/archive/b.md", basename: "b", content: "" },
{ path: "other/c.md", basename: "c", content: "" },
],
["cards"],
["cards/archive"],
);
expect(files.map((file) => file.path)).toEqual(["cards/a.md"]);
});
});

View file

@ -0,0 +1,33 @@
import type { SourceFile } from "@/domain/card/entities/SourceFile";
export class ScanScopeService {
filter(files: SourceFile[], includeFolders: string[], excludeFolders: string[]): SourceFile[] {
const normalizedIncludes = includeFolders.map(normalizeFolderPath).filter(Boolean);
const normalizedExcludes = excludeFolders.map(normalizeFolderPath).filter(Boolean);
return files.filter((file) => {
if (!file.path.toLowerCase().endsWith(".md")) {
return false;
}
const normalizedPath = normalizeFilePath(file.path);
const included =
normalizedIncludes.length === 0 || normalizedIncludes.some((folder) => isPathInsideFolder(normalizedPath, folder));
const excluded = normalizedExcludes.some((folder) => isPathInsideFolder(normalizedPath, folder));
return included && !excluded;
});
}
}
function normalizeFolderPath(folderPath: string): string {
return folderPath.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
}
function normalizeFilePath(filePath: string): string {
return filePath.trim().replace(/\\/g, "/").replace(/^\/+/, "");
}
function isPathInsideFolder(filePath: string, folderPath: string): boolean {
return filePath === folderPath || filePath.startsWith(`${folderPath}/`);
}

View file

@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import type { AnkiGateway } from "@/application/ports/AnkiGateway";
import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository";
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 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[] = [];
async ensureDeckExists(deckName: string): Promise<void> {
this.ensuredDecks.push(deckName);
}
async getModelDetails(modelName: string) {
return modelName === "Cloze"
? { fieldNames: ["Text", "Extra"], isCloze: true }
: { fieldNames: ["Front", "Back"], isCloze: false };
}
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
this.addedNotes.push(input);
return 9001;
}
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
this.updatedNotes.push(input);
}
async storeMedia(asset: { fileName: string }): Promise<void> {
this.storedMedia.push(asset.fileName);
}
}
function createCard(overrides: Partial<Card> = {}): Card {
return {
key: createCardKey("card-1"),
source: {
filePath: "notes/current.md",
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
headingLevel: 4,
headingText: "Prompt",
},
type: "basic",
heading: "Prompt",
bodyMarkdown: "Answer",
deck: createDeckName("Deck"),
noteModel: createNoteModelName("Basic"),
tags: [],
renderedFields: {
kind: "basic",
values: {
front: "Prompt",
back: "Answer",
},
},
fields: {
front: "Prompt",
back: "Answer",
},
contentHash: createContentHash("hash-1"),
media: [],
...overrides,
};
}
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,
},
]),
);
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1234);
const result: ScanAndPlanResult = {
cards: [createCard()],
registry: new SyncRegistry([
{
cardKey: createCardKey("orphan-card"),
noteId: 42,
filePath: "notes/orphan.md",
sourceHash: createContentHash("old-hash"),
lastSyncedAt: 1,
orphan: false,
},
]),
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [createCard()],
toUpdate: [],
toMarkOrphan: [
{
cardKey: createCardKey("orphan-card"),
noteId: 42,
filePath: "notes/orphan.md",
sourceHash: createContentHash("old-hash"),
lastSyncedAt: 1,
orphan: false,
},
],
},
scopedFilePaths: ["notes/current.md", "notes/orphan.md"],
};
const execution = await useCase.execute(result);
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);
});
});

View file

@ -0,0 +1,130 @@
import type { AnkiGateway } from "@/application/ports/AnkiGateway";
import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository";
import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService";
import { SyncRegistry } from "@/domain/sync/entities/SyncRegistry";
import type { ExecuteSyncPlanResult, ScanAndPlanResult } from "./types";
export class ExecuteSyncPlanUseCase {
constructor(
private readonly ankiGateway: AnkiGateway,
private readonly syncRegistryRepository: SyncRegistryRepository,
private readonly noteFieldMappingService = new NoteFieldMappingService(),
private readonly now: () => number = () => Date.now(),
) {}
async execute(scanAndPlanResult: ScanAndPlanResult): Promise<ExecuteSyncPlanResult> {
const syncRegistry = new SyncRegistry(scanAndPlanResult.registry.list());
const modelDetailsCache = new Map<string, Awaited<ReturnType<AnkiGateway["getModelDetails"]>>>();
const timestamp = this.now();
for (const deckName of scanAndPlanResult.plan.toCreateDecks) {
await this.ankiGateway.ensureDeckExists(deckName);
}
const syncCards = [
...scanAndPlanResult.plan.toAdd,
...scanAndPlanResult.plan.toUpdate.map((entry) => entry.card),
];
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),
tags: card.tags,
});
syncRegistry.recordSync({
cardKey: card.key,
noteId,
filePath: card.source.filePath,
sourceHash: card.contentHash,
lastSyncedAt: timestamp,
orphan: false,
});
}
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),
});
syncRegistry.recordSync({
cardKey: entry.card.key,
noteId: entry.noteId,
filePath: entry.card.source.filePath,
sourceHash: entry.card.contentHash,
lastSyncedAt: timestamp,
orphan: false,
});
}
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 (!existingRecord) {
continue;
}
syncRegistry.refresh(card.key, card.source.filePath, card.contentHash, timestamp);
}
for (const orphanRecord of scanAndPlanResult.plan.toMarkOrphan) {
syncRegistry.markOrphan(orphanRecord.cardKey, timestamp);
}
await this.syncRegistryRepository.save(syncRegistry);
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 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;
}
}

View file

@ -0,0 +1,72 @@
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.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,
registry,
plan,
scopedFilePaths,
};
}
}

View file

@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
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,
};
}
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 getModelDetails(modelName: string) {
return modelName === "Cloze"
? { fieldNames: ["Text", "Extra"], isCloze: true }
: { fieldNames: ["Front", "Back"], isCloze: false };
}
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
this.addCalls.push(input);
return 500 + this.addCalls.length;
}
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
this.updateCalls.push(input);
}
async storeMedia(asset: { fileName: string }): Promise<void> {
this.storedMedia.push(asset.fileName);
}
}
function createSettings(overrides: Partial<PluginSettings> = {}): PluginSettings {
return {
...DEFAULT_SETTINGS,
addObsidianBacklink: false,
...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, 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();
});
});

View file

@ -0,0 +1,17 @@
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);
}
}

View file

@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
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,
};
}
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 getModelDetails(modelName: string) {
return modelName === "Cloze"
? { fieldNames: ["Text", "Extra"], isCloze: true }
: { fieldNames: ["Front", "Back"], isCloze: false };
}
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
this.addCalls.push(input);
return 200 + this.addCalls.length;
}
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
this.updateCalls.push(input);
}
async storeMedia(): Promise<void> {}
}
function createSettings(overrides: Partial<PluginSettings> = {}): PluginSettings {
return {
...DEFAULT_SETTINGS,
addObsidianBacklink: false,
...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, undefined, () => 2000);
const useCase = new SyncVaultUseCase(scanUseCase, executeUseCase);
const result = await useCase.execute(
createSettings({
includeFolders: ["cards"],
excludeFolders: ["cards/skip"],
}),
);
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");
});
});

View file

@ -0,0 +1,17 @@
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);
}
}

View file

@ -0,0 +1,19 @@
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[];
registry: SyncRegistry;
plan: SyncPlan;
scopedFilePaths: string[];
}
export interface ExecuteSyncPlanResult {
created: number;
markedOrphan: number;
scanned: number;
unchanged: number;
updated: number;
uploadedMedia: number;
}

View file

@ -0,0 +1,21 @@
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;
deck: DeckName;
noteModel: NoteModelName;
tags: string[];
renderedFields: RenderedFields;
fields: Record<string, string>;
contentHash: ContentHash;
media: MediaAsset[];
}

View file

@ -0,0 +1,12 @@
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;
deckHint?: DeckName;
}

View file

@ -0,0 +1,26 @@
export type CardType = "basic" | "cloze";
export interface BasicRenderedFields {
kind: "basic";
values: {
front: string;
back: string;
};
}
export interface ClozeRenderedFields {
kind: "cloze";
values: {
text: string;
extra: string;
};
}
export type RenderedFields = BasicRenderedFields | ClozeRenderedFields;
export interface MediaAsset {
kind: "image" | "audio";
fileName: string;
absolutePath: string;
altText?: string;
}

View file

@ -0,0 +1,5 @@
export interface SourceFile {
path: string;
basename: string;
content: string;
}

View file

@ -0,0 +1,41 @@
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,
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,
headingLevel: 4,
headingText: "Prompt",
};
const movedLocation: SourceLocation = {
...location,
blockStartLine: 7,
};
expect(policy.create(location, "basic")).not.toBe(policy.create(movedLocation, "basic"));
});
});

View file

@ -0,0 +1,20 @@
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("|"),
),
);
}
}

View file

@ -0,0 +1,19 @@
import type { SourceLocation } from "../value-objects/SourceLocation";
export interface ResolvedWikiLink {
url: string;
displayText: string;
}
export interface ResolvedEmbed {
kind: "image" | "audio";
fileName: string;
absolutePath: string;
altText?: string;
}
export interface RenderResourceResolver {
resolveWikiLink(rawTarget: string, sourcePath: string): ResolvedWikiLink | null;
resolveEmbed(rawTarget: string, sourcePath: string): ResolvedEmbed | null;
createBacklink(location: SourceLocation): string;
}

View file

@ -0,0 +1,75 @@
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("rejects equal heading levels", () => {
expect(() => validateHeadingPolicy({ qaHeadingLevel: 4, clozeHeadingLevel: 4 })).toThrow(
"QA and Cloze heading levels must not be equal.",
);
});
});

View file

@ -0,0 +1,172 @@
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;
}
const HEADING_REGEXP = /^(#{1,6})\s+(.*?)\s*$/;
const TARGET_DECK_REGEXP = /^\s*TARGET DECK\s*:\s*(.+?)\s*$/i;
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 bodyMarkdown = trimBlankEdges(bodyLines).join("\n");
drafts.push({
source: {
filePath: sourceFile.path,
headingLine: heading.lineIndex + 1,
blockStartLine: heading.lineIndex + 1,
bodyStartLine: heading.lineIndex + 2,
blockEndLine: blockEndLineIndex,
headingLevel: heading.level,
headingText: heading.text,
},
heading: heading.text,
headingLevel: heading.level,
type: cardType,
bodyMarkdown,
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);
}

View file

@ -0,0 +1,161 @@
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,
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.kind).toBe("basic");
expect(card.fields.front).toContain("Prompt");
expect(card.fields.back).toContain("Open in Obsidian");
expect(card.contentHash).toMatch(/^[0-9a-f]{8}$/);
});
it("renders a cloze card with heading in the auxiliary field", () => {
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,
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.kind).toBe("cloze");
expect(card.fields.text).toContain("{{c1::ubiquitous language}}");
expect(card.fields.extra).toContain("Context");
expect(card.fields.extra).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.back).toContain("\\(a+b\\)");
expect(card.fields.back).toContain("\\[x^2\\]");
expect(card.fields.back).toContain("<code>const value = 1</code>");
expect(card.fields.back).toContain("language-ts");
expect(card.fields.back).toContain("obsidian://open?vault=Vault&amp;file=Concept");
expect(card.fields.back).toContain("<img src=\"diagram.png\" alt=\"diagram.png\">");
expect(card.fields.back).toContain("[sound:clip.mp3]");
expect(card.media).toHaveLength(2);
});
});

View file

@ -0,0 +1,219 @@
import MarkdownIt from "markdown-it";
import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceResolver";
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(draft.type === "basic" ? context.qaNoteType : context.clozeNoteType);
const headingResult = this.renderMarkdown(draft.heading, draft, context, false, true);
const bodyResult = this.renderMarkdown(draft.bodyMarkdown, draft, context, draft.type === "cloze", 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 =
draft.type === "basic"
? {
kind: "basic",
values: {
front: headingResult.html,
back: bodyResult.html + backlinkHtml,
},
}
: {
kind: "cloze",
values: {
text: bodyResult.html,
extra: headingResult.html + backlinkHtml,
},
};
const fields = { ...renderedFields.values };
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,
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

View file

@ -0,0 +1,17 @@
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");
});
});

View file

@ -0,0 +1,13 @@
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);
}
}

View file

@ -0,0 +1,11 @@
export type CardKey = string & { readonly __brand: "CardKey" };
export function createCardKey(value: string): CardKey {
const normalized = value.trim();
if (!normalized) {
throw new Error("Card key cannot be empty.");
}
return normalized as CardKey;
}

View file

@ -0,0 +1,11 @@
export type ContentHash = string & { readonly __brand: "ContentHash" };
export function createContentHash(value: string): ContentHash {
const normalized = value.trim();
if (!normalized) {
throw new Error("Content hash cannot be empty.");
}
return normalized as ContentHash;
}

View file

@ -0,0 +1,11 @@
export type DeckName = string & { readonly __brand: "DeckName" };
export function createDeckName(value: string): DeckName {
const normalized = value.trim();
if (!normalized) {
throw new Error("Deck name cannot be empty.");
}
return normalized as DeckName;
}

View file

@ -0,0 +1,11 @@
export type NoteModelName = string & { readonly __brand: "NoteModelName" };
export function createNoteModelName(value: string): NoteModelName {
const normalized = value.trim();
if (!normalized) {
throw new Error("Note model name cannot be empty.");
}
return normalized as NoteModelName;
}

View file

@ -0,0 +1,9 @@
export interface SourceLocation {
filePath: string;
headingLine: number;
blockStartLine: number;
bodyStartLine: number;
blockEndLine: number;
headingLevel: number;
headingText: string;
}

10
src/domain/shared/hash.ts Normal file
View file

@ -0,0 +1,10 @@
export function hashString(input: string): string {
let hash = 2166136261;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}

View file

@ -0,0 +1,11 @@
import type { CardKey } from "../../card/value-objects/CardKey";
import type { ContentHash } from "../../card/value-objects/ContentHash";
export interface SyncRecord {
cardKey: CardKey;
noteId: number;
filePath: string;
sourceHash: ContentHash;
lastSyncedAt: number;
orphan: boolean;
}

View file

@ -0,0 +1,50 @@
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"),
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"),
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"),
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);
});
});

View file

@ -0,0 +1,82 @@
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) {
throw new Error(`Note ${record.noteId} is already assigned to another card key.`);
}
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;
}
}

View file

@ -0,0 +1,103 @@
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",
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
headingLevel: 4,
headingText: "Prompt",
},
type: "basic",
heading: "Prompt",
bodyMarkdown: "Answer",
deck: createDeckName("Deck"),
noteModel: createNoteModelName("Basic"),
tags: [],
renderedFields: {
kind: "basic",
values: {
front: "Prompt",
back: "Answer",
},
},
fields: {
front: "Prompt",
back: "Answer",
},
contentHash: createContentHash("hash-a"),
media: [],
...overrides,
};
}
function createRecord(overrides: Partial<SyncRecord> = {}): SyncRecord {
return {
cardKey: createCardKey("card-a"),
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",
headingLine: 1,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
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",
);
});
});

View file

@ -0,0 +1,42 @@
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 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);
const existingRecord = registry.get(card.key);
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) => scopedPaths.has(record.filePath) && !record.orphan && !seenKeys.has(record.cardKey));
return {
toCreateDecks: Array.from(createDecks.values()),
toAdd,
toUpdate,
toMarkOrphan,
};
}
}

View file

@ -0,0 +1,15 @@
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[];
}

View file

@ -0,0 +1,104 @@
import { requestUrl } from "obsidian";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import type { AddAnkiNoteInput, AnkiGateway, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
interface AnkiResponse<T> {
error: string | null;
result: T;
}
interface NoteInfo {
cards: number[];
}
type ModelTemplates = Record<string, unknown>;
export class AnkiConnectGateway implements AnkiGateway {
constructor(private readonly getBaseUrl: () => string) {}
async ensureDeckExists(deckName: string): Promise<void> {
await this.invoke("createDeck", { deck: deckName });
}
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
const fieldNames = await this.invoke<string[]>("modelFieldNames", { modelName });
let isCloze = modelName.toLowerCase().includes("cloze");
try {
const templates = await this.invoke<ModelTemplates>("modelTemplates", { modelName });
isCloze = isCloze || Object.keys(templates).some((templateName) => templateName.toLowerCase().includes("cloze"));
} catch {
isCloze = isCloze || fieldNames.some((fieldName) => fieldName.toLowerCase() === "text");
}
return {
fieldNames,
isCloze,
};
}
async addNote(input: AddAnkiNoteInput): Promise<number> {
return this.invoke<number>("addNote", {
note: {
deckName: input.deckName,
modelName: input.modelName,
fields: input.fields,
options: {
allowDuplicate: false,
duplicateScope: "deck",
},
tags: input.tags,
},
});
}
async updateNote(input: UpdateAnkiNoteInput): Promise<void> {
await this.invoke("updateNoteFields", {
note: {
id: input.noteId,
fields: input.fields,
},
});
const noteInfo = await this.invoke<NoteInfo[]>("notesInfo", {
notes: [input.noteId],
});
const cardIds = noteInfo[0]?.cards ?? [];
if (cardIds.length > 0) {
await this.invoke("changeDeck", {
cards: cardIds,
deck: input.deckName,
});
}
}
async storeMedia(asset: MediaAsset): Promise<void> {
await this.invoke("storeMediaFile", {
filename: asset.fileName,
path: asset.absolutePath,
});
}
private async invoke<TResult>(action: string, params: Record<string, unknown>): Promise<TResult> {
const response = await requestUrl({
url: this.getBaseUrl(),
method: "POST",
contentType: "application/json",
body: JSON.stringify({
action,
version: 6,
params,
}),
});
const parsed = response.json as AnkiResponse<TResult>;
if (parsed.error) {
throw new Error(parsed.error);
}
return parsed.result;
}
}

View file

@ -0,0 +1,16 @@
import type { Plugin } from "obsidian";
import type { PluginDataStore } from "@/application/ports/PluginDataStore";
export class ObsidianPluginDataStore<TData extends object> implements PluginDataStore<TData> {
constructor(private readonly plugin: Plugin) {}
async load(): Promise<TData | null> {
const data = await this.plugin.loadData();
return (data as TData | null) ?? null;
}
async save(data: TData): Promise<void> {
await this.plugin.saveData(data);
}
}

View file

@ -0,0 +1,115 @@
import { TFile } from "obsidian";
import type { App } from "obsidian";
import type { VaultGateway } from "@/application/ports/VaultGateway";
import { hashString } from "@/domain/shared/hash";
import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation";
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "tiff"]);
const AUDIO_EXTENSIONS = new Set(["wav", "m4a", "flac", "mp3", "wma", "aac", "webm", "ogg"]);
export class ObsidianVaultGateway implements VaultGateway {
constructor(private readonly app: App) {}
async listMarkdownFiles() {
const files = this.app.vault.getMarkdownFiles();
return Promise.all(files.map((file) => this.toSourceFile(file)));
}
async getMarkdownFile(path: string) {
const abstractFile = this.app.vault.getAbstractFileByPath(path);
if (!(abstractFile instanceof TFile) || abstractFile.extension.toLowerCase() !== "md") {
return null;
}
return this.toSourceFile(abstractFile);
}
resolveWikiLink(rawTarget: string, sourcePath: string) {
const { alias, linkPath } = parseLinkTarget(rawTarget);
const destination = this.app.metadataCache.getFirstLinkpathDest(stripSubpath(linkPath), sourcePath);
const resolvedPath = destination?.path ?? stripSubpath(linkPath);
const displayText = alias || deriveDisplayText(linkPath);
return {
url: this.createObsidianUrl(linkPath.includes("#") ? `${resolvedPath}${linkPath.slice(linkPath.indexOf("#"))}` : resolvedPath),
displayText,
};
}
resolveEmbed(rawTarget: string, sourcePath: string) {
const { alias, linkPath } = parseLinkTarget(rawTarget);
const destination = this.app.metadataCache.getFirstLinkpathDest(stripSubpath(linkPath), sourcePath);
if (!(destination instanceof TFile)) {
return null;
}
const extension = destination.extension.toLowerCase();
const kind: "image" | "audio" | null = IMAGE_EXTENSIONS.has(extension)
? "image"
: AUDIO_EXTENSIONS.has(extension)
? "audio"
: null;
if (!kind) {
return null;
}
const absolutePath = getFullPath(this.app, destination.path);
const hashedFileName = `${hashString(destination.path)}-${destination.name}`;
return {
kind,
fileName: hashedFileName,
absolutePath,
altText: alias || destination.name,
};
}
createBacklink(location: SourceLocation): string {
return this.createObsidianUrl(`${location.filePath}#${location.headingText}`);
}
private async toSourceFile(file: TFile) {
return {
path: file.path,
basename: file.basename,
content: await this.app.vault.cachedRead(file),
};
}
private createObsidianUrl(target: string): string {
return `obsidian://open?vault=${encodeURIComponent(this.app.vault.getName())}&file=${encodeURIComponent(target)}`;
}
}
function parseLinkTarget(rawTarget: string): { alias?: string; linkPath: string } {
const [linkPath, alias] = rawTarget.split("|", 2).map((segment) => segment.trim());
return {
linkPath,
alias: alias || undefined,
};
}
function stripSubpath(linkPath: string): string {
return linkPath.split("#", 1)[0];
}
function deriveDisplayText(linkPath: string): string {
const withoutSubpath = stripSubpath(linkPath);
const lastSegment = withoutSubpath.split("/").pop() ?? withoutSubpath;
return lastSegment.replace(/\.[^.]+$/, "") || linkPath;
}
function getFullPath(app: App, vaultPath: string): string {
const adapter = app.vault.adapter as { getFullPath?: (normalizedPath: string) => string };
if (!adapter.getFullPath) {
throw new Error("The active vault adapter does not expose an absolute filesystem path.");
}
return adapter.getFullPath(vaultPath);
}

View file

@ -0,0 +1,44 @@
import { DEFAULT_SETTINGS, type PluginSettings, validatePluginSettings } from "@/application/config/PluginSettings";
import type { PluginConfigRepository } from "@/application/ports/PluginConfigRepository";
import type { PluginDataStore } from "@/application/ports/PluginDataStore";
export interface PluginDataSnapshot {
settings?: Partial<PluginSettings>;
syncRegistry?: {
records: Array<{
cardKey: string;
filePath: string;
lastSyncedAt: number;
noteId: number;
orphan: boolean;
sourceHash: string;
}>;
};
}
export class DataJsonPluginConfigRepository implements PluginConfigRepository {
constructor(private readonly pluginDataStore: PluginDataStore<PluginDataSnapshot>) {}
async load(): Promise<PluginSettings> {
const snapshot = (await this.pluginDataStore.load()) ?? {};
const mergedSettings: PluginSettings = {
...DEFAULT_SETTINGS,
...snapshot.settings,
includeFolders: snapshot.settings?.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
excludeFolders: snapshot.settings?.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders,
};
validatePluginSettings(mergedSettings);
return mergedSettings;
}
async save(settings: PluginSettings): Promise<void> {
validatePluginSettings(settings);
const snapshot = (await this.pluginDataStore.load()) ?? {};
await this.pluginDataStore.save({
...snapshot,
settings,
});
}
}

View file

@ -0,0 +1,43 @@
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"),
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());
});
});

View file

@ -0,0 +1,44 @@
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,
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,
sourceHash: record.sourceHash,
lastSyncedAt: record.lastSyncedAt,
orphan: record.orphan,
})),
},
});
}
}

View file

@ -0,0 +1,104 @@
import { Plugin } from "obsidian";
import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings";
import { ScanAndPlanSyncUseCase } from "@/application/use-cases/ScanAndPlanSyncUseCase";
import { ExecuteSyncPlanUseCase } from "@/application/use-cases/ExecuteSyncPlanUseCase";
import { SyncCurrentFileUseCase } from "@/application/use-cases/SyncCurrentFileUseCase";
import { SyncVaultUseCase } from "@/application/use-cases/SyncVaultUseCase";
import { AnkiConnectGateway } from "@/infrastructure/anki/AnkiConnectGateway";
import { ObsidianPluginDataStore } from "@/infrastructure/obsidian/ObsidianPluginDataStore";
import { ObsidianVaultGateway } from "@/infrastructure/obsidian/ObsidianVaultGateway";
import { DataJsonPluginConfigRepository, type PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository";
import { DataJsonSyncRegistryRepository } from "@/infrastructure/persistence/DataJsonSyncRegistryRepository";
import { registerCommands } from "@/presentation/commands/registerCommands";
import { NoticeService } from "@/presentation/notices/NoticeService";
import { AnkiHeadingSyncSettingTab } from "@/presentation/settings/PluginSettingTab";
export default class AnkiHeadingSyncPlugin extends Plugin {
settings: PluginSettings = DEFAULT_SETTINGS;
private readonly noticeService = new NoticeService();
private syncCurrentFileUseCase?: SyncCurrentFileUseCase;
private syncVaultUseCase?: SyncVaultUseCase;
private pluginConfigRepository?: DataJsonPluginConfigRepository;
async onload(): Promise<void> {
const pluginDataStore = new ObsidianPluginDataStore<PluginDataSnapshot>(this);
this.pluginConfigRepository = new DataJsonPluginConfigRepository(pluginDataStore);
const syncRegistryRepository = new DataJsonSyncRegistryRepository(pluginDataStore);
const vaultGateway = new ObsidianVaultGateway(this.app);
const ankiGateway = new AnkiConnectGateway(() => this.settings.ankiConnectUrl);
try {
this.settings = await this.pluginConfigRepository.load();
} catch (error) {
console.error("Failed to load plugin settings, falling back to defaults.", error);
this.settings = DEFAULT_SETTINGS;
this.noticeService.error("Invalid plugin settings were detected. Default settings were restored in memory.");
}
const scanAndPlanSyncUseCase = new ScanAndPlanSyncUseCase(vaultGateway, syncRegistryRepository);
const executeSyncPlanUseCase = new ExecuteSyncPlanUseCase(ankiGateway, syncRegistryRepository);
this.syncCurrentFileUseCase = new SyncCurrentFileUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase);
this.syncVaultUseCase = new SyncVaultUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase);
registerCommands(this);
this.addSettingTab(new AnkiHeadingSyncSettingTab(this));
}
async updateSettings(partialSettings: Partial<PluginSettings>): Promise<void> {
if (!this.pluginConfigRepository) {
return;
}
const nextSettings: PluginSettings = {
...this.settings,
...partialSettings,
};
try {
await this.pluginConfigRepository.save(nextSettings);
this.settings = nextSettings;
} catch (error) {
this.noticeService.error(error instanceof Error ? error.message : "Failed to save plugin settings.");
}
}
async runSyncCurrentFile(): Promise<void> {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile || activeFile.extension.toLowerCase() !== "md") {
this.noticeService.error("No active Markdown file is available for sync.");
return;
}
if (!this.syncCurrentFileUseCase) {
this.noticeService.error("Sync use case is not initialized.");
return;
}
try {
const result = await this.syncCurrentFileUseCase.execute(activeFile.path, this.settings);
this.noticeService.showSyncSummary("Current file sync finished", result);
} catch (error) {
console.error("Current file sync failed.", error);
this.noticeService.error(error instanceof Error ? error.message : "Current file sync failed.");
}
}
async runSyncVault(): Promise<void> {
if (!this.syncVaultUseCase) {
this.noticeService.error("Sync use case is not initialized.");
return;
}
try {
const result = await this.syncVaultUseCase.execute(this.settings);
this.noticeService.showSyncSummary("Vault sync finished", result);
} catch (error) {
console.error("Vault sync failed.", error);
this.noticeService.error(error instanceof Error ? error.message : "Vault sync failed.");
}
}
}

View file

@ -0,0 +1,19 @@
import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin";
export function registerCommands(plugin: AnkiHeadingSyncPlugin): void {
plugin.addCommand({
id: "sync-current-file-to-anki",
name: "Sync current file to Anki",
callback: () => {
void plugin.runSyncCurrentFile();
},
});
plugin.addCommand({
id: "sync-vault-to-anki",
name: "Sync vault to Anki",
callback: () => {
void plugin.runSyncVault();
},
});
}

View file

@ -0,0 +1,19 @@
import { Notice } from "obsidian";
import type { ExecuteSyncPlanResult } from "@/application/use-cases/types";
export class NoticeService {
info(message: string): void {
new Notice(message, 5000);
}
error(message: string): void {
new Notice(message, 8000);
}
showSyncSummary(prefix: string, result: ExecuteSyncPlanResult): void {
this.info(
`${prefix}: scanned ${result.scanned}, created ${result.created}, updated ${result.updated}, orphaned ${result.markedOrphan}, media ${result.uploadedMedia}.`,
);
}
}

View file

@ -0,0 +1,125 @@
import { PluginSettingTab, Setting } from "obsidian";
import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin";
export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
constructor(plugin: AnkiHeadingSyncPlugin) {
super(plugin.app, plugin);
this.plugin = plugin;
}
declare plugin: AnkiHeadingSyncPlugin;
display(): void {
const { containerEl } = this;
const settings = this.plugin.settings;
containerEl.empty();
containerEl.createEl("h2", { text: "Anki Heading Sync" });
new Setting(containerEl)
.setName("AnkiConnect URL")
.setDesc("Default is http://127.0.0.1:8765")
.addText((text) => {
text.setPlaceholder("http://127.0.0.1:8765").setValue(settings.ankiConnectUrl).onChange((value) => {
void this.plugin.updateSettings({ ankiConnectUrl: value.trim() || settings.ankiConnectUrl });
});
});
new Setting(containerEl)
.setName("Default deck")
.setDesc("Used when a file does not define TARGET DECK")
.addText((text) => {
text.setValue(settings.defaultDeck).onChange((value) => {
void this.plugin.updateSettings({ defaultDeck: value });
});
});
new Setting(containerEl)
.setName("QA heading level")
.setDesc("Default is H4")
.addDropdown((dropdown) => {
for (let level = 1; level <= 6; level += 1) {
dropdown.addOption(String(level), `H${level}`);
}
dropdown.setValue(String(settings.qaHeadingLevel)).onChange((value) => {
void this.plugin.updateSettings({ qaHeadingLevel: Number(value) });
});
});
new Setting(containerEl)
.setName("Cloze heading level")
.setDesc("Default is H5")
.addDropdown((dropdown) => {
for (let level = 1; level <= 6; level += 1) {
dropdown.addOption(String(level), `H${level}`);
}
dropdown.setValue(String(settings.clozeHeadingLevel)).onChange((value) => {
void this.plugin.updateSettings({ clozeHeadingLevel: Number(value) });
});
});
new Setting(containerEl)
.setName("QA note type")
.setDesc("Default is Basic")
.addText((text) => {
text.setValue(settings.qaNoteType).onChange((value) => {
void this.plugin.updateSettings({ qaNoteType: value });
});
});
new Setting(containerEl)
.setName("Cloze note type")
.setDesc("Default is Cloze")
.addText((text) => {
text.setValue(settings.clozeNoteType).onChange((value) => {
void this.plugin.updateSettings({ clozeNoteType: value });
});
});
new Setting(containerEl)
.setName("Include folders")
.setDesc("Comma-separated folder paths. Empty means scan the whole vault.")
.addTextArea((textArea) => {
textArea.setValue(settings.includeFolders.join(", ")).onChange((value) => {
void this.plugin.updateSettings({ includeFolders: splitFolders(value) });
});
});
new Setting(containerEl)
.setName("Exclude folders")
.setDesc("Comma-separated folder paths always filtered out of vault sync.")
.addTextArea((textArea) => {
textArea.setValue(settings.excludeFolders.join(", ")).onChange((value) => {
void this.plugin.updateSettings({ excludeFolders: splitFolders(value) });
});
});
new Setting(containerEl)
.setName("Add Obsidian backlink")
.setDesc("Append a backlink to the source heading into synced cards.")
.addToggle((toggle) => {
toggle.setValue(settings.addObsidianBacklink).onChange((value) => {
void this.plugin.updateSettings({ addObsidianBacklink: value });
});
});
new Setting(containerEl)
.setName("Highlights to Cloze")
.setDesc("Convert ==highlight== segments into cloze deletions for cloze cards.")
.addToggle((toggle) => {
toggle.setValue(settings.convertHighlightsToCloze).onChange((value) => {
void this.plugin.updateSettings({ convertHighlightsToCloze: value });
});
});
}
}
function splitFolders(value: string): string[] {
return value
.split(",")
.map((segment) => segment.trim())
.filter(Boolean);
}

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"types": [
"node",
"vitest/globals"
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"main.ts",
"src/**/*.ts",
"vitest.config.ts"
]
}

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "1.5.0"
}

16
vitest.config.ts Normal file
View file

@ -0,0 +1,16 @@
import { resolve } from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
test: {
environment: "node",
globals: true,
include: ["src/**/*.test.ts"],
},
});