fix(anki): keep overlay progress visible through card updates (#218)

This commit is contained in:
2026-08-25 20:27:58 -07:00
committed by GitHub
parent 556de61756
commit c2c25c0da6
12 changed files with 127 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept the Anki card update spinner visible until audio and image updates finish.
+78
View File
@@ -11,10 +11,12 @@ import type { MediaInput } from './media-input';
import { AnkiConnectConfig } from './types';
type TestOverlayNotificationPayload = {
id?: string;
title: string;
body?: string;
image?: string;
variant?: string;
persistent?: boolean;
actions?: Array<{ id: string; label: string; noteId?: number }>;
};
@@ -1182,6 +1184,82 @@ test('AnkiIntegration embeds generated notification image on overlay mined-card
assert.deepEqual(cleanupPaths, [notificationIconPath]);
});
test('AnkiIntegration keeps overlay card-update progress visible until the terminal notification', async () => {
const overlayNotifications: TestOverlayNotificationPayload[] = [];
const integration = new AnkiIntegration(
{
behavior: {
notificationType: 'overlay',
},
},
{} as never,
{} as never,
undefined,
undefined,
undefined,
undefined,
{},
undefined,
(payload) => {
overlayNotifications.push(payload);
},
);
const updateNotifications = integration as unknown as {
beginUpdateProgress: (message: string) => void;
showNotification: (noteId: number, label: string | number) => Promise<void>;
};
updateNotifications.beginUpdateProgress('Updating card');
await updateNotifications.showNotification(42, '食べる');
assert.deepEqual(
overlayNotifications.map(({ id, variant, persistent }) => ({ id, variant, persistent })),
[
{ id: 'anki-update-progress', variant: 'progress', persistent: true },
{ id: 'anki-update-progress', variant: 'success', persistent: false },
],
);
});
test('AnkiIntegration dismisses persistent overlay update progress when no terminal notification replaces it', () => {
const overlayNotifications: TestOverlayNotificationPayload[] = [];
const dismissedIds: string[] = [];
const integration = new AnkiIntegration(
{
behavior: {
notificationType: 'overlay',
},
},
{} as never,
{} as never,
undefined,
undefined,
undefined,
undefined,
{},
undefined,
(payload) => {
overlayNotifications.push(payload);
},
undefined,
undefined,
undefined,
(id) => {
dismissedIds.push(id);
},
);
const updateNotifications = integration as unknown as {
beginUpdateProgress: (message: string) => void;
endUpdateProgress: () => void;
};
updateNotifications.beginUpdateProgress('Updating card');
updateNotifications.endUpdateProgress();
assert.equal(overlayNotifications[0]?.persistent, true);
assert.deepEqual(dismissedIds, ['anki-update-progress']);
});
test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => {
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
const overlayNotifications: TestOverlayNotificationPayload[] = [];
+14 -2
View File
@@ -218,6 +218,8 @@ export class AnkiIntegration {
null;
private overlayNotificationCallback: ((payload: OverlayNotificationPayload) => void) | null =
null;
private overlayNotificationDismissCallback: ((id: string) => void) | null = null;
private overlayUpdateProgressActive = false;
private updateInProgress = false;
private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
private parseWarningKeys = new Set<string>();
@@ -265,6 +267,7 @@ export class AnkiIntegration {
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
shouldRequireRemoteMediaCache?: () => boolean,
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined,
overlayNotificationDismissCallback?: (id: string) => void,
) {
this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig };
@@ -280,6 +283,7 @@ export class AnkiIntegration {
this.getCachedMediaPath = getCachedMediaPath ?? null;
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
this.getYoutubeMediaSourceUrl = getYoutubeMediaSourceUrl ?? null;
this.overlayNotificationDismissCallback = overlayNotificationDismissCallback ?? null;
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
this.pollingRunner = this.createPollingRunner();
@@ -1203,12 +1207,13 @@ export class AnkiIntegration {
private beginUpdateProgress(initialMessage: string): void {
if (!this.shouldUseOsdNotifications()) {
if (this.shouldUseOverlayNotifications()) {
this.overlayUpdateProgressActive = true;
this.overlayNotificationCallback?.({
id: 'anki-update-progress',
title: 'Anki update',
body: initialMessage,
variant: 'progress',
persistent: false,
persistent: true,
});
}
return;
@@ -1220,6 +1225,10 @@ export class AnkiIntegration {
private endUpdateProgress(): void {
if (!this.shouldUseOsdNotifications()) {
if (this.overlayUpdateProgressActive) {
this.overlayUpdateProgressActive = false;
this.overlayNotificationDismissCallback?.('anki-update-progress');
}
return;
}
endUpdateProgress(this.uiFeedbackState, (timer) => {
@@ -1243,18 +1252,20 @@ export class AnkiIntegration {
if (!this.shouldUseOsdNotifications()) {
this.updateInProgress = true;
if (this.shouldUseOverlayNotifications()) {
this.overlayUpdateProgressActive = true;
this.overlayNotificationCallback?.({
id: 'anki-update-progress',
title: 'Anki update',
body: initialMessage,
variant: 'progress',
persistent: false,
persistent: true,
});
}
try {
return await action();
} finally {
this.updateInProgress = false;
this.endUpdateProgress();
}
}
return withUpdateProgress(
@@ -1353,6 +1364,7 @@ export class AnkiIntegration {
: undefined;
if (shouldShowOverlayNotification && this.overlayNotificationCallback) {
this.overlayUpdateProgressActive = false;
this.overlayNotificationCallback({
id: 'anki-update-progress',
title: 'Anki Card Updated',
+2
View File
@@ -65,6 +65,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -166,6 +167,7 @@ export function registerAnkiJimakuIpcRuntime(
options.getCachedMediaPath,
options.shouldRequireRemoteMediaCache,
options.getYoutubeMediaSourceUrl,
options.dismissOverlayNotification,
);
integration.start();
options.setAnkiIntegration(integration);
@@ -21,6 +21,7 @@ type CreateAnkiIntegrationArgs = {
mpvClient: { send?: (payload: { command: string[] }) => void };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -74,6 +75,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
args.getCachedMediaPath,
args.shouldRequireRemoteMediaCache,
args.getYoutubeMediaSourceUrl,
args.dismissOverlayNotification,
);
}
@@ -137,6 +139,7 @@ export function initializeOverlayRuntime(
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -177,6 +180,7 @@ export function initializeOverlayAnkiIntegration(options: {
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -219,6 +223,7 @@ export function initializeOverlayAnkiIntegration(options: {
mpvClient,
showDesktopNotification: options.showDesktopNotification,
showOverlayNotification: options.showOverlayNotification,
dismissOverlayNotification: options.dismissOverlayNotification,
createFieldGroupingCallback: options.createFieldGroupingCallback,
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}),
+4
View File
@@ -5925,6 +5925,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
showDesktopNotification,
showOverlayNotification: (payload) =>
overlayNotificationsRuntime.showOverlayNotification(payload),
dismissOverlayNotification: (id) =>
overlayNotificationsRuntime.dismissOverlayNotification(id),
createFieldGroupingCallback: () => createFieldGroupingCallback(),
broadcastRuntimeOptionsChanged: () =>
overlayVisibilityComposer.broadcastRuntimeOptionsChanged(),
@@ -6415,6 +6417,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
showDesktopNotification,
showOverlayNotification: (payload) =>
overlayNotificationsRuntime.showOverlayNotification(payload),
dismissOverlayNotification: (id) =>
overlayNotificationsRuntime.dismissOverlayNotification(id),
createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
getCachedMediaPath: (currentVideoPath, kind) =>
+2
View File
@@ -132,6 +132,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl'];
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged'];
getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver'];
@@ -334,6 +335,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
: {}),
showDesktopNotification: params.showDesktopNotification,
showOverlayNotification: params.showOverlayNotification,
dismissOverlayNotification: params.dismissOverlayNotification,
createFieldGroupingCallback: params.createFieldGroupingCallback,
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
getFieldGroupingResolver: params.getFieldGroupingResolver,
@@ -26,6 +26,7 @@ type InitializeOverlayRuntimeCore = (options: {
} | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -33,6 +33,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
getOverlayWindows: () => [],
getResolvedConfig: () => ({}),
showDesktopNotification: () => calls.push('notify'),
showOverlayNotification: () => calls.push('show-overlay'),
dismissOverlayNotification: () => calls.push('dismiss-overlay'),
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
@@ -57,6 +59,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
deps.refreshCurrentSubtitle?.();
deps.syncOverlayShortcuts();
deps.showDesktopNotification('title', {});
deps.showOverlayNotification?.({ title: 'title' });
deps.dismissOverlayNotification?.('notification-id');
const tracker = {
close: () => {},
@@ -73,6 +77,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
'refresh-subtitle',
'sync-shortcuts',
'notify',
'show-overlay',
'dismiss-overlay',
]);
assert.equal(appState.windowTracker, tracker);
assert.deepEqual(appState.ankiIntegration, { id: 'anki' });
@@ -39,6 +39,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
@@ -78,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
},
showDesktopNotification: deps.showDesktopNotification,
showOverlayNotification: deps.showOverlayNotification,
dismissOverlayNotification: deps.dismissOverlayNotification,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
@@ -22,6 +22,8 @@ test('build initialize overlay runtime options maps dependencies', () => {
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => calls.push('set-anki'),
showDesktopNotification: () => calls.push('notify'),
showOverlayNotification: () => calls.push('show-overlay'),
dismissOverlayNotification: () => calls.push('dismiss-overlay'),
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
@@ -47,6 +49,8 @@ test('build initialize overlay runtime options maps dependencies', () => {
options.setWindowTracker(null);
options.setAnkiIntegration(null);
options.showDesktopNotification('title', {});
options.showOverlayNotification?.({ title: 'title' });
options.dismissOverlayNotification?.('notification-id');
assert.deepEqual(calls, [
'create-main',
@@ -58,5 +62,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
'set-tracker',
'set-anki',
'notify',
'show-overlay',
'dismiss-overlay',
]);
});
@@ -33,6 +33,7 @@ type OverlayRuntimeOptions = {
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -73,6 +74,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -107,6 +109,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
setAnkiIntegration: deps.setAnkiIntegration,
showDesktopNotification: deps.showDesktopNotification,
showOverlayNotification: deps.showOverlayNotification,
dismissOverlayNotification: deps.dismissOverlayNotification,
createFieldGroupingCallback: deps.createFieldGroupingCallback,
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),