Files
SubMiner/src/anki-integration.ts
2026-02-09 19:04:19 -08:00

2680 lines
84 KiB
TypeScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
import { AnkiConnectClient } from "./anki-connect";
import { SubtitleTimingTracker } from "./subtitle-timing-tracker";
import { MediaGenerator } from "./media-generator";
import * as path from "path";
import axios from "axios";
import {
AnkiConnectConfig,
KikuDuplicateCardInfo,
KikuFieldGroupingChoice,
KikuMergePreviewResponse,
MpvClient,
NotificationOptions,
} from "./types";
import { DEFAULT_ANKI_CONNECT_CONFIG } from "./config";
import { createLogger } from "./logger";
const log = createLogger("anki").child("integration");
interface NoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
type CardKind = "sentence" | "audio";
export class AnkiIntegration {
private client: AnkiConnectClient;
private mediaGenerator: MediaGenerator;
private timingTracker: SubtitleTimingTracker;
private config: AnkiConnectConfig;
private pollingInterval: ReturnType<typeof setInterval> | null = null;
private previousNoteIds = new Set<number>();
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 progressDepth = 0;
private progressTimer: ReturnType<typeof setInterval> | null = null;
private progressMessage = "";
private progressFrame = 0;
private parseWarningKeys = new Set<string>();
private readonly strictGroupingFieldDefaults = new Set<string>([
"picture",
"sentence",
"sentenceaudio",
"sentencefurigana",
"miscinfo",
]);
private fieldGroupingCallback:
| ((data: {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}) => Promise<KikuFieldGroupingChoice>)
| null = null;
constructor(
config: AnkiConnectConfig,
timingTracker: SubtitleTimingTracker,
mpvClient: MpvClient,
osdCallback?: (text: string) => void,
notificationCallback?: (
title: string,
options: NotificationOptions,
) => void,
fieldGroupingCallback?: (data: {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}) => Promise<KikuFieldGroupingChoice>,
) {
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;
}
private extractAiText(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (!Array.isArray(content)) {
return "";
}
const parts: string[] = [];
for (const item of content) {
if (
item &&
typeof item === "object" &&
"type" in item &&
(item as { type?: unknown }).type === "text" &&
"text" in item &&
typeof (item as { text?: unknown }).text === "string"
) {
parts.push((item as { text: string }).text);
}
}
return parts.join("").trim();
}
private normalizeOpenAiBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
if (/\/v1$/i.test(trimmed)) {
return trimmed;
}
return `${trimmed}/v1`;
}
private async translateSentenceWithAi(
sentence: string,
): Promise<string | null> {
const ai = this.config.ai ?? DEFAULT_ANKI_CONNECT_CONFIG.ai;
if (!ai) {
return null;
}
const apiKey = ai?.apiKey?.trim();
if (!apiKey) {
return null;
}
const baseUrl = this.normalizeOpenAiBaseUrl(
ai.baseUrl || "https://openrouter.ai/api",
);
const model = ai.model || "openai/gpt-4o-mini";
const targetLanguage = ai.targetLanguage || "English";
const defaultSystemPrompt =
"You are a translation engine. Return only the translated text with no explanations.";
const systemPrompt = ai.systemPrompt?.trim() || defaultSystemPrompt;
try {
const response = await axios.post(
`${baseUrl}/chat/completions`,
{
model,
temperature: 0,
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content: `Translate this text to ${targetLanguage}:\n\n${sentence}`,
},
],
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: 15000,
},
);
const content = (response.data as { choices?: unknown[] })?.choices?.[0] as
| { message?: { content?: unknown } }
| undefined;
const translated = this.extractAiText(content?.message?.content);
return translated || null;
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown translation error";
log.warn("AI translation failed:", message);
return null;
}
}
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.poll();
}
stop(): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
log.info("Stopped AnkiConnect integration");
}
private poll(): void {
this.pollOnce();
this.pollingInterval = setInterval(() => {
this.pollOnce();
}, this.config.pollingRate);
}
private async pollOnce(): Promise<void> {
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<void> {
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];
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<string, string> = {};
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,
`<img src="${imageFilename}">`,
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<string, { value: string }>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(fields)) {
result[key.toLowerCase()] = value.value || "";
}
return result;
}
private processSentence(
mpvSentence: string,
noteFields: Record<string, string>,
): 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>(.*?)<\/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}<b>${highlightedText}</b>${suffix}`;
}
private async generateAudio(): Promise<Buffer | null> {
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<Buffer | null> {
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(/<br>/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 {
const type = this.config.behavior?.notificationType || "osd";
if (type === "osd" || type === "both") {
this.showOsdNotification(message);
}
if ((type === "system" || type === "both") && this.notificationCallback) {
this.notificationCallback("SubMiner", { body: message });
}
}
private beginUpdateProgress(initialMessage: string): void {
this.progressDepth += 1;
if (this.progressDepth > 1) return;
this.progressMessage = initialMessage;
this.progressFrame = 0;
this.showProgressTick();
this.progressTimer = setInterval(() => {
this.showProgressTick();
}, 180);
}
private endUpdateProgress(): void {
this.progressDepth = Math.max(0, this.progressDepth - 1);
if (this.progressDepth > 0) return;
if (this.progressTimer) {
clearInterval(this.progressTimer);
this.progressTimer = null;
}
this.progressMessage = "";
this.progressFrame = 0;
}
private showProgressTick(): void {
if (!this.progressMessage) return;
const frames = ["|", "/", "-", "\\"];
const frame = frames[this.progressFrame % frames.length];
this.progressFrame += 1;
this.showOsdNotification(`${this.progressMessage} ${frame}`);
}
private async withUpdateProgress<T>(
initialMessage: string,
action: () => Promise<T>,
): Promise<T> {
this.beginUpdateProgress(initialMessage);
this.updateInProgress = true;
try {
return await action();
} finally {
this.updateInProgress = false;
this.endUpdateProgress();
}
}
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<string, string>,
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<void> {
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<void> {
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<string, string> = {};
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,
`<img src="${imageFilename}">`,
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<void> {
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<void> {
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<string, string> = {};
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] =
`<img src="${imageFilename}">`;
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<void> {
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<string, string> = {};
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 hasSecondarySub = Boolean(secondarySubText?.trim());
let backText = secondarySubText?.trim() || "";
const aiConfig = this.config.ai ?? DEFAULT_ANKI_CONNECT_CONFIG.ai;
const aiEnabled = aiConfig?.enabled === true;
const alwaysUseAiTranslation =
aiConfig?.alwaysUseAiTranslation === true;
const shouldAttemptAiTranslation =
aiEnabled && (alwaysUseAiTranslation || !hasSecondarySub);
if (shouldAttemptAiTranslation) {
const translated = await this.translateSentenceWithAi(sentence);
if (translated) {
backText = translated;
} else if (!hasSecondarySub) {
backText = sentence;
}
}
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];
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<string, string> = {};
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<string, string> = {};
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] = `<img src="${imageFilename}">`;
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<number | null> {
let fieldName = "";
for (const name of Object.keys(noteInfo.fields)) {
if (
["word", "expression"].includes(name.toLowerCase()) &&
noteInfo.fields[name].value
) {
fieldName = name;
break;
}
}
if (!fieldName) return null;
const escapedFieldName = this.escapeAnkiSearchValue(fieldName);
const escapedExpression = this.escapeAnkiSearchValue(expression);
const deckPrefix = this.config.deck
? `"deck:${this.escapeAnkiSearchValue(this.config.deck)}" `
: "";
const query = `${deckPrefix}"${escapedFieldName}:${escapedExpression}"`;
try {
const noteIds = (await this.client.findNotes(query)) as number[];
return await this.findFirstExactDuplicateNoteId(
noteIds,
excludeNoteId,
fieldName,
expression,
);
} catch (error) {
log.warn("Duplicate search failed:", (error as Error).message);
return null;
}
}
private escapeAnkiSearchValue(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/([:*?()[\]{}])/g, "\\$1");
}
private normalizeDuplicateValue(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
private async findFirstExactDuplicateNoteId(
candidateNoteIds: number[],
excludeNoteId: number,
fieldName: string,
expression: string,
): Promise<number | null> {
const candidates = candidateNoteIds.filter((id) => id !== excludeNoteId);
if (candidates.length === 0) return null;
const normalizedExpression = this.normalizeDuplicateValue(expression);
const chunkSize = 50;
for (let i = 0; i < candidates.length; i += chunkSize) {
const chunk = candidates.slice(i, i + chunkSize);
const notesInfoResult = await this.client.notesInfo(chunk);
const notesInfo = notesInfoResult as unknown as NoteInfo[];
for (const noteInfo of notesInfo) {
const resolvedField = this.resolveNoteFieldName(noteInfo, fieldName);
if (!resolvedField) continue;
const candidateValue = noteInfo.fields[resolvedField]?.value || "";
if (
this.normalizeDuplicateValue(candidateValue) === normalizedExpression
) {
return noteInfo.noteId;
}
}
}
return null;
}
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 = /<span\s+data-group-id="[^"]*">[\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(/<img\b[^>]*>/gi);
if (!matches || matches.length === 0) return "";
return matches[matches.length - 1];
}
private extractImageTags(value: string): string[] {
const matches = value.match(/<img\b[^>]*>/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(/<img\b/i, `<img data-group-id="${groupId}"`);
}
private extractSpanEntries(
value: string,
fieldName: string,
): { groupId: number; content: string }[] {
const entries: { groupId: number; content: string }[] = [];
const malformedIdRegex = /<span\s+[^>]*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 = /<span\s+data-group-id="(\d+)"[^>]*>([\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 && /<span\b/i.test(value)) {
this.warnFieldParseOnce(fieldName, "no-usable-span-entries");
}
return entries;
}
private parseStrictEntries(
value: string,
fallbackGroupId: number,
fieldName: string,
): { groupId: number; content: string }[] {
const entries = this.extractSpanEntries(value, fieldName);
if (entries.length === 0) {
const ungrouped = this.normalizeStrictGroupedValue(
this.extractUngroupedValue(value),
fieldName,
);
if (ungrouped) {
entries.push({ groupId: fallbackGroupId, content: ungrouped });
}
}
const unique: { groupId: number; content: string }[] = [];
const seen = new Set<string>();
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<string> {
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) =>
`<span data-group-id="${entry.groupId}">${entry.content}</span>`,
)
.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) =>
`<span data-group-id="${entry.groupId}">${entry.content}</span>`,
)
.join("");
}
if (!existingValue.trim()) return newValue;
if (!newValue.trim()) return existingValue;
const hasGroups = /data-group-id/.test(existingValue);
if (!hasGroups) {
return (
`<span data-group-id="${keepGroupId}">${existingValue}</span>\n` +
newValue
);
}
const groupedSpanRegex = /<span\s+data-group-id="[^"]*">[\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 += `<span data-group-id="${keepGroupId}">${before.trim()}</span>\n`;
}
result += match[0] + "\n";
lastEnd = match.index + match[0].length;
}
const after = existingValue.slice(lastEnd);
if (after.trim()) {
result += `\n<span data-group-id="${keepGroupId}">${after.trim()}</span>`;
}
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 = `<img src="${imageFilename}">`;
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<Record<string, string>> {
const groupableFields = this.getGroupableFieldNames();
const keepFieldNames = Object.keys(keepNoteInfo.fields);
const sourceFields: Record<string, string> = {};
const resolvedKeepFieldByPreferred = new Map<string, string>();
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<string, string> = {};
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<string, string> {
const fields: Record<string, string> = {};
for (const [name, field] of Object.entries(noteInfo.fields)) {
fields[name] = field?.value || "";
}
return fields;
}
async buildFieldGroupingPreview(
keepNoteId: number,
deleteNoteId: number,
deleteDuplicate: boolean,
): Promise<KikuMergePreviewResponse> {
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<string, string> = {};
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<void> {
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<void> {
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<boolean> {
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<void> {
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<AnkiConnectConfig>): void {
const previousPollingRate = this.config.pollingRate;
this.config = {
...this.config,
...patch,
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 (
patch.pollingRate !== undefined &&
previousPollingRate !== this.config.pollingRate &&
this.pollingInterval
) {
this.stop();
this.poll();
}
}
destroy(): void {
this.stop();
this.mediaGenerator.cleanup();
}
}