123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- /* eslint-disable @typescript-eslint/no-explicit-any */
- /** Function that takes the data in the old format and returns the data in the new format. Also supports an asynchronous migration. */
- type MigrationFunc = (oldData: any) => any | Promise<any>;
- /** Dictionary of format version numbers and the function that migrates to them from the previous whole integer */
- export type ConfigMigrationsDict = Record<number, MigrationFunc>;
- /** Options for the ConfigManager instance */
- export type ConfigManagerOptions<TData> = {
- /** A unique internal ID for this configuration - choose wisely as changing it is not supported yet. */
- id: string;
- /**
- * The default config data object to use if no data is saved in persistent storage yet.
- * Until the data is loaded from persistent storage with `loadData()`, this will be the data returned by `getData()`
- *
- * ⚠️ This has to be an object that can be serialized to JSON, so no functions or circular references are allowed, they will cause unexpected behavior.
- */
- defaultConfig: TData;
- /**
- * An incremental, whole integer version number of the current format of config data.
- * If the format of the data is changed in any way, this number should be incremented, in which case all necessary functions of the migrations dictionary will be run consecutively.
- *
- * ⚠️ Never decrement this number and optimally don't skip any numbers either!
- */
- formatVersion: number;
- /**
- * A dictionary of functions that can be used to migrate data from older versions of the configuration to newer ones.
- * The keys of the dictionary should be the format version that the functions can migrate to, from the previous whole integer value.
- * The values should be functions that take the data in the old format and return the data in the new format.
- * The functions will be run in order from the oldest to the newest version.
- * If the current format version is not in the dictionary, no migrations will be run.
- */
- migrations?: ConfigMigrationsDict;
- }
- & ({
- /**
- * Function to use to encode the data prior to saving it in persistent storage.
- * If this is specified, make sure to declare {@linkcode decodeData()} as well.
- *
- * You can make use of UserUtils' [`compress()`](https://github.com/Sv443-Network/UserUtils?tab=readme-ov-file#compress) function here to make the data use up less space at the cost of a little bit of performance.
- * @param data The input data as a serialized object (JSON string)
- */
- encodeData: (data: string) => string | Promise<string>,
- /**
- * Function to use to decode the data after reading it from persistent storage.
- * If this is specified, make sure to declare {@linkcode encodeData()} as well.
- *
- * You can make use of UserUtils' [`decompress()`](https://github.com/Sv443-Network/UserUtils?tab=readme-ov-file#decompress) function here to make the data use up less space at the cost of a little bit of performance.
- * @returns The resulting data as a valid serialized object (JSON string)
- */
- decodeData: (data: string) => string | Promise<string>,
- } | {
- encodeData?: never,
- decodeData?: never,
- });
- /**
- * Manages a user configuration that is cached in memory and persistently saved across sessions.
- * Supports migrating data from older versions of the configuration to newer ones and populating the cache with default data if no persistent data is found.
- *
- * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue`
- * ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultConfig`
- *
- * @template TData The type of the data that is saved in persistent storage (will be automatically inferred from `config.defaultConfig`) - this should also be the type of the data format associated with the current `options.formatVersion`
- */
- export class ConfigManager<TData = any> {
- public readonly id: string;
- public readonly formatVersion: number;
- public readonly defaultConfig: TData;
- private cachedData: TData;
- private migrations?: ConfigMigrationsDict;
- private encodeData: ConfigManagerOptions<TData>["encodeData"];
- private decodeData: ConfigManagerOptions<TData>["decodeData"];
- /**
- * Creates an instance of ConfigManager to manage a user configuration that is cached in memory and persistently saved across sessions.
- * Supports migrating data from older versions of the configuration to newer ones and populating the cache with default data if no persistent data is found.
- *
- * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue`
- * ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultConfig`
- *
- * @template TData The type of the data that is saved in persistent storage (will be automatically inferred from `config.defaultConfig`) - this should also be the type of the data format associated with the current `options.formatVersion`
- * @param options The options for this ConfigManager instance
- */
- constructor(options: ConfigManagerOptions<TData>) {
- this.id = options.id;
- this.formatVersion = options.formatVersion;
- this.defaultConfig = options.defaultConfig;
- this.cachedData = options.defaultConfig;
- this.migrations = options.migrations;
- this.encodeData = options.encodeData;
- this.decodeData = options.decodeData;
- }
- /**
- * Loads the data saved in persistent storage into the in-memory cache and also returns it.
- * Automatically populates persistent storage with default data if it doesn't contain any data yet.
- * Also runs all necessary migration functions if the data format has changed since the last time the data was saved.
- */
- public async loadData(): Promise<TData> {
- try {
- const gmData = await GM.getValue(`_uucfg-${this.id}`, this.defaultConfig);
- let gmFmtVer = Number(await GM.getValue(`_uucfgver-${this.id}`));
- if(typeof gmData !== "string") {
- await this.saveDefaultData();
- return { ...this.defaultConfig };
- }
- const isEncoded = await GM.getValue(`_uucfgenc-${this.id}`, false);
- if(isNaN(gmFmtVer))
- await GM.setValue(`_uucfgver-${this.id}`, gmFmtVer = this.formatVersion);
- let parsed = await this.deserializeData(gmData, isEncoded);
- if(gmFmtVer < this.formatVersion && this.migrations)
- parsed = await this.runMigrations(parsed, gmFmtVer);
- return { ...(this.cachedData = parsed) };
- }
- catch(err) {
- console.warn("Error while loading config data, resetting it to the default value.", err);
- await this.saveDefaultData();
- return this.defaultConfig;
- }
- }
- /**
- * Returns a copy of the data from the in-memory cache.
- * Use {@linkcode loadData()} to get fresh data from persistent storage (usually not necessary since the cache should always exactly reflect persistent storage).
- */
- public getData(): TData {
- return this.deepCopy(this.cachedData);
- }
- /** Saves the data synchronously to the in-memory cache and asynchronously to the persistent storage */
- public setData(data: TData) {
- this.cachedData = data;
- const useEncoding = Boolean(this.encodeData && this.decodeData);
- return new Promise<void>(async (resolve) => {
- await Promise.all([
- GM.setValue(`_uucfg-${this.id}`, await this.serializeData(data, useEncoding)),
- GM.setValue(`_uucfgver-${this.id}`, this.formatVersion),
- GM.setValue(`_uucfgenc-${this.id}`, useEncoding),
- ]);
- resolve();
- });
- }
- /** Saves the default configuration data passed in the constructor synchronously to the in-memory cache and asynchronously to persistent storage */
- public async saveDefaultData() {
- this.cachedData = this.defaultConfig;
- const useEncoding = Boolean(this.encodeData && this.decodeData);
- return new Promise<void>(async (resolve) => {
- await Promise.all([
- GM.setValue(`_uucfg-${this.id}`, await this.serializeData(this.defaultConfig, useEncoding)),
- GM.setValue(`_uucfgver-${this.id}`, this.formatVersion),
- GM.setValue(`_uucfgenc-${this.id}`, useEncoding),
- ]);
- resolve();
- });
- }
- /**
- * Call this method to clear all persistently stored data associated with this ConfigManager instance.
- * The in-memory cache will be left untouched, so you may still access the data with {@linkcode getData()}
- * Calling {@linkcode loadData()} or {@linkcode setData()} after this method was called will recreate persistent storage with the cached or default data.
- *
- * ⚠️ This requires the additional directive `@grant GM.deleteValue`
- */
- public async deleteConfig() {
- await Promise.all([
- GM.deleteValue(`_uucfg-${this.id}`),
- GM.deleteValue(`_uucfgver-${this.id}`),
- GM.deleteValue(`_uucfgenc-${this.id}`),
- ]);
- }
- /** Runs all necessary migration functions consecutively - may be overwritten in a subclass */
- protected async runMigrations(oldData: any, oldFmtVer: number): Promise<TData> {
- if(!this.migrations)
- return oldData as TData;
- let newData = oldData;
- const sortedMigrations = Object.entries(this.migrations)
- .sort(([a], [b]) => Number(a) - Number(b));
- let lastFmtVer = oldFmtVer;
- for(const [fmtVer, migrationFunc] of sortedMigrations) {
- const ver = Number(fmtVer);
- if(oldFmtVer < this.formatVersion && oldFmtVer < ver) {
- try {
- const migRes = migrationFunc(newData);
- newData = migRes instanceof Promise ? await migRes : migRes;
- lastFmtVer = oldFmtVer = ver;
- }
- catch(err) {
- console.error(`Error while running migration function for format version '${fmtVer}' - resetting to the default value.`, err);
- await this.saveDefaultData();
- return this.getData();
- }
- }
- }
- await Promise.all([
- GM.setValue(`_uucfg-${this.id}`, await this.serializeData(newData)),
- GM.setValue(`_uucfgver-${this.id}`, lastFmtVer),
- GM.setValue(`_uucfgenc-${this.id}`, Boolean(this.encodeData && this.decodeData)),
- ]);
- return newData as TData;
- }
- /** Serializes the data using the optional this.encodeData() and returns it as a string */
- private async serializeData(data: TData, useEncoding = true) {
- const stringData = JSON.stringify(data);
- if(!this.encodeData || !this.decodeData || !useEncoding)
- return stringData;
- const encRes = this.encodeData(stringData);
- if(encRes instanceof Promise)
- return await encRes;
- return encRes;
- }
- /** Deserializes the data using the optional this.decodeData() and returns it as a JSON object */
- private async deserializeData(data: string, useEncoding = true) {
- let decRes = this.decodeData && this.encodeData && useEncoding ? this.decodeData(data) : undefined;
- if(decRes instanceof Promise)
- decRes = await decRes;
- return JSON.parse(decRes ?? data) as TData;
- }
- /** Copies a JSON-compatible object and loses its internal references */
- private deepCopy<T>(obj: T): T {
- return JSON.parse(JSON.stringify(obj));
- }
- }
|