/*
* SubMiner - Subtitle mining overlay for mpv
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
import { AnkiConnectClient } from './anki-connect';
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { MediaGenerator } from './media-generator';
import path from 'path';
import {
AnkiConnectConfig,
KikuDuplicateCardInfo,
KikuFieldGroupingChoice,
KikuMergePreviewResponse,
MpvClient,
NotificationOptions,
NPlusOneMatchMode,
} from './types';
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
import { createLogger } from './logger';
import {
createUiFeedbackState,
beginUpdateProgress,
endUpdateProgress,
showProgressTick,
showStatusNotification,
withUpdateProgress,
UiFeedbackState,
} from './anki-integration/ui-feedback';
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
import { PollingRunner } from './anki-integration/polling';
import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki-integration/duplicate';
import { CardCreationService } from './anki-integration/card-creation';
import { FieldGroupingService } from './anki-integration/field-grouping';
const log = createLogger('anki').child('integration');
interface NoteInfo {
noteId: number;
fields: Record;
}
type CardKind = 'sentence' | 'audio';
export class AnkiIntegration {
private client: AnkiConnectClient;
private mediaGenerator: MediaGenerator;
private timingTracker: SubtitleTimingTracker;
private config: AnkiConnectConfig;
private pollingRunner!: PollingRunner;
private previousNoteIds = new Set();
private mpvClient: MpvClient;
private osdCallback: ((text: string) => void) | null = null;
private notificationCallback: ((title: string, options: NotificationOptions) => void) | null =
null;
private updateInProgress = false;
private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
private parseWarningKeys = new Set();
private readonly strictGroupingFieldDefaults = new Set([
'picture',
'sentence',
'sentenceaudio',
'sentencefurigana',
'miscinfo',
]);
private fieldGroupingCallback:
| ((data: {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}) => Promise)
| null = null;
private knownWordCache: KnownWordCacheManager;
private cardCreationService: CardCreationService;
private fieldGroupingService: FieldGroupingService;
constructor(
config: AnkiConnectConfig,
timingTracker: SubtitleTimingTracker,
mpvClient: MpvClient,
osdCallback?: (text: string) => void,
notificationCallback?: (title: string, options: NotificationOptions) => void,
fieldGroupingCallback?: (data: {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}) => Promise,
knownWordCacheStatePath?: string,
) {
this.config = {
...DEFAULT_ANKI_CONNECT_CONFIG,
...config,
fields: {
...DEFAULT_ANKI_CONNECT_CONFIG.fields,
...(config.fields ?? {}),
},
ai: {
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
...(config.openRouter ?? {}),
...(config.ai ?? {}),
},
media: {
...DEFAULT_ANKI_CONNECT_CONFIG.media,
...(config.media ?? {}),
},
behavior: {
...DEFAULT_ANKI_CONNECT_CONFIG.behavior,
...(config.behavior ?? {}),
},
metadata: {
...DEFAULT_ANKI_CONNECT_CONFIG.metadata,
...(config.metadata ?? {}),
},
isLapis: {
...DEFAULT_ANKI_CONNECT_CONFIG.isLapis,
...(config.isLapis ?? {}),
},
isKiku: {
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
...(config.isKiku ?? {}),
},
} as AnkiConnectConfig;
this.client = new AnkiConnectClient(this.config.url!);
this.mediaGenerator = new MediaGenerator();
this.timingTracker = timingTracker;
this.mpvClient = mpvClient;
this.osdCallback = osdCallback || null;
this.notificationCallback = notificationCallback || null;
this.fieldGroupingCallback = fieldGroupingCallback || null;
this.knownWordCache = new KnownWordCacheManager({
client: {
findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as unknown,
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
},
getConfig: () => this.config,
knownWordCacheStatePath,
showStatusNotification: (message: string) => this.showStatusNotification(message),
});
this.pollingRunner = new PollingRunner({
getDeck: () => this.config.deck,
getPollingRate: () => this.config.pollingRate || DEFAULT_ANKI_CONNECT_CONFIG.pollingRate,
findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[],
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
processNewCard: (noteId) => this.processNewCard(noteId),
isUpdateInProgress: () => this.updateInProgress,
setUpdateInProgress: (value) => {
this.updateInProgress = value;
},
getTrackedNoteIds: () => this.previousNoteIds,
setTrackedNoteIds: (noteIds) => {
this.previousNoteIds = noteIds;
},
showStatusNotification: (message: string) => this.showStatusNotification(message),
logDebug: (...args) => log.debug(args[0] as string, ...args.slice(1)),
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
});
this.cardCreationService = new CardCreationService({
getConfig: () => this.config,
getTimingTracker: () => this.timingTracker,
getMpvClient: () => this.mpvClient,
getDeck: () => this.config.deck,
client: {
addNote: (deck, modelName, fields, tags) =>
this.client.addNote(deck, modelName, fields, tags),
addTags: (noteIds, tags) => this.client.addTags(noteIds, tags),
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
updateNoteFields: (noteId, fields) =>
this.client.updateNoteFields(noteId, fields) as Promise,
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[],
},
mediaGenerator: {
generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) =>
this.mediaGenerator.generateAudio(
videoPath,
startTime,
endTime,
audioPadding,
audioStreamIndex,
),
generateScreenshot: (videoPath, timestamp, options) =>
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
generateAnimatedImage: (videoPath, startTime, endTime, audioPadding, options) =>
this.mediaGenerator.generateAnimatedImage(
videoPath,
startTime,
endTime,
audioPadding,
options,
),
},
showOsdNotification: (text: string) => this.showOsdNotification(text),
showStatusNotification: (message: string) => this.showStatusNotification(message),
showNotification: (noteId, label, errorSuffix) =>
this.showNotification(noteId, label, errorSuffix),
beginUpdateProgress: (initialMessage: string) => this.beginUpdateProgress(initialMessage),
endUpdateProgress: () => this.endUpdateProgress(),
withUpdateProgress: (initialMessage: string, action: () => Promise) =>
this.withUpdateProgress(initialMessage, action),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
resolveNoteFieldName: (noteInfo, preferredName) =>
this.resolveNoteFieldName(noteInfo, preferredName),
extractFields: (fields) => this.extractFields(fields),
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
setCardTypeFields: (updatedFields, availableFieldNames, cardKind) =>
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
mergeFieldValue: (existing, newValue, overwrite) =>
this.mergeFieldValue(existing, newValue, overwrite),
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
getFallbackDurationSeconds: () => this.getFallbackDurationSeconds(),
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
isUpdateInProgress: () => this.updateInProgress,
setUpdateInProgress: (value) => {
this.updateInProgress = value;
},
trackLastAddedNoteId: (noteId) => {
this.previousNoteIds.add(noteId);
},
});
this.fieldGroupingService = new FieldGroupingService({
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
isUpdateInProgress: () => this.updateInProgress,
getDeck: () => this.config.deck,
withUpdateProgress: (initialMessage: string, action: () => Promise) =>
this.withUpdateProgress(initialMessage, action),
showOsdNotification: (text: string) => this.showOsdNotification(text),
findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[],
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown as NoteInfo[],
extractFields: (fields) => this.extractFields(fields),
findDuplicateNote: (expression, noteId, noteInfo) =>
this.findDuplicateNote(expression, noteId, noteInfo),
hasAllConfiguredFields: (noteInfo, configuredFieldNames) =>
this.hasAllConfiguredFields(noteInfo, configuredFieldNames),
processNewCard: (noteId, options) => this.processNewCard(noteId, options),
getSentenceCardImageFieldName: () => this.config.fields?.image,
resolveFieldName: (availableFieldNames, preferredName) =>
this.resolveFieldName(availableFieldNames, preferredName),
computeFieldGroupingMergedFields: (
keepNoteId,
deleteNoteId,
keepNoteInfo,
deleteNoteInfo,
includeGeneratedMedia,
) =>
this.computeFieldGroupingMergedFields(
keepNoteId,
deleteNoteId,
keepNoteInfo,
deleteNoteInfo,
includeGeneratedMedia,
),
getNoteFieldMap: (noteInfo) => this.getNoteFieldMap(noteInfo),
handleFieldGroupingAuto: (originalNoteId, newNoteId, newNoteInfo, expression) =>
this.handleFieldGroupingAuto(originalNoteId, newNoteId, newNoteInfo, expression),
handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
});
}
isKnownWord(text: string): boolean {
return this.knownWordCache.isKnownWord(text);
}
getKnownWordMatchMode(): NPlusOneMatchMode {
return this.config.nPlusOne?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.matchMode;
}
private isKnownWordCacheEnabled(): boolean {
return this.config.nPlusOne?.highlightEnabled === true;
}
private startKnownWordCacheLifecycle(): void {
this.knownWordCache.startLifecycle();
}
private stopKnownWordCacheLifecycle(): void {
this.knownWordCache.stopLifecycle();
}
private getConfiguredAnkiTags(): string[] {
if (!Array.isArray(this.config.tags)) {
return [];
}
return [...new Set(this.config.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))];
}
private async addConfiguredTagsToNote(noteId: number): Promise {
const tags = this.getConfiguredAnkiTags();
if (tags.length === 0) {
return;
}
try {
await this.client.addTags([noteId], tags);
} catch (error) {
log.warn('Failed to add tags to card:', (error as Error).message);
}
}
async refreshKnownWordCache(): Promise {
return this.knownWordCache.refresh(true);
}
private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void {
if (!this.isKnownWordCacheEnabled()) {
return;
}
this.knownWordCache.appendFromNoteInfo({
noteId: noteInfo.noteId,
fields: noteInfo.fields,
});
}
private getLapisConfig(): {
enabled: boolean;
sentenceCardModel?: string;
} {
const lapis = this.config.isLapis;
return {
enabled: lapis?.enabled === true,
sentenceCardModel: lapis?.sentenceCardModel,
};
}
private getKikuConfig(): {
enabled: boolean;
fieldGrouping?: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto?: boolean;
} {
const kiku = this.config.isKiku;
return {
enabled: kiku?.enabled === true,
fieldGrouping: kiku?.fieldGrouping,
deleteDuplicateInAuto: kiku?.deleteDuplicateInAuto,
};
}
private getEffectiveSentenceCardConfig(): {
model?: string;
sentenceField: string;
audioField: string;
lapisEnabled: boolean;
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean;
} {
const lapis = this.getLapisConfig();
const kiku = this.getKikuConfig();
return {
model: lapis.sentenceCardModel,
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: lapis.enabled,
kikuEnabled: kiku.enabled,
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
};
}
start(): void {
if (this.pollingRunner.isRunning) {
this.stop();
}
log.info('Starting AnkiConnect integration with polling rate:', this.config.pollingRate);
this.startKnownWordCacheLifecycle();
this.pollingRunner.start();
}
stop(): void {
this.pollingRunner.stop();
this.stopKnownWordCacheLifecycle();
log.info('Stopped AnkiConnect integration');
}
private poll(): void {
void this.pollingRunner.poll();
}
private async processNewCard(
noteId: number,
options?: { skipKikuFieldGrouping?: boolean },
): Promise {
this.beginUpdateProgress('Updating card');
try {
const notesInfoResult = await this.client.notesInfo([noteId]);
const notesInfo = notesInfoResult as unknown as NoteInfo[];
if (!notesInfo || notesInfo.length === 0) {
log.warn('Card not found:', noteId);
return;
}
const noteInfo = notesInfo[0];
this.appendKnownWordsFromNoteInfo(noteInfo);
const fields = this.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || '';
if (!expressionText) {
log.warn('No expression/word field found in card:', noteId);
return;
}
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
if (
!options?.skipKikuFieldGrouping &&
sentenceCardConfig.kikuEnabled &&
sentenceCardConfig.kikuFieldGrouping !== 'disabled'
) {
const duplicateNoteId = await this.findDuplicateNote(expressionText, noteId, noteInfo);
if (duplicateNoteId !== null) {
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
await this.handleFieldGroupingAuto(duplicateNoteId, noteId, noteInfo, expressionText);
return;
} else if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
const handled = await this.handleFieldGroupingManual(
duplicateNoteId,
noteId,
noteInfo,
expressionText,
);
if (handled) return;
}
}
}
const updatedFields: Record = {};
let updatePerformed = false;
let miscInfoFilename: string | null = null;
const sentenceField = sentenceCardConfig.sentenceField;
if (sentenceField && this.mpvClient.currentSubText) {
const processedSentence = this.processSentence(this.mpvClient.currentSubText, fields);
updatedFields[sentenceField] = processedSentence;
updatePerformed = true;
}
if (this.config.media?.generateAudio && this.mpvClient) {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.generateAudio();
if (audioBuffer) {
await this.client.storeMediaFile(audioFilename, audioBuffer);
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
if (sentenceAudioField) {
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
updatedFields[sentenceAudioField] = this.mergeFieldValue(
existingAudio,
`[sound:${audioFilename}]`,
this.config.behavior?.overwriteAudio !== false,
);
}
miscInfoFilename = audioFilename;
updatePerformed = true;
}
} catch (error) {
log.error('Failed to generate audio:', (error as Error).message);
this.showOsdNotification(`Audio generation failed: ${(error as Error).message}`);
}
}
let imageBuffer: Buffer | null = null;
if (this.config.media?.generateImage && this.mpvClient) {
try {
const imageFilename = this.generateImageFilename();
imageBuffer = await this.generateImage();
if (imageBuffer) {
await this.client.storeMediaFile(imageFilename, imageBuffer);
const imageFieldName = this.resolveConfiguredFieldName(
noteInfo,
this.config.fields?.image,
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
);
if (!imageFieldName) {
log.warn('Image field not found on note, skipping image update');
} else {
const existingImage = noteInfo.fields[imageFieldName]?.value || '';
updatedFields[imageFieldName] = this.mergeFieldValue(
existingImage,
`
`,
this.config.behavior?.overwriteImage !== false,
);
miscInfoFilename = imageFilename;
updatePerformed = true;
}
}
} catch (error) {
log.error('Failed to generate image:', (error as Error).message);
this.showOsdNotification(`Image generation failed: ${(error as Error).message}`);
}
}
if (this.config.fields?.miscInfo) {
const miscInfo = this.formatMiscInfoPattern(
miscInfoFilename || '',
this.mpvClient.currentSubStart,
);
const miscInfoField = this.resolveConfiguredFieldName(
noteInfo,
this.config.fields?.miscInfo,
);
if (miscInfo && miscInfoField) {
updatedFields[miscInfoField] = miscInfo;
updatePerformed = true;
}
}
if (updatePerformed) {
await this.client.updateNoteFields(noteId, updatedFields);
await this.addConfiguredTagsToNote(noteId);
log.info('Updated card fields for:', expressionText);
await this.showNotification(noteId, expressionText);
}
} catch (error) {
if ((error as Error).message.includes('note was not found')) {
log.warn('Card was deleted before update:', noteId);
} else {
log.error('Error processing new card:', (error as Error).message);
}
} finally {
this.endUpdateProgress();
}
}
private extractFields(fields: Record): Record {
const result: Record = {};
for (const [key, value] of Object.entries(fields)) {
result[key.toLowerCase()] = value.value || '';
}
return result;
}
private processSentence(mpvSentence: string, noteFields: Record): string {
if (this.config.behavior?.highlightWord === false) {
return mpvSentence;
}
const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence';
const existingSentence = noteFields[sentenceFieldName] || '';
const highlightMatch = existingSentence.match(/(.*?)<\/b>/);
if (!highlightMatch || !highlightMatch[1]) {
return mpvSentence;
}
const highlightedText = highlightMatch[1];
const index = mpvSentence.indexOf(highlightedText);
if (index === -1) {
return mpvSentence;
}
const prefix = mpvSentence.substring(0, index);
const suffix = mpvSentence.substring(index + highlightedText.length);
return `${prefix}${highlightedText}${suffix}`;
}
private async generateAudio(): Promise {
const mpvClient = this.mpvClient;
if (!mpvClient || !mpvClient.currentVideoPath) {
return null;
}
const videoPath = mpvClient.currentVideoPath;
let startTime = mpvClient.currentSubStart;
let endTime = mpvClient.currentSubEnd;
if (startTime === undefined || endTime === undefined) {
const currentTime = mpvClient.currentTimePos || 0;
const fallback = this.getFallbackDurationSeconds() / 2;
startTime = currentTime - fallback;
endTime = currentTime + fallback;
}
return this.mediaGenerator.generateAudio(
videoPath,
startTime,
endTime,
this.config.media?.audioPadding,
this.mpvClient.currentAudioStreamIndex,
);
}
private async generateImage(): Promise {
if (!this.mpvClient || !this.mpvClient.currentVideoPath) {
return null;
}
const videoPath = this.mpvClient.currentVideoPath;
const timestamp = this.mpvClient.currentTimePos || 0;
if (this.config.media?.imageType === 'avif') {
let startTime = this.mpvClient.currentSubStart;
let endTime = this.mpvClient.currentSubEnd;
if (startTime === undefined || endTime === undefined) {
const fallback = this.getFallbackDurationSeconds() / 2;
startTime = timestamp - fallback;
endTime = timestamp + fallback;
}
return this.mediaGenerator.generateAnimatedImage(
videoPath,
startTime,
endTime,
this.config.media?.audioPadding,
{
fps: this.config.media?.animatedFps,
maxWidth: this.config.media?.animatedMaxWidth,
maxHeight: this.config.media?.animatedMaxHeight,
crf: this.config.media?.animatedCrf,
},
);
} else {
return this.mediaGenerator.generateScreenshot(videoPath, timestamp, {
format: this.config.media?.imageFormat as 'jpg' | 'png' | 'webp',
quality: this.config.media?.imageQuality,
maxWidth: this.config.media?.imageMaxWidth,
maxHeight: this.config.media?.imageMaxHeight,
});
}
}
private formatMiscInfoPattern(fallbackFilename: string, startTimeSeconds?: number): string {
if (!this.config.metadata?.pattern) {
return '';
}
const currentVideoPath = this.mpvClient.currentVideoPath || '';
const videoFilename = currentVideoPath ? path.basename(currentVideoPath) : '';
const filenameWithExt = videoFilename || fallbackFilename;
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const currentTimePos =
typeof startTimeSeconds === 'number' && Number.isFinite(startTimeSeconds)
? startTimeSeconds
: this.mpvClient.currentTimePos;
let totalMilliseconds = 0;
if (Number.isFinite(currentTimePos) && currentTimePos >= 0) {
totalMilliseconds = Math.floor(currentTimePos * 1000);
} else {
const now = new Date();
totalMilliseconds =
now.getHours() * 3600000 +
now.getMinutes() * 60000 +
now.getSeconds() * 1000 +
now.getMilliseconds();
}
const totalSeconds = Math.floor(totalMilliseconds / 1000);
const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0');
const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0');
const seconds = String(totalSeconds % 60).padStart(2, '0');
const milliseconds = String(totalMilliseconds % 1000).padStart(3, '0');
let result = this.config.metadata?.pattern
.replace(/%f/g, filenameWithoutExt)
.replace(/%F/g, filenameWithExt)
.replace(/%t/g, `${hours}:${minutes}:${seconds}`)
.replace(/%T/g, `${hours}:${minutes}:${seconds}:${milliseconds}`)
.replace(/
/g, '\n');
return result;
}
private getFallbackDurationSeconds(): number {
const configured = this.config.media?.fallbackDuration;
if (typeof configured === 'number' && Number.isFinite(configured) && configured > 0) {
return configured;
}
return DEFAULT_ANKI_CONNECT_CONFIG.media.fallbackDuration;
}
private generateAudioFilename(): string {
const timestamp = Date.now();
return `audio_${timestamp}.mp3`;
}
private generateImageFilename(): string {
const timestamp = Date.now();
const ext = this.config.media?.imageType === 'avif' ? 'avif' : this.config.media?.imageFormat;
return `image_${timestamp}.${ext}`;
}
private showStatusNotification(message: string): void {
showStatusNotification(message, {
getNotificationType: () => this.config.behavior?.notificationType,
showOsd: (text: string) => {
this.showOsdNotification(text);
},
showSystemNotification: (title: string, options: NotificationOptions) => {
if (this.notificationCallback) {
this.notificationCallback(title, options);
}
},
});
}
private beginUpdateProgress(initialMessage: string): void {
beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => {
this.showOsdNotification(text);
});
}
private endUpdateProgress(): void {
endUpdateProgress(this.uiFeedbackState, (timer) => {
clearInterval(timer);
});
}
private showProgressTick(): void {
showProgressTick(this.uiFeedbackState, (text: string) => {
this.showOsdNotification(text);
});
}
private async withUpdateProgress(
initialMessage: string,
action: () => Promise,
): Promise {
return withUpdateProgress(
this.uiFeedbackState,
{
setUpdateInProgress: (value: boolean) => {
this.updateInProgress = value;
},
showOsdNotification: (text: string) => {
this.showOsdNotification(text);
},
},
initialMessage,
action,
);
}
private showOsdNotification(text: string): void {
if (this.osdCallback) {
this.osdCallback(text);
} else if (this.mpvClient && this.mpvClient.send) {
this.mpvClient.send({
command: ['show-text', text, '3000'],
});
}
}
private resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
const exact = availableFieldNames.find((name) => name === preferredName);
if (exact) return exact;
const lower = preferredName.toLowerCase();
const ci = availableFieldNames.find((name) => name.toLowerCase() === lower);
return ci || null;
}
private resolveNoteFieldName(noteInfo: NoteInfo, preferredName?: string): string | null {
if (!preferredName) return null;
return this.resolveFieldName(Object.keys(noteInfo.fields), preferredName);
}
private resolveConfiguredFieldName(
noteInfo: NoteInfo,
...preferredNames: (string | undefined)[]
): string | null {
for (const preferredName of preferredNames) {
const resolved = this.resolveNoteFieldName(noteInfo, preferredName);
if (resolved) return resolved;
}
return null;
}
private warnFieldParseOnce(fieldName: string, reason: string, detail?: string): void {
const key = `${fieldName.toLowerCase()}::${reason}`;
if (this.parseWarningKeys.has(key)) return;
this.parseWarningKeys.add(key);
const suffix = detail ? ` (${detail})` : '';
log.warn(`Field grouping parse warning [${fieldName}] ${reason}${suffix}`);
}
private setCardTypeFields(
updatedFields: Record,
availableFieldNames: string[],
cardKind: CardKind,
): void {
const audioFlagNames = ['IsAudioCard'];
if (cardKind === 'sentence') {
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
if (sentenceFlag) {
updatedFields[sentenceFlag] = 'x';
}
for (const audioFlagName of audioFlagNames) {
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
if (resolved && resolved !== sentenceFlag) {
updatedFields[resolved] = '';
}
}
const wordAndSentenceFlag = this.resolveFieldName(
availableFieldNames,
'IsWordAndSentenceCard',
);
if (wordAndSentenceFlag && wordAndSentenceFlag !== sentenceFlag) {
updatedFields[wordAndSentenceFlag] = '';
}
return;
}
const resolvedAudioFlags = Array.from(
new Set(
audioFlagNames
.map((name) => this.resolveFieldName(availableFieldNames, name))
.filter((name): name is string => Boolean(name)),
),
);
const audioFlagName = resolvedAudioFlags[0] || null;
if (audioFlagName) {
updatedFields[audioFlagName] = 'x';
}
for (const extraAudioFlag of resolvedAudioFlags.slice(1)) {
updatedFields[extraAudioFlag] = '';
}
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
if (sentenceFlag && sentenceFlag !== audioFlagName) {
updatedFields[sentenceFlag] = '';
}
const wordAndSentenceFlag = this.resolveFieldName(availableFieldNames, 'IsWordAndSentenceCard');
if (wordAndSentenceFlag && wordAndSentenceFlag !== audioFlagName) {
updatedFields[wordAndSentenceFlag] = '';
}
}
private async showNotification(
noteId: number,
label: string | number,
errorSuffix?: string,
): Promise {
const message = errorSuffix
? `Updated card: ${label} (${errorSuffix})`
: `Updated card: ${label}`;
const type = this.config.behavior?.notificationType || 'osd';
if (type === 'osd' || type === 'both') {
this.showOsdNotification(message);
}
if ((type === 'system' || type === 'both') && this.notificationCallback) {
let notificationIconPath: string | undefined;
if (this.mpvClient && this.mpvClient.currentVideoPath) {
try {
const timestamp = this.mpvClient.currentTimePos || 0;
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
this.mpvClient.currentVideoPath,
timestamp,
);
if (iconBuffer && iconBuffer.length > 0) {
notificationIconPath = this.mediaGenerator.writeNotificationIconToFile(
iconBuffer,
noteId,
);
}
} catch (err) {
log.warn('Failed to generate notification icon:', (err as Error).message);
}
}
this.notificationCallback('Anki Card Updated', {
body: message,
icon: notificationIconPath,
});
if (notificationIconPath) {
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath);
}
}
}
private mergeFieldValue(existing: string, newValue: string, overwrite: boolean): string {
if (overwrite || !existing.trim()) {
return newValue;
}
if (this.config.behavior?.mediaInsertMode === 'prepend') {
return newValue + existing;
}
return existing + newValue;
}
/**
* Update the last added Anki card using subtitle blocks from clipboard.
* This is the manual update flow (animecards-style) when auto-update is disabled.
*/
async updateLastAddedFromClipboard(clipboardText: string): Promise {
return this.cardCreationService.updateLastAddedFromClipboard(clipboardText);
}
async triggerFieldGroupingForLastAddedCard(): Promise {
return this.fieldGroupingService.triggerFieldGroupingForLastAddedCard();
}
async markLastCardAsAudioCard(): Promise {
return this.cardCreationService.markLastCardAsAudioCard();
}
async createSentenceCard(
sentence: string,
startTime: number,
endTime: number,
secondarySubText?: string,
): Promise {
return this.cardCreationService.createSentenceCard(
sentence,
startTime,
endTime,
secondarySubText,
);
}
private async findDuplicateNote(
expression: string,
excludeNoteId: number,
noteInfo: NoteInfo,
): Promise {
return findDuplicateNoteForAnkiIntegration(expression, excludeNoteId, noteInfo, {
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,
resolveFieldName: (info, preferredName) => this.resolveNoteFieldName(info, preferredName),
logWarn: (message, error) => {
log.warn(message, (error as Error).message);
},
});
}
private getGroupableFieldNames(): string[] {
const fields: string[] = [];
fields.push('Sentence');
fields.push('SentenceAudio');
fields.push('Picture');
if (this.config.fields?.image) fields.push(this.config.fields?.image);
if (this.config.fields?.sentence) fields.push(this.config.fields?.sentence);
if (
this.config.fields?.audio &&
this.config.fields?.audio.toLowerCase() !== 'expressionaudio'
) {
fields.push(this.config.fields?.audio);
}
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
const sentenceAudioField = sentenceCardConfig.audioField;
if (!fields.includes(sentenceAudioField)) fields.push(sentenceAudioField);
if (this.config.fields?.miscInfo) fields.push(this.config.fields?.miscInfo);
fields.push('SentenceFurigana');
return fields;
}
private getPreferredSentenceAudioFieldName(): string {
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
return sentenceCardConfig.audioField || 'SentenceAudio';
}
private getResolvedSentenceAudioFieldName(noteInfo: NoteInfo): string | null {
return (
this.resolveNoteFieldName(noteInfo, this.getPreferredSentenceAudioFieldName()) ||
this.resolveConfiguredFieldName(noteInfo, this.config.fields?.audio)
);
}
private extractUngroupedValue(value: string): string {
const groupedSpanRegex = /[\s\S]*?<\/span>/gi;
const ungrouped = value.replace(groupedSpanRegex, '').trim();
if (ungrouped) return ungrouped;
return value.trim();
}
private extractLastSoundTag(value: string): string {
const matches = value.match(/\[sound:[^\]]+\]/g);
if (!matches || matches.length === 0) return '';
return matches[matches.length - 1];
}
private extractLastImageTag(value: string): string {
const matches = value.match(/
]*>/gi);
if (!matches || matches.length === 0) return '';
return matches[matches.length - 1];
}
private extractImageTags(value: string): string[] {
const matches = value.match(/
]*>/gi);
return matches || [];
}
private ensureImageGroupId(imageTag: string, groupId: number): string {
if (!imageTag) return '';
if (/data-group-id=/i.test(imageTag)) {
return imageTag.replace(/data-group-id="[^"]*"/i, `data-group-id="${groupId}"`);
}
return imageTag.replace(/
]*data-group-id="([^"]*)"[^>]*>/gi;
let malformed;
while ((malformed = malformedIdRegex.exec(value)) !== null) {
const rawId = malformed[1];
const groupId = Number(rawId);
if (!Number.isFinite(groupId) || groupId <= 0) {
this.warnFieldParseOnce(fieldName, 'invalid-group-id', rawId);
}
}
const spanRegex = /]*>([\s\S]*?)<\/span>/gi;
let match;
while ((match = spanRegex.exec(value)) !== null) {
const groupId = Number(match[1]);
if (!Number.isFinite(groupId) || groupId <= 0) continue;
const content = this.normalizeStrictGroupedValue(match[2] || '', fieldName);
if (!content) {
this.warnFieldParseOnce(fieldName, 'empty-group-content');
log.debug('Skipping span with empty normalized content', {
fieldName,
rawContent: (match[2] || '').slice(0, 120),
});
continue;
}
entries.push({ groupId, content });
}
if (entries.length === 0 && /();
for (const entry of entries) {
const key = `${entry.groupId}::${entry.content}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(entry);
}
return unique;
}
private parsePictureEntries(
value: string,
fallbackGroupId: number,
): { groupId: number; tag: string }[] {
const tags = this.extractImageTags(value);
const result: { groupId: number; tag: string }[] = [];
for (const tag of tags) {
const idMatch = tag.match(/data-group-id="(\d+)"/i);
let groupId = fallbackGroupId;
if (idMatch) {
const parsed = Number(idMatch[1]);
if (!Number.isFinite(parsed) || parsed <= 0) {
this.warnFieldParseOnce('Picture', 'invalid-group-id', idMatch[1]);
} else {
groupId = parsed;
}
}
const normalizedTag = this.ensureImageGroupId(tag, groupId);
if (!normalizedTag) {
this.warnFieldParseOnce('Picture', 'empty-image-tag');
continue;
}
result.push({ groupId, tag: normalizedTag });
}
return result;
}
private normalizeStrictGroupedValue(value: string, fieldName: string): string {
const ungrouped = this.extractUngroupedValue(value);
if (!ungrouped) return '';
const normalizedField = fieldName.toLowerCase();
if (normalizedField === 'sentenceaudio' || normalizedField === 'expressionaudio') {
const lastSoundTag = this.extractLastSoundTag(ungrouped);
if (!lastSoundTag) {
this.warnFieldParseOnce(fieldName, 'missing-sound-tag');
}
return lastSoundTag || ungrouped;
}
if (normalizedField === 'picture') {
const lastImageTag = this.extractLastImageTag(ungrouped);
if (!lastImageTag) {
this.warnFieldParseOnce(fieldName, 'missing-image-tag');
}
return lastImageTag || ungrouped;
}
return ungrouped;
}
private getStrictSpanGroupingFields(): Set {
const strictFields = new Set(this.strictGroupingFieldDefaults);
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
strictFields.add((sentenceCardConfig.sentenceField || 'sentence').toLowerCase());
strictFields.add((sentenceCardConfig.audioField || 'sentenceaudio').toLowerCase());
if (this.config.fields?.image) strictFields.add(this.config.fields.image.toLowerCase());
if (this.config.fields?.miscInfo) strictFields.add(this.config.fields.miscInfo.toLowerCase());
return strictFields;
}
private shouldUseStrictSpanGrouping(fieldName: string): boolean {
const normalized = fieldName.toLowerCase();
return this.getStrictSpanGroupingFields().has(normalized);
}
private applyFieldGrouping(
existingValue: string,
newValue: string,
keepGroupId: number,
sourceGroupId: number,
fieldName: string,
): string {
if (this.shouldUseStrictSpanGrouping(fieldName)) {
if (fieldName.toLowerCase() === 'picture') {
const keepEntries = this.parsePictureEntries(existingValue, keepGroupId);
const sourceEntries = this.parsePictureEntries(newValue, sourceGroupId);
if (keepEntries.length === 0 && sourceEntries.length === 0) {
return existingValue || newValue;
}
const mergedTags = keepEntries.map((entry) =>
this.ensureImageGroupId(entry.tag, entry.groupId),
);
const seen = new Set(mergedTags);
for (const entry of sourceEntries) {
const normalized = this.ensureImageGroupId(entry.tag, entry.groupId);
if (seen.has(normalized)) continue;
seen.add(normalized);
mergedTags.push(normalized);
}
return mergedTags.join('');
}
const keepEntries = this.parseStrictEntries(existingValue, keepGroupId, fieldName);
const sourceEntries = this.parseStrictEntries(newValue, sourceGroupId, fieldName);
if (keepEntries.length === 0 && sourceEntries.length === 0) {
return existingValue || newValue;
}
if (sourceEntries.length === 0) {
return keepEntries
.map((entry) => `${entry.content}`)
.join('');
}
const merged = [...keepEntries];
const seen = new Set(keepEntries.map((entry) => `${entry.groupId}::${entry.content}`));
for (const entry of sourceEntries) {
const key = `${entry.groupId}::${entry.content}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(entry);
}
if (merged.length === 0) return existingValue;
return merged
.map((entry) => `${entry.content}`)
.join('');
}
if (!existingValue.trim()) return newValue;
if (!newValue.trim()) return existingValue;
const hasGroups = /data-group-id/.test(existingValue);
if (!hasGroups) {
return `${existingValue}\n` + newValue;
}
const groupedSpanRegex = /[\s\S]*?<\/span>/g;
let lastEnd = 0;
let result = '';
let match;
while ((match = groupedSpanRegex.exec(existingValue)) !== null) {
const before = existingValue.slice(lastEnd, match.index);
if (before.trim()) {
result += `${before.trim()}\n`;
}
result += match[0] + '\n';
lastEnd = match.index + match[0].length;
}
const after = existingValue.slice(lastEnd);
if (after.trim()) {
result += `\n${after.trim()}`;
}
return result + '\n' + newValue;
}
private async generateMediaForMerge(): Promise<{
audioField?: string;
audioValue?: string;
imageField?: string;
imageValue?: string;
miscInfoValue?: string;
}> {
const result: {
audioField?: string;
audioValue?: string;
imageField?: string;
imageValue?: string;
miscInfoValue?: string;
} = {};
if (this.config.media?.generateAudio && this.mpvClient?.currentVideoPath) {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.generateAudio();
if (audioBuffer) {
await this.client.storeMediaFile(audioFilename, audioBuffer);
result.audioField = this.getPreferredSentenceAudioFieldName();
result.audioValue = `[sound:${audioFilename}]`;
if (this.config.fields?.miscInfo) {
result.miscInfoValue = this.formatMiscInfoPattern(
audioFilename,
this.mpvClient.currentSubStart,
);
}
}
} catch (error) {
log.error('Failed to generate audio for merge:', (error as Error).message);
}
}
if (this.config.media?.generateImage && this.mpvClient?.currentVideoPath) {
try {
const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImage();
if (imageBuffer) {
await this.client.storeMediaFile(imageFilename, imageBuffer);
result.imageField = this.config.fields?.image || DEFAULT_ANKI_CONNECT_CONFIG.fields.image;
result.imageValue = `
`;
if (this.config.fields?.miscInfo && !result.miscInfoValue) {
result.miscInfoValue = this.formatMiscInfoPattern(
imageFilename,
this.mpvClient.currentSubStart,
);
}
}
} catch (error) {
log.error('Failed to generate image for merge:', (error as Error).message);
}
}
return result;
}
private getResolvedFieldValue(noteInfo: NoteInfo, preferredFieldName?: string): string {
if (!preferredFieldName) return '';
const resolved = this.resolveNoteFieldName(noteInfo, preferredFieldName);
if (!resolved) return '';
return noteInfo.fields[resolved]?.value || '';
}
private async computeFieldGroupingMergedFields(
keepNoteId: number,
deleteNoteId: number,
keepNoteInfo: NoteInfo,
deleteNoteInfo: NoteInfo,
includeGeneratedMedia: boolean,
): Promise> {
const groupableFields = this.getGroupableFieldNames();
const keepFieldNames = Object.keys(keepNoteInfo.fields);
const sourceFields: Record = {};
const resolvedKeepFieldByPreferred = new Map();
for (const preferredFieldName of groupableFields) {
sourceFields[preferredFieldName] = this.getResolvedFieldValue(
deleteNoteInfo,
preferredFieldName,
);
const keepResolved = this.resolveFieldName(keepFieldNames, preferredFieldName);
if (keepResolved) {
resolvedKeepFieldByPreferred.set(preferredFieldName, keepResolved);
}
}
if (!sourceFields['SentenceFurigana'] && sourceFields['Sentence']) {
sourceFields['SentenceFurigana'] = sourceFields['Sentence'];
}
if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) {
sourceFields['Sentence'] = sourceFields['SentenceFurigana'];
}
if (!sourceFields['Expression'] && sourceFields['Word']) {
sourceFields['Expression'] = sourceFields['Word'];
}
if (!sourceFields['Word'] && sourceFields['Expression']) {
sourceFields['Word'] = sourceFields['Expression'];
}
if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) {
sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio'];
}
if (!sourceFields['ExpressionAudio'] && sourceFields['SentenceAudio']) {
sourceFields['ExpressionAudio'] = sourceFields['SentenceAudio'];
}
if (
this.config.fields?.sentence &&
!sourceFields[this.config.fields?.sentence] &&
this.mpvClient.currentSubText
) {
const deleteFields = this.extractFields(deleteNoteInfo.fields);
sourceFields[this.config.fields?.sentence] = this.processSentence(
this.mpvClient.currentSubText,
deleteFields,
);
}
if (includeGeneratedMedia) {
const media = await this.generateMediaForMerge();
if (media.audioField && media.audioValue && !sourceFields[media.audioField]) {
sourceFields[media.audioField] = media.audioValue;
}
if (media.imageField && media.imageValue && !sourceFields[media.imageField]) {
sourceFields[media.imageField] = media.imageValue;
}
if (
this.config.fields?.miscInfo &&
media.miscInfoValue &&
!sourceFields[this.config.fields?.miscInfo]
) {
sourceFields[this.config.fields?.miscInfo] = media.miscInfoValue;
}
}
const mergedFields: Record = {};
for (const preferredFieldName of groupableFields) {
const keepFieldName = resolvedKeepFieldByPreferred.get(preferredFieldName);
if (!keepFieldName) continue;
const keepFieldNormalized = keepFieldName.toLowerCase();
if (
keepFieldNormalized === 'expression' ||
keepFieldNormalized === 'expressionfurigana' ||
keepFieldNormalized === 'expressionreading' ||
keepFieldNormalized === 'expressionaudio'
) {
continue;
}
const existingValue = keepNoteInfo.fields[keepFieldName]?.value || '';
const newValue = sourceFields[preferredFieldName] || '';
const isStrictField = this.shouldUseStrictSpanGrouping(keepFieldName);
if (!existingValue.trim() && !newValue.trim()) continue;
if (isStrictField) {
mergedFields[keepFieldName] = this.applyFieldGrouping(
existingValue,
newValue,
keepNoteId,
deleteNoteId,
keepFieldName,
);
} else if (existingValue.trim() && newValue.trim()) {
mergedFields[keepFieldName] = this.applyFieldGrouping(
existingValue,
newValue,
keepNoteId,
deleteNoteId,
keepFieldName,
);
} else {
if (!newValue.trim()) continue;
mergedFields[keepFieldName] = newValue;
}
}
// Keep sentence/expression audio fields aligned after grouping. Otherwise a
// kept note can retain stale ExpressionAudio while SentenceAudio is merged.
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
const resolvedSentenceAudioField = this.resolveFieldName(
keepFieldNames,
sentenceCardConfig.audioField || 'SentenceAudio',
);
const resolvedExpressionAudioField = this.resolveFieldName(
keepFieldNames,
this.config.fields?.audio || 'ExpressionAudio',
);
if (
resolvedSentenceAudioField &&
resolvedExpressionAudioField &&
resolvedExpressionAudioField !== resolvedSentenceAudioField
) {
const mergedSentenceAudioValue =
mergedFields[resolvedSentenceAudioField] ||
keepNoteInfo.fields[resolvedSentenceAudioField]?.value ||
'';
if (mergedSentenceAudioValue.trim()) {
mergedFields[resolvedExpressionAudioField] = mergedSentenceAudioValue;
}
}
return mergedFields;
}
private getNoteFieldMap(noteInfo: NoteInfo): Record {
const fields: Record = {};
for (const [name, field] of Object.entries(noteInfo.fields)) {
fields[name] = field?.value || '';
}
return fields;
}
async buildFieldGroupingPreview(
keepNoteId: number,
deleteNoteId: number,
deleteDuplicate: boolean,
): Promise {
return this.fieldGroupingService.buildFieldGroupingPreview(
keepNoteId,
deleteNoteId,
deleteDuplicate,
);
}
private async performFieldGroupingMerge(
keepNoteId: number,
deleteNoteId: number,
deleteNoteInfo: NoteInfo,
expression: string,
deleteDuplicate = true,
): Promise {
const keepNotesInfoResult = await this.client.notesInfo([keepNoteId]);
const keepNotesInfo = keepNotesInfoResult as unknown as NoteInfo[];
if (!keepNotesInfo || keepNotesInfo.length === 0) {
log.warn('Keep note not found:', keepNoteId);
return;
}
const keepNoteInfo = keepNotesInfo[0];
const mergedFields = await this.computeFieldGroupingMergedFields(
keepNoteId,
deleteNoteId,
keepNoteInfo,
deleteNoteInfo,
true,
);
if (Object.keys(mergedFields).length > 0) {
await this.client.updateNoteFields(keepNoteId, mergedFields);
await this.addConfiguredTagsToNote(keepNoteId);
}
if (deleteDuplicate) {
await this.client.deleteNotes([deleteNoteId]);
this.previousNoteIds.delete(deleteNoteId);
}
log.info('Merged duplicate card:', expression, 'into note:', keepNoteId);
this.showStatusNotification(
deleteDuplicate
? `Merged duplicate: ${expression}`
: `Grouped duplicate (kept both): ${expression}`,
);
await this.showNotification(keepNoteId, expression);
}
private async handleFieldGroupingAuto(
originalNoteId: number,
newNoteId: number,
newNoteInfo: NoteInfo,
expression: string,
): Promise {
try {
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
await this.performFieldGroupingMerge(
originalNoteId,
newNoteId,
newNoteInfo,
expression,
sentenceCardConfig.kikuDeleteDuplicateInAuto,
);
} catch (error) {
log.error('Field grouping auto merge failed:', (error as Error).message);
this.showOsdNotification(`Field grouping failed: ${(error as Error).message}`);
}
}
private async handleFieldGroupingManual(
originalNoteId: number,
newNoteId: number,
newNoteInfo: NoteInfo,
expression: string,
): Promise {
if (!this.fieldGroupingCallback) {
log.warn('No field grouping callback registered, skipping manual mode');
this.showOsdNotification('Field grouping UI unavailable');
return false;
}
try {
const originalNotesInfoResult = await this.client.notesInfo([originalNoteId]);
const originalNotesInfo = originalNotesInfoResult as unknown as NoteInfo[];
if (!originalNotesInfo || originalNotesInfo.length === 0) {
return false;
}
const originalNoteInfo = originalNotesInfo[0];
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
const originalFields = this.extractFields(originalNoteInfo.fields);
const newFields = this.extractFields(newNoteInfo.fields);
const originalCard: KikuDuplicateCardInfo = {
noteId: originalNoteId,
expression: originalFields.expression || originalFields.word || expression,
sentencePreview: this.truncateSentence(
originalFields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] || '',
),
hasAudio:
this.hasFieldValue(originalNoteInfo, this.config.fields?.audio) ||
this.hasFieldValue(originalNoteInfo, sentenceCardConfig.audioField),
hasImage: this.hasFieldValue(originalNoteInfo, this.config.fields?.image),
isOriginal: true,
};
const newCard: KikuDuplicateCardInfo = {
noteId: newNoteId,
expression: newFields.expression || newFields.word || expression,
sentencePreview: this.truncateSentence(
newFields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] ||
this.mpvClient.currentSubText ||
'',
),
hasAudio:
this.hasFieldValue(newNoteInfo, this.config.fields?.audio) ||
this.hasFieldValue(newNoteInfo, sentenceCardConfig.audioField),
hasImage: this.hasFieldValue(newNoteInfo, this.config.fields?.image),
isOriginal: false,
};
const choice = await this.fieldGroupingCallback({
original: originalCard,
duplicate: newCard,
});
if (choice.cancelled) {
this.showOsdNotification('Field grouping cancelled');
return false;
}
const keepNoteId = choice.keepNoteId;
const deleteNoteId = choice.deleteNoteId;
const deleteNoteInfo = deleteNoteId === newNoteId ? newNoteInfo : originalNoteInfo;
await this.performFieldGroupingMerge(
keepNoteId,
deleteNoteId,
deleteNoteInfo,
expression,
choice.deleteDuplicate,
);
return true;
} catch (error) {
log.error('Field grouping manual merge failed:', (error as Error).message);
this.showOsdNotification(`Field grouping failed: ${(error as Error).message}`);
return false;
}
}
private truncateSentence(sentence: string): string {
const clean = sentence.replace(/<[^>]*>/g, '').trim();
if (clean.length <= 100) return clean;
return clean.substring(0, 100) + '...';
}
private hasFieldValue(noteInfo: NoteInfo, preferredFieldName?: string): boolean {
const resolved = this.resolveNoteFieldName(noteInfo, preferredFieldName);
if (!resolved) return false;
return Boolean(noteInfo.fields[resolved]?.value);
}
private hasAllConfiguredFields(
noteInfo: NoteInfo,
configuredFieldNames: (string | undefined)[],
): boolean {
const requiredFields = configuredFieldNames.filter((fieldName): fieldName is string =>
Boolean(fieldName),
);
if (requiredFields.length === 0) return true;
return requiredFields.every((fieldName) => this.hasFieldValue(noteInfo, fieldName));
}
private async refreshMiscInfoField(noteId: number, noteInfo: NoteInfo): Promise {
if (!this.config.fields?.miscInfo || !this.config.metadata?.pattern) return;
const resolvedMiscField = this.resolveNoteFieldName(noteInfo, this.config.fields?.miscInfo);
if (!resolvedMiscField) return;
const nextValue = this.formatMiscInfoPattern('', this.mpvClient.currentSubStart);
if (!nextValue) return;
const currentValue = noteInfo.fields[resolvedMiscField]?.value || '';
if (currentValue === nextValue) return;
await this.client.updateNoteFields(noteId, {
[resolvedMiscField]: nextValue,
});
await this.addConfiguredTagsToNote(noteId);
}
applyRuntimeConfigPatch(patch: Partial): void {
const wasEnabled = this.config.nPlusOne?.highlightEnabled === true;
const previousPollingRate = this.config.pollingRate;
this.config = {
...this.config,
...patch,
nPlusOne:
patch.nPlusOne !== undefined
? {
...(this.config.nPlusOne ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne),
...patch.nPlusOne,
}
: this.config.nPlusOne,
fields:
patch.fields !== undefined
? { ...this.config.fields, ...patch.fields }
: this.config.fields,
media:
patch.media !== undefined ? { ...this.config.media, ...patch.media } : this.config.media,
behavior:
patch.behavior !== undefined
? { ...this.config.behavior, ...patch.behavior }
: this.config.behavior,
metadata:
patch.metadata !== undefined
? { ...this.config.metadata, ...patch.metadata }
: this.config.metadata,
isLapis:
patch.isLapis !== undefined
? { ...this.config.isLapis, ...patch.isLapis }
: this.config.isLapis,
isKiku:
patch.isKiku !== undefined
? { ...this.config.isKiku, ...patch.isKiku }
: this.config.isKiku,
};
if (wasEnabled && this.config.nPlusOne?.highlightEnabled === false) {
this.stopKnownWordCacheLifecycle();
this.knownWordCache.clearKnownWordCacheState();
} else {
this.startKnownWordCacheLifecycle();
}
if (
patch.pollingRate !== undefined &&
previousPollingRate !== this.config.pollingRate &&
this.pollingRunner.isRunning
) {
this.pollingRunner.start();
}
}
destroy(): void {
this.stop();
this.mediaGenerator.cleanup();
}
}