mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-27 18:12:05 -07:00
feat(stats): add v1 immersion stats dashboard (#19)
This commit is contained in:
82
src/anki-integration/animated-image-sync.test.ts
Normal file
82
src/anki-integration/animated-image-sync.test.ts
Normal 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);
|
||||
});
|
||||
133
src/anki-integration/animated-image-sync.ts
Normal file
133
src/anki-integration/animated-image-sync.ts
Normal 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;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
285
src/anki-integration/card-creation.test.ts
Normal file
285
src/anki-integration/card-creation.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
|
||||
535
src/anki-integration/known-word-cache.test.ts
Normal file
535
src/anki-integration/known-word-cache.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
38
src/anki-integration/polling.test.ts
Normal file
38
src/anki-integration/polling.test.ts
Normal 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]);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
67
src/anki-integration/ui-feedback.test.ts
Normal file
67
src/anki-integration/ui-feedback.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user