From 5099b39d3fa4d5d72239b24c35f7855dbb478e8a Mon Sep 17 00:00:00 2001 From: Dusk Date: Thu, 16 Apr 2026 23:02:07 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20commit=20Module=201=20changes=20?= =?UTF-8?q?=E2=80=94=20add=20NoteFieldMapping,=20mapping=20service,=20pers?= =?UTF-8?q?istence,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/2026-04-16PLAN2.md | 83 +++++ docs/module-1-gap-report.md | 31 ++ .../config/NoteModelFieldMapping.ts | 15 + src/application/config/PluginSettings.ts | 30 ++ src/application/ports/AnkiGateway.ts | 1 + .../services/NoteFieldMappingService.test.ts | 122 ++++--- .../services/NoteFieldMappingService.ts | 125 +++++-- .../use-cases/ExecuteSyncPlanUseCase.test.ts | 94 ++++- .../use-cases/ExecuteSyncPlanUseCase.ts | 14 +- .../use-cases/ScanAndPlanSyncUseCase.ts | 1 + .../use-cases/SyncCurrentFileUseCase.test.ts | 22 ++ .../use-cases/SyncVaultUseCase.test.ts | 22 ++ src/application/use-cases/types.ts | 2 + src/domain/card/entities/RenderedFields.ts | 19 +- .../services/CardRenderingService.test.ts | 30 +- .../card/services/CardRenderingService.ts | 22 +- .../sync/services/SyncPlanningService.test.ts | 11 +- .../anki/AnkiConnectGateway.test.ts | 62 ++++ src/infrastructure/anki/AnkiConnectGateway.ts | 4 + .../DataJsonPluginConfigRepository.test.ts | 69 ++++ .../DataJsonPluginConfigRepository.ts | 1 + src/presentation/AnkiHeadingSyncPlugin.ts | 13 +- .../settings/PluginSettingTab.test.ts | 320 ++++++++++++++++++ src/presentation/settings/PluginSettingTab.ts | 309 ++++++++++++++++- src/test-support/obsidianStub.ts | 82 +++++ vitest.config.ts | 1 + 26 files changed, 1353 insertions(+), 152 deletions(-) create mode 100644 docs/2026-04-16PLAN2.md create mode 100644 docs/module-1-gap-report.md create mode 100644 src/application/config/NoteModelFieldMapping.ts create mode 100644 src/infrastructure/anki/AnkiConnectGateway.test.ts create mode 100644 src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts create mode 100644 src/presentation/settings/PluginSettingTab.test.ts create mode 100644 src/test-support/obsidianStub.ts diff --git a/docs/2026-04-16PLAN2.md b/docs/2026-04-16PLAN2.md new file mode 100644 index 0000000..3003436 --- /dev/null +++ b/docs/2026-04-16PLAN2.md @@ -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`,用于设置页拉取全部 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 + "

" + bodyHtml`;辅助字段本模块不写入,保持空缺。 +- 同步执行前加入映射校验: + - 选中的 note type 没有已确认映射时,直接报错并提示先去设置页读取字段。 + - 已保存映射里的字段名不再存在于 Anki 当前模型时,直接报错并提示重新读取字段。 +- 老数据迁移策略固定: + - 旧用户升级后,保留 `qaNoteType` / `clozeNoteType` 原值。 + - `noteFieldMappings` 初始为空。 + - 第一次同步若未配置映射则阻止执行,并给出明确 notice。 + +## Public Interfaces / Types + +- `AnkiGateway.listNoteModels(): Promise` +- `NoteModelFieldMapping`: + - `cardType: "basic" | "cloze"` + - `modelName: string` + - `loadedFieldNames: string[]` + - `titleField?: string` + - `bodyField?: string` + - `mainField?: string` + - `loadedAt: number` +- `PluginSettings.noteFieldMappings: Record` + +## Test Plan + +- `AnkiConnectGateway`: + - 能拉取 note type 列表 + - 能读取模型字段 +- 设置持久化: + - 旧 settings 无 `noteFieldMappings` 时能正常加载 + - 保存后重新加载仍能还原映射 +- `NoteFieldMappingService`: + - Basic 按已保存映射输出标题/正文 + - Cloze 按 `title +

