feat(core): add Electron runtime, services, and app composition

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent 448ce03fd4
commit d3fd47f0ec
562 changed files with 69719 additions and 0 deletions

155
src/anki-integration/ai.ts Normal file
View File

@@ -0,0 +1,155 @@
import axios from 'axios';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
const DEFAULT_AI_SYSTEM_PROMPT =
'You are a translation engine. Return only the translated text with no explanations.';
export function 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();
}
export function normalizeOpenAiBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, '');
if (/\/v1$/i.test(trimmed)) {
return trimmed;
}
return `${trimmed}/v1`;
}
export interface AiTranslateRequest {
sentence: string;
apiKey: string;
baseUrl?: string;
model?: string;
targetLanguage?: string;
systemPrompt?: string;
}
export interface AiTranslateCallbacks {
logWarning: (message: string) => void;
}
export interface AiSentenceTranslationInput {
sentence: string;
secondarySubText?: string;
config: {
apiKey?: string;
baseUrl?: string;
model?: string;
targetLanguage?: string;
systemPrompt?: string;
enabled?: boolean;
alwaysUseAiTranslation?: boolean;
};
}
export interface AiSentenceTranslationCallbacks {
logWarning: (message: string) => void;
}
export async function translateSentenceWithAi(
request: AiTranslateRequest,
callbacks: AiTranslateCallbacks,
): Promise<string | null> {
const aiConfig = DEFAULT_ANKI_CONNECT_CONFIG.ai;
if (!request.apiKey.trim()) {
return null;
}
const baseUrl = normalizeOpenAiBaseUrl(
request.baseUrl || aiConfig.baseUrl || 'https://openrouter.ai/api',
);
const model = request.model || 'openai/gpt-4o-mini';
const targetLanguage = request.targetLanguage || 'English';
const prompt = request.systemPrompt?.trim() || DEFAULT_AI_SYSTEM_PROMPT;
try {
const response = await axios.post(
`${baseUrl}/chat/completions`,
{
model,
temperature: 0,
messages: [
{ role: 'system', content: prompt },
{
role: 'user',
content: `Translate this text to ${targetLanguage}:\n\n${request.sentence}`,
},
],
},
{
headers: {
Authorization: `Bearer ${request.apiKey}`,
'Content-Type': 'application/json',
},
timeout: 15000,
},
);
const content = (response.data as { choices?: unknown[] })?.choices?.[0] as
| { message?: { content?: unknown } }
| undefined;
return extractAiText(content?.message?.content) || null;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown translation error';
callbacks.logWarning(`AI translation failed: ${message}`);
return null;
}
}
export async function resolveSentenceBackText(
input: AiSentenceTranslationInput,
callbacks: AiSentenceTranslationCallbacks,
): Promise<string> {
const hasSecondarySub = Boolean(input.secondarySubText?.trim());
let backText = input.secondarySubText?.trim() || '';
const aiConfig = {
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
...input.config,
};
const shouldAttemptAiTranslation =
aiConfig.enabled === true && (aiConfig.alwaysUseAiTranslation === true || !hasSecondarySub);
if (!shouldAttemptAiTranslation) return backText;
const request: AiTranslateRequest = {
sentence: input.sentence,
apiKey: aiConfig.apiKey ?? '',
baseUrl: aiConfig.baseUrl,
model: aiConfig.model,
targetLanguage: aiConfig.targetLanguage,
systemPrompt: aiConfig.systemPrompt,
};
const translated = await translateSentenceWithAi(request, {
logWarning: (message) => callbacks.logWarning(message),
});
if (translated) {
return translated;
}
return hasSecondarySub ? backText : input.sentence;
}

View File

@@ -0,0 +1,717 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { AnkiConnectConfig } from '../types';
import { createLogger } from '../logger';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import { MpvClient } from '../types';
import { resolveSentenceBackText } from './ai';
const log = createLogger('anki').child('integration.card-creation');
export interface CardCreationNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
type CardKind = 'sentence' | 'audio';
interface CardCreationClient {
addNote(
deck: string,
modelName: string,
fields: Record<string, string>,
tags?: string[],
): Promise<number>;
addTags(noteIds: number[], tags: string[]): Promise<void>;
notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
storeMediaFile(filename: string, data: Buffer): Promise<void>;
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
}
interface CardCreationMediaGenerator {
generateAudio(
path: string,
startTime: number,
endTime: number,
audioPadding?: number,
audioStreamIndex?: number,
): Promise<Buffer | null>;
generateScreenshot(
path: string,
timestamp: number,
options: {
format: 'jpg' | 'png' | 'webp';
quality?: number;
maxWidth?: number;
maxHeight?: number;
},
): Promise<Buffer | null>;
generateAnimatedImage(
path: string,
startTime: number,
endTime: number,
audioPadding?: number,
options?: {
fps?: number;
maxWidth?: number;
maxHeight?: number;
crf?: number;
},
): Promise<Buffer | null>;
}
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<void>;
beginUpdateProgress: (initialMessage: string) => void;
endUpdateProgress: () => void;
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) => Promise<T>;
resolveConfiguredFieldName: (
noteInfo: CardCreationNoteInfo,
...preferredNames: (string | undefined)[]
) => string | null;
resolveNoteFieldName: (noteInfo: CardCreationNoteInfo, preferredName?: string) => string | null;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
setCardTypeFields: (
updatedFields: Record<string, string>,
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) {}
private getConfiguredAnkiTags(): string[] {
const tags = this.deps.getConfig().tags;
if (!Array.isArray(tags)) {
return [];
}
return [...new Set(tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))];
}
private async addConfiguredTagsToNote(noteId: number): Promise<void> {
const tags = this.getConfiguredAnkiTags();
if (tags.length === 0) {
return;
}
try {
await this.deps.client.addTags([noteId], tags);
} catch (error) {
log.warn('Failed to add tags to card:', (error as Error).message);
}
}
async updateLastAddedFromClipboard(clipboardText: string): Promise<void> {
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<string, string> = {};
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,
`<img src="${imageFilename}">`,
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);
await this.addConfiguredTagsToNote(noteId);
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<void> {
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<string, string> = {};
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] = `<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.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);
await this.addConfiguredTagsToNote(noteId);
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<boolean> {
if (this.deps.isUpdateInProgress()) {
this.deps.showOsdNotification('Anki update already in progress');
return false;
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceCardModel = sentenceCardConfig.model;
if (!sentenceCardModel) {
this.deps.showOsdNotification('sentenceCardModel not configured');
return false;
}
const mpvClient = this.deps.getMpvClient();
if (!mpvClient || !mpvClient.currentVideoPath) {
this.deps.showOsdNotification('No video loaded');
return false;
}
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...');
try {
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
const videoPath = 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.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,
this.getConfiguredAnkiTags(),
);
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 false;
}
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<string, string> = {};
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<string, string> = {};
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] = `<img src="${imageFilename}">`;
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);
return true;
});
} catch (error) {
log.error('Error creating sentence card:', (error as Error).message);
this.deps.showOsdNotification(`Sentence card failed: ${(error as Error).message}`);
return false;
}
}
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<Buffer | null> {
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<Buffer | null> {
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}`;
}
}

View File

