mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
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:
85
src/anki-field-config.ts
Normal file
85
src/anki-field-config.ts
Normal 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(/ /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 '';
|
||||
}
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -23,6 +23,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
},
|
||||
tags: ['SubMiner'],
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
audio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
sentence: 'Sentence',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user