feat(config): reorganize settings window and move annotation colors to subtitleStyle

- Reorganize Configuration window into Appearance, Behavior, Anki, Input, and Integration sections
- Add AnkiConnect-backed deck, note-type, and field pickers in the Anki section
- Add click-to-learn keybinding controls
- Move known-word and N+1 highlight colors to subtitleStyle.knownWordColor / subtitleStyle.nPlusOneColor; legacy ankiConnect.knownWords.color and ankiConnect.nPlusOne.nPlusOne keys still accepted with deprecation warnings
- Add deckNames, modelNames, modelFieldNames, and fieldNamesForDeck methods to AnkiConnectClient
- Mark discordPresence.presenceStyle as an enum in the config registry
This commit is contained in:
2026-05-17 02:10:16 -07:00
parent e84674e3b5
commit a4314ab5de
44 changed files with 2152 additions and 321 deletions
+473
View File
@@ -0,0 +1,473 @@
import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings';
import type { SettingsControlContext } from './settings-control-context';
import { addOption, createElement, uniqueSorted } from './settings-control-dom';
const state: {
deckNames: string[] | null;
deckNamesLoading: boolean;
deckNamesError: string | null;
deckFieldNames: Map<string, string[]>;
deckFieldNamesLoading: Set<string>;
deckFieldNamesErrors: Map<string, string>;
modelNames: string[] | null;
modelNamesLoading: boolean;
modelNamesError: string | null;
modelFieldNames: Map<string, string[]>;
modelFieldNamesLoading: Set<string>;
modelFieldNamesErrors: Map<string, string>;
noteFieldModelName: string;
ankiConnectUrl: string;
} = {
deckNames: null,
deckNamesLoading: false,
deckNamesError: null,
deckFieldNames: new Map(),
deckFieldNamesLoading: new Set(),
deckFieldNamesErrors: new Map(),
modelNames: null,
modelNamesLoading: false,
modelNamesError: null,
modelFieldNames: new Map(),
modelFieldNamesLoading: new Set(),
modelFieldNamesErrors: new Map(),
noteFieldModelName: '',
ankiConnectUrl: '',
};
let requestRender = (): void => undefined;
export function configureAnkiControls(options: { requestRender: () => void }): void {
requestRender = options.requestRender;
}
export function initializeAnkiControls(values: Record<string, ConfigSettingsSnapshotValue>): void {
const configuredNoteType = values['ankiConnect.isLapis.sentenceCardModel'];
if (!state.noteFieldModelName && typeof configuredNoteType === 'string') {
state.noteFieldModelName = configuredNoteType;
}
}
function normalizeStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
: [];
}
function normalizeKnownWordsDecks(value: unknown): Record<string, string[]> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const decks: Record<string, string[]> = {};
for (const [deckName, fields] of Object.entries(value)) {
if (!deckName) continue;
decks[deckName] = normalizeStringArray(fields);
}
return decks;
}
function setKnownWordsDecks(
context: SettingsControlContext,
path: string,
decks: Record<string, string[]>,
): void {
const next: Record<string, string[]> = {};
for (const [deckName, fields] of Object.entries(decks)) {
if (!deckName) continue;
next[deckName] = uniqueSorted(fields);
}
context.updateDraft(path, next);
}
function getDraftAnkiConnectUrl(context: SettingsControlContext): string | undefined {
const value = context.valueForPath('ankiConnect.url');
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
function syncAnkiConnectUrl(draftUrl: string | undefined): void {
const nextUrl = draftUrl ?? '';
if (state.ankiConnectUrl === nextUrl) {
return;
}
state.ankiConnectUrl = nextUrl;
state.deckNames = null;
state.deckNamesLoading = false;
state.deckNamesError = null;
state.deckFieldNames.clear();
state.deckFieldNamesLoading.clear();
state.deckFieldNamesErrors.clear();
state.modelNames = null;
state.modelNamesLoading = false;
state.modelNamesError = null;
state.modelFieldNames.clear();
state.modelFieldNamesLoading.clear();
state.modelFieldNamesErrors.clear();
}
async function loadAnkiDeckNames(draftUrl?: string): Promise<void> {
syncAnkiConnectUrl(draftUrl);
if (state.deckNames || state.deckNamesLoading) return;
state.deckNamesLoading = true;
try {
const result = await window.configSettingsAPI.getAnkiDeckNames(draftUrl);
if (result.ok) {
state.deckNames = uniqueSorted(result.values);
state.deckNamesError = null;
} else {
state.deckNames = [];
state.deckNamesError = result.error ?? 'Failed to load Anki decks.';
}
} catch (error) {
state.deckNames = [];
state.deckNamesError = error instanceof Error ? error.message : 'Failed to load Anki decks.';
} finally {
state.deckNamesLoading = false;
requestRender();
}
}
async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Promise<void> {
syncAnkiConnectUrl(draftUrl);
if (
!deckName ||
state.deckFieldNames.has(deckName) ||
state.deckFieldNamesLoading.has(deckName)
) {
return;
}
state.deckFieldNamesLoading.add(deckName);
try {
const result = await window.configSettingsAPI.getAnkiDeckFieldNames(deckName, draftUrl);
if (result.ok) {
state.deckFieldNames.set(deckName, uniqueSorted(result.values));
state.deckFieldNamesErrors.delete(deckName);
} else {
state.deckFieldNames.set(deckName, []);
state.deckFieldNamesErrors.set(
deckName,
result.error ?? `Failed to load fields for ${deckName}.`,
);
}
} catch (error) {
state.deckFieldNames.set(deckName, []);
state.deckFieldNamesErrors.set(
deckName,
error instanceof Error ? error.message : `Failed to load fields for ${deckName}.`,
);
} finally {
state.deckFieldNamesLoading.delete(deckName);
requestRender();
}
}
async function loadAnkiModelNames(draftUrl?: string): Promise<void> {
syncAnkiConnectUrl(draftUrl);
if (state.modelNames || state.modelNamesLoading) return;
state.modelNamesLoading = true;
try {
const result = await window.configSettingsAPI.getAnkiModelNames(draftUrl);
if (result.ok) {
state.modelNames = uniqueSorted(result.values);
state.modelNamesError = null;
if (!state.noteFieldModelName && state.modelNames[0]) {
state.noteFieldModelName = state.modelNames[0];
}
} else {
state.modelNames = [];
state.modelNamesError = result.error ?? 'Failed to load Anki note types.';
}
} catch (error) {
state.modelNames = [];
state.modelNamesError =
error instanceof Error ? error.message : 'Failed to load Anki note types.';
} finally {
state.modelNamesLoading = false;
requestRender();
}
}
async function loadAnkiModelFieldNames(modelName: string, draftUrl?: string): Promise<void> {
syncAnkiConnectUrl(draftUrl);
if (
!modelName ||
state.modelFieldNames.has(modelName) ||
state.modelFieldNamesLoading.has(modelName)
) {
return;
}
state.modelFieldNamesLoading.add(modelName);
try {
const result = await window.configSettingsAPI.getAnkiModelFieldNames(modelName, draftUrl);
if (result.ok) {
state.modelFieldNames.set(modelName, uniqueSorted(result.values));
state.modelFieldNamesErrors.delete(modelName);
} else {
state.modelFieldNames.set(modelName, []);
state.modelFieldNamesErrors.set(
modelName,
result.error ?? `Failed to load fields for ${modelName}.`,
);
}
} catch (error) {
state.modelFieldNames.set(modelName, []);
state.modelFieldNamesErrors.set(
modelName,
error instanceof Error ? error.message : `Failed to load fields for ${modelName}.`,
);
} finally {
state.modelFieldNamesLoading.delete(modelName);
requestRender();
}
}
export function renderAnkiNoteTypeInput(
context: SettingsControlContext,
field: ConfigSettingsField,
): HTMLElement {
const draftUrl = getDraftAnkiConnectUrl(context);
void loadAnkiModelNames(draftUrl);
const currentValue = context.valueForField(field);
const current = typeof currentValue === 'string' ? currentValue : '';
const select = createElement('select', 'config-input') as HTMLSelectElement;
const modelNames = uniqueSorted([...(state.modelNames ?? []), current]);
if (state.modelNamesLoading && modelNames.length === 0) {
addOption(select, current, 'Loading Note Types...');
}
for (const modelName of modelNames) {
addOption(select, modelName);
}
select.value = current;
select.addEventListener('change', () => context.updateDraft(field.configPath, select.value));
const wrap = createElement('div', 'stacked-control');
wrap.append(select);
if (state.modelNamesError) {
const hint = createElement('div', 'control-hint error');
hint.textContent = state.modelNamesError;
wrap.append(hint);
}
return wrap;
}
export function renderAnkiFieldInput(
context: SettingsControlContext,
field: ConfigSettingsField,
): HTMLElement {
const draftUrl = getDraftAnkiConnectUrl(context);
void loadAnkiModelNames(draftUrl);
if (state.noteFieldModelName) {
void loadAnkiModelFieldNames(state.noteFieldModelName, draftUrl);
}
const currentValue = context.valueForField(field);
const current = typeof currentValue === 'string' ? currentValue : '';
const availableFields = state.noteFieldModelName
? (state.modelFieldNames.get(state.noteFieldModelName) ?? [])
: [];
const select = createElement('select', 'config-input') as HTMLSelectElement;
if (!state.noteFieldModelName) {
addOption(select, current, 'Select Note Type First');
select.disabled = true;
} else if (state.modelFieldNamesLoading.has(state.noteFieldModelName)) {
addOption(select, current, current || 'Loading Fields...');
select.disabled = true;
} else {
for (const fieldName of uniqueSorted([...availableFields, current])) {
addOption(select, fieldName);
}
}
select.value = current;
select.addEventListener('change', () => context.updateDraft(field.configPath, select.value));
const wrap = createElement('div', 'stacked-control');
wrap.append(select);
const error = state.modelFieldNamesErrors.get(state.noteFieldModelName);
if (error) {
const hint = createElement('div', 'control-hint error');
hint.textContent = error;
wrap.append(hint);
}
return wrap;
}
export function renderNoteFieldModelPicker(context: SettingsControlContext): HTMLElement {
const draftUrl = getDraftAnkiConnectUrl(context);
void loadAnkiModelNames(draftUrl);
if (state.noteFieldModelName) {
void loadAnkiModelFieldNames(state.noteFieldModelName, draftUrl);
}
const row = createElement('article', 'field-row helper-row');
const copy = createElement('div', 'field-copy');
const title = createElement('h3');
title.textContent = 'Note Type';
const description = createElement('p');
description.textContent =
'Choose a note type from AnkiConnect to populate the field dropdowns below.';
copy.append(title, description);
const control = createElement('div', 'field-control');
const select = createElement('select', 'config-input') as HTMLSelectElement;
const modelNames = state.modelNames ?? [];
if (state.modelNamesLoading && modelNames.length === 0) {
addOption(select, '', 'Loading Note Types...');
} else {
for (const modelName of modelNames) {
addOption(select, modelName);
}
}
select.value = state.noteFieldModelName;
select.addEventListener('change', () => {
state.noteFieldModelName = select.value;
requestRender();
});
control.append(select);
row.append(copy, control);
return row;
}
export function renderKnownWordsDecksInput(
context: SettingsControlContext,
field: ConfigSettingsField,
): HTMLElement {
const draftUrl = getDraftAnkiConnectUrl(context);
void loadAnkiDeckNames(draftUrl);
const currentDecks = normalizeKnownWordsDecks(context.valueForField(field));
const deckNames = state.deckNames ?? [];
const container = createElement('div', 'deck-field-editor');
const entries = Object.entries(currentDecks).sort(([left], [right]) => left.localeCompare(right));
if (entries.length === 0) {
const empty = createElement('div', 'control-hint');
empty.textContent = state.deckNamesLoading
? 'Loading Anki decks...'
: 'No known-word decks configured.';
container.append(empty);
}
for (const [deckName, selectedFields] of entries) {
if (deckName) {
void loadAnkiDeckFieldNames(deckName, draftUrl);
}
const row = createElement('div', 'deck-field-row');
const deckSelect = createElement('select', 'config-input') as HTMLSelectElement;
for (const candidateDeck of uniqueSorted([...deckNames, deckName])) {
addOption(deckSelect, candidateDeck);
}
deckSelect.value = deckName;
deckSelect.addEventListener('change', () => {
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
const fields = nextDecks[deckName] ?? [];
delete nextDecks[deckName];
nextDecks[deckSelect.value] = fields;
setKnownWordsDecks(context, field.configPath, nextDecks);
requestRender();
});
const availableFields = deckName ? (state.deckFieldNames.get(deckName) ?? []) : [];
const fieldNames = uniqueSorted([...availableFields, ...selectedFields]);
const fieldsWrap = createElement('div', 'deck-field-fields');
const fieldActions = createElement('div', 'deck-field-actions');
const checkboxList = createElement('div', 'field-checkbox-list');
const setSelectedFields = (fields: string[]): void => {
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
nextDecks[deckName] = fields;
setKnownWordsDecks(context, field.configPath, nextDecks);
};
const selectAllButton = createElement(
'button',
'secondary-button compact-button',
) as HTMLButtonElement;
selectAllButton.type = 'button';
selectAllButton.textContent = 'Select All';
selectAllButton.disabled = fieldNames.length === 0;
selectAllButton.addEventListener('click', () => {
setSelectedFields(fieldNames);
requestRender();
});
const clearButton = createElement(
'button',
'secondary-button compact-button',
) as HTMLButtonElement;
clearButton.type = 'button';
clearButton.textContent = 'Clear';
clearButton.disabled = selectedFields.length === 0;
clearButton.addEventListener('click', () => {
setSelectedFields([]);
requestRender();
});
fieldActions.append(selectAllButton, clearButton);
fieldsWrap.append(fieldActions, checkboxList);
if (state.deckFieldNamesLoading.has(deckName)) {
const hint = createElement('div', 'control-hint');
hint.textContent = 'Loading Fields...';
checkboxList.append(hint);
} else if (fieldNames.length === 0) {
const hint = createElement('div', 'control-hint');
hint.textContent = deckName ? 'No fields found for this deck.' : 'Select A Deck First.';
checkboxList.append(hint);
}
for (const candidateField of fieldNames) {
const label = createElement('label', 'field-checkbox-row');
const checkbox = createElement('input') as HTMLInputElement;
checkbox.type = 'checkbox';
checkbox.value = candidateField;
checkbox.checked = selectedFields.includes(candidateField);
checkbox.addEventListener('change', () => {
const checkedFields = [
...checkboxList.querySelectorAll<HTMLInputElement>('input[type="checkbox"]:checked'),
].map((input) => input.value);
setSelectedFields(checkedFields);
});
const text = createElement('span');
text.textContent = candidateField;
label.append(checkbox, text);
checkboxList.append(label);
}
const removeButton = createElement('button', 'reset-button icon-button') as HTMLButtonElement;
removeButton.type = 'button';
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => {
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
delete nextDecks[deckName];
setKnownWordsDecks(context, field.configPath, nextDecks);
requestRender();
});
row.append(deckSelect, fieldsWrap, removeButton);
const error = state.deckFieldNamesErrors.get(deckName);
if (error) {
const hint = createElement('div', 'control-hint error');
hint.textContent = error;
row.append(hint);
}
container.append(row);
}
const addButton = createElement('button', 'secondary-button compact-button') as HTMLButtonElement;
addButton.type = 'button';
addButton.textContent = 'Add Deck';
addButton.disabled = deckNames.length === 0;
addButton.addEventListener('click', () => {
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
const nextDeckName = deckNames.find((deckName) => !Object.hasOwn(nextDecks, deckName));
if (!nextDeckName) return;
nextDecks[nextDeckName] = [];
setKnownWordsDecks(context, field.configPath, nextDecks);
requestRender();
});
container.append(addButton);
if (state.deckNamesError) {
const hint = createElement('div', 'control-hint error');
hint.textContent = state.deckNamesError;
container.append(hint);
}
return container;
}