@@ -0,0 +1,265 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { findDuplicateNote, type NoteInfo } from './duplicate';
function createFieldResolver(noteInfo: NoteInfo, preferredName: string): string | null {
const names = Object.keys(noteInfo.fields);
const exact = names.find((name) => name === preferredName);
if (exact) return exact;
const lower = preferredName.toLowerCase();
return names.find((name) => name.toLowerCase() === lower) ?? null;
}
test('findDuplicateNote matches duplicate when candidate uses alternate word/expression field name', async () => {
const currentNote: NoteInfo = {
noteId: 100,
fields: {
Expression: { value: '食べる' },
},
};
const duplicateId = await findDuplicateNote('食べる', 100, currentNote, {
findNotes: async () => [100, 200],
notesInfo: async () => [
{
noteId: 200,
fields: {
Word: { value: '食べる' },
},
},
],
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
});
assert.equal(duplicateId, 200);
});
test('findDuplicateNote falls back to alias field query when primary field query returns no candidates', async () => {
const currentNote: NoteInfo = {
noteId: 100,
fields: {
Expression: { value: '食べる' },
},
};
const seenQueries: string[] = [];
const duplicateId = await findDuplicateNote('食べる', 100, currentNote, {
findNotes: async (query) => {
seenQueries.push(query);
if (query.includes('"Expression:')) {
return [];
}
if (query.includes('"word:') || query.includes('"Word:') || query.includes('"expression:')) {
return [200];
}
return [];
},
notesInfo: async () => [
{
noteId: 200,
fields: {
Word: { value: '食べる' },
},
},
],
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
});
assert.equal(duplicateId, 200);
assert.equal(seenQueries.length, 2);
});
test('findDuplicateNote checks both source expression/word values when both fields are present', async () => {
const currentNote: NoteInfo = {
noteId: 100,
fields: {
Expression: { value: '昨日は雨だった。' },
Word: { value: '雨' },
},
};
const seenQueries: string[] = [];
const duplicateId = await findDuplicateNote('昨日は雨だった。', 100, currentNote, {
findNotes: async (query) => {
seenQueries.push(query);
if (query.includes('昨日は雨だった。')) {
return [];
}
if (query.includes('"Word:雨"') || query.includes('"word:雨"') || query.includes('"Expression:雨"')) {
return [200];
}
return [];
},
notesInfo: async () => [
{
noteId: 200,
fields: {
Word: { value: '雨' },
},
},
],
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
});
assert.equal(duplicateId, 200);
assert.ok(seenQueries.some((query) => query.includes('昨日は雨だった。')));
assert.ok(seenQueries.some((query) => query.includes('雨')));
});
test('findDuplicateNote falls back to collection-wide query when deck-scoped query has no matches', async () => {
const currentNote: NoteInfo = {
noteId: 100,
fields: {
Expression: { value: '貴様' },
},
};
const seenQueries: string[] = [];
const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
findNotes: async (query) => {
seenQueries.push(query);
if (query.includes('deck:Japanese')) {
return [];
}
if (query.includes('"Expression:貴様"') || query.includes('"Word:貴様"')) {
return [200];
}
return [];
},
notesInfo: async () => [
{
noteId: 200,
fields: {
Expression: { value: '貴様' },
},
},
],
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
});
assert.equal(duplicateId, 200);
assert.ok(seenQueries.some((query) => query.includes('deck:Japanese')));
assert.ok(seenQueries.some((query) => !query.includes('deck:Japanese')));
});
test('findDuplicateNote falls back to plain text query when field queries miss', async () => {
const currentNote: NoteInfo = {
noteId: 100,
fields: {
Expression: { value: '貴様' },
},
};
const seenQueries: string[] = [];
const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
findNotes: async (query) => {
seenQueries.push(query);
if (query.includes('Expression:') || query.includes('Word:')) {
return [];
}
if (query.includes('"貴様"')) {
return [200];
}
return [];
},
notesInfo: async () => [
{
noteId: 200,
fields: {
Expression: { value: '貴様' },
},
},
],
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
});
assert.equal(duplicateId, 200);
assert.ok(seenQueries.some((query) => query.includes('Expression:')));
assert.ok(seenQueries.some((query) => query.endsWith('"貴様"')));
});
test('findDuplicateNote exact compare tolerates furigana bracket markup in candidate field', async () => {
const currentNote: NoteInfo = {
noteId: 100,
fields: {
Expression: { value: '貴様' },
},
};
const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
findNotes: async () => [200],
notesInfo: async () => [
{
noteId: 200,
fields: {
Expression: { value: '貴様[きさま]' },
},
},
],
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
});
assert.equal(duplicateId, 200);
});
test('findDuplicateNote exact compare tolerates html wrappers in candidate field', async () => {
const currentNote: NoteInfo = {
noteId: 100,
fields: {
Expression: { value: '貴様' },
},
};
const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
findNotes: async () => [200],
notesInfo: async () => [
{
noteId: 200,
fields: {
Expression: { value: '<span data-x="1">貴様</span>' },
},
},
],
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
});
assert.equal(duplicateId, 200);
});
test('findDuplicateNote does not disable retries on findNotes calls', async () => {
const currentNote: NoteInfo = {
noteId: 100,
fields: {
Expression: { value: '貴様' },
},
};
const seenOptions: Array<{ maxRetries?: number } | undefined> = [];
await findDuplicateNote('貴様', 100, currentNote, {
findNotes: async (_query, options) => {
seenOptions.push(options);
return [];
},
notesInfo: async () => [],
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
});
assert.ok(seenOptions.length > 0);
assert.ok(seenOptions.every((options) => options?.maxRetries !== 0));
});

View File