+ body` 输出到主字段 + - 缺映射时报错 + - 映射字段不存在时报错 + - Basic 两个字段选成同一字段时报错 +- 设置页交互: + - 刷新 note type 列表 + - 读取字段后生成自动建议 + - 用户改选后正确保存 +- 同步用例: + - 已配置映射时 add/update 正确写入真实 Anki 字段 + - 未配置映射时同步被阻止并提示 + - Anki 模型字段变更后旧映射失效并提示重新读取 + +## Assumptions + +- 本模块只做字段结构读取与映射,不读取 Anki 卡片模板的 HTML/CSS,也不做模板编辑器。 +- note type 列表只做运行时拉取,不持久化缓存到 `data.json`。 +- 这一版不保留旧的“无映射时自动兜底同步”路径;安全优先。 +- Cloze 在本模块内采用“标题和正文都进入主字段,辅助字段留空”的规则;如果以后要恢复 `Extra/Context`,作为下一模块单独扩展。 diff --git a/docs/module-1-gap-report.md b/docs/module-1-gap-report.md new file mode 100644 index 0000000..5d3740c --- /dev/null +++ b/docs/module-1-gap-report.md @@ -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 +

+ 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. \ No newline at end of file diff --git a/src/application/config/NoteModelFieldMapping.ts b/src/application/config/NoteModelFieldMapping.ts new file mode 100644 index 0000000..0579f5b --- /dev/null +++ b/src/application/config/NoteModelFieldMapping.ts @@ -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}`; +} \ No newline at end of file diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts index 62969d5..5c52991 100644 --- a/src/application/config/PluginSettings.ts +++ b/src/application/config/PluginSettings.ts @@ -1,8 +1,11 @@ +import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping"; + export interface PluginSettings { qaHeadingLevel: number; clozeHeadingLevel: number; qaNoteType: string; clozeNoteType: string; + noteFieldMappings: Record; 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): 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."); + } + } } \ No newline at end of file diff --git a/src/application/ports/AnkiGateway.ts b/src/application/ports/AnkiGateway.ts index 0a0554d..21f2fee 100644 --- a/src/application/ports/AnkiGateway.ts +++ b/src/application/ports/AnkiGateway.ts @@ -16,6 +16,7 @@ export interface UpdateAnkiNoteInput { export interface AnkiGateway { ensureDeckExists(deckName: string): Promise; + listNoteModels(): Promise; getModelDetails(modelName: string): Promise; addNote(input: AddAnkiNoteInput): Promise; updateNote(input: UpdateAnkiNoteInput): Promise; diff --git a/src/application/services/NoteFieldMappingService.test.ts b/src/application/services/NoteFieldMappingService.test.ts index 190130f..2d096b0 100644 --- a/src/application/services/NoteFieldMappingService.test.ts +++ b/src/application/services/NoteFieldMappingService.test.ts @@ -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 { 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 { } 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

{{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'); }); }); \ No newline at end of file diff --git a/src/application/services/NoteFieldMappingService.ts b/src/application/services/NoteFieldMappingService.ts index ed84170..e5b201e 100644 --- a/src/application/services/NoteFieldMappingService.ts +++ b/src/application/services/NoteFieldMappingService.ts @@ -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 { + map( + card: Card, + noteModelDetails: NoteModelDetails, + noteFieldMappings: Record, + ): Record { + 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 { - 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 { 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 { + 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 { + 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}

${card.renderedFields.body}`, + }; + } + + private getRequiredMapping( + card: Card, + noteFieldMappings: Record, + ): 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 { diff --git a/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts b/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts index dd00a6e..36d7c3f 100644 --- a/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts +++ b/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts @@ -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 }> = []; public updatedNotes: Array<{ noteId: number; deckName: string; fields: Record }> = []; public storedMedia: string[] = []; + public modelDetailsByName: Record = { + Basic: { fieldNames: ["Front", "Back"], isCloze: false }, + Cloze: { fieldNames: ["Text", "Extra"], isCloze: true }, + }; async ensureDeckExists(deckName: string): Promise { this.ensuredDecks.push(deckName); } + async listNoteModels(): Promise { + 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 }): Promise { @@ -75,15 +82,12 @@ function createCard(overrides: Partial = {}): 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 { }; } +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); + }); }); \ No newline at end of file diff --git a/src/application/use-cases/ExecuteSyncPlanUseCase.ts b/src/application/use-cases/ExecuteSyncPlanUseCase.ts index 61ad3c0..3d56299 100644 --- a/src/application/use-cases/ExecuteSyncPlanUseCase.ts +++ b/src/application/use-cases/ExecuteSyncPlanUseCase.ts @@ -18,14 +18,20 @@ export class ExecuteSyncPlanUseCase { const modelDetailsCache = new Map>>(); 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({ diff --git a/src/application/use-cases/ScanAndPlanSyncUseCase.ts b/src/application/use-cases/ScanAndPlanSyncUseCase.ts index 70bbb8f..bdaa3a3 100644 --- a/src/application/use-cases/ScanAndPlanSyncUseCase.ts +++ b/src/application/use-cases/ScanAndPlanSyncUseCase.ts @@ -64,6 +64,7 @@ export class ScanAndPlanSyncUseCase { return { cards, + noteFieldMappings: settings.noteFieldMappings, registry, plan, scopedFilePaths, diff --git a/src/application/use-cases/SyncCurrentFileUseCase.test.ts b/src/application/use-cases/SyncCurrentFileUseCase.test.ts index 8a9ca74..8944c38 100644 --- a/src/application/use-cases/SyncCurrentFileUseCase.test.ts +++ b/src/application/use-cases/SyncCurrentFileUseCase.test.ts @@ -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 { + return ["Basic", "Cloze"]; + } + async getModelDetails(modelName: string) { return modelName === "Cloze" ? { fieldNames: ["Text", "Extra"], isCloze: true } @@ -106,6 +111,23 @@ function createSettings(overrides: Partial = {}): 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, }; } diff --git a/src/application/use-cases/SyncVaultUseCase.test.ts b/src/application/use-cases/SyncVaultUseCase.test.ts index bba0554..5470d7c 100644 --- a/src/application/use-cases/SyncVaultUseCase.test.ts +++ b/src/application/use-cases/SyncVaultUseCase.test.ts @@ -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 { + return ["Basic", "Cloze"]; + } + async getModelDetails(modelName: string) { return modelName === "Cloze" ? { fieldNames: ["Text", "Extra"], isCloze: true } @@ -96,6 +101,23 @@ function createSettings(overrides: Partial = {}): 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, }; } diff --git a/src/application/use-cases/types.ts b/src/application/use-cases/types.ts index 8b399ce..ad89927 100644 --- a/src/application/use-cases/types.ts +++ b/src/application/use-cases/types.ts @@ -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; registry: SyncRegistry; plan: SyncPlan; scopedFilePaths: string[]; diff --git a/src/domain/card/entities/RenderedFields.ts b/src/domain/card/entities/RenderedFields.ts index 72d8e98..fc37093 100644 --- a/src/domain/card/entities/RenderedFields.ts +++ b/src/domain/card/entities/RenderedFields.ts @@ -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; diff --git a/src/domain/card/services/CardRenderingService.test.ts b/src/domain/card/services/CardRenderingService.test.ts index 86d5824..0e12608 100644 --- a/src/domain/card/services/CardRenderingService.test.ts +++ b/src/domain/card/services/CardRenderingService.test.ts @@ -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("const value = 1"); - expect(card.fields.back).toContain("language-ts"); - expect(card.fields.back).toContain("obsidian://open?vault=Vault&file=Concept"); - expect(card.fields.back).toContain("\"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("const value = 1"); + expect(card.fields.body).toContain("language-ts"); + expect(card.fields.body).toContain("obsidian://open?vault=Vault&file=Concept"); + expect(card.fields.body).toContain("\"diagram.png\""); + expect(card.fields.body).toContain("[sound:clip.mp3]"); expect(card.media).toHaveLength(2); }); }); \ No newline at end of file diff --git a/src/domain/card/services/CardRenderingService.ts b/src/domain/card/services/CardRenderingService.ts index 07e32d0..8ddc9d7 100644 --- a/src/domain/card/services/CardRenderingService.ts +++ b/src/domain/card/services/CardRenderingService.ts @@ -56,24 +56,12 @@ export class CardRenderingService { ? `

Open in Obsidian

` : ""; - 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({ diff --git a/src/domain/sync/services/SyncPlanningService.test.ts b/src/domain/sync/services/SyncPlanningService.test.ts index 56f14e6..02660a4 100644 --- a/src/domain/sync/services/SyncPlanningService.test.ts +++ b/src/domain/sync/services/SyncPlanningService.test.ts @@ -28,15 +28,12 @@ function createCard(overrides: Partial = {}): 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: [], diff --git a/src/infrastructure/anki/AnkiConnectGateway.test.ts b/src/infrastructure/anki/AnkiConnectGateway.test.ts new file mode 100644 index 0000000..30bef8c --- /dev/null +++ b/src/infrastructure/anki/AnkiConnectGateway.test.ts @@ -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, + }); + }); +}); \ No newline at end of file diff --git a/src/infrastructure/anki/AnkiConnectGateway.ts b/src/infrastructure/anki/AnkiConnectGateway.ts index 84c3d21..00fe613 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.ts @@ -22,6 +22,10 @@ export class AnkiConnectGateway implements AnkiGateway { await this.invoke("createDeck", { deck: deckName }); } + async listNoteModels(): Promise { + return this.invoke("modelNames", {}); + } + async getModelDetails(modelName: string): Promise { const fieldNames = await this.invoke("modelFieldNames", { modelName }); let isCloze = modelName.toLowerCase().includes("cloze"); diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts new file mode 100644 index 0000000..1257e27 --- /dev/null +++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts @@ -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 { + constructor(private snapshot: PluginDataSnapshot | null = null) {} + + async load(): Promise { + return this.snapshot; + } + + async save(data: PluginDataSnapshot): Promise { + 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, + }, + }); + }); +}); \ No newline at end of file diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts index 2e20db1..c4d52d9 100644 --- a/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts +++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts @@ -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, }; diff --git a/src/presentation/AnkiHeadingSyncPlugin.ts b/src/presentation/AnkiHeadingSyncPlugin.ts index 85e3cb7..d336222 100644 --- a/src/presentation/AnkiHeadingSyncPlugin.ts +++ b/src/presentation/AnkiHeadingSyncPlugin.ts @@ -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 { + return this.ankiGateway.listNoteModels(); + } + + async getNoteModelDetails(modelName: string): Promise { + return this.ankiGateway.getModelDetails(modelName); + } + async runSyncCurrentFile(): Promise { const activeFile = this.app.workspace.getActiveFile(); diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts new file mode 100644 index 0000000..fa037c4 --- /dev/null +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -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; + + setButtonText(text: string): this { + this.text = text; + return this; + } + + onClick(callback: () => void | Promise): this { + this.onClickHandler = callback; + return this; + } + + async click(): Promise { + await this.onClickHandler?.(); + } + } + + class HoistedFakeDropdownComponent { + public options: Array<{ value: string; label: string }> = []; + public value = ""; + private onChangeHandler?: (value: string) => void | Promise; + + 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): this { + this.onChangeHandler = callback; + return this; + } + + async triggerChange(value: string): Promise { + 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): 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): 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["containerEl"]; +type FakeSettingInstance = InstanceType; +type FakeButtonComponentInstance = InstanceType; +type FakeDropdownComponentInstance = InstanceType; + +class FakePlugin { + public readonly app = {}; + public settings = { + ...DEFAULT_SETTINGS, + qaNoteType: "Custom Basic", + noteFieldMappings: {}, + }; + + async updateSettings(partialSettings: Record): Promise { + this.settings = { + ...this.settings, + ...partialSettings, + }; + } + + async listNoteModels(): Promise { + 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"); + }); +}); \ No newline at end of file diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index 46dbcb3..c40cb80 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -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 = {}; + private readonly loadedModelDetails: Record = {}; + private readonly sectionStatuses: Partial> = {}; + 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 +

+ 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 { + 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 { + 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 { + 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 { + 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, + ): 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; } } diff --git a/src/test-support/obsidianStub.ts b/src/test-support/obsidianStub.ts new file mode 100644 index 0000000..f779a47 --- /dev/null +++ b/src/test-support/obsidianStub.ts @@ -0,0 +1,82 @@ +export async function requestUrl(): Promise { + 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; + } +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 78d07c0..0e90a19 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ resolve: { alias: { "@": resolve(__dirname, "src"), + obsidian: resolve(__dirname, "src/test-support/obsidianStub.ts"), }, }, test: {