mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 23:35:17 -07:00
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:
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>;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user