@@ -0,0 +1,194 @@
export interface NoteField {
value: string;
}
export interface NoteInfo {
noteId: number;
fields: Record<string, NoteField>;
}
export interface DuplicateDetectionDeps {
findNotes: (query: string, options?: { maxRetries?: number }) => Promise<unknown>;
notesInfo: (noteIds: number[]) => Promise<unknown>;
getDeck: () => string | null | undefined;
resolveFieldName: (noteInfo: NoteInfo, preferredName: string) => string | null;
logInfo?: (message: string) => void;
logDebug?: (message: string) => void;
logWarn: (message: string, error: unknown) => void;
}
export async function findDuplicateNote(
expression: string,
excludeNoteId: number,
noteInfo: NoteInfo,
deps: DuplicateDetectionDeps,
): Promise<number | null> {
const sourceCandidates = getDuplicateSourceCandidates(noteInfo, expression);
if (sourceCandidates.length === 0) return null;
deps.logInfo?.(
`[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates
.map((entry) => `${entry.fieldName}:${entry.value}`)
.join('|')}`,
);
const deckValue = deps.getDeck();
const queryPrefixes = deckValue
? [`"deck:${escapeAnkiSearchValue(deckValue)}" `, '']
: [''];
try {
const noteIds = new Set<number>();
const executedQueries = new Set<string>();
for (const queryPrefix of queryPrefixes) {
for (const sourceCandidate of sourceCandidates) {
const escapedExpression = escapeAnkiSearchValue(sourceCandidate.value);
const queryFieldNames = getDuplicateCandidateFieldNames(sourceCandidate.fieldName);
for (const queryFieldName of queryFieldNames) {
const escapedFieldName = escapeAnkiSearchValue(queryFieldName);
const query = `${queryPrefix}"${escapedFieldName}:${escapedExpression}"`;
if (executedQueries.has(query)) continue;
executedQueries.add(query);
const results = (await deps.findNotes(query)) as number[];
deps.logDebug?.(
`[duplicate] query(field)="${query}" hits=${Array.isArray(results) ? results.length : 0}`,
);
for (const noteId of results) {
noteIds.add(noteId);
}
}
}
if (noteIds.size > 0) break;
}
if (noteIds.size === 0) {
for (const queryPrefix of queryPrefixes) {
for (const sourceCandidate of sourceCandidates) {
const escapedExpression = escapeAnkiSearchValue(sourceCandidate.value);
const query = `${queryPrefix}"${escapedExpression}"`;
if (executedQueries.has(query)) continue;
executedQueries.add(query);
const results = (await deps.findNotes(query)) as number[];
deps.logDebug?.(
`[duplicate] query(text)="${query}" hits=${Array.isArray(results) ? results.length : 0}`,
);
for (const noteId of results) {
noteIds.add(noteId);
}
}
if (noteIds.size > 0) break;
}
}
return await findFirstExactDuplicateNoteId(
noteIds,
excludeNoteId,
sourceCandidates.map((candidate) => candidate.value),
deps,
);
} catch (error) {
deps.logWarn('Duplicate search failed:', error);
return null;
}
}
function findFirstExactDuplicateNoteId(
candidateNoteIds: Iterable<number>,
excludeNoteId: number,
sourceValues: string[],
deps: DuplicateDetectionDeps,
): Promise<number | null> {
const candidates = Array.from(candidateNoteIds).filter((id) => id !== excludeNoteId);
deps.logDebug?.(`[duplicate] candidateIds=${candidates.length} exclude=${excludeNoteId}`);
if (candidates.length === 0) {
deps.logInfo?.('[duplicate] no candidates after query + exclude');
return Promise.resolve(null);
}
const normalizedValues = new Set(
sourceValues.map((value) => normalizeDuplicateValue(value)).filter((value) => value.length > 0),
);
if (normalizedValues.size === 0) {
return Promise.resolve(null);
}
const chunkSize = 50;
return (async () => {
for (let i = 0; i < candidates.length; i += chunkSize) {
const chunk = candidates.slice(i, i + chunkSize);
const notesInfoResult = (await deps.notesInfo(chunk)) as unknown[];
const notesInfo = notesInfoResult as NoteInfo[];
for (const noteInfo of notesInfo) {
const candidateFieldNames = ['word', 'expression'];
for (const candidateFieldName of candidateFieldNames) {
const resolvedField = deps.resolveFieldName(noteInfo, candidateFieldName);
if (!resolvedField) continue;
const candidateValue = noteInfo.fields[resolvedField]?.value || '';
if (normalizedValues.has(normalizeDuplicateValue(candidateValue))) {
deps.logDebug?.(
`[duplicate] exact-match noteId=${noteInfo.noteId} field=${resolvedField}`,
);
deps.logInfo?.(`[duplicate] matched noteId=${noteInfo.noteId} field=${resolvedField}`);
return noteInfo.noteId;
}
}
}
}
deps.logInfo?.('[duplicate] no exact match in candidate notes');
return null;
})();
}
function getDuplicateCandidateFieldNames(fieldName: string): string[] {
const candidates = [fieldName];
const lower = fieldName.toLowerCase();
if (lower === 'word') {
candidates.push('expression');
} else if (lower === 'expression') {
candidates.push('word');
}
return candidates;
}
function getDuplicateSourceCandidates(
noteInfo: NoteInfo,
fallbackExpression: string,
): Array<{ fieldName: string; value: string }> {
const candidates: Array<{ fieldName: string; value: string }> = [];
const dedupeKey = new Set<string>();
for (const fieldName of Object.keys(noteInfo.fields)) {
const lower = fieldName.toLowerCase();
if (lower !== 'word' && lower !== 'expression') continue;
const value = noteInfo.fields[fieldName]?.value?.trim() ?? '';
if (!value) continue;
const key = `${lower}:${normalizeDuplicateValue(value)}`;
if (dedupeKey.has(key)) continue;
dedupeKey.add(key);
candidates.push({ fieldName, value });
}
const trimmedFallback = fallbackExpression.trim();
if (trimmedFallback.length > 0) {
const fallbackKey = `expression:${normalizeDuplicateValue(trimmedFallback)}`;
if (!dedupeKey.has(fallbackKey)) {
candidates.push({ fieldName: 'expression', value: trimmedFallback });
}
}
return candidates;
}
function normalizeDuplicateValue(value: string): string {
return value
.replace(/<[^>]*>/g, '')
.replace(/([^\s\[\]]+)\[[^\]]*\]/g, '$1')
.replace(/\s+/g, ' ')
.trim();
}
function escapeAnkiSearchValue(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/([:*?()[\]{}])/g, '\\$1');
}

View File

