fix(settings): cache Anki note templates

中文: 优化设置页卡片类型布局,隐藏语义问答题与 AnkiConnect URL;允许多级列表问答题从 Anki 模板中选择笔记类型和字段。

中文: 将手动读取到的 Anki 笔记模板列表缓存到 data.json,设置页重新打开后下拉菜单可直接使用上次读取结果。

English: Refine the settings card-type layout, hide Semantic Q&A and the AnkiConnect URL, and let nested-list Q&A use selectable Anki note types and fields.

English: Cache loaded Anki note templates in data.json so settings dropdowns can reuse the last loaded list after reopening.
This commit is contained in:
Dusk 2026-04-24 14:59:22 +08:00
parent 0fdf9c8ef3
commit 06492948d9
8 changed files with 340 additions and 126 deletions

View file

@ -1,7 +1,9 @@
import type { CardType } from "@/domain/card/entities/RenderedFields";
export type NoteModelFieldMappingCardType = CardType | "qa-group";
export interface NoteModelFieldMapping {
cardType: CardType;
cardType: NoteModelFieldMappingCardType;
modelName: string;
loadedFieldNames: string[];
titleField?: string;
@ -10,6 +12,6 @@ export interface NoteModelFieldMapping {
loadedAt: number;
}
export function createNoteFieldMappingKey(cardType: CardType, modelName: string): string {
export function createNoteFieldMappingKey(cardType: NoteModelFieldMappingCardType, modelName: string): string {
return `${cardType}:${modelName}`;
}
}

View file

@ -80,6 +80,7 @@ describe("PluginSettings", () => {
expect(DEFAULT_SETTINGS.obsidianBacklinkPlacement).toBe("answer-last-line");
expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true);
expect(DEFAULT_SETTINGS.keepPureTagLinesInCardBody).toBe(true);
expect(DEFAULT_SETTINGS.ankiNoteTypeCache).toEqual([]);
expect(DEFAULT_SETTINGS.cardTypeConfigs).toEqual({
basic: {
enabled: true,
@ -108,6 +109,34 @@ describe("PluginSettings", () => {
});
});
it("normalizes cached Anki note types on load and save paths", () => {
const settings = mergePluginSettings({
ankiNoteTypeCache: [" Custom Basic ", "", "Cloze", "Custom Basic"],
});
expect(settings.ankiNoteTypeCache).toEqual(["Cloze", "Custom Basic"]);
expect(normalizePluginSettings({
...DEFAULT_SETTINGS,
ankiNoteTypeCache: ["Basic", " ", "Cloze", "Basic"],
}).ankiNoteTypeCache).toEqual(["Basic", "Cloze"]);
});
it("rejects invalid cached Anki note type lists", () => {
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
ankiNoteTypeCache: "Basic" as never,
});
}, "errors.settings.ankiNoteTypeCacheArray");
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
ankiNoteTypeCache: ["Basic", 1] as never,
});
}, "errors.settings.ankiNoteTypeCacheStrings");
});
it("fills new backlink defaults when loading legacy snapshots", () => {
const settings = mergePluginSettings({
qaNoteType: "Legacy Basic",
@ -195,7 +224,7 @@ describe("PluginSettings", () => {
})).not.toThrow();
});
it("writes managed QA Group model back during normalization", () => {
it("preserves user-selected QA Group model during normalization", () => {
const settings = mergePluginSettings({
cardTypeConfigs: {
...DEFAULT_SETTINGS.cardTypeConfigs,
@ -206,7 +235,7 @@ describe("PluginSettings", () => {
},
});
expect(settings.cardTypeConfigs["qa-group"].noteType).toBe(QA_GROUP_MODEL_NAME);
expect(settings.cardTypeConfigs["qa-group"].noteType).toBe("Custom QA Group");
});
it("rejects invalid module 5 enum values", () => {
@ -231,4 +260,4 @@ describe("PluginSettings", () => {
});
}, "errors.settings.cardAnswerCutoffModeInvalid");
});
});
});

View file

