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

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;
}
}
}