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
@@ -49,3 +49,10 @@ test('known word deck rename selection keeps current deck on collision', () => {
'Core',
);
});
test('Anki deck autofill uses inferred Yomitan deck only for untouched empty values', () => {
assert.equal(ankiControls.chooseAnkiDeckAutofillValue('', 'Mining', false), 'Mining');
assert.equal(ankiControls.chooseAnkiDeckAutofillValue('Current', 'Mining', false), null);
assert.equal(ankiControls.chooseAnkiDeckAutofillValue('', 'Mining', true), null);
assert.equal(ankiControls.chooseAnkiDeckAutofillValue('', ' ', false), null);
});
+95
View File
@@ -17,6 +17,11 @@ const state: {
modelFieldNames: Map<string, string[]>;
modelFieldNamesLoading: Set<string>;
modelFieldNamesErrors: Map<string, string>;
yomitanAnkiDeckName: string | null;
yomitanAnkiDeckNameLoading: boolean;
yomitanAnkiDeckNameError: string | null;
ankiDeckNameManuallySelected: boolean;
ankiDeckNameAutofilled: boolean;
noteFieldModelName: string;
ankiConnectUrl: string;
noteFieldModelNameManuallySelected: boolean;
@@ -35,6 +40,11 @@ const state: {
modelFieldNames: new Map(),
modelFieldNamesLoading: new Set(),
modelFieldNamesErrors: new Map(),
yomitanAnkiDeckName: null,
yomitanAnkiDeckNameLoading: false,
yomitanAnkiDeckNameError: null,
ankiDeckNameManuallySelected: false,
ankiDeckNameAutofilled: false,
noteFieldModelName: '',
ankiConnectUrl: '',
noteFieldModelNameManuallySelected: false,
@@ -49,6 +59,11 @@ export function configureAnkiControls(options: { requestRender: () => void }): v
export function initializeAnkiControls(_values: Record<string, ConfigSettingsSnapshotValue>): void {
state.noteFieldModelName = '';
state.noteFieldModelNameManuallySelected = false;
state.yomitanAnkiDeckName = null;
state.yomitanAnkiDeckNameLoading = false;
state.yomitanAnkiDeckNameError = null;
state.ankiDeckNameManuallySelected = false;
state.ankiDeckNameAutofilled = false;
}
export function selectPreferredNoteFieldModelName(
@@ -90,6 +105,16 @@ export function chooseKnownWordsDeckRenameValue(
return nextDeckName;
}
export function chooseAnkiDeckAutofillValue(
currentDeckName: string,
inferredDeckName: string,
manuallySelected: boolean,
): string | null {
const current = currentDeckName.trim();
const inferred = inferredDeckName.trim();
return !manuallySelected && current.length === 0 && inferred.length > 0 ? inferred : null;
}
function normalizeStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
@@ -195,6 +220,28 @@ async function loadAnkiDeckNames(draftUrl?: string): Promise<void> {
}
}
async function loadYomitanAnkiDeckName(): Promise<void> {
if (state.yomitanAnkiDeckName !== null || state.yomitanAnkiDeckNameLoading) return;
state.yomitanAnkiDeckNameLoading = true;
try {
const result = await window.configSettingsAPI.getYomitanAnkiDeckName();
if (result.ok) {
state.yomitanAnkiDeckName = result.value.trim();
state.yomitanAnkiDeckNameError = null;
} else {
state.yomitanAnkiDeckName = '';
state.yomitanAnkiDeckNameError = result.error ?? 'Failed to read Yomitan Anki deck.';
}
} catch (error) {
state.yomitanAnkiDeckName = '';
state.yomitanAnkiDeckNameError =
error instanceof Error ? error.message : 'Failed to read Yomitan Anki deck.';
} finally {
state.yomitanAnkiDeckNameLoading = false;
requestRender();
}
}
async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Promise<void> {
syncAnkiConnectUrl(draftUrl);
if (
@@ -409,6 +456,54 @@ export function renderAnkiNoteTypeInput(
return wrap;
}
export function renderAnkiDeckInput(
context: SettingsControlContext,
field: ConfigSettingsField,
): HTMLElement {
const draftUrl = getDraftAnkiConnectUrl(context);
void loadAnkiDeckNames(draftUrl);
void loadYomitanAnkiDeckName();
const currentValue = context.valueForField(field);
let current = typeof currentValue === 'string' ? currentValue.trim() : '';
const inferred = state.yomitanAnkiDeckName ?? '';
const autofillValue =
state.ankiDeckNameAutofilled === false
? chooseAnkiDeckAutofillValue(current, inferred, state.ankiDeckNameManuallySelected)
: null;
if (autofillValue !== null) {
state.ankiDeckNameAutofilled = true;
current = autofillValue;
context.updateDraft(field.configPath, autofillValue);
}
const select = createElement('select', 'config-input') as HTMLSelectElement;
addOption(select, '', state.deckNamesLoading ? 'Loading Decks...' : 'Select Deck');
for (const deckName of uniqueSorted([...(state.deckNames ?? []), current])) {
if (!deckName) continue;
addOption(select, deckName);
}
select.value = current;
select.addEventListener('change', () => {
state.ankiDeckNameManuallySelected = true;
state.ankiDeckNameAutofilled = true;
context.updateDraft(field.configPath, select.value);
});
const wrap = createElement('div', 'stacked-control');
wrap.append(select);
if (state.deckNamesError) {
const hint = createElement('div', 'control-hint error');
hint.textContent = state.deckNamesError;
wrap.append(hint);
} else if (state.yomitanAnkiDeckNameError && !state.yomitanAnkiDeckNameLoading) {
const hint = createElement('div', 'control-hint');
hint.textContent = state.yomitanAnkiDeckNameError;
wrap.append(hint);
}
return wrap;
}
export function renderAnkiFieldInput(
context: SettingsControlContext,
field: ConfigSettingsField,
+5
View File
@@ -3,6 +3,7 @@ import { toConfigDraftValue, toSettingsDisplayValue } from './settings-model';
import { parseOptionalNumberInputValue } from './input-values';
import {
configureAnkiControls,
renderAnkiDeckInput,
initializeAnkiControls,
renderAnkiFieldInput,
renderAnkiNoteTypeInput,
@@ -162,6 +163,10 @@ export function renderControl(
return renderKnownWordsDecksInput(context, field);
}
if (field.control === 'anki-deck') {
return renderAnkiDeckInput(context, field);
}
if (field.control === 'anki-note-type') {
return renderAnkiNoteTypeInput(context, field);
}