chore: commit Module 1 changes — add NoteFieldMapping, mapping service, persistence, tests

This commit is contained in:
Dusk 2026-04-16 23:02:07 +08:00
parent f854a3b581
commit 5099b39d3f
26 changed files with 1353 additions and 152 deletions

83
docs/2026-04-16PLAN2.md Normal file
View file

@ -0,0 +1,83 @@
# 模块 1从 Anki 读取 Note Type 字段并建立标题/正文映射
## Summary
- 目标是把当前“靠字段名猜测”的同步方式,升级成“先从 Anki 读取 note type 字段,再由用户确认映射,之后同步严格按映射执行”。
- 本模块只解决“字段识别与映射配置”,不处理真正的卡片模板 HTML/CSS 编辑;这里把“读取卡片样式”明确落成“读取 note type 与字段结构”。
- 范围覆盖两类卡:`QA/Basic` 和 `Cloze`
- UI 方向固定为:从 Anki 拉取 note type 列表做下拉选择,再点击按钮读取当前 note type 的字段,系统先自动建议,再让用户确认。
- 映射按“具体 note type”持久化保存为避免同一个模型名在 Basic/Cloze 语义冲突,持久化 key 使用 `cardType:modelName`
## Implementation Changes
- 在 `AnkiGateway` / `AnkiConnectGateway` 增加 `listNoteModels(): Promise<string[]>`,用于设置页拉取全部 note type 名称;保留现有 `getModelDetails(modelName)` 负责读取字段名。
- 在 `PluginSettings` 增加持久化配置 `noteFieldMappings`,结构固定为:
- `key`: `${cardType}:${modelName}`
- `value`: `{ cardType, modelName, loadedFieldNames, titleField?, bodyField?, mainField?, loadedAt }`
- 保留现有 `qaNoteType` / `clozeNoteType` 两个设置字段,但设置页改成 Anki 下拉选择,不再靠自由文本输入作为主交互。
- 设置页拆成两个区块:`QA/Basic` 和 `Cloze`。每个区块都包含:
- 当前 note type 下拉框
- `从 Anki 读取字段` 按钮
- 字段读取结果展示
- 自动建议后的映射下拉框
- 保存后的状态提示
- 自动建议规则固定:
- Basic 标题字段优先匹配 `Front` / `Title`,否则取第一个字段。
- Basic 正文字段优先匹配 `Back` / `Body` / `Answer`,否则取第二个不同字段。
- Cloze 主字段优先匹配 `Text` / `Body` / `Content`,否则取第一个字段。
- `NoteFieldMappingService` 改为“配置驱动”而不是“仅启发式驱动”:
- Basic 必须存在 `titleField``bodyField`,且二者不能相同。
- Cloze 必须存在 `mainField`
- 没有配置时禁止同步,不允许继续偷偷猜字段。
- `CardRenderingService` 的语义输出改成面向“标题片段 + 正文片段”,不要再把 `front/back`、`text/extra` 当成固定领域语义。
- Cloze 的最终拼接策略固定为:`mainField = titleHtml + "<br><br>" + bodyHtml`;辅助字段本模块不写入,保持空缺。
- 同步执行前加入映射校验:
- 选中的 note type 没有已确认映射时,直接报错并提示先去设置页读取字段。
- 已保存映射里的字段名不再存在于 Anki 当前模型时,直接报错并提示重新读取字段。
- 老数据迁移策略固定:
- 旧用户升级后,保留 `qaNoteType` / `clozeNoteType` 原值。
- `noteFieldMappings` 初始为空。
- 第一次同步若未配置映射则阻止执行,并给出明确 notice。
## Public Interfaces / Types
- `AnkiGateway.listNoteModels(): Promise<string[]>`
- `NoteModelFieldMapping`:
- `cardType: "basic" | "cloze"`
- `modelName: string`
- `loadedFieldNames: string[]`
- `titleField?: string`
- `bodyField?: string`
- `mainField?: string`
- `loadedAt: number`
- `PluginSettings.noteFieldMappings: Record<string, NoteModelFieldMapping>`
## Test Plan
- `AnkiConnectGateway`
- 能拉取 note type 列表
- 能读取模型字段
- 设置持久化:
- 旧 settings 无 `noteFieldMappings` 时能正常加载
- 保存后重新加载仍能还原映射
- `NoteFieldMappingService`
- Basic 按已保存映射输出标题/正文
- Cloze 按 `title + <br><br> + body` 输出到主字段
- 缺映射时报错
- 映射字段不存在时报错
- Basic 两个字段选成同一字段时报错
- 设置页交互:
- 刷新 note type 列表
- 读取字段后生成自动建议
- 用户改选后正确保存
- 同步用例:
- 已配置映射时 add/update 正确写入真实 Anki 字段
- 未配置映射时同步被阻止并提示
- Anki 模型字段变更后旧映射失效并提示重新读取
## Assumptions
- 本模块只做字段结构读取与映射,不读取 Anki 卡片模板的 HTML/CSS也不做模板编辑器。
- note type 列表只做运行时拉取,不持久化缓存到 `data.json`
- 这一版不保留旧的“无映射时自动兜底同步”路径;安全优先。
- Cloze 在本模块内采用“标题和正文都进入主字段,辅助字段留空”的规则;如果以后要恢复 `Extra/Context`,作为下一模块单独扩展。

View file

@ -0,0 +1,31 @@
# Module 1 Gap Report
## What Is Already Correct
- `AnkiConnectGateway.getModelDetails(modelName)` already reads field names from Anki.
- `qaNoteType` and `clozeNoteType` are already persisted in plugin settings.
- `CardRenderingService` already renders heading and body separately before assigning them to Anki-facing fields.
## Gaps Against Module 1 Spec
- `AnkiGateway` does not expose `listNoteModels()`, so settings cannot populate note type dropdowns from Anki.
- `PluginSettings` has no `noteFieldMappings`, so there is no persisted, note-type-specific field mapping.
- Settings UI still uses free-text note type inputs and has no field-loading, mapping dropdowns, or saved-state feedback.
- `NoteFieldMappingService` still relies on heuristic fallback guesses (`Front`/`Back`, `Text`/`Extra`) during sync.
- Sync execution does not block when mappings are missing or stale.
- Cloze output is still treated as `text` + `extra`, not `title + <br><br> + body` into one mapped main field.
- There is no migration coverage proving old settings load with empty `noteFieldMappings`.
- There are no tests for note type list loading, mapping persistence, stale mapping validation, or settings UI mapping flow.
## Focused Repair Plan
1. Add persisted `NoteModelFieldMapping` and migrate settings load/save to default `noteFieldMappings` to `{}`.
2. Replace heuristic sync mapping with configuration-driven mapping + validation.
3. Add `listNoteModels()` to the Anki gateway and wire settings UI to Anki-backed dropdowns.
4. Persist confirmed mappings per `${cardType}:${modelName}` and block sync when missing or stale.
5. Update rendering semantics so sync assignment is driven by title/body fragments.
6. Add targeted tests for gateway, settings persistence, mapping validation, UI flow, and sync gating.
## Blockers
- No concrete repository blocker found at audit time.

View file

