mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
chore: rebuild dist and sync
This commit is contained in:
commit
f854a3b581
72 changed files with 8071 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
dist/
|
||||
node_modules/
|
||||
1287
anki-plugin-ddd-idea.md
Normal file
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
111
docs/v1-decisions.md
Normal 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
110
docs/v1-gap-report.md
Normal 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.
|
||||
46
docs/v1-implementation-plan.md
Normal file
46
docs/v1-implementation-plan.md
Normal 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
43
esbuild.config.mjs
Normal 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
30
eslint.config.mjs
Normal 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
20
main.js
Normal file
File diff suppressed because one or more lines are too long
3
main.ts
Normal file
3
main.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import AnkiHeadingSyncPlugin from "./src/presentation/AnkiHeadingSyncPlugin";
|
||||
|
||||
export default AnkiHeadingSyncPlugin;
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal 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
3428
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
package.json
Normal file
35
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
31
scripts/stage-plugin-dist.mjs
Normal file
31
scripts/stage-plugin-dist.mjs
Normal 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",
|
||||
);
|
||||
19
src/application/config/PluginSettings.test.ts
Normal file
19
src/application/config/PluginSettings.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
55
src/application/config/PluginSettings.ts
Normal file
55
src/application/config/PluginSettings.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
4
src/application/dto/NoteModelDetails.ts
Normal file
4
src/application/dto/NoteModelDetails.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface NoteModelDetails {
|
||||
fieldNames: string[];
|
||||
isCloze: boolean;
|
||||
}
|
||||
23
src/application/ports/AnkiGateway.ts
Normal file
23
src/application/ports/AnkiGateway.ts
Normal 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>;
|
||||
}
|
||||
6
src/application/ports/PluginConfigRepository.ts
Normal file
6
src/application/ports/PluginConfigRepository.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { PluginSettings } from "../config/PluginSettings";
|
||||
|
||||
export interface PluginConfigRepository {
|
||||
load(): Promise<PluginSettings>;
|
||||
save(settings: PluginSettings): Promise<void>;
|
||||
}
|
||||
4
src/application/ports/PluginDataStore.ts
Normal file
4
src/application/ports/PluginDataStore.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface PluginDataStore<TData> {
|
||||
load(): Promise<TData | null>;
|
||||
save(data: TData): Promise<void>;
|
||||
}
|
||||
5
src/application/ports/RenderResourceResolver.ts
Normal file
5
src/application/ports/RenderResourceResolver.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type {
|
||||
RenderResourceResolver,
|
||||
ResolvedEmbed,
|
||||
ResolvedWikiLink,
|
||||
} from "@/domain/card/ports/RenderResourceResolver";
|
||||
6
src/application/ports/SyncRegistryRepository.ts
Normal file
6
src/application/ports/SyncRegistryRepository.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { SyncRegistry } from "@/domain/sync/entities/SyncRegistry";
|
||||
|
||||
export interface SyncRegistryRepository {
|
||||
load(): Promise<SyncRegistry>;
|
||||
save(registry: SyncRegistry): Promise<void>;
|
||||
}
|
||||
7
src/application/ports/VaultGateway.ts
Normal file
7
src/application/ports/VaultGateway.ts
Normal 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>;
|
||||
}
|
||||
111
src/application/services/NoteFieldMappingService.test.ts
Normal file
111
src/application/services/NoteFieldMappingService.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
49
src/application/services/NoteFieldMappingService.ts
Normal file
49
src/application/services/NoteFieldMappingService.ts
Normal 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()));
|
||||
}
|
||||
35
src/application/services/ScanScopeService.test.ts
Normal file
35
src/application/services/ScanScopeService.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
33
src/application/services/ScanScopeService.ts
Normal file
33
src/application/services/ScanScopeService.ts
Normal 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}/`);
|
||||
}
|
||||
151
src/application/use-cases/ExecuteSyncPlanUseCase.test.ts
Normal file
151
src/application/use-cases/ExecuteSyncPlanUseCase.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
130
src/application/use-cases/ExecuteSyncPlanUseCase.ts
Normal file
130
src/application/use-cases/ExecuteSyncPlanUseCase.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
72
src/application/use-cases/ScanAndPlanSyncUseCase.ts
Normal file
72
src/application/use-cases/ScanAndPlanSyncUseCase.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
165
src/application/use-cases/SyncCurrentFileUseCase.test.ts
Normal file
165
src/application/use-cases/SyncCurrentFileUseCase.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
17
src/application/use-cases/SyncCurrentFileUseCase.ts
Normal file
17
src/application/use-cases/SyncCurrentFileUseCase.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
142
src/application/use-cases/SyncVaultUseCase.test.ts
Normal file
142
src/application/use-cases/SyncVaultUseCase.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
17
src/application/use-cases/SyncVaultUseCase.ts
Normal file
17
src/application/use-cases/SyncVaultUseCase.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
src/application/use-cases/types.ts
Normal file
19
src/application/use-cases/types.ts
Normal 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;
|
||||
}
|
||||
21
src/domain/card/entities/Card.ts
Normal file
21
src/domain/card/entities/Card.ts
Normal 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[];
|
||||
}
|
||||
12
src/domain/card/entities/CardDraft.ts
Normal file
12
src/domain/card/entities/CardDraft.ts
Normal 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;
|
||||
}
|
||||
26
src/domain/card/entities/RenderedFields.ts
Normal file
26
src/domain/card/entities/RenderedFields.ts
Normal 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;
|
||||
}
|
||||
5
src/domain/card/entities/SourceFile.ts
Normal file
5
src/domain/card/entities/SourceFile.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface SourceFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
content: string;
|
||||
}
|
||||
41
src/domain/card/policies/CardIdentityPolicy.test.ts
Normal file
41
src/domain/card/policies/CardIdentityPolicy.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
20
src/domain/card/policies/CardIdentityPolicy.ts
Normal file
20
src/domain/card/policies/CardIdentityPolicy.ts
Normal 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("|"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
src/domain/card/ports/RenderResourceResolver.ts
Normal file
19
src/domain/card/ports/RenderResourceResolver.ts
Normal 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;
|
||||
}
|
||||
75
src/domain/card/services/CardExtractionService.test.ts
Normal file
75
src/domain/card/services/CardExtractionService.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
172
src/domain/card/services/CardExtractionService.ts
Normal file
172
src/domain/card/services/CardExtractionService.ts
Normal 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);
|
||||
}
|
||||
161
src/domain/card/services/CardRenderingService.test.ts
Normal file
161
src/domain/card/services/CardRenderingService.test.ts
Normal 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&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);
|
||||
});
|
||||
});
|
||||
219
src/domain/card/services/CardRenderingService.ts
Normal file
219
src/domain/card/services/CardRenderingService.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
17
src/domain/card/services/DeckResolutionService.test.ts
Normal file
17
src/domain/card/services/DeckResolutionService.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
13
src/domain/card/services/DeckResolutionService.ts
Normal file
13
src/domain/card/services/DeckResolutionService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/domain/card/value-objects/CardKey.ts
Normal file
11
src/domain/card/value-objects/CardKey.ts
Normal 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;
|
||||
}
|
||||
11
src/domain/card/value-objects/ContentHash.ts
Normal file
11
src/domain/card/value-objects/ContentHash.ts
Normal 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;
|
||||
}
|
||||
11
src/domain/card/value-objects/DeckName.ts
Normal file
11
src/domain/card/value-objects/DeckName.ts
Normal 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;
|
||||
}
|
||||
11
src/domain/card/value-objects/NoteModelName.ts
Normal file
11
src/domain/card/value-objects/NoteModelName.ts
Normal 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;
|
||||
}
|
||||
9
src/domain/card/value-objects/SourceLocation.ts
Normal file
9
src/domain/card/value-objects/SourceLocation.ts
Normal 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
10
src/domain/shared/hash.ts
Normal 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");
|
||||
}
|
||||
11
src/domain/sync/entities/SyncRecord.ts
Normal file
11
src/domain/sync/entities/SyncRecord.ts
Normal 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;
|
||||
}
|
||||
50
src/domain/sync/entities/SyncRegistry.test.ts
Normal file
50
src/domain/sync/entities/SyncRegistry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
82
src/domain/sync/entities/SyncRegistry.ts
Normal file
82
src/domain/sync/entities/SyncRegistry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
103
src/domain/sync/services/SyncPlanningService.test.ts
Normal file
103
src/domain/sync/services/SyncPlanningService.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
42
src/domain/sync/services/SyncPlanningService.ts
Normal file
42
src/domain/sync/services/SyncPlanningService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
src/domain/sync/value-objects/SyncPlan.ts
Normal file
15
src/domain/sync/value-objects/SyncPlan.ts
Normal 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[];
|
||||
}
|
||||
104
src/infrastructure/anki/AnkiConnectGateway.ts
Normal file
104
src/infrastructure/anki/AnkiConnectGateway.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
src/infrastructure/obsidian/ObsidianPluginDataStore.ts
Normal file
16
src/infrastructure/obsidian/ObsidianPluginDataStore.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
115
src/infrastructure/obsidian/ObsidianVaultGateway.ts
Normal file
115
src/infrastructure/obsidian/ObsidianVaultGateway.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
104
src/presentation/AnkiHeadingSyncPlugin.ts
Normal file
104
src/presentation/AnkiHeadingSyncPlugin.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/presentation/commands/registerCommands.ts
Normal file
19
src/presentation/commands/registerCommands.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
19
src/presentation/notices/NoticeService.ts
Normal file
19
src/presentation/notices/NoticeService.ts
Normal 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}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
125
src/presentation/settings/PluginSettingTab.ts
Normal file
125
src/presentation/settings/PluginSettingTab.ts
Normal 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
26
tsconfig.json
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "1.5.0"
|
||||
}
|
||||
16
vitest.config.ts
Normal file
16
vitest.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue