feat(stats): add v1 immersion stats dashboard (#19)

This commit is contained in:
2026-03-20 02:43:28 -07:00
committed by GitHub
parent 42abdd1268
commit 6749ff843c
555 changed files with 46356 additions and 2553 deletions

View File

@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveAnimatedImageLeadInSeconds, extractSoundFilenames } from './animated-image-sync';
test('extractSoundFilenames returns ordered sound filenames from an Anki field value', () => {
assert.deepEqual(
extractSoundFilenames('before [sound:word.mp3] middle [sound:alt.ogg] after'),
['word.mp3', 'alt.ogg'],
);
});
test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for animated images', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: {
audio: 'ExpressionAudio',
},
media: {
imageType: 'avif',
syncAnimatedImageToWordAudio: true,
},
},
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: {
value: '[sound:word.mp3][sound:alt.ogg]',
},
},
},
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
for (const preferredName of preferredNames) {
if (!preferredName) continue;
const resolved = Object.keys(noteInfo.fields).find(
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
);
if (resolved) return resolved;
}
return null;
},
retrieveMediaFileBase64: async (filename) =>
filename === 'word.mp3' ? 'd29yZA==' : filename === 'alt.ogg' ? 'YWx0' : '',
probeAudioDurationSeconds: async (_buffer, filename) =>
filename === 'word.mp3' ? 0.41 : filename === 'alt.ogg' ? 0.84 : null,
logWarn: () => undefined,
});
assert.equal(leadInSeconds, 1.25);
});
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: {
audio: 'ExpressionAudio',
},
media: {
imageType: 'avif',
syncAnimatedImageToWordAudio: false,
},
},
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: {
value: '[sound:word.mp3]',
},
},
},
resolveConfiguredFieldName: () => 'ExpressionAudio',
retrieveMediaFileBase64: async () => {
throw new Error('should not be called');
},
probeAudioDurationSeconds: async () => {
throw new Error('should not be called');
},
logWarn: () => undefined,
});
assert.equal(leadInSeconds, 0);
});

View File