@@ -0,0 +1,461 @@
import { AnkiConnectConfig } from '../types';
interface FieldGroupingMergeMedia {
audioField?: string;
audioValue?: string;
imageField?: string;
imageValue?: string;
miscInfoValue?: string;
}
export interface FieldGroupingMergeNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
interface FieldGroupingMergeDeps {
getConfig: () => AnkiConnectConfig;
getEffectiveSentenceCardConfig: () => {
sentenceField: string;
audioField: string;
};
getCurrentSubtitleText: () => string | undefined;
resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
resolveNoteFieldName: (
noteInfo: FieldGroupingMergeNoteInfo,
preferredName?: string,
) => string | null;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
generateMediaForMerge: () => Promise<FieldGroupingMergeMedia>;
warnFieldParseOnce: (fieldName: string, reason: string, detail?: string) => void;
}
export class FieldGroupingMergeCollaborator {
private readonly strictGroupingFieldDefaults = new Set<string>([
'picture',
'sentence',
'sentenceaudio',
'sentencefurigana',
'miscinfo',
]);
constructor(private readonly deps: FieldGroupingMergeDeps) {}
getGroupableFieldNames(): string[] {
const config = this.deps.getConfig();
const fields: string[] = [];
fields.push('Sentence');
fields.push('SentenceAudio');
fields.push('Picture');
if (config.fields?.image) fields.push(config.fields?.image);
if (config.fields?.sentence) fields.push(config.fields?.sentence);
if (config.fields?.audio && config.fields?.audio.toLowerCase() !== 'expressionaudio') {
fields.push(config.fields?.audio);
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceAudioField = sentenceCardConfig.audioField;
if (!fields.includes(sentenceAudioField)) fields.push(sentenceAudioField);
if (config.fields?.miscInfo) fields.push(config.fields?.miscInfo);
fields.push('SentenceFurigana');
return fields;
}
getNoteFieldMap(noteInfo: FieldGroupingMergeNoteInfo): Record<string, string> {
const fields: Record<string, string> = {};
for (const [name, field] of Object.entries(noteInfo.fields)) {
fields[name] = field?.value || '';
}
return fields;
}
async computeFieldGroupingMergedFields(
keepNoteId: number,
deleteNoteId: number,
keepNoteInfo: FieldGroupingMergeNoteInfo,
deleteNoteInfo: FieldGroupingMergeNoteInfo,
includeGeneratedMedia: boolean,
): Promise<Record<string, string>> {
const config = this.deps.getConfig();
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.deps.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 (
config.fields?.sentence &&
!sourceFields[config.fields?.sentence] &&
this.deps.getCurrentSubtitleText()
) {
const deleteFields = this.deps.extractFields(deleteNoteInfo.fields);
sourceFields[config.fields?.sentence] = this.deps.processSentence(
this.deps.getCurrentSubtitleText()!,
deleteFields,
);
}
if (includeGeneratedMedia) {
const media = await this.deps.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 (
config.fields?.miscInfo &&
media.miscInfoValue &&
!sourceFields[config.fields?.miscInfo]
) {
sourceFields[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;
}
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const resolvedSentenceAudioField = this.deps.resolveFieldName(
keepFieldNames,
sentenceCardConfig.audioField || 'SentenceAudio',
);
const resolvedExpressionAudioField = this.deps.resolveFieldName(
keepFieldNames,
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 getResolvedFieldValue(
noteInfo: FieldGroupingMergeNoteInfo,
preferredFieldName?: string,
): string {
if (!preferredFieldName) return '';
const resolved = this.deps.resolveNoteFieldName(noteInfo, preferredFieldName);
if (!resolved) return '';
return noteInfo.fields[resolved]?.value || '';
}
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.deps.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.deps.warnFieldParseOnce(fieldName, 'empty-group-content');
continue;
}
entries.push({ groupId, content });
}
if (entries.length === 0 && /<span\b/i.test(value)) {
this.deps.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.deps.warnFieldParseOnce('Picture', 'invalid-group-id', idMatch[1]);
} else {
groupId = parsed;
}
}
const normalizedTag = this.ensureImageGroupId(tag, groupId);
if (!normalizedTag) {
this.deps.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.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
}
return lastSoundTag || ungrouped;
}
if (normalizedField === 'picture') {
const lastImageTag = this.extractLastImageTag(ungrouped);
if (!lastImageTag) {
this.deps.warnFieldParseOnce(fieldName, 'missing-image-tag');
}
return lastImageTag || ungrouped;
}
return ungrouped;
}
private getStrictSpanGroupingFields(): Set<string> {
const strictFields = new Set(this.strictGroupingFieldDefaults);
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
strictFields.add((sentenceCardConfig.sentenceField || 'sentence').toLowerCase());
strictFields.add((sentenceCardConfig.audioField || 'sentenceaudio').toLowerCase());
const config = this.deps.getConfig();
if (config.fields?.image) strictFields.add(config.fields.image.toLowerCase());
if (config.fields?.miscInfo) strictFields.add(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;
}
}

View File

@@ -0,0 +1,114 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { FieldGroupingWorkflow } from './field-grouping-workflow';
type NoteInfo = {
noteId: number;
fields: Record<string, { value: string }>;
};
function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const deleted: number[][] = [];
const statuses: string[] = [];
const deps = {
client: {
notesInfo: async (noteIds: number[]) =>
noteIds.map(
(noteId) =>
({
noteId,
fields: {
Expression: { value: `word-${noteId}` },
Sentence: { value: `line-${noteId}` },
},
}) satisfies NoteInfo,
),
updateNoteFields: async (noteId: number, fields: Record<string, string>) => {
updates.push({ noteId, fields });
},
deleteNotes: async (noteIds: number[]) => {
deleted.push(noteIds);
},
},
getConfig: () => ({
fields: {
audio: 'ExpressionAudio',
image: 'Picture',
},
isKiku: {
deleteDuplicateInAuto: true,
},
}),
getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
kikuDeleteDuplicateInAuto: true,
}),
getCurrentSubtitleText: () => 'subtitle-text',
getFieldGroupingCallback: () => null,
setFieldGroupingCallback: () => undefined,
computeFieldGroupingMergedFields: async () => ({
Sentence: 'merged sentence',
}),
extractFields: (fields: Record<string, { value: string }>) => {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(fields)) {
out[key.toLowerCase()] = value.value;
}
return out;
},
hasFieldValue: (_noteInfo: NoteInfo, _field?: string) => false,
addConfiguredTagsToNote: async () => undefined,
removeTrackedNoteId: () => undefined,
showStatusNotification: (message: string) => {
statuses.push(message);
},
showNotification: async () => undefined,
showOsdNotification: () => undefined,
logError: () => undefined,
logInfo: () => undefined,
truncateSentence: (value: string) => value,
};
return {
workflow: new FieldGroupingWorkflow(deps),
updates,
deleted,
statuses,
deps,
};
}
test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate by default', async () => {
const harness = createWorkflowHarness();
await harness.workflow.handleAuto(1, 2, {
noteId: 2,
fields: {
Expression: { value: 'word-2' },
Sentence: { value: 'line-2' },
},
});
assert.equal(harness.updates.length, 1);
assert.equal(harness.updates[0]?.noteId, 1);
assert.deepEqual(harness.deleted, [[2]]);
assert.equal(harness.statuses.length, 1);
});
test('FieldGroupingWorkflow manual mode returns false when callback unavailable', async () => {
const harness = createWorkflowHarness();
const handled = await harness.workflow.handleManual(1, 2, {
noteId: 2,
fields: {
Expression: { value: 'word-2' },
Sentence: { value: 'line-2' },
},
});
assert.equal(handled, false);
assert.equal(harness.updates.length, 0);
});

View File

