/* * 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 * as path from "path"; import * as fs from "fs"; 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 { resolveSentenceBackText, } from "./anki-integration/ai"; import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from "./anki-integration-duplicate"; const log = createLogger("anki").child("integration"); interface NoteInfo { noteId: number; fields: Record; } interface KnownWordCacheState { readonly version: 1; readonly refreshedAtMs: number; readonly scope: string; readonly words: string[]; } type CardKind = "sentence" | "audio"; export class AnkiIntegration { private client: AnkiConnectClient; private mediaGenerator: MediaGenerator; private timingTracker: SubtitleTimingTracker; private config: AnkiConnectConfig; private pollingInterval: ReturnType | null = null; private previousNoteIds = new Set(); private initialized = false; private backoffMs = 200; private maxBackoffMs = 5000; private nextPollTime = 0; 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 readonly knownWordCacheStatePath: string; private knownWordsLastRefreshedAtMs = 0; private knownWordsScope = ""; private knownWords: Set = new Set(); private knownWordsRefreshTimer: ReturnType | null = null; private isRefreshingKnownWords = false; 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.knownWordCacheStatePath = path.normalize( knownWordCacheStatePath || path.join(process.cwd(), "known-words-cache.json"), ); } isKnownWord(text: string): boolean { if (!this.isKnownWordCacheEnabled()) { return false; } const normalized = this.normalizeKnownWordForLookup(text); return normalized.length > 0 ? this.knownWords.has(normalized) : false; } getKnownWordMatchMode(): NPlusOneMatchMode { return ( this.config.nPlusOne?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.matchMode ); } private isKnownWordCacheEnabled(): boolean { return this.config.nPlusOne?.highlightEnabled === true; } private getKnownWordRefreshIntervalMs(): number { const minutes = this.config.nPlusOne?.refreshMinutes; const safeMinutes = typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0 ? minutes : DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.refreshMinutes; return safeMinutes * 60_000; } private startKnownWordCacheLifecycle(): void { this.stopKnownWordCacheLifecycle(); if (!this.isKnownWordCacheEnabled()) { log.info("Known-word cache disabled; clearing local cache state"); this.clearKnownWordCacheState(); return; } const refreshMinutes = this.getKnownWordRefreshIntervalMs() / 60_000; const scope = this.getKnownWordCacheScope(); log.info( "Known-word cache lifecycle enabled", `scope=${scope}`, `refreshMinutes=${refreshMinutes}`, `cachePath=${this.knownWordCacheStatePath}`, ); this.loadKnownWordCacheState(); void this.refreshKnownWords(); const refreshIntervalMs = this.getKnownWordRefreshIntervalMs(); this.knownWordsRefreshTimer = setInterval(() => { void this.refreshKnownWords(); }, refreshIntervalMs); } private stopKnownWordCacheLifecycle(): void { if (this.knownWordsRefreshTimer) { clearInterval(this.knownWordsRefreshTimer); this.knownWordsRefreshTimer = null; } } async refreshKnownWordCache(): Promise { return this.refreshKnownWords(true); } private async refreshKnownWords(force = false): Promise { if (!this.isKnownWordCacheEnabled()) { log.debug("Known-word cache refresh skipped; feature disabled"); return; } if (this.isRefreshingKnownWords) { log.debug("Known-word cache refresh skipped; already refreshing"); return; } if (!force && !this.isKnownWordCacheStale()) { log.debug("Known-word cache refresh skipped; cache is fresh"); return; } this.isRefreshingKnownWords = true; try { const query = this.buildKnownWordsQuery(); log.debug("Refreshing known-word cache", `query=${query}`); const noteIds = (await this.client.findNotes(query, { maxRetries: 0, })) as number[]; const nextKnownWords = new Set(); if (noteIds.length > 0) { const chunkSize = 50; for (let i = 0; i < noteIds.length; i += chunkSize) { const chunk = noteIds.slice(i, i + chunkSize); const notesInfoResult = (await this.client.notesInfo(chunk)) as unknown[]; const notesInfo = notesInfoResult as NoteInfo[]; for (const noteInfo of notesInfo) { for (const word of this.extractKnownWordsFromNoteInfo(noteInfo)) { const normalized = this.normalizeKnownWordForLookup(word); if (normalized) { nextKnownWords.add(normalized); } } } } } this.knownWords = nextKnownWords; this.knownWordsLastRefreshedAtMs = Date.now(); this.knownWordsScope = this.getKnownWordCacheScope(); this.persistKnownWordCacheState(); log.info( "Known-word cache refreshed", `noteCount=${noteIds.length}`, `wordCount=${nextKnownWords.size}`, ); } catch (error) { log.warn( "Failed to refresh known-word cache:", (error as Error).message, ); this.showStatusNotification("AnkiConnect: unable to refresh known words"); } finally { this.isRefreshingKnownWords = false; } } private getKnownWordDecks(): string[] { const configuredDecks = this.config.nPlusOne?.decks; if (Array.isArray(configuredDecks)) { const decks = configuredDecks .filter((entry): entry is string => typeof entry === "string") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); return [...new Set(decks)]; } const deck = this.config.deck?.trim(); return deck ? [deck] : []; } private buildKnownWordsQuery(): string { const decks = this.getKnownWordDecks(); if (decks.length === 0) { return "is:note"; } if (decks.length === 1) { return `deck:"${escapeAnkiSearchValue(decks[0])}"`; } const deckQueries = decks.map( (deck) => `deck:"${escapeAnkiSearchValue(deck)}"`, ); return `(${deckQueries.join(" OR ")})`; } private getKnownWordCacheScope(): string { const decks = this.getKnownWordDecks(); if (decks.length === 0) { return "is:note"; } return `decks:${JSON.stringify(decks)}`; } private isKnownWordCacheStale(): boolean { if (!this.isKnownWordCacheEnabled()) { return true; } if (this.knownWordsScope !== this.getKnownWordCacheScope()) { return true; } if (this.knownWordsLastRefreshedAtMs <= 0) { return true; } return ( Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs() ); } private loadKnownWordCacheState(): void { try { if (!fs.existsSync(this.knownWordCacheStatePath)) { this.knownWords = new Set(); this.knownWordsLastRefreshedAtMs = 0; this.knownWordsScope = this.getKnownWordCacheScope(); return; } const raw = fs.readFileSync(this.knownWordCacheStatePath, "utf-8"); if (!raw.trim()) { this.knownWords = new Set(); this.knownWordsLastRefreshedAtMs = 0; this.knownWordsScope = this.getKnownWordCacheScope(); return; } const parsed = JSON.parse(raw) as unknown; if (!this.isKnownWordCacheStateValid(parsed)) { this.knownWords = new Set(); this.knownWordsLastRefreshedAtMs = 0; this.knownWordsScope = this.getKnownWordCacheScope(); return; } if (parsed.scope !== this.getKnownWordCacheScope()) { this.knownWords = new Set(); this.knownWordsLastRefreshedAtMs = 0; this.knownWordsScope = this.getKnownWordCacheScope(); return; } const nextKnownWords = new Set(); for (const value of parsed.words) { const normalized = this.normalizeKnownWordForLookup(value); if (normalized) { nextKnownWords.add(normalized); } } this.knownWords = nextKnownWords; this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs; this.knownWordsScope = parsed.scope; } catch (error) { log.warn( "Failed to load known-word cache state:", (error as Error).message, ); this.knownWords = new Set(); this.knownWordsLastRefreshedAtMs = 0; this.knownWordsScope = this.getKnownWordCacheScope(); } } private persistKnownWordCacheState(): void { try { const state: KnownWordCacheState = { version: 1, refreshedAtMs: this.knownWordsLastRefreshedAtMs, scope: this.knownWordsScope, words: Array.from(this.knownWords), }; fs.writeFileSync(this.knownWordCacheStatePath, JSON.stringify(state), "utf-8"); } catch (error) { log.warn( "Failed to persist known-word cache state:", (error as Error).message, ); } } private clearKnownWordCacheState(): void { this.knownWords = new Set(); this.knownWordsLastRefreshedAtMs = 0; this.knownWordsScope = this.getKnownWordCacheScope(); try { if (fs.existsSync(this.knownWordCacheStatePath)) { fs.unlinkSync(this.knownWordCacheStatePath); } } catch (error) { log.warn("Failed to clear known-word cache state:", (error as Error).message); } } private isKnownWordCacheStateValid( value: unknown, ): value is KnownWordCacheState { if (typeof value !== "object" || value === null) return false; const candidate = value as Partial; if (candidate.version !== 1) return false; if (typeof candidate.refreshedAtMs !== "number") return false; if (typeof candidate.scope !== "string") return false; if (!Array.isArray(candidate.words)) return false; if (!candidate.words.every((entry) => typeof entry === "string")) { return false; } return true; } private extractKnownWordsFromNoteInfo(noteInfo: NoteInfo): string[] { const words: string[] = []; const preferredFields = ["Expression", "Word"]; for (const preferredField of preferredFields) { const fieldName = this.resolveFieldName( Object.keys(noteInfo.fields), preferredField, ); if (!fieldName) continue; const raw = noteInfo.fields[fieldName]?.value; if (!raw) continue; const extracted = this.normalizeRawKnownWordValue(raw); if (extracted) { words.push(extracted); } } return words; } private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void { if (!this.isKnownWordCacheEnabled()) { return; } const currentScope = this.getKnownWordCacheScope(); if (this.knownWordsScope && this.knownWordsScope !== currentScope) { this.clearKnownWordCacheState(); } if (!this.knownWordsScope) { this.knownWordsScope = currentScope; } let addedCount = 0; for (const rawWord of this.extractKnownWordsFromNoteInfo(noteInfo)) { const normalized = this.normalizeKnownWordForLookup(rawWord); if (!normalized || this.knownWords.has(normalized)) { continue; } this.knownWords.add(normalized); addedCount += 1; } if (addedCount > 0) { if (this.knownWordsLastRefreshedAtMs <= 0) { this.knownWordsLastRefreshedAtMs = Date.now(); } this.persistKnownWordCacheState(); log.info( "Known-word cache updated in-session", `added=${addedCount}`, `scope=${currentScope}`, ); } } private normalizeRawKnownWordValue(value: string): string { return value .replace(/<[^>]*>/g, "") .replace(/\u3000/g, " ") .trim(); } private normalizeKnownWordForLookup(value: string): string { return this.normalizeRawKnownWordValue(value).toLowerCase(); } private getLapisConfig(): { enabled: boolean; sentenceCardModel?: string; sentenceCardSentenceField?: string; sentenceCardAudioField?: string; } { const lapis = this.config.isLapis; return { enabled: lapis?.enabled === true, sentenceCardModel: lapis?.sentenceCardModel, sentenceCardSentenceField: lapis?.sentenceCardSentenceField, sentenceCardAudioField: lapis?.sentenceCardAudioField, }; } 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: lapis.sentenceCardSentenceField || "Sentence", audioField: lapis.sentenceCardAudioField || "SentenceAudio", lapisEnabled: lapis.enabled, kikuEnabled: kiku.enabled, kikuFieldGrouping: (kiku.fieldGrouping || "disabled") as | "auto" | "manual" | "disabled", kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false, }; } start(): void { if (this.pollingInterval) { this.stop(); } log.info( "Starting AnkiConnect integration with polling rate:", this.config.pollingRate, ); this.startKnownWordCacheLifecycle(); this.poll(); } stop(): void { if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = null; } this.stopKnownWordCacheLifecycle(); log.info("Stopped AnkiConnect integration"); } private poll(): void { this.pollOnce(); this.pollingInterval = setInterval(() => { this.pollOnce(); }, this.config.pollingRate); } private async pollOnce(): Promise { if (this.updateInProgress) return; if (Date.now() < this.nextPollTime) return; this.updateInProgress = true; try { const query = this.config.deck ? `"deck:${this.config.deck}" added:1` : "added:1"; const noteIds = (await this.client.findNotes(query, { maxRetries: 0, })) as number[]; const currentNoteIds = new Set(noteIds); if (!this.initialized) { this.previousNoteIds = currentNoteIds; this.initialized = true; log.info( `AnkiConnect initialized with ${currentNoteIds.size} existing cards`, ); this.backoffMs = 200; return; } const newNoteIds = Array.from(currentNoteIds).filter( (id) => !this.previousNoteIds.has(id), ); if (newNoteIds.length > 0) { log.info("Found new cards:", newNoteIds); for (const noteId of newNoteIds) { this.previousNoteIds.add(noteId); } if (this.config.behavior?.autoUpdateNewCards !== false) { for (const noteId of newNoteIds) { await this.processNewCard(noteId); } } else { log.info( "New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.", ); } } if (this.backoffMs > 200) { log.info("AnkiConnect connection restored"); } this.backoffMs = 200; } catch (error) { const wasBackingOff = this.backoffMs > 200; this.backoffMs = Math.min(this.backoffMs * 2, this.maxBackoffMs); this.nextPollTime = Date.now() + this.backoffMs; if (!wasBackingOff) { log.warn("AnkiConnect polling failed, backing off..."); this.showStatusNotification("AnkiConnect: unable to connect"); } } finally { this.updateInProgress = false; } } 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); 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 { try { if (!clipboardText || !clipboardText.trim()) { this.showOsdNotification("Clipboard is empty"); return; } if (!this.mpvClient || !this.mpvClient.currentVideoPath) { this.showOsdNotification("No video loaded"); return; } // Parse clipboard into blocks (separated by blank lines) const blocks = clipboardText .split(/\n\s*\n/) .map((b) => b.trim()) .filter((b) => b.length > 0); if (blocks.length === 0) { this.showOsdNotification("No subtitle blocks found in clipboard"); return; } // Lookup timings for each block const timings: { startTime: number; endTime: number }[] = []; for (const block of blocks) { const timing = this.timingTracker.findTiming(block); if (timing) { timings.push(timing); } } if (timings.length === 0) { this.showOsdNotification( "Subtitle timing not found; copy again while playing", ); return; } // Compute range from all matched timings const rangeStart = Math.min(...timings.map((t) => t.startTime)); let rangeEnd = Math.max(...timings.map((t) => t.endTime)); const maxMediaDuration = this.config.media?.maxMediaDuration ?? 30; if (maxMediaDuration > 0 && rangeEnd - rangeStart > maxMediaDuration) { log.warn( `Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, ); rangeEnd = rangeStart + maxMediaDuration; } this.showOsdNotification("Updating card from clipboard..."); this.beginUpdateProgress("Updating card from clipboard"); this.updateInProgress = true; try { // Get last added note const query = this.config.deck ? `"deck:${this.config.deck}" added:1` : "added:1"; const noteIds = (await this.client.findNotes(query)) as number[]; if (!noteIds || noteIds.length === 0) { this.showOsdNotification("No recently added cards found"); return; } // Get max note ID (most recent) const noteId = Math.max(...noteIds); // Get note info for expression const notesInfoResult = await this.client.notesInfo([noteId]); const notesInfo = notesInfoResult as unknown as NoteInfo[]; if (!notesInfo || notesInfo.length === 0) { this.showOsdNotification("Card not found"); return; } const noteInfo = notesInfo[0]; const fields = this.extractFields(noteInfo.fields); const expressionText = fields.expression || fields.word || ""; const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo); const sentenceField = this.getEffectiveSentenceCardConfig().sentenceField; // Build sentence from blocks (join with spaces between blocks) const sentence = blocks.join(" "); const updatedFields: Record = {}; let updatePerformed = false; const errors: string[] = []; let miscInfoFilename: string | null = null; // Add sentence field if (sentenceField) { const processedSentence = this.processSentence(sentence, fields); updatedFields[sentenceField] = processedSentence; updatePerformed = true; } log.info( `Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`, ); // Generate and upload audio if (this.config.media?.generateAudio) { try { const audioFilename = this.generateAudioFilename(); const audioBuffer = await this.mediaGenerator.generateAudio( this.mpvClient.currentVideoPath, rangeStart, rangeEnd, this.config.media?.audioPadding, this.mpvClient.currentAudioStreamIndex, ); if (audioBuffer) { await this.client.storeMediaFile(audioFilename, audioBuffer); 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, ); errors.push("audio"); } } // Generate and upload image if (this.config.media?.generateImage) { try { const imageFilename = this.generateImageFilename(); let imageBuffer: Buffer | null = null; if (this.config.media?.imageType === "avif") { imageBuffer = await this.mediaGenerator.generateAnimatedImage( this.mpvClient.currentVideoPath, rangeStart, rangeEnd, 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 { const timestamp = this.mpvClient.currentTimePos || 0; imageBuffer = await this.mediaGenerator.generateScreenshot( this.mpvClient.currentVideoPath, 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, }, ); } 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, ); errors.push("image"); } } if (this.config.fields?.miscInfo) { const miscInfo = this.formatMiscInfoPattern( miscInfoFilename || "", rangeStart, ); 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); const label = expressionText || noteId; log.info("Updated card from clipboard:", label); const errorSuffix = errors.length > 0 ? `${errors.join(", ")} failed` : undefined; await this.showNotification(noteId, label, errorSuffix); } } finally { this.updateInProgress = false; this.endUpdateProgress(); } } catch (error) { log.error( "Error updating card from clipboard:", (error as Error).message, ); this.showOsdNotification(`Update failed: ${(error as Error).message}`); } } async triggerFieldGroupingForLastAddedCard(): Promise { const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); if (!sentenceCardConfig.kikuEnabled) { this.showOsdNotification("Kiku mode is not enabled"); return; } if (sentenceCardConfig.kikuFieldGrouping === "disabled") { this.showOsdNotification("Kiku field grouping is disabled"); return; } if (this.updateInProgress) { this.showOsdNotification("Anki update already in progress"); return; } try { await this.withUpdateProgress("Grouping duplicate cards", async () => { const query = this.config.deck ? `"deck:${this.config.deck}" added:1` : "added:1"; const noteIds = (await this.client.findNotes(query)) as number[]; if (!noteIds || noteIds.length === 0) { this.showOsdNotification("No recently added cards found"); return; } const noteId = Math.max(...noteIds); const notesInfoResult = await this.client.notesInfo([noteId]); const notesInfo = notesInfoResult as unknown as NoteInfo[]; if (!notesInfo || notesInfo.length === 0) { this.showOsdNotification("Card not found"); return; } const noteInfoBeforeUpdate = notesInfo[0]; const fields = this.extractFields(noteInfoBeforeUpdate.fields); const expressionText = fields.expression || fields.word || ""; if (!expressionText) { this.showOsdNotification("No expression/word field found"); return; } const duplicateNoteId = await this.findDuplicateNote( expressionText, noteId, noteInfoBeforeUpdate, ); if (duplicateNoteId === null) { this.showOsdNotification("No duplicate card found"); return; } // Only do card update work when we already know a merge candidate exists. if ( !this.hasAllConfiguredFields(noteInfoBeforeUpdate, [ this.config.fields?.image, ]) ) { await this.processNewCard(noteId, { skipKikuFieldGrouping: true }); } const refreshedInfoResult = await this.client.notesInfo([noteId]); const refreshedInfo = refreshedInfoResult as unknown as NoteInfo[]; if (!refreshedInfo || refreshedInfo.length === 0) { this.showOsdNotification("Card not found"); return; } const noteInfo = refreshedInfo[0]; if (sentenceCardConfig.kikuFieldGrouping === "auto") { await this.handleFieldGroupingAuto( duplicateNoteId, noteId, noteInfo, expressionText, ); return; } const handled = await this.handleFieldGroupingManual( duplicateNoteId, noteId, noteInfo, expressionText, ); if (!handled) { this.showOsdNotification("Field grouping cancelled"); } }); } catch (error) { log.error( "Error triggering field grouping:", (error as Error).message, ); this.showOsdNotification( `Field grouping failed: ${(error as Error).message}`, ); } } async markLastCardAsAudioCard(): Promise { if (this.updateInProgress) { this.showOsdNotification("Anki update already in progress"); return; } try { if (!this.mpvClient || !this.mpvClient.currentVideoPath) { this.showOsdNotification("No video loaded"); return; } if (!this.mpvClient.currentSubText) { this.showOsdNotification("No current subtitle"); return; } let startTime = this.mpvClient.currentSubStart; let endTime = this.mpvClient.currentSubEnd; if (startTime === undefined || endTime === undefined) { const currentTime = this.mpvClient.currentTimePos || 0; const fallback = this.getFallbackDurationSeconds() / 2; startTime = currentTime - fallback; endTime = currentTime + fallback; } const maxMediaDuration = this.config.media?.maxMediaDuration ?? 30; if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { endTime = startTime + maxMediaDuration; } this.showOsdNotification("Marking card as audio card..."); await this.withUpdateProgress("Marking audio card", async () => { const query = this.config.deck ? `"deck:${this.config.deck}" added:1` : "added:1"; const noteIds = (await this.client.findNotes(query)) as number[]; if (!noteIds || noteIds.length === 0) { this.showOsdNotification("No recently added cards found"); return; } const noteId = Math.max(...noteIds); const notesInfoResult = await this.client.notesInfo([noteId]); const notesInfo = notesInfoResult as unknown as NoteInfo[]; if (!notesInfo || notesInfo.length === 0) { this.showOsdNotification("Card not found"); return; } const noteInfo = notesInfo[0]; const fields = this.extractFields(noteInfo.fields); const expressionText = fields.expression || fields.word || ""; const updatedFields: Record = {}; const errors: string[] = []; let miscInfoFilename: string | null = null; this.setCardTypeFields( updatedFields, Object.keys(noteInfo.fields), "audio", ); if (this.config.fields?.sentence) { const processedSentence = this.processSentence( this.mpvClient.currentSubText, fields, ); updatedFields[this.config.fields?.sentence] = processedSentence; } const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); const audioFieldName = sentenceCardConfig.audioField; try { const audioFilename = this.generateAudioFilename(); const audioBuffer = await this.mediaGenerator.generateAudio( this.mpvClient.currentVideoPath, startTime, endTime, this.config.media?.audioPadding, this.mpvClient.currentAudioStreamIndex, ); if (audioBuffer) { await this.client.storeMediaFile(audioFilename, audioBuffer); updatedFields[audioFieldName] = `[sound:${audioFilename}]`; miscInfoFilename = audioFilename; } } catch (error) { log.error( "Failed to generate audio for audio card:", (error as Error).message, ); errors.push("audio"); } if (this.config.media?.generateImage) { try { const imageFilename = this.generateImageFilename(); let imageBuffer: Buffer | null = null; if (this.config.media?.imageType === "avif") { imageBuffer = await this.mediaGenerator.generateAnimatedImage( this.mpvClient.currentVideoPath, 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 { const timestamp = this.mpvClient.currentTimePos || 0; imageBuffer = await this.mediaGenerator.generateScreenshot( this.mpvClient.currentVideoPath, 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, }, ); } if (imageBuffer && this.config.fields?.image) { await this.client.storeMediaFile(imageFilename, imageBuffer); updatedFields[this.config.fields?.image] = ``; miscInfoFilename = imageFilename; } } catch (error) { log.error( "Failed to generate image for audio card:", (error as Error).message, ); errors.push("image"); } } if (this.config.fields?.miscInfo) { const miscInfo = this.formatMiscInfoPattern( miscInfoFilename || "", startTime, ); const miscInfoField = this.resolveConfiguredFieldName( noteInfo, this.config.fields?.miscInfo, ); if (miscInfo && miscInfoField) { updatedFields[miscInfoField] = miscInfo; } } await this.client.updateNoteFields(noteId, updatedFields); const label = expressionText || noteId; log.info("Marked card as audio card:", label); const errorSuffix = errors.length > 0 ? `${errors.join(", ")} failed` : undefined; await this.showNotification(noteId, label, errorSuffix); }); } catch (error) { log.error( "Error marking card as audio card:", (error as Error).message, ); this.showOsdNotification( `Audio card failed: ${(error as Error).message}`, ); } } async createSentenceCard( sentence: string, startTime: number, endTime: number, secondarySubText?: string, ): Promise { if (this.updateInProgress) { this.showOsdNotification("Anki update already in progress"); return; } const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); const sentenceCardModel = sentenceCardConfig.model; if (!sentenceCardModel) { this.showOsdNotification("sentenceCardModel not configured"); return; } if (!this.mpvClient || !this.mpvClient.currentVideoPath) { this.showOsdNotification("No video loaded"); return; } const maxMediaDuration = this.config.media?.maxMediaDuration ?? 30; if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { log.warn( `Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, ); endTime = startTime + maxMediaDuration; } this.showOsdNotification("Creating sentence card..."); await this.withUpdateProgress("Creating sentence card", async () => { const videoPath = this.mpvClient.currentVideoPath; const fields: Record = {}; const errors: string[] = []; let miscInfoFilename: string | null = null; const sentenceField = sentenceCardConfig.sentenceField; const audioFieldName = sentenceCardConfig.audioField || "SentenceAudio"; const translationField = this.config.fields?.translation || "SelectionText"; let resolvedMiscInfoField: string | null = null; let resolvedSentenceAudioField: string = audioFieldName; let resolvedExpressionAudioField: string | null = null; fields[sentenceField] = sentence; const backText = await resolveSentenceBackText( { sentence, secondarySubText, config: this.config.ai || {}, }, { logWarning: (message: string) => log.warn(message), }, ); if (backText) { fields[translationField] = backText; } if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) { fields["IsSentenceCard"] = "x"; fields["Expression"] = sentence; } const deck = this.config.deck || "Default"; let noteId: number; try { noteId = await this.client.addNote( deck, sentenceCardModel, fields, ); log.info("Created sentence card:", noteId); this.previousNoteIds.add(noteId); } catch (error) { log.error( "Failed to create sentence card:", (error as Error).message, ); this.showOsdNotification( `Sentence card failed: ${(error as Error).message}`, ); return; } try { const noteInfoResult = await this.client.notesInfo([noteId]); const noteInfos = noteInfoResult as unknown as NoteInfo[]; if (noteInfos.length > 0) { const createdNoteInfo = noteInfos[0]; this.appendKnownWordsFromNoteInfo(createdNoteInfo); resolvedSentenceAudioField = this.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName; resolvedExpressionAudioField = this.resolveConfiguredFieldName( createdNoteInfo, this.config.fields?.audio || "ExpressionAudio", "ExpressionAudio", ); resolvedMiscInfoField = this.resolveConfiguredFieldName( createdNoteInfo, this.config.fields?.miscInfo, ); const cardTypeFields: Record = {}; this.setCardTypeFields( cardTypeFields, Object.keys(createdNoteInfo.fields), "sentence", ); if (Object.keys(cardTypeFields).length > 0) { await this.client.updateNoteFields(noteId, cardTypeFields); } } } catch (error) { log.error( "Failed to normalize sentence card type fields:", (error as Error).message, ); errors.push("card type fields"); } const mediaFields: Record = {}; try { const audioFilename = this.generateAudioFilename(); const audioBuffer = await this.mediaGenerator.generateAudio( videoPath, startTime, endTime, this.config.media?.audioPadding, this.mpvClient.currentAudioStreamIndex, ); if (audioBuffer) { await this.client.storeMediaFile(audioFilename, audioBuffer); const audioValue = `[sound:${audioFilename}]`; mediaFields[resolvedSentenceAudioField] = audioValue; if ( resolvedExpressionAudioField && resolvedExpressionAudioField !== resolvedSentenceAudioField ) { mediaFields[resolvedExpressionAudioField] = audioValue; } miscInfoFilename = audioFilename; } } catch (error) { log.error( "Failed to generate sentence audio:", (error as Error).message, ); errors.push("audio"); } try { const imageFilename = this.generateImageFilename(); let imageBuffer: Buffer | null = null; if (this.config.media?.imageType === "avif") { imageBuffer = await 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 { const timestamp = this.mpvClient.currentTimePos || 0; imageBuffer = await 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, }, ); } if (imageBuffer && this.config.fields?.image) { await this.client.storeMediaFile(imageFilename, imageBuffer); mediaFields[this.config.fields?.image] = ``; miscInfoFilename = imageFilename; } } catch (error) { log.error( "Failed to generate sentence image:", (error as Error).message, ); errors.push("image"); } if (this.config.fields?.miscInfo) { const miscInfo = this.formatMiscInfoPattern( miscInfoFilename || "", startTime, ); if (miscInfo && resolvedMiscInfoField) { mediaFields[resolvedMiscInfoField] = miscInfo; } } if (Object.keys(mediaFields).length > 0) { try { await this.client.updateNoteFields(noteId, mediaFields); } catch (error) { log.error( "Failed to update sentence card media:", (error as Error).message, ); errors.push("media update"); } } const label = sentence.length > 30 ? sentence.substring(0, 30) + "..." : sentence; const errorSuffix = errors.length > 0 ? `${errors.join(", ")} failed` : undefined; await this.showNotification(noteId, label, errorSuffix); }); } 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 { try { const notesInfoResult = await this.client.notesInfo([ keepNoteId, deleteNoteId, ]); const notesInfo = notesInfoResult as unknown as NoteInfo[]; const keepNoteInfo = notesInfo.find((note) => note.noteId === keepNoteId); const deleteNoteInfo = notesInfo.find( (note) => note.noteId === deleteNoteId, ); if (!keepNoteInfo || !deleteNoteInfo) { return { ok: false, error: "Could not load selected notes" }; } const mergedFields = await this.computeFieldGroupingMergedFields( keepNoteId, deleteNoteId, keepNoteInfo, deleteNoteInfo, false, ); const keepBefore = this.getNoteFieldMap(keepNoteInfo); const keepAfter = { ...keepBefore, ...mergedFields }; const sourceBefore = this.getNoteFieldMap(deleteNoteInfo); const compactFields: Record = {}; for (const fieldName of [ "Sentence", "SentenceFurigana", "SentenceAudio", "Picture", "MiscInfo", ]) { const resolved = this.resolveFieldName( Object.keys(keepAfter), fieldName, ); if (!resolved) continue; compactFields[fieldName] = keepAfter[resolved] || ""; } return { ok: true, compact: { action: { keepNoteId, deleteNoteId, deleteDuplicate, }, mergedFields: compactFields, }, full: { keepNote: { id: keepNoteId, fieldsBefore: keepBefore, }, sourceNote: { id: deleteNoteId, fieldsBefore: sourceBefore, }, result: { fieldsAfter: keepAfter, wouldDeleteNoteId: deleteDuplicate ? deleteNoteId : null, }, }, }; } catch (error) { return { ok: false, error: `Failed to build preview: ${(error as Error).message}`, }; } } 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); } 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, }); } 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.clearKnownWordCacheState(); } else if ( !wasEnabled && this.config.nPlusOne?.highlightEnabled === true ) { this.startKnownWordCacheLifecycle(); } else { this.startKnownWordCacheLifecycle(); } if ( patch.pollingRate !== undefined && previousPollingRate !== this.config.pollingRate && this.pollingInterval ) { this.stop(); this.poll(); } } destroy(): void { this.stop(); this.mediaGenerator.cleanup(); } } function escapeAnkiSearchValue(value: string): string { return value .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/([:*?()[\]{}])/g, "\\$1"); }