mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
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:
parent
06492948d9
commit
043a207149
12 changed files with 529 additions and 38 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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: "字段映射必须包含模型名称。",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue