panatgithub_AnkiHeadingSync/src/application/config/PluginSettings.ts
2026-04-27 19:54:49 +08:00

853 lines
29 KiB
TypeScript

import { createNoteFieldMappingKey, isQaGroupFieldMapping, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import { PluginUserError } from "@/application/errors/PluginUserError";
import { QaGroupFieldMappingService } from "@/application/services/QaGroupFieldMappingService";
import type { TranslationKey } from "@/presentation/i18n";
export type ScopeMode = "all" | "include" | "exclude";
export type FileDeckInsertLocation = "yaml" | "body";
export type FolderDeckMode = "off" | "folder" | "folder-and-file";
export type CardAnswerCutoffMode = "heading-block" | "double-blank-lines";
export type ObsidianBacklinkPlacement = "question-last-line" | "answer-first-line" | "answer-last-line";
export const CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze"] as const;
export type CardTypeConfigId = (typeof CARD_TYPE_CONFIG_IDS)[number];
export interface CardTypeConfig {
enabled: boolean;
headingLevel: number;
extraMarker: string;
noteType: string;
}
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;
const LEGACY_DEFAULT_CLOZE_HEADING_LEVEL = 5;
const DEFAULT_CLOZE_HEADING_LEVEL = 4;
const DEFAULT_BASIC_NOTE_TYPE = "";
const DEFAULT_CLOZE_NOTE_TYPE = "";
const LEGACY_DEFAULT_CLOZE_MARKER = "";
const DEFAULT_CLOZE_MARKER = "#anki-cloze";
const DEFAULT_QA_GROUP_MARKER = "#anki-list";
const qaGroupFieldMappingService = new QaGroupFieldMappingService();
export interface PluginSettings {
qaHeadingLevel: number;
clozeHeadingLevel: number;
cardAnswerCutoffMode: CardAnswerCutoffMode;
qaGroupMarker: string;
qaNoteType: string;
clozeNoteType: string;
cardTypeConfigs: CardTypeConfigs;
noteFieldMappings: Record<string, NoteModelFieldMapping>;
ankiNoteTypeCache: string[];
ankiModelFieldCache: AnkiModelFieldCache;
defaultDeck: string;
fileDeckEnabled: boolean;
fileDeckMarker: string;
fileDeckTemplate: string;
fileDeckInsertLocation: FileDeckInsertLocation;
folderDeckMode: FolderDeckMode;
scopeMode: ScopeMode;
includeFolders: string[];
excludeFolders: string[];
addObsidianBacklink: boolean;
obsidianBacklinkLabel: string;
obsidianBacklinkPlacement: ObsidianBacklinkPlacement;
convertHighlightsToCloze: boolean;
syncObsidianTagsToAnki: boolean;
keepPureTagLinesInCardBody: boolean;
ankiConnectUrl: string;
}
export const DEFAULT_SETTINGS: PluginSettings = {
qaHeadingLevel: DEFAULT_BASIC_HEADING_LEVEL,
clozeHeadingLevel: DEFAULT_CLOZE_HEADING_LEVEL,
cardAnswerCutoffMode: "heading-block",
qaGroupMarker: DEFAULT_QA_GROUP_MARKER,
qaNoteType: DEFAULT_BASIC_NOTE_TYPE,
clozeNoteType: DEFAULT_CLOZE_NOTE_TYPE,
cardTypeConfigs: createDefaultCardTypeConfigs(),
noteFieldMappings: {},
ankiNoteTypeCache: [],
ankiModelFieldCache: {},
defaultDeck: "Obsidian",
fileDeckEnabled: false,
fileDeckMarker: "TARGET DECK",
fileDeckTemplate: "obsidian::filename",
fileDeckInsertLocation: "body",
folderDeckMode: "folder-and-file",
scopeMode: "include",
includeFolders: [],
excludeFolders: [],
addObsidianBacklink: true,
obsidianBacklinkLabel: DEFAULT_OBSIDIAN_BACKLINK_LABEL,
obsidianBacklinkPlacement: "answer-last-line",
convertHighlightsToCloze: true,
syncObsidianTagsToAnki: true,
keepPureTagLinesInCardBody: true,
ankiConnectUrl: "http://127.0.0.1:8765",
};
export const HASHTAG_MARKER_REGEXP = /^#[^\s#]+$/;
export function isValidHashtagMarker(marker: string): boolean {
return HASHTAG_MARKER_REGEXP.test(marker);
}
export function normalizeObsidianBacklinkLabel(value: string | null | undefined): string {
if (typeof value !== "string") {
return DEFAULT_OBSIDIAN_BACKLINK_LABEL;
}
const trimmedValue = value.trim();
return trimmedValue.length > 0 ? trimmedValue : DEFAULT_OBSIDIAN_BACKLINK_LABEL;
}
export function normalizePluginSettings(settings: PluginSettings): PluginSettings {
const cardTypeConfigs = mergeCardTypeConfigs(settings.cardTypeConfigs, settings);
const legacySettings = deriveLegacySettings(cardTypeConfigs);
const normalizedSettings = {
...settings,
...legacySettings,
cardTypeConfigs,
noteFieldMappings: normalizeNoteFieldMappings(settings.noteFieldMappings),
ankiNoteTypeCache: normalizeAnkiNoteTypeCache(settings.ankiNoteTypeCache),
ankiModelFieldCache: normalizeAnkiModelFieldCache(settings.ankiModelFieldCache),
obsidianBacklinkLabel: normalizeObsidianBacklinkLabel(settings.obsidianBacklinkLabel),
};
return {
...normalizedSettings,
noteFieldMappings: hydrateMissingNoteFieldMappingsFromCache(normalizedSettings),
};
}
export function mergePluginSettings(settings?: Partial<PluginSettings> | null): PluginSettings {
const partialSettings = settings ?? {};
const cardTypeConfigs = mergeCardTypeConfigs(partialSettings.cardTypeConfigs, partialSettings);
return normalizePluginSettings({
...DEFAULT_SETTINGS,
...partialSettings,
cardTypeConfigs,
noteFieldMappings: normalizeNoteFieldMappings(partialSettings.noteFieldMappings ?? DEFAULT_SETTINGS.noteFieldMappings),
ankiModelFieldCache: partialSettings.ankiModelFieldCache ?? DEFAULT_SETTINGS.ankiModelFieldCache,
includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
excludeFolders: partialSettings.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders,
});
}
export function validatePluginSettings(settings: PluginSettings): void {
validateCardTypeConfigs(settings.cardTypeConfigs);
if (settings.cardAnswerCutoffMode !== "heading-block" && settings.cardAnswerCutoffMode !== "double-blank-lines") {
throw new PluginUserError("errors.settings.cardAnswerCutoffModeInvalid");
}
validateNoteFieldMappings(settings.noteFieldMappings);
validateAnkiNoteTypeCache(settings.ankiNoteTypeCache);
validateAnkiModelFieldCache(settings.ankiModelFieldCache);
if (!settings.defaultDeck.trim()) {
throw new PluginUserError("errors.settings.defaultDeckRequired");
}
if (typeof settings.fileDeckEnabled !== "boolean") {
throw new PluginUserError("errors.settings.fileDeckEnabledBoolean");
}
if (typeof settings.syncObsidianTagsToAnki !== "boolean") {
throw new PluginUserError("errors.settings.syncObsidianTagsToAnkiBoolean");
}
if (typeof settings.keepPureTagLinesInCardBody !== "boolean") {
throw new PluginUserError("errors.settings.keepPureTagLinesInCardBodyBoolean");
}
if (typeof settings.fileDeckMarker !== "string") {
throw new PluginUserError("errors.settings.fileDeckMarkerString");
}
if (typeof settings.fileDeckTemplate !== "string") {
throw new PluginUserError("errors.settings.fileDeckTemplateString");
}
if (settings.fileDeckInsertLocation !== "yaml" && settings.fileDeckInsertLocation !== "body") {
throw new PluginUserError("errors.settings.fileDeckInsertLocationInvalid");
}
if (settings.folderDeckMode !== "off" && settings.folderDeckMode !== "folder" && settings.folderDeckMode !== "folder-and-file") {
throw new PluginUserError("errors.settings.folderDeckModeInvalid");
}
if (settings.scopeMode !== "all" && settings.scopeMode !== "include" && settings.scopeMode !== "exclude") {
throw new PluginUserError("errors.settings.scopeModeInvalid");
}
if (
settings.obsidianBacklinkPlacement !== "question-last-line"
&& settings.obsidianBacklinkPlacement !== "answer-first-line"
&& settings.obsidianBacklinkPlacement !== "answer-last-line"
) {
throw new PluginUserError("errors.settings.obsidianBacklinkPlacementInvalid");
}
validateFolderList(settings.includeFolders, "include");
validateFolderList(settings.excludeFolders, "exclude");
if (!settings.ankiConnectUrl.trim()) {
throw new PluginUserError("errors.settings.ankiConnectUrlRequired");
}
}
export function isRunScopeConfigured(scopeMode: ScopeMode, includeFolders: string[]): boolean {
if (scopeMode !== "include") {
return true;
}
return includeFolders.some((folder) => folder.trim().length > 0);
}
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 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");
}
for (const folder of folderList) {
if (typeof folder !== "string") {
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersStrings" : "errors.settings.excludeFoldersStrings");
}
}
}
export function validateCardTypeConfigs(cardTypeConfigs: CardTypeConfigs): void {
if (!cardTypeConfigs || typeof cardTypeConfigs !== "object" || Array.isArray(cardTypeConfigs)) {
throw new PluginUserError("errors.settings.cardTypeConfigsObject");
}
const emptyDefaultsByHeading = new Map<number, CardTypeConfigId[]>();
for (const configId of CARD_TYPE_CONFIG_IDS) {
const config = (cardTypeConfigs as Partial<CardTypeConfigs>)[configId];
if (!config || typeof config !== "object" || Array.isArray(config)) {
throw new PluginUserError("errors.settings.cardTypeConfigsObject");
}
if (typeof config.enabled !== "boolean") {
throw new PluginUserError("errors.settings.cardTypeEnabledBoolean");
}
if (!Number.isInteger(config.headingLevel) || config.headingLevel < 1 || config.headingLevel > 6) {
throw new PluginUserError("errors.settings.headingLevelsRange");
}
if (typeof config.extraMarker !== "string") {
throw new PluginUserError("errors.settings.cardTypeExtraMarkerString");
}
if (!config.enabled || config.extraMarker.trim().length > 0) {
continue;
}
const defaults = emptyDefaultsByHeading.get(config.headingLevel) ?? [];
defaults.push(configId);
emptyDefaultsByHeading.set(config.headingLevel, defaults);
}
for (const [headingLevel, defaults] of emptyDefaultsByHeading.entries()) {
if (defaults.length > 1) {
throw new PluginUserError("errors.settings.cardTypeDefaultConflict", { headingLevel });
}
}
}
function validateNoteFieldMappings(noteFieldMappings: Record<string, NoteModelFieldMapping>): void {
if (!noteFieldMappings || typeof noteFieldMappings !== "object" || Array.isArray(noteFieldMappings)) {
throw new PluginUserError("errors.settings.noteFieldMappingsObject");
}
for (const mapping of Object.values(noteFieldMappings)) {
if (mapping.cardType !== "basic" && mapping.cardType !== "qa-group" && mapping.cardType !== "cloze") {
throw new PluginUserError("errors.settings.noteFieldMappingsCardType");
}
if (!mapping.modelName.trim()) {
throw new PluginUserError("errors.settings.noteFieldMappingsModelName");
}
if (!Array.isArray(mapping.loadedFieldNames)) {
throw new PluginUserError("errors.settings.noteFieldMappingsLoadedFieldNames");
}
if (!Number.isFinite(mapping.loadedAt)) {
throw new PluginUserError("errors.settings.noteFieldMappingsLoadedAt");
}
if (isQaGroupFieldMapping(mapping)) {
if (!Array.isArray(mapping.slots)) {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupSlotsArray");
}
for (const slot of mapping.slots) {
if (!slot || typeof slot !== "object" || Array.isArray(slot)) {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupSlotObject");
}
if (!Number.isInteger(slot.index) || slot.index < 1) {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupSlotIndex");
}
if (typeof slot.questionField !== "string") {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupSlotQuestionField");
}
if (typeof slot.answerField !== "string") {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupSlotAnswerField");
}
}
validateStringList(mapping.warnings, "errors.settings.noteFieldMappingsQaGroupWarningsArray", "errors.settings.noteFieldMappingsQaGroupWarningsStrings");
validateStringList(mapping.acceptedWarnings, "errors.settings.noteFieldMappingsQaGroupAcceptedWarningsArray", "errors.settings.noteFieldMappingsQaGroupAcceptedWarningsStrings", true);
if (mapping.derivation !== undefined) {
if (!mapping.derivation || typeof mapping.derivation !== "object" || Array.isArray(mapping.derivation)) {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupDerivationObject");
}
if (mapping.derivation.mode !== "first-pair") {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupDerivationMode");
}
if (mapping.derivation.firstQuestionField !== undefined && typeof mapping.derivation.firstQuestionField !== "string") {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupDerivationFirstQuestionField");
}
if (mapping.derivation.firstAnswerField !== undefined && typeof mapping.derivation.firstAnswerField !== "string") {
throw new PluginUserError("errors.settings.noteFieldMappingsQaGroupDerivationFirstAnswerField");
}
}
}
}
}
function createDefaultCardTypeConfigs(): CardTypeConfigs {
return {
basic: {
enabled: true,
headingLevel: DEFAULT_BASIC_HEADING_LEVEL,
extraMarker: "",
noteType: DEFAULT_BASIC_NOTE_TYPE,
},
"qa-group": {
enabled: true,
headingLevel: DEFAULT_BASIC_HEADING_LEVEL,
extraMarker: DEFAULT_QA_GROUP_MARKER,
noteType: "",
},
cloze: {
enabled: true,
headingLevel: DEFAULT_CLOZE_HEADING_LEVEL,
extraMarker: DEFAULT_CLOZE_MARKER,
noteType: DEFAULT_CLOZE_NOTE_TYPE,
},
};
}
function createLegacyBackfilledCardTypeConfigs(settings: Partial<PluginSettings>): CardTypeConfigs {
const defaults = createDefaultCardTypeConfigs();
return {
basic: {
...defaults.basic,
headingLevel: sanitizeHeadingLevel(settings.qaHeadingLevel, defaults.basic.headingLevel),
noteType: sanitizeNoteType(settings.qaNoteType, defaults.basic.noteType),
},
"qa-group": {
...defaults["qa-group"],
headingLevel: sanitizeHeadingLevel(settings.qaHeadingLevel, defaults["qa-group"].headingLevel),
extraMarker: sanitizeMarker(settings.qaGroupMarker, defaults["qa-group"].extraMarker),
noteType: sanitizeNoteType(settings.cardTypeConfigs?.["qa-group"]?.noteType, defaults["qa-group"].noteType),
},
cloze: {
...defaults.cloze,
headingLevel: sanitizeHeadingLevel(settings.clozeHeadingLevel, defaults.cloze.headingLevel),
noteType: sanitizeNoteType(settings.clozeNoteType, defaults.cloze.noteType),
},
};
}
function mergeCardTypeConfigs(
rawCardTypeConfigs: Partial<Record<CardTypeConfigId, Partial<CardTypeConfig>>> | null | undefined,
legacySettings: Partial<PluginSettings>,
): CardTypeConfigs {
const legacyBackfilledConfigs = createLegacyBackfilledCardTypeConfigs(legacySettings);
const nextConfigs = {} as CardTypeConfigs;
for (const configId of CARD_TYPE_CONFIG_IDS) {
const rawConfig = rawCardTypeConfigs?.[configId];
const fallbackConfig = legacyBackfilledConfigs[configId];
const mergedConfig = rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig)
? { ...fallbackConfig, ...rawConfig }
: fallbackConfig;
const normalizedConfig: CardTypeConfig = {
enabled: typeof mergedConfig.enabled === "boolean" ? mergedConfig.enabled : fallbackConfig.enabled,
headingLevel: sanitizeHeadingLevel(mergedConfig.headingLevel, fallbackConfig.headingLevel),
extraMarker: sanitizeMarker(mergedConfig.extraMarker, fallbackConfig.extraMarker),
noteType: sanitizeNoteType(mergedConfig.noteType, fallbackConfig.noteType),
};
nextConfigs[configId] = configId === "cloze"
? migrateLegacyDefaultClozeConfig(normalizedConfig, rawConfig, legacySettings)
: normalizedConfig;
}
return nextConfigs;
}
function migrateLegacyDefaultClozeConfig(
config: CardTypeConfig,
rawConfig: Partial<CardTypeConfig> | null | undefined,
legacySettings: Partial<PluginSettings>,
): CardTypeConfig {
if (config.headingLevel === LEGACY_DEFAULT_CLOZE_HEADING_LEVEL && config.extraMarker === LEGACY_DEFAULT_CLOZE_MARKER) {
return {
...config,
headingLevel: DEFAULT_CLOZE_HEADING_LEVEL,
extraMarker: DEFAULT_CLOZE_MARKER,
};
}
if (!hasExplicitClozeRecognitionConfig(rawConfig) && legacySettings.clozeHeadingLevel === LEGACY_DEFAULT_CLOZE_HEADING_LEVEL) {
return {
...config,
headingLevel: DEFAULT_CLOZE_HEADING_LEVEL,
extraMarker: DEFAULT_CLOZE_MARKER,
};
}
return config;
}
function hasExplicitClozeRecognitionConfig(rawConfig: Partial<CardTypeConfig> | null | undefined): boolean {
if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
return false;
}
return "headingLevel" in rawConfig || "extraMarker" in rawConfig;
}
function deriveLegacySettings(cardTypeConfigs: CardTypeConfigs): Pick<PluginSettings, "qaHeadingLevel" | "clozeHeadingLevel" | "qaGroupMarker" | "qaNoteType" | "clozeNoteType"> {
return {
qaHeadingLevel: cardTypeConfigs.basic.headingLevel,
clozeHeadingLevel: cardTypeConfigs.cloze.headingLevel,
qaGroupMarker: cardTypeConfigs["qa-group"].extraMarker,
qaNoteType: cardTypeConfigs.basic.noteType,
clozeNoteType: cardTypeConfigs.cloze.noteType,
};
}
function sanitizeHeadingLevel(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 6 ? value : fallback;
}
function sanitizeMarker(value: string | undefined, fallback: string): string {
if (typeof value !== "string") {
return fallback;
}
return value.trim();
}
function sanitizeNoteType(value: string | undefined, fallback: string): string {
if (typeof value !== "string") {
return fallback;
}
const trimmedValue = value.trim();
return trimmedValue.length > 0 ? trimmedValue : fallback;
}
function normalizeNoteFieldMappings(value: unknown): Record<string, NoteModelFieldMapping> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const normalizedMappings: Record<string, NoteModelFieldMapping> = {};
for (const rawMapping of Object.values(value)) {
if (!rawMapping || typeof rawMapping !== "object" || Array.isArray(rawMapping)) {
continue;
}
const mapping = rawMapping as Partial<NoteModelFieldMapping> & Record<string, unknown>;
if (mapping.cardType !== "basic" && mapping.cardType !== "qa-group" && mapping.cardType !== "cloze") {
continue;
}
const modelName = typeof mapping.modelName === "string" ? mapping.modelName.trim() : "";
if (modelName.length === 0) {
continue;
}
const loadedFieldNames = normalizeModelFieldNames(mapping.loadedFieldNames);
const loadedAt = typeof mapping.loadedAt === "number" && Number.isFinite(mapping.loadedAt) ? mapping.loadedAt : 0;
if (mapping.cardType === "qa-group") {
const normalizedQaGroupMapping = normalizeQaGroupFieldMapping(mapping, modelName, loadedFieldNames, loadedAt);
normalizedMappings[createNoteFieldMappingKey(normalizedQaGroupMapping.cardType, normalizedQaGroupMapping.modelName)] = normalizedQaGroupMapping;
continue;
}
if (mapping.cardType === "cloze") {
normalizedMappings[createNoteFieldMappingKey(mapping.cardType, modelName)] = {
cardType: mapping.cardType,
modelName,
loadedFieldNames,
mainField: normalizeOptionalFieldName(mapping.mainField),
loadedAt,
};
continue;
}
normalizedMappings[createNoteFieldMappingKey(mapping.cardType, modelName)] = {
cardType: mapping.cardType,
modelName,
loadedFieldNames,
titleField: normalizeOptionalFieldName(mapping.titleField),
bodyField: normalizeOptionalFieldName(mapping.bodyField),
loadedAt,
};
}
return normalizedMappings;
}
function hydrateMissingNoteFieldMappingsFromCache(settings: PluginSettings): Record<string, NoteModelFieldMapping> {
const hydratedMappings = { ...settings.noteFieldMappings };
for (const configId of CARD_TYPE_CONFIG_IDS) {
if (configId === "qa-group") {
continue;
}
const config = settings.cardTypeConfigs[configId];
if (!config.enabled) {
continue;
}
const modelName = config.noteType.trim();
if (modelName.length === 0) {
continue;
}
const cardType = configId === "cloze" ? "cloze" : "basic";
const mappingKey = createNoteFieldMappingKey(cardType, modelName);
if (hydratedMappings[mappingKey]) {
continue;
}
const cacheEntry = settings.ankiModelFieldCache[modelName];
if (!cacheEntry || cacheEntry.fieldNames.length === 0) {
continue;
}
const fieldNames = [...cacheEntry.fieldNames];
hydratedMappings[mappingKey] = cardType === "cloze"
? {
cardType,
modelName,
loadedFieldNames: fieldNames,
mainField: findPreferredFieldName(fieldNames, ["Text", "Body", "Content"]) ?? fieldNames[0],
loadedAt: cacheEntry.loadedAt,
}
: {
cardType,
modelName,
loadedFieldNames: fieldNames,
titleField: findPreferredFieldName(fieldNames, ["Front", "Title"]) ?? fieldNames[0],
bodyField: findPreferredFieldName(fieldNames, ["Back", "Body", "Answer"]) ?? fieldNames.find((fieldName) => fieldName !== (findPreferredFieldName(fieldNames, ["Front", "Title"]) ?? fieldNames[0])),
loadedAt: cacheEntry.loadedAt,
};
}
return hydratedMappings;
}
function findPreferredFieldName(fieldNames: string[], preferredNames: string[]): string | undefined {
return fieldNames.find((fieldName) => preferredNames.some((preferredName) => preferredName.toLowerCase() === fieldName.toLowerCase()));
}
function normalizeQaGroupFieldMapping(
mapping: Record<string, unknown>,
modelName: string,
loadedFieldNames: string[],
loadedAt: number,
): NoteModelFieldMapping {
const acceptedWarnings = normalizeStringList(mapping.acceptedWarnings);
const normalizedSlots = normalizeQaGroupSlots(mapping.slots);
const normalizedWarnings = normalizeStringList(mapping.warnings);
const normalizedTitleField = normalizeOptionalFieldName(mapping.titleField);
const normalizedDerivation = normalizeQaGroupDerivation(mapping.derivation);
const hasExplicitQaGroupShape = normalizedSlots.length > 0 || normalizedWarnings.length > 0 || acceptedWarnings.length > 0 || normalizedDerivation !== undefined;
if (hasExplicitQaGroupShape) {
return {
cardType: "qa-group",
modelName,
loadedFieldNames,
titleField: normalizedTitleField,
slots: normalizedSlots,
warnings: normalizedWarnings,
acceptedWarnings: acceptedWarnings.length > 0 ? acceptedWarnings : undefined,
derivation: normalizedDerivation,
loadedAt,
};
}
if (loadedFieldNames.length > 0) {
try {
return qaGroupFieldMappingService.suggest(modelName, loadedFieldNames, loadedAt, acceptedWarnings, normalizedTitleField);
} catch {
return {
cardType: "qa-group",
modelName,
loadedFieldNames,
titleField: normalizedTitleField,
slots: [],
warnings: [],
acceptedWarnings: undefined,
derivation: normalizedDerivation,
loadedAt,
};
}
}
return {
cardType: "qa-group",
modelName,
loadedFieldNames,
titleField: normalizedTitleField,
slots: [],
warnings: [],
acceptedWarnings: undefined,
derivation: normalizedDerivation,
loadedAt,
};
}
function normalizeQaGroupDerivation(value: unknown): { mode: "first-pair"; firstQuestionField?: string; firstAnswerField?: string } | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const derivation = value as Partial<{ mode: unknown; firstQuestionField: unknown; firstAnswerField: unknown }>;
if (derivation.mode !== "first-pair") {
return undefined;
}
return {
mode: "first-pair",
firstQuestionField: normalizeOptionalFieldName(derivation.firstQuestionField),
firstAnswerField: normalizeOptionalFieldName(derivation.firstAnswerField),
};
}
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));
}
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;
}
function normalizeQaGroupSlots(value: unknown): { index: number; questionField: string; answerField: string }[] {
if (!Array.isArray(value)) {
return [];
}
const normalizedSlots: { index: number; questionField: string; answerField: string }[] = [];
for (const slot of value) {
if (!slot || typeof slot !== "object" || Array.isArray(slot)) {
continue;
}
const rawSlot = slot as Record<string, unknown>;
if (!Number.isInteger(rawSlot.index) || (rawSlot.index as number) < 1) {
continue;
}
const questionField = normalizeOptionalFieldName(rawSlot.questionField);
const answerField = normalizeOptionalFieldName(rawSlot.answerField);
if (!questionField || !answerField) {
continue;
}
normalizedSlots.push({
index: rawSlot.index as number,
questionField,
answerField,
});
}
return normalizedSlots;
}
function normalizeOptionalFieldName(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmedValue = value.trim();
return trimmedValue.length > 0 ? trimmedValue : undefined;
}
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const normalizedValues: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
continue;
}
const trimmedEntry = entry.trim();
if (trimmedEntry.length === 0) {
continue;
}
normalizedValues.push(trimmedEntry);
}
return normalizedValues;
}
function validateStringList(
value: unknown,
arrayKey: TranslationKey,
stringKey: TranslationKey,
allowUndefined = false,
): void {
if (typeof value === "undefined" && allowUndefined) {
return;
}
if (!Array.isArray(value)) {
throw new PluginUserError(arrayKey);
}
for (const entry of value) {
if (typeof entry !== "string") {
throw new PluginUserError(stringKey);
}
}
}