/*
* SubMiner - All-in-one sentence mining overlay
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
import {
AnkiConnectConfig,
RuntimeOptionApplyResult,
RuntimeOptionId,
RuntimeOptionState,
RuntimeOptionValue,
} from "./types";
import { RUNTIME_OPTION_REGISTRY, RuntimeOptionRegistryEntry } from "./config";
type RuntimeOverrides = Record;
function deepClone(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function getPathValue(source: Record, path: string): unknown {
const parts = path.split(".");
let current: unknown = source;
for (const part of parts) {
if (!current || typeof current !== "object" || Array.isArray(current)) {
return undefined;
}
current = (current as Record)[part];
}
return current;
}
function setPathValue(target: Record, path: string, value: unknown): void {
const parts = path.split(".");
let current = target;
for (let i = 0; i < parts.length; i += 1) {
const part = parts[i];
const isLeaf = i === parts.length - 1;
if (isLeaf) {
current[part] = value;
return;
}
const next = current[part];
if (!next || typeof next !== "object" || Array.isArray(next)) {
current[part] = {};
}
current = current[part] as Record;
}
}
function allowedValues(definition: RuntimeOptionRegistryEntry): RuntimeOptionValue[] {
return [...definition.allowedValues];
}
function isAllowedValue(
definition: RuntimeOptionRegistryEntry,
value: RuntimeOptionValue,
): boolean {
if (definition.valueType === "boolean") {
return typeof value === "boolean";
}
return typeof value === "string" && definition.allowedValues.includes(value);
}
export class RuntimeOptionsManager {
private readonly getAnkiConfig: () => AnkiConnectConfig;
private readonly applyAnkiPatch: (patch: Partial) => void;
private readonly onOptionsChanged: (options: RuntimeOptionState[]) => void;
private runtimeOverrides: RuntimeOverrides = {};
private readonly definitions = new Map();
constructor(
getAnkiConfig: () => AnkiConnectConfig,
callbacks: {
applyAnkiPatch: (patch: Partial) => void;
onOptionsChanged: (options: RuntimeOptionState[]) => void;
},
) {
this.getAnkiConfig = getAnkiConfig;
this.applyAnkiPatch = callbacks.applyAnkiPatch;
this.onOptionsChanged = callbacks.onOptionsChanged;
for (const definition of RUNTIME_OPTION_REGISTRY) {
this.definitions.set(definition.id, definition);
}
}
private getEffectiveValue(definition: RuntimeOptionRegistryEntry): RuntimeOptionValue {
const override = getPathValue(this.runtimeOverrides, definition.path);
if (override !== undefined) return override as RuntimeOptionValue;
const source = {
ankiConnect: this.getAnkiConfig(),
} as Record;
const raw = getPathValue(source, definition.path);
if (raw === undefined || raw === null) {
return definition.defaultValue;
}
return raw as RuntimeOptionValue;
}
listOptions(): RuntimeOptionState[] {
const options: RuntimeOptionState[] = [];
for (const definition of RUNTIME_OPTION_REGISTRY) {
options.push({
id: definition.id,
label: definition.label,
scope: definition.scope,
valueType: definition.valueType,
value: this.getEffectiveValue(definition),
allowedValues: allowedValues(definition),
requiresRestart: definition.requiresRestart,
});
}
return options;
}
getOptionValue(id: RuntimeOptionId): RuntimeOptionValue | undefined {
const definition = this.definitions.get(id);
if (!definition) return undefined;
return this.getEffectiveValue(definition);
}
setOptionValue(id: RuntimeOptionId, value: RuntimeOptionValue): RuntimeOptionApplyResult {
const definition = this.definitions.get(id);
if (!definition) {
return { ok: false, error: `Unknown runtime option: ${id}` };
}
if (!isAllowedValue(definition, value)) {
return {
ok: false,
error: `Invalid value for ${id}: ${String(value)}`,
};
}
const next = deepClone(this.runtimeOverrides);
setPathValue(next, definition.path, value);
this.runtimeOverrides = next;
const ankiPatch = definition.toAnkiPatch(value);
this.applyAnkiPatch(ankiPatch);
const option = this.listOptions().find((item) => item.id === id);
if (!option) {
return { ok: false, error: `Failed to apply option: ${id}` };
}
const osdMessage = `Runtime option: ${definition.label} -> ${definition.formatValueForOsd(option.value)}`;
this.onOptionsChanged(this.listOptions());
return {
ok: true,
option,
osdMessage,
requiresRestart: definition.requiresRestart,
};
}
cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult {
const definition = this.definitions.get(id);
if (!definition) {
return { ok: false, error: `Unknown runtime option: ${id}` };
}
const values = allowedValues(definition);
if (values.length === 0) {
return { ok: false, error: `Option ${id} has no allowed values` };
}
const currentValue = this.getEffectiveValue(definition);
const currentIndex = values.findIndex((value) => value === currentValue);
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
const nextIndex =
direction === 1
? (safeIndex + 1) % values.length
: (safeIndex - 1 + values.length) % values.length;
return this.setOptionValue(id, values[nextIndex]);
}
getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig {
const source = baseConfig ?? this.getAnkiConfig();
const effective: AnkiConnectConfig = deepClone(source);
for (const definition of RUNTIME_OPTION_REGISTRY) {
const override = getPathValue(this.runtimeOverrides, definition.path);
if (override === undefined) continue;
const subPath = definition.path.replace(/^ankiConnect\./, "");
setPathValue(effective as unknown as Record, subPath, override);
}
return effective;
}
}