perf(settings): batch cache Anki model fields

中文: 设置页手动读取 Anki 模板时改为批量读取字段并缓存到 data.json,重新打开设置页可直接使用缓存字段。

中文: 修复字段缓存 loadedAt 类型收窄和测试 fake 缺少批量字段接口导致的 production build 失败。

English: Batch-load Anki model fields for settings, cache them in data.json, and reuse cached fields when reopening the settings page.

English: Fix production build failures by narrowing cached loadedAt values and adding the batch field API to test fakes.
This commit is contained in:
Dusk 2026-04-24 18:38:10 +08:00
parent 06492948d9
commit 043a207149
12 changed files with 529 additions and 38 deletions

View file

@ -81,6 +81,7 @@ describe("PluginSettings", () => {
expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true);
expect(DEFAULT_SETTINGS.keepPureTagLinesInCardBody).toBe(true);
expect(DEFAULT_SETTINGS.ankiNoteTypeCache).toEqual([]);
expect(DEFAULT_SETTINGS.ankiModelFieldCache).toEqual({});
expect(DEFAULT_SETTINGS.cardTypeConfigs).toEqual({
basic: {
enabled: true,
@ -121,6 +122,59 @@ describe("PluginSettings", () => {
}).ankiNoteTypeCache).toEqual(["Basic", "Cloze"]);
});
it("normalizes cached Anki model fields on load and save paths", () => {
const settings = mergePluginSettings({
ankiModelFieldCache: {
" Custom Basic ": {
fieldNames: [" Front ", "", "Back", "Front"],
loadedAt: 123,
},
Cloze: {
fieldNames: [" Text ", "Extra", "Text"],
loadedAt: Number.NaN,
},
" ": {
fieldNames: ["Ignored"],
loadedAt: 999,
},
},
});
expect(settings.ankiModelFieldCache).toEqual({
Cloze: {
fieldNames: ["Text", "Extra"],
loadedAt: 0,
},
"Custom Basic": {
fieldNames: ["Front", "Back"],
loadedAt: 123,
},
});
expect(normalizePluginSettings({
...DEFAULT_SETTINGS,
ankiModelFieldCache: {
Basic: {
fieldNames: [" Title ", "Body", "Title"],
loadedAt: 456,
},
"Custom Cloze": {
fieldNames: "Text" as never,
loadedAt: "invalid" as never,
},
},
}).ankiModelFieldCache).toEqual({
Basic: {
fieldNames: ["Title", "Body"],
loadedAt: 456,
},
"Custom Cloze": {
fieldNames: [],
loadedAt: 0,
},
});
});
it("rejects invalid cached Anki note type lists", () => {
expectPluginUserError(() => {
validatePluginSettings({
@ -137,6 +191,60 @@ describe("PluginSettings", () => {
}, "errors.settings.ankiNoteTypeCacheStrings");
});
it("rejects invalid cached Anki model field maps", () => {
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
ankiModelFieldCache: "Basic" as never,
});
}, "errors.settings.ankiModelFieldCacheObject");
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
ankiModelFieldCache: {
Basic: [] as never,
},
});
}, "errors.settings.ankiModelFieldCacheEntryObject");
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
ankiModelFieldCache: {
Basic: {
fieldNames: "Front" as never,
loadedAt: 1,
},
},
});
}, "errors.settings.ankiModelFieldCacheFieldNamesArray");
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
ankiModelFieldCache: {
Basic: {
fieldNames: ["Front", 1] as never,
loadedAt: 1,
},
},
});
}, "errors.settings.ankiModelFieldCacheFieldNamesStrings");
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
ankiModelFieldCache: {
Basic: {
fieldNames: ["Front"],
loadedAt: "invalid" as never,
},
},
});
}, "errors.settings.ankiModelFieldCacheLoadedAt");
});
it("fills new backlink defaults when loading legacy snapshots", () => {
const settings = mergePluginSettings({
qaNoteType: "Legacy Basic",

View file

@ -19,6 +19,13 @@ export interface CardTypeConfig {
export type CardTypeConfigs = Record<CardTypeConfigId, CardTypeConfig>;
export interface AnkiModelFieldCacheEntry {
fieldNames: string[];
loadedAt: number;
}
export type AnkiModelFieldCache = Record<string, AnkiModelFieldCacheEntry>;
export const DEFAULT_OBSIDIAN_BACKLINK_LABEL = "Open in Obsidian";
const DEFAULT_BASIC_HEADING_LEVEL = 4;
@ -41,6 +48,7 @@ export interface PluginSettings {
cardTypeConfigs: CardTypeConfigs;
noteFieldMappings: Record<string, NoteModelFieldMapping>;
ankiNoteTypeCache: string[];
ankiModelFieldCache: AnkiModelFieldCache;
defaultDeck: string;
fileDeckEnabled: boolean;
fileDeckMarker: string;
@ -71,6 +79,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
cardTypeConfigs: createDefaultCardTypeConfigs(),
noteFieldMappings: {},
ankiNoteTypeCache: [],
ankiModelFieldCache: {},
defaultDeck: "Obsidian",
fileDeckEnabled: false,
fileDeckMarker: "TARGET DECK",
@ -117,6 +126,7 @@ export function normalizePluginSettings(settings: PluginSettings): PluginSetting
...legacySettings,
cardTypeConfigs,
ankiNoteTypeCache: normalizeAnkiNoteTypeCache(settings.ankiNoteTypeCache),
ankiModelFieldCache: normalizeAnkiModelFieldCache(settings.ankiModelFieldCache),
obsidianBacklinkLabel: normalizeObsidianBacklinkLabel(settings.obsidianBacklinkLabel),
};
}
@ -130,6 +140,7 @@ export function mergePluginSettings(settings?: Partial<PluginSettings> | null):
...partialSettings,
cardTypeConfigs,
noteFieldMappings: partialSettings.noteFieldMappings ?? DEFAULT_SETTINGS.noteFieldMappings,
ankiModelFieldCache: partialSettings.ankiModelFieldCache ?? DEFAULT_SETTINGS.ankiModelFieldCache,
includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
excludeFolders: partialSettings.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders,
});
@ -144,6 +155,7 @@ export function validatePluginSettings(settings: PluginSettings): void {
validateNoteFieldMappings(settings.noteFieldMappings);
validateAnkiNoteTypeCache(settings.ankiNoteTypeCache);
validateAnkiModelFieldCache(settings.ankiModelFieldCache);
if (!settings.defaultDeck.trim()) {
throw new PluginUserError("errors.settings.defaultDeckRequired");
@ -209,6 +221,32 @@ function validateAnkiNoteTypeCache(ankiNoteTypeCache: string[]): void {
}
}
function validateAnkiModelFieldCache(ankiModelFieldCache: AnkiModelFieldCache): void {
if (!ankiModelFieldCache || typeof ankiModelFieldCache !== "object" || Array.isArray(ankiModelFieldCache)) {
throw new PluginUserError("errors.settings.ankiModelFieldCacheObject");
}
for (const cacheEntry of Object.values(ankiModelFieldCache)) {
if (!cacheEntry || typeof cacheEntry !== "object" || Array.isArray(cacheEntry)) {
throw new PluginUserError("errors.settings.ankiModelFieldCacheEntryObject");
}
if (!Array.isArray(cacheEntry.fieldNames)) {
throw new PluginUserError("errors.settings.ankiModelFieldCacheFieldNamesArray");
}
for (const fieldName of cacheEntry.fieldNames) {
if (typeof fieldName !== "string") {
throw new PluginUserError("errors.settings.ankiModelFieldCacheFieldNamesStrings");
}
}
if (!Number.isFinite(cacheEntry.loadedAt)) {
throw new PluginUserError("errors.settings.ankiModelFieldCacheLoadedAt");
}
}
}
function validateFolderList(folderList: string[], label: "include" | "exclude"): void {
if (!Array.isArray(folderList)) {
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersArray" : "errors.settings.excludeFoldersArray");
@ -437,3 +475,51 @@ function normalizeAnkiNoteTypeCache(value: unknown): string[] {
return [...noteTypeSet].sort((left, right) => left.localeCompare(right));
}
function normalizeAnkiModelFieldCache(value: unknown): AnkiModelFieldCache {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const normalizedEntries = Object.entries(value).flatMap(([modelName, cacheEntry]) => {
const trimmedModelName = modelName.trim();
if (trimmedModelName.length === 0 || !cacheEntry || typeof cacheEntry !== "object" || Array.isArray(cacheEntry)) {
return [];
}
const entry = cacheEntry as Partial<AnkiModelFieldCacheEntry>;
const loadedAt = typeof entry.loadedAt === "number" && Number.isFinite(entry.loadedAt) ? entry.loadedAt : 0;
return [[trimmedModelName, {
fieldNames: normalizeModelFieldNames(entry.fieldNames),
loadedAt,
}] as const];
});
normalizedEntries.sort(([left], [right]) => left.localeCompare(right));
return Object.fromEntries(normalizedEntries);
}
function normalizeModelFieldNames(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const normalizedFieldNames: string[] = [];
const seenFieldNames = new Set<string>();
for (const fieldName of value) {
if (typeof fieldName !== "string") {
continue;
}
const trimmedFieldName = fieldName.trim();
if (trimmedFieldName.length === 0 || seenFieldNames.has(trimmedFieldName)) {
continue;
}
seenFieldNames.add(trimmedFieldName);
normalizedFieldNames.push(trimmedFieldName);
}
return normalizedFieldNames;
}

View file

@ -78,6 +78,7 @@ export interface AnkiGateway {
export interface AnkiGroupGateway extends AnkiGateway {
getModelFieldNames(modelName: string): Promise<string[]>;
getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>>;
getModelTemplates(modelName: string): Promise<Record<string, AnkiModelTemplate>>;
getModelStyling(modelName: string): Promise<string>;
createModel(input: CreateAnkiModelInput): Promise<void>;

View file

@ -34,6 +34,74 @@ describe("AnkiConnectGateway", () => {
});
});
it("loads model field names in one multi request and skips per-item errors", async () => {
requestUrlMock.mockResolvedValue({
json: {
error: null,
result: [
{
error: null,
result: ["Front", "Back"],
},
{
error: "missing model",
result: null,
},
["Text", "Extra"],
],
},
});
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
const fieldNamesByModelName = await gateway.getModelFieldNamesByModelNames(["Basic", "Missing", "Cloze"]);
expect(fieldNamesByModelName).toEqual({
Basic: ["Front", "Back"],
Cloze: ["Text", "Extra"],
});
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
action: "multi",
version: 6,
params: {
actions: [
{
action: "modelFieldNames",
params: {
modelName: "Basic",
},
},
{
action: "modelFieldNames",
params: {
modelName: "Missing",
},
},
{
action: "modelFieldNames",
params: {
modelName: "Cloze",
},
},
],
},
});
});
it("bubbles top-level multi errors when loading model field names", async () => {
requestUrlMock.mockResolvedValue({
json: {
error: "AnkiConnect unavailable",
result: null,
},
});
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
await expect(gateway.getModelFieldNamesByModelNames(["Basic"]))
.rejects
.toThrow("AnkiConnect unavailable");
});
it("loads deck names from AnkiConnect", async () => {
requestUrlMock.mockResolvedValue({
json: {

View file

@ -9,6 +9,11 @@ interface AnkiResponse<T> {
result: T;
}
interface AnkiMultiActionResponse<T> {
error: string | null;
result: T;
}
interface NoteInfo {
cards: number[];
fields?: Record<string, { value?: string }>;
@ -71,6 +76,32 @@ export class AnkiConnectGateway implements AnkiGroupGateway {
return this.invoke<string[]>("modelFieldNames", { modelName });
}
async getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
const uniqueModelNames = Array.from(new Set(modelNames
.map((modelName) => modelName.trim())
.filter((modelName) => modelName.length > 0)));
if (uniqueModelNames.length === 0) {
return {};
}
const results = await this.invoke<Array<AnkiMultiActionResponse<string[]> | string[]>>("multi", {
actions: uniqueModelNames.map((modelName) => ({
action: "modelFieldNames",
params: { modelName },
})),
});
return uniqueModelNames.reduce<Record<string, string[]>>((fieldNamesByModelName, modelName, index) => {
const fieldNames = extractMultiModelFieldNames(results[index]);
if (fieldNames) {
fieldNamesByModelName[modelName] = fieldNames;
}
return fieldNamesByModelName;
}, {});
}
async getModelTemplates(modelName: string): Promise<Record<string, AnkiModelTemplate>> {
const templates = await this.invoke<ModelTemplates>("modelTemplates", { modelName });
return Object.fromEntries(Object.entries(templates).map(([templateName, template]) => [templateName, {
@ -404,6 +435,27 @@ export class AnkiConnectGateway implements AnkiGroupGateway {
}
}
function extractMultiModelFieldNames(value: unknown): string[] | undefined {
if (Array.isArray(value)) {
return value.filter((fieldName): fieldName is string => typeof fieldName === "string");
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const multiActionResponse = value as Partial<AnkiMultiActionResponse<unknown>>;
if (multiActionResponse.error) {
return undefined;
}
if (!Array.isArray(multiActionResponse.result)) {
return undefined;
}
return multiActionResponse.result.filter((fieldName): fieldName is string => typeof fieldName === "string");
}
function extractDeckNoteCount(rawStats: unknown, deckId: number | undefined): number | undefined {
if (!rawStats || typeof rawStats !== "object") {
return undefined;

View file

@ -47,6 +47,7 @@ describe("DataJsonPluginConfigRepository", () => {
expect(settings.semanticQaNoteType).toBe("Semantic QA");
expect(settings.obsidianBacklinkLabel).toBe("Open in Obsidian");
expect(settings.obsidianBacklinkPlacement).toBe("answer-last-line");
expect(settings.ankiModelFieldCache).toEqual({});
});
it("normalizes blank backlink labels on load and save", async () => {
@ -103,6 +104,38 @@ describe("DataJsonPluginConfigRepository", () => {
});
});
it("persists cached Anki model fields across save and reload", async () => {
const store = new InMemoryPluginDataStore();
const repository = new DataJsonPluginConfigRepository(store);
await repository.save({
...DEFAULT_SETTINGS,
ankiModelFieldCache: {
Basic: {
fieldNames: ["Front", "Back"],
loadedAt: 123,
},
Cloze: {
fieldNames: ["Text", "Extra"],
loadedAt: 456,
},
},
});
const reloaded = await repository.load();
expect(reloaded.ankiModelFieldCache).toEqual({
Basic: {
fieldNames: ["Front", "Back"],
loadedAt: 123,
},
Cloze: {
fieldNames: ["Text", "Extra"],
loadedAt: 456,
},
});
});
it("persists semantic QA settings and mappings across save and reload", async () => {
const store = new InMemoryPluginDataStore();
const repository = new DataJsonPluginConfigRepository(store);

View file

@ -86,6 +86,10 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
return this.ankiGateway.listNoteModels();
}
async getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
return this.ankiGateway.getModelFieldNamesByModelNames(modelNames);
}
async getNoteModelDetails(modelName: string): Promise<NoteModelDetails> {
return this.ankiGateway.getModelDetails(modelName);
}

View file

@ -367,6 +367,11 @@ export const en = {
ankiConnectUrlRequired: "AnkiConnect URL is required.",
ankiNoteTypeCacheArray: "Anki note type cache must be an array.",
ankiNoteTypeCacheStrings: "Anki note type cache can only contain strings.",
ankiModelFieldCacheObject: "Anki model field cache must be an object.",
ankiModelFieldCacheEntryObject: "Each Anki model field cache entry must be an object.",
ankiModelFieldCacheFieldNamesArray: "Anki model field cache fieldNames must be an array.",
ankiModelFieldCacheFieldNamesStrings: "Anki model field cache fieldNames can only contain strings.",
ankiModelFieldCacheLoadedAt: "Anki model field cache loadedAt must be a finite number.",
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.",

View file

@ -365,6 +365,11 @@ export const zh = {
ankiConnectUrlRequired: "AnkiConnect URL 不能为空。",
ankiNoteTypeCacheArray: "Anki 笔记模板缓存必须是数组。",
ankiNoteTypeCacheStrings: "Anki 笔记模板缓存中只能包含字符串。",
ankiModelFieldCacheObject: "Anki 模板字段缓存必须是对象。",
ankiModelFieldCacheEntryObject: "每个 Anki 模板字段缓存项都必须是对象。",
ankiModelFieldCacheFieldNamesArray: "Anki 模板字段缓存中的 fieldNames 必须是数组。",
ankiModelFieldCacheFieldNamesStrings: "Anki 模板字段缓存中的 fieldNames 只能包含字符串。",
ankiModelFieldCacheLoadedAt: "Anki 模板字段缓存中的 loadedAt 必须是有限数字。",
noteFieldMappingsObject: "字段映射必须是对象。",
noteFieldMappingsCardType: "字段映射必须使用受支持的卡片类型。",
noteFieldMappingsModelName: "字段映射必须包含模型名称。",

View file

@ -283,6 +283,8 @@ class FakePlugin {
public readonly app = {};
public readonly updateCalls: Array<Record<string, unknown>> = [];
public listNoteModelsCalls = 0;
public getModelFieldNamesByModelNamesCalls = 0;
public getNoteModelDetailsCalls = 0;
public listFolderTreeCalls = 0;
public insertDeckTemplateCalls = 0;
public settings = normalizePluginSettings({
@ -336,24 +338,20 @@ class FakePlugin {
return ["Basic", "Custom Basic", "Cloze", "Custom Cloze", "Semantic QA", QA_GROUP_MODEL_NAME];
}
async getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
this.getModelFieldNamesByModelNamesCalls += 1;
return modelNames.reduce<Record<string, string[]>>((fieldNamesByModelName, modelName) => {
fieldNamesByModelName[modelName] = this.getFieldNamesForModel(modelName);
return fieldNamesByModelName;
}, {});
}
async getNoteModelDetails(modelName: string): Promise<{ fieldNames: string[]; isCloze: boolean }> {
if (modelName.includes("Cloze")) {
return {
fieldNames: ["Text", "Extra", "Hint"],
isCloze: true,
};
}
if (modelName === "Semantic QA") {
return {
fieldNames: ["Title", "Body", "Source"],
isCloze: false,
};
}
this.getNoteModelDetailsCalls += 1;
return {
fieldNames: ["Title", "Body", "Hint"],
isCloze: false,
fieldNames: this.getFieldNamesForModel(modelName),
isCloze: modelName.includes("Cloze"),
};
}
@ -365,6 +363,22 @@ class FakePlugin {
async insertDeckTemplateToCurrentFile(): Promise<void> {
this.insertDeckTemplateCalls += 1;
}
private getFieldNamesForModel(modelName: string): string[] {
if (modelName.includes("Cloze")) {
return ["Text", "Extra", "Hint"];
}
if (modelName === "Semantic QA") {
return ["Title", "Body", "Source"];
}
if (modelName === "Basic") {
return ["Front", "Back", "Extra"];
}
return ["Title", "Body", "Hint"];
}
}
function asFakeContainer(container: QueryRoot): FakeContainer {
@ -620,7 +634,10 @@ describe("PluginSettingTab", () => {
await flushPromises();
expect(plugin.listNoteModelsCalls).toBe(1);
expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(1);
expect(plugin.getNoteModelDetailsCalls).toBe(0);
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
expect(collectTexts(tab.containerEl).some((text) => text.includes("已获取 6 个笔记模板,已选择并配置 3 个卡片模式。"))).toBe(true);
});
it("persists loaded Anki note types and uses the cache before the next manual refresh", async () => {
@ -640,11 +657,32 @@ describe("PluginSettingTab", () => {
"Semantic QA",
]);
expect(plugin.updateCalls.some((call) => Array.isArray(call.ankiNoteTypeCache))).toBe(true);
expect(plugin.settings.ankiModelFieldCache).toEqual(expect.objectContaining({
Basic: {
fieldNames: ["Front", "Back", "Extra"],
loadedAt: expect.any(Number),
},
"Custom Basic": {
fieldNames: ["Title", "Body", "Hint"],
loadedAt: expect.any(Number),
},
Cloze: {
fieldNames: ["Text", "Extra", "Hint"],
loadedAt: expect.any(Number),
},
}));
expect(plugin.updateCalls.some((call) => typeof call.ankiModelFieldCache === "object" && call.ankiModelFieldCache !== null)).toBe(true);
const cachedPlugin = new FakePlugin();
cachedPlugin.settings = normalizePluginSettings({
...cachedPlugin.settings,
ankiNoteTypeCache: ["Basic", "Cached Basic", "Cached Cloze"],
ankiModelFieldCache: {
"Cached Basic": {
fieldNames: ["Front", "Back", "Hint"],
loadedAt: 123,
},
},
cardTypeConfigs: {
...cachedPlugin.settings.cardTypeConfigs,
basic: {
@ -659,9 +697,52 @@ describe("PluginSettingTab", () => {
const basicNoteTypeSelect = queryByDataset(cachedTab.containerEl, "cardTypeNoteType", "basic");
expect(collectOptionValues(basicNoteTypeSelect)).toEqual(["Basic", "Cached Basic", "Cached Cloze"]);
const basicQuestionFieldSelect = queryByDataset(cachedTab.containerEl, "cardTypeQuestionField", "basic");
const basicAnswerFieldSelect = queryByDataset(cachedTab.containerEl, "cardTypeAnswerField", "basic");
expect(collectOptionValues(basicQuestionFieldSelect)).toEqual(["", "Front", "Back", "Hint"]);
expect(collectOptionValues(basicAnswerFieldSelect)).toEqual(["", "Front", "Back", "Hint"]);
expect(cachedPlugin.listNoteModelsCalls).toBe(0);
});
it("switching note type immediately reuses cached field names", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
ankiNoteTypeCache: ["Basic", "Custom Basic"],
ankiModelFieldCache: {
Basic: {
fieldNames: ["Front", "Back", "Extra"],
loadedAt: 100,
},
"Custom Basic": {
fieldNames: ["Title", "Body", "Hint"],
loadedAt: 200,
},
},
cardTypeConfigs: {
...plugin.settings.cardTypeConfigs,
basic: {
...plugin.settings.cardTypeConfigs.basic,
noteType: "Custom Basic",
},
},
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
const basicNoteTypeSelect = queryByDataset(tab.containerEl, "cardTypeNoteType", "basic");
basicNoteTypeSelect.value = "Basic";
await basicNoteTypeSelect.trigger("change");
await flushPromises();
const basicQuestionFieldSelect = queryByDataset(tab.containerEl, "cardTypeQuestionField", "basic");
const basicAnswerFieldSelect = queryByDataset(tab.containerEl, "cardTypeAnswerField", "basic");
expect(collectOptionValues(basicQuestionFieldSelect)).toEqual(["", "Front", "Back", "Extra"]);
expect(collectOptionValues(basicAnswerFieldSelect)).toEqual(["", "Front", "Back", "Extra"]);
expect(plugin.getNoteModelDetailsCalls).toBe(0);
});
it("folder tree expand and check refresh only the scope card", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({

View file

@ -161,6 +161,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
private renderCardTypesCard(containerEl: HTMLElement): void {
this.hydrateVisibleCardTypeCaches();
containerEl.createEl("p", { text: t("settings.cards.cardTypes.desc") });
const actionRow = containerEl.createDiv();
@ -631,26 +632,26 @@ 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)));
await this.plugin.updateSettings({ ankiNoteTypeCache: this.getAvailableNoteModels() });
const selectedConfigs = [...VISIBLE_CARD_TYPE_CONFIG_IDS];
let configuredCount = 0;
const nextAvailableNoteModels = Array.from(new Set(noteModels)).sort((left, right) => left.localeCompare(right));
this.availableNoteModels.splice(0, this.availableNoteModels.length, ...nextAvailableNoteModels);
for (const configId of selectedConfigs) {
const runtimeCardType = this.getRuntimeCardType(configId);
const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType;
const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName);
let modelDetails: NoteModelDetails;
try {
modelDetails = await this.plugin.getNoteModelDetails(modelName);
} catch {
continue;
}
const fieldNamesByModelName = await this.plugin.getModelFieldNamesByModelNames(nextAvailableNoteModels);
const loadedAt = Date.now();
const ankiModelFieldCache = Object.entries(fieldNamesByModelName).reduce<AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"]>((cache, [modelName, fieldNames]) => {
cache[modelName] = {
fieldNames: [...fieldNames],
loadedAt,
};
return cache;
}, {});
this.loadedModelDetails[mappingKey] = modelDetails;
this.seedDraftMapping(runtimeCardType, modelName, modelDetails);
configuredCount += 1;
}
await this.plugin.updateSettings({
ankiNoteTypeCache: this.getAvailableNoteModels(),
ankiModelFieldCache,
});
this.hydrateVisibleCardTypeCaches(ankiModelFieldCache);
const configuredCount = this.countConfiguredCardTypes(ankiModelFieldCache);
this.cardTypeStatus = {
key: "settings.cards.cardTypes.loadedSummary",
@ -667,7 +668,47 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
}
private seedDraftMapping(runtimeCardType: NoteModelFieldMappingCardType, modelName: string, modelDetails: NoteModelDetails): void {
private hydrateVisibleCardTypeCaches(
ankiModelFieldCache: AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"] = this.plugin.settings.ankiModelFieldCache,
): void {
for (const configId of VISIBLE_CARD_TYPE_CONFIG_IDS) {
this.hydrateCardTypeCache(configId, ankiModelFieldCache);
}
}
private hydrateCardTypeCache(
configId: CardTypeConfigId,
ankiModelFieldCache: AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"],
): void {
const runtimeCardType = this.getRuntimeCardType(configId);
const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType;
const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName);
const cacheEntry = ankiModelFieldCache[modelName];
if (!cacheEntry) {
return;
}
const modelDetails = this.createCachedModelDetails(modelName, cacheEntry.fieldNames);
this.loadedModelDetails[mappingKey] = modelDetails;
this.seedDraftMapping(runtimeCardType, modelName, modelDetails, cacheEntry.loadedAt);
}
private createCachedModelDetails(modelName: string, fieldNames: string[]): NoteModelDetails {
return {
fieldNames: [...fieldNames],
isCloze: modelName.toLowerCase().includes("cloze"),
};
}
private countConfiguredCardTypes(ankiModelFieldCache: AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"]): number {
return VISIBLE_CARD_TYPE_CONFIG_IDS.reduce((configuredCount, configId) => {
const selectedModelName = this.plugin.settings.cardTypeConfigs[configId].noteType;
return configuredCount + (ankiModelFieldCache[selectedModelName] ? 1 : 0);
}, 0);
}
private seedDraftMapping(runtimeCardType: NoteModelFieldMappingCardType, modelName: string, modelDetails: NoteModelDetails, loadedAt = Date.now()): void {
const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName);
const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey];
@ -675,12 +716,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.draftMappings[mappingKey] = {
...currentMapping,
loadedFieldNames: [...modelDetails.fieldNames],
loadedAt: Date.now(),
loadedAt,
};
return;
}
this.draftMappings[mappingKey] = this.noteFieldMappingService.suggest(runtimeCardType, modelName, modelDetails.fieldNames);
this.draftMappings[mappingKey] = this.noteFieldMappingService.suggest(runtimeCardType, modelName, modelDetails.fieldNames, loadedAt);
}
private getSelectableNoteModels(selectedModelName: string): string[] {

View file

@ -187,6 +187,13 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
return this.modelDetailsByName[modelName]?.fieldNames ?? [];
}
async getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
return Object.fromEntries(await Promise.all(modelNames.map(async (modelName) => [
modelName,
await this.getModelFieldNames(modelName),
])));
}
async getModelTemplates(modelName: string): Promise<Record<string, AnkiModelTemplate>> {
return this.modelTemplatesByName[modelName] ?? {};
}
@ -391,4 +398,4 @@ function buildFolderTree(filePaths: string[]): FolderTreeNode[] {
}
return root.children;
}
}