import { DEFAULT_ANKI_CONNECT_CONFIG } from "../config"; import { AnkiConnectConfig } from "../types"; import { createLogger } from "../logger"; import { SubtitleTimingTracker } from "../subtitle-timing-tracker"; import { MediaGenerator } from "../media-generator"; import { MpvClient } from "../types"; import { resolveSentenceBackText } from "./ai"; const log = createLogger("anki").child("integration.card-creation"); export interface CardCreationNoteInfo { noteId: number; fields: Record; } type CardKind = "sentence" | "audio"; interface CardCreationClient { addNote( deck: string, modelName: string, fields: Record, ): Promise; notesInfo(noteIds: number[]): Promise; updateNoteFields(noteId: number, fields: Record): Promise; storeMediaFile(filename: string, data: Buffer): Promise; findNotes(query: string, options?: { maxRetries?: number }): Promise; } interface CardCreationMediaGenerator { generateAudio( path: string, startTime: number, endTime: number, audioPadding?: number, audioStreamIndex?: number, ): Promise; generateScreenshot( path: string, timestamp: number, options: { format: "jpg" | "png" | "webp"; quality?: number; maxWidth?: number; maxHeight?: number; }, ): Promise; generateAnimatedImage( path: string, startTime: number, endTime: number, audioPadding?: number, options?: { fps?: number; maxWidth?: number; maxHeight?: number; crf?: number; }, ): Promise; } interface CardCreationDeps { getConfig: () => AnkiConnectConfig; getTimingTracker: () => SubtitleTimingTracker; getMpvClient: () => MpvClient; getDeck?: () => string | undefined; client: CardCreationClient; mediaGenerator: CardCreationMediaGenerator; showOsdNotification: (text: string) => void; showStatusNotification: (message: string) => void; showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise; beginUpdateProgress: (initialMessage: string) => void; endUpdateProgress: () => void; withUpdateProgress: (initialMessage: string, action: () => Promise) => Promise; resolveConfiguredFieldName: ( noteInfo: CardCreationNoteInfo, ...preferredNames: (string | undefined)[] ) => string | null; resolveNoteFieldName: ( noteInfo: CardCreationNoteInfo, preferredName?: string, ) => string | null; extractFields: (fields: Record) => Record; processSentence: (mpvSentence: string, noteFields: Record) => string; setCardTypeFields: ( updatedFields: Record, availableFieldNames: string[], cardKind: CardKind, ) => void; mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string; formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; getEffectiveSentenceCardConfig: () => { model?: string; sentenceField: string; audioField: string; lapisEnabled: boolean; kikuEnabled: boolean; kikuFieldGrouping: "auto" | "manual" | "disabled"; kikuDeleteDuplicateInAuto: boolean; }; getFallbackDurationSeconds: () => number; appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void; isUpdateInProgress: () => boolean; setUpdateInProgress: (value: boolean) => void; trackLastAddedNoteId?: (noteId: number) => void; } export class CardCreationService { constructor(private readonly deps: CardCreationDeps) {} async updateLastAddedFromClipboard(clipboardText: string): Promise { try { if (!clipboardText || !clipboardText.trim()) { this.deps.showOsdNotification("Clipboard is empty"); return; } const mpvClient = this.deps.getMpvClient(); if (!mpvClient || !mpvClient.currentVideoPath) { this.deps.showOsdNotification("No video loaded"); return; } const blocks = clipboardText .split(/\n\s*\n/) .map((block) => block.trim()) .filter((block) => block.length > 0); if (blocks.length === 0) { this.deps.showOsdNotification("No subtitle blocks found in clipboard"); return; } const timings: { startTime: number; endTime: number }[] = []; const timingTracker = this.deps.getTimingTracker(); for (const block of blocks) { const timing = timingTracker.findTiming(block); if (timing) { timings.push(timing); } } if (timings.length === 0) { this.deps.showOsdNotification("Subtitle timing not found; copy again while playing"); return; } const rangeStart = Math.min(...timings.map((entry) => entry.startTime)); let rangeEnd = Math.max(...timings.map((entry) => entry.endTime)); const maxMediaDuration = this.deps.getConfig().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.deps.showOsdNotification("Updating card from clipboard..."); this.deps.beginUpdateProgress("Updating card from clipboard"); this.deps.setUpdateInProgress(true); try { const deck = this.deps.getDeck?.() ?? this.deps.getConfig().deck; const query = deck ? `"deck:${deck}" added:1` : "added:1"; const noteIds = (await this.deps.client.findNotes(query, { maxRetries: 0, })) as number[]; if (!noteIds || noteIds.length === 0) { this.deps.showOsdNotification("No recently added cards found"); return; } const noteId = Math.max(...noteIds); const notesInfoResult = (await this.deps.client.notesInfo([noteId])) as CardCreationNoteInfo[]; if (!notesInfoResult || notesInfoResult.length === 0) { this.deps.showOsdNotification("Card not found"); return; } const noteInfo = notesInfoResult[0]; const fields = this.deps.extractFields(noteInfo.fields); const expressionText = fields.expression || fields.word || ""; const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo); const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField; const sentence = blocks.join(" "); const updatedFields: Record = {}; let updatePerformed = false; const errors: string[] = []; let miscInfoFilename: string | null = null; if (sentenceField) { const processedSentence = this.deps.processSentence(sentence, fields); updatedFields[sentenceField] = processedSentence; updatePerformed = true; } log.info( `Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`, ); if (this.deps.getConfig().media?.generateAudio) { try { const audioFilename = this.generateAudioFilename(); const audioBuffer = await this.mediaGenerateAudio( mpvClient.currentVideoPath, rangeStart, rangeEnd, ); if (audioBuffer) { await this.deps.client.storeMediaFile(audioFilename, audioBuffer); if (sentenceAudioField) { const existingAudio = noteInfo.fields[sentenceAudioField]?.value || ""; updatedFields[sentenceAudioField] = this.deps.mergeFieldValue( existingAudio, `[sound:${audioFilename}]`, this.deps.getConfig().behavior?.overwriteAudio !== false, ); } miscInfoFilename = audioFilename; updatePerformed = true; } } catch (error) { log.error( "Failed to generate audio:", (error as Error).message, ); errors.push("audio"); } } if (this.deps.getConfig().media?.generateImage) { try { const imageFilename = this.generateImageFilename(); const imageBuffer = await this.generateImageBuffer( mpvClient.currentVideoPath, rangeStart, rangeEnd, ); if (imageBuffer) { await this.deps.client.storeMediaFile(imageFilename, imageBuffer); const imageFieldName = this.deps.resolveConfiguredFieldName( noteInfo, this.deps.getConfig().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.deps.mergeFieldValue( existingImage, ``, this.deps.getConfig().behavior?.overwriteImage !== false, ); miscInfoFilename = imageFilename; updatePerformed = true; } } } catch (error) { log.error( "Failed to generate image:", (error as Error).message, ); errors.push("image"); } } if (this.deps.getConfig().fields?.miscInfo) { const miscInfo = this.deps.formatMiscInfoPattern( miscInfoFilename || "", rangeStart, ); const miscInfoField = this.deps.resolveConfiguredFieldName( noteInfo, this.deps.getConfig().fields?.miscInfo, ); if (miscInfo && miscInfoField) { updatedFields[miscInfoField] = miscInfo; updatePerformed = true; } } if (updatePerformed) { await this.deps.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.deps.showNotification(noteId, label, errorSuffix); } } finally { this.deps.setUpdateInProgress(false); this.deps.endUpdateProgress(); } } catch (error) { log.error("Error updating card from clipboard:", (error as Error).message); this.deps.showOsdNotification(`Update failed: ${(error as Error).message}`); } } async markLastCardAsAudioCard(): Promise { if (this.deps.isUpdateInProgress()) { this.deps.showOsdNotification("Anki update already in progress"); return; } try { const mpvClient = this.deps.getMpvClient(); if (!mpvClient || !mpvClient.currentVideoPath) { this.deps.showOsdNotification("No video loaded"); return; } if (!mpvClient.currentSubText) { this.deps.showOsdNotification("No current subtitle"); return; } let startTime = mpvClient.currentSubStart; let endTime = mpvClient.currentSubEnd; if (startTime === undefined || endTime === undefined) { const currentTime = mpvClient.currentTimePos || 0; const fallback = this.deps.getFallbackDurationSeconds() / 2; startTime = currentTime - fallback; endTime = currentTime + fallback; } const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { endTime = startTime + maxMediaDuration; } this.deps.showOsdNotification("Marking card as audio card..."); await this.deps.withUpdateProgress("Marking audio card", async () => { const deck = this.deps.getDeck?.() ?? this.deps.getConfig().deck; const query = deck ? `"deck:${deck}" added:1` : "added:1"; const noteIds = (await this.deps.client.findNotes(query)) as number[]; if (!noteIds || noteIds.length === 0) { this.deps.showOsdNotification("No recently added cards found"); return; } const noteId = Math.max(...noteIds); const notesInfoResult = (await this.deps.client.notesInfo([noteId])) as CardCreationNoteInfo[]; if (!notesInfoResult || notesInfoResult.length === 0) { this.deps.showOsdNotification("Card not found"); return; } const noteInfo = notesInfoResult[0]; const fields = this.deps.extractFields(noteInfo.fields); const expressionText = fields.expression || fields.word || ""; const updatedFields: Record = {}; const errors: string[] = []; let miscInfoFilename: string | null = null; this.deps.setCardTypeFields( updatedFields, Object.keys(noteInfo.fields), "audio", ); const sentenceField = this.deps.getConfig().fields?.sentence; if (sentenceField) { const processedSentence = this.deps.processSentence( mpvClient.currentSubText, fields, ); updatedFields[sentenceField] = processedSentence; } const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const audioFieldName = sentenceCardConfig.audioField; try { const audioFilename = this.generateAudioFilename(); const audioBuffer = await this.mediaGenerateAudio( mpvClient.currentVideoPath, startTime, endTime, ); if (audioBuffer) { await this.deps.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.deps.getConfig().media?.generateImage) { try { const imageFilename = this.generateImageFilename(); const imageBuffer = await this.generateImageBuffer( mpvClient.currentVideoPath, startTime, endTime, ); const imageField = this.deps.getConfig().fields?.image; if (imageBuffer && imageField) { await this.deps.client.storeMediaFile(imageFilename, imageBuffer); updatedFields[imageField] = ``; miscInfoFilename = imageFilename; } } catch (error) { log.error( "Failed to generate image for audio card:", (error as Error).message, ); errors.push("image"); } } if (this.deps.getConfig().fields?.miscInfo) { const miscInfo = this.deps.formatMiscInfoPattern( miscInfoFilename || "", startTime, ); const miscInfoField = this.deps.resolveConfiguredFieldName( noteInfo, this.deps.getConfig().fields?.miscInfo, ); if (miscInfo && miscInfoField) { updatedFields[miscInfoField] = miscInfo; } } await this.deps.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.deps.showNotification(noteId, label, errorSuffix); }); } catch (error) { log.error( "Error marking card as audio card:", (error as Error).message, ); this.deps.showOsdNotification( `Audio card failed: ${(error as Error).message}`, ); } } async createSentenceCard( sentence: string, startTime: number, endTime: number, secondarySubText?: string, ): Promise { if (this.deps.isUpdateInProgress()) { this.deps.showOsdNotification("Anki update already in progress"); return; } const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const sentenceCardModel = sentenceCardConfig.model; if (!sentenceCardModel) { this.deps.showOsdNotification("sentenceCardModel not configured"); return; } const mpvClient = this.deps.getMpvClient(); if (!mpvClient || !mpvClient.currentVideoPath) { this.deps.showOsdNotification("No video loaded"); return; } const maxMediaDuration = this.deps.getConfig().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.deps.showOsdNotification("Creating sentence card..."); await this.deps.withUpdateProgress("Creating sentence card", async () => { const videoPath = 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.deps.getConfig().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.deps.getConfig().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.deps.getConfig().deck || "Default"; let noteId: number; try { noteId = await this.deps.client.addNote(deck, sentenceCardModel, fields); log.info("Created sentence card:", noteId); this.deps.trackLastAddedNoteId?.(noteId); } catch (error) { log.error("Failed to create sentence card:", (error as Error).message); this.deps.showOsdNotification( `Sentence card failed: ${(error as Error).message}`, ); return; } try { const noteInfoResult = await this.deps.client.notesInfo([noteId]); const noteInfos = noteInfoResult as CardCreationNoteInfo[]; if (noteInfos.length > 0) { const createdNoteInfo = noteInfos[0]; this.deps.appendKnownWordsFromNoteInfo(createdNoteInfo); resolvedSentenceAudioField = this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName; resolvedExpressionAudioField = this.deps.resolveConfiguredFieldName( createdNoteInfo, this.deps.getConfig().fields?.audio || "ExpressionAudio", ); resolvedMiscInfoField = this.deps.resolveConfiguredFieldName( createdNoteInfo, this.deps.getConfig().fields?.miscInfo, ); const cardTypeFields: Record = {}; this.deps.setCardTypeFields( cardTypeFields, Object.keys(createdNoteInfo.fields), "sentence", ); if (Object.keys(cardTypeFields).length > 0) { await this.deps.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.mediaGenerateAudio(videoPath, startTime, endTime); if (audioBuffer) { await this.deps.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(); const imageBuffer = await this.generateImageBuffer(videoPath, startTime, endTime); const imageField = this.deps.getConfig().fields?.image; if (imageBuffer && imageField) { await this.deps.client.storeMediaFile(imageFilename, imageBuffer); mediaFields[imageField] = ``; miscInfoFilename = imageFilename; } } catch (error) { log.error("Failed to generate sentence image:", (error as Error).message); errors.push("image"); } if (this.deps.getConfig().fields?.miscInfo) { const miscInfo = this.deps.formatMiscInfoPattern( miscInfoFilename || "", startTime, ); if (miscInfo && resolvedMiscInfoField) { mediaFields[resolvedMiscInfoField] = miscInfo; } } if (Object.keys(mediaFields).length > 0) { try { await this.deps.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.deps.showNotification(noteId, label, errorSuffix); }); } private getResolvedSentenceAudioFieldName(noteInfo: CardCreationNoteInfo): string | null { return ( this.deps.resolveNoteFieldName( noteInfo, this.deps.getEffectiveSentenceCardConfig().audioField || "SentenceAudio", ) || this.deps.resolveConfiguredFieldName(noteInfo, this.deps.getConfig().fields?.audio) ); } private async mediaGenerateAudio( videoPath: string, startTime: number, endTime: number, ): Promise { const mpvClient = this.deps.getMpvClient(); if (!mpvClient) { return null; } return this.deps.mediaGenerator.generateAudio( videoPath, startTime, endTime, this.deps.getConfig().media?.audioPadding, mpvClient.currentAudioStreamIndex ?? undefined, ); } private async generateImageBuffer( videoPath: string, startTime: number, endTime: number, ): Promise { const mpvClient = this.deps.getMpvClient(); if (!mpvClient) { return null; } const timestamp = mpvClient.currentTimePos || 0; if (this.deps.getConfig().media?.imageType === "avif") { let imageStart = startTime; let imageEnd = endTime; if (!Number.isFinite(imageStart) || !Number.isFinite(imageEnd)) { const fallback = this.deps.getFallbackDurationSeconds() / 2; imageStart = timestamp - fallback; imageEnd = timestamp + fallback; } return this.deps.mediaGenerator.generateAnimatedImage( videoPath, imageStart, imageEnd, this.deps.getConfig().media?.audioPadding, { fps: this.deps.getConfig().media?.animatedFps, maxWidth: this.deps.getConfig().media?.animatedMaxWidth, maxHeight: this.deps.getConfig().media?.animatedMaxHeight, crf: this.deps.getConfig().media?.animatedCrf, }, ); } return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, { format: this.deps.getConfig().media?.imageFormat as "jpg" | "png" | "webp", quality: this.deps.getConfig().media?.imageQuality, maxWidth: this.deps.getConfig().media?.imageMaxWidth, maxHeight: this.deps.getConfig().media?.imageMaxHeight, }); } private generateAudioFilename(): string { const timestamp = Date.now(); return `audio_${timestamp}.mp3`; } private generateImageFilename(): string { const timestamp = Date.now(); const ext = this.deps.getConfig().media?.imageType === "avif" ? "avif" : this.deps.getConfig().media?.imageFormat; return `image_${timestamp}.${ext}`; } }