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

@@ -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 () => {
@@ -16,6 +16,8 @@ export interface AnkiConnectProxyServerDeps {
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?: (
@@ -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 {
@@ -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<CardCreationDeps>;
+6
View File
@@ -152,6 +152,8 @@ interface CardCreationDeps {
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
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<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
) => Promise<MediaTimingReviewDecision>;
@@ -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]);
}
}
+9 -2
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],
@@ -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;
}
+8 -1
View File
@@ -10,6 +10,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>;
@@ -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.',
);