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', () => { test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
const integration = new AnkiIntegration( const integration = new AnkiIntegration(
{ {

View File

@@ -31,6 +31,11 @@ import {
NPlusOneMatchMode, NPlusOneMatchMode,
} from './types'; } from './types';
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
import {
getConfiguredWordFieldCandidates,
getConfiguredWordFieldName,
getPreferredWordValueFromExtractedFields,
} from './anki-field-config';
import { createLogger } from './logger'; import { createLogger } from './logger';
import { import {
createUiFeedbackState, createUiFeedbackState,
@@ -138,6 +143,7 @@ export class AnkiIntegration {
private runtime: AnkiIntegrationRuntime; private runtime: AnkiIntegrationRuntime;
private aiConfig: AiConfig; private aiConfig: AiConfig;
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null; private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
private noteIdRedirects = new Map<number, number>();
constructor( constructor(
config: AnkiConnectConfig, config: AnkiConnectConfig,
@@ -337,6 +343,7 @@ export class AnkiIntegration {
private createFieldGroupingService(): FieldGroupingService { private createFieldGroupingService(): FieldGroupingService {
return new FieldGroupingService({ return new FieldGroupingService({
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(), getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
getConfig: () => this.config,
isUpdateInProgress: () => this.updateInProgress, isUpdateInProgress: () => this.updateInProgress,
getDeck: () => this.config.deck, getDeck: () => this.config.deck,
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) => withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) =>
@@ -451,6 +458,9 @@ export class AnkiIntegration {
removeTrackedNoteId: (noteId) => { removeTrackedNoteId: (noteId) => {
this.previousNoteIds.delete(noteId); this.previousNoteIds.delete(noteId);
}, },
rememberMergedNoteIds: (deletedNoteId, keptNoteId) => {
this.rememberMergedNoteIds(deletedNoteId, keptNoteId);
},
showStatusNotification: (message) => this.showStatusNotification(message), showStatusNotification: (message) => this.showStatusNotification(message),
showNotification: (noteId, label) => this.showNotification(noteId, label), showNotification: (noteId, label) => this.showNotification(noteId, label),
showOsdNotification: (message) => this.showOsdNotification(message), showOsdNotification: (message) => this.showOsdNotification(message),
@@ -972,6 +982,7 @@ export class AnkiIntegration {
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown, findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown, notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
getDeck: () => this.config.deck, getDeck: () => this.config.deck,
getWordFieldCandidates: () => this.getConfiguredWordFieldCandidates(),
resolveFieldName: (info, preferredName) => this.resolveNoteFieldName(info, preferredName), resolveFieldName: (info, preferredName) => this.resolveNoteFieldName(info, preferredName),
logInfo: (message) => { logInfo: (message) => {
log.info(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<{ private async generateMediaForMerge(): Promise<{
audioField?: string; audioField?: string;
audioValue?: string; audioValue?: string;
@@ -1127,4 +1150,32 @@ export class AnkiIntegration {
): void { ): void {
this.recordCardsMinedCallback = callback; 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 { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import {
getConfiguredWordFieldName,
getPreferredWordValueFromExtractedFields,
} from '../anki-field-config';
import { AiConfig, AnkiConnectConfig } from '../types'; import { AiConfig, AnkiConnectConfig } from '../types';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
@@ -201,7 +205,10 @@ export class CardCreationService {
const noteInfo = notesInfoResult[0]!; const noteInfo = notesInfoResult[0]!;
const fields = this.deps.extractFields(noteInfo.fields); 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 sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField; const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
@@ -368,7 +375,10 @@ export class CardCreationService {
const noteInfo = notesInfoResult[0]!; const noteInfo = notesInfoResult[0]!;
const fields = this.deps.extractFields(noteInfo.fields); 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 updatedFields: Record<string, string> = {};
const errors: string[] = []; const errors: string[] = [];
@@ -519,7 +529,7 @@ export class CardCreationService {
if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) { if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
fields.IsSentenceCard = 'x'; fields.IsSentenceCard = 'x';
fields.Expression = sentence; fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
} }
const deck = this.deps.getConfig().deck || 'Default'; const deck = this.deps.getConfig().deck || 'Default';

View File

@@ -11,6 +11,7 @@ export interface DuplicateDetectionDeps {
findNotes: (query: string, options?: { maxRetries?: number }) => Promise<unknown>; findNotes: (query: string, options?: { maxRetries?: number }) => Promise<unknown>;
notesInfo: (noteIds: number[]) => Promise<unknown>; notesInfo: (noteIds: number[]) => Promise<unknown>;
getDeck: () => string | null | undefined; getDeck: () => string | null | undefined;
getWordFieldCandidates?: () => string[];
resolveFieldName: (noteInfo: NoteInfo, preferredName: string) => string | null; resolveFieldName: (noteInfo: NoteInfo, preferredName: string) => string | null;
logInfo?: (message: string) => void; logInfo?: (message: string) => void;
logDebug?: (message: string) => void; logDebug?: (message: string) => void;
@@ -23,7 +24,12 @@ export async function findDuplicateNote(
noteInfo: NoteInfo, noteInfo: NoteInfo,
deps: DuplicateDetectionDeps, deps: DuplicateDetectionDeps,
): Promise<number | null> { ): 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; if (sourceCandidates.length === 0) return null;
deps.logInfo?.( deps.logInfo?.(
`[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates `[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates
@@ -81,6 +87,7 @@ export async function findDuplicateNote(
noteIds, noteIds,
excludeNoteId, excludeNoteId,
sourceCandidates.map((candidate) => candidate.value), sourceCandidates.map((candidate) => candidate.value),
configuredWordFieldCandidates,
deps, deps,
); );
} catch (error) { } catch (error) {
@@ -93,6 +100,7 @@ function findFirstExactDuplicateNoteId(
candidateNoteIds: Iterable<number>, candidateNoteIds: Iterable<number>,
excludeNoteId: number, excludeNoteId: number,
sourceValues: string[], sourceValues: string[],
candidateFieldNames: string[],
deps: DuplicateDetectionDeps, deps: DuplicateDetectionDeps,
): Promise<number | null> { ): Promise<number | null> {
const candidates = Array.from(candidateNoteIds).filter((id) => id !== excludeNoteId); const candidates = Array.from(candidateNoteIds).filter((id) => id !== excludeNoteId);
@@ -116,7 +124,6 @@ function findFirstExactDuplicateNoteId(
const notesInfoResult = (await deps.notesInfo(chunk)) as unknown[]; const notesInfoResult = (await deps.notesInfo(chunk)) as unknown[];
const notesInfo = notesInfoResult as NoteInfo[]; const notesInfo = notesInfoResult as NoteInfo[];
for (const noteInfo of notesInfo) { for (const noteInfo of notesInfo) {
const candidateFieldNames = ['word', 'expression'];
for (const candidateFieldName of candidateFieldNames) { for (const candidateFieldName of candidateFieldNames) {
const resolvedField = deps.resolveFieldName(noteInfo, candidateFieldName); const resolvedField = deps.resolveFieldName(noteInfo, candidateFieldName);
if (!resolvedField) continue; if (!resolvedField) continue;
@@ -150,13 +157,15 @@ function getDuplicateCandidateFieldNames(fieldName: string): string[] {
function getDuplicateSourceCandidates( function getDuplicateSourceCandidates(
noteInfo: NoteInfo, noteInfo: NoteInfo,
fallbackExpression: string, fallbackExpression: string,
configuredFieldNames: string[],
): Array<{ fieldName: string; value: string }> { ): Array<{ fieldName: string; value: string }> {
const candidates: Array<{ fieldName: string; value: string }> = []; const candidates: Array<{ fieldName: string; value: string }> = [];
const dedupeKey = new Set<string>(); const dedupeKey = new Set<string>();
const configuredFieldNameSet = new Set(configuredFieldNames.map((name) => name.toLowerCase()));
for (const fieldName of Object.keys(noteInfo.fields)) { for (const fieldName of Object.keys(noteInfo.fields)) {
const lower = fieldName.toLowerCase(); const lower = fieldName.toLowerCase();
if (lower !== 'word' && lower !== 'expression') continue; if (!configuredFieldNameSet.has(lower)) continue;
const value = noteInfo.fields[fieldName]?.value?.trim() ?? ''; const value = noteInfo.fields[fieldName]?.value?.trim() ?? '';
if (!value) continue; if (!value) continue;
const key = `${lower}:${normalizeDuplicateValue(value)}`; const key = `${lower}:${normalizeDuplicateValue(value)}`;
@@ -167,9 +176,10 @@ function getDuplicateSourceCandidates(
const trimmedFallback = fallbackExpression.trim(); const trimmedFallback = fallbackExpression.trim();
if (trimmedFallback.length > 0) { if (trimmedFallback.length > 0) {
const fallbackKey = `expression:${normalizeDuplicateValue(trimmedFallback)}`; const fallbackFieldName = configuredFieldNames[0]?.toLowerCase() || 'expression';
const fallbackKey = `${fallbackFieldName}:${normalizeDuplicateValue(trimmedFallback)}`;
if (!dedupeKey.has(fallbackKey)) { 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 { AnkiConnectConfig } from '../types';
import { getConfiguredWordFieldName } from '../anki-field-config';
interface FieldGroupingMergeMedia { interface FieldGroupingMergeMedia {
audioField?: string; audioField?: string;
@@ -77,6 +78,7 @@ export class FieldGroupingMergeCollaborator {
includeGeneratedMedia: boolean, includeGeneratedMedia: boolean,
): Promise<Record<string, string>> { ): Promise<Record<string, string>> {
const config = this.deps.getConfig(); const config = this.deps.getConfig();
const configuredWordField = getConfiguredWordFieldName(config);
const groupableFields = this.getGroupableFieldNames(); const groupableFields = this.getGroupableFieldNames();
const keepFieldNames = Object.keys(keepNoteInfo.fields); const keepFieldNames = Object.keys(keepNoteInfo.fields);
const sourceFields: Record<string, string> = {}; const sourceFields: Record<string, string> = {};
@@ -98,11 +100,17 @@ export class FieldGroupingMergeCollaborator {
if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) { if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) {
sourceFields['Sentence'] = sourceFields['SentenceFurigana']; sourceFields['Sentence'] = sourceFields['SentenceFurigana'];
} }
if (!sourceFields['Expression'] && sourceFields['Word']) { if (!sourceFields[configuredWordField] && sourceFields['Expression']) {
sourceFields['Expression'] = sourceFields['Word']; sourceFields[configuredWordField] = sourceFields['Expression'];
} }
if (!sourceFields['Word'] && sourceFields['Expression']) { if (!sourceFields[configuredWordField] && sourceFields['Word']) {
sourceFields['Word'] = sourceFields['Expression']; 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']) { if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) {
sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio']; sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio'];
@@ -148,6 +156,7 @@ export class FieldGroupingMergeCollaborator {
const keepFieldNormalized = keepFieldName.toLowerCase(); const keepFieldNormalized = keepFieldName.toLowerCase();
if ( if (
keepFieldNormalized === 'expression' || keepFieldNormalized === 'expression' ||
keepFieldNormalized === configuredWordField.toLowerCase() ||
keepFieldNormalized === 'expressionfurigana' || keepFieldNormalized === 'expressionfurigana' ||
keepFieldNormalized === 'expressionreading' || keepFieldNormalized === 'expressionreading' ||
keepFieldNormalized === 'expressionaudio' keepFieldNormalized === 'expressionaudio'

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getConfiguredWordFieldName } from '../anki-field-config';
import { AnkiConnectConfig } from '../types'; import { AnkiConnectConfig } from '../types';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
@@ -240,7 +241,8 @@ export class KnownWordCacheManager {
} }
if (allFields.size > 0) return [...allFields]; 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 { private buildKnownWordsQuery(): string {

View File

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

View File

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

View File

@@ -51,6 +51,12 @@ export function buildIntegrationConfigOptionRegistry(
description: description:
'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.', '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', path: 'ankiConnect.ai.enabled',
kind: 'boolean', 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', () => { test('warns and falls back for invalid proxy settings', () => {
const { context, warnings } = makeContext({ const { context, warnings } = makeContext({
proxy: { 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 metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
const proxy = isObject(ac.proxy) ? (ac.proxy as Record<string, unknown>) : {}; const proxy = isObject(ac.proxy) ? (ac.proxy as Record<string, unknown>) : {};
const legacyKeys = new Set([ const legacyKeys = new Set([
'wordField',
'audioField', 'audioField',
'imageField', 'imageField',
'sentenceField', 'sentenceField',
@@ -359,6 +360,17 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'Expected string.', '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')) { if (!hasOwn(fields, 'image')) {
mapLegacy( mapLegacy(
'imageField', 'imageField',
@@ -833,7 +845,12 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode; 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 knownWordsDecks = knownWordsConfig.decks;
const legacyNPlusOneDecks = nPlusOneConfig.decks; const legacyNPlusOneDecks = nPlusOneConfig.decks;
if (isObject(knownWordsDecks)) { if (isObject(knownWordsDecks)) {

View File

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

View File

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