@@ -0,0 +1,133 @@
import { execFile as nodeExecFile } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import type { AnkiConnectConfig } from '../types';
type NoteInfoLike = {
noteId: number;
fields: Record<string, { value: string }>;
};
interface ResolveAnimatedImageLeadInSecondsArgs<TNoteInfo extends NoteInfoLike> {
config: Pick<AnkiConnectConfig, 'fields' | 'media'>;
noteInfo: TNoteInfo;
resolveConfiguredFieldName: (
noteInfo: TNoteInfo,
...preferredNames: (string | undefined)[]
) => string | null;
retrieveMediaFileBase64: (filename: string) => Promise<string>;
probeAudioDurationSeconds?: (buffer: Buffer, filename: string) => Promise<number | null>;
logWarn?: (message: string, ...args: unknown[]) => void;
}
interface ProbeAudioDurationDeps {
execFile?: typeof nodeExecFile;
mkdtempSync?: typeof fs.mkdtempSync;
writeFileSync?: typeof fs.writeFileSync;
rmSync?: typeof fs.rmSync;
}
export function extractSoundFilenames(value: string): string[] {
const matches = value.matchAll(/\[sound:([^\]]+)\]/gi);
return Array.from(matches, (match) => match[1]?.trim() || '').filter((value) => value.length > 0);
}
function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'media'>): boolean {
return (
config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false
);
}
export async function probeAudioDurationSeconds(
buffer: Buffer,
filename: string,
deps: ProbeAudioDurationDeps = {},
): Promise<number | null> {
const execFile = deps.execFile ?? nodeExecFile;
const mkdtempSync = deps.mkdtempSync ?? fs.mkdtempSync;
const writeFileSync = deps.writeFileSync ?? fs.writeFileSync;
const rmSync = deps.rmSync ?? fs.rmSync;
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'subminer-audio-probe-'));
const ext = path.extname(filename) || '.bin';
const tempPath = path.join(tempDir, `probe${ext}`);
writeFileSync(tempPath, buffer);
return new Promise((resolve) => {
execFile(
'ffprobe',
[
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'default=noprint_wrappers=1:nokey=1',
tempPath,
],
(error, stdout) => {
try {
if (error) {
resolve(null);
return;
}
const durationSeconds = Number.parseFloat((stdout || '').trim());
resolve(Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : null);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
},
);
});
}
export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteInfoLike>({
config,
noteInfo,
resolveConfiguredFieldName,
retrieveMediaFileBase64,
probeAudioDurationSeconds: probeDuration = probeAudioDurationSeconds,
logWarn,
}: ResolveAnimatedImageLeadInSecondsArgs<TNoteInfo>): Promise<number> {
if (!shouldSyncAnimatedImageToWordAudio(config)) {
return 0;
}
const wordAudioFieldName = resolveConfiguredFieldName(
noteInfo,
config.fields?.audio,
DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
if (!wordAudioFieldName) {
return 0;
}
const wordAudioValue = noteInfo.fields[wordAudioFieldName]?.value || '';
const filenames = extractSoundFilenames(wordAudioValue);
if (filenames.length === 0) {
return 0;
}
let totalLeadInSeconds = 0;
for (const filename of filenames) {
const encoded = await retrieveMediaFileBase64(filename);
if (!encoded) {
logWarn?.('Animated image sync skipped: failed to retrieve word audio', filename);
return 0;
}
const durationSeconds = await probeDuration(Buffer.from(encoded, 'base64'), filename);
if (!(typeof durationSeconds === 'number' && Number.isFinite(durationSeconds))) {
logWarn?.('Animated image sync skipped: failed to probe word audio duration', filename);
return 0;
}
totalLeadInSeconds += durationSeconds;
}
return totalLeadInSeconds;
}

View File

@@ -1,4 +1,6 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import test from 'node:test';
import { AnkiConnectProxyServer } from './anki-connect-proxy';
@@ -17,11 +19,15 @@ async function waitForCondition(
test('proxy enqueues addNote result for enrichment', async () => {
const processed: number[] = [];
const recordedCards: number[] = [];
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async (noteId) => {
processed.push(noteId);
},
recordCardsAdded: (count) => {
recordedCards.push(count);
},
logInfo: () => undefined,
logWarn: () => undefined,
logError: () => undefined,
@@ -38,6 +44,7 @@ test('proxy enqueues addNote result for enrichment', async () => {
await waitForCondition(() => processed.length === 1);
assert.deepEqual(processed, [42]);
assert.deepEqual(recordedCards, [1]);
});
test('proxy enqueues addNote bare numeric response for enrichment', async () => {
@@ -64,12 +71,16 @@ test('proxy enqueues addNote bare numeric response for enrichment', async () =>
test('proxy de-duplicates addNotes IDs within the same response', async () => {
const processed: number[] = [];
const recordedCards: number[] = [];
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async (noteId) => {
processed.push(noteId);
await new Promise((resolve) => setTimeout(resolve, 5));
},
recordCardsAdded: (count) => {
recordedCards.push(count);
},
logInfo: () => undefined,
logWarn: () => undefined,
logError: () => undefined,
@@ -86,6 +97,7 @@ test('proxy de-duplicates addNotes IDs within the same response', async () => {
await waitForCondition(() => processed.length === 2);
assert.deepEqual(processed, [101, 102]);
assert.deepEqual(recordedCards, [2]);
});
test('proxy enqueues note IDs from multi action addNote/addNotes results', async () => {
@@ -277,12 +289,16 @@ test('proxy does not fallback-enqueue latest note for multi requests without add
test('proxy fallback-enqueues latest note for addNote responses without note IDs and escapes deck quotes', async () => {
const processed: number[] = [];
const recordedCards: number[] = [];
const findNotesQueries: string[] = [];
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async (noteId) => {
processed.push(noteId);
},
recordCardsAdded: (count) => {
recordedCards.push(count);
},
getDeck: () => 'My "Japanese" Deck',
findNotes: async (query) => {
findNotesQueries.push(query);
@@ -305,6 +321,84 @@ test('proxy fallback-enqueues latest note for addNote responses without note IDs
await waitForCondition(() => processed.length === 1);
assert.deepEqual(findNotesQueries, ['"deck:My \\"Japanese\\" Deck" added:1']);
assert.deepEqual(processed, [501]);
assert.deepEqual(recordedCards, [1]);
});
test('proxy returns addNote response without waiting for background enrichment', async () => {
const processed: number[] = [];
let releaseProcessing: (() => void) | undefined;
const processingGate = new Promise<void>((resolve) => {
releaseProcessing = resolve;
});
const upstream = http.createServer((req, res) => {
assert.equal(req.method, 'POST');
res.statusCode = 200;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ result: 42, error: null }));
});
upstream.listen(0, '127.0.0.1');
await once(upstream, 'listening');
const upstreamAddress = upstream.address();
assert.ok(upstreamAddress && typeof upstreamAddress === 'object');
const upstreamPort = upstreamAddress.port;
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async (noteId) => {
processed.push(noteId);
await processingGate;
},
logInfo: () => undefined,
logWarn: () => undefined,
logError: () => undefined,
});
try {
proxy.start({
host: '127.0.0.1',
port: 0,
upstreamUrl: `http://127.0.0.1:${upstreamPort}`,
});
const proxyServer = (
proxy as unknown as {
server: http.Server | null;
}
).server;
assert.ok(proxyServer);
if (!proxyServer.listening) {
await once(proxyServer, 'listening');
}
const proxyAddress = proxyServer.address();
assert.ok(proxyAddress && typeof proxyAddress === 'object');
const proxyPort = proxyAddress.port;
const response = await Promise.race([
fetch(`http://127.0.0.1:${proxyPort}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ action: 'addNote', version: 6, params: {} }),
}),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Timed out waiting for proxy response')), 500);
}),
]);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { result: 42, error: null });
await waitForCondition(() => processed.length === 1);
assert.deepEqual(processed, [42]);
} finally {
if (releaseProcessing) {
releaseProcessing();
}
proxy.stop();
upstream.close();
await once(upstream, 'close');
}
});
test('proxy detects self-referential loop configuration', () => {

View File

@@ -15,6 +15,7 @@ interface AnkiConnectEnvelope {
export interface AnkiConnectProxyServerDeps {
shouldAutoUpdateNewCards: () => boolean;
processNewCard: (noteId: number) => Promise<void>;
recordCardsAdded?: (count: number, noteIds: number[]) => void;
getDeck?: () => string | undefined;
findNotes?: (
query: string,
@@ -332,12 +333,14 @@ export class AnkiConnectProxyServer {
private enqueueNotes(noteIds: number[]): void {
let enqueuedCount = 0;
const acceptedIds: number[] = [];
for (const noteId of noteIds) {
if (this.pendingNoteIdSet.has(noteId) || this.inFlightNoteIds.has(noteId)) {
continue;
}
this.pendingNoteIds.push(noteId);
this.pendingNoteIdSet.add(noteId);
acceptedIds.push(noteId);
enqueuedCount += 1;
}
@@ -345,6 +348,7 @@ export class AnkiConnectProxyServer {
return;
}
this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`);
this.processQueue();
}

View File

@@ -0,0 +1,285 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
import type { AnkiConnectConfig } from '../types';
test('CardCreationService counts locally created sentence cards', async () => {
const minedCards: Array<{ count: number; noteIds?: number[] }> = [];
const service = new CardCreationService({
getConfig: () =>
({
deck: 'Mining',
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
},
media: {
generateAudio: false,
generateImage: false,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
getAiConfig: () => ({}),
getTimingTracker: () => ({}) as never,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentSubText: '字幕',
currentSubStart: 1,
currentSubEnd: 2,
currentTimePos: 1.5,
currentAudioStreamIndex: 0,
}) as never,
client: {
addNote: async () => 42,
addTags: async () => undefined,
notesInfo: async () => [],
updateNoteFields: async () => undefined,
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
},
mediaGenerator: {
generateAudio: async () => null,
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
showOsdNotification: () => undefined,
showUpdateResult: () => undefined,
showStatusNotification: () => undefined,
showNotification: async () => undefined,
beginUpdateProgress: () => undefined,
endUpdateProgress: () => undefined,
withUpdateProgress: async (_message, action) => action(),
resolveConfiguredFieldName: () => null,
resolveNoteFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0,
extractFields: () => ({}),
processSentence: (sentence) => sentence,
setCardTypeFields: () => undefined,
mergeFieldValue: (_existing, newValue) => newValue,
formatMiscInfoPattern: () => '',
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
recordCardsMinedCallback: (count, noteIds) => {
minedCards.push({ count, noteIds });
},
});
const created = await service.createSentenceCard('テスト', 0, 1);
assert.equal(created, true);
assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]);
});
test('CardCreationService keeps updating after trackLastAddedNoteId throws', async () => {
const calls = {
notesInfo: 0,
updateNoteFields: 0,
};
const service = new CardCreationService({
getConfig: () =>
({
deck: 'Mining',
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
},
media: {
generateAudio: false,
generateImage: false,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
getAiConfig: () => ({}),
getTimingTracker: () => ({}) as never,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentSubText: '字幕',
currentSubStart: 1,
currentSubEnd: 2,
currentTimePos: 1.5,
currentAudioStreamIndex: 0,
}) as never,
client: {
addNote: async () => 42,
addTags: async () => undefined,
notesInfo: async () => {
calls.notesInfo += 1;
return [
{
noteId: 42,
fields: {
Sentence: { value: 'existing' },
},
},
];
},
updateNoteFields: async () => {
calls.updateNoteFields += 1;
},
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
},
mediaGenerator: {
generateAudio: async () => null,
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
showOsdNotification: () => undefined,
showUpdateResult: () => undefined,
showStatusNotification: () => undefined,
showNotification: async () => undefined,
beginUpdateProgress: () => undefined,
endUpdateProgress: () => undefined,
withUpdateProgress: async (_message, action) => action(),
resolveConfiguredFieldName: () => null,
resolveNoteFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0,
extractFields: () => ({}),
processSentence: (sentence) => sentence,
setCardTypeFields: (updatedFields) => {
updatedFields.CardType = 'sentence';
},
mergeFieldValue: (_existing, newValue) => newValue,
formatMiscInfoPattern: () => '',
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => {
throw new Error('track failed');
},
});
const created = await service.createSentenceCard('テスト', 0, 1);
assert.equal(created, true);
assert.equal(calls.notesInfo, 1);
assert.equal(calls.updateNoteFields, 1);
});
test('CardCreationService keeps updating after recordCardsMinedCallback throws', async () => {
const calls = {
notesInfo: 0,
updateNoteFields: 0,
};
const service = new CardCreationService({
getConfig: () =>
({
deck: 'Mining',
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
},
media: {
generateAudio: false,
generateImage: false,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
getAiConfig: () => ({}),
getTimingTracker: () => ({}) as never,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentSubText: '字幕',
currentSubStart: 1,
currentSubEnd: 2,
currentTimePos: 1.5,
currentAudioStreamIndex: 0,
}) as never,
client: {
addNote: async () => 42,
addTags: async () => undefined,
notesInfo: async () => {
calls.notesInfo += 1;
return [
{
noteId: 42,
fields: {
Sentence: { value: 'existing' },
},
},
];
},
updateNoteFields: async () => {
calls.updateNoteFields += 1;
},
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
},
mediaGenerator: {
generateAudio: async () => null,
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
showOsdNotification: () => undefined,
showUpdateResult: () => undefined,
showStatusNotification: () => undefined,
showNotification: async () => undefined,
beginUpdateProgress: () => undefined,
endUpdateProgress: () => undefined,
withUpdateProgress: async (_message, action) => action(),
resolveConfiguredFieldName: () => null,
resolveNoteFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0,
extractFields: () => ({}),
processSentence: (sentence) => sentence,
setCardTypeFields: (updatedFields) => {
updatedFields.CardType = 'sentence';
},
mergeFieldValue: (_existing, newValue) => newValue,
formatMiscInfoPattern: () => '',
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
recordCardsMinedCallback: () => {
throw new Error('record failed');
},
});
const created = await service.createSentenceCard('テスト', 0, 1);
assert.equal(created, true);
assert.equal(calls.notesInfo, 1);
assert.equal(calls.updateNoteFields, 1);
});

View File

@@ -1,4 +1,8 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import {
getConfiguredWordFieldName,
getPreferredWordValueFromExtractedFields,
} from '../anki-field-config';
import { AiConfig, AnkiConnectConfig } from '../types';
import { createLogger } from '../logger';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
@@ -26,6 +30,7 @@ interface CardCreationClient {
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
storeMediaFile(filename: string, data: Buffer): Promise<void>;
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
retrieveMediaFile(filename: string): Promise<string>;
}
interface CardCreationMediaGenerator {
@@ -56,6 +61,7 @@ interface CardCreationMediaGenerator {
maxWidth?: number;
maxHeight?: number;
crf?: number;
leadingStillDuration?: number;
},
): Promise<Buffer | null>;
}
@@ -69,6 +75,7 @@ interface CardCreationDeps {
client: CardCreationClient;
mediaGenerator: CardCreationMediaGenerator;
showOsdNotification: (text: string) => void;
showUpdateResult: (message: string, success: boolean) => void;
showStatusNotification: (message: string) => void;
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>;
beginUpdateProgress: (initialMessage: string) => void;
@@ -79,6 +86,7 @@ interface CardCreationDeps {
...preferredNames: (string | undefined)[]
) => string | null;
resolveNoteFieldName: (noteInfo: CardCreationNoteInfo, preferredName?: string) => string | null;
getAnimatedImageLeadInSeconds: (noteInfo: CardCreationNoteInfo) => Promise<number>;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
setCardTypeFields: (
@@ -102,6 +110,7 @@ interface CardCreationDeps {
isUpdateInProgress: () => boolean;
setUpdateInProgress: (value: boolean) => void;
trackLastAddedNoteId?: (noteId: number) => void;
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
}
export class CardCreationService {
@@ -201,7 +210,10 @@ export class CardCreationService {
const noteInfo = notesInfoResult[0]!;
const fields = this.deps.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || '';
const expressionText = getPreferredWordValueFromExtractedFields(
fields,
this.deps.getConfig(),
);
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
@@ -251,11 +263,13 @@ export class CardCreationService {
if (this.deps.getConfig().media?.generateImage) {
try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer(
mpvClient.currentVideoPath,
rangeStart,
rangeEnd,
animatedLeadInSeconds,
);
if (imageBuffer) {
@@ -368,7 +382,10 @@ export class CardCreationService {
const noteInfo = notesInfoResult[0]!;
const fields = this.deps.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || '';
const expressionText = getPreferredWordValueFromExtractedFields(
fields,
this.deps.getConfig(),
);
const updatedFields: Record<string, string> = {};
const errors: string[] = [];
@@ -404,11 +421,13 @@ export class CardCreationService {
if (this.deps.getConfig().media?.generateImage) {
try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer(
mpvClient.currentVideoPath,
startTime,
endTime,
animatedLeadInSeconds,
);
const imageField = this.deps.getConfig().fields?.image;
@@ -519,7 +538,7 @@ export class CardCreationService {
if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
fields.IsSentenceCard = 'x';
fields.Expression = sentence;
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
}
const deck = this.deps.getConfig().deck || 'Default';
@@ -532,13 +551,24 @@ export class CardCreationService {
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}`);
this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false);
return false;
}
try {
this.deps.trackLastAddedNoteId?.(noteId);
} catch (error) {
log.warn('Failed to track last added note:', (error as Error).message);
}
try {
this.deps.recordCardsMinedCallback?.(1, [noteId]);
} catch (error) {
log.warn('Failed to record mined card:', (error as Error).message);
}
try {
const noteInfoResult = await this.deps.client.notesInfo([noteId]);
const noteInfos = noteInfoResult as CardCreationNoteInfo[];
@@ -632,7 +662,7 @@ export class CardCreationService {
});
} catch (error) {
log.error('Error creating sentence card:', (error as Error).message);
this.deps.showOsdNotification(`Sentence card failed: ${(error as Error).message}`);
this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false);
return false;
}
}
@@ -669,6 +699,7 @@ export class CardCreationService {
videoPath: string,
startTime: number,
endTime: number,
animatedLeadInSeconds = 0,
): Promise<Buffer | null> {
const mpvClient = this.deps.getMpvClient();
if (!mpvClient) {
@@ -697,6 +728,7 @@ export class CardCreationService {
maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
maxHeight: this.deps.getConfig().media?.animatedMaxHeight,
crf: this.deps.getConfig().media?.animatedCrf,
leadingStillDuration: animatedLeadInSeconds,
},
);
}

View File

@@ -11,6 +11,7 @@ export interface DuplicateDetectionDeps {
findNotes: (query: string, options?: { maxRetries?: number }) => Promise<unknown>;
notesInfo: (noteIds: number[]) => Promise<unknown>;
getDeck: () => string | null | undefined;
getWordFieldCandidates?: () => string[];
resolveFieldName: (noteInfo: NoteInfo, preferredName: string) => string | null;
logInfo?: (message: string) => void;
logDebug?: (message: string) => void;
@@ -23,7 +24,12 @@ export async function findDuplicateNote(
noteInfo: NoteInfo,
deps: DuplicateDetectionDeps,
): Promise<number | null> {
const sourceCandidates = getDuplicateSourceCandidates(noteInfo, expression);
const configuredWordFieldCandidates = deps.getWordFieldCandidates?.() ?? ['Expression', 'Word'];
const sourceCandidates = getDuplicateSourceCandidates(
noteInfo,
expression,
configuredWordFieldCandidates,
);
if (sourceCandidates.length === 0) return null;
deps.logInfo?.(
`[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates
@@ -81,6 +87,7 @@ export async function findDuplicateNote(
noteIds,
excludeNoteId,
sourceCandidates.map((candidate) => candidate.value),
configuredWordFieldCandidates,
deps,
);
} catch (error) {
@@ -93,6 +100,7 @@ function findFirstExactDuplicateNoteId(
candidateNoteIds: Iterable<number>,
excludeNoteId: number,
sourceValues: string[],
candidateFieldNames: string[],
deps: DuplicateDetectionDeps,
): Promise<number | null> {
const candidates = Array.from(candidateNoteIds).filter((id) => id !== excludeNoteId);
@@ -116,7 +124,6 @@ function findFirstExactDuplicateNoteId(
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;
@@ -150,13 +157,15 @@ function getDuplicateCandidateFieldNames(fieldName: string): string[] {
function getDuplicateSourceCandidates(
noteInfo: NoteInfo,
fallbackExpression: string,
configuredFieldNames: string[],
): Array<{ fieldName: string; value: string }> {
const candidates: Array<{ fieldName: string; value: string }> = [];
const dedupeKey = new Set<string>();
const configuredFieldNameSet = new Set(configuredFieldNames.map((name) => name.toLowerCase()));
for (const fieldName of Object.keys(noteInfo.fields)) {
const lower = fieldName.toLowerCase();
if (lower !== 'word' && lower !== 'expression') continue;
if (!configuredFieldNameSet.has(lower)) continue;
const value = noteInfo.fields[fieldName]?.value?.trim() ?? '';
if (!value) continue;
const key = `${lower}:${normalizeDuplicateValue(value)}`;
@@ -167,9 +176,10 @@ function getDuplicateSourceCandidates(
const trimmedFallback = fallbackExpression.trim();
if (trimmedFallback.length > 0) {
const fallbackKey = `expression:${normalizeDuplicateValue(trimmedFallback)}`;
const fallbackFieldName = configuredFieldNames[0]?.toLowerCase() || 'expression';
const fallbackKey = `${fallbackFieldName}:${normalizeDuplicateValue(trimmedFallback)}`;
if (!dedupeKey.has(fallbackKey)) {
candidates.push({ fieldName: 'expression', value: trimmedFallback });
candidates.push({ fieldName: configuredFieldNames[0] || 'Expression', value: trimmedFallback });
}
}

View File

@@ -1,4 +1,5 @@
import { AnkiConnectConfig } from '../types';
import { getConfiguredWordFieldName } from '../anki-field-config';
interface FieldGroupingMergeMedia {
audioField?: string;
@@ -27,7 +28,7 @@ interface FieldGroupingMergeDeps {
) => string | null;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
generateMediaForMerge: () => Promise<FieldGroupingMergeMedia>;
generateMediaForMerge: (noteInfo: FieldGroupingMergeNoteInfo) => Promise<FieldGroupingMergeMedia>;
warnFieldParseOnce: (fieldName: string, reason: string, detail?: string) => void;
}
@@ -77,6 +78,7 @@ export class FieldGroupingMergeCollaborator {
includeGeneratedMedia: boolean,
): Promise<Record<string, string>> {
const config = this.deps.getConfig();
const configuredWordField = getConfiguredWordFieldName(config);
const groupableFields = this.getGroupableFieldNames();
const keepFieldNames = Object.keys(keepNoteInfo.fields);
const sourceFields: Record<string, string> = {};
@@ -98,11 +100,17 @@ export class FieldGroupingMergeCollaborator {
if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) {
sourceFields['Sentence'] = sourceFields['SentenceFurigana'];
}
if (!sourceFields['Expression'] && sourceFields['Word']) {
sourceFields['Expression'] = sourceFields['Word'];
if (!sourceFields[configuredWordField] && sourceFields['Expression']) {
sourceFields[configuredWordField] = sourceFields['Expression'];
}
if (!sourceFields['Word'] && sourceFields['Expression']) {
sourceFields['Word'] = sourceFields['Expression'];
if (!sourceFields[configuredWordField] && sourceFields['Word']) {
sourceFields[configuredWordField] = sourceFields['Word'];
}
if (!sourceFields['Expression'] && sourceFields[configuredWordField]) {
sourceFields['Expression'] = sourceFields[configuredWordField];
}
if (!sourceFields['Word'] && sourceFields[configuredWordField]) {
sourceFields['Word'] = sourceFields[configuredWordField];
}
if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) {
sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio'];
@@ -124,7 +132,7 @@ export class FieldGroupingMergeCollaborator {
}
if (includeGeneratedMedia) {
const media = await this.deps.generateMediaForMerge();
const media = await this.deps.generateMediaForMerge(keepNoteInfo);
if (media.audioField && media.audioValue && !sourceFields[media.audioField]) {
sourceFields[media.audioField] = media.audioValue;
}
@@ -148,6 +156,7 @@ export class FieldGroupingMergeCollaborator {
const keepFieldNormalized = keepFieldName.toLowerCase();
if (
keepFieldNormalized === 'expression' ||
keepFieldNormalized === configuredWordField.toLowerCase() ||
keepFieldNormalized === 'expressionfurigana' ||
keepFieldNormalized === 'expressionreading' ||
keepFieldNormalized === 'expressionaudio'

View File

@@ -24,6 +24,7 @@ function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const deleted: number[][] = [];
const statuses: string[] = [];
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
const mergeCalls: Array<{
keepNoteId: number;
deleteNoteId: number;
@@ -99,6 +100,9 @@ function createWorkflowHarness() {
hasFieldValue: (_noteInfo: NoteInfo, _field?: string) => false,
addConfiguredTagsToNote: async () => undefined,
removeTrackedNoteId: () => undefined,
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => {
rememberedMerges.push({ deletedNoteId, keptNoteId });
},
showStatusNotification: (message: string) => {
statuses.push(message);
},
@@ -113,6 +117,7 @@ function createWorkflowHarness() {
workflow: new FieldGroupingWorkflow(deps),
updates,
deleted,
rememberedMerges,
statuses,
mergeCalls,
setManualChoice: (choice: typeof manualChoice) => {
@@ -136,6 +141,7 @@ test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate b
assert.equal(harness.updates.length, 1);
assert.equal(harness.updates[0]?.noteId, 1);
assert.deepEqual(harness.deleted, [[2]]);
assert.deepEqual(harness.rememberedMerges, [{ deletedNoteId: 2, keptNoteId: 1 }]);
assert.equal(harness.statuses.length, 1);
});

View File

@@ -1,4 +1,5 @@
import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
export interface FieldGroupingWorkflowNoteInfo {
noteId: number;
@@ -13,6 +14,7 @@ export interface FieldGroupingWorkflowDeps {
};
getConfig: () => {
fields?: {
word?: string;
audio?: string;
image?: string;
};
@@ -48,6 +50,7 @@ export interface FieldGroupingWorkflowDeps {
hasFieldValue: (noteInfo: FieldGroupingWorkflowNoteInfo, preferredFieldName?: string) => boolean;
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
removeTrackedNoteId: (noteId: number) => void;
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
showStatusNotification: (message: string) => void;
showNotification: (noteId: number, label: string | number) => Promise<void>;
showOsdNotification: (message: string) => void;
@@ -156,6 +159,7 @@ export class FieldGroupingWorkflow {
if (deleteDuplicate) {
await this.deps.client.deleteNotes([deleteNoteId]);
this.deps.removeTrackedNoteId(deleteNoteId);
this.deps.rememberMergedNoteIds(deleteNoteId, keepNoteId);
}
this.deps.logInfo('Merged duplicate card:', expression, 'into note:', keepNoteId);
@@ -176,7 +180,8 @@ export class FieldGroupingWorkflow {
const fields = this.deps.extractFields(noteInfo.fields);
return {
noteId: noteInfo.noteId,
expression: fields.expression || fields.word || fallbackExpression,
expression:
getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig()) || fallbackExpression,
sentencePreview: this.deps.truncateSentence(
fields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] ||
(isOriginal ? '' : this.deps.getCurrentSubtitleText() || ''),
@@ -191,7 +196,7 @@ export class FieldGroupingWorkflow {
private getExpression(noteInfo: FieldGroupingWorkflowNoteInfo): string {
const fields = this.deps.extractFields(noteInfo.fields);
return fields.expression || fields.word || '';
return getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig());
}
private async resolveFieldGroupingCallback(): Promise<

View File

@@ -1,5 +1,6 @@
import { KikuMergePreviewResponse } from '../types';
import { createLogger } from '../logger';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
const log = createLogger('anki').child('integration.field-grouping');
@@ -9,6 +10,11 @@ interface FieldGroupingNoteInfo {
}
interface FieldGroupingDeps {
getConfig: () => {
fields?: {
word?: string;
};
};
getEffectiveSentenceCardConfig: () => {
model?: string;
sentenceField: string;
@@ -102,7 +108,10 @@ export class FieldGroupingService {
}
const noteInfoBeforeUpdate = notesInfo[0]!;
const fields = this.deps.extractFields(noteInfoBeforeUpdate.fields);
const expressionText = fields.expression || fields.word || '';
const expressionText = getPreferredWordValueFromExtractedFields(
fields,
this.deps.getConfig(),
);
if (!expressionText) {
this.deps.showOsdNotification('No expression/word field found');
return;

View File

@@ -0,0 +1,535 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { AnkiConnectConfig } from '../types';
import { KnownWordCacheManager } from './known-word-cache';
async function waitForCondition(
condition: () => boolean,
timeoutMs = 500,
intervalMs = 10,
): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (condition()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error('Timed out waiting for condition');
}
function createKnownWordCacheHarness(config: AnkiConnectConfig): {
manager: KnownWordCacheManager;
calls: {
findNotes: number;
notesInfo: number;
};
statePath: string;
clientState: {
findNotesResult: number[];
notesInfoResult: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
findNotesByQuery: Map<string, number[]>;
};
cleanup: () => void;
} {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-cache-'));
const statePath = path.join(stateDir, 'known-words-cache.json');
const calls = {
findNotes: 0,
notesInfo: 0,
};
const clientState = {
findNotesResult: [] as number[],
notesInfoResult: [] as Array<{ noteId: number; fields: Record<string, { value: string }> }>,
findNotesByQuery: new Map<string, number[]>(),
};
const manager = new KnownWordCacheManager({
client: {
findNotes: async (query) => {
calls.findNotes += 1;
if (clientState.findNotesByQuery.has(query)) {
return clientState.findNotesByQuery.get(query) ?? [];
}
return clientState.findNotesResult;
},
notesInfo: async (noteIds) => {
calls.notesInfo += 1;
return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId));
},
},
getConfig: () => config,
knownWordCacheStatePath: statePath,
showStatusNotification: () => undefined,
});
return {
manager,
calls,
statePath,
clientState,
cleanup: () => {
fs.rmSync(stateDir, { recursive: true, force: true });
},
};
}
test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without immediate refresh', async () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
refreshMinutes: 60,
},
};
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
try {
fs.writeFileSync(
statePath,
JSON.stringify({
version: 2,
refreshedAtMs: Date.now(),
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
words: ['猫'],
notes: {
'1': ['猫'],
},
}),
'utf-8',
);
manager.startLifecycle();
await new Promise((resolve) => setTimeout(resolve, 25));
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(calls.findNotes, 0);
assert.equal(calls.notesInfo, 0);
} finally {
manager.stopLifecycle();
cleanup();
}
});
test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted cache', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
refreshMinutes: 1,
},
};
const { manager, calls, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
fs.writeFileSync(
statePath,
JSON.stringify({
version: 2,
refreshedAtMs: Date.now() - 61_000,
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
words: ['猫'],
notes: {
'1': ['猫'],
},
}),
'utf-8',
);
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '犬' },
},
},
];
manager.startLifecycle();
await waitForCondition(() => calls.findNotes === 1 && calls.notesInfo === 1);
assert.equal(manager.isKnownWord('猫'), false);
assert.equal(manager.isKnownWord('犬'), true);
} finally {
manager.stopLifecycle();
cleanup();
}
});
test('KnownWordCacheManager invalidates persisted cache when fields.word changes', () => {
const config: AnkiConnectConfig = {
deck: 'Mining',
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, cleanup } = createKnownWordCacheHarness(config);
try {
manager.appendFromNoteInfo({
noteId: 1,
fields: {
Word: { value: '猫' },
},
});
assert.equal(manager.isKnownWord('猫'), true);
config.fields = {
...config.fields,
word: 'Expression',
};
(
manager as unknown as {
loadKnownWordCacheState: () => void;
}
).loadKnownWordCacheState();
assert.equal(manager.isKnownWord('猫'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
fs.writeFileSync(
statePath,
JSON.stringify({
version: 2,
refreshedAtMs: 1,
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":"Word"}',
words: ['猫', '犬'],
notes: {
'1': ['猫'],
'2': ['犬'],
},
}),
'utf-8',
);
(
manager as unknown as {
loadKnownWordCacheState: () => void;
}
).loadKnownWordCacheState();
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '鳥' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), false);
assert.equal(manager.isKnownWord('犬'), false);
assert.equal(manager.isKnownWord('鳥'), true);
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
version: number;
words: string[];
notes?: Record<string, string[]>;
};
assert.equal(persisted.version, 2);
assert.deepEqual(persisted.words.sort(), ['鳥']);
assert.deepEqual(persisted.notes, {
'1': ['鳥'],
});
} finally {
cleanup();
}
});
test('KnownWordCacheManager skips malformed note info without fields', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1, 2];
clientState.notesInfoResult = [
{
noteId: 1,
fields: undefined as unknown as Record<string, { value: string }>,
},
{
noteId: 2,
fields: {
Word: { value: '猫' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.isKnownWord('犬'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager preserves cache state key captured before refresh work', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
refreshMinutes: 1,
},
};
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-cache-key-'));
const statePath = path.join(stateDir, 'known-words-cache.json');
let notesInfoStarted = false;
let releaseNotesInfo!: () => void;
const notesInfoGate = new Promise<void>((resolve) => {
releaseNotesInfo = resolve;
});
const manager = new KnownWordCacheManager({
client: {
findNotes: async () => [1],
notesInfo: async () => {
notesInfoStarted = true;
await notesInfoGate;
return [
{
noteId: 1,
fields: {
Word: { value: '猫' },
},
},
];
},
},
getConfig: () => config,
knownWordCacheStatePath: statePath,
showStatusNotification: () => undefined,
});
try {
const refreshPromise = manager.refresh(true);
await waitForCondition(() => notesInfoStarted);
config.fields = {
...config.fields,
word: 'Expression',
};
releaseNotesInfo();
await refreshPromise;
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
scope: string;
words: string[];
};
assert.equal(
persisted.scope,
'{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
);
assert.deepEqual(persisted.words, ['猫']);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
test('KnownWordCacheManager does not borrow fields from other decks during refresh', async () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
decks: {
Mining: [],
Reading: ['AltWord'],
},
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.findNotesByQuery.set('deck:"Reading"', []);
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
AltWord: { value: '猫' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager invalidates persisted cache when per-deck fields change', () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
decks: {
Mining: ['Word'],
},
},
};
const { manager, cleanup } = createKnownWordCacheHarness(config);
try {
manager.appendFromNoteInfo({
noteId: 1,
fields: {
Word: { value: '猫' },
},
});
assert.equal(manager.isKnownWord('猫'), true);
config.knownWords = {
...config.knownWords,
decks: {
Mining: ['Expression'],
},
};
(
manager as unknown as {
loadKnownWordCacheState: () => void;
}
).loadKnownWordCacheState();
assert.equal(manager.isKnownWord('猫'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager preserves deck-specific field mappings during refresh', async () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
decks: {
Mining: ['Expression'],
Reading: ['Word'],
},
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.findNotesByQuery.set('deck:"Reading"', [2]);
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Expression: { value: '猫' },
Word: { value: 'should-not-count' },
},
},
{
noteId: 2,
fields: {
Word: { value: '犬' },
Expression: { value: 'also-ignored' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.isKnownWord('犬'), true);
assert.equal(manager.isKnownWord('should-not-count'), false);
assert.equal(manager.isKnownWord('also-ignored'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager uses the current deck fields for immediate append', () => {
const config: AnkiConnectConfig = {
deck: 'Mining',
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
decks: {
Mining: ['Expression'],
Reading: ['Word'],
},
},
};
const { manager, cleanup } = createKnownWordCacheHarness(config);
try {
manager.appendFromNoteInfo({
noteId: 1,
fields: {
Expression: { value: '猫' },
Word: { value: 'should-not-count' },
},
});
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.isKnownWord('should-not-count'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately is disabled', () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
addMinedWordsImmediately: false,
},
};
const { manager, statePath, cleanup } = createKnownWordCacheHarness(config);
try {
manager.appendFromNoteInfo({
noteId: 1,
fields: {
Expression: { value: '猫' },
},
});
assert.equal(manager.isKnownWord('猫'), false);
assert.equal(fs.existsSync(statePath), false);
} finally {
cleanup();
}
});

View File

@@ -2,23 +2,85 @@ import fs from 'fs';
import path from 'path';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getConfiguredWordFieldName } from '../anki-field-config';
import { AnkiConnectConfig } from '../types';
import { createLogger } from '../logger';
const log = createLogger('anki').child('integration.known-word-cache');
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function getKnownWordCacheRefreshIntervalMinutes(config: AnkiConnectConfig): number {
const refreshMinutes = config.knownWords?.refreshMinutes;
return typeof refreshMinutes === 'number' && Number.isFinite(refreshMinutes) && refreshMinutes > 0
? refreshMinutes
: DEFAULT_ANKI_CONNECT_CONFIG.knownWords.refreshMinutes;
}
export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): string {
const configuredDecks = config.knownWords?.decks;
if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
const normalizedDecks = Object.entries(configuredDecks)
.map(([deckName, fields]) => {
const name = trimToNonEmptyString(deckName);
if (!name) return null;
const normalizedFields = Array.isArray(fields)
? [
...new Set(
fields
.map(String)
.map(trimToNonEmptyString)
.filter((field): field is string => Boolean(field)),
),
].sort()
: [];
return [name, normalizedFields];
})
.filter((entry): entry is [string, string[]] => entry !== null)
.sort(([a], [b]) => a.localeCompare(b));
if (normalizedDecks.length > 0) {
return `decks:${JSON.stringify(normalizedDecks)}`;
}
}
const configuredDeck = trimToNonEmptyString(config.deck);
return configuredDeck ? `deck:${configuredDeck}` : 'is:note';
}
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
return JSON.stringify({
refreshMinutes: getKnownWordCacheRefreshIntervalMinutes(config),
scope: getKnownWordCacheScopeForConfig(config),
fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '',
});
}
export interface KnownWordCacheNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
interface KnownWordCacheState {
interface KnownWordCacheStateV1 {
readonly version: 1;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
}
interface KnownWordCacheStateV2 {
readonly version: 2;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
readonly notes: Record<string, string[]>;
}
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2;
interface KnownWordCacheClient {
findNotes: (
query: string,
@@ -36,11 +98,19 @@ interface KnownWordCacheDeps {
showStatusNotification: (message: string) => void;
}
type KnownWordQueryScope = {
query: string;
fields: string[];
};
export class KnownWordCacheManager {
private knownWordsLastRefreshedAtMs = 0;
private knownWordsScope = '';
private knownWordsStateKey = '';
private knownWords: Set<string> = new Set();
private wordReferenceCounts = new Map<string, number>();
private noteWordsById = new Map<number, string[]>();
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
private isRefreshingKnownWords = false;
private readonly statePath: string;
@@ -72,7 +142,7 @@ export class KnownWordCacheManager {
}
const refreshMinutes = this.getKnownWordRefreshIntervalMs() / 60_000;
const scope = this.getKnownWordCacheScope();
const scope = getKnownWordCacheScopeForConfig(this.deps.getConfig());
log.info(
'Known-word cache lifecycle enabled',
`scope=${scope}`,
@@ -81,14 +151,14 @@ export class KnownWordCacheManager {
);
this.loadKnownWordCacheState();
void this.refreshKnownWords();
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
this.knownWordsRefreshTimer = setInterval(() => {
void this.refreshKnownWords();
}, refreshIntervalMs);
this.scheduleKnownWordRefreshLifecycle();
}
stopLifecycle(): void {
if (this.knownWordsRefreshTimeout) {
clearTimeout(this.knownWordsRefreshTimeout);
this.knownWordsRefreshTimeout = null;
}
if (this.knownWordsRefreshTimer) {
clearInterval(this.knownWordsRefreshTimer);
this.knownWordsRefreshTimer = null;
@@ -96,45 +166,44 @@ export class KnownWordCacheManager {
}
appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): void {
if (!this.isKnownWordCacheEnabled()) {
if (!this.isKnownWordCacheEnabled() || !this.shouldAddMinedWordsImmediately()) {
return;
}
const currentScope = this.getKnownWordCacheScope();
if (this.knownWordsScope && this.knownWordsScope !== currentScope) {
const currentStateKey = this.getKnownWordCacheStateKey();
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
this.clearKnownWordCacheState();
}
if (!this.knownWordsScope) {
this.knownWordsScope = currentScope;
if (!this.knownWordsStateKey) {
this.knownWordsStateKey = currentStateKey;
}
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;
const preferredFields = this.getImmediateAppendFields();
if (!preferredFields) {
return;
}
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}`,
);
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields);
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
if (!changed) {
return;
}
if (this.knownWordsLastRefreshedAtMs <= 0) {
this.knownWordsLastRefreshedAtMs = Date.now();
}
this.persistKnownWordCacheState();
log.info(
'Known-word cache updated in-session',
`noteId=${noteInfo.noteId}`,
`wordCount=${nextWords.length}`,
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
);
}
clearKnownWordCacheState(): void {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
try {
if (fs.existsSync(this.statePath)) {
fs.unlinkSync(this.statePath);
@@ -158,41 +227,43 @@ export class KnownWordCacheManager {
return;
}
const frozenStateKey = this.getKnownWordCacheStateKey();
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 noteFieldsById = await this.fetchKnownWordNoteFieldsById();
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
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[];
if (this.noteWordsById.size === 0) {
await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById);
} else {
const currentNoteIdSet = new Set(currentNoteIds);
for (const noteId of Array.from(this.noteWordsById.keys())) {
if (!currentNoteIdSet.has(noteId)) {
this.removeNoteSnapshot(noteId);
}
}
for (const noteInfo of notesInfo) {
for (const word of this.extractKnownWordsFromNoteInfo(noteInfo)) {
const normalized = this.normalizeKnownWordForLookup(word);
if (normalized) {
nextKnownWords.add(normalized);
}
}
if (currentNoteIds.length > 0) {
const noteInfos = await this.fetchKnownWordNotesInfo(currentNoteIds);
for (const noteInfo of noteInfos) {
this.replaceNoteSnapshot(
noteInfo.noteId,
this.extractNormalizedKnownWordsFromNoteInfo(
noteInfo,
noteFieldsById.get(noteInfo.noteId),
),
);
}
}
}
this.knownWords = nextKnownWords;
this.knownWordsLastRefreshedAtMs = Date.now();
this.knownWordsScope = this.getKnownWordCacheScope();
this.knownWordsStateKey = frozenStateKey;
this.persistKnownWordCacheState();
log.info(
'Known-word cache refreshed',
`noteCount=${noteIds.length}`,
`wordCount=${nextKnownWords.size}`,
`noteCount=${currentNoteIds.length}`,
`wordCount=${this.knownWords.size}`,
);
} catch (error) {
log.warn('Failed to refresh known-word cache:', (error as Error).message);
@@ -203,32 +274,100 @@ export class KnownWordCacheManager {
}
private isKnownWordCacheEnabled(): boolean {
return this.deps.getConfig().nPlusOne?.highlightEnabled === true;
return this.deps.getConfig().knownWords?.highlightEnabled === true;
}
private shouldAddMinedWordsImmediately(): boolean {
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
}
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;
return getKnownWordCacheRefreshIntervalMinutes(this.deps.getConfig()) * 60_000;
}
private getDefaultKnownWordFields(): string[] {
const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig());
return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])];
}
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 configuredDecks = this.deps.getConfig().knownWords?.decks;
if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
return Object.keys(configuredDecks)
.map((d) => d.trim())
.filter((d) => d.length > 0);
}
const deck = this.deps.getConfig().deck?.trim();
return deck ? [deck] : [];
}
private getConfiguredFields(): string[] {
return this.getDefaultKnownWordFields();
}
private getImmediateAppendFields(): string[] | null {
const configuredDecks = this.deps.getConfig().knownWords?.decks;
if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
const trimmedDeckEntries = Object.entries(configuredDecks)
.map(([deckName, fields]) => [deckName.trim(), fields] as const)
.filter(([deckName]) => deckName.length > 0);
const currentDeck = this.deps.getConfig().deck?.trim();
const selectedDeckEntry =
currentDeck !== undefined && currentDeck.length > 0
? trimmedDeckEntries.find(([deckName]) => deckName === currentDeck) ?? null
: trimmedDeckEntries.length === 1
? trimmedDeckEntries[0] ?? null
: null;
if (!selectedDeckEntry) {
return null;
}
const deckFields = selectedDeckEntry[1];
if (Array.isArray(deckFields)) {
const normalizedFields = [
...new Set(
deckFields.map(String).map((field) => field.trim()).filter((field) => field.length > 0),
),
];
if (normalizedFields.length > 0) {
return normalizedFields;
}
}
return this.getDefaultKnownWordFields();
}
return this.getConfiguredFields();
}
private getKnownWordQueryScopes(): KnownWordQueryScope[] {
const configuredDecks = this.deps.getConfig().knownWords?.decks;
if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
const scopes: KnownWordQueryScope[] = [];
for (const [deckName, fields] of Object.entries(configuredDecks)) {
const trimmedDeckName = deckName.trim();
if (!trimmedDeckName) {
continue;
}
const normalizedFields = Array.isArray(fields)
? [...new Set(fields.map(String).map((field) => field.trim()).filter(Boolean))]
: [];
scopes.push({
query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`,
fields: normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields(),
});
}
if (scopes.length > 0) {
return scopes;
}
}
return [{ query: this.buildKnownWordsQuery(), fields: this.getDefaultKnownWordFields() }];
}
private buildKnownWordsQuery(): string {
const decks = this.getKnownWordDecks();
if (decks.length === 0) {
@@ -243,19 +382,15 @@ export class KnownWordCacheManager {
return `(${deckQueries.join(' OR ')})`;
}
private getKnownWordCacheScope(): string {
const decks = this.getKnownWordDecks();
if (decks.length === 0) {
return 'is:note';
}
return `decks:${JSON.stringify(decks)}`;
private getKnownWordCacheStateKey(): string {
return getKnownWordCacheLifecycleConfig(this.deps.getConfig());
}
private isKnownWordCacheStale(): boolean {
if (!this.isKnownWordCacheEnabled()) {
return true;
}
if (this.knownWordsScope !== this.getKnownWordCacheScope()) {
if (this.knownWordsStateKey !== this.getKnownWordCacheStateKey()) {
return true;
}
if (this.knownWordsLastRefreshedAtMs <= 0) {
@@ -264,64 +399,231 @@ export class KnownWordCacheManager {
return Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs();
}
private async fetchKnownWordNoteFieldsById(): Promise<Map<number, string[]>> {
const scopes = this.getKnownWordQueryScopes();
const noteFieldsById = new Map<number, string[]>();
log.debug('Refreshing known-word cache', `queries=${scopes.map((scope) => scope.query).join(' | ')}`);
for (const scope of scopes) {
const noteIds = (await this.deps.client.findNotes(scope.query, {
maxRetries: 0,
})) as number[];
for (const noteId of noteIds) {
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const existingFields = noteFieldsById.get(noteId) ?? [];
noteFieldsById.set(
noteId,
[...new Set([...existingFields, ...scope.fields])],
);
}
}
return noteFieldsById;
}
private scheduleKnownWordRefreshLifecycle(): void {
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
const scheduleInterval = () => {
this.knownWordsRefreshTimer = setInterval(() => {
void this.refreshKnownWords();
}, refreshIntervalMs);
};
const initialDelayMs = this.getMsUntilNextRefresh();
this.knownWordsRefreshTimeout = setTimeout(() => {
this.knownWordsRefreshTimeout = null;
void this.refreshKnownWords();
scheduleInterval();
}, initialDelayMs);
}
private getMsUntilNextRefresh(): number {
if (this.knownWordsStateKey !== this.getKnownWordCacheStateKey()) {
return 0;
}
if (this.knownWordsLastRefreshedAtMs <= 0) {
return 0;
}
const remainingMs =
this.getKnownWordRefreshIntervalMs() - (Date.now() - this.knownWordsLastRefreshedAtMs);
return Math.max(0, remainingMs);
}
private async rebuildFromCurrentNotes(
noteIds: number[],
noteFieldsById: Map<number, string[]>,
): Promise<void> {
this.clearInMemoryState();
if (noteIds.length === 0) {
return;
}
const noteInfos = await this.fetchKnownWordNotesInfo(noteIds);
for (const noteInfo of noteInfos) {
this.replaceNoteSnapshot(
noteInfo.noteId,
this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
);
}
}
private async fetchKnownWordNotesInfo(noteIds: number[]): Promise<KnownWordCacheNoteInfo[]> {
const noteInfos: KnownWordCacheNoteInfo[] = [];
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 chunkInfos = notesInfoResult as KnownWordCacheNoteInfo[];
for (const noteInfo of chunkInfos) {
if (
!noteInfo ||
!Number.isInteger(noteInfo.noteId) ||
noteInfo.noteId <= 0 ||
typeof noteInfo.fields !== 'object' ||
noteInfo.fields === null ||
Array.isArray(noteInfo.fields)
) {
continue;
}
noteInfos.push(noteInfo);
}
}
return noteInfos;
}
private replaceNoteSnapshot(noteId: number, nextWords: string[]): boolean {
const normalizedWords = normalizeKnownWordList(nextWords);
const previousWords = this.noteWordsById.get(noteId) ?? [];
if (knownWordListsEqual(previousWords, normalizedWords)) {
return false;
}
this.removeWordsFromCounts(previousWords);
if (normalizedWords.length > 0) {
this.noteWordsById.set(noteId, normalizedWords);
this.addWordsToCounts(normalizedWords);
} else {
this.noteWordsById.delete(noteId);
}
return true;
}
private removeNoteSnapshot(noteId: number): void {
const previousWords = this.noteWordsById.get(noteId);
if (!previousWords) {
return;
}
this.noteWordsById.delete(noteId);
this.removeWordsFromCounts(previousWords);
}
private addWordsToCounts(words: string[]): void {
for (const word of words) {
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) + 1;
this.wordReferenceCounts.set(word, nextCount);
this.knownWords.add(word);
}
}
private removeWordsFromCounts(words: string[]): void {
for (const word of words) {
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) - 1;
if (nextCount > 0) {
this.wordReferenceCounts.set(word, nextCount);
} else {
this.wordReferenceCounts.delete(word);
this.knownWords.delete(word);
}
}
}
private clearInMemoryState(): void {
this.knownWords = new Set();
this.wordReferenceCounts = new Map();
this.noteWordsById = new Map();
this.knownWordsLastRefreshedAtMs = 0;
}
private loadKnownWordCacheState(): void {
try {
if (!fs.existsSync(this.statePath)) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
return;
}
const raw = fs.readFileSync(this.statePath, 'utf-8');
if (!raw.trim()) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
return;
}
const parsed = JSON.parse(raw) as unknown;
if (!this.isKnownWordCacheStateValid(parsed)) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
return;
}
if (parsed.scope !== this.getKnownWordCacheScope()) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
if (parsed.scope !== this.getKnownWordCacheStateKey()) {
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
return;
}
const nextKnownWords = new Set<string>();
for (const value of parsed.words) {
const normalized = this.normalizeKnownWordForLookup(value);
if (normalized) {
nextKnownWords.add(normalized);
this.clearInMemoryState();
if (parsed.version === 2) {
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const normalizedWords = normalizeKnownWordList(words);
if (normalizedWords.length === 0) {
continue;
}
this.noteWordsById.set(noteId, normalizedWords);
this.addWordsToCounts(normalizedWords);
}
} else {
for (const value of parsed.words) {
const normalized = this.normalizeKnownWordForLookup(value);
if (!normalized) {
continue;
}
this.knownWords.add(normalized);
this.wordReferenceCounts.set(normalized, 1);
}
}
this.knownWords = nextKnownWords;
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsScope = parsed.scope;
this.knownWordsStateKey = 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();
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
}
}
private persistKnownWordCacheState(): void {
try {
const state: KnownWordCacheState = {
version: 1,
const notes: Record<string, string[]> = {};
for (const [noteId, words] of this.noteWordsById.entries()) {
if (words.length > 0) {
notes[String(noteId)] = words;
}
}
const state: KnownWordCacheStateV2 = {
version: 2,
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
scope: this.knownWordsScope,
scope: this.knownWordsStateKey,
words: Array.from(this.knownWords),
notes,
};
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
} catch (error) {
@@ -331,20 +633,39 @@ export class KnownWordCacheManager {
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;
const candidate = value as Record<string, unknown>;
if (candidate.version !== 1 && candidate.version !== 2) 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')) {
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
return false;
}
if (candidate.version === 2) {
if (
typeof candidate.notes !== 'object' ||
candidate.notes === null ||
Array.isArray(candidate.notes)
) {
return false;
}
if (
!Object.values(candidate.notes as Record<string, unknown>).every(
(entry) =>
Array.isArray(entry) && entry.every((word: unknown) => typeof word === 'string'),
)
) {
return false;
}
}
return true;
}
private extractKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
private extractNormalizedKnownWordsFromNoteInfo(
noteInfo: KnownWordCacheNoteInfo,
preferredFields = this.getConfiguredFields(),
): string[] {
const words: string[] = [];
const preferredFields = ['Expression', 'Word'];
for (const preferredField of preferredFields) {
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
if (!fieldName) continue;
@@ -352,12 +673,12 @@ export class KnownWordCacheManager {
const raw = noteInfo.fields[fieldName]?.value;
if (!raw) continue;
const extracted = this.normalizeRawKnownWordValue(raw);
if (extracted) {
words.push(extracted);
const normalized = this.normalizeKnownWordForLookup(raw);
if (normalized) {
words.push(normalized);
}
}
return words;
return normalizeKnownWordList(words);
}
private normalizeRawKnownWordValue(value: string): string {
@@ -372,6 +693,22 @@ export class KnownWordCacheManager {
}
}
function normalizeKnownWordList(words: string[]): string[] {
return [...new Set(words.map((word) => word.trim()).filter((word) => word.length > 0))].sort();
}
function knownWordListsEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
const exact = availableFieldNames.find((name) => name === preferredName);
if (exact) return exact;

View File

@@ -62,6 +62,7 @@ function createWorkflowHarness() {
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
},
getResolvedSentenceAudioFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0,
mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
generateAudioFilename: () => 'audio_1.mp3',
generateAudio: async () => null,
@@ -163,3 +164,42 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async (
assert.deepEqual(callOrder, ['update', 'auto']);
assert.equal(harness.updates.length, 1);
});
test('NoteUpdateWorkflow passes animated image lead-in when syncing avif to word audio', async () => {
const harness = createWorkflowHarness();
let receivedLeadInSeconds = 0;
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
ExpressionAudio: { value: '[sound:word.mp3]' },
Sentence: { value: '' },
Picture: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
image: 'Picture',
},
media: {
generateImage: true,
imageType: 'avif',
syncAnimatedImageToWordAudio: true,
},
behavior: {},
});
harness.deps.getAnimatedImageLeadInSeconds = async () => 1.25;
harness.deps.generateImage = async (leadInSeconds?: number) => {
receivedLeadInSeconds = leadInSeconds ?? 0;
return Buffer.from('image');
};
await harness.workflow.execute(42);
assert.equal(receivedLeadInSeconds, 1.25);
});

View File

@@ -1,4 +1,5 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
export interface NoteUpdateWorkflowNoteInfo {
noteId: number;
@@ -13,6 +14,7 @@ export interface NoteUpdateWorkflowDeps {
};
getConfig: () => {
fields?: {
word?: string;
sentence?: string;
image?: string;
miscInfo?: string;
@@ -20,6 +22,8 @@ export interface NoteUpdateWorkflowDeps {
media?: {
generateAudio?: boolean;
generateImage?: boolean;
imageType?: 'static' | 'avif';
syncAnimatedImageToWordAudio?: boolean;
};
behavior?: {
overwriteAudio?: boolean;
@@ -58,11 +62,12 @@ export interface NoteUpdateWorkflowDeps {
...preferredNames: (string | undefined)[]
) => string | null;
getResolvedSentenceAudioFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo) => string | null;
getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
generateAudioFilename: () => string;
generateAudio: () => Promise<Buffer | null>;
generateImageFilename: () => string;
generateImage: () => Promise<Buffer | null>;
generateImage: (animatedLeadInSeconds?: number) => Promise<Buffer | null>;
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
showNotification: (noteId: number, label: string | number) => Promise<void>;
@@ -90,8 +95,9 @@ export class NoteUpdateWorkflow {
const noteInfo = notesInfo[0]!;
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
const fields = this.deps.extractFields(noteInfo.fields);
const config = this.deps.getConfig();
const expressionText = (fields.expression || fields.word || '').trim();
const expressionText = getPreferredWordValueFromExtractedFields(fields, config).trim();
const hasExpressionText = expressionText.length > 0;
if (!hasExpressionText) {
// Some note types omit Expression/Word; still run enrichment updates and skip duplicate checks.
@@ -123,8 +129,6 @@ export class NoteUpdateWorkflow {
updatePerformed = true;
}
const config = this.deps.getConfig();
if (config.media?.generateAudio) {
try {
const audioFilename = this.deps.generateAudioFilename();
@@ -152,8 +156,9 @@ export class NoteUpdateWorkflow {
if (config.media?.generateImage) {
try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.deps.generateImageFilename();
const imageBuffer = await this.deps.generateImage();
const imageBuffer = await this.deps.generateImage(animatedLeadInSeconds);
if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);

View File

@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PollingRunner } from './polling';
test('polling runner records newly added cards after initialization', async () => {
const recordedCards: number[] = [];
let tracked = new Set<number>();
const responses = [
[10, 11],
[10, 11, 12, 13],
];
const runner = new PollingRunner({
getDeck: () => 'Mining',
getPollingRate: () => 250,
findNotes: async () => responses.shift() ?? [],
shouldAutoUpdateNewCards: () => true,
processNewCard: async () => undefined,
recordCardsAdded: (count) => {
recordedCards.push(count);
},
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
getTrackedNoteIds: () => tracked,
setTrackedNoteIds: (noteIds) => {
tracked = noteIds;
},
showStatusNotification: () => undefined,
logDebug: () => undefined,
logInfo: () => undefined,
logWarn: () => undefined,
});
await runner.pollOnce();
await runner.pollOnce();
assert.deepEqual(recordedCards, [2]);
});

View File

@@ -9,6 +9,7 @@ export interface PollingRunnerDeps {
) => Promise<number[]>;
shouldAutoUpdateNewCards: () => boolean;
processNewCard: (noteId: number) => Promise<void>;
recordCardsAdded?: (count: number, noteIds: number[]) => void;
isUpdateInProgress: () => boolean;
setUpdateInProgress: (value: boolean) => void;
getTrackedNoteIds: () => Set<number>;
@@ -80,6 +81,7 @@ export class PollingRunner {
previousNoteIds.add(noteId);
}
this.deps.setTrackedNoteIds(previousNoteIds);
this.deps.recordCardsAdded?.(newNoteIds.length, newNoteIds);
if (this.deps.shouldAutoUpdateNewCards()) {
for (const noteId of newNoteIds) {

View File

@@ -59,6 +59,10 @@ test('AnkiIntegrationRuntime normalizes url and proxy defaults', () => {
normalized.media?.fallbackDuration,
DEFAULT_ANKI_CONNECT_CONFIG.media.fallbackDuration,
);
assert.equal(
normalized.media?.syncAnimatedImageToWordAudio,
DEFAULT_ANKI_CONNECT_CONFIG.media.syncAnimatedImageToWordAudio,
);
});
test('AnkiIntegrationRuntime starts proxy transport when proxy mode is enabled', () => {
@@ -78,7 +82,7 @@ test('AnkiIntegrationRuntime starts proxy transport when proxy mode is enabled',
test('AnkiIntegrationRuntime switches transports and clears known words when runtime patch disables highlighting', () => {
const { runtime, calls } = createRuntime({
nPlusOne: {
knownWords: {
highlightEnabled: true,
},
pollingRate: 250,
@@ -88,7 +92,7 @@ test('AnkiIntegrationRuntime switches transports and clears known words when run
calls.length = 0;
runtime.applyRuntimeConfigPatch({
nPlusOne: {
knownWords: {
highlightEnabled: false,
},
proxy: {
@@ -106,3 +110,77 @@ test('AnkiIntegrationRuntime switches transports and clears known words when run
'proxy:start:127.0.0.1:8766:http://127.0.0.1:8765',
]);
});
test('AnkiIntegrationRuntime skips known-word lifecycle restart for unrelated runtime patches', () => {
const { runtime, calls } = createRuntime({
knownWords: {
highlightEnabled: true,
},
pollingRate: 250,
});
runtime.start();
calls.length = 0;
runtime.applyRuntimeConfigPatch({
behavior: {
autoUpdateNewCards: false,
},
});
assert.deepEqual(calls, []);
});
test('AnkiIntegrationRuntime restarts known-word lifecycle when known-word settings change', () => {
const { runtime, calls } = createRuntime({
knownWords: {
highlightEnabled: true,
refreshMinutes: 90,
},
pollingRate: 250,
});
runtime.start();
calls.length = 0;
runtime.applyRuntimeConfigPatch({
knownWords: {
refreshMinutes: 120,
},
});
assert.deepEqual(calls, ['known:start']);
});
test('AnkiIntegrationRuntime does not stop lifecycle when disabled while runtime is stopped', () => {
const { runtime, calls } = createRuntime({
knownWords: {
highlightEnabled: true,
},
});
runtime.applyRuntimeConfigPatch({
knownWords: {
highlightEnabled: false,
},
});
assert.deepEqual(calls, ['known:clear']);
});
test('AnkiIntegrationRuntime does not restart known-word lifecycle for config changes while stopped', () => {
const { runtime, calls } = createRuntime({
knownWords: {
highlightEnabled: true,
refreshMinutes: 90,
},
});
runtime.applyRuntimeConfigPatch({
knownWords: {
refreshMinutes: 120,
},
});
assert.deepEqual(calls, []);
});

View File

@@ -1,5 +1,10 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import type { AnkiConnectConfig } from '../types';
import {
getKnownWordCacheLifecycleConfig,
getKnownWordCacheRefreshIntervalMinutes,
getKnownWordCacheScopeForConfig,
} from './known-word-cache';
export interface AnkiIntegrationRuntimeProxyServer {
start(options: { host: string; port: number; upstreamUrl: string }): void;
@@ -86,6 +91,14 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
...DEFAULT_ANKI_CONNECT_CONFIG.media,
...(config.media ?? {}),
},
knownWords: {
...DEFAULT_ANKI_CONNECT_CONFIG.knownWords,
...(config.knownWords ?? {}),
},
nPlusOne: {
...DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne,
...(config.nPlusOne ?? {}),
},
behavior: {
...DEFAULT_ANKI_CONNECT_CONFIG.behavior,
...(config.behavior ?? {}),
@@ -136,12 +149,22 @@ export class AnkiIntegrationRuntime {
}
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
const wasKnownWordCacheEnabled = this.config.nPlusOne?.highlightEnabled === true;
const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
? this.getKnownWordCacheLifecycleConfig(this.config)
: null;
const previousTransportKey = this.getTransportConfigKey(this.config);
const mergedConfig: AnkiConnectConfig = {
...this.config,
...patch,
knownWords:
patch.knownWords !== undefined
? {
...(this.config.knownWords ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords),
...patch.knownWords,
}
: this.config.knownWords,
nPlusOne:
patch.nPlusOne !== undefined
? {
@@ -176,11 +199,22 @@ export class AnkiIntegrationRuntime {
};
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
this.deps.onConfigChanged?.(this.config);
const nextKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
if (wasKnownWordCacheEnabled && this.config.nPlusOne?.highlightEnabled === false) {
this.deps.knownWordCache.stopLifecycle();
if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
if (this.started) {
this.deps.knownWordCache.stopLifecycle();
}
this.deps.knownWordCache.clearKnownWordCacheState();
} else {
} else if (this.started && !wasKnownWordCacheEnabled && nextKnownWordCacheEnabled) {
this.deps.knownWordCache.startLifecycle();
} else if (
this.started &&
wasKnownWordCacheEnabled &&
nextKnownWordCacheEnabled &&
previousKnownWordCacheConfig !== null &&
previousKnownWordCacheConfig !== this.getKnownWordCacheLifecycleConfig(this.config)
) {
this.deps.knownWordCache.startLifecycle();
}
@@ -191,6 +225,18 @@ export class AnkiIntegrationRuntime {
}
}
private getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
return getKnownWordCacheLifecycleConfig(config);
}
private getKnownWordRefreshIntervalMinutes(config: AnkiConnectConfig): number {
return getKnownWordCacheRefreshIntervalMinutes(config);
}
private getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): string {
return getKnownWordCacheScopeForConfig(config);
}
getOrCreateProxyServer(): AnkiIntegrationRuntimeProxyServer {
if (!this.proxyServer) {
this.proxyServer = this.deps.proxyServerFactory();

View File

@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
beginUpdateProgress,
createUiFeedbackState,
showProgressTick,
showUpdateResult,
} from './ui-feedback';
test('showUpdateResult stops spinner before success notification and suppresses stale ticks', () => {
const state = createUiFeedbackState();
const osdMessages: string[] = [];
beginUpdateProgress(state, 'Creating sentence card', () => {
showProgressTick(state, (text) => {
osdMessages.push(text);
});
});
showUpdateResult(
state,
{
clearProgressTimer: (timer) => {
clearInterval(timer);
},
showOsdNotification: (text) => {
osdMessages.push(text);
},
},
{ success: true, message: 'Updated card: taberu' },
);
showProgressTick(state, (text) => {
osdMessages.push(text);
});
assert.deepEqual(osdMessages, ['Creating sentence card |', '✓ Updated card: taberu']);
});
test('showUpdateResult renders failed updates with an x marker', () => {
const state = createUiFeedbackState();
const osdMessages: string[] = [];
beginUpdateProgress(state, 'Creating sentence card', () => {
showProgressTick(state, (text) => {
osdMessages.push(text);
});
});
showUpdateResult(
state,
{
clearProgressTimer: (timer) => {
clearInterval(timer);
},
showOsdNotification: (text) => {
osdMessages.push(text);
},
},
{ success: false, message: 'Sentence card failed: deck missing' },
);
assert.deepEqual(osdMessages, [
'Creating sentence card |',
'x Sentence card failed: deck missing',
]);
});

View File

@@ -7,6 +7,11 @@ export interface UiFeedbackState {
progressFrame: number;
}
export interface UiFeedbackResult {
success: boolean;
message: string;
}
export interface UiFeedbackNotificationContext {
getNotificationType: () => string | undefined;
showOsd: (text: string) => void;
@@ -66,6 +71,15 @@ export function endUpdateProgress(
state.progressDepth = Math.max(0, state.progressDepth - 1);
if (state.progressDepth > 0) return;
clearUpdateProgress(state, clearProgressTimer);
}
export function clearUpdateProgress(
state: UiFeedbackState,
clearProgressTimer: (timer: ReturnType<typeof setInterval>) => void,
): void {
state.progressDepth = 0;
if (state.progressTimer) {
clearProgressTimer(state.progressTimer);
state.progressTimer = null;
@@ -85,6 +99,19 @@ export function showProgressTick(
showOsdNotification(`${state.progressMessage} ${frame}`);
}
export function showUpdateResult(
state: UiFeedbackState,
options: {
clearProgressTimer: (timer: ReturnType<typeof setInterval>) => void;
showOsdNotification: (text: string) => void;
},
result: UiFeedbackResult,
): void {
clearUpdateProgress(state, options.clearProgressTimer);
const prefix = result.success ? '✓' : 'x';
options.showOsdNotification(`${prefix} ${result.message}`);
}
export async function withUpdateProgress<T>(
state: UiFeedbackState,
options: UiFeedbackOptions,