mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 12:55:20 -07:00
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user