@ -0,0 +1,15 @@
import type { CardType } from "@/domain/card/entities/RenderedFields";
export interface NoteModelFieldMapping {
cardType: CardType;
modelName: string;
loadedFieldNames: string[];
titleField?: string;
bodyField?: string;
mainField?: string;
loadedAt: number;
}
export function createNoteFieldMappingKey(cardType: CardType, modelName: string): string {
return `${cardType}:${modelName}`;
}

View file

@ -1,8 +1,11 @@
import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
export interface PluginSettings {
qaHeadingLevel: number;
clozeHeadingLevel: number;
qaNoteType: string;
clozeNoteType: string;
noteFieldMappings: Record<string, NoteModelFieldMapping>;
defaultDeck: string;
includeFolders: string[];
excludeFolders: string[];
@ -16,6 +19,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
clozeHeadingLevel: 5,
qaNoteType: "Basic",
clozeNoteType: "Cloze",
noteFieldMappings: {},
defaultDeck: "Obsidian",
includeFolders: [],
excludeFolders: [],
@ -45,6 +49,8 @@ export function validatePluginSettings(settings: PluginSettings): void {
throw new Error("Cloze note type is required.");
}
validateNoteFieldMappings(settings.noteFieldMappings);
if (!settings.defaultDeck.trim()) {
throw new Error("Default deck is required.");
}
@ -52,4 +58,28 @@ export function validatePluginSettings(settings: PluginSettings): void {
if (!settings.ankiConnectUrl.trim()) {
throw new Error("AnkiConnect URL is required.");
}
}
function validateNoteFieldMappings(noteFieldMappings: Record<string, NoteModelFieldMapping>): void {
if (!noteFieldMappings || typeof noteFieldMappings !== "object" || Array.isArray(noteFieldMappings)) {
throw new Error("Note field mappings must be an object.");
}
for (const mapping of Object.values(noteFieldMappings)) {
if (mapping.cardType !== "basic" && mapping.cardType !== "cloze") {
throw new Error("Note field mappings must use a supported card type.");
}
if (!mapping.modelName.trim()) {
throw new Error("Note field mappings must include a model name.");
}
if (!Array.isArray(mapping.loadedFieldNames)) {
throw new Error("Note field mappings must include loaded field names.");
}
if (!Number.isFinite(mapping.loadedAt)) {
throw new Error("Note field mappings must include a loaded timestamp.");
}
}
}

View file

@ -16,6 +16,7 @@ export interface UpdateAnkiNoteInput {
export interface AnkiGateway {
ensureDeckExists(deckName: string): Promise<void>;
listNoteModels(): Promise<string[]>;
getModelDetails(modelName: string): Promise<NoteModelDetails>;
addNote(input: AddAnkiNoteInput): Promise<number>;
updateNote(input: UpdateAnkiNoteInput): Promise<void>;

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
import type { Card } from "@/domain/card/entities/Card";
import { createCardKey } from "@/domain/card/value-objects/CardKey";
import { createContentHash } from "@/domain/card/value-objects/ContentHash";
@ -27,15 +28,12 @@ function createCard(overrides: Partial<Card>): Card {
noteModel: createNoteModelName("Basic"),
tags: [],
renderedFields: {
kind: "basic",
values: {
front: "Prompt",
back: "Answer",
},
title: "Prompt",
body: "Answer",
},
fields: {
front: "Prompt",
back: "Answer",
title: "Prompt",
body: "Answer",
},
contentHash: createContentHash("hash"),
media: [],
@ -44,68 +42,110 @@ function createCard(overrides: Partial<Card>): Card {
}
describe("NoteFieldMappingService", () => {
it("maps basic semantic fields onto a basic model", () => {
it("maps a basic card using a saved title/body mapping", () => {
const service = new NoteFieldMappingService();
const fields = service.map(createCard({}), {
fieldNames: ["Front", "Back"],
isCloze: false,
});
const card = createCard({});
const fields = service.map(
card,
{
fieldNames: ["Title", "Body"],
isCloze: false,
},
{
[createNoteFieldMappingKey("basic", "Basic")]: {
cardType: "basic",
modelName: "Basic",
loadedFieldNames: ["Title", "Body"],
titleField: "Title",
bodyField: "Body",
loadedAt: 1,
},
},
);
expect(fields).toEqual({ Front: "Prompt", Back: "Answer" });
expect(fields).toEqual({ Title: "Prompt", Body: "Answer" });
});
it("maps cloze semantic fields onto text and extra fields", () => {
it("maps a cloze card into the configured main field using title and body fragments", () => {
const service = new NoteFieldMappingService();
const fields = service.map(
createCard({
type: "cloze",
noteModel: createNoteModelName("Cloze"),
renderedFields: {
kind: "cloze",
values: {
text: "{{c1::answer}}",
extra: "Context",
},
title: "Context",
body: "{{c1::answer}}",
},
fields: {
text: "{{c1::answer}}",
extra: "Context",
title: "Context",
body: "{{c1::answer}}",
},
}),
{
fieldNames: ["Text", "Extra"],
isCloze: true,
},
{
[createNoteFieldMappingKey("cloze", "Cloze")]: {
cardType: "cloze",
modelName: "Cloze",
loadedFieldNames: ["Text", "Extra"],
mainField: "Text",
loadedAt: 1,
},
},
);
expect(fields).toEqual({ Text: "{{c1::answer}}", Extra: "Context" });
expect(fields).toEqual({ Text: "Context<br><br>{{c1::answer}}" });
});
it("rejects a non-cloze model for a cloze card", () => {
it("throws when a mapping is missing", () => {
const service = new NoteFieldMappingService();
expect(() =>
service.map(createCard({}), { fieldNames: ["Front", "Back"], isCloze: false }, {}),
).toThrow("Open plugin settings and read fields from Anki first");
});
it("throws when a saved mapping is stale", () => {
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",
},
}),
createCard({}),
{ fieldNames: ["Front", "Body"], isCloze: false },
{
fieldNames: ["Front", "Back"],
isCloze: false,
[createNoteFieldMappingKey("basic", "Basic")]: {
cardType: "basic",
modelName: "Basic",
loadedFieldNames: ["Front", "Back"],
titleField: "Front",
bodyField: "Back",
loadedAt: 1,
},
},
),
).toThrow("must target a cloze-compatible note model");
).toThrow("is stale because these fields no longer exist in Anki");
});
it("throws when a basic mapping reuses the same field for title and body", () => {
const service = new NoteFieldMappingService();
expect(() =>
service.map(
createCard({}),
{ fieldNames: ["Front", "Back"], isCloze: false },
{
[createNoteFieldMappingKey("basic", "Basic")]: {
cardType: "basic",
modelName: "Basic",
loadedFieldNames: ["Front", "Back"],
titleField: "Front",
bodyField: "Front",
loadedAt: 1,
},
},
),
).toThrow('must use different title and body fields');
});
});

View file

@ -1,47 +1,128 @@
import type { Card } from "@/domain/card/entities/Card";
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
export class NoteFieldMappingService {
map(card: Card, noteModelDetails: NoteModelDetails): Record<string, string> {
map(
card: Card,
noteModelDetails: NoteModelDetails,
noteFieldMappings: Record<string, NoteModelFieldMapping>,
): Record<string, string> {
const mapping = this.getRequiredMapping(card, noteFieldMappings);
this.validateMapping(mapping, noteModelDetails);
if (card.type === "basic") {
return this.mapBasic(card, noteModelDetails.fieldNames);
return this.mapBasic(card, mapping);
}
return this.mapCloze(card, noteModelDetails);
return this.mapCloze(card, noteModelDetails, mapping);
}
private mapBasic(card: Card, fieldNames: string[]): Record<string, string> {
const frontFieldName = findFieldName(fieldNames, ["Front"]) ?? fieldNames[0];
const backFieldName = findFieldName(fieldNames, ["Back"]) ?? fieldNames[1];
suggest(cardType: Card["type"], modelName: string, fieldNames: string[], loadedAt = Date.now()): NoteModelFieldMapping {
return cardType === "basic"
? {
cardType,
modelName,
loadedFieldNames: [...fieldNames],
titleField: findFieldName(fieldNames, ["Front", "Title"]) ?? fieldNames[0],
bodyField:
findFieldName(fieldNames, ["Back", "Body", "Answer"]) ??
fieldNames.find((fieldName) => fieldName !== (findFieldName(fieldNames, ["Front", "Title"]) ?? fieldNames[0])),
loadedAt,
}
: {
cardType,
modelName,
loadedFieldNames: [...fieldNames],
mainField: findFieldName(fieldNames, ["Text", "Body", "Content"]) ?? fieldNames[0],
loadedAt,
};
}
if (!frontFieldName || !backFieldName) {
throw new Error(`Basic note model ${card.noteModel} must expose at least two fields.`);
validateMapping(mapping: NoteModelFieldMapping, noteModelDetails: NoteModelDetails): void {
const availableFields = new Set(noteModelDetails.fieldNames);
if (mapping.cardType === "basic") {
if (!mapping.titleField || !mapping.bodyField) {
throw new Error(
`Saved field mapping for basic note type "${mapping.modelName}" is incomplete. Open plugin settings and save both title and body fields.`,
);
}
if (mapping.titleField === mapping.bodyField) {
throw new Error(`Basic note type "${mapping.modelName}" must use different title and body fields.`);
}
const missingFields = [mapping.titleField, mapping.bodyField].filter((fieldName) => !availableFields.has(fieldName));
if (missingFields.length > 0) {
throw new Error(this.createStaleMappingError(mapping.modelName, missingFields));
}
return;
}
return {
[frontFieldName]: card.fields.front,
[backFieldName]: card.fields.back,
};
}
if (!mapping.mainField) {
throw new Error(
`Saved field mapping for cloze note type "${mapping.modelName}" is incomplete. Open plugin settings and save the main field.`,
);
}
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.`);
throw new Error(`Cloze note type "${mapping.modelName}" is not cloze-compatible in Anki.`);
}
const textFieldName = findFieldName(noteModelDetails.fieldNames, ["Text"]) ?? noteModelDetails.fieldNames[0];
const extraFieldName =
findFieldName(noteModelDetails.fieldNames, ["Extra", "Context"]) ?? noteModelDetails.fieldNames[1];
if (!availableFields.has(mapping.mainField)) {
throw new Error(this.createStaleMappingError(mapping.modelName, [mapping.mainField]));
}
}
if (!textFieldName || !extraFieldName) {
throw new Error(`Cloze note model ${card.noteModel} must expose text and auxiliary fields.`);
private mapBasic(card: Card, mapping: NoteModelFieldMapping): Record<string, string> {
const titleFieldName = mapping.titleField;
const bodyFieldName = mapping.bodyField;
if (!titleFieldName || !bodyFieldName) {
throw new Error(`Saved field mapping for basic note type "${card.noteModel}" is incomplete.`);
}
return {
[textFieldName]: card.fields.text,
[extraFieldName]: card.fields.extra,
[titleFieldName]: card.renderedFields.title,
[bodyFieldName]: card.renderedFields.body,
};
}
private mapCloze(card: Card, noteModelDetails: NoteModelDetails, mapping: NoteModelFieldMapping): Record<string, string> {
if (!noteModelDetails.isCloze) {
throw new Error(`Cloze note type "${card.noteModel}" is not cloze-compatible in Anki.`);
}
if (!mapping.mainField) {
throw new Error(`Saved field mapping for cloze note type "${card.noteModel}" is incomplete.`);
}
return {
[mapping.mainField]: `${card.renderedFields.title}<br><br>${card.renderedFields.body}`,
};
}
private getRequiredMapping(
card: Card,
noteFieldMappings: Record<string, NoteModelFieldMapping>,
): NoteModelFieldMapping {
const mapping = noteFieldMappings[createNoteFieldMappingKey(card.type, card.noteModel)];
if (!mapping) {
throw new Error(
`No saved field mapping found for ${card.type === "basic" ? "basic" : "cloze"} note type "${card.noteModel}". Open plugin settings and read fields from Anki first.`,
);
}
return mapping;
}
private createStaleMappingError(modelName: string, missingFields: string[]): string {
const quotedMissingFields = missingFields.map((fieldName) => `"${fieldName}"`).join(", ");
return `Saved field mapping for note type "${modelName}" is stale because these fields no longer exist in Anki: ${quotedMissingFields}. Open plugin settings and read fields from Anki again.`;
}
}
function findFieldName(fieldNames: string[], preferredNames: string[]): string | undefined {

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
import type { AnkiGateway } from "@/application/ports/AnkiGateway";
import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository";
import type { Card } from "@/domain/card/entities/Card";
@ -31,15 +32,21 @@ class FakeAnkiGateway implements AnkiGateway {
public addedNotes: Array<{ deckName: string; modelName: string; fields: Record<string, string> }> = [];
public updatedNotes: Array<{ noteId: number; deckName: string; fields: Record<string, string> }> = [];
public storedMedia: string[] = [];
public modelDetailsByName: Record<string, { fieldNames: string[]; isCloze: boolean }> = {
Basic: { fieldNames: ["Front", "Back"], isCloze: false },
Cloze: { fieldNames: ["Text", "Extra"], isCloze: true },
};
async ensureDeckExists(deckName: string): Promise<void> {
this.ensuredDecks.push(deckName);
}
async listNoteModels(): Promise<string[]> {
return Object.keys(this.modelDetailsByName);
}
async getModelDetails(modelName: string) {
return modelName === "Cloze"
? { fieldNames: ["Text", "Extra"], isCloze: true }
: { fieldNames: ["Front", "Back"], isCloze: false };
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false };
}
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
@ -75,15 +82,12 @@ function createCard(overrides: Partial<Card> = {}): Card {
noteModel: createNoteModelName("Basic"),
tags: [],
renderedFields: {
kind: "basic",
values: {
front: "Prompt",
back: "Answer",
},
title: "Prompt",
body: "Answer",
},
fields: {
front: "Prompt",
back: "Answer",
title: "Prompt",
body: "Answer",
},
contentHash: createContentHash("hash-1"),
media: [],
@ -91,6 +95,26 @@ function createCard(overrides: Partial<Card> = {}): Card {
};
}
function createMappings() {
return {
[createNoteFieldMappingKey("basic", "Basic")]: {
cardType: "basic" as const,
modelName: "Basic",
loadedFieldNames: ["Front", "Back"],
titleField: "Front",
bodyField: "Back",
loadedAt: 1,
},
[createNoteFieldMappingKey("cloze", "Cloze")]: {
cardType: "cloze" as const,
modelName: "Cloze",
loadedFieldNames: ["Text", "Extra"],
mainField: "Text",
loadedAt: 1,
},
};
}
describe("ExecuteSyncPlanUseCase", () => {
it("marks orphan records locally without any delete path", async () => {
const ankiGateway = new FakeAnkiGateway();
@ -110,6 +134,7 @@ describe("ExecuteSyncPlanUseCase", () => {
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1234);
const result: ScanAndPlanResult = {
cards: [createCard()],
noteFieldMappings: createMappings(),
registry: new SyncRegistry([
{
cardKey: createCardKey("orphan-card"),
@ -148,4 +173,53 @@ describe("ExecuteSyncPlanUseCase", () => {
expect(repository.savedRegistry?.get(createCardKey("orphan-card"))?.orphan).toBe(true);
expect(repository.savedRegistry?.get(createCardKey("orphan-card"))?.noteId).toBe(42);
});
it("blocks sync when a selected note type has no saved mapping", async () => {
const ankiGateway = new FakeAnkiGateway();
const repository = new InMemorySyncRegistryRepository();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1234);
await expect(
useCase.execute({
cards: [createCard()],
noteFieldMappings: {},
registry: new SyncRegistry(),
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [createCard()],
toUpdate: [],
toMarkOrphan: [],
},
scopedFilePaths: ["notes/current.md"],
}),
).rejects.toThrow("Open plugin settings and read fields from Anki first");
expect(ankiGateway.ensuredDecks).toHaveLength(0);
expect(ankiGateway.addedNotes).toHaveLength(0);
});
it("blocks sync when a saved mapping becomes stale", async () => {
const ankiGateway = new FakeAnkiGateway();
ankiGateway.modelDetailsByName.Basic = { fieldNames: ["Front", "Body"], isCloze: false };
const repository = new InMemorySyncRegistryRepository();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1234);
await expect(
useCase.execute({
cards: [createCard()],
noteFieldMappings: createMappings(),
registry: new SyncRegistry(),
plan: {
toCreateDecks: [createDeckName("Deck")],
toAdd: [createCard()],
toUpdate: [],
toMarkOrphan: [],
},
scopedFilePaths: ["notes/current.md"],
}),
).rejects.toThrow("is stale because these fields no longer exist in Anki");
expect(ankiGateway.ensuredDecks).toHaveLength(0);
expect(ankiGateway.addedNotes).toHaveLength(0);
});
});

View file

@ -18,14 +18,20 @@ export class ExecuteSyncPlanUseCase {
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);
for (const card of scanAndPlanResult.cards) {
const modelDetails = await this.getModelDetails(modelDetailsCache, card.noteModel);
this.noteFieldMappingService.map(card, modelDetails, scanAndPlanResult.noteFieldMappings);
}
const syncCards = [
...scanAndPlanResult.plan.toAdd,
...scanAndPlanResult.plan.toUpdate.map((entry) => entry.card),
];
for (const deckName of scanAndPlanResult.plan.toCreateDecks) {
await this.ankiGateway.ensureDeckExists(deckName);
}
const uploadedMedia = await this.uploadMedia(syncCards);
for (const card of scanAndPlanResult.plan.toAdd) {
@ -33,7 +39,7 @@ export class ExecuteSyncPlanUseCase {
const noteId = await this.ankiGateway.addNote({
deckName: card.deck,
modelName: card.noteModel,
fields: this.noteFieldMappingService.map(card, modelDetails),
fields: this.noteFieldMappingService.map(card, modelDetails, scanAndPlanResult.noteFieldMappings),
tags: card.tags,
});
@ -52,7 +58,7 @@ export class ExecuteSyncPlanUseCase {
await this.ankiGateway.updateNote({
noteId: entry.noteId,
deckName: entry.card.deck,
fields: this.noteFieldMappingService.map(entry.card, modelDetails),
fields: this.noteFieldMappingService.map(entry.card, modelDetails, scanAndPlanResult.noteFieldMappings),
});
syncRegistry.recordSync({

View file

@ -64,6 +64,7 @@ export class ScanAndPlanSyncUseCase {
return {
cards,
noteFieldMappings: settings.noteFieldMappings,
registry,
plan,
scopedFilePaths,

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings";
import type { AnkiGateway } from "@/application/ports/AnkiGateway";
import type { PluginDataStore } from "@/application/ports/PluginDataStore";
@ -82,6 +83,10 @@ class FakeAnkiGateway implements AnkiGateway {
this.ensureDeckCalls.push(deckName);
}
async listNoteModels(): Promise<string[]> {
return ["Basic", "Cloze"];
}
async getModelDetails(modelName: string) {
return modelName === "Cloze"
? { fieldNames: ["Text", "Extra"], isCloze: true }
@ -106,6 +111,23 @@ function createSettings(overrides: Partial<PluginSettings> = {}): PluginSettings
return {
...DEFAULT_SETTINGS,
addObsidianBacklink: false,
noteFieldMappings: {
[createNoteFieldMappingKey("basic", "Basic")]: {
cardType: "basic",
modelName: "Basic",
loadedFieldNames: ["Front", "Back"],
titleField: "Front",
bodyField: "Back",
loadedAt: 1,
},
[createNoteFieldMappingKey("cloze", "Cloze")]: {
cardType: "cloze",
modelName: "Cloze",
loadedFieldNames: ["Text", "Extra"],
mainField: "Text",
loadedAt: 1,
},
},
...overrides,
};
}

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings";
import type { AnkiGateway } from "@/application/ports/AnkiGateway";
import type { PluginDataStore } from "@/application/ports/PluginDataStore";
@ -74,6 +75,10 @@ class FakeAnkiGateway implements AnkiGateway {
this.ensureDeckCalls.push(deckName);
}
async listNoteModels(): Promise<string[]> {
return ["Basic", "Cloze"];
}
async getModelDetails(modelName: string) {
return modelName === "Cloze"
? { fieldNames: ["Text", "Extra"], isCloze: true }
@ -96,6 +101,23 @@ function createSettings(overrides: Partial<PluginSettings> = {}): PluginSettings
return {
...DEFAULT_SETTINGS,
addObsidianBacklink: false,
noteFieldMappings: {
[createNoteFieldMappingKey("basic", "Basic")]: {
cardType: "basic",
modelName: "Basic",
loadedFieldNames: ["Front", "Back"],
titleField: "Front",
bodyField: "Back",
loadedAt: 1,
},
[createNoteFieldMappingKey("cloze", "Cloze")]: {
cardType: "cloze",
modelName: "Cloze",
loadedFieldNames: ["Text", "Extra"],
mainField: "Text",
loadedAt: 1,
},
},
...overrides,
};
}

View file

@ -1,9 +1,11 @@
import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import type { Card } from "@/domain/card/entities/Card";
import type { SyncRegistry } from "@/domain/sync/entities/SyncRegistry";
import type { SyncPlan } from "@/domain/sync/value-objects/SyncPlan";
export interface ScanAndPlanResult {
cards: Card[];
noteFieldMappings: Record<string, NoteModelFieldMapping>;
registry: SyncRegistry;
plan: SyncPlan;
scopedFilePaths: string[];

View file

@ -1,23 +1,10 @@
export type CardType = "basic" | "cloze";
export interface BasicRenderedFields {
kind: "basic";
values: {
front: string;
back: string;
};
export interface RenderedFields {
title: string;
body: string;
}
export interface ClozeRenderedFields {
kind: "cloze";
values: {
text: string;
extra: string;
};
}
export type RenderedFields = BasicRenderedFields | ClozeRenderedFields;
export interface MediaAsset {
kind: "image" | "audio";
fileName: string;

View file

@ -74,13 +74,14 @@ describe("CardRenderingService", () => {
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.renderedFields.title).toContain("Prompt");
expect(card.renderedFields.body).toContain("Open in Obsidian");
expect(card.fields.title).toContain("Prompt");
expect(card.fields.body).toContain("Open in Obsidian");
expect(card.contentHash).toMatch(/^[0-9a-f]{8}$/);
});
it("renders a cloze card with heading in the auxiliary field", () => {
it("renders a cloze card as title/body fragments before mapping", () => {
const service = new CardRenderingService();
const card = service.render(
createDraft({
@ -109,10 +110,9 @@ describe("CardRenderingService", () => {
);
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");
expect(card.renderedFields.title).toContain("Context");
expect(card.renderedFields.body).toContain("{{c1::ubiquitous language}}");
expect(card.renderedFields.body).toContain("Open in Obsidian");
});
it("preserves markdown fidelity for math, code, links, and media where feasible", () => {
@ -149,13 +149,13 @@ describe("CardRenderingService", () => {
},
);
expect(card.fields.back).toContain("\\(a+b\\)");
expect(card.fields.back).toContain("\\[x^2\\]");
expect(card.fields.back).toContain("<code>const value = 1</code>");
expect(card.fields.back).toContain("language-ts");
expect(card.fields.back).toContain("obsidian://open?vault=Vault&amp;file=Concept");
expect(card.fields.back).toContain("<img src=\"diagram.png\" alt=\"diagram.png\">");
expect(card.fields.back).toContain("[sound:clip.mp3]");
expect(card.fields.body).toContain("\\(a+b\\)");
expect(card.fields.body).toContain("\\[x^2\\]");
expect(card.fields.body).toContain("<code>const value = 1</code>");
expect(card.fields.body).toContain("language-ts");
expect(card.fields.body).toContain("obsidian://open?vault=Vault&amp;file=Concept");
expect(card.fields.body).toContain("<img src=\"diagram.png\" alt=\"diagram.png\">");
expect(card.fields.body).toContain("[sound:clip.mp3]");
expect(card.media).toHaveLength(2);
});
});

View file

@ -56,24 +56,12 @@ export class CardRenderingService {
? `<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 renderedFields: RenderedFields = {
title: headingResult.html,
body: bodyResult.html + backlinkHtml,
};
const fields = { ...renderedFields.values };
const fields = { ...renderedFields };
const contentHash = createContentHash(
hashString(
JSON.stringify({

View file

@ -28,15 +28,12 @@ function createCard(overrides: Partial<Card> = {}): Card {
noteModel: createNoteModelName("Basic"),
tags: [],
renderedFields: {
kind: "basic",
values: {
front: "Prompt",
back: "Answer",
},
title: "Prompt",
body: "Answer",
},
fields: {
front: "Prompt",
back: "Answer",
title: "Prompt",
body: "Answer",
},
contentHash: createContentHash("hash-a"),
media: [],

View file

@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { requestUrlMock } = vi.hoisted(() => ({
requestUrlMock: vi.fn(),
}));
vi.mock("obsidian", () => ({
requestUrl: requestUrlMock,
}));
import { AnkiConnectGateway } from "./AnkiConnectGateway";
describe("AnkiConnectGateway", () => {
beforeEach(() => {
requestUrlMock.mockReset();
});
it("loads note type names from AnkiConnect", async () => {
requestUrlMock.mockResolvedValue({
json: {
error: null,
result: ["Basic", "Cloze", "Custom Basic"],
},
});
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
const models = await gateway.listNoteModels();
expect(models).toEqual(["Basic", "Cloze", "Custom Basic"]);
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
action: "modelNames",
version: 6,
params: {},
});
});
it("loads model fields and detects cloze templates", async () => {
requestUrlMock
.mockResolvedValueOnce({
json: {
error: null,
result: ["Text", "Extra"],
},
})
.mockResolvedValueOnce({
json: {
error: null,
result: {
"Cloze Card": {},
},
},
});
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
const details = await gateway.getModelDetails("My Cloze");
expect(details).toEqual({
fieldNames: ["Text", "Extra"],
isCloze: true,
});
});
});

View file

@ -22,6 +22,10 @@ export class AnkiConnectGateway implements AnkiGateway {
await this.invoke("createDeck", { deck: deckName });
}
async listNoteModels(): Promise<string[]> {
return this.invoke<string[]>("modelNames", {});
}
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
const fieldNames = await this.invoke<string[]>("modelFieldNames", { modelName });
let isCloze = modelName.toLowerCase().includes("cloze");

View file

@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings";
import type { PluginDataStore } from "@/application/ports/PluginDataStore";
import { DataJsonPluginConfigRepository, type PluginDataSnapshot } from "./DataJsonPluginConfigRepository";
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;
}
}
describe("DataJsonPluginConfigRepository", () => {
it("loads legacy settings snapshots without noteFieldMappings", async () => {
const repository = new DataJsonPluginConfigRepository(
new InMemoryPluginDataStore({
settings: {
qaNoteType: "Legacy Basic",
clozeNoteType: "Legacy Cloze",
},
}),
);
const settings = await repository.load();
expect(settings.qaNoteType).toBe("Legacy Basic");
expect(settings.clozeNoteType).toBe("Legacy Cloze");
expect(settings.noteFieldMappings).toEqual({});
});
it("persists note field mappings across save and reload", async () => {
const store = new InMemoryPluginDataStore();
const repository = new DataJsonPluginConfigRepository(store);
await repository.save({
...DEFAULT_SETTINGS,
noteFieldMappings: {
"basic:Basic": {
cardType: "basic",
modelName: "Basic",
loadedFieldNames: ["Front", "Back"],
titleField: "Front",
bodyField: "Back",
loadedAt: 123,
},
},
});
const reloaded = await repository.load();
expect(reloaded.noteFieldMappings).toEqual({
"basic:Basic": {
cardType: "basic",
modelName: "Basic",
loadedFieldNames: ["Front", "Back"],
titleField: "Front",
bodyField: "Back",
loadedAt: 123,
},
});
});
});

View file

@ -24,6 +24,7 @@ export class DataJsonPluginConfigRepository implements PluginConfigRepository {
const mergedSettings: PluginSettings = {
...DEFAULT_SETTINGS,
...snapshot.settings,
noteFieldMappings: snapshot.settings?.noteFieldMappings ?? DEFAULT_SETTINGS.noteFieldMappings,
includeFolders: snapshot.settings?.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
excludeFolders: snapshot.settings?.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders,
};

View file

@ -1,6 +1,7 @@
import { Plugin } from "obsidian";
import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import { ScanAndPlanSyncUseCase } from "@/application/use-cases/ScanAndPlanSyncUseCase";
import { ExecuteSyncPlanUseCase } from "@/application/use-cases/ExecuteSyncPlanUseCase";
import { SyncCurrentFileUseCase } from "@/application/use-cases/SyncCurrentFileUseCase";
@ -18,6 +19,7 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
settings: PluginSettings = DEFAULT_SETTINGS;
private readonly noticeService = new NoticeService();
private readonly ankiGateway = new AnkiConnectGateway(() => this.settings.ankiConnectUrl);
private syncCurrentFileUseCase?: SyncCurrentFileUseCase;
private syncVaultUseCase?: SyncVaultUseCase;
@ -28,7 +30,6 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
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();
@ -39,7 +40,7 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
}
const scanAndPlanSyncUseCase = new ScanAndPlanSyncUseCase(vaultGateway, syncRegistryRepository);
const executeSyncPlanUseCase = new ExecuteSyncPlanUseCase(ankiGateway, syncRegistryRepository);
const executeSyncPlanUseCase = new ExecuteSyncPlanUseCase(this.ankiGateway, syncRegistryRepository);
this.syncCurrentFileUseCase = new SyncCurrentFileUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase);
this.syncVaultUseCase = new SyncVaultUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase);
@ -65,6 +66,14 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
}
}
async listNoteModels(): Promise<string[]> {
return this.ankiGateway.listNoteModels();
}
async getNoteModelDetails(modelName: string): Promise<NoteModelDetails> {
return this.ankiGateway.getModelDetails(modelName);
}
async runSyncCurrentFile(): Promise<void> {
const activeFile = this.app.workspace.getActiveFile();

View file

@ -0,0 +1,320 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings";
const {
FakeButtonComponent,
FakeDropdownComponent,
FakePluginSettingTab,
FakeSetting,
} = vi.hoisted(() => {
class HoistedFakeContainerEl {
settings: HoistedFakeSetting[] = [];
textNodes: string[] = [];
empty(): void {
this.settings = [];
this.textNodes = [];
}
createEl(_tag: string, options?: { text?: string }): HoistedFakeContainerEl {
if (options?.text) {
this.textNodes.push(options.text);
}
return this;
}
}
class HoistedFakeButtonComponent {
public text = "";
private onClickHandler?: () => void | Promise<void>;
setButtonText(text: string): this {
this.text = text;
return this;
}
onClick(callback: () => void | Promise<void>): this {
this.onClickHandler = callback;
return this;
}
async click(): Promise<void> {
await this.onClickHandler?.();
}
}
class HoistedFakeDropdownComponent {
public options: Array<{ value: string; label: string }> = [];
public value = "";
private onChangeHandler?: (value: string) => void | Promise<void>;
addOption(value: string, label: string): this {
this.options.push({ value, label });
return this;
}
setValue(value: string): this {
this.value = value;
return this;
}
onChange(callback: (value: string) => void | Promise<void>): this {
this.onChangeHandler = callback;
return this;
}
async triggerChange(value: string): Promise<void> {
this.value = value;
await this.onChangeHandler?.(value);
}
}
class HoistedFakeTextComponent {
public value = "";
setPlaceholder(value: string): this {
void value;
return this;
}
setValue(value: string): this {
this.value = value;
return this;
}
onChange(callback: (value: string) => void | Promise<void>): this {
void callback;
return this;
}
}
class HoistedFakeToggleComponent {
public value = false;
setValue(value: boolean): this {
this.value = value;
return this;
}
onChange(callback: (value: boolean) => void | Promise<void>): this {
void callback;
return this;
}
}
class HoistedFakeSetting {
public name = "";
public desc = "";
public controls: Array<
HoistedFakeButtonComponent | HoistedFakeDropdownComponent | HoistedFakeTextComponent | HoistedFakeToggleComponent
> = [];
constructor(containerEl: HoistedFakeContainerEl) {
containerEl.settings.push(this);
}
setName(name: string): this {
this.name = name;
return this;
}
setDesc(desc: string): this {
this.desc = desc;
return this;
}
addButton(callback: (button: HoistedFakeButtonComponent) => void): this {
const button = new HoistedFakeButtonComponent();
this.controls.push(button);
callback(button);
return this;
}
addDropdown(callback: (dropdown: HoistedFakeDropdownComponent) => void): this {
const dropdown = new HoistedFakeDropdownComponent();
this.controls.push(dropdown);
callback(dropdown);
return this;
}
addText(callback: (text: HoistedFakeTextComponent) => void): this {
const text = new HoistedFakeTextComponent();
this.controls.push(text);
callback(text);
return this;
}
addTextArea(callback: (text: HoistedFakeTextComponent) => void): this {
const text = new HoistedFakeTextComponent();
this.controls.push(text);
callback(text);
return this;
}
addToggle(callback: (toggle: HoistedFakeToggleComponent) => void): this {
const toggle = new HoistedFakeToggleComponent();
this.controls.push(toggle);
callback(toggle);
return this;
}
}
class HoistedFakePluginSettingTab {
public containerEl = new HoistedFakeContainerEl();
constructor(
public readonly app: unknown,
public readonly plugin: unknown,
) {}
}
return {
FakeButtonComponent: HoistedFakeButtonComponent,
FakeContainerEl: HoistedFakeContainerEl,
FakeDropdownComponent: HoistedFakeDropdownComponent,
FakePluginSettingTab: HoistedFakePluginSettingTab,
FakeSetting: HoistedFakeSetting,
};
});
vi.mock("obsidian", () => ({
PluginSettingTab: FakePluginSettingTab,
Setting: FakeSetting,
}));
import { AnkiHeadingSyncSettingTab } from "./PluginSettingTab";
type FakeContainerElInstance = InstanceType<typeof FakePluginSettingTab>["containerEl"];
type FakeSettingInstance = InstanceType<typeof FakeSetting>;
type FakeButtonComponentInstance = InstanceType<typeof FakeButtonComponent>;
type FakeDropdownComponentInstance = InstanceType<typeof FakeDropdownComponent>;
class FakePlugin {
public readonly app = {};
public settings = {
...DEFAULT_SETTINGS,
qaNoteType: "Custom Basic",
noteFieldMappings: {},
};
async updateSettings(partialSettings: Record<string, unknown>): Promise<void> {
this.settings = {
...this.settings,
...partialSettings,
};
}
async listNoteModels(): Promise<string[]> {
return ["Basic", "Cloze", "Custom Basic", "Custom Cloze"];
}
async getNoteModelDetails(modelName: string) {
if (modelName === "Custom Cloze") {
return {
fieldNames: ["Text", "Extra", "Context"],
isCloze: true,
};
}
return {
fieldNames: ["Title", "Body", "Hint"],
isCloze: false,
};
}
}
function findSetting(containerEl: FakeContainerElInstance, name: string): FakeSettingInstance {
const setting = containerEl.settings.find((candidate: FakeSettingInstance) => candidate.name === name);
if (!setting) {
throw new Error(`Setting not found: ${name}`);
}
return setting;
}
function getButton(setting: FakeSettingInstance): FakeButtonComponentInstance {
const button = setting.controls.find((control: unknown) => control instanceof FakeButtonComponent);
if (!button || !(button instanceof FakeButtonComponent)) {
throw new Error(`Button not found for setting: ${setting.name}`);
}
return button;
}
function getDropdown(setting: FakeSettingInstance): FakeDropdownComponentInstance {
const dropdown = setting.controls.find((control: unknown) => control instanceof FakeDropdownComponent);
if (!dropdown || !(dropdown instanceof FakeDropdownComponent)) {
throw new Error(`Dropdown not found for setting: ${setting.name}`);
}
return dropdown;
}
describe("AnkiHeadingSyncSettingTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("refreshes note type list from Anki into the dropdowns", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
const container = tab.containerEl as unknown as FakeContainerElInstance;
tab.display();
await getButton(findSetting(container, "Refresh note types from Anki")).click();
const dropdown = getDropdown(findSetting(container, "QA / Basic note type"));
expect(dropdown.options.map((option: { value: string }) => option.value)).toEqual(["Basic", "Cloze", "Custom Basic", "Custom Cloze"]);
});
it("loads fields, applies suggestions, and saves a user-adjusted basic mapping", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
const container = tab.containerEl as unknown as FakeContainerElInstance;
tab.display();
await getButton(findSetting(container, "Refresh note types from Anki")).click();
await getButton(findSetting(container, "QA / Basic fields")).click();
const titleDropdown = getDropdown(findSetting(container, "QA / Basic title field"));
const bodyDropdown = getDropdown(findSetting(container, "QA / Basic body field"));
expect(titleDropdown.value).toBe("Title");
expect(bodyDropdown.value).toBe("Body");
await bodyDropdown.triggerChange("Hint");
await getButton(findSetting(container, "QA / Basic mapping")).click();
expect(plugin.settings.noteFieldMappings).toEqual({
[createNoteFieldMappingKey("basic", "Custom Basic")]: {
cardType: "basic",
modelName: "Custom Basic",
loadedFieldNames: ["Title", "Body", "Hint"],
titleField: "Title",
bodyField: "Hint",
loadedAt: expect.any(Number),
},
});
expect(container.textNodes).toContain("Saved mapping for Custom Basic.");
});
it("loads cloze fields and suggests the main field", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
const container = tab.containerEl as unknown as FakeContainerElInstance;
tab.display();
await getButton(findSetting(container, "Refresh note types from Anki")).click();
await getDropdown(findSetting(container, "Cloze note type")).triggerChange("Custom Cloze");
await getButton(findSetting(container, "Cloze fields")).click();
const mainFieldDropdown = getDropdown(findSetting(container, "Cloze main field"));
expect(mainFieldDropdown.value).toBe("Text");
});
});

View file

@ -1,8 +1,33 @@
import { PluginSettingTab, Setting } from "obsidian";
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService";
import type { CardType } from "@/domain/card/entities/RenderedFields";
import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin";
const NOTE_TYPE_STATUS_IDLE = "Refresh note types from Anki to load the available note types.";
interface MappingSectionConfig {
cardType: CardType;
title: string;
description: string;
}
type SimpleDropdown = {
addOption(value: string, label: string): unknown;
setValue(value: string): unknown;
onChange(callback: (value: string) => void): unknown;
};
export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private readonly noteFieldMappingService = new NoteFieldMappingService();
private availableNoteModels: string[] = [];
private noteTypeStatus = NOTE_TYPE_STATUS_IDLE;
private readonly draftMappings: Record<string, NoteModelFieldMapping> = {};
private readonly loadedModelDetails: Record<string, NoteModelDetails> = {};
private readonly sectionStatuses: Partial<Record<CardType, string>> = {};
constructor(plugin: AnkiHeadingSyncPlugin) {
super(plugin.app, plugin);
this.plugin = plugin;
@ -61,24 +86,6 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
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.")
@ -114,6 +121,272 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
void this.plugin.updateSettings({ convertHighlightsToCloze: value });
});
});
containerEl.createEl("h3", { text: "Note type field mappings" });
new Setting(containerEl)
.setName("Refresh note types from Anki")
.setDesc(this.noteTypeStatus)
.addButton((button) => {
button.setButtonText("Refresh note types").onClick(() => {
void this.refreshNoteTypes();
});
});
this.renderMappingSection(containerEl, {
cardType: "basic",
title: "QA / Basic",
description: "Choose the QA note type, read its fields from Anki, then confirm the title/body mapping.",
});
this.renderMappingSection(containerEl, {
cardType: "cloze",
title: "Cloze",
description: "Choose the cloze note type, read its fields from Anki, then confirm the main field mapping.",
});
}
private renderMappingSection(containerEl: HTMLElement, config: MappingSectionConfig): void {
const selectedModelName = this.getSelectedNoteType(config.cardType);
const mappingKey = createNoteFieldMappingKey(config.cardType, selectedModelName);
const currentMapping = this.getCurrentMapping(mappingKey);
containerEl.createEl("h4", { text: config.title });
containerEl.createEl("p", { text: config.description });
new Setting(containerEl)
.setName(`${config.title} note type`)
.setDesc("Loaded from Anki note types. Refresh if the latest models are not shown.")
.addDropdown((dropdown) => {
for (const noteModel of this.getSelectableNoteModels(selectedModelName)) {
dropdown.addOption(noteModel, noteModel);
}
dropdown.setValue(selectedModelName).onChange((value) => {
void this.updateSelectedNoteType(config.cardType, value);
});
});
new Setting(containerEl)
.setName(`${config.title} fields`)
.setDesc("Load the current note type fields from Anki, then review the suggested mapping.")
.addButton((button) => {
button.setButtonText("Read fields from Anki").onClick(() => {
void this.loadFieldsFromAnki(config.cardType);
});
});
containerEl.createEl("p", {
text:
currentMapping?.loadedFieldNames.length
? `Loaded fields: ${currentMapping.loadedFieldNames.join(", ")}`
: "Loaded fields: none. Read fields from Anki first.",
});
if (config.cardType === "basic") {
this.renderBasicFieldSelectors(containerEl, selectedModelName, currentMapping);
} else {
this.renderClozeFieldSelector(containerEl, selectedModelName, currentMapping);
}
new Setting(containerEl)
.setName(`${config.title} mapping`)
.setDesc("Save the current field mapping for this specific note type.")
.addButton((button) => {
button.setButtonText("Save mapping").onClick(() => {
void this.saveMapping(config.cardType);
});
});
containerEl.createEl("p", {
text:
this.sectionStatuses[config.cardType] ??
(this.plugin.settings.noteFieldMappings[mappingKey]
? `Saved mapping is ready for ${config.title}.`
: `No saved mapping for ${config.title}. Read fields from Anki first.`),
});
}
private renderBasicFieldSelectors(
containerEl: HTMLElement,
selectedModelName: string,
mapping: NoteModelFieldMapping | undefined,
): void {
const fieldNames = mapping?.loadedFieldNames ?? [];
new Setting(containerEl)
.setName("QA / Basic title field")
.setDesc("Which Anki field should receive the heading/title fragment.")
.addDropdown((dropdown) => {
this.populateFieldDropdown(dropdown, fieldNames, mapping?.titleField);
dropdown.onChange((value) => {
this.updateDraftMapping("basic", selectedModelName, { titleField: value || undefined });
});
});
new Setting(containerEl)
.setName("QA / Basic body field")
.setDesc("Which Anki field should receive the body fragment.")
.addDropdown((dropdown) => {
this.populateFieldDropdown(dropdown, fieldNames, mapping?.bodyField);
dropdown.onChange((value) => {
this.updateDraftMapping("basic", selectedModelName, { bodyField: value || undefined });
});
});
}
private renderClozeFieldSelector(
containerEl: HTMLElement,
selectedModelName: string,
mapping: NoteModelFieldMapping | undefined,
): void {
const fieldNames = mapping?.loadedFieldNames ?? [];
new Setting(containerEl)
.setName("Cloze main field")
.setDesc("The selected field receives title + <br><br> + body during sync.")
.addDropdown((dropdown) => {
this.populateFieldDropdown(dropdown, fieldNames, mapping?.mainField);
dropdown.onChange((value) => {
this.updateDraftMapping("cloze", selectedModelName, { mainField: value || undefined });
});
});
}
private populateFieldDropdown(dropdown: SimpleDropdown, fieldNames: string[], selectedValue: string | undefined): void {
dropdown.addOption("", "-- Select field --");
for (const fieldName of fieldNames) {
dropdown.addOption(fieldName, fieldName);
}
dropdown.setValue(selectedValue ?? "");
}
private async refreshNoteTypes(): Promise<void> {
try {
const noteModels = await this.plugin.listNoteModels();
this.availableNoteModels = [...noteModels].sort((left, right) => left.localeCompare(right));
this.noteTypeStatus =
this.availableNoteModels.length > 0
? `Loaded ${this.availableNoteModels.length} note types from Anki.`
: "Anki returned no note types.";
} catch (error) {
this.noteTypeStatus = error instanceof Error ? error.message : "Failed to load note types from Anki.";
}
this.display();
}
private async updateSelectedNoteType(cardType: CardType, modelName: string): Promise<void> {
if (cardType === "basic") {
await this.plugin.updateSettings({ qaNoteType: modelName });
} else {
await this.plugin.updateSettings({ clozeNoteType: modelName });
}
const mappingKey = createNoteFieldMappingKey(cardType, modelName);
this.sectionStatuses[cardType] = this.plugin.settings.noteFieldMappings[mappingKey]
? `Loaded saved mapping for ${modelName}.`
: `Selected ${modelName}. Read fields from Anki to create or refresh its mapping.`;
this.display();
}
private async loadFieldsFromAnki(cardType: CardType): Promise<void> {
const modelName = this.getSelectedNoteType(cardType);
try {
const modelDetails = await this.plugin.getNoteModelDetails(modelName);
const mapping = this.noteFieldMappingService.suggest(cardType, modelName, modelDetails.fieldNames);
const mappingKey = createNoteFieldMappingKey(cardType, modelName);
this.draftMappings[mappingKey] = mapping;
this.loadedModelDetails[mappingKey] = modelDetails;
this.sectionStatuses[cardType] = `Loaded fields for ${modelName}. Review the suggested mapping and save it.`;
} catch (error) {
this.sectionStatuses[cardType] = error instanceof Error ? error.message : `Failed to load fields for ${modelName}.`;
}
this.display();
}
private async saveMapping(cardType: CardType): Promise<void> {
const modelName = this.getSelectedNoteType(cardType);
const mappingKey = createNoteFieldMappingKey(cardType, modelName);
const mapping = this.getCurrentMapping(mappingKey);
if (!mapping) {
this.sectionStatuses[cardType] = `No loaded fields for ${modelName}. Read fields from Anki first.`;
this.display();
return;
}
try {
this.noteFieldMappingService.validateMapping(mapping, this.loadedModelDetails[mappingKey] ?? {
fieldNames: mapping.loadedFieldNames,
isCloze: cardType === "cloze",
});
await this.plugin.updateSettings({
noteFieldMappings: {
...this.plugin.settings.noteFieldMappings,
[mappingKey]: {
...mapping,
loadedFieldNames: [...mapping.loadedFieldNames],
},
},
});
this.sectionStatuses[cardType] = `Saved mapping for ${modelName}.`;
} catch (error) {
this.sectionStatuses[cardType] = error instanceof Error ? error.message : `Failed to save mapping for ${modelName}.`;
}
this.display();
}
private getSelectableNoteModels(selectedModelName: string): string[] {
const noteModels = this.availableNoteModels.length > 0 ? [...this.availableNoteModels] : [];
if (!noteModels.includes(selectedModelName)) {
noteModels.unshift(selectedModelName);
}
return noteModels;
}
private updateDraftMapping(
cardType: CardType,
modelName: string,
partialMapping: Partial<NoteModelFieldMapping>,
): void {
const mappingKey = createNoteFieldMappingKey(cardType, modelName);
const currentMapping = this.getCurrentMapping(mappingKey);
if (!currentMapping) {
return;
}
this.draftMappings[mappingKey] = {
...currentMapping,
...partialMapping,
};
}
private getCurrentMapping(mappingKey: string): NoteModelFieldMapping | undefined {
const mapping = this.draftMappings[mappingKey] ?? this.plugin.settings.noteFieldMappings[mappingKey];
if (!mapping) {
return undefined;
}
return {
...mapping,
loadedFieldNames: [...mapping.loadedFieldNames],
};
}
private getSelectedNoteType(cardType: CardType): string {
return cardType === "basic" ? this.plugin.settings.qaNoteType : this.plugin.settings.clozeNoteType;
}
}

