feat: add configurable Anki word field with note ID merge tracking

- Extract word field config into reusable anki-field-config module
- Add ankiConnect.fields.word config option (default: "Expression")
- Replace hardcoded "Expression" field references across Anki integration
- Add note ID redirect tracking for merged/moved cards
- Support legacy ankiConnect.wordField migration path
This commit is contained in:
2026-03-18 02:24:26 -07:00
parent 61e1621137
commit a0015dc75c
17 changed files with 286 additions and 20 deletions

85
src/anki-field-config.ts Normal file
View File

@@ -0,0 +1,85 @@
import type { AnkiConnectConfig } from './types';
type NoteFieldValue = { value?: string } | string | null | undefined;
function normalizeFieldName(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function getConfiguredWordFieldName(config?: Pick<AnkiConnectConfig, 'fields'> | null): string {
return normalizeFieldName(config?.fields?.word) ?? 'Expression';
}
export function getConfiguredSentenceFieldName(
config?: Pick<AnkiConnectConfig, 'fields'> | null,
): string {
return normalizeFieldName(config?.fields?.sentence) ?? 'Sentence';
}
export function getConfiguredTranslationFieldName(
config?: Pick<AnkiConnectConfig, 'fields'> | null,
): string {
return normalizeFieldName(config?.fields?.translation) ?? 'SelectionText';
}
export function getConfiguredWordFieldCandidates(
config?: Pick<AnkiConnectConfig, 'fields'> | null,
): string[] {
const preferred = getConfiguredWordFieldName(config);
const candidates = [preferred, 'Expression', 'Word'];
const seen = new Set<string>();
return candidates.filter((candidate) => {
const key = candidate.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function coerceFieldValue(value: NoteFieldValue): string {
if (typeof value === 'string') return value;
if (value && typeof value === 'object' && typeof value.value === 'string') {
return value.value;
}
return '';
}
export function stripAnkiFieldHtml(value: string): string {
return value
.replace(/\[sound:[^\]]+\]/gi, ' ')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function getPreferredNoteFieldValue(
fields: Record<string, NoteFieldValue> | null | undefined,
preferredNames: string[],
): string {
if (!fields) return '';
const entries = Object.entries(fields);
for (const preferredName of preferredNames) {
const preferredKey = preferredName.trim().toLowerCase();
if (!preferredKey) continue;
const entry = entries.find(([fieldName]) => fieldName.trim().toLowerCase() === preferredKey);
if (!entry) continue;
const cleaned = stripAnkiFieldHtml(coerceFieldValue(entry[1]));
if (cleaned) return cleaned;
}
return '';
}
export function getPreferredWordValueFromExtractedFields(
fields: Record<string, string>,
config?: Pick<AnkiConnectConfig, 'fields'> | null,
): string {
for (const candidate of getConfiguredWordFieldCandidates(config)) {
const value = fields[candidate.toLowerCase()]?.trim();
if (value) return value;
}
return '';
}

View File

@@ -209,6 +209,27 @@ test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes',
}
});
test('AnkiIntegration resolves merged-away note ids to the kept note id', () => {
const ctx = createIntegrationTestContext({
stateDirPrefix: 'subminer-anki-integration-note-redirect-',
});
try {
const integrationWithInternals = ctx.integration as unknown as {
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
};
integrationWithInternals.rememberMergedNoteIds(111, 222);
integrationWithInternals.rememberMergedNoteIds(222, 333);
assert.equal(ctx.integration.resolveCurrentNoteId(111), 333);
assert.equal(ctx.integration.resolveCurrentNoteId(222), 333);
assert.equal(ctx.integration.resolveCurrentNoteId(333), 333);
assert.equal(ctx.integration.resolveCurrentNoteId(444), 444);
} finally {
cleanupIntegrationTestContext(ctx);
}
});
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
const integration = new AnkiIntegration(
{

View File

@@ -31,6 +31,11 @@ import {
NPlusOneMatchMode,
} from './types';
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
import {
getConfiguredWordFieldCandidates,
getConfiguredWordFieldName,
getPreferredWordValueFromExtractedFields,
} from './anki-field-config';
import { createLogger } from './logger';
import {
createUiFeedbackState,
@@ -138,6 +143,7 @@ export class AnkiIntegration {
private runtime: AnkiIntegrationRuntime;
private aiConfig: AiConfig;
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
private noteIdRedirects = new Map<number, number>();
constructor(
config: AnkiConnectConfig,
@@ -337,6 +343,7 @@ export class AnkiIntegration {
private createFieldGroupingService(): FieldGroupingService {
return new FieldGroupingService({
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
getConfig: () => this.config,
isUpdateInProgress: () => this.updateInProgress,
getDeck: () => this.config.deck,
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) =>
@@ -451,6 +458,9 @@ export class AnkiIntegration {
removeTrackedNoteId: (noteId) => {
this.previousNoteIds.delete(noteId);
},
rememberMergedNoteIds: (deletedNoteId, keptNoteId) => {
this.rememberMergedNoteIds(deletedNoteId, keptNoteId);
},
showStatusNotification: (message) => this.showStatusNotification(message),
showNotification: (noteId, label) => this.showNotification(noteId, label),
showOsdNotification: (message) => this.showOsdNotification(message),
@@ -972,6 +982,7 @@ export class AnkiIntegration {
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
getDeck: () => this.config.deck,
getWordFieldCandidates: () => this.getConfiguredWordFieldCandidates(),
resolveFieldName: (info, preferredName) => this.resolveNoteFieldName(info, preferredName),
logInfo: (message) => {
log.info(message);
@@ -997,6 +1008,18 @@ export class AnkiIntegration {
);
}
private getConfiguredWordFieldName(): string {
return getConfiguredWordFieldName(this.config);
}
private getConfiguredWordFieldCandidates(): string[] {
return getConfiguredWordFieldCandidates(this.config);
}
private getPreferredWordValue(fields: Record<string, string>): string {
return getPreferredWordValueFromExtractedFields(fields, this.config);
}
private async generateMediaForMerge(): Promise<{
audioField?: string;
audioValue?: string;
@@ -1127,4 +1150,32 @@ export class AnkiIntegration {
): void {
this.recordCardsMinedCallback = callback;
}
resolveCurrentNoteId(noteId: number): number {
let resolved = noteId;
const seen = new Set<number>();
while (this.noteIdRedirects.has(resolved) && !seen.has(resolved)) {
seen.add(resolved);
resolved = this.noteIdRedirects.get(resolved)!;
}
return resolved;
}
private rememberMergedNoteIds(deletedNoteId: number, keptNoteId: number): void {
const resolvedKeepNoteId = this.resolveCurrentNoteId(keptNoteId);
const visited = new Set<number>([deletedNoteId]);
let current = deletedNoteId;
while (true) {
this.noteIdRedirects.set(current, resolvedKeepNoteId);
const next = Array.from(this.noteIdRedirects.entries()).find(
([, targetNoteId]) => targetNoteId === current,
)?.[0];
if (next === undefined || visited.has(next)) {
break;
}
visited.add(next);
current = next;
}
}
}

View File

@@ -1,4 +1,8 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import {
getConfiguredWordFieldName,
getPreferredWordValueFromExtractedFields,
} from '../anki-field-config';
import { AiConfig, AnkiConnectConfig } from '../types';
import { createLogger } from '../logger';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
@@ -201,7 +205,10 @@ export class CardCreationService {
const noteInfo = notesInfoResult[0]!;
const fields = this.deps.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || '';
const expressionText = getPreferredWordValueFromExtractedFields(
fields,
this.deps.getConfig(),
);
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
@@ -368,7 +375,10 @@ export class CardCreationService {
const noteInfo = notesInfoResult[0]!;
const fields = this.deps.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || '';
const expressionText = getPreferredWordValueFromExtractedFields(
fields,
this.deps.getConfig(),
);
const updatedFields: Record<string, string> = {};
const errors: string[] = [];
@@ -519,7 +529,7 @@ export class CardCreationService {
if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
fields.IsSentenceCard = 'x';
fields.Expression = sentence;
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
}
const deck = this.deps.getConfig().deck || 'Default';

View File

@@ -11,6 +11,7 @@ export interface DuplicateDetectionDeps {
findNotes: (query: string, options?: { maxRetries?: number }) => Promise<unknown>;
notesInfo: (noteIds: number[]) => Promise<unknown>;
getDeck: () => string | null | undefined;
getWordFieldCandidates?: () => string[];
resolveFieldName: (noteInfo: NoteInfo, preferredName: string) => string | null;
logInfo?: (message: string) => void;
logDebug?: (message: string) => void;
@@ -23,7 +24,12 @@ export async function findDuplicateNote(
noteInfo: NoteInfo,
deps: DuplicateDetectionDeps,
): Promise<number | null> {
const sourceCandidates = getDuplicateSourceCandidates(noteInfo, expression);
const configuredWordFieldCandidates = deps.getWordFieldCandidates?.() ?? ['Expression', 'Word'];
const sourceCandidates = getDuplicateSourceCandidates(
noteInfo,
expression,
configuredWordFieldCandidates,
);
if (sourceCandidates.length === 0) return null;
deps.logInfo?.(
`[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates
@@ -81,6 +87,7 @@ export async function findDuplicateNote(
noteIds,
excludeNoteId,
sourceCandidates.map((candidate) => candidate.value),
configuredWordFieldCandidates,
deps,
);
} catch (error) {
@@ -93,6 +100,7 @@ function findFirstExactDuplicateNoteId(
candidateNoteIds: Iterable<number>,
excludeNoteId: number,
sourceValues: string[],
candidateFieldNames: string[],
deps: DuplicateDetectionDeps,
): Promise<number | null> {
const candidates = Array.from(candidateNoteIds).filter((id) => id !== excludeNoteId);
@@ -116,7 +124,6 @@ function findFirstExactDuplicateNoteId(
const notesInfoResult = (await deps.notesInfo(chunk)) as unknown[];
const notesInfo = notesInfoResult as NoteInfo[];
for (const noteInfo of notesInfo) {
const candidateFieldNames = ['word', 'expression'];
for (const candidateFieldName of candidateFieldNames) {
const resolvedField = deps.resolveFieldName(noteInfo, candidateFieldName);
if (!resolvedField) continue;
@@ -150,13 +157,15 @@ function getDuplicateCandidateFieldNames(fieldName: string): string[] {
function getDuplicateSourceCandidates(
noteInfo: NoteInfo,
fallbackExpression: string,
configuredFieldNames: string[],
): Array<{ fieldName: string; value: string }> {
const candidates: Array<{ fieldName: string; value: string }> = [];
const dedupeKey = new Set<string>();
const configuredFieldNameSet = new Set(configuredFieldNames.map((name) => name.toLowerCase()));
for (const fieldName of Object.keys(noteInfo.fields)) {
const lower = fieldName.toLowerCase();
if (lower !== 'word' && lower !== 'expression') continue;
if (!configuredFieldNameSet.has(lower)) continue;
const value = noteInfo.fields[fieldName]?.value?.trim() ?? '';
if (!value) continue;
const key = `${lower}:${normalizeDuplicateValue(value)}`;
@@ -167,9 +176,10 @@ function getDuplicateSourceCandidates(
const trimmedFallback = fallbackExpression.trim();
if (trimmedFallback.length > 0) {
const fallbackKey = `expression:${normalizeDuplicateValue(trimmedFallback)}`;
const fallbackFieldName = configuredFieldNames[0]?.toLowerCase() || 'expression';
const fallbackKey = `${fallbackFieldName}:${normalizeDuplicateValue(trimmedFallback)}`;
if (!dedupeKey.has(fallbackKey)) {
candidates.push({ fieldName: 'expression', value: trimmedFallback });
candidates.push({ fieldName: configuredFieldNames[0] || 'Expression', value: trimmedFallback });
}
}

View File

@@ -1,4 +1,5 @@
import { AnkiConnectConfig } from '../types';
import { getConfiguredWordFieldName } from '../anki-field-config';
interface FieldGroupingMergeMedia {
audioField?: string;
@@ -77,6 +78,7 @@ export class FieldGroupingMergeCollaborator {
includeGeneratedMedia: boolean,
): Promise<Record<string, string>> {
const config = this.deps.getConfig();
const configuredWordField = getConfiguredWordFieldName(config);
const groupableFields = this.getGroupableFieldNames();
const keepFieldNames = Object.keys(keepNoteInfo.fields);
const sourceFields: Record<string, string> = {};
@@ -98,11 +100,17 @@ export class FieldGroupingMergeCollaborator {
if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) {
sourceFields['Sentence'] = sourceFields['SentenceFurigana'];
}
if (!sourceFields['Expression'] && sourceFields['Word']) {
sourceFields['Expression'] = sourceFields['Word'];
if (!sourceFields[configuredWordField] && sourceFields['Expression']) {
sourceFields[configuredWordField] = sourceFields['Expression'];
}
if (!sourceFields['Word'] && sourceFields['Expression']) {
sourceFields['Word'] = sourceFields['Expression'];
if (!sourceFields[configuredWordField] && sourceFields['Word']) {
sourceFields[configuredWordField] = sourceFields['Word'];
}
if (!sourceFields['Expression'] && sourceFields[configuredWordField]) {
sourceFields['Expression'] = sourceFields[configuredWordField];
}
if (!sourceFields['Word'] && sourceFields[configuredWordField]) {
sourceFields['Word'] = sourceFields[configuredWordField];
}
if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) {
sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio'];
@@ -148,6 +156,7 @@ export class FieldGroupingMergeCollaborator {
const keepFieldNormalized = keepFieldName.toLowerCase();
if (
keepFieldNormalized === 'expression' ||
keepFieldNormalized === configuredWordField.toLowerCase() ||
keepFieldNormalized === 'expressionfurigana' ||
keepFieldNormalized === 'expressionreading' ||
keepFieldNormalized === 'expressionaudio'

View File

@@ -24,6 +24,7 @@ function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const deleted: number[][] = [];
const statuses: string[] = [];
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
const mergeCalls: Array<{
keepNoteId: number;
deleteNoteId: number;
@@ -99,6 +100,9 @@ function createWorkflowHarness() {
hasFieldValue: (_noteInfo: NoteInfo, _field?: string) => false,
addConfiguredTagsToNote: async () => undefined,
removeTrackedNoteId: () => undefined,
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => {
rememberedMerges.push({ deletedNoteId, keptNoteId });
},
showStatusNotification: (message: string) => {
statuses.push(message);
},
@@ -113,6 +117,7 @@ function createWorkflowHarness() {
workflow: new FieldGroupingWorkflow(deps),
updates,
deleted,
rememberedMerges,
statuses,
mergeCalls,
setManualChoice: (choice: typeof manualChoice) => {
@@ -136,6 +141,7 @@ test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate b
assert.equal(harness.updates.length, 1);
assert.equal(harness.updates[0]?.noteId, 1);
assert.deepEqual(harness.deleted, [[2]]);
assert.deepEqual(harness.rememberedMerges, [{ deletedNoteId: 2, keptNoteId: 1 }]);
assert.equal(harness.statuses.length, 1);
});

View File

@@ -1,4 +1,5 @@
import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
export interface FieldGroupingWorkflowNoteInfo {
noteId: number;
@@ -13,6 +14,7 @@ export interface FieldGroupingWorkflowDeps {
};
getConfig: () => {
fields?: {
word?: string;
audio?: string;
image?: string;
};
@@ -48,6 +50,7 @@ export interface FieldGroupingWorkflowDeps {
hasFieldValue: (noteInfo: FieldGroupingWorkflowNoteInfo, preferredFieldName?: string) => boolean;
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
removeTrackedNoteId: (noteId: number) => void;
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
showStatusNotification: (message: string) => void;
showNotification: (noteId: number, label: string | number) => Promise<void>;
showOsdNotification: (message: string) => void;
@@ -156,6 +159,7 @@ export class FieldGroupingWorkflow {
if (deleteDuplicate) {
await this.deps.client.deleteNotes([deleteNoteId]);
this.deps.removeTrackedNoteId(deleteNoteId);
this.deps.rememberMergedNoteIds(deleteNoteId, keepNoteId);
}
this.deps.logInfo('Merged duplicate card:', expression, 'into note:', keepNoteId);
@@ -176,7 +180,8 @@ export class FieldGroupingWorkflow {
const fields = this.deps.extractFields(noteInfo.fields);
return {
noteId: noteInfo.noteId,
expression: fields.expression || fields.word || fallbackExpression,
expression:
getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig()) || fallbackExpression,
sentencePreview: this.deps.truncateSentence(
fields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] ||
(isOriginal ? '' : this.deps.getCurrentSubtitleText() || ''),
@@ -191,7 +196,7 @@ export class FieldGroupingWorkflow {
private getExpression(noteInfo: FieldGroupingWorkflowNoteInfo): string {
const fields = this.deps.extractFields(noteInfo.fields);
return fields.expression || fields.word || '';
return getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig());
}
private async resolveFieldGroupingCallback(): Promise<

View File

@@ -1,5 +1,6 @@
import { KikuMergePreviewResponse } from '../types';
import { createLogger } from '../logger';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
const log = createLogger('anki').child('integration.field-grouping');
@@ -9,6 +10,11 @@ interface FieldGroupingNoteInfo {
}
interface FieldGroupingDeps {
getConfig: () => {
fields?: {
word?: string;
};
};
getEffectiveSentenceCardConfig: () => {
model?: string;
sentenceField: string;
@@ -102,7 +108,10 @@ export class FieldGroupingService {
}
const noteInfoBeforeUpdate = notesInfo[0]!;
const fields = this.deps.extractFields(noteInfoBeforeUpdate.fields);
const expressionText = fields.expression || fields.word || '';
const expressionText = getPreferredWordValueFromExtractedFields(
fields,
this.deps.getConfig(),
);
if (!expressionText) {
this.deps.showOsdNotification('No expression/word field found');
return;

View File

@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getConfiguredWordFieldName } from '../anki-field-config';
import { AnkiConnectConfig } from '../types';
import { createLogger } from '../logger';
@@ -240,7 +241,8 @@ export class KnownWordCacheManager {
}
if (allFields.size > 0) return [...allFields];
}
return ['Expression', 'Word', 'Reading', 'Word Reading'];
const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig());
return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])];
}
private buildKnownWordsQuery(): string {

View File

@@ -1,4 +1,5 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
export interface NoteUpdateWorkflowNoteInfo {
noteId: number;
@@ -13,6 +14,7 @@ export interface NoteUpdateWorkflowDeps {
};
getConfig: () => {
fields?: {
word?: string;
sentence?: string;
image?: string;
miscInfo?: string;
@@ -90,8 +92,9 @@ export class NoteUpdateWorkflow {
const noteInfo = notesInfo[0]!;
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
const fields = this.deps.extractFields(noteInfo.fields);
const config = this.deps.getConfig();
const expressionText = (fields.expression || fields.word || '').trim();
const expressionText = getPreferredWordValueFromExtractedFields(fields, config).trim();
const hasExpressionText = expressionText.length > 0;
if (!hasExpressionText) {
// Some note types omit Expression/Word; still run enrichment updates and skip duplicate checks.
@@ -123,8 +126,6 @@ export class NoteUpdateWorkflow {
updatePerformed = true;
}
const config = this.deps.getConfig();
if (config.media?.generateAudio) {
try {
const audioFilename = this.deps.generateAudioFilename();

View File

@@ -23,6 +23,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
},
tags: ['SubMiner'],
fields: {
word: 'Expression',
audio: 'ExpressionAudio',
image: 'Picture',
sentence: 'Sentence',

View File

@@ -51,6 +51,12 @@ export function buildIntegrationConfigOptionRegistry(
description:
'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.',
},
{
path: 'ankiConnect.fields.word',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.fields.word,
description: 'Card field for the mined word or expression text.',
},
{
path: 'ankiConnect.ai.enabled',
kind: 'boolean',

View File

@@ -105,6 +105,36 @@ test('accepts valid proxy settings', () => {
);
});
test('accepts configured ankiConnect.fields.word override', () => {
const { context, warnings } = makeContext({
fields: {
word: 'TargetWord',
},
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.fields.word, 'TargetWord');
assert.equal(
warnings.some((warning) => warning.path === 'ankiConnect.fields.word'),
false,
);
});
test('maps legacy ankiConnect.wordField to modern ankiConnect.fields.word', () => {
const { context, warnings } = makeContext({
wordField: 'TargetWordLegacy',
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.fields.word, 'TargetWordLegacy');
assert.equal(
warnings.some((warning) => warning.path === 'ankiConnect.wordField'),
false,
);
});
test('warns and falls back for invalid proxy settings', () => {
const { context, warnings } = makeContext({
proxy: {

View File

@@ -14,6 +14,7 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
const proxy = isObject(ac.proxy) ? (ac.proxy as Record<string, unknown>) : {};
const legacyKeys = new Set([
'wordField',
'audioField',
'imageField',
'sentenceField',
@@ -359,6 +360,17 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'Expected string.',
);
}
if (!hasOwn(fields, 'word')) {
mapLegacy(
'wordField',
asString,
(value) => {
context.resolved.ankiConnect.fields.word = value;
},
context.resolved.ankiConnect.fields.word,
'Expected string.',
);
}
if (!hasOwn(fields, 'image')) {
mapLegacy(
'imageField',
@@ -833,7 +845,12 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
const DEFAULT_FIELDS = ['Expression', 'Word', 'Reading', 'Word Reading'];
const DEFAULT_FIELDS = [
DEFAULT_CONFIG.ankiConnect.fields.word,
'Word',
'Reading',
'Word Reading',
];
const knownWordsDecks = knownWordsConfig.decks;
const legacyNPlusOneDecks = nPlusOneConfig.decks;
if (isObject(knownWordsDecks)) {

View File

@@ -2598,6 +2598,7 @@ const ensureStatsServerStarted = (): string => {
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
mpvSocketPath: appState.mpvSocketPath,
ankiConnectConfig: getResolvedConfig().ankiConnect,
resolveAnkiNoteId: (noteId: number) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
addYomitanNote: async (word: string) => {
const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {

View File

@@ -221,6 +221,7 @@ export interface AnkiConnectConfig {
};
tags?: string[];
fields?: {
word?: string;
audio?: string;
image?: string;
sentence?: string;
@@ -722,6 +723,7 @@ export interface ResolvedConfig {
};
tags: string[];
fields: {
word: string;
audio: string;
image: string;
sentence: string;