mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
155
src/anki-integration/ai.ts
Normal file
155
src/anki-integration/ai.ts
Normal 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;
|
||||
}
|
||||
717
src/anki-integration/card-creation.ts
Normal file
717
src/anki-integration/card-creation.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
265
src/anki-integration/duplicate.test.ts
Normal file
265
src/anki-integration/duplicate.test.ts
Normal 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));
|
||||
});
|
||||
194
src/anki-integration/duplicate.ts
Normal file
194
src/anki-integration/duplicate.ts
Normal 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');
|
||||
}
|
||||
461
src/anki-integration/field-grouping-merge.ts
Normal file
461
src/anki-integration/field-grouping-merge.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
114
src/anki-integration/field-grouping-workflow.test.ts
Normal file
114
src/anki-integration/field-grouping-workflow.test.ts
Normal 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);
|
||||
});
|
||||
214
src/anki-integration/field-grouping-workflow.ts
Normal file
214
src/anki-integration/field-grouping-workflow.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
236
src/anki-integration/field-grouping.ts
Normal file
236
src/anki-integration/field-grouping.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
388
src/anki-integration/known-word-cache.ts
Normal file
388
src/anki-integration/known-word-cache.ts
Normal 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');
|
||||
}
|
||||
173
src/anki-integration/note-update-workflow.test.ts
Normal file
173
src/anki-integration/note-update-workflow.test.ts
Normal 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);
|
||||
});
|
||||
242
src/anki-integration/note-update-workflow.ts
Normal file
242
src/anki-integration/note-update-workflow.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
119
src/anki-integration/polling.ts
Normal file
119
src/anki-integration/polling.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
104
src/anki-integration/ui-feedback.ts
Normal file
104
src/anki-integration/ui-feedback.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user