anki/ts/deck-options/lib.ts
Damien Elmes b2049209ff Re-enable formatting for .ts files
There are some style differences compared to prettier, and not all are
necessarily an improvement, but it's much faster now.
2022-11-28 09:33:04 +10:00

365 lines
12 KiB
TypeScript

// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { localeCompare } from "@tslib/i18n";
import { DeckConfig, deckConfig } from "@tslib/proto";
import { cloneDeep, isEqual } from "lodash-es";
import type { Readable, Writable } from "svelte/store";
import { get, readable, writable } from "svelte/store";
import type { DynamicSvelteComponent } from "../sveltelib/dynamicComponent";
export type DeckOptionsId = number;
export interface ConfigWithCount {
config: DeckConfig.DeckConfig;
useCount: number;
}
export interface ParentLimits {
newCards: number;
reviews: number;
}
/// Info for showing the top selector
export interface ConfigListEntry {
idx: number;
name: string;
useCount: number;
current: boolean;
}
export class DeckOptionsState {
readonly currentConfig: Writable<DeckConfig.DeckConfig.Config>;
readonly currentAuxData: Writable<Record<string, unknown>>;
readonly configList: Readable<ConfigListEntry[]>;
readonly parentLimits: Readable<ParentLimits>;
readonly cardStateCustomizer: Writable<string>;
readonly currentDeck: DeckConfig.DeckConfigsForUpdate.CurrentDeck;
readonly deckLimits: Writable<DeckConfig.DeckConfigsForUpdate.CurrentDeck.Limits>;
readonly defaults: DeckConfig.DeckConfig.Config;
readonly addonComponents: Writable<DynamicSvelteComponent[]>;
readonly v3Scheduler: boolean;
private targetDeckId: number;
private configs: ConfigWithCount[];
private selectedIdx: number;
private configListSetter!: (val: ConfigListEntry[]) => void;
private parentLimitsSetter!: (val: ParentLimits) => void;
private modifiedConfigs: Set<DeckOptionsId> = new Set();
private removedConfigs: DeckOptionsId[] = [];
private schemaModified: boolean;
constructor(targetDeckId: number, data: DeckConfig.DeckConfigsForUpdate) {
this.targetDeckId = targetDeckId;
this.currentDeck = data.currentDeck!;
this.defaults = data.defaults!.config!;
this.configs = data.allConfig.map((config) => {
const configInner = config.config!;
return {
config: configInner,
useCount: config.useCount!,
};
});
this.selectedIdx = Math.max(
0,
this.configs.findIndex((c) => c.config.id === this.currentDeck.configId),
);
this.sortConfigs();
this.v3Scheduler = data.v3Scheduler;
this.cardStateCustomizer = writable(data.cardStateCustomizer);
this.deckLimits = writable(data.currentDeck?.limits ?? createLimits());
// decrement the use count of the starting item, as we'll apply +1 to currently
// selected one at display time
this.configs[this.selectedIdx].useCount -= 1;
this.currentConfig = writable(this.getCurrentConfig());
this.currentAuxData = writable(this.getCurrentAuxData());
this.configList = readable(this.getConfigList(), (set) => {
this.configListSetter = set;
return;
});
this.parentLimits = readable(this.getParentLimits(), (set) => {
this.parentLimitsSetter = set;
return;
});
this.schemaModified = data.schemaModified;
this.addonComponents = writable([]);
// create a temporary subscription to force our setters to be set immediately,
// so unit tests don't get stale results
get(this.configList);
get(this.parentLimits);
// update our state when the current config is changed
this.currentConfig.subscribe((val) => this.onCurrentConfigChanged(val));
this.currentAuxData.subscribe((val) => this.onCurrentAuxDataChanged(val));
}
setCurrentIndex(index: number): void {
this.selectedIdx = index;
this.updateCurrentConfig();
// use counts have changed
this.updateConfigList();
}
getCurrentName(): string {
return this.configs[this.selectedIdx].config.name;
}
setCurrentName(name: string): void {
if (this.configs[this.selectedIdx].config.name === name) {
return;
}
const uniqueName = this.ensureNewNameUnique(name);
const config = this.configs[this.selectedIdx].config;
config.name = uniqueName;
if (config.id) {
this.modifiedConfigs.add(config.id);
}
this.sortConfigs();
this.updateConfigList();
}
/// Adds a new config, making it current.
addConfig(name: string): void {
this.addConfigFrom(name, this.defaults);
}
/// Clone the current config, making it current.
cloneConfig(name: string): void {
this.addConfigFrom(name, this.configs[this.selectedIdx].config.config!);
}
/// Clone the current config, making it current.
private addConfigFrom(name: string, source: DeckConfig.DeckConfig.IConfig): void {
const uniqueName = this.ensureNewNameUnique(name);
const config = DeckConfig.DeckConfig.create({
id: 0,
name: uniqueName,
config: DeckConfig.DeckConfig.Config.create(cloneDeep(source)),
});
const configWithCount = { config, useCount: 0 };
this.configs.push(configWithCount);
this.selectedIdx = this.configs.length - 1;
this.sortConfigs();
this.updateCurrentConfig();
this.updateConfigList();
}
removalWilLForceFullSync(): boolean {
return !this.schemaModified && this.configs[this.selectedIdx].config.id !== 0;
}
defaultConfigSelected(): boolean {
return this.configs[this.selectedIdx].config.id === 1;
}
/// Will throw if the default deck is selected.
removeCurrentConfig(): void {
const currentId = this.configs[this.selectedIdx].config.id;
if (currentId === 1) {
throw Error("can't remove default config");
}
if (currentId !== 0) {
this.removedConfigs.push(currentId);
this.schemaModified = true;
}
this.configs.splice(this.selectedIdx, 1);
this.selectedIdx = Math.max(0, this.selectedIdx - 1);
this.updateCurrentConfig();
this.updateConfigList();
}
dataForSaving(
applyToChildren: boolean,
): NonNullable<DeckConfig.IUpdateDeckConfigsRequest> {
const modifiedConfigsExcludingCurrent = this.configs
.map((c) => c.config)
.filter((c, idx) => {
return (
idx !== this.selectedIdx
&& (c.id === 0 || this.modifiedConfigs.has(c.id))
);
});
const configs = [
...modifiedConfigsExcludingCurrent,
// current must come last, even if unmodified
this.configs[this.selectedIdx].config,
];
return {
targetDeckId: this.targetDeckId,
removedConfigIds: this.removedConfigs,
configs,
applyToChildren,
cardStateCustomizer: get(this.cardStateCustomizer),
limits: get(this.deckLimits),
};
}
async save(applyToChildren: boolean): Promise<void> {
await deckConfig.updateDeckConfigs(
DeckConfig.UpdateDeckConfigsRequest.create(
this.dataForSaving(applyToChildren),
),
);
}
private onCurrentConfigChanged(config: DeckConfig.DeckConfig.Config): void {
const configOuter = this.configs[this.selectedIdx].config;
if (!isEqual(config, configOuter.config)) {
configOuter.config = config;
if (configOuter.id) {
this.modifiedConfigs.add(configOuter.id);
}
}
this.parentLimitsSetter?.(this.getParentLimits());
}
private onCurrentAuxDataChanged(data: Record<string, unknown>): void {
const current = this.getCurrentAuxData();
if (!isEqual(current, data)) {
this.currentConfig.update((config) => {
const asBytes = new TextEncoder().encode(JSON.stringify(data));
config.other = asBytes;
return config;
});
}
}
private ensureNewNameUnique(name: string): string {
const idx = this.configs.findIndex((e) => e.config.name === name);
if (idx !== -1) {
return name + (new Date().getTime() / 1000).toFixed(0);
} else {
return name;
}
}
private updateCurrentConfig(): void {
this.currentConfig.set(this.getCurrentConfig());
this.currentAuxData.set(this.getCurrentAuxData());
this.parentLimitsSetter?.(this.getParentLimits());
}
private updateConfigList(): void {
this.configListSetter?.(this.getConfigList());
}
/// Returns a copy of the currently selected config.
private getCurrentConfig(): DeckConfig.DeckConfig.Config {
return cloneDeep(this.configs[this.selectedIdx].config.config!);
}
/// Extra data associated with current config (for add-ons)
private getCurrentAuxData(): Record<string, unknown> {
const conf = this.configs[this.selectedIdx].config.config!;
return bytesToObject(conf.other);
}
private sortConfigs() {
const currentConfigName = this.configs[this.selectedIdx].config.name;
this.configs.sort((a, b) => localeCompare(a.config.name, b.config.name, { sensitivity: "base" }));
this.selectedIdx = this.configs.findIndex(
(c) => c.config.name == currentConfigName,
);
}
private getConfigList(): ConfigListEntry[] {
const list: ConfigListEntry[] = this.configs.map((c, idx) => {
const useCount = c.useCount + (idx === this.selectedIdx ? 1 : 0);
return {
name: c.config.name,
current: idx === this.selectedIdx,
idx,
useCount,
};
});
return list;
}
private getParentLimits(): ParentLimits {
const parentConfigs = this.configs.filter((c) => this.currentDeck.parentConfigIds.includes(c.config.id));
const newCards = parentConfigs.reduce(
(previous, current) => Math.min(previous, current.config.config?.newPerDay ?? 0),
2 ** 31,
);
const reviews = parentConfigs.reduce(
(previous, current) => Math.min(previous, current.config.config?.reviewsPerDay ?? 0),
2 ** 31,
);
return {
newCards,
reviews,
};
}
}
function bytesToObject(bytes: Uint8Array): Record<string, unknown> {
if (!bytes.length) {
return {};
}
let obj: Record<string, unknown>;
try {
obj = JSON.parse(new TextDecoder().decode(bytes));
} catch (err) {
console.log(`invalid json in deck config`);
return {};
}
if (obj.constructor !== Object) {
console.log(`invalid object in deck config`);
return {};
}
return obj;
}
export function createLimits(): DeckConfig.DeckConfigsForUpdate.CurrentDeck.Limits {
return DeckConfig.DeckConfigsForUpdate.CurrentDeck.Limits.create({});
}
export class ValueTab {
readonly title: string;
value: number | null;
private setter: (value: number | null) => void;
private disabledValue: number | null;
private startValue: number | null;
private initialValue: number | null;
constructor(
title: string,
value: number | null,
setter: (value: number | null) => void,
disabledValue: number | null,
startValue: number | null,
) {
this.title = title;
this.value = this.initialValue = value;
this.setter = setter;
this.disabledValue = disabledValue;
this.startValue = startValue;
}
reset(): void {
this.setter(this.initialValue);
}
disable(): void {
this.setter(this.disabledValue);
}
enable(fallbackValue: number): void {
this.value = this.value ?? this.startValue ?? fallbackValue;
this.setter(this.value);
}
setValue(value: number): void {
this.value = value;
this.setter(value);
}
}