View file

@ -0,0 +1,82 @@
export async function requestUrl(): Promise<never> {
throw new Error("obsidian.requestUrl stub was called without a test mock.");
}
export class Plugin {
public app: unknown;
constructor(app: unknown = {}) {
this.app = app;
}
addSettingTab(): void {}
}
export class PluginSettingTab {
public containerEl = {
empty() {},
createEl() {
return this;
},
};
constructor(
public readonly app: unknown,
public readonly plugin: unknown,
) {}
}
export class Setting {
constructor(containerEl: unknown) {
void containerEl;
}
setName(): this {
return this;
}
setDesc(): this {
return this;
}
addText(): this {
return this;
}
addTextArea(): this {
return this;
}
addDropdown(): this {
return this;
}
addToggle(): this {
return this;
}
addButton(): this {
return this;
}
}
export class Notice {
constructor(
public readonly message: string,
public readonly timeout?: number,
) {}
}
export class TFile {
public readonly extension: string;
public readonly basename: string;
public readonly name: string;
constructor(public readonly path: string) {
const segments = path.split("/");
this.name = segments[segments.length - 1] ?? path;
const extensionIndex = this.name.lastIndexOf(".");
this.extension = extensionIndex >= 0 ? this.name.slice(extensionIndex + 1) : "";
this.basename = extensionIndex >= 0 ? this.name.slice(0, extensionIndex) : this.name;
}
}

View file

@ -6,6 +6,7 @@ export default defineConfig({
resolve: {
alias: {
"@": resolve(__dirname, "src"),
obsidian: resolve(__dirname, "src/test-support/obsidianStub.ts"),
},
},
test: {