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', () => {
|
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
|
||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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<
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user