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
This commit is contained in:
sudacode committed 2026-10-27 20:04:34 -07:00
1 parent 4bb85f19bc
commit ff012f0497
16 files changed
+308 -6

No files matched your search

+61
View File
@@ -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 };
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Anki Sync",
"version": "1.0.0",
"main": "main.js"
}
+2 -1
View File
@@ -1,5 +1,6 @@
type: added type: added
area: plugins area: plugins
- Added JavaScript plugins loaded at startup from `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<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 `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<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 `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.
+19
View File
@@ -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. 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 ## Write a plugin
A plugin folder needs a `plugin.json`: 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.getProperty(name)` | Read an mpv property (Promise) |
| `mpv.connected` | Whether mpv is connected | | `mpv.connected` | Whether mpv is connected |
| `mpv.ensureRunning()` | Connect to mpv, starting an idle mpv if none is running. Resolves `false` if that fails | | `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.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 | | `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:<id>/<name>` | | `actions.register(name, fn)` | Add an action, bindable as `__plugin-action:<id>/<name>` |
@@ -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. `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`. 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.
+1 -1
View File
@@ -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. - `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/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/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 `<config dir>/plugins/<id>/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:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` 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 `<config dir>/plugins/<id>/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:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` 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/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution. - `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. - `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
+20
View File
@@ -240,6 +240,7 @@ export class AnkiIntegration {
private runtime: AnkiIntegrationRuntime; private runtime: AnkiIntegrationRuntime;
private aiConfig: AiConfig; private aiConfig: AiConfig;
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null; private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
private cardsReadyCallback: ((noteIds: number[]) => void) | null = null;
private knownWordCacheUpdatedCallback: (() => void) | null = null; private knownWordCacheUpdatedCallback: (() => void) | null = null;
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null; private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
private generateSentenceFuriganaCallback: 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 { private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
const options: MediaGenerationInputResolverOptions = { const options: MediaGenerationInputResolverOptions = {
logDebug: (message) => log.debug(message), logDebug: (message) => log.debug(message),
@@ -444,6 +453,7 @@ export class AnkiIntegration {
recordCardsAdded: (count, noteIds) => { recordCardsAdded: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'polling'); this.recordCardsMinedSafely(count, noteIds, 'polling');
}, },
onCardsReady: (noteIds) => this.reportCardsReady(noteIds),
isUpdateInProgress: () => this.updateInProgress, isUpdateInProgress: () => this.updateInProgress,
setUpdateInProgress: (value) => { setUpdateInProgress: (value) => {
this.updateInProgress = value; this.updateInProgress = value;
@@ -468,6 +478,7 @@ export class AnkiIntegration {
recordCardsAdded: (count, noteIds) => { recordCardsAdded: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'proxy'); this.recordCardsMinedSafely(count, noteIds, 'proxy');
}, },
onCardsReady: (noteIds) => this.reportCardsReady(noteIds),
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => { trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
this.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds); this.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
}, },
@@ -593,6 +604,7 @@ export class AnkiIntegration {
recordCardsMinedCallback: (count, noteIds) => { recordCardsMinedCallback: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'card creation'); this.recordCardsMinedSafely(count, noteIds, 'card creation');
}, },
onCardsReady: (noteIds) => this.reportCardsReady(noteIds),
reviewMediaTiming: (request) => this.reviewMediaTiming(request), reviewMediaTiming: (request) => this.reviewMediaTiming(request),
}); });
} }
@@ -1780,6 +1792,14 @@ export class AnkiIntegration {
this.recordCardsMinedCallback = callback; 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 { setKnownWordCacheUpdatedCallback(callback: (() => void) | null): void {
this.knownWordCacheUpdatedCallback = callback; this.knownWordCacheUpdatedCallback = callback;
} }
@@ -17,17 +17,23 @@ async function waitForCondition(
throw new Error('Timed out waiting for condition'); 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 processed: number[] = [];
const recordedCards: number[] = []; const recordedCards: number[] = [];
const events: string[] = [];
const proxy = new AnkiConnectProxyServer({ const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true, shouldAutoUpdateNewCards: () => true,
processNewCard: async (noteId) => { processNewCard: async (noteId) => {
processed.push(noteId); processed.push(noteId);
events.push(`process ${noteId}`);
throw new Error('enrichment failed');
}, },
recordCardsAdded: (count) => { recordCardsAdded: (count) => {
recordedCards.push(count); recordedCards.push(count);
}, },
onCardsReady: (noteIds) => {
events.push(`ready ${noteIds.join(',')}`);
},
logInfo: () => undefined, logInfo: () => undefined,
logWarn: () => undefined, logWarn: () => undefined,
logError: () => undefined, logError: () => undefined,
@@ -42,9 +48,10 @@ test('proxy enqueues addNote result for enrichment', async () => {
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'), 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(processed, [42]);
assert.deepEqual(recordedCards, [1]); assert.deepEqual(recordedCards, [1]);
assert.deepEqual(events, ['process 42', 'ready 42']);
}); });
test('proxy enqueues addNote bare numeric response for enrichment', async () => { test('proxy enqueues addNote bare numeric response for enrichment', async () => {
@@ -16,6 +16,8 @@ export interface AnkiConnectProxyServerDeps {
shouldAutoUpdateNewCards: () => boolean; shouldAutoUpdateNewCards: () => boolean;
processNewCard: (noteId: number) => Promise<void>; processNewCard: (noteId: number) => Promise<void>;
recordCardsAdded?: (count: number, noteIds: number[]) => 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; trackAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
getDeck?: () => string | undefined; getDeck?: () => string | undefined;
findNotes?: ( findNotes?: (
@@ -534,6 +536,7 @@ export class AnkiConnectProxyServer {
this.pendingNoteIdSet.delete(noteId); this.pendingNoteIdSet.delete(noteId);
if (!this.deps.shouldAutoUpdateNewCards()) { if (!this.deps.shouldAutoUpdateNewCards()) {
this.deps.onCardsReady?.([noteId]);
continue; continue;
} }
@@ -547,6 +550,7 @@ export class AnkiConnectProxyServer {
); );
} finally { } finally {
this.inFlightNoteIds.delete(noteId); this.inFlightNoteIds.delete(noteId);
this.deps.onCardsReady?.([noteId]);
} }
} }
} finally { } finally {
@@ -31,6 +31,29 @@ test('CardCreationService counts locally created sentence cards', async () => {
assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]); 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<{ const THROWING_HOOKS: Array<{
name: string; name: string;
hook: Partial<CardCreationDeps>; hook: Partial<CardCreationDeps>;
+6
View File
@@ -152,6 +152,8 @@ interface CardCreationDeps {
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void; trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>; findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void; recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
/** Called once a created card has all its fields and media written. */
onCardsReady?: (noteIds: number[]) => void;
reviewMediaTiming?: ( reviewMediaTiming?: (
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>, request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
) => Promise<MediaTimingReviewDecision>; ) => Promise<MediaTimingReviewDecision>;
@@ -608,6 +610,7 @@ export class CardCreationService {
endTime = cappedEndTime; endTime = cappedEndTime;
} }
let createdNoteId: number | null = null;
try { try {
return await this.deps.withUpdateProgress('Creating sentence card', async () => { return await this.deps.withUpdateProgress('Creating sentence card', async () => {
const timingDecision = this.deps.reviewMediaTiming const timingDecision = this.deps.reviewMediaTiming
@@ -720,6 +723,7 @@ export class CardCreationService {
fields, fields,
this.getConfiguredAnkiTags(), this.getConfiguredAnkiTags(),
); );
createdNoteId = noteId;
log.info('Created sentence card:', noteId); log.info('Created sentence card:', noteId);
} catch (error) { } catch (error) {
log.error('Failed to create sentence card:', (error as Error).message); 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); log.error('Error creating sentence card:', (error as Error).message);
this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false); this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false);
return false; return false;
} finally {
if (createdNoteId !== null) this.deps.onCardsReady?.([createdNoteId]);
} }
} }
+9 -2
View File
@@ -3,9 +3,10 @@ import test from 'node:test';
import { PollingRunner } from './polling'; 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 originalDateNow = Date.now;
const recordedCards: number[] = []; const recordedCards: number[] = [];
const events: string[] = [];
let tracked = new Set<number>(); let tracked = new Set<number>();
const responses = [ const responses = [
[10, 11], [10, 11],
@@ -18,10 +19,15 @@ test('polling runner records newly added cards after initialization', async () =
getPollingRate: () => 250, getPollingRate: () => 250,
findNotes: async () => responses.shift() ?? [], findNotes: async () => responses.shift() ?? [],
shouldAutoUpdateNewCards: () => true, shouldAutoUpdateNewCards: () => true,
processNewCard: async () => undefined, processNewCard: async (noteId) => {
events.push(`process ${noteId}`);
},
recordCardsAdded: (count) => { recordCardsAdded: (count) => {
recordedCards.push(count); recordedCards.push(count);
}, },
onCardsReady: (noteIds) => {
events.push(`ready ${noteIds.join(',')}`);
},
isUpdateInProgress: () => false, isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined, setUpdateInProgress: () => undefined,
getTrackedNoteIds: () => tracked, getTrackedNoteIds: () => tracked,
@@ -38,6 +44,7 @@ test('polling runner records newly added cards after initialization', async () =
await runner.pollOnce(); await runner.pollOnce();
assert.deepEqual(recordedCards, [2]); assert.deepEqual(recordedCards, [2]);
assert.deepEqual(events, ['process 12', 'ready 12', 'process 13', 'ready 13']);
} finally { } finally {
Date.now = originalDateNow; Date.now = originalDateNow;
} }
+7
View File
@@ -10,6 +10,8 @@ export interface PollingRunnerDeps {
shouldAutoUpdateNewCards: () => boolean; shouldAutoUpdateNewCards: () => boolean;
processNewCard: (noteId: number) => Promise<void>; processNewCard: (noteId: number) => Promise<void>;
recordCardsAdded?: (count: number, noteIds: number[]) => 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; isUpdateInProgress: () => boolean;
setUpdateInProgress: (value: boolean) => void; setUpdateInProgress: (value: boolean) => void;
getTrackedNoteIds: () => Set<number>; getTrackedNoteIds: () => Set<number>;
@@ -85,9 +87,14 @@ export class PollingRunner {
if (this.deps.shouldAutoUpdateNewCards()) { if (this.deps.shouldAutoUpdateNewCards()) {
for (const noteId of newNoteIds) { for (const noteId of newNoteIds) {
try {
await this.deps.processNewCard(noteId); await this.deps.processNewCard(noteId);
} finally {
this.deps.onCardsReady?.([noteId]);
}
} }
} else { } else {
this.deps.onCardsReady?.(newNoteIds);
this.deps.logInfo( this.deps.logInfo(
'New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.', 'New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.',
); );
@@ -67,6 +67,7 @@ function createHarness(
sendMpvCommand: () => {}, sendMpvCommand: () => {},
requestMpvProperty: async () => null, requestMpvProperty: async () => null,
ensureMpvRunning: async () => true, ensureMpvRunning: async () => true,
invokeAnkiConnect: async () => null,
dispatchSessionAction: async (request) => { dispatchSessionAction: async (request) => {
sessionActions.push(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 () => { test('runAction waits for a load that is still in progress', async () => {
const h = createHarness({ slow: enabled() }); const h = createHarness({ slow: enabled() });
try { 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 () => { test('actions.run forwards built-in session actions', async () => {
const h = createHarness({ caller: enabled() }); const h = createHarness({ caller: enabled() });
try { try {
+38
View File
@@ -9,6 +9,9 @@ import { execPluginCommand } from './exec';
import { isValidPluginName, parsePluginManifest, resolvePluginFilePath } from './manifest'; import { isValidPluginName, parsePluginManifest, resolvePluginFilePath } from './manifest';
import { import {
PLUGIN_MPV_EVENT_NAMES, PLUGIN_MPV_EVENT_NAMES,
type PluginAnkiEventMap,
type PluginAnkiEventName,
type PluginAnkiInvokeOptions,
type PluginConfigEntry, type PluginConfigEntry,
type PluginContext, type PluginContext,
type PluginLogger, type PluginLogger,
@@ -46,6 +49,11 @@ export interface PluginHostDeps<TUnstable> {
sendMpvCommand: (command: (string | number)[]) => void; sendMpvCommand: (command: (string | number)[]) => void;
requestMpvProperty: (name: string) => Promise<unknown>; requestMpvProperty: (name: string) => Promise<unknown>;
ensureMpvRunning: () => Promise<boolean>; ensureMpvRunning: () => Promise<boolean>;
invokeAnkiConnect: (
action: string,
params: Record<string, unknown>,
options: PluginAnkiInvokeOptions,
) => Promise<unknown>;
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>; dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
getCurrentSubtitle: () => PluginSubtitleLine | null; getCurrentSubtitle: () => PluginSubtitleLine | null;
getSubtitleContext: (options: { before?: number; after?: number }) => PluginSubtitleContext; getSubtitleContext: (options: { before?: number; after?: number }) => PluginSubtitleContext;
@@ -114,6 +122,8 @@ function pickExport<K extends 'activate' | 'deactivate'>(
export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) { export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
const mpvEvents = new EventEmitter(); const mpvEvents = new EventEmitter();
mpvEvents.setMaxListeners(0); mpvEvents.setMaxListeners(0);
const ankiEvents = new EventEmitter();
ankiEvents.setMaxListeners(0);
const plugins = new Map<string, LoadedPlugin>(); const plugins = new Map<string, LoadedPlugin>();
const actions = new Map<string, { pluginId: string; handler: () => unknown }>(); const actions = new Map<string, { pluginId: string; handler: () => unknown }>();
const loadModule = deps.loadModule ?? ((entryPath: string) => require(entryPath) as PluginModule); const loadModule = deps.loadModule ?? ((entryPath: string) => require(entryPath) as PluginModule);
@@ -123,6 +133,8 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
forwarders: Map<string, (p: unknown) => void>; forwarders: Map<string, (p: unknown) => void>;
} | null = null; } | null = null;
let loadPromise: Promise<void> | null = null; let loadPromise: Promise<void> | 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 { function runGuarded(log: PluginLogger, label: string, callback: () => unknown): void {
try { try {
@@ -196,6 +208,18 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
getProperty: (name) => deps.requestMpvProperty(name), getProperty: (name) => deps.requestMpvProperty(name),
ensureRunning: () => deps.ensureMpvRunning(), ensureRunning: () => deps.ensureMpvRunning(),
}, },
anki: {
on: (event, listener) => {
const wrapped = (payload: unknown): void =>
runGuarded(log, `anki ${event} listener`, () =>
listener(payload as Parameters<typeof listener>[0]),
);
ankiEvents.on(event, wrapped);
return track(() => ankiEvents.off(event, wrapped));
},
invoke: (action, params = {}, options = {}) =>
deps.invokeAnkiConnect(action, params, options),
},
subtitles: { subtitles: {
current: () => deps.getCurrentSubtitle(), current: () => deps.getCurrentSubtitle(),
context: (options = {}) => deps.getSubtitleContext(options), context: (options = {}) => deps.getSubtitleContext(options),
@@ -318,6 +342,7 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
deactivate: null, deactivate: null,
lifetime: new AbortController(), lifetime: new AbortController(),
}; };
if (unloaded) return;
plugins.set(id, plugin); plugins.set(id, plugin);
try { try {
const mod = loadModule(entryPath); const mod = loadModule(entryPath);
@@ -332,6 +357,13 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
typeof returned === 'function' typeof returned === 'function'
? (returned as () => unknown) ? (returned as () => unknown)
: pickExport(mod, 'deactivate'); : 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}` : ''; const version = parsed.manifest.version ? ` v${parsed.manifest.version}` : '';
log.info(`Loaded ${parsed.manifest.name}${version}.`); log.info(`Loaded ${parsed.manifest.name}${version}.`);
} catch (error) { } catch (error) {
@@ -359,6 +391,11 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
attachedClient = { client, forwarders }; attachedClient = { client, forwarders };
}, },
/** Delivers an Anki event to every plugin listening for it. */
emitAnkiEvent<E extends PluginAnkiEventName>(event: E, payload: PluginAnkiEventMap[E]): void {
ankiEvents.emit(event, payload);
},
/** Loads every enabled plugin once; later calls return the first load. */ /** Loads every enabled plugin once; later calls return the first load. */
loadAll(): Promise<void> { loadAll(): Promise<void> {
loadPromise ??= Promise.all( loadPromise ??= Promise.all(
@@ -384,6 +421,7 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
} }
} }
loadPromise = null; loadPromise = null;
unloaded = true;
await Promise.all( await Promise.all(
[...plugins.values()].map(async (plugin) => { [...plugins.values()].map(async (plugin) => {
try { try {
+27
View File
@@ -42,6 +42,21 @@ export const PLUGIN_MPV_EVENT_NAMES = [
'end-file', 'end-file',
] as const satisfies readonly PluginMpvEventName[]; ] 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 { export interface PluginSubtitleLine {
text: string; text: string;
/** Seconds on the subtitle file's timeline; null when mpv did not report timing. */ /** Seconds on the subtitle file's timeline; null when mpv did not report timing. */
@@ -150,6 +165,18 @@ export interface PluginContext<TUnstable = unknown> {
/** Connects to mpv, launching an idle mpv if none is running. Resolves false when that fails. */ /** Connects to mpv, launching an idle mpv if none is running. Resolves false when that fails. */
ensureRunning: () => Promise<boolean>; ensureRunning: () => Promise<boolean>;
}; };
readonly anki: {
on: <E extends PluginAnkiEventName>(
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<string, unknown>,
options?: PluginAnkiInvokeOptions,
) => Promise<unknown>;
};
readonly subtitles: { readonly subtitles: {
current: () => PluginSubtitleLine | null; current: () => PluginSubtitleLine | null;
context: (options?: { before?: number; after?: number }) => PluginSubtitleContext; context: (options?: { before?: number; after?: number }) => PluginSubtitleContext;
+15
View File
@@ -3151,6 +3151,18 @@ const pluginHost = createPluginHost({
return await appState.mpvClient.requestProperty(name); return await appState.mpvClient.requestProperty(name);
}, },
ensureMpvRunning: () => ensureMpvConnectedForPlayback(), 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), dispatchSessionAction: (request) => dispatchSessionAction(request),
getCurrentSubtitle: () => getPluginCurrentSubtitle(), getCurrentSubtitle: () => getPluginCurrentSubtitle(),
getSubtitleContext: (options) => getSubtitleContext: (options) =>
@@ -5503,6 +5515,9 @@ function initializeOverlayRuntime(): void {
overlayModalRuntime.primeModalWindow(); overlayModalRuntime.primeModalWindow();
} }
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
appState.ankiIntegration?.setCardsReadyCallback((noteIds) =>
pluginHost.emitAnkiEvent('card-mined', { noteIds }),
);
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations); appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext); appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview); appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);