fix(plugins): delay card-mined until field grouping and YouTube media

- Hold a sentence card's ready report until field grouping finishes
- Report cards waiting on queued YouTube media once it is applied or the download fails
- Proxy reports added notes ready right away when auto-update is off
- Polling keeps enriching the rest of a batch after one note fails
- Update plugin and architecture docs
This commit is contained in:
sudacode committed 2026-10-27 21:35:57 -07:00
1 parent ff012f0497
commit d2ab808bc3
10 files changed
+210 -24

No files matched your search

@@ -54,6 +54,40 @@ test('proxy enqueues addNote result for enrichment and reports it ready afterwar
assert.deepEqual(events, ['process 42', 'ready 42']);
});
test('proxy reports an added note ready right away when auto-update is off', () => {
const processed: number[] = [];
const recordedCards: number[] = [];
const ready: number[][] = [];
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => false,
processNewCard: async (noteId) => {
processed.push(noteId);
},
recordCardsAdded: (count) => {
recordedCards.push(count);
},
onCardsReady: (noteIds) => {
ready.push(noteIds);
},
logInfo: () => undefined,
logWarn: () => undefined,
logError: () => undefined,
});
(
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
}
).maybeEnqueueFromRequest(
{ action: 'addNote' },
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
);
assert.deepEqual(ready, [[42]]);
assert.deepEqual(processed, []);
assert.deepEqual(recordedCards, []);
});
test('proxy enqueues addNote bare numeric response for enrichment', async () => {
const processed: number[] = [];
const proxy = new AnkiConnectProxyServer({
+9 -2
View File
@@ -218,15 +218,17 @@ export class AnkiConnectProxyServer {
requestJson: Record<string, unknown> | null,
responseBody: Buffer,
): void {
if (!requestJson || !this.deps.shouldAutoUpdateNewCards()) {
if (!requestJson) {
return;
}
const autoUpdate = this.deps.shouldAutoUpdateNewCards();
const action =
typeof requestJson.action === 'string'
? requestJson.action
: String(requestJson.action ?? '');
if (action === 'updateNoteFields') {
if (!autoUpdate) return;
const params = requestJson.params;
if (
!params ||
@@ -279,7 +281,12 @@ export class AnkiConnectProxyServer {
params.subminerEnrich === false
) {
// Stats owns the saved sentence and media; the live mpv context is unrelated.
if (noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds);
if (autoUpdate && noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds);
return;
}
if (!autoUpdate) {
// Nothing to enrich, so the added notes are already in their final state.
if (noteIds.length > 0) this.deps.onCardsReady?.(noteIds);
return;
}
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
+1 -1
View File
@@ -152,7 +152,7 @@ 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. */
/** Called once card creation is done writing a card; queued YouTube media may still be pending. */
onCardsReady?: (noteIds: number[]) => void;
reviewMediaTiming?: (
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
@@ -56,6 +56,8 @@ export interface PendingYoutubeMediaQueueDeps {
) => string;
showStatusNotification: (message: string) => void;
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>;
/** Called once a queued note's media was applied or dropped, so the card is in its final state. */
onUpdateSettled?: (noteId: number) => void;
logInfo: (message: string, ...args: unknown[]) => void;
logWarn: (message: string, ...args: unknown[]) => void;
logError: (message: string, ...args: unknown[]) => void;
@@ -79,9 +81,18 @@ function shouldGenerateImage(config: AnkiConnectConfig): boolean {
export class PendingYoutubeMediaQueue {
private updates: PendingYoutubeMediaUpdate[] = [];
// Jobs taken off the queue whose media is still being applied or dropped.
private settlingNoteIds = new Set<number>();
constructor(private readonly deps: PendingYoutubeMediaQueueDeps) {}
/** True while the note still waits on queued media. */
hasPendingNote(noteId: number): boolean {
return (
this.settlingNoteIds.has(noteId) || this.updates.some((update) => update.noteId === noteId)
);
}
enqueue(job: PendingYoutubeMediaUpdate): void {
if (!job.generateAudio && !job.generateImage) {
return;
@@ -195,6 +206,8 @@ export class PendingYoutubeMediaQueue {
'Failed to apply queued YouTube media update:',
error instanceof Error ? error.message : String(error),
);
} finally {
this.settle(job);
}
}
@@ -234,16 +247,24 @@ export class PendingYoutubeMediaQueue {
'Failed to show queued YouTube media failure notification:',
error instanceof Error ? error.message : String(error),
);
} finally {
this.settle(job);
}
}
}
private settle(job: PendingYoutubeMediaUpdate): void {
this.settlingNoteIds.delete(job.noteId);
this.deps.onUpdateSettled?.(job.noteId);
}
private takeMatchingUpdates(sourceUrl: string): PendingYoutubeMediaUpdate[] {
const matched: PendingYoutubeMediaUpdate[] = [];
const remaining: PendingYoutubeMediaUpdate[] = [];
for (const job of this.updates) {
if (youtubeMediaUrlsMatch(job.sourceUrl, sourceUrl)) {
matched.push(job);
this.settlingNoteIds.add(job.noteId);
} else {
remaining.push(job);
}
+44
View File
@@ -49,3 +49,47 @@ test('polling runner records new cards and reports each ready after enrichment',
Date.now = originalDateNow;
}
});
test('polling runner keeps going after one note fails to enrich', async () => {
const originalDateNow = Date.now;
const events: string[] = [];
const warnings: unknown[][] = [];
let tracked = new Set<number>();
const responses = [[10], [10, 12, 13]];
try {
Date.now = () => 120_000;
const runner = new PollingRunner({
getDeck: () => undefined,
getPollingRate: () => 250,
findNotes: async () => responses.shift() ?? [],
shouldAutoUpdateNewCards: () => true,
processNewCard: async (noteId) => {
events.push(`process ${noteId}`);
if (noteId === 12) throw new Error('overlay callback failed');
},
onCardsReady: (noteIds) => {
events.push(`ready ${noteIds.join(',')}`);
},
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
getTrackedNoteIds: () => tracked,
setTrackedNoteIds: (noteIds) => {
tracked = noteIds;
},
showStatusNotification: () => undefined,
logDebug: () => undefined,
logInfo: () => undefined,
logWarn: (...args) => {
warnings.push(args);
},
});
await runner.pollOnce();
await runner.pollOnce();
assert.deepEqual(events, ['process 12', 'ready 12', 'process 13', 'ready 13']);
assert.deepEqual(warnings, [['Failed to auto-enrich note 12:', 'overlay callback failed']]);
} finally {
Date.now = originalDateNow;
}
});
+3
View File
@@ -89,6 +89,9 @@ export class PollingRunner {
for (const noteId of newNoteIds) {
try {
await this.deps.processNewCard(noteId);
} catch (error) {
// One failed note must not leave the rest of the batch unprocessed.
this.deps.logWarn(`Failed to auto-enrich note ${noteId}:`, (error as Error).message);
} finally {
this.deps.onCardsReady?.([noteId]);
}