@ -40,6 +40,7 @@ export interface PluginSettings {
semanticQaNoteType: string;
cardTypeConfigs: CardTypeConfigs;
noteFieldMappings: Record<string, NoteModelFieldMapping>;
ankiNoteTypeCache: string[];
defaultDeck: string;
fileDeckEnabled: boolean;
fileDeckMarker: string;
@ -69,6 +70,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
semanticQaNoteType: DEFAULT_SEMANTIC_QA_NOTE_TYPE,
cardTypeConfigs: createDefaultCardTypeConfigs(),
noteFieldMappings: {},
ankiNoteTypeCache: [],
defaultDeck: "Obsidian",
fileDeckEnabled: false,
fileDeckMarker: "TARGET DECK",
@ -114,6 +116,7 @@ export function normalizePluginSettings(settings: PluginSettings): PluginSetting
...settings,
...legacySettings,
cardTypeConfigs,
ankiNoteTypeCache: normalizeAnkiNoteTypeCache(settings.ankiNoteTypeCache),
obsidianBacklinkLabel: normalizeObsidianBacklinkLabel(settings.obsidianBacklinkLabel),
};
}
@ -140,6 +143,7 @@ export function validatePluginSettings(settings: PluginSettings): void {
}
validateNoteFieldMappings(settings.noteFieldMappings);
validateAnkiNoteTypeCache(settings.ankiNoteTypeCache);
if (!settings.defaultDeck.trim()) {
throw new PluginUserError("errors.settings.defaultDeckRequired");
@ -193,6 +197,18 @@ export function validatePluginSettings(settings: PluginSettings): void {
}
}
function validateAnkiNoteTypeCache(ankiNoteTypeCache: string[]): void {
if (!Array.isArray(ankiNoteTypeCache)) {
throw new PluginUserError("errors.settings.ankiNoteTypeCacheArray");
}
for (const noteType of ankiNoteTypeCache) {
if (typeof noteType !== "string") {
throw new PluginUserError("errors.settings.ankiNoteTypeCacheStrings");
}
}
}
function validateFolderList(folderList: string[], label: "include" | "exclude"): void {
if (!Array.isArray(folderList)) {
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersArray" : "errors.settings.excludeFoldersArray");
@ -234,6 +250,10 @@ export function validateCardTypeConfigs(cardTypeConfigs: CardTypeConfigs): void
throw new PluginUserError("errors.settings.qaNoteTypeRequired");
}
if (configId === "qa-group" && !config.noteType.trim()) {
throw new PluginUserError("errors.settings.qaNoteTypeRequired");
}
if (configId === "cloze" && !config.noteType.trim()) {
throw new PluginUserError("errors.settings.clozeNoteTypeRequired");
}
@ -264,7 +284,7 @@ function validateNoteFieldMappings(noteFieldMappings: Record<string, NoteModelFi
}
for (const mapping of Object.values(noteFieldMappings)) {
if (mapping.cardType !== "basic" && mapping.cardType !== "cloze" && mapping.cardType !== "semantic-qa") {
if (mapping.cardType !== "basic" && mapping.cardType !== "qa-group" && mapping.cardType !== "cloze" && mapping.cardType !== "semantic-qa") {
throw new PluginUserError("errors.settings.noteFieldMappingsCardType");
}
@ -324,7 +344,7 @@ function createLegacyBackfilledCardTypeConfigs(settings: Partial<PluginSettings>
...defaults["qa-group"],
headingLevel: sanitizeHeadingLevel(settings.qaHeadingLevel, defaults["qa-group"].headingLevel),
extraMarker: sanitizeMarker(settings.qaGroupMarker, defaults["qa-group"].extraMarker),
noteType: QA_GROUP_MODEL_NAME,
noteType: sanitizeNoteType(settings.cardTypeConfigs?.["qa-group"]?.noteType ?? QA_GROUP_MODEL_NAME, defaults["qa-group"].noteType),
},
cloze: {
...defaults.cloze,
@ -358,9 +378,7 @@ function mergeCardTypeConfigs(
enabled: typeof mergedConfig.enabled === "boolean" ? mergedConfig.enabled : fallbackConfig.enabled,
headingLevel: sanitizeHeadingLevel(mergedConfig.headingLevel, fallbackConfig.headingLevel),
extraMarker: sanitizeMarker(mergedConfig.extraMarker, fallbackConfig.extraMarker),
noteType: configId === "qa-group"
? QA_GROUP_MODEL_NAME
: sanitizeNoteType(mergedConfig.noteType, fallbackConfig.noteType),
noteType: sanitizeNoteType(mergedConfig.noteType, fallbackConfig.noteType),
};
}
@ -398,4 +416,24 @@ function sanitizeNoteType(value: string | undefined, fallback: string): string {
const trimmedValue = value.trim();
return trimmedValue.length > 0 ? trimmedValue : fallback;
}
}
function normalizeAnkiNoteTypeCache(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const noteTypeSet = new Set<string>();
for (const noteType of value) {
if (typeof noteType !== "string") {
continue;
}
const trimmedNoteType = noteType.trim();
if (trimmedNoteType.length > 0) {
noteTypeSet.add(trimmedNoteType);
}
}
return [...noteTypeSet].sort((left, right) => left.localeCompare(right));
}

View file

@ -1,4 +1,4 @@
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import { createNoteFieldMappingKey, type NoteModelFieldMapping, type NoteModelFieldMappingCardType } from "@/application/config/NoteModelFieldMapping";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import { PluginUserError } from "@/application/errors/PluginUserError";
import { isBasicLikeCardType, type CardType, type RenderedFields } from "@/domain/card/entities/RenderedFields";
@ -33,8 +33,8 @@ export class NoteFieldMappingService {
return this.mapCloze(card, noteModelDetails, mapping);
}
suggest(cardType: CardType, modelName: string, fieldNames: string[], loadedAt = Date.now()): NoteModelFieldMapping {
return isBasicLikeCardType(cardType)
suggest(cardType: NoteModelFieldMappingCardType, modelName: string, fieldNames: string[], loadedAt = Date.now()): NoteModelFieldMapping {
return isBasicLikeMappingCardType(cardType)
? {
cardType,
modelName,
@ -57,7 +57,7 @@ export class NoteFieldMappingService {
validateMapping(mapping: NoteModelFieldMapping, noteModelDetails: NoteModelDetails): void {
const availableFields = new Set(noteModelDetails.fieldNames);
if (isBasicLikeCardType(mapping.cardType)) {
if (isBasicLikeMappingCardType(mapping.cardType)) {
if (!mapping.titleField || !mapping.bodyField) {
throw new PluginUserError(getIncompleteSavedMappingKey(mapping.cardType), {
modelName: mapping.modelName,
@ -151,6 +151,10 @@ export class NoteFieldMappingService {
}
}
function isBasicLikeMappingCardType(cardType: NoteModelFieldMappingCardType): cardType is Extract<NoteModelFieldMappingCardType, "basic" | "qa-group" | "semantic-qa"> {
return cardType === "qa-group" || cardType === "basic" || cardType === "semantic-qa";
}
function getMissingSavedMappingKey(cardType: CardType): "errors.noteFieldMapping.missingSavedMapping.basic" | "errors.noteFieldMapping.missingSavedMapping.cloze" | "errors.noteFieldMapping.missingSavedMapping.semanticQa" {
if (cardType === "cloze") {
return "errors.noteFieldMapping.missingSavedMapping.cloze";
@ -161,7 +165,7 @@ function getMissingSavedMappingKey(cardType: CardType): "errors.noteFieldMapping
: "errors.noteFieldMapping.missingSavedMapping.basic";
}
function getIncompleteSavedMappingKey(cardType: CardType): "errors.noteFieldMapping.incompleteSavedMapping.basic" | "errors.noteFieldMapping.incompleteSavedMapping.cloze" | "errors.noteFieldMapping.incompleteSavedMapping.semanticQa" {
function getIncompleteSavedMappingKey(cardType: NoteModelFieldMappingCardType): "errors.noteFieldMapping.incompleteSavedMapping.basic" | "errors.noteFieldMapping.incompleteSavedMapping.cloze" | "errors.noteFieldMapping.incompleteSavedMapping.semanticQa" {
if (cardType === "cloze") {
return "errors.noteFieldMapping.incompleteSavedMapping.cloze";
}
@ -171,7 +175,7 @@ function getIncompleteSavedMappingKey(cardType: CardType): "errors.noteFieldMapp
: "errors.noteFieldMapping.incompleteSavedMapping.basic";
}
function getTitleBodyMustDifferKey(cardType: Extract<CardType, "basic" | "semantic-qa">): "errors.noteFieldMapping.titleBodyMustDiffer.basic" | "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" {
function getTitleBodyMustDifferKey(cardType: Extract<NoteModelFieldMappingCardType, "basic" | "qa-group" | "semantic-qa">): "errors.noteFieldMapping.titleBodyMustDiffer.basic" | "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" {
return cardType === "semantic-qa"
? "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa"
: "errors.noteFieldMapping.titleBodyMustDiffer.basic";
@ -179,4 +183,4 @@ function getTitleBodyMustDifferKey(cardType: Extract<CardType, "basic" | "semant
function findFieldName(fieldNames: string[], preferredNames: string[]): string | undefined {
return fieldNames.find((fieldName) => preferredNames.some((preferredName) => preferredName.toLowerCase() === fieldName.toLowerCase()));
}
}

View file

@ -215,13 +215,15 @@ export const en = {
cards: {
cardTypes: {
title: "Card types and basics",
desc: "Edit the recognition table directly. Changes save automatically and the table drives runtime recognition.",
desc: "Edit recognition rules directly. Changes save automatically.",
advancedHint: "Advanced: keep AnkiConnect URL here without promoting it to a separate status card.",
markerPlaceholder: "/",
fieldsUnavailable: "-- Read fields first --",
autoManagedField: "Managed automatically",
autoComposedAnswer: "Auto composed",
qaGroupManagedNoteType: "{{modelName}} (managed automatically)",
statusLabel: "Status: ",
loadedSummary: "Loaded {{noteTypeCount}} note templates and configured {{configuredCount}} selected card modes.",
loadedFieldsStatus: "Refreshed fields for {{count}} selected note types.",
failedLoad: "Failed to load note types or fields from Anki.",
failedSave: "Failed to save card type settings.",
@ -238,6 +240,15 @@ export const en = {
questionField: "Question field",
answerField: "Answer field",
},
labels: {
headingLevel: "Card marker:",
extraMarker: "Extra marker:",
noteType: "Anki note type:",
questionField: "Question field:",
answerField: "Answer field:",
mainField: "Main field:",
fields: "Fields:",
},
rows: {
basic: "Q&A (regular paragraph)",
qaGroup: "Q&A (nested list)",
@ -354,6 +365,8 @@ export const en = {
excludeFoldersArray: "Exclude folders must be an array.",
excludeFoldersStrings: "Exclude folders must only contain strings.",
ankiConnectUrlRequired: "AnkiConnect URL is required.",
ankiNoteTypeCacheArray: "Anki note type cache must be an array.",
ankiNoteTypeCacheStrings: "Anki note type cache can only contain strings.",
noteFieldMappingsObject: "Note field mappings must be an object.",
noteFieldMappingsCardType: "Note field mappings must use a supported card type.",
noteFieldMappingsModelName: "Note field mappings must include a model name.",
@ -398,4 +411,4 @@ export const en = {
},
} satisfies MessageTree;
export default en;
export default en;

View file

@ -213,13 +213,15 @@ export const zh = {
cards: {
cardTypes: {
title: "卡片类型及基础设置",
desc: "直接编辑识别表格。修改后自动保存,运行时识别也以这张表为准。",
desc: "直接编辑识别规则,修改后自动保存。",
advancedHint: "高级项:将 AnkiConnect URL 保留在这里,不单独做连接状态卡片。",
markerPlaceholder: "/",
fieldsUnavailable: "-- 请先读取字段 --",
autoManagedField: "自动维护",
autoComposedAnswer: "自动拼接",
qaGroupManagedNoteType: "{{modelName}}(自动维护)",
statusLabel: "状态:",
loadedSummary: "已获取 {{noteTypeCount}} 个笔记模板,已选择并配置 {{configuredCount}} 个卡片模式。",
loadedFieldsStatus: "已刷新 {{count}} 个当前所选模板的字段。",
failedLoad: "从 Anki 读取模板或字段失败。",
failedSave: "保存卡片类型设置失败。",
@ -236,6 +238,15 @@ export const zh = {
questionField: "问题字段",
answerField: "答案字段",
},
labels: {
headingLevel: "卡片标记:",
extraMarker: "额外标记:",
noteType: "Anki 笔记模板:",
questionField: "问题字段:",
answerField: "答案字段:",
mainField: "主字段:",
fields: "字段:",
},
rows: {
basic: "问答题(常规段落形式)",
qaGroup: "问答题(多级列表形式)",
@ -352,6 +363,8 @@ export const zh = {
excludeFoldersArray: "排除文件夹必须是数组。",
excludeFoldersStrings: "排除文件夹列表中只能包含字符串。",
ankiConnectUrlRequired: "AnkiConnect URL 不能为空。",
ankiNoteTypeCacheArray: "Anki 笔记模板缓存必须是数组。",
ankiNoteTypeCacheStrings: "Anki 笔记模板缓存中只能包含字符串。",
noteFieldMappingsObject: "字段映射必须是对象。",
noteFieldMappingsCardType: "字段映射必须使用受支持的卡片类型。",
noteFieldMappingsModelName: "字段映射必须包含模型名称。",
@ -396,4 +409,4 @@ export const zh = {
},
} satisfies typeof en;
export default zh;
export default zh;

View file

@ -276,7 +276,8 @@ import { AnkiHeadingSyncSettingTab } from "./PluginSettingTab";
type FakeContainer = InstanceType<typeof FakePluginSettingTab>["containerEl"];
type FakeSettingInstance = InstanceType<typeof FakeSetting>;
type FakeToggleInstance = { triggerChange(value: boolean): Promise<void> };
type QueryRoot = FakeContainer | HTMLElement;
type FakeElementInstance = InstanceType<typeof FakeElement>;
type QueryRoot = FakeContainer | FakeElementInstance | HTMLElement;
class FakePlugin {
public readonly app = {};
@ -441,6 +442,12 @@ function collectTexts(container: QueryRoot): string[] {
.filter((text): text is string => Boolean(text));
}
function collectOptionValues(selectEl: FakeElementInstance): string[] {
return selectEl.children
.filter((element) => element.tag === "option")
.map((element) => element.value);
}
function getEmptyCallCount(container: QueryRoot): number {
return asFakeContainer(container).emptyCallCount;
}
@ -475,22 +482,24 @@ describe("PluginSettingTab", () => {
expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none");
});
it("renders card 1 table with four rows and the required columns", () => {
it("renders card 1 as three readable card type blocks", () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
const headerTexts = findElements(tab.containerEl, (element) => element.tag === "th").map((element) => element.textContent);
expect(headerTexts).toEqual(["启用", "卡片类型", "卡片标记", "额外标记", "Anki 笔记模板", "问题字段", "答案字段"]);
expect(queryByDataset(tab.containerEl, "cardTypeList", "true")).toBeDefined();
expect(findElements(tab.containerEl, (element) => element.tag === "th")).toHaveLength(0);
expect(() => findSetting(tab.containerEl, "AnkiConnect URL")).toThrow("Setting not found");
const rowLabels = findElements(tab.containerEl, (element) => element.dataset.cardTypeConfig !== undefined).map((row) => row.children[1]?.textContent);
const rowLabels = findElements(tab.containerEl, (element) => element.dataset.cardTypeConfig !== undefined)
.map((row) => collectTexts(row).find((text) => text.includes("题")));
expect(rowLabels).toEqual([
"问答题(常规段落形式)",
"问答题(多级列表形式)",
"填空题",
"语义问答题",
]);
expect(() => queryByDataset(tab.containerEl, "cardTypeMarker", "semantic-qa")).toThrow("Element not found");
});
it("auto saves toggle, heading, note type and field mapping edits", async () => {
@ -516,6 +525,11 @@ describe("PluginSettingTab", () => {
await basicNoteType.trigger("change");
expect(plugin.settings.cardTypeConfigs.basic.noteType).toBe("Basic");
const qaGroupNoteType = queryByDataset(tab.containerEl, "cardTypeNoteType", "qa-group");
qaGroupNoteType.value = "Custom Basic";
await qaGroupNoteType.trigger("change");
expect(plugin.settings.cardTypeConfigs["qa-group"].noteType).toBe("Custom Basic");
await flushPromises();
const basicQuestionField = queryByDataset(tab.containerEl, "cardTypeQuestionField", "basic");
basicQuestionField.value = "Hint";
@ -528,6 +542,18 @@ describe("PluginSettingTab", () => {
titleField: "Hint",
bodyField: "Body",
}));
const qaGroupQuestionField = queryByDataset(tab.containerEl, "cardTypeQuestionField", "qa-group");
qaGroupQuestionField.value = "Title";
await qaGroupQuestionField.trigger("change");
const qaGroupAnswerField = queryByDataset(tab.containerEl, "cardTypeAnswerField", "qa-group");
qaGroupAnswerField.value = "Body";
await qaGroupAnswerField.trigger("change");
expect(plugin.settings.noteFieldMappings[createNoteFieldMappingKey("qa-group", plugin.settings.cardTypeConfigs["qa-group"].noteType)]).toEqual(expect.objectContaining({
titleField: "Title",
bodyField: "Body",
}));
});
it("debounces text input saves instead of saving every keystroke", async () => {
@ -537,10 +563,10 @@ describe("PluginSettingTab", () => {
tab.display();
const markerInput = queryByDataset(tab.containerEl, "cardTypeMarker", "semantic-qa");
markerInput.value = "#semantic-a";
const markerInput = queryByDataset(tab.containerEl, "cardTypeMarker", "cloze");
markerInput.value = "#cloze-a";
await markerInput.trigger("input");
markerInput.value = "#semantic-ab";
markerInput.value = "#cloze-ab";
await markerInput.trigger("input");
expect(plugin.updateCalls).toHaveLength(0);
@ -551,7 +577,7 @@ describe("PluginSettingTab", () => {
vi.advanceTimersByTime(1);
await flushPromises();
expect(plugin.settings.cardTypeConfigs["semantic-qa"].extraMarker).toBe("#semantic-ab");
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-ab");
expect(plugin.updateCalls).toHaveLength(1);
});
@ -597,6 +623,45 @@ describe("PluginSettingTab", () => {
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
});
it("persists loaded Anki note types and uses the cache before the next manual refresh", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click");
await flushPromises();
expect(plugin.settings.ankiNoteTypeCache).toEqual([
"Basic",
"Cloze",
"Custom Basic",
"Custom Cloze",
QA_GROUP_MODEL_NAME,
"Semantic QA",
]);
expect(plugin.updateCalls.some((call) => Array.isArray(call.ankiNoteTypeCache))).toBe(true);
const cachedPlugin = new FakePlugin();
cachedPlugin.settings = normalizePluginSettings({
...cachedPlugin.settings,
ankiNoteTypeCache: ["Basic", "Cached Basic", "Cached Cloze"],
cardTypeConfigs: {
...cachedPlugin.settings.cardTypeConfigs,
basic: {
...cachedPlugin.settings.cardTypeConfigs.basic,
noteType: "Cached Basic",
},
},
});
const cachedTab = new AnkiHeadingSyncSettingTab(cachedPlugin as never);
cachedTab.display();
const basicNoteTypeSelect = queryByDataset(cachedTab.containerEl, "cardTypeNoteType", "basic");
expect(collectOptionValues(basicNoteTypeSelect)).toEqual(["Basic", "Cached Basic", "Cached Cloze"]);
expect(cachedPlugin.listNoteModelsCalls).toBe(0);
});
it("folder tree expand and check refresh only the scope card", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
@ -637,4 +702,4 @@ describe("PluginSettingTab", () => {
expect(plugin.settings.fileDeckEnabled).toBe(true);
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
});
});
});

View file

@ -1,8 +1,6 @@
import { PluginSettingTab, Setting } from "obsidian";
import { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels";
import {
CARD_TYPE_CONFIG_IDS,
DEFAULT_OBSIDIAN_BACKLINK_LABEL,
normalizePluginSettings,
type CardAnswerCutoffMode,
@ -13,12 +11,11 @@ import {
type ScopeMode,
validatePluginSettings,
} from "@/application/config/PluginSettings";
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import { createNoteFieldMappingKey, type NoteModelFieldMapping, type NoteModelFieldMappingCardType } from "@/application/config/NoteModelFieldMapping";
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import { renderUserFacingMessage, toUserFacingMessage, type UserFacingMessage } from "@/application/errors/PluginUserError";
import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService";
import type { CardType } from "@/domain/card/entities/RenderedFields";
import { t } from "@/presentation/i18n";
import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin";
@ -28,6 +25,7 @@ const NOTE_TYPE_STATUS_IDLE: UserFacingMessage = { key: "settings.mapping.status
const FOLDER_TREE_STATUS_LOADING: UserFacingMessage = { key: "settings.scope.loading" };
const TEXT_SAVE_DEBOUNCE_MS = 500;
const SETTINGS_CARD_ORDER = ["card-types", "sync-content", "scope", "deck", "commands"] as const;
const VISIBLE_CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze"] as const;
type SettingsCardId = (typeof SETTINGS_CARD_ORDER)[number];
@ -166,6 +164,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
containerEl.createEl("p", { text: t("settings.cards.cardTypes.desc") });
const actionRow = containerEl.createDiv();
actionRow.style.display = "flex";
actionRow.style.flexDirection = "column";
actionRow.style.alignItems = "flex-start";
actionRow.style.gap = "8px";
const loadButton = actionRow.createEl("button", {
text: this.ankiConfigLoading
? t("settings.cards.cardTypes.loadAnki.loading")
@ -177,52 +179,38 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
loadButton.addEventListener("click", () => {
void this.loadAnkiCardTypeConfig();
});
actionRow.createEl("span", { text: renderUserFacingMessage(this.cardTypeStatus) });
actionRow.createEl("span", {
text: `${t("settings.cards.cardTypes.statusLabel")}${renderUserFacingMessage(this.cardTypeStatus)}`,
});
const table = containerEl.createEl("table");
table.dataset.cardTypeTable = "true";
const headerRow = table.createEl("tr");
for (const columnLabel of [
t("settings.cards.cardTypes.columns.enabled"),
t("settings.cards.cardTypes.columns.type"),
t("settings.cards.cardTypes.columns.headingLevel"),
t("settings.cards.cardTypes.columns.extraMarker"),
t("settings.cards.cardTypes.columns.noteType"),
t("settings.cards.cardTypes.columns.questionField"),
t("settings.cards.cardTypes.columns.answerField"),
]) {
headerRow.createEl("th", { text: columnLabel });
const cardTypeList = containerEl.createDiv();
cardTypeList.dataset.cardTypeList = "true";
cardTypeList.style.display = "flex";
cardTypeList.style.flexDirection = "column";
cardTypeList.style.gap = "14px";
for (const configId of VISIBLE_CARD_TYPE_CONFIG_IDS) {
this.renderCardTypeBlock(cardTypeList, configId);
}
for (const configId of CARD_TYPE_CONFIG_IDS) {
this.renderCardTypeRow(table, configId);
}
containerEl.createEl("p", { text: t("settings.cards.cardTypes.advancedHint") });
new Setting(containerEl)
.setName(t("settings.ankiConnectUrl.name"))
.setDesc(t("settings.ankiConnectUrl.desc"))
.addText((text) => {
text
.setPlaceholder(t("settings.ankiConnectUrl.placeholder"))
.setValue(this.getDraftValue("anki-connect-url", this.plugin.settings.ankiConnectUrl))
.onChange((value) => {
this.scheduleDebouncedTextSave("anki-connect-url", value, async (draftValue) => {
const nextValue = draftValue.trim() || this.plugin.settings.ankiConnectUrl;
await this.plugin.updateSettings({ ankiConnectUrl: nextValue });
this.textDraftValues.delete("anki-connect-url");
});
});
});
}
private renderCardTypeRow(tableEl: HTMLElement, configId: CardTypeConfigId): void {
private renderCardTypeBlock(containerEl: HTMLElement, configId: CardTypeConfigId): void {
const config = this.plugin.settings.cardTypeConfigs[configId];
const row = tableEl.createEl("tr");
row.dataset.cardTypeConfig = configId;
const blockEl = containerEl.createDiv();
blockEl.dataset.cardTypeConfig = configId;
blockEl.style.display = "flex";
blockEl.style.flexDirection = "column";
blockEl.style.gap = "8px";
blockEl.style.padding = "12px";
blockEl.style.border = "1px solid var(--background-modifier-border)";
blockEl.style.borderRadius = "8px";
const enabledCell = row.createEl("td");
const enabledCheckbox = enabledCell.createEl("input") as HTMLInputElement;
const headerRow = blockEl.createDiv();
headerRow.style.display = "flex";
headerRow.style.alignItems = "center";
headerRow.style.gap = "10px";
const enabledCheckbox = headerRow.createEl("input") as HTMLInputElement;
enabledCheckbox.type = "checkbox";
enabledCheckbox.checked = config.enabled;
enabledCheckbox.dataset.cardTypeEnabled = configId;
@ -230,11 +218,19 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
void this.saveCardTypeConfig(configId, { enabled: enabledCheckbox.checked });
});
row.createEl("td", { text: this.getCardTypeLabel(configId) });
const titleEl = headerRow.createEl("strong", { text: this.getCardTypeLabel(configId) });
titleEl.style.fontSize = "var(--font-ui-medium)";
const headingCell = row.createEl("td");
const headingSelect = this.createSelect(headingCell, `card-type-heading:${configId}`);
const recognitionRow = blockEl.createDiv();
recognitionRow.style.display = "flex";
recognitionRow.style.flexWrap = "wrap";
recognitionRow.style.alignItems = "center";
recognitionRow.style.gap = "10px 14px";
const headingGroup = this.createInlineControlGroup(recognitionRow, t("settings.cards.cardTypes.labels.headingLevel"));
const headingSelect = this.createSelect(headingGroup, `card-type-heading:${configId}`);
headingSelect.dataset.cardTypeHeading = configId;
headingSelect.style.width = "88px";
for (let level = 1; level <= 6; level += 1) {
this.appendOption(headingSelect, String(level), `H${level}`);
}
@ -243,11 +239,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
void this.saveCardTypeConfig(configId, { headingLevel: Number(headingSelect.value) });
});
const markerCell = row.createEl("td");
const markerInput = markerCell.createEl("input") as HTMLInputElement;
const markerGroup = this.createInlineControlGroup(recognitionRow, t("settings.cards.cardTypes.labels.extraMarker"));
const markerInput = markerGroup.createEl("input") as HTMLInputElement;
markerInput.type = "text";
markerInput.dataset.cardTypeMarker = configId;
markerInput.placeholder = t("settings.cards.cardTypes.markerPlaceholder");
markerInput.style.width = "220px";
markerInput.value = this.getDraftValue(`card-type-marker:${configId}`, config.extraMarker);
markerInput.addEventListener("input", () => {
this.scheduleDebouncedTextSave(`card-type-marker:${configId}`, markerInput.value, async (draftValue) => {
@ -258,59 +255,57 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
const noteTypeCell = row.createEl("td");
if (configId === "qa-group") {
const noteTypeText = noteTypeCell.createEl("span", {
text: t("settings.cards.cardTypes.qaGroupManagedNoteType", { modelName: QA_GROUP_MODEL_NAME }),
});
noteTypeText.dataset.cardTypeNoteType = configId;
} else {
const noteTypeSelect = this.createSelect(noteTypeCell, `card-type-note-type:${configId}`);
noteTypeSelect.dataset.cardTypeNoteType = configId;
for (const noteModel of this.getSelectableNoteModels(config.noteType)) {
this.appendOption(noteTypeSelect, noteModel, noteModel);
}
noteTypeSelect.value = config.noteType;
noteTypeSelect.disabled = this.ankiConfigLoading;
noteTypeSelect.addEventListener("change", () => {
void this.saveCardTypeConfig(configId, { noteType: noteTypeSelect.value });
});
}
const ankiRow = blockEl.createDiv();
ankiRow.style.display = "grid";
ankiRow.style.gridTemplateColumns = "max-content 260px max-content 160px max-content 160px";
ankiRow.style.alignItems = "center";
ankiRow.style.columnGap = "12px";
ankiRow.style.rowGap = "8px";
ankiRow.style.overflow = "hidden";
this.renderCardTypeFieldCells(row, configId);
const noteTypeGroup = this.createGridControlSlot(ankiRow, t("settings.cards.cardTypes.labels.noteType"));
const noteTypeSelect = this.createSelect(noteTypeGroup, `card-type-note-type:${configId}`);
noteTypeSelect.dataset.cardTypeNoteType = configId;
this.applyEllipsisWidth(noteTypeSelect, "260px");
for (const noteModel of this.getSelectableNoteModels(config.noteType)) {
this.appendOption(noteTypeSelect, noteModel, noteModel);
}
noteTypeSelect.value = config.noteType;
noteTypeSelect.disabled = this.ankiConfigLoading;
noteTypeSelect.addEventListener("change", () => {
void this.saveCardTypeConfig(configId, { noteType: noteTypeSelect.value });
});
this.renderCardTypeFieldControls(ankiRow, configId);
}
private renderCardTypeFieldCells(rowEl: HTMLElement, configId: CardTypeConfigId): void {
const questionCell = rowEl.createEl("td");
const answerCell = rowEl.createEl("td");
if (configId === "qa-group") {
questionCell.createEl("span", { text: t("settings.cards.cardTypes.autoManagedField") });
answerCell.createEl("span", { text: t("settings.cards.cardTypes.autoManagedField") });
return;
}
private renderCardTypeFieldControls(containerEl: HTMLElement, configId: CardTypeConfigId): void {
const mapping = this.getCurrentMappingForConfig(configId);
if (configId === "cloze") {
const mainFieldSelect = this.createFieldSelect(questionCell, `card-type-question-field:${configId}`);
const mainFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.mainField"));
const mainFieldSelect = this.createFieldSelect(mainFieldGroup, `card-type-question-field:${configId}`);
mainFieldSelect.dataset.cardTypeQuestionField = configId;
this.applyEllipsisWidth(mainFieldSelect, "180px");
this.populateFieldSelect(mainFieldSelect, mapping?.loadedFieldNames ?? [], mapping?.mainField);
mainFieldSelect.addEventListener("change", () => {
void this.saveFieldMapping(configId, { mainField: mainFieldSelect.value || undefined });
});
answerCell.createEl("span", { text: t("settings.cards.cardTypes.autoComposedAnswer") });
return;
}
const titleFieldSelect = this.createFieldSelect(questionCell, `card-type-question-field:${configId}`);
const titleFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.questionField"));
const titleFieldSelect = this.createFieldSelect(titleFieldGroup, `card-type-question-field:${configId}`);
titleFieldSelect.dataset.cardTypeQuestionField = configId;
this.applyEllipsisWidth(titleFieldSelect, "160px");
this.populateFieldSelect(titleFieldSelect, mapping?.loadedFieldNames ?? [], mapping?.titleField);
titleFieldSelect.addEventListener("change", () => {
void this.saveFieldMapping(configId, { titleField: titleFieldSelect.value || undefined });
});
const bodyFieldSelect = this.createFieldSelect(answerCell, `card-type-answer-field:${configId}`);
const bodyFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.answerField"));
const bodyFieldSelect = this.createFieldSelect(bodyFieldGroup, `card-type-answer-field:${configId}`);
bodyFieldSelect.dataset.cardTypeAnswerField = configId;
this.applyEllipsisWidth(bodyFieldSelect, "160px");
this.populateFieldSelect(bodyFieldSelect, mapping?.loadedFieldNames ?? [], mapping?.bodyField);
bodyFieldSelect.addEventListener("change", () => {
void this.saveFieldMapping(configId, { bodyField: bodyFieldSelect.value || undefined });
@ -606,7 +601,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
return true;
}
private async saveFieldMapping(configId: Exclude<CardTypeConfigId, "qa-group">, partialMapping: Partial<NoteModelFieldMapping>): Promise<void> {
private async saveFieldMapping(configId: CardTypeConfigId, partialMapping: Partial<NoteModelFieldMapping>): Promise<void> {
const mapping = this.getCurrentMappingForConfig(configId);
const runtimeCardType = this.getRuntimeCardType(configId);
const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType;
@ -637,20 +632,32 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
try {
const noteModels = await this.plugin.listNoteModels();
this.availableNoteModels.splice(0, this.availableNoteModels.length, ...[...noteModels].sort((left, right) => left.localeCompare(right)));
const selectedConfigs = CARD_TYPE_CONFIG_IDS.filter((configId) => configId !== "qa-group") as Array<Exclude<CardTypeConfigId, "qa-group">>;
await this.plugin.updateSettings({ ankiNoteTypeCache: this.getAvailableNoteModels() });
const selectedConfigs = [...VISIBLE_CARD_TYPE_CONFIG_IDS];
let configuredCount = 0;
for (const configId of selectedConfigs) {
const runtimeCardType = this.getRuntimeCardType(configId);
const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType;
const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName);
const modelDetails = await this.plugin.getNoteModelDetails(modelName);
let modelDetails: NoteModelDetails;
try {
modelDetails = await this.plugin.getNoteModelDetails(modelName);
} catch {
continue;
}
this.loadedModelDetails[mappingKey] = modelDetails;
this.seedDraftMapping(runtimeCardType, modelName, modelDetails);
configuredCount += 1;
}
this.cardTypeStatus = {
rawMessage: `${t("settings.mapping.status.loadedCount", { count: this.availableNoteModels.length })} ${t("settings.cards.cardTypes.loadedFieldsStatus", { count: selectedConfigs.length })}`,
key: "settings.cards.cardTypes.loadedSummary",
params: {
noteTypeCount: this.availableNoteModels.length,
configuredCount,
},
};
} catch (error) {
this.cardTypeStatus = toUserFacingMessage(error, "settings.cards.cardTypes.failedLoad");
@ -660,7 +667,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
}
private seedDraftMapping(runtimeCardType: CardType, modelName: string, modelDetails: NoteModelDetails): void {
private seedDraftMapping(runtimeCardType: NoteModelFieldMappingCardType, modelName: string, modelDetails: NoteModelDetails): void {
const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName);
const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey];
@ -677,7 +684,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
private getSelectableNoteModels(selectedModelName: string): string[] {
const noteModels = this.availableNoteModels.length > 0 ? [...this.availableNoteModels] : [];
const noteModels = this.availableNoteModels.length > 0
? this.getAvailableNoteModels()
: [...this.plugin.settings.ankiNoteTypeCache];
if (!noteModels.includes(selectedModelName)) {
noteModels.unshift(selectedModelName);
}
@ -685,7 +694,11 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
return noteModels;
}
private getCurrentMappingForConfig(configId: Exclude<CardTypeConfigId, "qa-group">): NoteModelFieldMapping | undefined {
private getAvailableNoteModels(): string[] {
return [...this.availableNoteModels];
}
private getCurrentMappingForConfig(configId: CardTypeConfigId): NoteModelFieldMapping | undefined {
const runtimeCardType = this.getRuntimeCardType(configId);
const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType;
const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName);
@ -711,7 +724,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
};
}
private createFallbackMapping(runtimeCardType: CardType, modelName: string): NoteModelFieldMapping {
private createFallbackMapping(runtimeCardType: NoteModelFieldMappingCardType, modelName: string): NoteModelFieldMapping {
const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName);
const modelDetails = this.loadedModelDetails[mappingKey];
@ -727,7 +740,11 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
};
}
private getRuntimeCardType(configId: Exclude<CardTypeConfigId, "qa-group">): CardType {
private getRuntimeCardType(configId: CardTypeConfigId): NoteModelFieldMapping["cardType"] {
if (configId === "qa-group") {
return "qa-group";
}
return configId === "cloze" ? "cloze" : configId === "semantic-qa" ? "semantic-qa" : "basic";
}
@ -825,6 +842,39 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
}
private createInlineControlGroup(containerEl: HTMLElement, label: string): HTMLElement {
const groupEl = containerEl.createEl("label");
groupEl.style.display = "inline-flex";
groupEl.style.alignItems = "center";
groupEl.style.gap = "6px";
groupEl.style.flexWrap = "nowrap";
const labelEl = groupEl.createEl("span", { text: label });
labelEl.style.whiteSpace = "nowrap";
return groupEl;
}
private createGridControlSlot(containerEl: HTMLElement, label: string): HTMLElement {
const labelEl = containerEl.createEl("span", { text: label });
labelEl.style.whiteSpace = "nowrap";
const slotEl = containerEl.createDiv();
slotEl.style.minWidth = "0";
slotEl.style.overflow = "hidden";
return slotEl;
}
private applyEllipsisWidth(element: HTMLElement, width: string): void {
element.style.width = width;
element.style.maxWidth = width;
element.style.minWidth = width;
element.style.overflow = "hidden";
element.style.textOverflow = "ellipsis";
element.style.whiteSpace = "nowrap";
}
private scheduleDebouncedTextSave(key: string, value: string, saveAction: (draftValue: string) => Promise<void>): void {
this.textDraftValues.set(key, value);
@ -958,4 +1008,4 @@ function getScopeModeTreeDescription(scopeMode: ScopeMode): string {
}
return t("settings.scope.treeDescription.exclude");
}
}