@@ -0,0 +1,214 @@
import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types';
export interface FieldGroupingWorkflowNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
export interface FieldGroupingWorkflowDeps {
client: {
notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
deleteNotes(noteIds: number[]): Promise<void>;
};
getConfig: () => {
fields?: {
audio?: string;
image?: string;
};
};
getEffectiveSentenceCardConfig: () => {
sentenceField: string;
audioField: string;
kikuDeleteDuplicateInAuto: boolean;
};
getCurrentSubtitleText: () => string | undefined;
getFieldGroupingCallback:
| (() => Promise<
| ((data: {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}) => Promise<KikuFieldGroupingChoice>)
| null
>)
| (() =>
| ((data: {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}) => Promise<KikuFieldGroupingChoice>)
| null);
computeFieldGroupingMergedFields: (
keepNoteId: number,
deleteNoteId: number,
keepNoteInfo: FieldGroupingWorkflowNoteInfo,
deleteNoteInfo: FieldGroupingWorkflowNoteInfo,
includeGeneratedMedia: boolean,
) => Promise<Record<string, string>>;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
hasFieldValue: (noteInfo: FieldGroupingWorkflowNoteInfo, preferredFieldName?: string) => boolean;
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
removeTrackedNoteId: (noteId: number) => void;
showStatusNotification: (message: string) => void;
showNotification: (noteId: number, label: string | number) => Promise<void>;
showOsdNotification: (message: string) => void;
logError: (message: string, ...args: unknown[]) => void;
logInfo: (message: string, ...args: unknown[]) => void;
truncateSentence: (sentence: string) => string;
}
export class FieldGroupingWorkflow {
constructor(private readonly deps: FieldGroupingWorkflowDeps) {}
async handleAuto(
originalNoteId: number,
newNoteId: number,
newNoteInfo: FieldGroupingWorkflowNoteInfo,
): Promise<void> {
try {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
await this.performMerge(
originalNoteId,
newNoteId,
newNoteInfo,
this.getExpression(newNoteInfo),
sentenceCardConfig.kikuDeleteDuplicateInAuto,
);
} catch (error) {
this.deps.logError('Field grouping auto merge failed:', (error as Error).message);
this.deps.showOsdNotification(`Field grouping failed: ${(error as Error).message}`);
}
}
async handleManual(
originalNoteId: number,
newNoteId: number,
newNoteInfo: FieldGroupingWorkflowNoteInfo,
): Promise<boolean> {
const callback = await this.resolveFieldGroupingCallback();
if (!callback) {
this.deps.showOsdNotification('Field grouping UI unavailable');
return false;
}
try {
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
if (!originalNotesInfo || originalNotesInfo.length === 0) {
return false;
}
const originalNoteInfo = originalNotesInfo[0]!;
const expression = this.getExpression(newNoteInfo) || this.getExpression(originalNoteInfo);
const choice = await callback({
original: this.buildDuplicateCardInfo(originalNoteInfo, expression, true),
duplicate: this.buildDuplicateCardInfo(newNoteInfo, expression, false),
});
if (choice.cancelled) {
this.deps.showOsdNotification('Field grouping cancelled');
return false;
}
const keepNoteId = choice.keepNoteId;
const deleteNoteId = choice.deleteNoteId;
const deleteNoteInfo = deleteNoteId === newNoteId ? newNoteInfo : originalNoteInfo;
await this.performMerge(
keepNoteId,
deleteNoteId,
deleteNoteInfo,
expression,
choice.deleteDuplicate,
);
return true;
} catch (error) {
this.deps.logError('Field grouping manual merge failed:', (error as Error).message);
this.deps.showOsdNotification(`Field grouping failed: ${(error as Error).message}`);
return false;
}
}
private async performMerge(
keepNoteId: number,
deleteNoteId: number,
deleteNoteInfo: FieldGroupingWorkflowNoteInfo,
expression: string,
deleteDuplicate = true,
): Promise<void> {
const keepNotesInfoResult = await this.deps.client.notesInfo([keepNoteId]);
const keepNotesInfo = keepNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
if (!keepNotesInfo || keepNotesInfo.length === 0) {
this.deps.logInfo('Keep note not found:', keepNoteId);
return;
}
const keepNoteInfo = keepNotesInfo[0]!;
const mergedFields = await this.deps.computeFieldGroupingMergedFields(
keepNoteId,
deleteNoteId,
keepNoteInfo,
deleteNoteInfo,
true,
);
if (Object.keys(mergedFields).length > 0) {
await this.deps.client.updateNoteFields(keepNoteId, mergedFields);
await this.deps.addConfiguredTagsToNote(keepNoteId);
}
if (deleteDuplicate) {
await this.deps.client.deleteNotes([deleteNoteId]);
this.deps.removeTrackedNoteId(deleteNoteId);
}
this.deps.logInfo('Merged duplicate card:', expression, 'into note:', keepNoteId);
this.deps.showStatusNotification(
deleteDuplicate
? `Merged duplicate: ${expression}`
: `Grouped duplicate (kept both): ${expression}`,
);
await this.deps.showNotification(keepNoteId, expression);
}
private buildDuplicateCardInfo(
noteInfo: FieldGroupingWorkflowNoteInfo,
fallbackExpression: string,
isOriginal: boolean,
): KikuDuplicateCardInfo {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const fields = this.deps.extractFields(noteInfo.fields);
return {
noteId: noteInfo.noteId,
expression: fields.expression || fields.word || fallbackExpression,
sentencePreview: this.deps.truncateSentence(
fields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] ||
(isOriginal ? '' : this.deps.getCurrentSubtitleText() || ''),
),
hasAudio:
this.deps.hasFieldValue(noteInfo, this.deps.getConfig().fields?.audio) ||
this.deps.hasFieldValue(noteInfo, sentenceCardConfig.audioField),
hasImage: this.deps.hasFieldValue(noteInfo, this.deps.getConfig().fields?.image),
isOriginal,
};
}
private getExpression(noteInfo: FieldGroupingWorkflowNoteInfo): string {
const fields = this.deps.extractFields(noteInfo.fields);
return fields.expression || fields.word || '';
}
private async resolveFieldGroupingCallback(): Promise<
| ((data: {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}) => Promise<KikuFieldGroupingChoice>)
| null
> {
const callback = this.deps.getFieldGroupingCallback();
if (callback instanceof Promise) {
return callback;
}
return callback;
}
}

View File

