mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
chore: commit Module 1 changes — add NoteFieldMapping, mapping service, persistence, tests
This commit is contained in:
parent
f854a3b581
commit
5099b39d3f
26 changed files with 1353 additions and 152 deletions
83
docs/2026-04-16PLAN2.md
Normal file
83
docs/2026-04-16PLAN2.md
Normal 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`,作为下一模块单独扩展。
|
||||
31
docs/module-1-gap-report.md
Normal file
31
docs/module-1-gap-report.md
Normal 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.
|
||||
15
src/application/config/NoteModelFieldMapping.ts
Normal file
15
src/application/config/NoteModelFieldMapping.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export class ScanAndPlanSyncUseCase {
|
|||
|
||||
return {
|
||||
cards,
|
||||
noteFieldMappings: settings.noteFieldMappings,
|
||||
registry,
|
||||
plan,
|
||||
scopedFilePaths,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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&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&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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
62
src/infrastructure/anki/AnkiConnectGateway.test.ts
Normal file
62
src/infrastructure/anki/AnkiConnectGateway.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
320
src/presentation/settings/PluginSettingTab.test.ts
Normal file
320
src/presentation/settings/PluginSettingTab.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
82
src/test-support/obsidianStub.ts
Normal file
82
src/test-support/obsidianStub.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export default defineConfig({
|
|||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
obsidian: resolve(__dirname, "src/test-support/obsidianStub.ts"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue