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
+309 -7

No files matched your search

@@ -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 {