@@ -0,0 +1,236 @@
import { KikuMergePreviewResponse } from '../types';
import { createLogger } from '../logger';
const log = createLogger('anki').child('integration.field-grouping');
interface FieldGroupingNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
interface FieldGroupingDeps {
getEffectiveSentenceCardConfig: () => {
model?: string;
sentenceField: string;
audioField: string;
lapisEnabled: boolean;
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean;
};
isUpdateInProgress: () => boolean;
getDeck?: () => string | undefined;
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) => Promise<T>;
showOsdNotification: (text: string) => void;
findNotes: (
query: string,
options?: {
maxRetries?: number;
},
) => Promise<number[]>;
notesInfo: (noteIds: number[]) => Promise<FieldGroupingNoteInfo[]>;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
findDuplicateNote: (
expression: string,
excludeNoteId: number,
noteInfo: FieldGroupingNoteInfo,
) => Promise<number | null>;
hasAllConfiguredFields: (
noteInfo: FieldGroupingNoteInfo,
configuredFieldNames: (string | undefined)[],
) => boolean;
processNewCard: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => Promise<void>;
getSentenceCardImageFieldName: () => string | undefined;
resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
computeFieldGroupingMergedFields: (
keepNoteId: number,
deleteNoteId: number,
keepNoteInfo: FieldGroupingNoteInfo,
deleteNoteInfo: FieldGroupingNoteInfo,
includeGeneratedMedia: boolean,
) => Promise<Record<string, string>>;
getNoteFieldMap: (noteInfo: FieldGroupingNoteInfo) => Record<string, string>;
handleFieldGroupingAuto: (
originalNoteId: number,
newNoteId: number,
newNoteInfo: FieldGroupingNoteInfo,
expression: string,
) => Promise<void>;
handleFieldGroupingManual: (
originalNoteId: number,
newNoteId: number,
newNoteInfo: FieldGroupingNoteInfo,
expression: string,
) => Promise<boolean>;
}
export class FieldGroupingService {
constructor(private readonly deps: FieldGroupingDeps) {}
async triggerFieldGroupingForLastAddedCard(): Promise<void> {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
if (!sentenceCardConfig.kikuEnabled) {
this.deps.showOsdNotification('Kiku mode is not enabled');
return;
}
if (sentenceCardConfig.kikuFieldGrouping === 'disabled') {
this.deps.showOsdNotification('Kiku field grouping is disabled');
return;
}
if (this.deps.isUpdateInProgress()) {
this.deps.showOsdNotification('Anki update already in progress');
return;
}
try {
await this.deps.withUpdateProgress('Grouping duplicate cards', async () => {
const deck = this.deps.getDeck ? this.deps.getDeck() : undefined;
const query = deck ? `"deck:${deck}" added:1` : 'added:1';
const noteIds = await this.deps.findNotes(query);
if (!noteIds || noteIds.length === 0) {
this.deps.showOsdNotification('No recently added cards found');
return;
}
const noteId = Math.max(...noteIds);
const notesInfoResult = await this.deps.notesInfo([noteId]);
const notesInfo = notesInfoResult as FieldGroupingNoteInfo[];
if (!notesInfo || notesInfo.length === 0) {
this.deps.showOsdNotification('Card not found');
return;
}
const noteInfoBeforeUpdate = notesInfo[0]!;
const fields = this.deps.extractFields(noteInfoBeforeUpdate.fields);
const expressionText = fields.expression || fields.word || '';
if (!expressionText) {
this.deps.showOsdNotification('No expression/word field found');
return;
}
const duplicateNoteId = await this.deps.findDuplicateNote(
expressionText,
noteId,
noteInfoBeforeUpdate,
);
if (duplicateNoteId === null) {
this.deps.showOsdNotification('No duplicate card found');
return;
}
if (
!this.deps.hasAllConfiguredFields(noteInfoBeforeUpdate, [
this.deps.getSentenceCardImageFieldName(),
])
) {
await this.deps.processNewCard(noteId, {
skipKikuFieldGrouping: true,
});
}
const refreshedInfoResult = await this.deps.notesInfo([noteId]);
const refreshedInfo = refreshedInfoResult as FieldGroupingNoteInfo[];
if (!refreshedInfo || refreshedInfo.length === 0) {
this.deps.showOsdNotification('Card not found');
return;
}
const noteInfo = refreshedInfo[0]!;
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
await this.deps.handleFieldGroupingAuto(
duplicateNoteId,
noteId,
noteInfo,
expressionText,
);
return;
}
const handled = await this.deps.handleFieldGroupingManual(
duplicateNoteId,
noteId,
noteInfo,
expressionText,
);
if (!handled) {
this.deps.showOsdNotification('Field grouping cancelled');
}
});
} catch (error) {
log.error('Error triggering field grouping:', (error as Error).message);
this.deps.showOsdNotification(`Field grouping failed: ${(error as Error).message}`);
}
}
async buildFieldGroupingPreview(
keepNoteId: number,
deleteNoteId: number,
deleteDuplicate: boolean,
): Promise<KikuMergePreviewResponse> {
try {
const notesInfoResult = await this.deps.notesInfo([keepNoteId, deleteNoteId]);
const notesInfo = notesInfoResult as FieldGroupingNoteInfo[];
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.deps.computeFieldGroupingMergedFields(
keepNoteId,
deleteNoteId,
keepNoteInfo,
deleteNoteInfo,
false,
);
const keepBefore = this.deps.getNoteFieldMap(keepNoteInfo);
const keepAfter = { ...keepBefore, ...mergedFields };
const sourceBefore = this.deps.getNoteFieldMap(deleteNoteInfo);
const compactFields: Record<string, string> = {};
for (const fieldName of [
'Sentence',
'SentenceFurigana',
'SentenceAudio',
'Picture',
'MiscInfo',
]) {
const resolved = this.deps.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}`,
};
}
}
}

View File

@@ -0,0 +1,388 @@
import fs from 'fs';
import path from 'path';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { AnkiConnectConfig } from '../types';
import { createLogger } from '../logger';
const log = createLogger('anki').child('integration.known-word-cache');
export interface KnownWordCacheNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
interface KnownWordCacheState {
readonly version: 1;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
}
interface KnownWordCacheClient {
findNotes: (
query: string,
options?: {
maxRetries?: number;
},
) => Promise<unknown>;
notesInfo: (noteIds: number[]) => Promise<unknown>;
}
interface KnownWordCacheDeps {
client: KnownWordCacheClient;
getConfig: () => AnkiConnectConfig;
knownWordCacheStatePath?: string;
showStatusNotification: (message: string) => void;
}
export class KnownWordCacheManager {
private knownWordsLastRefreshedAtMs = 0;
private knownWordsScope = '';
private knownWords: Set<string> = new Set();
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
private isRefreshingKnownWords = false;
private readonly statePath: string;
constructor(private readonly deps: KnownWordCacheDeps) {
this.statePath = path.normalize(
deps.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;
}
refresh(force = false): Promise<void> {
return this.refreshKnownWords(force);
}
startLifecycle(): void {
this.stopLifecycle();
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.statePath}`,
);
this.loadKnownWordCacheState();
void this.refreshKnownWords();
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
this.knownWordsRefreshTimer = setInterval(() => {
void this.refreshKnownWords();
}, refreshIntervalMs);
}
stopLifecycle(): void {
if (this.knownWordsRefreshTimer) {
clearInterval(this.knownWordsRefreshTimer);
this.knownWordsRefreshTimer = null;
}
}
appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): 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}`,
);
}
}
clearKnownWordCacheState(): void {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
try {
if (fs.existsSync(this.statePath)) {
fs.unlinkSync(this.statePath);
}
} catch (error) {
log.warn('Failed to clear known-word cache state:', (error as Error).message);
}
}
private async refreshKnownWords(force = false): Promise<void> {
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.deps.client.findNotes(query, {
maxRetries: 0,
})) as number[];
const nextKnownWords = new Set<string>();
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.deps.client.notesInfo(chunk)) as unknown[];
const notesInfo = notesInfoResult as KnownWordCacheNoteInfo[];
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.deps.showStatusNotification('AnkiConnect: unable to refresh known words');
} finally {
this.isRefreshingKnownWords = false;
}
}
private isKnownWordCacheEnabled(): boolean {
return this.deps.getConfig().nPlusOne?.highlightEnabled === true;
}
private getKnownWordRefreshIntervalMs(): number {
const minutes = this.deps.getConfig().nPlusOne?.refreshMinutes;
const safeMinutes =
typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0
? minutes
: DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.refreshMinutes;
return safeMinutes * 60_000;
}
private getKnownWordDecks(): string[] {
const configuredDecks = this.deps.getConfig().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.deps.getConfig().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.statePath)) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
return;
}
const raw = fs.readFileSync(this.statePath, '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<string>();
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.statePath, JSON.stringify(state), 'utf-8');
} catch (error) {
log.warn('Failed to persist 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<KnownWordCacheState>;
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: KnownWordCacheNoteInfo): string[] {
const words: string[] = [];
const preferredFields = ['Expression', 'Word'];
for (const preferredField of preferredFields) {
const fieldName = 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 normalizeRawKnownWordValue(value: string): string {
return value
.replace(/<[^>]*>/g, '')
.replace(/\u3000/g, ' ')
.trim();
}
private normalizeKnownWordForLookup(value: string): string {
return this.normalizeRawKnownWordValue(value).toLowerCase();
}
}
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
const exact = availableFieldNames.find((name) => name === preferredName);
if (exact) return exact;
const lower = preferredName.toLowerCase();
return availableFieldNames.find((name) => name.toLowerCase() === lower) || null;
}
function escapeAnkiSearchValue(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/\"/g, '\\"')
.replace(/([:*?()\[\]{}])/g, '\\$1');
}

View File

@@ -0,0 +1,173 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
NoteUpdateWorkflow,
type NoteUpdateWorkflowDeps,
type NoteUpdateWorkflowNoteInfo,
} from './note-update-workflow';
function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const notifications: Array<{ noteId: number; label: string | number }> = [];
const warnings: string[] = [];
const deps: NoteUpdateWorkflowDeps = {
client: {
notesInfo: async (_noteIds: number[]) =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[],
updateNoteFields: async (noteId: number, fields: Record<string, string>) => {
updates.push({ noteId, fields });
},
storeMediaFile: async () => undefined,
},
getConfig: () => ({
fields: {
sentence: 'Sentence',
},
media: {},
behavior: {},
}),
getCurrentSubtitleText: () => 'subtitle-text',
getCurrentSubtitleStart: () => 12.3,
getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence',
kikuEnabled: false,
kikuFieldGrouping: 'disabled' as const,
}),
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
extractFields: (fields: Record<string, { value: string }>) => {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(fields)) {
out[key.toLowerCase()] = value.value;
}
return out;
},
findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null,
handleFieldGroupingAuto: async (
_originalNoteId,
_newNoteId,
_newNoteInfo,
_expression,
) => undefined,
handleFieldGroupingManual: async (
_originalNoteId,
_newNoteId,
_newNoteInfo,
_expression,
) => false,
processSentence: (text: string, _noteFields: Record<string, string>) => text,
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
if (!preferred) return null;
const names = Object.keys(noteInfo.fields);
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
},
getResolvedSentenceAudioFieldName: () => null,
mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
generateAudioFilename: () => 'audio_1.mp3',
generateAudio: async () => null,
generateImageFilename: () => 'image_1.jpg',
generateImage: async () => null,
formatMiscInfoPattern: () => '',
addConfiguredTagsToNote: async () => undefined,
showNotification: async (noteId: number, label: string | number) => {
notifications.push({ noteId, label });
},
showOsdNotification: (_text: string) => undefined,
beginUpdateProgress: (_text: string) => undefined,
endUpdateProgress: () => undefined,
logWarn: (message: string, ..._args: unknown[]) => warnings.push(message),
logInfo: (_message: string) => undefined,
logError: (_message: string) => undefined,
};
return {
workflow: new NoteUpdateWorkflow(deps),
updates,
notifications,
warnings,
deps,
};
}
test('NoteUpdateWorkflow updates sentence field and emits notification', async () => {
const harness = createWorkflowHarness();
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.equal(harness.updates[0]?.noteId, 42);
assert.equal(harness.updates[0]?.fields.Sentence, 'subtitle-text');
assert.equal(harness.notifications.length, 1);
});
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () => [];
await harness.workflow.execute(777);
assert.equal(harness.updates.length, 0);
assert.equal(harness.notifications.length, 0);
assert.equal(harness.warnings.length, 1);
});
test('NoteUpdateWorkflow updates note before auto field grouping merge', async () => {
const harness = createWorkflowHarness();
const callOrder: string[] = [];
let notesInfoCallCount = 0;
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
kikuEnabled: true,
kikuFieldGrouping: 'auto',
});
harness.deps.findDuplicateNote = async () => 99;
harness.deps.client.notesInfo = async () => {
notesInfoCallCount += 1;
if (notesInfoCallCount === 1) {
return [
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
}
return [
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: 'subtitle-text' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
};
harness.deps.client.updateNoteFields = async (noteId, fields) => {
callOrder.push('update');
harness.updates.push({ noteId, fields });
};
harness.deps.handleFieldGroupingAuto = async (
_originalNoteId,
_newNoteId,
newNoteInfo,
_expression,
) => {
callOrder.push('auto');
assert.equal(newNoteInfo.fields.Sentence?.value, 'subtitle-text');
};
await harness.workflow.execute(42);
assert.deepEqual(callOrder, ['update', 'auto']);
assert.equal(harness.updates.length, 1);
});

View File

@@ -0,0 +1,242 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
export interface NoteUpdateWorkflowNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
export interface NoteUpdateWorkflowDeps {
client: {
notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
storeMediaFile(filename: string, data: Buffer): Promise<void>;
};
getConfig: () => {
fields?: {
sentence?: string;
image?: string;
miscInfo?: string;
};
media?: {
generateAudio?: boolean;
generateImage?: boolean;
};
behavior?: {
overwriteAudio?: boolean;
overwriteImage?: boolean;
};
};
getCurrentSubtitleText: () => string | undefined;
getCurrentSubtitleStart: () => number | undefined;
getEffectiveSentenceCardConfig: () => {
sentenceField: string;
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
};
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
findDuplicateNote: (
expression: string,
excludeNoteId: number,
noteInfo: NoteUpdateWorkflowNoteInfo,
) => Promise<number | null>;
handleFieldGroupingAuto: (
originalNoteId: number,
newNoteId: number,
newNoteInfo: NoteUpdateWorkflowNoteInfo,
expression: string,
) => Promise<void>;
handleFieldGroupingManual: (
originalNoteId: number,
newNoteId: number,
newNoteInfo: NoteUpdateWorkflowNoteInfo,
expression: string,
) => Promise<boolean>;
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
resolveConfiguredFieldName: (
noteInfo: NoteUpdateWorkflowNoteInfo,
...preferredNames: (string | undefined)[]
) => string | null;
getResolvedSentenceAudioFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo) => string | null;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
generateAudioFilename: () => string;
generateAudio: () => Promise<Buffer | null>;
generateImageFilename: () => string;
generateImage: () => Promise<Buffer | null>;
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
showNotification: (noteId: number, label: string | number) => Promise<void>;
showOsdNotification: (message: string) => void;
beginUpdateProgress: (initialMessage: string) => void;
endUpdateProgress: () => void;
logWarn: (message: string, ...args: unknown[]) => void;
logInfo: (message: string, ...args: unknown[]) => void;
logError: (message: string, ...args: unknown[]) => void;
}
export class NoteUpdateWorkflow {
constructor(private readonly deps: NoteUpdateWorkflowDeps) {}
async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise<void> {
this.deps.beginUpdateProgress('Updating card');
try {
const notesInfoResult = await this.deps.client.notesInfo([noteId]);
const notesInfo = notesInfoResult as NoteUpdateWorkflowNoteInfo[];
if (!notesInfo || notesInfo.length === 0) {
this.deps.logWarn('Card not found:', noteId);
return;
}
const noteInfo = notesInfo[0]!;
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
const fields = this.deps.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || '';
if (!expressionText) {
this.deps.logWarn('No expression/word field found in card:', noteId);
return;
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const shouldRunFieldGrouping =
!options?.skipKikuFieldGrouping &&
sentenceCardConfig.kikuEnabled &&
sentenceCardConfig.kikuFieldGrouping !== 'disabled';
let duplicateNoteId: number | null = null;
if (shouldRunFieldGrouping) {
duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo);
}
const updatedFields: Record<string, string> = {};
let updatePerformed = false;
let miscInfoFilename: string | null = null;
const sentenceField = sentenceCardConfig.sentenceField;
const currentSubtitleText = this.deps.getCurrentSubtitleText();
if (sentenceField && currentSubtitleText) {
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
updatedFields[sentenceField] = processedSentence;
updatePerformed = true;
}
const config = this.deps.getConfig();
if (config.media?.generateAudio) {
try {
const audioFilename = this.deps.generateAudioFilename();
const audioBuffer = await this.deps.generateAudio();
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const sentenceAudioField = this.deps.getResolvedSentenceAudioFieldName(noteInfo);
if (sentenceAudioField) {
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
existingAudio,
`[sound:${audioFilename}]`,
config.behavior?.overwriteAudio !== false,
);
}
miscInfoFilename = audioFilename;
updatePerformed = true;
}
} catch (error) {
this.deps.logError('Failed to generate audio:', (error as Error).message);
this.deps.showOsdNotification(`Audio generation failed: ${(error as Error).message}`);
}
}
if (config.media?.generateImage) {
try {
const imageFilename = this.deps.generateImageFilename();
const imageBuffer = await this.deps.generateImage();
if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
const imageFieldName = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.image,
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
);
if (!imageFieldName) {
this.deps.logWarn('Image field not found on note, skipping image update');
} else {
const existingImage = noteInfo.fields[imageFieldName]?.value || '';
updatedFields[imageFieldName] = this.deps.mergeFieldValue(
existingImage,
`<img src="${imageFilename}">`,
config.behavior?.overwriteImage !== false,
);
miscInfoFilename = imageFilename;
updatePerformed = true;
}
}
} catch (error) {
this.deps.logError('Failed to generate image:', (error as Error).message);
this.deps.showOsdNotification(`Image generation failed: ${(error as Error).message}`);
}
}
if (config.fields?.miscInfo) {
const miscInfo = this.deps.formatMiscInfoPattern(
miscInfoFilename || '',
this.deps.getCurrentSubtitleStart(),
);
const miscInfoField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.miscInfo,
);
if (miscInfo && miscInfoField) {
updatedFields[miscInfoField] = miscInfo;
updatePerformed = true;
}
}
if (updatePerformed) {
await this.deps.client.updateNoteFields(noteId, updatedFields);
await this.deps.addConfiguredTagsToNote(noteId);
this.deps.logInfo('Updated card fields for:', expressionText);
await this.deps.showNotification(noteId, expressionText);
}
if (shouldRunFieldGrouping && duplicateNoteId !== null) {
let noteInfoForGrouping = noteInfo;
if (updatePerformed) {
const refreshedInfoResult = await this.deps.client.notesInfo([noteId]);
const refreshedInfo = refreshedInfoResult as NoteUpdateWorkflowNoteInfo[];
if (!refreshedInfo || refreshedInfo.length === 0) {
this.deps.logWarn('Card not found after update:', noteId);
return;
}
noteInfoForGrouping = refreshedInfo[0]!;
}
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
await this.deps.handleFieldGroupingAuto(
duplicateNoteId,
noteId,
noteInfoForGrouping,
expressionText,
);
return;
}
if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
await this.deps.handleFieldGroupingManual(
duplicateNoteId,
noteId,
noteInfoForGrouping,
expressionText,
);
}
}
} catch (error) {
if ((error as Error).message.includes('note was not found')) {
this.deps.logWarn('Card was deleted before update:', noteId);
} else {
this.deps.logError('Error processing new card:', (error as Error).message);
}
} finally {
this.deps.endUpdateProgress();
}
}
}

View File

@@ -0,0 +1,119 @@
export interface PollingRunnerDeps {
getDeck: () => string | undefined;
getPollingRate: () => number;
findNotes: (
query: string,
options?: {
maxRetries?: number;
},
) => Promise<number[]>;
shouldAutoUpdateNewCards: () => boolean;
processNewCard: (noteId: number) => Promise<void>;
isUpdateInProgress: () => boolean;
setUpdateInProgress: (value: boolean) => void;
getTrackedNoteIds: () => Set<number>;
setTrackedNoteIds: (noteIds: Set<number>) => void;
showStatusNotification: (message: string) => void;
logDebug: (...args: unknown[]) => void;
logInfo: (...args: unknown[]) => void;
logWarn: (...args: unknown[]) => void;
}
export class PollingRunner {
private pollingInterval: ReturnType<typeof setInterval> | null = null;
private initialized = false;
private backoffMs = 200;
private maxBackoffMs = 5000;
private nextPollTime = 0;
constructor(private readonly deps: PollingRunnerDeps) {}
get isRunning(): boolean {
return this.pollingInterval !== null;
}
start(): void {
if (this.pollingInterval) {
this.stop();
}
void this.pollOnce();
this.pollingInterval = setInterval(() => {
void this.pollOnce();
}, this.deps.getPollingRate());
}
stop(): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
async pollOnce(): Promise<void> {
if (this.deps.isUpdateInProgress()) return;
if (Date.now() < this.nextPollTime) return;
this.deps.setUpdateInProgress(true);
try {
const query = this.deps.getDeck() ? `"deck:${this.deps.getDeck()}" added:1` : 'added:1';
const noteIds = await this.deps.findNotes(query, {
maxRetries: 0,
});
const currentNoteIds = new Set(noteIds);
const previousNoteIds = this.deps.getTrackedNoteIds();
if (!this.initialized) {
this.deps.setTrackedNoteIds(currentNoteIds);
this.initialized = true;
this.deps.logInfo(`AnkiConnect initialized with ${currentNoteIds.size} existing cards`);
this.backoffMs = 200;
return;
}
const newNoteIds = Array.from(currentNoteIds).filter((id) => !previousNoteIds.has(id));
if (newNoteIds.length > 0) {
this.deps.logInfo('Found new cards:', newNoteIds);
for (const noteId of newNoteIds) {
previousNoteIds.add(noteId);
}
this.deps.setTrackedNoteIds(previousNoteIds);
if (this.deps.shouldAutoUpdateNewCards()) {
for (const noteId of newNoteIds) {
await this.deps.processNewCard(noteId);
}
} else {
this.deps.logInfo(
'New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.',
);
}
}
if (this.backoffMs > 200) {
this.deps.logInfo('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) {
this.deps.logWarn('AnkiConnect polling failed, backing off...');
this.deps.showStatusNotification('AnkiConnect: unable to connect');
}
this.deps.logWarn((error as Error).message);
} finally {
this.deps.setUpdateInProgress(false);
}
}
async poll(): Promise<void> {
if (this.pollingInterval) {
return;
}
return this.pollOnce();
}
}

View File

@@ -0,0 +1,104 @@
import { NotificationOptions } from '../types';
export interface UiFeedbackState {
progressDepth: number;
progressTimer: ReturnType<typeof setInterval> | null;
progressMessage: string;
progressFrame: number;
}
export interface UiFeedbackNotificationContext {
getNotificationType: () => string | undefined;
showOsd: (text: string) => void;
showSystemNotification: (title: string, options: NotificationOptions) => void;
}
export interface UiFeedbackOptions {
setUpdateInProgress: (value: boolean) => void;
showOsdNotification: (text: string) => void;
}
export function createUiFeedbackState(): UiFeedbackState {
return {
progressDepth: 0,
progressTimer: null,
progressMessage: '',
progressFrame: 0,
};
}
export function showStatusNotification(
message: string,
context: UiFeedbackNotificationContext,
): void {
const type = context.getNotificationType() || 'osd';
if (type === 'osd' || type === 'both') {
context.showOsd(message);
}
if (type === 'system' || type === 'both') {
context.showSystemNotification('SubMiner', { body: message });
}
}
export function beginUpdateProgress(
state: UiFeedbackState,
initialMessage: string,
showProgressTick: (text: string) => void,
): void {
state.progressDepth += 1;
if (state.progressDepth > 1) return;
state.progressMessage = initialMessage;
state.progressFrame = 0;
showProgressTick(`${state.progressMessage}`);
state.progressTimer = setInterval(() => {
showProgressTick(`${state.progressMessage} ${['|', '/', '-', '\\'][state.progressFrame % 4]}`);
state.progressFrame += 1;
}, 180);
}
export function endUpdateProgress(
state: UiFeedbackState,
clearProgressTimer: (timer: ReturnType<typeof setInterval>) => void,
): void {
state.progressDepth = Math.max(0, state.progressDepth - 1);
if (state.progressDepth > 0) return;
if (state.progressTimer) {
clearProgressTimer(state.progressTimer);
state.progressTimer = null;
}
state.progressMessage = '';
state.progressFrame = 0;
}
export function showProgressTick(
state: UiFeedbackState,
showOsdNotification: (text: string) => void,
): void {
if (!state.progressMessage) return;
const frames = ['|', '/', '-', '\\'];
const frame = frames[state.progressFrame % frames.length];
state.progressFrame += 1;
showOsdNotification(`${state.progressMessage} ${frame}`);
}
export async function withUpdateProgress<T>(
state: UiFeedbackState,
options: UiFeedbackOptions,
initialMessage: string,
action: () => Promise<T>,
): Promise<T> {
beginUpdateProgress(state, initialMessage, () =>
showProgressTick(state, options.showOsdNotification),
);
options.setUpdateInProgress(true);
try {
return await action();
} finally {
options.setUpdateInProgress(false);
endUpdateProgress(state, clearInterval);
}
}