mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
feat(plugins): add JavaScript plugin system with overlay panel (#303)
This commit is contained in:
1 parent
90fc7cf940
commit
a9a4c4f083
110 files changed
+5897
-344
No files matched your search
@@ -3,6 +3,7 @@ import http from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import { AnkiConnectProxyServer } from './anki-connect-proxy';
|
||||
import type { PluginMiningProvenance } from '../plugin-api';
|
||||
|
||||
async function waitForCondition(
|
||||
condition: () => boolean,
|
||||
@@ -17,17 +18,23 @@ async function waitForCondition(
|
||||
throw new Error('Timed out waiting for condition');
|
||||
}
|
||||
|
||||
test('proxy enqueues addNote result for enrichment', async () => {
|
||||
test('proxy enqueues addNote result for enrichment and reports it ready afterwards', async () => {
|
||||
const processed: number[] = [];
|
||||
const recordedCards: number[] = [];
|
||||
const events: string[] = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (noteId) => {
|
||||
processed.push(noteId);
|
||||
events.push(`process ${noteId}`);
|
||||
throw new Error('enrichment failed');
|
||||
},
|
||||
recordCardsAdded: (count) => {
|
||||
recordedCards.push(count);
|
||||
},
|
||||
onCardsReady: (noteIds) => {
|
||||
events.push(`ready ${noteIds.join(',')}`);
|
||||
},
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
logError: () => undefined,
|
||||
@@ -42,9 +49,44 @@ test('proxy enqueues addNote result for enrichment', async () => {
|
||||
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
|
||||
);
|
||||
|
||||
await waitForCondition(() => processed.length === 1);
|
||||
await waitForCondition(() => events.length === 2);
|
||||
assert.deepEqual(processed, [42]);
|
||||
assert.deepEqual(recordedCards, [1]);
|
||||
assert.deepEqual(events, ['process 42', 'ready 42']);
|
||||
});
|
||||
|
||||
test('proxy reports an added note ready right away when auto-update is off', () => {
|
||||
const processed: number[] = [];
|
||||
const recordedCards: number[] = [];
|
||||
const ready: number[][] = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => false,
|
||||
processNewCard: async (noteId) => {
|
||||
processed.push(noteId);
|
||||
},
|
||||
recordCardsAdded: (count) => {
|
||||
recordedCards.push(count);
|
||||
},
|
||||
onCardsReady: (noteIds) => {
|
||||
ready.push(noteIds);
|
||||
},
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
logError: () => undefined,
|
||||
});
|
||||
|
||||
(
|
||||
proxy as unknown as {
|
||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||
}
|
||||
).maybeEnqueueFromRequest(
|
||||
{ action: 'addNote' },
|
||||
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
|
||||
);
|
||||
|
||||
assert.deepEqual(ready, [[42]]);
|
||||
assert.deepEqual(processed, []);
|
||||
assert.deepEqual(recordedCards, []);
|
||||
});
|
||||
|
||||
test('proxy enqueues addNote bare numeric response for enrichment', async () => {
|
||||
@@ -360,8 +402,11 @@ test('proxy tracks duplicate note ids from addNote request metadata before enric
|
||||
assert.deepEqual(processed, [42]);
|
||||
});
|
||||
|
||||
test('proxy strips SubMiner duplicate metadata before forwarding upstream addNote request', async () => {
|
||||
test('proxy strips duplicate metadata and captures mining provenance before the upstream request', async () => {
|
||||
let upstreamBody = '';
|
||||
let mediaPath = 'Show - 02.mkv';
|
||||
let captureCount = 0;
|
||||
const sources: Array<PluginMiningProvenance | null> = [];
|
||||
const upstream = http.createServer(async (req, res) => {
|
||||
upstreamBody = await new Promise<string>((resolve) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -369,6 +414,7 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
res.statusCode = 200;
|
||||
mediaPath = 'Show - 03.mkv';
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ result: 42, error: null }));
|
||||
});
|
||||
@@ -380,6 +426,15 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
||||
|
||||
const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
captureMiningProvenance: () => {
|
||||
captureCount++;
|
||||
return {
|
||||
minedAt: 1234,
|
||||
media: { playbackId: 1, path: mediaPath, title: null },
|
||||
subtitle: null,
|
||||
};
|
||||
},
|
||||
onNotesAdded: (_ids, provenance) => sources.push(provenance),
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async () => undefined,
|
||||
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
@@ -434,6 +489,16 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
||||
await waitForCondition(() => tracked.length === 1);
|
||||
assert.equal(upstreamBody.includes('subminerDuplicateNoteIds'), false);
|
||||
assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [7, 18] }]);
|
||||
assert.equal(sources[0]?.media?.path, 'Show - 02.mkv');
|
||||
assert.equal(captureCount, 1);
|
||||
const preflight = await fetch(`http://127.0.0.1:${proxyPort}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'canAddNotes', version: 6, params: { notes: [] } }),
|
||||
});
|
||||
assert.equal(preflight.status, 200);
|
||||
await preflight.json();
|
||||
assert.equal(captureCount, 1);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
upstream.close();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import http, { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import type { PluginMiningProvenance } from '../plugin-api';
|
||||
|
||||
interface StartProxyOptions {
|
||||
host: string;
|
||||
@@ -13,9 +14,13 @@ interface AnkiConnectEnvelope {
|
||||
}
|
||||
|
||||
export interface AnkiConnectProxyServerDeps {
|
||||
captureMiningProvenance?: () => PluginMiningProvenance | null;
|
||||
onNotesAdded?: (noteIds: number[], provenance: PluginMiningProvenance | null) => void;
|
||||
shouldAutoUpdateNewCards: () => boolean;
|
||||
processNewCard: (noteId: number) => Promise<void>;
|
||||
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
||||
/** Called once SubMiner is done with an added note: after enrichment, or right away when it is off. */
|
||||
onCardsReady?: (noteIds: number[]) => void;
|
||||
trackAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
getDeck?: () => string | undefined;
|
||||
findNotes?: (
|
||||
@@ -186,6 +191,12 @@ export class AnkiConnectProxyServer {
|
||||
typeof req.headers['content-type'] === 'string'
|
||||
? req.headers['content-type']
|
||||
: 'application/json';
|
||||
const action = requestJson?.action;
|
||||
const provenance =
|
||||
typeof action === 'string' &&
|
||||
['addNote', 'addNotes', 'multi', 'updateNoteFields'].includes(action)
|
||||
? (this.deps.captureMiningProvenance?.() ?? null)
|
||||
: null;
|
||||
const upstreamResponse = await this.client.request<ArrayBuffer>({
|
||||
url: targetUrl,
|
||||
method: req.method,
|
||||
@@ -203,7 +214,7 @@ export class AnkiConnectProxyServer {
|
||||
res.end(responseBody);
|
||||
|
||||
if (req.method === 'POST') {
|
||||
this.maybeEnqueueFromRequest(requestJson, responseBody);
|
||||
this.maybeEnqueueFromRequest(requestJson, responseBody, provenance);
|
||||
}
|
||||
} catch (error) {
|
||||
this.deps.logWarn('[anki-proxy] Failed to forward request:', (error as Error).message);
|
||||
@@ -215,16 +226,19 @@ export class AnkiConnectProxyServer {
|
||||
private maybeEnqueueFromRequest(
|
||||
requestJson: Record<string, unknown> | null,
|
||||
responseBody: Buffer,
|
||||
provenance: PluginMiningProvenance | null = null,
|
||||
): void {
|
||||
if (!requestJson || !this.deps.shouldAutoUpdateNewCards()) {
|
||||
if (!requestJson) {
|
||||
return;
|
||||
}
|
||||
const autoUpdate = this.deps.shouldAutoUpdateNewCards();
|
||||
|
||||
const action =
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action === 'updateNoteFields') {
|
||||
if (!autoUpdate) return;
|
||||
const params = requestJson.params;
|
||||
if (
|
||||
!params ||
|
||||
@@ -246,6 +260,7 @@ export class AnkiConnectProxyServer {
|
||||
response.result !== null
|
||||
)
|
||||
return;
|
||||
this.deps.onNotesAdded?.(this.collectSingleResultId(note.id), provenance);
|
||||
this.enqueueNotes(this.collectSingleResultId(note.id), false);
|
||||
return;
|
||||
}
|
||||
@@ -277,9 +292,16 @@ export class AnkiConnectProxyServer {
|
||||
params.subminerEnrich === false
|
||||
) {
|
||||
// Stats owns the saved sentence and media; the live mpv context is unrelated.
|
||||
if (noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds);
|
||||
if (autoUpdate && noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds);
|
||||
return;
|
||||
}
|
||||
if (!autoUpdate) {
|
||||
// Nothing to enrich, so the added notes are already in their final state.
|
||||
this.deps.onNotesAdded?.(noteIds, provenance);
|
||||
if (noteIds.length > 0) this.deps.onCardsReady?.(noteIds);
|
||||
return;
|
||||
}
|
||||
this.deps.onNotesAdded?.(noteIds, provenance);
|
||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||
if (noteIds.length === 0 && shouldFallbackToLatestAdded) {
|
||||
void this.enqueueMostRecentAddedNote();
|
||||
@@ -534,6 +556,7 @@ export class AnkiConnectProxyServer {
|
||||
this.pendingNoteIdSet.delete(noteId);
|
||||
|
||||
if (!this.deps.shouldAutoUpdateNewCards()) {
|
||||
this.deps.onCardsReady?.([noteId]);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -547,6 +570,7 @@ export class AnkiConnectProxyServer {
|
||||
);
|
||||
} finally {
|
||||
this.inFlightNoteIds.delete(noteId);
|
||||
this.deps.onCardsReady?.([noteId]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -11,12 +11,37 @@ import {
|
||||
} from './card-creation-test-utils';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { PluginMiningProvenance } from '../plugin-api';
|
||||
|
||||
const YOUTUBE_URL = 'https://www.youtube.com/watch?v=abc123';
|
||||
|
||||
const mediaInputPath = (mediaInput: MediaInput): string =>
|
||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||
|
||||
test('sentence provenance is captured before asynchronous creation and uses the mined line', async () => {
|
||||
let currentPath = 'Show - 02.mkv';
|
||||
const captured: Array<PluginMiningProvenance | null> = [];
|
||||
const service = new CardCreationService(
|
||||
createCardCreationDeps({
|
||||
captureMiningProvenance: () => ({
|
||||
minedAt: 1234,
|
||||
media: { playbackId: 1, path: currentPath, title: null },
|
||||
subtitle: null,
|
||||
}),
|
||||
client: createFakeClient({
|
||||
addNote: async () => {
|
||||
currentPath = 'Show - 03.mkv';
|
||||
return 42;
|
||||
},
|
||||
}),
|
||||
onNoteCreated: (_noteId, provenance) => captured.push(provenance),
|
||||
}),
|
||||
);
|
||||
assert.equal(await service.createSentenceCard('テスト', 1, 2), true);
|
||||
assert.equal(captured[0]?.media?.path, 'Show - 02.mkv');
|
||||
assert.deepEqual(captured[0]?.subtitle, { text: 'テスト', startTime: 1, endTime: 2 });
|
||||
});
|
||||
|
||||
test('CardCreationService counts locally created sentence cards', async () => {
|
||||
const minedCards: Array<{ count: number; noteIds?: number[] }> = [];
|
||||
const service = new CardCreationService(
|
||||
@@ -31,6 +56,58 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
||||
assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]);
|
||||
});
|
||||
|
||||
for (const delay of [5, -5]) {
|
||||
test(`sentence provenance stays on the subtitle clock with delay ${delay}`, async () => {
|
||||
let captured: PluginMiningProvenance | null = null;
|
||||
const audioRanges: number[][] = [];
|
||||
const defaults = createCardCreationDeps();
|
||||
const service = new CardCreationService(
|
||||
createCardCreationDeps({
|
||||
getConfig: () => createAnkiConfig({ media: { generateAudio: true } }),
|
||||
getMpvClient: () => ({ ...defaults.getMpvClient()!, currentSubDelay: delay }),
|
||||
captureMiningProvenance: () => ({ minedAt: 1234, media: null, subtitle: null }),
|
||||
onNoteCreated: (_id, provenance) => {
|
||||
captured = provenance;
|
||||
},
|
||||
mediaGenerator: createFakeMediaGenerator({
|
||||
generateAudio: async (_path, start, end) => {
|
||||
audioRanges.push([start, end]);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(await service.createSentenceCard('first\nsecond', 10 + delay, 12 + delay), true);
|
||||
assert.partialDeepStrictEqual(captured, {
|
||||
subtitle: { text: 'first\nsecond', startTime: 10, endTime: 12 },
|
||||
});
|
||||
assert.deepEqual(audioRanges, [[10 + delay, 12 + delay]]);
|
||||
});
|
||||
}
|
||||
|
||||
test('CardCreationService reports a created card ready after its fields are written', async () => {
|
||||
const events: string[] = [];
|
||||
const service = new CardCreationService(
|
||||
createCardCreationDeps({
|
||||
client: createFakeClient({
|
||||
notesInfo: async () => [{ noteId: 42, fields: { Sentence: { value: 'existing' } } }],
|
||||
updateNoteFields: async () => {
|
||||
events.push('update');
|
||||
},
|
||||
}),
|
||||
setCardTypeFields: (updatedFields) => {
|
||||
updatedFields.CardType = 'sentence';
|
||||
},
|
||||
onCardsReady: (noteIds) => {
|
||||
events.push(`ready ${noteIds.join(',')}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(await service.createSentenceCard('テスト', 0, 1), true);
|
||||
assert.deepEqual(events, ['update', 'ready 42']);
|
||||
});
|
||||
|
||||
const THROWING_HOOKS: Array<{
|
||||
name: string;
|
||||
hook: Partial<CardCreationDeps>;
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
type MediaTimingReviewRequest,
|
||||
type WordCardKind,
|
||||
} from '../types/anki';
|
||||
import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock';
|
||||
import { shiftSubtitleRangeToVideoClock, toSubtitleClock } from '../core/services/subtitle-clock';
|
||||
import { createLogger } from '../logger';
|
||||
import type { PluginMiningProvenance } from '../plugin-api';
|
||||
import type { MediaInput } from '../media-input';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import { AiConfig } from '../types/integrations';
|
||||
@@ -102,6 +103,8 @@ interface CardCreationMediaGenerator {
|
||||
}
|
||||
|
||||
interface CardCreationDeps {
|
||||
captureMiningProvenance?: () => PluginMiningProvenance | null;
|
||||
onNoteCreated?: (noteId: number, provenance: PluginMiningProvenance | null) => void;
|
||||
getConfig: () => AnkiConnectConfig;
|
||||
getAiConfig: () => AiConfig;
|
||||
getTimingTracker: () => SubtitleTimingTracker;
|
||||
@@ -153,6 +156,8 @@ interface CardCreationDeps {
|
||||
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||
/** Called once card creation is done writing a card; queued YouTube media may still be pending. */
|
||||
onCardsReady?: (noteIds: number[]) => void;
|
||||
reviewMediaTiming?: (
|
||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||
) => Promise<MediaTimingReviewDecision>;
|
||||
@@ -603,6 +608,15 @@ export class CardCreationService {
|
||||
}
|
||||
|
||||
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
|
||||
const captured = this.deps.captureMiningProvenance?.() ?? null;
|
||||
const provenance = captured && {
|
||||
...captured,
|
||||
subtitle: {
|
||||
text: sentence,
|
||||
startTime: toSubtitleClock(startTime, mpvClient.currentSubDelay ?? 0),
|
||||
endTime: toSubtitleClock(endTime, mpvClient.currentSubDelay ?? 0),
|
||||
},
|
||||
};
|
||||
const cappedEndTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
|
||||
if (cappedEndTime !== endTime) {
|
||||
log.warn(
|
||||
@@ -611,6 +625,7 @@ export class CardCreationService {
|
||||
endTime = cappedEndTime;
|
||||
}
|
||||
|
||||
let createdNoteId: number | null = null;
|
||||
try {
|
||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||
const timingDecision = this.deps.reviewMediaTiming
|
||||
@@ -723,6 +738,8 @@ export class CardCreationService {
|
||||
fields,
|
||||
this.getConfiguredAnkiTags(),
|
||||
);
|
||||
createdNoteId = noteId;
|
||||
this.deps.onNoteCreated?.(noteId, provenance);
|
||||
log.info('Created sentence card:', noteId);
|
||||
} catch (error) {
|
||||
log.error('Failed to create sentence card:', (error as Error).message);
|
||||
@@ -887,6 +904,8 @@ export class CardCreationService {
|
||||
log.error('Error creating sentence card:', (error as Error).message);
|
||||
this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false);
|
||||
return false;
|
||||
} finally {
|
||||
if (createdNoteId !== null) this.deps.onCardsReady?.([createdNoteId]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface PendingYoutubeMediaQueueDeps {
|
||||
) => string;
|
||||
showStatusNotification: (message: string) => void;
|
||||
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>;
|
||||
/** Called once a queued note's media was applied or dropped, so the card is in its final state. */
|
||||
onUpdateSettled?: (noteId: number) => void;
|
||||
logInfo: (message: string, ...args: unknown[]) => void;
|
||||
logWarn: (message: string, ...args: unknown[]) => void;
|
||||
logError: (message: string, ...args: unknown[]) => void;
|
||||
@@ -79,9 +81,18 @@ function shouldGenerateImage(config: AnkiConnectConfig): boolean {
|
||||
|
||||
export class PendingYoutubeMediaQueue {
|
||||
private updates: PendingYoutubeMediaUpdate[] = [];
|
||||
// Jobs taken off the queue whose media is still being applied or dropped.
|
||||
private settlingNoteIds = new Set<number>();
|
||||
|
||||
constructor(private readonly deps: PendingYoutubeMediaQueueDeps) {}
|
||||
|
||||
/** True while the note still waits on queued media. */
|
||||
hasPendingNote(noteId: number): boolean {
|
||||
return (
|
||||
this.settlingNoteIds.has(noteId) || this.updates.some((update) => update.noteId === noteId)
|
||||
);
|
||||
}
|
||||
|
||||
enqueue(job: PendingYoutubeMediaUpdate): void {
|
||||
if (!job.generateAudio && !job.generateImage) {
|
||||
return;
|
||||
@@ -195,6 +206,8 @@ export class PendingYoutubeMediaQueue {
|
||||
'Failed to apply queued YouTube media update:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
this.settle(job);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,16 +247,24 @@ export class PendingYoutubeMediaQueue {
|
||||
'Failed to show queued YouTube media failure notification:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
this.settle(job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private settle(job: PendingYoutubeMediaUpdate): void {
|
||||
this.settlingNoteIds.delete(job.noteId);
|
||||
this.deps.onUpdateSettled?.(job.noteId);
|
||||
}
|
||||
|
||||
private takeMatchingUpdates(sourceUrl: string): PendingYoutubeMediaUpdate[] {
|
||||
const matched: PendingYoutubeMediaUpdate[] = [];
|
||||
const remaining: PendingYoutubeMediaUpdate[] = [];
|
||||
for (const job of this.updates) {
|
||||
if (youtubeMediaUrlsMatch(job.sourceUrl, sourceUrl)) {
|
||||
matched.push(job);
|
||||
this.settlingNoteIds.add(job.noteId);
|
||||
} else {
|
||||
remaining.push(job);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import test from 'node:test';
|
||||
|
||||
import { PollingRunner } from './polling';
|
||||
|
||||
test('polling runner records newly added cards after initialization', async () => {
|
||||
test('polling runner records new cards and reports each ready after enrichment', async () => {
|
||||
const originalDateNow = Date.now;
|
||||
const recordedCards: number[] = [];
|
||||
const events: string[] = [];
|
||||
let tracked = new Set<number>();
|
||||
const responses = [
|
||||
[10, 11],
|
||||
@@ -16,12 +17,17 @@ test('polling runner records newly added cards after initialization', async () =
|
||||
const runner = new PollingRunner({
|
||||
getDeck: () => 'Mining',
|
||||
getPollingRate: () => 250,
|
||||
findNotes: async () => responses.shift() ?? [],
|
||||
findNotes: async (query) => (query.includes('tag:') ? [] : (responses.shift() ?? [])),
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async () => undefined,
|
||||
processNewCard: async (noteId) => {
|
||||
events.push(`process ${noteId}`);
|
||||
},
|
||||
recordCardsAdded: (count) => {
|
||||
recordedCards.push(count);
|
||||
},
|
||||
onCardsReady: (noteIds) => {
|
||||
events.push(`ready ${noteIds.join(',')}`);
|
||||
},
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
getTrackedNoteIds: () => tracked,
|
||||
@@ -38,7 +44,132 @@ test('polling runner records newly added cards after initialization', async () =
|
||||
await runner.pollOnce();
|
||||
|
||||
assert.deepEqual(recordedCards, [2]);
|
||||
assert.deepEqual(events, ['process 12', 'ready 12', 'process 13', 'ready 13']);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('polling runner keeps going after one note fails to enrich', async () => {
|
||||
const originalDateNow = Date.now;
|
||||
const events: string[] = [];
|
||||
const warnings: unknown[][] = [];
|
||||
let tracked = new Set<number>();
|
||||
const responses = [[10], [10, 12, 13]];
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
const runner = new PollingRunner({
|
||||
getDeck: () => undefined,
|
||||
getPollingRate: () => 250,
|
||||
findNotes: async (query) => (query.includes('tag:') ? [] : (responses.shift() ?? [])),
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (noteId) => {
|
||||
events.push(`process ${noteId}`);
|
||||
if (noteId === 12) throw new Error('overlay callback failed');
|
||||
},
|
||||
onCardsReady: (noteIds) => {
|
||||
events.push(`ready ${noteIds.join(',')}`);
|
||||
},
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
getTrackedNoteIds: () => tracked,
|
||||
setTrackedNoteIds: (noteIds) => {
|
||||
tracked = noteIds;
|
||||
},
|
||||
showStatusNotification: () => undefined,
|
||||
logDebug: () => undefined,
|
||||
logInfo: () => undefined,
|
||||
logWarn: (...args) => {
|
||||
warnings.push(args);
|
||||
},
|
||||
});
|
||||
|
||||
await runner.pollOnce();
|
||||
await runner.pollOnce();
|
||||
|
||||
assert.deepEqual(events, ['process 12', 'ready 12', 'process 13', 'ready 13']);
|
||||
assert.deepEqual(warnings, [['Failed to auto-enrich note 12:', 'overlay callback failed']]);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
function createStatsHarness(autoUpdate: boolean, sourceLookupFails = false) {
|
||||
let currentIds: number[] = [];
|
||||
let tracked = new Set<number>();
|
||||
const recorded: Array<{ count: number; noteIds: number[] }> = [];
|
||||
const processed: number[] = [];
|
||||
const ready: number[][] = [];
|
||||
const warnings: unknown[][] = [];
|
||||
const queries: string[] = [];
|
||||
const runner = new PollingRunner({
|
||||
getDeck: () => 'Mining',
|
||||
getPollingRate: () => 250,
|
||||
findNotes: async (query) => {
|
||||
queries.push(query);
|
||||
if (!query.includes('tag:')) return currentIds;
|
||||
if (sourceLookupFails) throw new Error('source lookup failed');
|
||||
return [2];
|
||||
},
|
||||
shouldAutoUpdateNewCards: () => autoUpdate,
|
||||
processNewCard: async (noteId) => {
|
||||
processed.push(noteId);
|
||||
},
|
||||
recordCardsAdded: (count, noteIds) => {
|
||||
recorded.push({ count, noteIds });
|
||||
},
|
||||
onCardsReady: (noteIds) => {
|
||||
ready.push(noteIds);
|
||||
},
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => {},
|
||||
getTrackedNoteIds: () => tracked,
|
||||
setTrackedNoteIds: (noteIds) => {
|
||||
tracked = noteIds;
|
||||
},
|
||||
showStatusNotification: () => assert.fail('source lookup failures must not back off polling'),
|
||||
logDebug: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: (...args) => {
|
||||
warnings.push(args);
|
||||
},
|
||||
});
|
||||
return {
|
||||
recorded,
|
||||
processed,
|
||||
ready,
|
||||
warnings,
|
||||
queries,
|
||||
poll: async (noteIds: number[]) => {
|
||||
currentIds = noteIds;
|
||||
await runner.pollOnce();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const autoUpdate of [true, false]) {
|
||||
test(`stats cards count without triggering plugins when auto-update is ${autoUpdate}`, async () => {
|
||||
const h = createStatsHarness(autoUpdate);
|
||||
await h.poll([]);
|
||||
await h.poll([1, 2]);
|
||||
assert.deepEqual(h.recorded, [{ count: 2, noteIds: [1, 2] }]);
|
||||
assert.deepEqual(h.processed, autoUpdate ? [1, 2] : []);
|
||||
assert.deepEqual(h.ready, [[1]]);
|
||||
assert.ok(h.queries.includes('nid:1,2 tag:"SubMiner::Stats"'));
|
||||
assert.deepEqual(h.warnings, []);
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed source lookup skips plugin events without stopping counting or enrichment', async () => {
|
||||
const h = createStatsHarness(true, true);
|
||||
await h.poll([]);
|
||||
await h.poll([1, 2]);
|
||||
await h.poll([1, 2, 3]);
|
||||
assert.deepEqual(h.processed, [1, 2, 3]);
|
||||
assert.deepEqual(h.recorded, [
|
||||
{ count: 2, noteIds: [1, 2] },
|
||||
{ count: 1, noteIds: [3] },
|
||||
]);
|
||||
assert.deepEqual(h.ready, []);
|
||||
assert.equal(h.warnings.length, 2);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { STATS_MINING_TAG } from '../shared/anki-source';
|
||||
|
||||
export interface PollingRunnerDeps {
|
||||
getDeck: () => string | undefined;
|
||||
getPollingRate: () => number;
|
||||
@@ -10,6 +12,8 @@ export interface PollingRunnerDeps {
|
||||
shouldAutoUpdateNewCards: () => boolean;
|
||||
processNewCard: (noteId: number) => Promise<void>;
|
||||
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
||||
/** Called once SubMiner is done with a new note: after enrichment, or at detection when it is off. */
|
||||
onCardsReady?: (noteIds: number[]) => void;
|
||||
isUpdateInProgress: () => boolean;
|
||||
setUpdateInProgress: (value: boolean) => void;
|
||||
getTrackedNoteIds: () => Set<number>;
|
||||
@@ -83,11 +87,39 @@ export class PollingRunner {
|
||||
this.deps.setTrackedNoteIds(previousNoteIds);
|
||||
this.deps.recordCardsAdded?.(newNoteIds.length, newNoteIds);
|
||||
|
||||
// Stats cards still count and update known words, but do not trigger plugins.
|
||||
const pluginNoteIds = new Set<number>();
|
||||
if (this.deps.onCardsReady) {
|
||||
try {
|
||||
const statsNoteIds = new Set(
|
||||
await this.deps.findNotes(`nid:${newNoteIds.join(',')} tag:"${STATS_MINING_TAG}"`, {
|
||||
maxRetries: 0,
|
||||
}),
|
||||
);
|
||||
for (const noteId of newNoteIds) {
|
||||
if (!statsNoteIds.has(noteId)) pluginNoteIds.add(noteId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.deps.logWarn(
|
||||
'Skipping plugin events because note sources could not be checked:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.deps.shouldAutoUpdateNewCards()) {
|
||||
for (const noteId of newNoteIds) {
|
||||
await this.deps.processNewCard(noteId);
|
||||
try {
|
||||
await this.deps.processNewCard(noteId);
|
||||
} catch (error) {
|
||||
// One failed note must not leave the rest of the batch unprocessed.
|
||||
this.deps.logWarn(`Failed to auto-enrich note ${noteId}:`, (error as Error).message);
|
||||
} finally {
|
||||
if (pluginNoteIds.has(noteId)) this.deps.onCardsReady?.([noteId]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (pluginNoteIds.size > 0) this.deps.onCardsReady?.([...pluginNoteIds]);
|
||||
this.deps.logInfo(
|
||||
'New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.',
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user