From ff012f04974652052adc16209b399b3a550a30eb Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 27 Oct 2026 20:04:34 -0700 Subject: [PATCH] feat(plugins): add Anki card-mined event, AnkiConnect invoke, anki-sync - Add ctx.anki.on('card-mined') fed by polling, proxy, and card creation once enrichment finishes - Add ctx.anki.invoke to send one AnkiConnect action to the effective url with no retries - Ship example anki-sync plugin that syncs to AnkiWeb after mining goes quiet or playback ends - Unload plugins that finish activating after unloadAll gave up on them - Document the Anki API and anki-sync plugin in docs-site and architecture docs --- assets/plugins/anki-sync/main.js | 61 ++++++++++++++++++ assets/plugins/anki-sync/plugin.json | 5 ++ changes/plugin-system.md | 3 +- docs-site/plugins.md | 19 ++++++ docs/architecture/README.md | 2 +- src/anki-integration.ts | 20 ++++++ .../anki-connect-proxy.test.ts | 11 +++- src/anki-integration/anki-connect-proxy.ts | 4 ++ src/anki-integration/card-creation.test.ts | 23 +++++++ src/anki-integration/card-creation.ts | 6 ++ src/anki-integration/polling.test.ts | 11 +++- src/anki-integration/polling.ts | 9 ++- src/core/services/plugins/plugin-host.test.ts | 62 +++++++++++++++++++ src/core/services/plugins/plugin-host.ts | 38 ++++++++++++ src/core/services/plugins/types.ts | 27 ++++++++ src/main.ts | 15 +++++ 16 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 assets/plugins/anki-sync/main.js create mode 100644 assets/plugins/anki-sync/plugin.json diff --git a/assets/plugins/anki-sync/main.js b/assets/plugins/anki-sync/main.js new file mode 100644 index 00000000..b39ab397 --- /dev/null +++ b/assets/plugins/anki-sync/main.js @@ -0,0 +1,61 @@ +// Runs AnkiConnect's `sync` (AnkiWeb) after you mine cards. A burst of mining becomes one sync: +// it waits until no card has been mined for `delayMs`, and syncs right away when playback ends. +// Also registers a `sync` action for a manual sync. + +const DEFAULT_SETTINGS = { + delayMs: 30_000, + syncOnEnd: true, + timeoutMs: 60_000, + notify: true, +}; + +const END_REASONS = new Set(['eof', 'stop', 'quit']); + +function activate(ctx) { + const settings = { ...DEFAULT_SETTINGS, ...ctx.settings }; + // Cards mined since the last sync started. + let dirty = false; + let syncing = false; + let cancelTimer = null; + + function schedule() { + cancelTimer?.(); + cancelTimer = ctx.after(settings.delayMs, flush); + } + + async function flush() { + cancelTimer?.(); + cancelTimer = null; + // A sync already running picks up new cards when it finishes. + if (!dirty || syncing) return; + dirty = false; + syncing = true; + try { + await ctx.anki.invoke('sync', {}, { timeoutMs: settings.timeoutMs }); + ctx.log.info('Synced Anki.'); + if (settings.notify) ctx.notify('Anki synced'); + } catch (error) { + ctx.log.warn(`Anki sync failed: ${error.message}`); + ctx.notify(`Anki sync failed: ${error.message}`, { variant: 'error' }); + } finally { + syncing = false; + if (dirty) schedule(); + } + } + + ctx.anki.on('card-mined', () => { + dirty = true; + schedule(); + }); + + ctx.mpv.on('end-file', ({ reason }) => { + if (settings.syncOnEnd && END_REASONS.has(reason)) return flush(); + }); + + ctx.actions.register('sync', () => { + dirty = true; + return flush(); + }); +} + +module.exports = { activate }; diff --git a/assets/plugins/anki-sync/plugin.json b/assets/plugins/anki-sync/plugin.json new file mode 100644 index 00000000..d0eeaf4f --- /dev/null +++ b/assets/plugins/anki-sync/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "Anki Sync", + "version": "1.0.0", + "main": "main.js" +} diff --git a/changes/plugin-system.md b/changes/plugin-system.md index 7beea8fa..09d724af 100644 --- a/changes/plugin-system.md +++ b/changes/plugin-system.md @@ -1,5 +1,6 @@ type: added area: plugins -- Added JavaScript plugins loaded at startup from `/plugins//` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:/` keybindings or run with `--plugin-action`, run built-in actions, start mpv, run programs, keep state in a per-plugin data folder, and show results in an overlay panel or their own window. +- Added JavaScript plugins loaded at startup from `/plugins//` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:/` keybindings or run with `--plugin-action`, run built-in actions, react to mined Anki cards and call AnkiConnect, start mpv, run programs, keep state in a per-plugin data folder, and show results in an overlay panel or their own window. - Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation. +- Added an example `anki-sync` plugin that syncs Anki to AnkiWeb after you mine cards, once mining goes quiet or playback ends. diff --git a/docs-site/plugins.md b/docs-site/plugins.md index 880bd6fe..60cb6e72 100644 --- a/docs-site/plugins.md +++ b/docs-site/plugins.md @@ -59,6 +59,19 @@ The repository ships [`assets/plugins/ai-explain`](https://github.com/ksyasuda/S Press `Esc` to close the panel. Closing it, or starting another request, stops the running Codex process. +## Example: sync Anki after mining + +[`assets/plugins/anki-sync`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/anki-sync) runs AnkiConnect's `sync` after you mine cards, so they reach AnkiWeb without opening Anki. It waits until you stop mining for a while, so a burst of cards becomes one sync. It also adds a `sync` action you can bind to a key. + +| Setting | Default | What it does | +| ----------- | ------- | ------------------------------------------------------- | +| `delayMs` | `30000` | Sync after no card has been mined for this long | +| `syncOnEnd` | `true` | Sync right away when playback ends or mpv quits | +| `timeoutMs` | `60000` | Give up on a sync after this long | +| `notify` | `true` | Show a notification after each sync. Failures always do | + +Anki must be running and logged in to AnkiWeb. Cards mined from the stats dashboard don't trigger a sync. + ## Write a plugin A plugin folder needs a `plugin.json`: @@ -97,6 +110,8 @@ An error thrown by a plugin is logged and does not crash SubMiner. A plugin that | `mpv.getProperty(name)` | Read an mpv property (Promise) | | `mpv.connected` | Whether mpv is connected | | `mpv.ensureRunning()` | Connect to mpv, starting an idle mpv if none is running. Resolves `false` if that fails | +| `anki.on(event, fn)` | Listen to Anki events (below). Returns an unsubscribe function | +| `anki.invoke(action, params, options)` | Send an AnkiConnect action to `ankiConnect.url`, e.g. `('sync')`. Resolves its result (below) | | `subtitles.current()` | The displayed line as `{ text, startTime, endTime }`, or `null` | | `subtitles.context({ before, after })` | `{ current, before, after }`. Neighbors come from the loaded subtitle file, or recent lines for streams | | `actions.register(name, fn)` | Add an action, bindable as `__plugin-action:/` | @@ -114,3 +129,7 @@ A panel's `signal` aborts when the user closes it or another panel replaces it. `ui.openWindow` takes `html` and an optional `preload`, both relative to the plugin folder, plus `title`, `width`, and `height`. Opening the same `html` again focuses the open window. The page runs with context isolation and no Node access. The preload is sandboxed too: it can `require('electron')` but nothing else, so bundle anything more into it. Expose what the page needs from the preload with `contextBridge`, and handle its messages with `ipcMain` from `require('electron')` in the plugin. Prefix channel names with the plugin id. Plugin windows close when SubMiner quits. mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, `client-message`, and `end-file`. `end-file` gives `{ reason, playlistEntryId, error }`, where `reason` is `eof`, `stop`, `quit`, `error`, `redirect`, or `unknown`. + +Anki events: `card-mined` gives `{ noteIds }` once SubMiner has finished filling in a mined card (sentence, audio, image). With `ankiConnect.behavior.autoUpdateNewCards` off, it fires as soon as polling sees the card, and not at all for cards added through the proxy. If field grouping merged the card into an older one, the id may no longer exist. + +`anki.invoke` takes `{ timeoutMs }` (default 10 seconds), rejects on an Anki error, and never retries, so an action like `sync` or `addNote` runs at most once. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 5b813e2c..7605876f 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -37,7 +37,7 @@ Update checks and startup launcher migration share a serialized update-state sto - `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps. - `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry. - `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes. -- `src/core/services/plugins/` owns user plugins: discovery from `/plugins//plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them on `before-quit`. Plugins run in the main process with no sandbox. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:/`, CLI `--plugin-action /`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `/plugin-data//` as `ctx.dataDir`. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open. +- `src/core/services/plugins/` owns user plugins: discovery from `/plugins//plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them on `before-quit`. Plugins run in the main process with no sandbox. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:/`, CLI `--plugin-action /`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `/plugin-data//` as `ctx.dataDir`. `ctx.anki` carries the `card-mined` event, fed by `AnkiIntegration.setCardsReadyCallback`: polling, the AnkiConnect proxy, and sentence-card creation report a note once its enrichment finishes (or when they see it, if auto-update is off), which is later than `recordCardsMined` (stats counts at add time). Stats-dashboard mining does not emit it. `ctx.anki.invoke` sends one action to the effective `ankiConnect.url` with no retries. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open. - `src/renderer/` owns overlay rendering and input behavior. - `src/config/` owns config definitions, defaults, loading, and resolution. - `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel. diff --git a/src/anki-integration.ts b/src/anki-integration.ts index cb2327c6..18281a7a 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -240,6 +240,7 @@ export class AnkiIntegration { private runtime: AnkiIntegrationRuntime; private aiConfig: AiConfig; private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null; + private cardsReadyCallback: ((noteIds: number[]) => void) | null = null; private knownWordCacheUpdatedCallback: (() => void) | null = null; private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null; private generateSentenceFuriganaCallback: @@ -336,6 +337,14 @@ export class AnkiIntegration { } } + private reportCardsReady(noteIds: number[]): void { + try { + this.cardsReadyCallback?.(noteIds); + } catch (error) { + log.warn('cardsReady callback failed:', (error as Error).message); + } + } + private getMediaResolverOptions(): MediaGenerationInputResolverOptions { const options: MediaGenerationInputResolverOptions = { logDebug: (message) => log.debug(message), @@ -444,6 +453,7 @@ export class AnkiIntegration { recordCardsAdded: (count, noteIds) => { this.recordCardsMinedSafely(count, noteIds, 'polling'); }, + onCardsReady: (noteIds) => this.reportCardsReady(noteIds), isUpdateInProgress: () => this.updateInProgress, setUpdateInProgress: (value) => { this.updateInProgress = value; @@ -468,6 +478,7 @@ export class AnkiIntegration { recordCardsAdded: (count, noteIds) => { this.recordCardsMinedSafely(count, noteIds, 'proxy'); }, + onCardsReady: (noteIds) => this.reportCardsReady(noteIds), trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => { this.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds); }, @@ -593,6 +604,7 @@ export class AnkiIntegration { recordCardsMinedCallback: (count, noteIds) => { this.recordCardsMinedSafely(count, noteIds, 'card creation'); }, + onCardsReady: (noteIds) => this.reportCardsReady(noteIds), reviewMediaTiming: (request) => this.reviewMediaTiming(request), }); } @@ -1780,6 +1792,14 @@ export class AnkiIntegration { this.recordCardsMinedCallback = callback; } + /** + * Called once SubMiner has finished writing a mined card (fields and media), or as soon as polling + * sees it when auto-update is off. Field grouping may have merged the note away by then. + */ + setCardsReadyCallback(callback: ((noteIds: number[]) => void) | null): void { + this.cardsReadyCallback = callback; + } + setKnownWordCacheUpdatedCallback(callback: (() => void) | null): void { this.knownWordCacheUpdatedCallback = callback; } diff --git a/src/anki-integration/anki-connect-proxy.test.ts b/src/anki-integration/anki-connect-proxy.test.ts index 8f811fae..9e506653 100644 --- a/src/anki-integration/anki-connect-proxy.test.ts +++ b/src/anki-integration/anki-connect-proxy.test.ts @@ -17,17 +17,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 +48,10 @@ 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 enqueues addNote bare numeric response for enrichment', async () => { diff --git a/src/anki-integration/anki-connect-proxy.ts b/src/anki-integration/anki-connect-proxy.ts index 91c25c1d..933d6361 100644 --- a/src/anki-integration/anki-connect-proxy.ts +++ b/src/anki-integration/anki-connect-proxy.ts @@ -16,6 +16,8 @@ export interface AnkiConnectProxyServerDeps { shouldAutoUpdateNewCards: () => boolean; processNewCard: (noteId: number) => Promise; 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?: ( @@ -534,6 +536,7 @@ export class AnkiConnectProxyServer { this.pendingNoteIdSet.delete(noteId); if (!this.deps.shouldAutoUpdateNewCards()) { + this.deps.onCardsReady?.([noteId]); continue; } @@ -547,6 +550,7 @@ export class AnkiConnectProxyServer { ); } finally { this.inFlightNoteIds.delete(noteId); + this.deps.onCardsReady?.([noteId]); } } } finally { diff --git a/src/anki-integration/card-creation.test.ts b/src/anki-integration/card-creation.test.ts index cf2d4002..c979c663 100644 --- a/src/anki-integration/card-creation.test.ts +++ b/src/anki-integration/card-creation.test.ts @@ -31,6 +31,29 @@ test('CardCreationService counts locally created sentence cards', async () => { assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]); }); +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; diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 5917b292..97c71969 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -152,6 +152,8 @@ interface CardCreationDeps { trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void; findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise; recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void; + /** Called once a created card has all its fields and media written. */ + onCardsReady?: (noteIds: number[]) => void; reviewMediaTiming?: ( request: Omit, ) => Promise; @@ -608,6 +610,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 @@ -720,6 +723,7 @@ export class CardCreationService { fields, this.getConfiguredAnkiTags(), ); + createdNoteId = noteId; log.info('Created sentence card:', noteId); } catch (error) { log.error('Failed to create sentence card:', (error as Error).message); @@ -884,6 +888,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]); } } diff --git a/src/anki-integration/polling.test.ts b/src/anki-integration/polling.test.ts index 550ba2bf..50e64209 100644 --- a/src/anki-integration/polling.test.ts +++ b/src/anki-integration/polling.test.ts @@ -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(); const responses = [ [10, 11], @@ -18,10 +19,15 @@ test('polling runner records newly added cards after initialization', async () = getPollingRate: () => 250, findNotes: async () => 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,6 +44,7 @@ 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; } diff --git a/src/anki-integration/polling.ts b/src/anki-integration/polling.ts index 32e14e3e..2f822dad 100644 --- a/src/anki-integration/polling.ts +++ b/src/anki-integration/polling.ts @@ -10,6 +10,8 @@ export interface PollingRunnerDeps { shouldAutoUpdateNewCards: () => boolean; processNewCard: (noteId: number) => Promise; 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; @@ -85,9 +87,14 @@ export class PollingRunner { if (this.deps.shouldAutoUpdateNewCards()) { for (const noteId of newNoteIds) { - await this.deps.processNewCard(noteId); + try { + await this.deps.processNewCard(noteId); + } finally { + this.deps.onCardsReady?.([noteId]); + } } } else { + this.deps.onCardsReady?.(newNoteIds); this.deps.logInfo( 'New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.', ); diff --git a/src/core/services/plugins/plugin-host.test.ts b/src/core/services/plugins/plugin-host.test.ts index d00f2230..f2a532c9 100644 --- a/src/core/services/plugins/plugin-host.test.ts +++ b/src/core/services/plugins/plugin-host.test.ts @@ -67,6 +67,7 @@ function createHarness( sendMpvCommand: () => {}, requestMpvProperty: async () => null, ensureMpvRunning: async () => true, + invokeAnkiConnect: async () => null, dispatchSessionAction: async (request) => { sessionActions.push(request); }, @@ -303,6 +304,31 @@ test('unload waits for an in-progress load so late plugins are unloaded too', as } }); +test('a plugin that finishes activating after unload gave up unloads itself', async () => { + const h = createHarness({ slow: enabled() }, { deactivateTimeoutMs: 20 }); + const client = createFakeMpvClient(); + try { + writePlugin(h.root, 'slow', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + 'main.cjs': `exports.activate = async (ctx) => { + await new Promise((resolve) => setTimeout(resolve, 60)); + ctx.mpv.on('pause-change', () => ctx.notify('still listening')); + return () => ctx.notify('deactivated'); + };`, + }); + h.host.attachMpvClient(client); + + const loading = h.host.loadAll(); + await h.host.unloadAll(); + await loading; + client.emit('pause-change', { paused: true }); + + assert.deepEqual(h.notifications, ['deactivated']); + } finally { + h.cleanup(); + } +}); + test('runAction waits for a load that is still in progress', async () => { const h = createHarness({ slow: enabled() }); try { @@ -346,6 +372,42 @@ test('each plugin gets its own data folder, created before activate', async () = } }); +test('anki card-mined reaches listeners until unload, and invoke forwards to AnkiConnect', async () => { + const invoked: unknown[][] = []; + const h = createHarness( + { syncer: enabled() }, + { + invokeAnkiConnect: async (...args) => { + invoked.push(args); + return 'synced'; + }, + }, + ); + try { + writePlugin(h.root, 'syncer', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + 'main.cjs': `exports.activate = (ctx) => { + ctx.anki.on('card-mined', async ({ noteIds }) => { + ctx.notify(noteIds.join(',') + ' ' + (await ctx.anki.invoke('sync', undefined, { timeoutMs: 5 }))); + }); + };`, + }); + await h.host.loadAll(); + + h.host.emitAnkiEvent('card-mined', { noteIds: [1, 2] }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(h.notifications, ['1,2 synced']); + assert.deepEqual(invoked, [['sync', {}, { timeoutMs: 5 }]]); + + await h.host.unloadAll(); + h.host.emitAnkiEvent('card-mined', { noteIds: [3] }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(h.notifications.length, 1); + } finally { + h.cleanup(); + } +}); + test('actions.run forwards built-in session actions', async () => { const h = createHarness({ caller: enabled() }); try { diff --git a/src/core/services/plugins/plugin-host.ts b/src/core/services/plugins/plugin-host.ts index 5bbc13a3..aba43d78 100644 --- a/src/core/services/plugins/plugin-host.ts +++ b/src/core/services/plugins/plugin-host.ts @@ -9,6 +9,9 @@ import { execPluginCommand } from './exec'; import { isValidPluginName, parsePluginManifest, resolvePluginFilePath } from './manifest'; import { PLUGIN_MPV_EVENT_NAMES, + type PluginAnkiEventMap, + type PluginAnkiEventName, + type PluginAnkiInvokeOptions, type PluginConfigEntry, type PluginContext, type PluginLogger, @@ -46,6 +49,11 @@ export interface PluginHostDeps { sendMpvCommand: (command: (string | number)[]) => void; requestMpvProperty: (name: string) => Promise; ensureMpvRunning: () => Promise; + invokeAnkiConnect: ( + action: string, + params: Record, + options: PluginAnkiInvokeOptions, + ) => Promise; dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise; getCurrentSubtitle: () => PluginSubtitleLine | null; getSubtitleContext: (options: { before?: number; after?: number }) => PluginSubtitleContext; @@ -114,6 +122,8 @@ function pickExport( export function createPluginHost(deps: PluginHostDeps) { const mpvEvents = new EventEmitter(); mpvEvents.setMaxListeners(0); + const ankiEvents = new EventEmitter(); + ankiEvents.setMaxListeners(0); const plugins = new Map(); const actions = new Map unknown }>(); const loadModule = deps.loadModule ?? ((entryPath: string) => require(entryPath) as PluginModule); @@ -123,6 +133,8 @@ export function createPluginHost(deps: PluginHostDeps) { forwarders: Map void>; } | null = null; let loadPromise: Promise | null = null; + // Set once unloadAll has picked the plugins to unload; anything that activates later cleans up itself. + let unloaded = false; function runGuarded(log: PluginLogger, label: string, callback: () => unknown): void { try { @@ -196,6 +208,18 @@ export function createPluginHost(deps: PluginHostDeps) { getProperty: (name) => deps.requestMpvProperty(name), ensureRunning: () => deps.ensureMpvRunning(), }, + anki: { + on: (event, listener) => { + const wrapped = (payload: unknown): void => + runGuarded(log, `anki ${event} listener`, () => + listener(payload as Parameters[0]), + ); + ankiEvents.on(event, wrapped); + return track(() => ankiEvents.off(event, wrapped)); + }, + invoke: (action, params = {}, options = {}) => + deps.invokeAnkiConnect(action, params, options), + }, subtitles: { current: () => deps.getCurrentSubtitle(), context: (options = {}) => deps.getSubtitleContext(options), @@ -318,6 +342,7 @@ export function createPluginHost(deps: PluginHostDeps) { deactivate: null, lifetime: new AbortController(), }; + if (unloaded) return; plugins.set(id, plugin); try { const mod = loadModule(entryPath); @@ -332,6 +357,13 @@ export function createPluginHost(deps: PluginHostDeps) { typeof returned === 'function' ? (returned as () => unknown) : pickExport(mod, 'deactivate'); + if (unloaded) { + // activate outlasted unloadAll's wait, so unloadAll skipped or only half-disposed it. + log.warn('Activated after unload started; unloading it now.'); + runGuarded(log, 'deactivate', () => plugin.deactivate?.()); + disposePlugin(plugin); + return; + } const version = parsed.manifest.version ? ` v${parsed.manifest.version}` : ''; log.info(`Loaded ${parsed.manifest.name}${version}.`); } catch (error) { @@ -359,6 +391,11 @@ export function createPluginHost(deps: PluginHostDeps) { attachedClient = { client, forwarders }; }, + /** Delivers an Anki event to every plugin listening for it. */ + emitAnkiEvent(event: E, payload: PluginAnkiEventMap[E]): void { + ankiEvents.emit(event, payload); + }, + /** Loads every enabled plugin once; later calls return the first load. */ loadAll(): Promise { loadPromise ??= Promise.all( @@ -384,6 +421,7 @@ export function createPluginHost(deps: PluginHostDeps) { } } loadPromise = null; + unloaded = true; await Promise.all( [...plugins.values()].map(async (plugin) => { try { diff --git a/src/core/services/plugins/types.ts b/src/core/services/plugins/types.ts index ffd096a2..2aeca0d9 100644 --- a/src/core/services/plugins/types.ts +++ b/src/core/services/plugins/types.ts @@ -42,6 +42,21 @@ export const PLUGIN_MPV_EVENT_NAMES = [ 'end-file', ] as const satisfies readonly PluginMpvEventName[]; +/** Anki events plugins may observe. */ +export interface PluginAnkiEventMap { + /** + * SubMiner finished writing a mined card, or polling saw it while auto-update is off. + * Field grouping may have merged the note into another one by the time this fires. + */ + 'card-mined': { noteIds: number[] }; +} +export type PluginAnkiEventName = keyof PluginAnkiEventMap; + +export interface PluginAnkiInvokeOptions { + /** Request timeout; AnkiConnect's default is 10 seconds. */ + timeoutMs?: number; +} + export interface PluginSubtitleLine { text: string; /** Seconds on the subtitle file's timeline; null when mpv did not report timing. */ @@ -150,6 +165,18 @@ export interface PluginContext { /** Connects to mpv, launching an idle mpv if none is running. Resolves false when that fails. */ ensureRunning: () => Promise; }; + readonly anki: { + on: ( + event: E, + listener: (payload: PluginAnkiEventMap[E]) => unknown, + ) => Disposer; + /** Sends one AnkiConnect action to the configured `ankiConnect.url`. Rejects on an Anki error. */ + invoke: ( + action: string, + params?: Record, + options?: PluginAnkiInvokeOptions, + ) => Promise; + }; readonly subtitles: { current: () => PluginSubtitleLine | null; context: (options?: { before?: number; after?: number }) => PluginSubtitleContext; diff --git a/src/main.ts b/src/main.ts index 83088bbc..be85e580 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3151,6 +3151,18 @@ const pluginHost = createPluginHost({ return await appState.mpvClient.requestProperty(name); }, ensureMpvRunning: () => ensureMpvConnectedForPlayback(), + invokeAnkiConnect: (action, params, options) => { + const ankiConfig = configService.getConfig().ankiConnect; + const url = ( + appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(ankiConfig) ?? ankiConfig + ).url; + if (!url) return Promise.reject(new Error('ankiConnect.url is not configured.')); + // No retries: a retried `sync` or `addNote` could run twice. + return new AnkiConnectClient(url).invoke(action, params, { + timeout: options.timeoutMs, + maxRetries: 0, + }); + }, dispatchSessionAction: (request) => dispatchSessionAction(request), getCurrentSubtitle: () => getPluginCurrentSubtitle(), getSubtitleContext: (options) => @@ -5503,6 +5515,9 @@ function initializeOverlayRuntime(): void { overlayModalRuntime.primeModalWindow(); } appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); + appState.ankiIntegration?.setCardsReadyCallback((noteIds) => + pluginHost.emitAnkiEvent('card-mined', { noteIds }), + ); appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations); appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext); appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);