diff --git a/src/anki-field-config.ts b/src/anki-field-config.ts new file mode 100644 index 0000000..b87f047 --- /dev/null +++ b/src/anki-field-config.ts @@ -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 | null): string { + return normalizeFieldName(config?.fields?.word) ?? 'Expression'; +} + +export function getConfiguredSentenceFieldName( + config?: Pick | null, +): string { + return normalizeFieldName(config?.fields?.sentence) ?? 'Sentence'; +} + +export function getConfiguredTranslationFieldName( + config?: Pick | null, +): string { + return normalizeFieldName(config?.fields?.translation) ?? 'SelectionText'; +} + +export function getConfiguredWordFieldCandidates( + config?: Pick | null, +): string[] { + const preferred = getConfiguredWordFieldName(config); + const candidates = [preferred, 'Expression', 'Word']; + const seen = new Set(); + 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(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +export function getPreferredNoteFieldValue( + fields: Record | 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, + config?: Pick | null, +): string { + for (const candidate of getConfiguredWordFieldCandidates(config)) { + const value = fields[candidate.toLowerCase()]?.trim(); + if (value) return value; + } + return ''; +} diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index f1734a8..6a6feae 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -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( { diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 1a37f8f..ad93798 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -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(); 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: (initialMessage: string, action: () => Promise) => @@ -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 { + 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(); + 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([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; + } + } } diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 1de0a01..f7f47cf 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -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 = {}; 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'; diff --git a/src/anki-integration/duplicate.ts b/src/anki-integration/duplicate.ts index c4084ff..992390d 100644 --- a/src/anki-integration/duplicate.ts +++ b/src/anki-integration/duplicate.ts @@ -11,6 +11,7 @@ export interface DuplicateDetectionDeps { findNotes: (query: string, options?: { maxRetries?: number }) => Promise; notesInfo: (noteIds: number[]) => Promise; 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 { - 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, excludeNoteId: number, sourceValues: string[], + candidateFieldNames: string[], deps: DuplicateDetectionDeps, ): Promise { 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(); + 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 }); } } diff --git a/src/anki-integration/field-grouping-merge.ts b/src/anki-integration/field-grouping-merge.ts index e570ec8..043f1e7 100644 --- a/src/anki-integration/field-grouping-merge.ts +++ b/src/anki-integration/field-grouping-merge.ts @@ -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> { const config = this.deps.getConfig(); + const configuredWordField = getConfiguredWordFieldName(config); const groupableFields = this.getGroupableFieldNames(); const keepFieldNames = Object.keys(keepNoteInfo.fields); const sourceFields: Record = {}; @@ -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' diff --git a/src/anki-integration/field-grouping-workflow.test.ts b/src/anki-integration/field-grouping-workflow.test.ts index 519990b..1c02015 100644 --- a/src/anki-integration/field-grouping-workflow.test.ts +++ b/src/anki-integration/field-grouping-workflow.test.ts @@ -24,6 +24,7 @@ function createWorkflowHarness() { const updates: Array<{ noteId: number; fields: Record }> = []; 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); }); diff --git a/src/anki-integration/field-grouping-workflow.ts b/src/anki-integration/field-grouping-workflow.ts index 6b030fd..34cad8f 100644 --- a/src/anki-integration/field-grouping-workflow.ts +++ b/src/anki-integration/field-grouping-workflow.ts @@ -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; removeTrackedNoteId: (noteId: number) => void; + rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void; showStatusNotification: (message: string) => void; showNotification: (noteId: number, label: string | number) => Promise; 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< diff --git a/src/anki-integration/field-grouping.ts b/src/anki-integration/field-grouping.ts index becb2f2..363b9a5 100644 --- a/src/anki-integration/field-grouping.ts +++ b/src/anki-integration/field-grouping.ts @@ -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; diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts index a124f6e..b67ec27 100644 --- a/src/anki-integration/known-word-cache.ts +++ b/src/anki-integration/known-word-cache.ts @@ -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 { diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index 0709dd6..ca6ceb0 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -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(); diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 7ae21b5..a10e27b 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -23,6 +23,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< }, tags: ['SubMiner'], fields: { + word: 'Expression', audio: 'ExpressionAudio', image: 'Picture', sentence: 'Sentence', diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 085c6b3..275ffd5 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -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', diff --git a/src/config/resolve/anki-connect.test.ts b/src/config/resolve/anki-connect.test.ts index 764d847..490cefc 100644 --- a/src/config/resolve/anki-connect.test.ts +++ b/src/config/resolve/anki-connect.test.ts @@ -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: { diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index db562d3..540d875 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -14,6 +14,7 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { const metadata = isObject(ac.metadata) ? (ac.metadata as Record) : {}; const proxy = isObject(ac.proxy) ? (ac.proxy as Record) : {}; 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)) { diff --git a/src/main.ts b/src/main.ts index fb5fda0..08a4695 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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, { diff --git a/src/types.ts b/src/types.ts index e2b8f2b..c8a743b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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;