feat: add Anki deck dropdown with Yomitan auto-fill in settings (#95)

This commit is contained in:
2026-05-27 23:13:43 -07:00
committed by GitHub
parent 75f9b8a803
commit 8d0535f3ca
24 changed files with 415 additions and 9 deletions
@@ -60,6 +60,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and logging changes', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
config.ankiConnect.behavior.autoUpdateNewCards = false;
config.ankiConnect.deck = 'Mining';
config.ankiConnect.knownWords.highlightEnabled = true;
config.ankiConnect.knownWords.refreshMinutes = 90;
config.ankiConnect.knownWords.decks = { Anime: ['Mining'] };
@@ -100,6 +101,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
{
hotReloadFields: [
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.deck',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.decks',
@@ -123,6 +125,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
assert.deepEqual(ankiPatches, [
{
deck: 'Mining',
behavior: { autoUpdateNewCards: false },
knownWords: config.ankiConnect.knownWords,
nPlusOne: config.ankiConnect.nPlusOne,
@@ -86,6 +86,9 @@ function buildAnkiRuntimeConfigPatch(
if (diff.hotReloadFields.includes('ankiConnect.behavior.autoUpdateNewCards')) {
patch.behavior = { autoUpdateNewCards: config.ankiConnect.behavior.autoUpdateNewCards };
}
if (diff.hotReloadFields.includes('ankiConnect.deck')) {
patch.deck = config.ankiConnect.deck;
}
if (hasAnyHotReloadField(diff, ['ankiConnect.knownWords'])) {
patch.knownWords = config.ankiConnect.knownWords;
}
@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { createConfigSettingsRuntime } from './config-settings-runtime';
test('config settings runtime exposes inferred Yomitan Anki deck lookup', async () => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
const runtime = createConfigSettingsRuntime({
fields: [],
getConfigPath: () => '/tmp/config.jsonc',
getRawConfig: () => ({}),
getConfig: () => deepCloneConfig(DEFAULT_CONFIG),
getWarnings: () => [],
reloadConfigStrict: () =>
({
ok: true,
config: deepCloneConfig(DEFAULT_CONFIG),
warnings: [],
path: '/tmp/config.jsonc',
}) as never,
getSettingsWindow: () => null,
setSettingsWindow: () => undefined,
createSettingsWindow: () => ({}) as never,
settingsHtmlPath: '/tmp/settings.html',
openPath: async () => '',
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: () =>
({
deckNames: async () => [],
fieldNamesForDeck: async () => [],
modelNamesForDeck: async () => [],
modelNames: async () => [],
modelFieldNames: async () => [],
}) as never,
getYomitanAnkiDeckName: async () => 'Mining',
ipcMain: {
handle: (channel, listener) => {
handlers.set(channel, listener);
},
},
ipcChannels: IPC_CHANNELS.request,
});
runtime.registerHandlers();
const handler = handlers.get(IPC_CHANNELS.request.getConfigSettingsYomitanAnkiDeckName);
assert.ok(handler);
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Mining' });
});
@@ -3,6 +3,7 @@ import path from 'node:path';
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
import type {
ConfigSettingsAnkiDeckResult,
ConfigSettingsAnkiListResult,
ConfigSettingsField,
ConfigSettingsSaveResult,
@@ -34,6 +35,7 @@ export interface ConfigSettingsIpcChannels {
getConfigSettingsAnkiDeckModelNames: string;
getConfigSettingsAnkiModelNames: string;
getConfigSettingsAnkiModelFieldNames: string;
getConfigSettingsYomitanAnkiDeckName: string;
}
export interface ConfigSettingsAnkiClient {
@@ -60,6 +62,7 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
openPath(path: string): Promise<string>;
defaultAnkiConnectUrl: string;
createAnkiClient(url: string): ConfigSettingsAnkiClient;
getYomitanAnkiDeckName?: () => Promise<string | null | undefined>;
ipcMain: ConfigSettingsIpcMainLike;
ipcChannels: ConfigSettingsIpcChannels;
log?: (message: string) => void;
@@ -190,6 +193,22 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
};
}
async function getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult> {
if (!deps.getYomitanAnkiDeckName) {
return { ok: true, value: '' };
}
try {
const value = await deps.getYomitanAnkiDeckName();
return { ok: true, value: typeof value === 'string' ? value.trim() : '' };
} catch (error) {
return {
ok: false,
value: '',
error: error instanceof Error ? error.message : 'Failed to query Yomitan.',
};
}
}
function registerHandlers(): void {
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
@@ -236,6 +255,9 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
: invalidAnkiListResult('Note type is required.');
},
);
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsYomitanAnkiDeckName, () =>
getYomitanAnkiDeckName(),
);
}
return {