feat(plugins): add JavaScript plugin system with overlay panel (#303)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-31 19:09:54 -07:00
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();
+27 -3
View File
@@ -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>;
+20 -1
View File
@@ -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);
}
+134 -3
View File
@@ -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);
});
+33 -1
View File
@@ -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.',
);