Browse Source

ref!: begin moving features to CoreUtils library

Sv443 1 month ago
parent
commit
31ac3f1e53

+ 1 - 1
LICENSE.txt

@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2023 Sven Fehler (Sv443)
+Copyright (c) 2023 Sv443 Network and Sven Fehler (Sv443)
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal

+ 8 - 2
docs.md

@@ -1,12 +1,18 @@
 # UserUtils Documentation
+General purpose DOM/GreaseMonkey library that allows you to register listeners for when CSS selectors exist, intercept events, create persistent & synchronous data stores, modify the DOM more easily and much more.  
+Contains builtin TypeScript declarations. Supports ESM and CJS imports via a bundler and global declaration via `@require` or `<script>`  
+The library works in any DOM environment with or without the [GreaseMonkey API](https://wiki.greasespot.net/Greasemonkey_Manual:API), but some features will be unavailable or limited.  
+  
+You may want to check out my [template for userscripts in TypeScript](https://github.com/Sv443/Userscript.ts) that you can use to get started quickly. It also includes this library by default.  
+If you like using this library, please consider [supporting the development ❤️](https://github.com/sponsors/Sv443)
 
 <br>
 
 <!-- #region Preamble -->
 ## Preamble:
-This library is written in TypeScript and contains builtin TypeScript declarations, but it will also work in plain JavaScript after removing the `: type` annotations.  
+This library is written in TypeScript and contains builtin TypeScript declarations, but it will also work in plain JavaScript after removing the `: type` annotations in the example code snippets.  
   
-Each feature has example code that can be expanded by clicking on the text "Example - click to view".  
+Each feature's example code snippet can be expanded by clicking on the text "Example - click to view".  
 The signatures and examples are written in TypeScript and use ESM import syntax to show you which types need to be provided and will be returned.  
 The library itself supports importing an ESM, CommonJS or global variable definition bundle, depending on your use case.  
   

+ 0 - 222
lib/DataStore.spec.ts

@@ -1,222 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { DataStore } from "./DataStore.js";
-import { compress, decompress } from "./crypto.js";
-
-class TestDataStore<TData extends object = object> extends DataStore<TData> {
-  public async test_getValue<TValue extends GM.Value = string>(name: string, defaultValue: TValue): Promise<string | TValue> {
-    return await this.getValue(name, defaultValue);
-  }
-
-  public async test_setValue(name: string, value: GM.Value): Promise<void> {
-    return await this.setValue(name, value);
-  }
-}
-
-describe("DataStore", () => {
-  //#region base
-  it("Basic usage", async () => {
-    const store = new DataStore({
-      id: "test-1",
-      defaultData: { a: 1, b: 2 },
-      formatVersion: 1,
-      storageMethod: "localStorage",
-      encodeData: (d) => d,
-      decodeData: (d) => d,
-    });
-
-    // should equal defaultData:
-    expect(store.getData().a).toBe(1);
-
-    // deepCopy should return a new object:
-    expect(store.getData(true) === store.getData(true)).toBe(false);
-
-    await store.loadData();
-
-    // synchronous in-memory change:
-    const prom = store.setData({ ...store.getData(), a: 2 });
-
-    expect(store.getData().a).toBe(2);
-
-    await prom;
-
-    // only clears persistent data, not the stuff in memory:
-    await store.deleteData();
-    expect(store.getData().a).toBe(2);
-
-    // refreshes memory data:
-    await store.loadData();
-    expect(store.getData().a).toBe(1);
-
-    expect(store.encodingEnabled()).toBe(true);
-
-    // restore initial state:
-    await store.deleteData();
-  });
-
-  //#region encoding
-  it("Works with encoding", async () => {
-    const store = new DataStore({
-      id: "test-2",
-      defaultData: { a: 1, b: 2 },
-      formatVersion: 1,
-      storageMethod: "sessionStorage",
-      encodeData: async (data) => await compress(data, "deflate-raw", "string"),
-      decodeData: async (data) => await decompress(data, "deflate-raw", "string"),
-    });
-
-    await store.loadData();
-
-    await store.setData({ ...store.getData(), a: 2 });
-
-    await store.loadData();
-
-    expect(store.getData()).toEqual({ a: 2, b: 2 });
-
-    expect(store.encodingEnabled()).toBe(true);
-
-    // restore initial state:
-    await store.deleteData();
-  });
-
-  //#region data & ID migrations
-  it("Data and ID migrations work", async () => {
-    const firstStore = new DataStore({
-      id: "test-3",
-      defaultData: { a: 1, b: 2 },
-      formatVersion: 1,
-      storageMethod: "sessionStorage",
-    });
-
-    await firstStore.loadData();
-
-    await firstStore.setData({ ...firstStore.getData(), a: 2 });
-
-    // new store with increased format version & new ID:
-    const secondStore = new DataStore({
-      id: "test-4",
-      migrateIds: [firstStore.id],
-      defaultData: { a: -1337, b: -1337, c: 69 },
-      formatVersion: 2,
-      storageMethod: "sessionStorage",
-      migrations: {
-        2: (oldData: Record<string, unknown>) => ({ ...oldData, c: 1 }),
-      },
-    });
-
-    const data1 = await secondStore.loadData();
-
-    expect(data1.a).toBe(2);
-    expect(data1.b).toBe(2);
-    expect(data1.c).toBe(1);
-
-    await secondStore.saveDefaultData();
-    const data2 = secondStore.getData();
-
-    expect(data2.a).toBe(-1337);
-    expect(data2.b).toBe(-1337);
-    expect(data2.c).toBe(69);
-
-    // migrate with migrateId method:
-    const thirdStore = new TestDataStore({
-      id: "test-5",
-      defaultData: secondStore.defaultData,
-      formatVersion: 3,
-      storageMethod: "sessionStorage",
-    });
-
-    await thirdStore.migrateId(secondStore.id);
-    const thirdData = await thirdStore.loadData();
-
-    expect(thirdData.a).toBe(-1337);
-    expect(thirdData.b).toBe(-1337);
-    expect(thirdData.c).toBe(69);
-
-    expect(await thirdStore.test_getValue("_uucfgver-test-5", "")).toBe("2");
-    await thirdStore.setData(thirdStore.getData());
-    expect(await thirdStore.test_getValue("_uucfgver-test-5", "")).toBe("3");
-
-    expect(await thirdStore.test_getValue("_uucfgver-test-3", "")).toBe("");
-    expect(await thirdStore.test_getValue("_uucfgver-test-4", "")).toBe("");
-
-    // restore initial state:
-    await firstStore.deleteData();
-    await secondStore.deleteData();
-    await thirdStore.deleteData();
-  });
-
-  //#region migration error
-  it("Migration error", async () => {
-    const store1 = new DataStore({
-      id: "test-migration-error",
-      defaultData: { a: 1, b: 2 },
-      formatVersion: 1,
-      storageMethod: "localStorage",
-    });
-
-    await store1.loadData();
-
-    const store2 = new DataStore({
-      id: "test-migration-error",
-      defaultData: { a: 5, b: 5, c: 5 },
-      formatVersion: 2,
-      storageMethod: "localStorage",
-      migrations: {
-        2: (_oldData: typeof store1["defaultData"]) => {
-          throw new Error("Some error in the migration function");
-        },
-      },
-    });
-
-    // should reset to defaultData, because of the migration error:
-    await store2.loadData();
-
-    expect(store2.getData().a).toBe(5);
-    expect(store2.getData().b).toBe(5);
-    expect(store2.getData().c).toBe(5);
-  });
-
-  //#region invalid persistent data
-  it("Invalid persistent data", async () => {
-    const store1 = new TestDataStore({
-      id: "test-6",
-      defaultData: { a: 1, b: 2 },
-      formatVersion: 1,
-      storageMethod: "sessionStorage",
-    });
-
-    await store1.loadData();
-    await store1.setData({ ...store1.getData(), a: 2 });
-
-    await store1.test_setValue(`_uucfg-${store1.id}`, "invalid");
-
-    // should reset to defaultData:
-    await store1.loadData();
-
-    expect(store1.getData().a).toBe(1);
-    expect(store1.getData().b).toBe(2);
-
-    // @ts-expect-error
-    window.GM = {
-      getValue: async () => 1337,
-      setValue: async () => undefined,
-    }
-
-    const store2 = new TestDataStore({
-      id: "test-7",
-      defaultData: { a: 1, b: 2 },
-      formatVersion: 1,
-      storageMethod: "GM",
-    });
-
-    await store1.setData({ ...store1.getData(), a: 2 });
-
-    // invalid type number should reset to defaultData:
-    await store2.loadData();
-
-    expect(store2.getData().a).toBe(1);
-    expect(store2.getData().b).toBe(2);
-
-    // @ts-expect-error
-    delete window.GM;
-  });
-});

+ 0 - 386
lib/DataStore.ts

@@ -1,386 +0,0 @@
-/**
- * @module lib/DataStore
- * This module contains the DataStore class, which is a general purpose, sync and async persistent JSON database - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#datastore)
- */
-
-import { MigrationError } from "./errors.js";
-import type { Prettify } from "./types.js";
-
-//#region types
-
-/** 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>; // eslint-disable-line @typescript-eslint/no-explicit-any
-
-/** Dictionary of format version numbers and the function that migrates to them from the previous whole integer */
-export type DataMigrationsDict = Record<number, MigrationFunc>;
-
-/** Options for the DataStore instance */
-export type DataStoreOptions<TData> = Prettify<
-  & {
-    /**
-     * A unique internal ID for this data store.  
-     * To avoid conflicts with other scripts, it is recommended to use a prefix that is unique to your script.  
-     * If you want to change the ID, you should make use of the {@linkcode DataStore.migrateId()} method.
-     */
-    id: string;
-    /**
-     * The default data object to use if no data is saved in persistent storage yet.  
-     * Until the data is loaded from persistent storage with {@linkcode DataStore.loadData()}, this will be the data returned by {@linkcode DataStore.getData()}.  
-     *   
-     * - ⚠️ This has to be an object that can be serialized to JSON using `JSON.stringify()`, so no functions or circular references are allowed, they will cause unexpected behavior.  
-     */
-    defaultData: TData;
-    /**
-     * An incremental, whole integer version number of the current format of 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 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?: DataMigrationsDict;
-    /**
-     * If an ID or multiple IDs are passed here, the data will be migrated from the old ID(s) to the current ID.  
-     * This will happen once per page load, when {@linkcode DataStore.loadData()} is called.  
-     * All future calls to {@linkcode DataStore.loadData()} in the session will not check for the old ID(s) anymore.  
-     * To migrate IDs manually, use the method {@linkcode DataStore.migrateId()} instead.
-     */
-    migrateIds?: string | string[];
-    /**
-     * Where the data should be saved (`"GM"` by default).  
-     * The protected methods {@linkcode DataStore.getValue()}, {@linkcode DataStore.setValue()}  and {@linkcode DataStore.deleteValue()} are used to interact with the storage.  
-     * `"GM"` storage, `"localStorage"` and `"sessionStorage"` are supported out of the box, but in an extended class you can overwrite those methods to implement any other storage method.
-     */
-    storageMethod?: "GM" | "localStorage" | "sessionStorage";
-  }
-  & (
-    // make sure that encodeData and decodeData are *both* either defined or undefined
-    | {
-      /**
-       * 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,
-    }
-  )
->;
-
-//#region class
-
-/**
- * Manages a hybrid synchronous & asynchronous persistent JSON database that is cached in memory and persistently saved across sessions using [GM storage](https://wiki.greasespot.net/GM.setValue) (default), [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) or [sessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage).  
- * Supports migrating data from older format versions to newer ones and populating the cache with default data if no persistent data is found.  
- * Can be overridden to implement any other storage method.  
- *   
- * All methods are `protected` or `public`, so you can easily extend this class and overwrite them to use a different storage method or to add other functionality.  
- * Remember that you can use `super.methodName()` in the subclass to call the original method if needed.  
- *   
- * - ⚠️ The data is stored as a JSON string, so only data compatible with [`JSON.stringify()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) can be used. Circular structures and complex objects (containing functions, symbols, etc.) will either throw an error on load and save or cause otherwise unexpected behavior. Properties with a value of `undefined` will be removed from the data prior to saving it, so use `null` instead.  
- * - ⚠️ If the storageMethod is left as the default of `"GM"`, the directives `@grant GM.getValue` and `@grant GM.setValue` are required. If you then also use the method {@linkcode DataStore.deleteData()}, the extra directive `@grant GM.deleteValue` is needed too.  
- * - ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultData`  
- * 
- * @template TData The type of the data that is saved in persistent storage for the currently set format version (will be automatically inferred from `defaultData` if not provided)
- */
-export class DataStore<TData extends object = object> {
-  public readonly id: string;
-  public readonly formatVersion: number;
-  public readonly defaultData: TData;
-  public readonly encodeData: DataStoreOptions<TData>["encodeData"];
-  public readonly decodeData: DataStoreOptions<TData>["decodeData"];
-  public readonly storageMethod: Required<DataStoreOptions<TData>>["storageMethod"];
-  private cachedData: TData;
-  private migrations?: DataMigrationsDict;
-  private migrateIds: string[] = [];
-
-  /**
-   * Creates an instance of DataStore to manage a sync & async database that is cached in memory and persistently saved across sessions.  
-   * Supports migrating data from older versions 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` if the storageMethod is left as the default of `"GM"`  
-   * - ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultData`
-   * 
-   * @template TData The type of the data that is saved in persistent storage for the currently set format version (will be automatically inferred from `defaultData` if not provided) - **This has to be a JSON-compatible object!** (no undefined, circular references, etc.)
-   * @param options The options for this DataStore instance
-   */
-  constructor(options: DataStoreOptions<TData>) {
-    this.id = options.id;
-    this.formatVersion = options.formatVersion;
-    this.defaultData = options.defaultData;
-    this.cachedData = options.defaultData;
-    this.migrations = options.migrations;
-    if(options.migrateIds)
-      this.migrateIds = Array.isArray(options.migrateIds) ? options.migrateIds : [options.migrateIds];
-    this.storageMethod = options.storageMethod ?? "GM";
-    this.encodeData = options.encodeData;
-    this.decodeData = options.decodeData;
-  }
-
-  //#region public
-
-  /**
-   * 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 {
-      if(this.migrateIds.length > 0) {
-        await this.migrateId(this.migrateIds);
-        this.migrateIds = [];
-      }
-
-      const gmData = await this.getValue(`_uucfg-${this.id}`, JSON.stringify(this.defaultData));
-      let gmFmtVer = Number(await this.getValue(`_uucfgver-${this.id}`, NaN));
-
-      if(typeof gmData !== "string") {
-        await this.saveDefaultData();
-        return { ...this.defaultData };
-      }
-
-      const isEncoded = Boolean(await this.getValue(`_uucfgenc-${this.id}`, false));
-
-      let saveData = false;
-      if(isNaN(gmFmtVer)) {
-        await this.setValue(`_uucfgver-${this.id}`, gmFmtVer = this.formatVersion);
-        saveData = true;
-      }
-
-      let parsed = await this.deserializeData(gmData, isEncoded);
-
-      if(gmFmtVer < this.formatVersion && this.migrations)
-        parsed = await this.runMigrations(parsed, gmFmtVer);
-
-      if(saveData)
-        await this.setData(parsed);
-
-      this.cachedData = { ...parsed };
-      return this.cachedData;
-    }
-    catch(err) {
-      console.warn("Error while parsing JSON data, resetting it to the default value.", err);
-      await this.saveDefaultData();
-      return this.defaultData;
-    }
-  }
-
-  /**
-   * 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).
-   * @param deepCopy Whether to return a deep copy of the data (default: `false`) - only necessary if your data object is nested and may have a bigger performance impact if enabled
-   */
-  public getData(deepCopy = false): TData {
-    return deepCopy
-      ? this.deepCopy(this.cachedData)
-      : { ...this.cachedData };
-  }
-
-  /** Saves the data synchronously to the in-memory cache and asynchronously to the persistent storage */
-  public setData(data: TData): Promise<void> {
-    this.cachedData = data;
-    const useEncoding = this.encodingEnabled();
-    return new Promise<void>(async (resolve) => {
-      await Promise.all([
-        this.setValue(`_uucfg-${this.id}`, await this.serializeData(data, useEncoding)),
-        this.setValue(`_uucfgver-${this.id}`, this.formatVersion),
-        this.setValue(`_uucfgenc-${this.id}`, useEncoding),
-      ]);
-      resolve();
-    });
-  }
-
-  /** Saves the default data passed in the constructor synchronously to the in-memory cache and asynchronously to persistent storage */
-  public async saveDefaultData(): Promise<void> {
-    this.cachedData = this.defaultData;
-    const useEncoding = this.encodingEnabled();
-    return new Promise<void>(async (resolve) => {
-      await Promise.all([
-        this.setValue(`_uucfg-${this.id}`, await this.serializeData(this.defaultData, useEncoding)),
-        this.setValue(`_uucfgver-${this.id}`, this.formatVersion),
-        this.setValue(`_uucfgenc-${this.id}`, useEncoding),
-      ]);
-      resolve();
-    });
-  }
-
-  /**
-   * Call this method to clear all persistently stored data associated with this DataStore 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` if the storageMethod is left as the default of `"GM"`
-   */
-  public async deleteData(): Promise<void> {
-    await Promise.all([
-      this.deleteValue(`_uucfg-${this.id}`),
-      this.deleteValue(`_uucfgver-${this.id}`),
-      this.deleteValue(`_uucfgenc-${this.id}`),
-    ]);
-  }
-
-  /** Returns whether encoding and decoding are enabled for this DataStore instance */
-  public encodingEnabled(): this is Required<Pick<DataStoreOptions<TData>, "encodeData" | "decodeData">> {
-    return Boolean(this.encodeData && this.decodeData);
-  }
-
-  //#region migrations
-
-  /**
-   * Runs all necessary migration functions consecutively and saves the result to the in-memory cache and persistent storage and also returns it.  
-   * This method is automatically called by {@linkcode loadData()} if the data format has changed since the last time the data was saved.  
-   * Though calling this method manually is not necessary, it can be useful if you want to run migrations for special occasions like a user importing potentially outdated data that has been previously exported.  
-   *   
-   * If one of the migrations fails, the data will be reset to the default value if `resetOnError` is set to `true` (default). Otherwise, an error will be thrown and no data will be saved.
-   */
-  public async runMigrations(oldData: unknown, oldFmtVer: number, resetOnError = true): 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) {
-          if(!resetOnError)
-            throw new MigrationError(`Error while running migration function for format version '${fmtVer}'`, { cause: err });
-
-          await this.saveDefaultData();
-          return this.getData();
-        }
-      }
-    }
-
-    await Promise.all([
-      this.setValue(`_uucfg-${this.id}`, await this.serializeData(newData as TData)),
-      this.setValue(`_uucfgver-${this.id}`, lastFmtVer),
-      this.setValue(`_uucfgenc-${this.id}`, this.encodingEnabled()),
-    ]);
-
-    return this.cachedData = { ...newData as TData };
-  }
-
-  /**
-   * Tries to migrate the currently saved persistent data from one or more old IDs to the ID set in the constructor.  
-   * If no data exist for the old ID(s), nothing will be done, but some time may still pass trying to fetch the non-existent data.
-   */
-  public async migrateId(oldIds: string | string[]): Promise<void> {
-    const ids = Array.isArray(oldIds) ? oldIds : [oldIds];
-    await Promise.all(ids.map(async id => {
-      const data = await this.getValue(`_uucfg-${id}`, JSON.stringify(this.defaultData));
-      const fmtVer = Number(await this.getValue(`_uucfgver-${id}`, NaN));
-      const isEncoded = Boolean(await this.getValue(`_uucfgenc-${id}`, false));
-
-      if(data === undefined || isNaN(fmtVer))
-        return;
-
-      const parsed = await this.deserializeData(data, isEncoded);
-      await Promise.allSettled([
-        this.setValue(`_uucfg-${this.id}`, await this.serializeData(parsed)),
-        this.setValue(`_uucfgver-${this.id}`, fmtVer),
-        this.setValue(`_uucfgenc-${this.id}`, isEncoded),
-        this.deleteValue(`_uucfg-${id}`),
-        this.deleteValue(`_uucfgver-${id}`),
-        this.deleteValue(`_uucfgenc-${id}`),
-      ]);
-    }));
-  }
-
-  //#region serialization
-
-  /** Serializes the data using the optional this.encodeData() and returns it as a string */
-  protected async serializeData(data: TData, useEncoding = true): Promise<string> {
-    const stringData = JSON.stringify(data);
-    if(!this.encodingEnabled() || !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 */
-  protected async deserializeData(data: string, useEncoding = true): Promise<TData> {
-    let decRes = this.encodingEnabled() && 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 all its internal references in the process */
-  protected deepCopy<T>(obj: T): T {
-    return JSON.parse(JSON.stringify(obj));
-  }
-
-  //#region storage
-
-  /** Gets a value from persistent storage - can be overwritten in a subclass if you want to use something other than the default storage methods */
-  protected async getValue<TValue extends GM.Value = string>(name: string, defaultValue: TValue): Promise<string | TValue> {
-    switch(this.storageMethod) {
-    case "localStorage":
-      return localStorage.getItem(name) as TValue ?? defaultValue;
-    case "sessionStorage":
-      return sessionStorage.getItem(name) as string ?? defaultValue;
-    default: 
-      return GM.getValue<TValue>(name, defaultValue);
-    }
-  }
-
-  /**
-   * Sets a value in persistent storage - can be overwritten in a subclass if you want to use something other than the default storage methods.  
-   * The default storage engines will stringify all passed values like numbers or booleans, so be aware of that.
-   */
-  protected async setValue(name: string, value: GM.Value): Promise<void> {
-    switch(this.storageMethod) {
-    case "localStorage":
-      return localStorage.setItem(name, String(value));
-    case "sessionStorage":
-      return sessionStorage.setItem(name, String(value));
-    default:
-      return GM.setValue(name, String(value));
-    }
-  }
-
-  /** Deletes a value from persistent storage - can be overwritten in a subclass if you want to use something other than the default storage methods */
-  protected async deleteValue(name: string): Promise<void> {
-    switch(this.storageMethod) {
-    case "localStorage":
-      return localStorage.removeItem(name);
-    case "sessionStorage":
-      return sessionStorage.removeItem(name);
-    default:
-      return GM.deleteValue(name);
-    }
-  }
-}

+ 0 - 92
lib/DataStoreSerializer.spec.ts

@@ -1,92 +0,0 @@
-import { afterAll, beforeAll, describe, expect, it } from "vitest";
-import { DataStoreSerializer } from "./DataStoreSerializer.js";
-import { DataStore } from "./DataStore.js";
-import { beforeEach } from "node:test";
-import { compress, decompress } from "./crypto.js";
-
-const store1 = new DataStore({
-  id: "dss-test-1",
-  defaultData: { a: 1, b: 2 },
-  formatVersion: 1,
-  storageMethod: "sessionStorage",
-});
-
-const store2 = new DataStore({
-  id: "dss-test-2",
-  defaultData: { c: 1, d: 2 },
-  formatVersion: 1,
-  storageMethod: "sessionStorage",
-  encodeData: async (data) => await compress(data, "deflate-raw", "string"),
-  decodeData: async (data) => await decompress(data, "deflate-raw", "string"),
-});
-
-const getStores = () => [
-  store1,
-  store2,
-];
-
-describe("DataStoreSerializer", () => {
-  beforeEach(async () => {
-    const ser = new DataStoreSerializer(getStores());
-    await ser.deleteStoresData();
-    await ser.resetStoresData();
-    await ser.loadStoresData();
-  });
-
-  afterAll(async () => {
-    await new DataStoreSerializer(getStores()).deleteStoresData();
-  });
-
-  it("Serialization", async () => {
-    const ser = new DataStoreSerializer(getStores());
-    await ser.loadStoresData();
-
-    const full = await ser.serialize();
-    expect(full).toEqual(`[{"id":"dss-test-1","data":"{\\"a\\":1,\\"b\\":2}","formatVersion":1,"encoded":false,"checksum":"43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777"},{"id":"dss-test-2","data":"q1ZKVrIy1FFKUbIyqgUA","formatVersion":1,"encoded":true,"checksum":"b1020c3faac493009494fa622f701b831657c11ea53f8c8236f0689089c7e2d3"}]`);
-
-    const partial = await ser.serializePartial(["dss-test-1"]);
-    expect(partial).toEqual(`[{"id":"dss-test-1","data":"{\\"a\\":1,\\"b\\":2}","formatVersion":1,"encoded":false,"checksum":"43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777"}]`);
-
-    const unencoded = await ser.serializePartial(["dss-test-2"], false);
-    expect(unencoded).toEqual(`[{"id":"dss-test-2","data":"{\\"c\\":1,\\"d\\":2}","formatVersion":1,"encoded":false,"checksum":"86cada6157f4b726bf413e0371a2f461a82d2809e6eb3c095ec796fcfd8d72ee"}]`);
-
-    const notStringified = await ser.serializePartial(["dss-test-2"], false, false);
-    expect(DataStoreSerializer.isSerializedDataStoreObjArray(notStringified)).toBe(true);
-    expect(DataStoreSerializer.isSerializedDataStoreObj(notStringified?.[0])).toBe(true);
-    expect(notStringified).toEqual([
-      {
-        id: "dss-test-2",
-        data: "{\"c\":1,\"d\":2}",
-        encoded: false,
-        formatVersion: 1,
-        checksum: "86cada6157f4b726bf413e0371a2f461a82d2809e6eb3c095ec796fcfd8d72ee",
-      },
-    ]);
-  });
-
-  it("Deserialization", async () => {
-    const stores = getStores();
-    const ser = new DataStoreSerializer(stores);
-
-    await ser.deserialize(`[{"id":"dss-test-2","data":"{\\"c\\":420,\\"d\\":420}","formatVersion":1,"encoded":false}]`);
-    expect(store2.getData().c).toBe(420);
-
-    await ser.resetStoresData();
-    expect(store1.getData().a).toBe(1);
-    expect(store2.getData().c).toBe(1);
-
-    await ser.resetStoresData();
-    await ser.deserializePartial(["dss-test-1"], `[{"id":"dss-test-1","data":"{\\"a\\":421,\\"b\\":421}","checksum":"ad33b8f6a1d18c781a80390496b1b7dfaf56d73cf25a9497cb156ba83214357d","formatVersion":1,"encoded":false}, {"id":"dss-test-2","data":"{\\"c\\":421,\\"d\\":421}","formatVersion":1,"encoded":false}]`);
-    expect(store1.getData().a).toBe(421);
-    expect(store2.getData().c).toBe(1);
-
-    await ser.resetStoresData();
-    await ser.deserializePartial(["dss-test-2"], `[{"id":"dss-test-1","data":"{\\"a\\":422,\\"b\\":422}","formatVersion":1,"encoded":false}, {"id":"dss-test-2","data":"{\\"c\\":422,\\"d\\":422}","checksum":"ab1d18cf13554369cea6bb517a9034e3d6548f19a40d176b16ac95c8e02d65bb","formatVersion":1,"encoded":false}]`);
-    expect(store1.getData().a).toBe(1);
-    expect(store2.getData().c).toBe(422);
-
-    await ser.resetStoresData(() => false);
-    expect(store1.getData().a).toBe(1);
-    expect(store2.getData().c).toBe(422);
-  });
-});

+ 0 - 233
lib/DataStoreSerializer.ts

@@ -1,233 +0,0 @@
-/**
- * @module lib/DataStoreSerializer
- * This module contains the DataStoreSerializer class, which allows you to import and export serialized DataStore data - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#datastoreserializer)
- */
-
-import { computeHash } from "./crypto.js";
-import { getUnsafeWindow } from "./dom.js";
-import { ChecksumMismatchError } from "./errors.js";
-import type { DataStore } from "./DataStore.js";
-
-export type DataStoreSerializerOptions = {
-  /** Whether to add a checksum to the exported data. Defaults to `true` */
-  addChecksum?: boolean;
-  /** Whether to ensure the integrity of the data when importing it by throwing an error (doesn't throw when the checksum property doesn't exist). Defaults to `true` */
-  ensureIntegrity?: boolean;
-};
-
-/** Serialized data of a DataStore instance */
-export type SerializedDataStore = {
-  /** The ID of the DataStore instance */
-  id: string;
-  /** The serialized data */
-  data: string;
-  /** The format version of the data */
-  formatVersion: number;
-  /** Whether the data is encoded */
-  encoded: boolean;
-  /** The checksum of the data - key is not present when `addChecksum` is `false` */
-  checksum?: string;
-};
-
-/** Result of {@linkcode DataStoreSerializer.loadStoresData()} */
-export type LoadStoresDataResult = {
-  /** The ID of the DataStore instance */
-  id: string;
-  /** The in-memory data object */
-  data: object;
-}
-
-/** A filter for selecting data stores */
-export type StoreFilter = string[] | ((id: string) => boolean);
-
-/**
- * Allows for easy serialization and deserialization of multiple DataStore instances.  
- *   
- * All methods are at least `protected`, so you can easily extend this class and overwrite them to use a different storage method or to add additional functionality.  
- * Remember that you can call `super.methodName()` in the subclass to access the original method.  
- *   
- * - ⚠️ Needs to run in a secure context (HTTPS) due to the use of the SubtleCrypto API if checksumming is enabled.  
- */
-export class DataStoreSerializer {
-  protected stores: DataStore[];
-  protected options: Required<DataStoreSerializerOptions>;
-
-  constructor(stores: DataStore[], options: DataStoreSerializerOptions = {}) {
-    if(!getUnsafeWindow().crypto || !getUnsafeWindow().crypto.subtle)
-      throw new Error("DataStoreSerializer has to run in a secure context (HTTPS)!");
-
-    this.stores = stores;
-    this.options = {
-      addChecksum: true,
-      ensureIntegrity: true,
-      ...options,
-    };
-  }
-
-  /** Calculates the checksum of a string */
-  protected async calcChecksum(input: string): Promise<string> {
-    return computeHash(input, "SHA-256");
-  }
-
-  /**
-   * Serializes only a subset of the data stores into a string.  
-   * @param stores An array of store IDs or functions that take a store ID and return a boolean
-   * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
-   * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
-   */
-  public async serializePartial(stores: StoreFilter, useEncoding?: boolean, stringified?: true): Promise<string>;
-  /**
-   * Serializes only a subset of the data stores into a string.  
-   * @param stores An array of store IDs or functions that take a store ID and return a boolean
-   * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
-   * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
-   */
-  public async serializePartial(stores: StoreFilter, useEncoding?: boolean, stringified?: false): Promise<SerializedDataStore[]>;
-  /**
-   * Serializes only a subset of the data stores into a string.  
-   * @param stores An array of store IDs or functions that take a store ID and return a boolean
-   * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
-   * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
-   */
-  public async serializePartial(stores: StoreFilter, useEncoding?: boolean, stringified?: boolean): Promise<string | SerializedDataStore[]>;
-  /**
-   * Serializes only a subset of the data stores into a string.  
-   * @param stores An array of store IDs or functions that take a store ID and return a boolean
-   * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
-   * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
-   */
-  public async serializePartial(stores: StoreFilter, useEncoding = true, stringified = true): Promise<string | SerializedDataStore[]> {
-    const serData: SerializedDataStore[] = [];
-
-    for(const storeInst of this.stores.filter(s => typeof stores === "function" ? stores(s.id) : stores.includes(s.id))) {
-      const data = useEncoding && storeInst.encodingEnabled()
-        ? await storeInst.encodeData(JSON.stringify(storeInst.getData()))
-        : JSON.stringify(storeInst.getData());
-
-      serData.push({
-        id: storeInst.id,
-        data,
-        formatVersion: storeInst.formatVersion,
-        encoded: useEncoding && storeInst.encodingEnabled(),
-        checksum: this.options.addChecksum
-          ? await this.calcChecksum(data)
-          : undefined,
-      });
-    }
-
-    return stringified ? JSON.stringify(serData) : serData;
-  }
-
-  /**
-   * Serializes the data stores into a string.  
-   * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
-   * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
-   */
-  public async serialize(useEncoding?: boolean, stringified?: true): Promise<string>;
-  /**
-   * Serializes the data stores into a string.  
-   * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
-   * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
-   */
-  public async serialize(useEncoding?: boolean, stringified?: false): Promise<SerializedDataStore[]>;
-  /**
-   * Serializes the data stores into a string.  
-   * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
-   * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
-   */
-  public async serialize(useEncoding = true, stringified = true): Promise<string | SerializedDataStore[]> {
-    return this.serializePartial(this.stores.map(s => s.id), useEncoding, stringified);
-  }
-
-  /**
-   * Deserializes the data exported via {@linkcode serialize()} and imports only a subset into the DataStore instances.  
-   * Also triggers the migration process if the data format has changed.
-   */
-  public async deserializePartial(stores: StoreFilter, data: string | SerializedDataStore[]): Promise<void> {
-    const deserStores: SerializedDataStore[] = typeof data === "string" ? JSON.parse(data) : data;
-
-    if(!Array.isArray(deserStores) || !deserStores.every(DataStoreSerializer.isSerializedDataStoreObj))
-      throw new TypeError("Invalid serialized data format! Expected an array of SerializedDataStore objects.");
-
-    for(const storeData of deserStores.filter(s => typeof stores === "function" ? stores(s.id) : stores.includes(s.id))) {
-      const storeInst = this.stores.find(s => s.id === storeData.id);
-      if(!storeInst)
-        throw new Error(`DataStore instance with ID "${storeData.id}" not found! Make sure to provide it in the DataStoreSerializer constructor.`);
-
-      if(this.options.ensureIntegrity && typeof storeData.checksum === "string") {
-        const checksum = await this.calcChecksum(storeData.data);
-        if(checksum !== storeData.checksum)
-          throw new ChecksumMismatchError(`Checksum mismatch for DataStore with ID "${storeData.id}"!\nExpected: ${storeData.checksum}\nHas: ${checksum}`);
-      }
-
-      const decodedData = storeData.encoded && storeInst.encodingEnabled()
-        ? await storeInst.decodeData(storeData.data)
-        : storeData.data;
-
-      if(storeData.formatVersion && !isNaN(Number(storeData.formatVersion)) && Number(storeData.formatVersion) < storeInst.formatVersion)
-        await storeInst.runMigrations(JSON.parse(decodedData), Number(storeData.formatVersion), false);
-      else
-        await storeInst.setData(JSON.parse(decodedData));
-    }
-  }
-
-  /**
-   * Deserializes the data exported via {@linkcode serialize()} and imports the data into all matching DataStore instances.  
-   * Also triggers the migration process if the data format has changed.
-   */
-  public async deserialize(data: string | SerializedDataStore[]): Promise<void> {
-    return this.deserializePartial(this.stores.map(s => s.id), data);
-  }
-
-  /**
-   * Loads the persistent data of the DataStore instances into the in-memory cache.  
-   * Also triggers the migration process if the data format has changed.
-   * @param stores An array of store IDs or a function that takes the store IDs and returns a boolean - if omitted, all stores will be loaded
-   * @returns Returns a PromiseSettledResult array with the results of each DataStore instance in the format `{ id: string, data: object }`
-   */
-  public async loadStoresData(stores?: StoreFilter): Promise<PromiseSettledResult<LoadStoresDataResult>[]> {
-    return Promise.allSettled(
-      this.getStoresFiltered(stores)
-        .map(async (store) => ({
-          id: store.id,
-          data: await store.loadData(),
-        })),
-    );
-  }
-
-  /**
-   * Resets the persistent and in-memory data of the DataStore instances to their default values.
-   * @param stores An array of store IDs or a function that takes the store IDs and returns a boolean - if omitted, all stores will be affected
-   */
-  public async resetStoresData(stores?: StoreFilter): Promise<PromiseSettledResult<void>[]> {
-    return Promise.allSettled(
-      this.getStoresFiltered(stores).map(store => store.saveDefaultData()),
-    );
-  }
-
-  /**
-   * Deletes the persistent data of the DataStore instances.  
-   * Leaves the in-memory data untouched.  
-   * @param stores An array of store IDs or a function that takes the store IDs and returns a boolean - if omitted, all stores will be affected
-   */
-  public async deleteStoresData(stores?: StoreFilter): Promise<PromiseSettledResult<void>[]> {
-    return Promise.allSettled(
-      this.getStoresFiltered(stores).map(store => store.deleteData()),
-    );
-  }
-
-  /** Checks if a given value is an array of SerializedDataStore objects */
-  public static isSerializedDataStoreObjArray(obj: unknown): obj is SerializedDataStore[] {
-    return Array.isArray(obj) && obj.every((o) => typeof o === "object" && o !== null && "id" in o && "data" in o && "formatVersion" in o && "encoded" in o);
-  }
-
-  /** Checks if a given value is a SerializedDataStore object */
-  public static isSerializedDataStoreObj(obj: unknown): obj is SerializedDataStore {
-    return typeof obj === "object" && obj !== null && "id" in obj && "data" in obj && "formatVersion" in obj && "encoded" in obj;
-  }
-
-  /** Returns the DataStore instances whose IDs match the provided array or function */
-  protected getStoresFiltered(stores?: StoreFilter): DataStore[] {
-    return this.stores.filter(s => typeof stores === "undefined" ? true : Array.isArray(stores) ? stores.includes(s.id) : stores(s.id));
-  }
-}

+ 0 - 172
lib/Debouncer.spec.ts

@@ -1,172 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { debounce, Debouncer } from "./Debouncer.js";
-import { pauseFor } from "./misc.js";
-
-describe("Debouncer", () => {
-  //#region deltaT
-  it("deltaT test with type \"immediate\"", async () => {
-    const deb = new Debouncer(200, "immediate");
-
-    deb.addListener(debCalled);
-
-    const deltaTs: number[] = [];
-    let lastCall: number | undefined;
-    function debCalled() {
-      const n = Date.now(),
-        deltaT = lastCall ? n - lastCall : undefined;
-      typeof deltaT === "number" && deltaT > 0 && deltaTs.push(deltaT);
-      lastCall = n;
-    }
-
-    for(let i = 0; i < 2; i++) {
-      for(let j = 0; j < 6; j++) {
-        deb.call(i, j);
-        expect(deb.isTimeoutActive()).toBe(true);
-        await pauseFor(50);
-      }
-      await pauseFor(300);
-    }
-
-    const avg = deltaTs
-      .reduce((a, b) => a + b, 0) / deltaTs.length;
-
-    expect(avg + 10).toBeLessThanOrEqual(deb.getTimeout() + 50);
-  });
-
-  //#region idle
-  it("deltaT test with type \"idle\"", async () => {
-    const deb = new Debouncer(200, "idle");
-
-    deb.addListener(debCalled);
-
-    const deltaTs: number[] = [];
-    let callCount = 0;
-    let lastCall: number | undefined;
-    function debCalled() {
-      callCount++;
-      const n = Date.now(),
-        deltaT = lastCall ? n - lastCall : undefined;
-      typeof deltaT === "number" && deltaT > 0 && deltaTs.push(deltaT);
-      lastCall = n;
-    }
-
-    const jAmt = 6,
-      iTime = 400,
-      jTime = 30;
-    for(let i = 0; i < 2; i++) {
-      for(let j = 0; j < jAmt; j++) {
-        deb.call(i, j);
-        await pauseFor(jTime);
-      }
-      await pauseFor(iTime);
-    }
-
-    expect(callCount).toBeLessThanOrEqual(5); // expected 2~3 calls
-
-    /** Minimum possible deltaT between calls */
-    const minDeltaT = jAmt * jTime + iTime;
-    const avg = deltaTs
-      .reduce((a, b) => a + b, 0) / deltaTs.length;
-
-    expect(avg + 10).toBeGreaterThanOrEqual(minDeltaT);
-  });
-
-  //#region modify props & listeners
-  it("Modify props and listeners", async () => {
-    const deb = new Debouncer(200);
-
-    expect(deb.getTimeout()).toBe(200);
-    deb.setTimeout(10);
-    expect(deb.getTimeout()).toBe(10);
-
-    expect(deb.getType()).toBe("immediate");
-    deb.setType("idle");
-    expect(deb.getType()).toBe("idle");
-
-    const l = () => {};
-    deb.addListener(l);
-    deb.addListener(() => {});
-    expect(deb.getListeners()).toHaveLength(2);
-
-    deb.removeListener(l);
-    expect(deb.getListeners()).toHaveLength(1);
-
-    deb.removeAllListeners();
-    expect(deb.getListeners()).toHaveLength(0);
-  });
-
-  //#region all methods
-  // TODO:FIXME:
-  it.skip("All methods", async () => {
-    const deb = new Debouncer<(v?: number) => void>(200);
-
-    let callAmt = 0, evtCallAmt = 0;
-    const debCalled = (): number => ++callAmt;
-    const debCalledEvt = (): number => ++evtCallAmt;
-
-    // hook debCalled first, then call, then hook debCalledEvt:
-    deb.addListener(debCalled);
-
-    deb.call();
-
-    deb.on("call", debCalledEvt);
-
-    expect(callAmt).toBe(1);
-    expect(evtCallAmt).toBe(0);
-
-    deb.setTimeout(10);
-    expect(deb.getTimeout()).toBe(10);
-
-    const callPaused = (v?: number): Promise<void> => {
-      deb.call(v);
-      return pauseFor(50);
-    };
-
-    let onceAmt = 0;
-    deb.once("call", () => ++onceAmt);
-    await callPaused();
-    await callPaused();
-    await callPaused();
-    expect(onceAmt).toBe(1);
-
-    let args = 0;
-    const setArgs = (v?: number) => args = v ?? args;
-    deb.addListener(setArgs);
-    await callPaused(1);
-    expect(args).toBe(1);
-
-    deb.removeListener(setArgs);
-    await callPaused(2);
-    expect(args).toBe(1);
-
-    deb.removeAllListeners();
-    await callPaused();
-    expect(callAmt).toEqual(evtCallAmt + 1); // evtCallAmt is always behind by 1
-  });
-
-  //#region errors
-  it("Errors", () => {
-    try {
-      // @ts-expect-error
-      const deb = new Debouncer(200, "invalid");
-      deb.call();
-    }
-    catch(e) {
-      expect(e).toBeInstanceOf(TypeError);
-    }
-  });
-
-  //#region debounce function
-  it("Debounce function", async () => {
-    let callAmt = 0;
-    const callFn = debounce(() => ++callAmt, 200);
-
-    for(let i = 0; i < 4; i++) {
-      callFn();
-      await pauseFor(25);
-    }
-
-    expect(callAmt).toBe(1);
-  });
-});
-  

+ 0 - 193
lib/Debouncer.ts

@@ -1,193 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
- 
-/**
- * @module lib/Debouncer
- * This module contains the Debouncer class and debounce function that allow you to reduce the amount of calls in rapidly firing event listeners and such - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer)
- */
-
-import { NanoEmitter } from "./NanoEmitter.js";
-
-//#region types
-
-/**
- * The type of edge to use for the debouncer - [see the docs for a diagram and explanation.](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer)  
- * - `immediate` - (default & recommended) - calls the listeners at the very first call ("rising" edge) and queues the latest call until the timeout expires  
- *   - Pros:  
- *     - First call is let through immediately  
- *   - Cons:  
- *     - After all calls stop, the JS engine's event loop will continue to run until the last timeout expires (doesn't really matter on the web, but could cause a process exit delay in Node.js)
- * - `idle` - queues all calls until there are no more calls in the given timeout duration ("falling" edge), and only then executes the very last call  
- *   - Pros:  
- *     - Makes sure there are zero calls in the given `timeoutDuration` before executing the last call
- *   - Cons:
- *     - Calls are always delayed by at least `1 * timeoutDuration`
- *     - Calls could get stuck in the queue indefinitely if there is no downtime between calls that is greater than the `timeoutDuration`
- */
-export type DebouncerType = "immediate" | "idle";
-
-type AnyFunc = (...args: any) => any;
-
-/** The debounced function type that is returned by the {@linkcode debounce} function */
-export type DebouncedFunction<TFunc extends AnyFunc> = ((...args: Parameters<TFunc>) => ReturnType<TFunc>) & { debouncer: Debouncer<TFunc> };
-
-/** Event map for the {@linkcode Debouncer} */
-export type DebouncerEventMap<TFunc extends AnyFunc> = {
-  /** Emitted when the debouncer calls all registered listeners, as a pub-sub alternative */
-  call: TFunc;
-  /** Emitted when the timeout or edge type is changed after the instance was created */
-  change: (timeout: number, type: DebouncerType) => void;
-};
-
-//#region debounce class
-
-/**
- * A debouncer that calls all listeners after a specified timeout, discarding all calls in-between.  
- * It is very useful for event listeners that fire quickly, like `input` or `mousemove`, to prevent the listeners from being called too often and hogging resources.  
- * The exact behavior can be customized with the `type` parameter.  
- *   
- * The instance inherits from {@linkcode NanoEmitter} and emits the following events:  
- * - `call` - emitted when the debouncer calls all listeners - use this as a pub-sub alternative to the default callback-style listeners
- * - `change` - emitted when the timeout or edge type is changed after the instance was created
- */
-export class Debouncer<TFunc extends AnyFunc> extends NanoEmitter<DebouncerEventMap<TFunc>> {
-  /** All registered listener functions and the time they were attached */
-  protected listeners: TFunc[] = [];
-
-  /** The currently active timeout */
-  protected activeTimeout: ReturnType<typeof setTimeout> | undefined;
-
-  /** The latest queued call */
-  protected queuedCall: (() => void) | undefined;
-
-  /**
-   * Creates a new debouncer with the specified timeout and edge type.
-   * @param timeout Timeout in milliseconds between letting through calls - defaults to 200
-   * @param type The edge type to use for the debouncer - see {@linkcode DebouncerType} for details or [the documentation for an explanation and diagram](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer) - defaults to "immediate"
-   */
-  constructor(protected timeout = 200, protected type: DebouncerType = "immediate") {
-    super();
-  }
-
-  //#region listeners
-
-  /** Adds a listener function that will be called on timeout */
-  public addListener(fn: TFunc): void {
-    this.listeners.push(fn);
-  }
-
-  /** Removes the listener with the specified function reference */
-  public removeListener(fn: TFunc): void {
-    const idx = this.listeners.findIndex((l) => l === fn);
-    idx !== -1 && this.listeners.splice(idx, 1);
-  }
-
-  /** Removes all listeners */
-  public removeAllListeners(): void {
-    this.listeners = [];
-  }
-
-  /** Returns all registered listeners */
-  public getListeners(): TFunc[] {
-    return this.listeners;
-  }
-
-  //#region timeout
-
-  /** Sets the timeout for the debouncer */
-  public setTimeout(timeout: number): void {
-    this.emit("change", this.timeout = timeout, this.type);
-  }
-
-  /** Returns the current timeout */
-  public getTimeout(): number {
-    return this.timeout;
-  }
-
-  /** Whether the timeout is currently active, meaning any latest call to the {@linkcode call()} method will be queued */
-  public isTimeoutActive(): boolean {
-    return typeof this.activeTimeout !== "undefined";
-  }
-
-  //#region type
-
-  /** Sets the edge type for the debouncer */
-  public setType(type: DebouncerType): void {
-    this.emit("change", this.timeout, this.type = type);
-  }
-
-  /** Returns the current edge type */
-  public getType(): DebouncerType {
-    return this.type;
-  }
-
-  //#region call
-
-  /** Use this to call the debouncer with the specified arguments that will be passed to all listener functions registered with {@linkcode addListener()} */
-  public call(...args: Parameters<TFunc>): void {
-    /** When called, calls all registered listeners */
-    const cl = (...a: Parameters<TFunc>): void => {
-      this.queuedCall = undefined;
-      this.emit("call", ...a);
-      this.listeners.forEach((l) => l.call(this, ...a));
-    };
-
-    /** Sets a timeout that will call the latest queued call and then set another timeout if there was a queued call */
-    const setRepeatTimeout = (): void => {
-      this.activeTimeout = setTimeout(() => {
-        if(this.queuedCall) {
-          this.queuedCall();
-          setRepeatTimeout();
-        }
-        else
-          this.activeTimeout = undefined;
-      }, this.timeout);
-    };
-
-    switch(this.type) {
-    case "immediate":
-      if(typeof this.activeTimeout === "undefined") {
-        cl(...args);
-        setRepeatTimeout();
-      }
-      else
-        this.queuedCall = () => cl(...args);
-
-      break;
-    case "idle":
-      if(this.activeTimeout)
-        clearTimeout(this.activeTimeout);
-
-      this.activeTimeout = setTimeout(() => {
-        cl(...args);
-        this.activeTimeout = undefined;
-      }, this.timeout);
-
-      break;
-    default:
-      throw new TypeError(`Invalid debouncer type: ${this.type}`);
-    }
-  }
-}
-
-//#region debounce fn
-
-/**
- * Creates a {@linkcode Debouncer} instance with the specified timeout and edge type and attaches the passed function as a listener.  
- * The returned function can be called with any arguments and will execute the `call()` method of the debouncer.  
- * The debouncer instance is accessible via the `debouncer` property of the returned function.  
- *   
- * Refer to the {@linkcode Debouncer} class definition or the [Debouncer documentation](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer) for more information.
- */
-export function debounce<TFunc extends (...args: any[]) => any>(
-  fn: TFunc,
-  timeout = 200,
-  type: DebouncerType = "immediate"
-): DebouncedFunction<TFunc> {
-  const debouncer = new Debouncer<TFunc>(timeout, type);
-  debouncer.addListener(fn);
-
-  const func = (((...args: Parameters<TFunc>) => debouncer.call(...args))) as DebouncedFunction<TFunc>;
-  func.debouncer = debouncer;
-
-  return func;
-}

+ 1 - 1
lib/Dialog.ts

@@ -3,7 +3,7 @@
  * This module contains the Dialog class, which allows you to quickly and easily create modal dialogs - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#dialog)
  */
 
-import { NanoEmitter } from "./NanoEmitter.js";
+import { NanoEmitter } from "@sv443-network/coreutils";
 import { addGlobalStyle } from "./dom.js";
 
 export const defaultDialogCss: string = `\

+ 25 - 16
lib/Mixins.spec.ts

@@ -1,12 +1,21 @@
 import { describe, it, expect } from "vitest";
 import { Mixins } from "./Mixins.js";
 
+class TestMixins<
+  TMixinMap extends Record<string, (arg: any, ctx?: any) => any>,
+  TMixinKey extends Extract<keyof TMixinMap, string> = Extract<keyof TMixinMap, string>,
+> extends Mixins<TMixinMap, TMixinKey> {
+  public removeAll(key: TMixinKey) {
+    super.removeAll(key);
+  }
+}
+
 describe("Mixins", () => {
   //#region base
   it("Base resolution", () => {
-    const mixins = new Mixins<{
-      foo: (v: number, ctx: { a: number }) => number;
-        }>({ autoIncrementPriority: true });
+    const mixins = new TestMixins<{ foo: (v: number, ctx: { a: number }) => number; }>({
+      autoIncrementPriority: true,
+    });
 
     mixins.add("foo", (v) => v ^ 0b0001); // 1 (prio 0)
     mixins.add("foo", (v) => v ^ 0b1000); // 2 (prio 1)
@@ -22,38 +31,38 @@ describe("Mixins", () => {
 
     expect(mixins.list()).toHaveLength(3);
     expect(mixins.list().every(m => m.key === "foo")).toBe(true);
+
+    mixins.removeAll("foo");
+    expect(mixins.list()).toHaveLength(0);
   });
 
   //#region priority
   it("Priority resolution", () => {
-    const mixins = new Mixins<{
-      foo: (v: number) => number;
-        }>();
+    const mixins = new Mixins<{ foo: (v: number) => number; }>();
 
     mixins.add("foo", (v) => v / 2, 1); // 2 (prio 1)
-    mixins.add("foo", (v) => Math.round(Math.log(v) * 10), -1); // 4 (prio -1)
-    mixins.add("foo", (v) => Math.pow(v, 2)); // 3 (prio 0)
+    mixins.add("foo", (v) => Math.round(Math.log(v) * 10), -1); // 4 (prio -1, index 0)
+    mixins.add("foo", (v) => v + 2, -1); // 5 (prio -1, index 1)
+    mixins.add("foo", (v) => v ** 2); // 3 (prio 0)
     mixins.add("foo", (v) => Math.sqrt(v), Number.MAX_SAFE_INTEGER); // 1 (prio max)
 
     // input: 100
     // 1: sqrt(100) = 10
     // 2: 10 / 2 = 5
-    // 3: 5 ^ 2 = 25
+    // 3: 5 ** 2 = 25
     // 4: round(log(25) * 10) = round(32.188758248682006) = 32
-    // result: 3
+    // 5: 32 + 2 = 34
 
-    expect(mixins.resolve("foo", 100)).toBe(32);
+    expect(mixins.resolve("foo", 100)).toBe(34);
   });
 
   //#region sync/async & cleanup
   it("Sync/async resolution & cleanup", async () => {
     const acAll = new AbortController();
 
-    const mixins = new Mixins<{
-      foo: (v: number) => Promise<number>;
-        }>({
-          defaultSignal: acAll.signal,
-        });
+    const mixins = new Mixins<{ foo: (v: number) => Promise<number>; }>({
+      defaultSignal: acAll.signal,
+    });
 
     const ac1 = new AbortController();
 

+ 4 - 5
lib/Mixins.ts

@@ -5,8 +5,7 @@
 
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
-import { purifyObj } from "./misc.js";
-import type { Prettify } from "./types.js";
+import { purifyObj, type Prettify } from "@sv443-network/coreutils";
 
 /** Full mixin object (either sync or async), as it is stored in the instance's mixin array. */
 export type MixinObj<TArg, TCtx> = Prettify<
@@ -46,9 +45,9 @@ export type MixinConfig = {
 export type MixinsConstructorConfig = {
   /**
    * If true, when no priority is specified, an auto-incrementing integer priority will be used, starting at `defaultPriority` or 0 (unique per mixin key). Defaults to false.  
-   * If a priority value is already used, it will be incremented until a unique value is found.  
-   * This is useful to ensure that mixins are applied in the order they were added, even if they don't specify a priority.  
-   * It also allows for a finer level of interjection when the priority is a floating point number.
+   * This means that the first mixin added will have the lowest priority of `defaultPriority`, and the last one will have the highest priority and be applied first.  
+   * If a priority value is already in use, it will be incremented until a unique value is found.  
+   * A finer level of interjection can be achieved by manually setting the priority to a floating point number, while the auto-incrementing priority will always be an integer.
    */
   autoIncrementPriority: boolean;
   /** The default priority for mixins that do not specify one. Defaults to 0. */

+ 0 - 60
lib/NanoEmitter.spec.ts

@@ -1,60 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { NanoEmitter } from "./NanoEmitter.js";
-
-describe("NanoEmitter", () => {
-  it("Functional", async () => {
-    const evts = new NanoEmitter<{
-      val: (v1: number, v2: number) => void;
-    }>({
-      publicEmit: true,
-    });
-
-    setTimeout(() => evts.emit("val", 5, 5), 1);
-    const [v1, v2] = await evts.once("val");
-    expect(v1 + v2).toBe(10);
-
-    let v3 = 0, v4 = 0;
-    const unsub = evts.on("val", (v1, v2) => {
-      v3 = v1;
-      v4 = v2;
-    });
-    evts.emit("val", 10, 10);
-    expect(v3 + v4).toBe(20);
-
-    unsub();
-    evts.emit("val", 20, 20);
-    expect(v3 + v4).toBe(20);
-
-    evts.on("val", (v1, v2) => {
-      v3 = v1;
-      v4 = v2;
-    });
-    evts.emit("val", 30, 30);
-    expect(v3 + v4).toBe(60);
-    evts.unsubscribeAll();
-    evts.emit("val", 40, 40);
-    expect(v3 + v4).toBe(60);
-  });
-
-  it("Object oriented", async () => {
-    class MyEmitter extends NanoEmitter<{
-      val: (v1: number, v2: number) => void;
-    }> {
-      constructor() {
-        super({ publicEmit: false });
-      }
-
-      run() {
-        this.events.emit("val", 5, 5);
-      }
-    }
-
-    const evts = new MyEmitter();
-
-    setTimeout(() => evts.run(), 1);
-    const [v1, v2] = await evts.once("val");
-    expect(v1 + v2).toBe(10);
-
-    expect(evts.emit("val", 0, 0)).toBe(false);
-  });
-});

+ 0 - 123
lib/NanoEmitter.ts

@@ -1,123 +0,0 @@
-/**
- * @module lib/NanoEmitter
- * This module contains the NanoEmitter class, which is a tiny event emitter powered by [nanoevents](https://www.npmjs.com/package/nanoevents) - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#nanoemitter)
- */
-
-import { createNanoEvents, type DefaultEvents, type Emitter, type EventsMap, type Unsubscribe } from "nanoevents";
-
-export interface NanoEmitterOptions {
-  /** If set to true, allows emitting events through the public method emit() */
-  publicEmit: boolean;
-}
-
-/**
- * Class that can be extended or instantiated by itself to create a lightweight event emitter with helper methods and a strongly typed event map.  
- * If extended from, you can use `this.events.emit()` to emit events, even if the `emit()` method doesn't work because `publicEmit` is not set to true in the constructor.
- */
-export class NanoEmitter<TEvtMap extends EventsMap = DefaultEvents> {
-  protected readonly events: Emitter<TEvtMap> = createNanoEvents<TEvtMap>();
-  protected eventUnsubscribes: Unsubscribe[] = [];
-  protected emitterOptions: NanoEmitterOptions;
-
-  /** Creates a new instance of NanoEmitter - a lightweight event emitter with helper methods and a strongly typed event map */
-  constructor(options: Partial<NanoEmitterOptions> = {}) {
-    this.emitterOptions = {
-      publicEmit: false,
-      ...options,
-    };
-  }
-
-  /**
-   * Subscribes to an event and calls the callback when it's emitted.  
-   * @param event The event to subscribe to. Use `as "_"` in case your event names aren't thoroughly typed (like when using a template literal, e.g. \`event-${val}\` as "_")
-   * @returns Returns a function that can be called to unsubscribe the event listener
-   * @example ```ts
-   * const emitter = new NanoEmitter<{
-   *   foo: (bar: string) => void;
-   * }>({
-   *   publicEmit: true,
-   * });
-   * 
-   * let i = 0;
-   * const unsub = emitter.on("foo", (bar) => {
-   *   // unsubscribe after 10 events:
-   *   if(++i === 10) unsub();
-   *   console.log(bar);
-   * });
-   * 
-   * emitter.emit("foo", "bar");
-   * ```
-   */
-  public on<TKey extends keyof TEvtMap>(event: TKey | "_", cb: TEvtMap[TKey]): () => void {
-    // eslint-disable-next-line prefer-const
-    let unsub: Unsubscribe | undefined;
-
-    const unsubProxy = (): void => {
-      if(!unsub)
-        return;
-      unsub();
-      this.eventUnsubscribes = this.eventUnsubscribes.filter(u => u !== unsub);
-    };
-
-    unsub = this.events.on(event, cb);
-
-    this.eventUnsubscribes.push(unsub);
-    return unsubProxy;
-  }
-
-  /**
-   * Subscribes to an event and calls the callback or resolves the Promise only once when it's emitted.  
-   * @param event The event to subscribe to. Use `as "_"` in case your event names aren't thoroughly typed (like when using a template literal, e.g. \`event-${val}\` as "_")
-   * @param cb The callback to call when the event is emitted - if provided or not, the returned Promise will resolve with the event arguments
-   * @returns Returns a Promise that resolves with the event arguments when the event is emitted
-   * @example ```ts
-   * const emitter = new NanoEmitter<{
-   *   foo: (bar: string) => void;
-   * }>();
-   * 
-   * // Promise syntax:
-   * const [bar] = await emitter.once("foo");
-   * console.log(bar);
-   * 
-   * // Callback syntax:
-   * emitter.once("foo", (bar) => console.log(bar));
-   * ```
-   */
-  public once<TKey extends keyof TEvtMap>(event: TKey | "_", cb?: TEvtMap[TKey]): Promise<Parameters<TEvtMap[TKey]>> {
-    return new Promise((resolve) => {
-      // eslint-disable-next-line prefer-const
-      let unsub: Unsubscribe | undefined;
-
-      const onceProxy = ((...args: Parameters<TEvtMap[TKey]>) => {
-        cb?.(...args);
-        unsub?.();
-        resolve(args);
-      }) as TEvtMap[TKey];
-
-      unsub = this.events.on(event, onceProxy);
-      this.eventUnsubscribes.push(unsub);
-    });
-  }
-
-  /**
-   * Emits an event on this instance.  
-   * ⚠️ Needs `publicEmit` to be set to true in the NanoEmitter constructor or super() call!
-   * @param event The event to emit
-   * @param args The arguments to pass to the event listeners
-   * @returns Returns true if `publicEmit` is true and the event was emitted successfully
-   */
-  public emit<TKey extends keyof TEvtMap>(event: TKey, ...args: Parameters<TEvtMap[TKey]>): boolean {
-    if(this.emitterOptions.publicEmit) {
-      this.events.emit(event, ...args);
-      return true;
-    }
-    return false;
-  }
-
-  /** Unsubscribes all event listeners from this instance */
-  public unsubscribeAll(): void {
-    for(const unsub of this.eventUnsubscribes)
-      unsub();
-    this.eventUnsubscribes = [];
-  }
-}

+ 0 - 80
lib/array.spec.ts

@@ -1,80 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { randomItem, randomItemIndex, randomizeArray, takeRandomItem } from "./array.js";
-
-//#region randomItem
-describe("array/randomItem", () => {
-  it("Returns a random item", () => {
-    const arr = [1, 2, 3, 4];
-    const items = [] as number[];
-
-    for(let i = 0; i < 500; i++)
-      items.push(randomItem(arr)!);
-
-    const missing = arr.filter(item => !items.some(i => i === item));
-    expect(missing).toHaveLength(0);
-  });
-
-  it("Returns undefined for an empty array", () => {
-    expect(randomItem([])).toBeUndefined();
-  });
-});
-
-//#region randomItemIndex
-describe("array/randomItemIndex", () => {
-  it("Returns a random item with the correct index", () => {
-    const arr = [1, 2, 3, 4];
-    const items = [] as [number, number][];
-
-    for(let i = 0; i < 500; i++)
-      items.push(randomItemIndex(arr) as [number, number]);
-
-    const missing = arr.filter((item, index) => !items.some(([it, idx]) => it === item && idx === index));
-    expect(missing).toHaveLength(0);
-  });
-
-  it("Returns undefined for an empty array", () => {
-    expect(randomItemIndex([])).toEqual([undefined, undefined]);
-  });
-});
-
-//#region takeRandomItem
-describe("array/takeRandomItem", () => {
-  it("Returns a random item and removes it from the array", () => {
-    const arr = [1, 2];
-
-    const itm = takeRandomItem(arr);
-    expect(arr).not.toContain(itm);
-
-    takeRandomItem(arr);
-
-    const itm2 = takeRandomItem(arr);
-    expect(itm2).toBeUndefined();
-    expect(arr).toHaveLength(0);
-  });
-
-  it("Returns undefined for an empty array", () => {
-    expect(takeRandomItem([])).toBeUndefined();
-  });
-});
-
-//#region randomizeArray
-describe("array/randomizeArray", () => {
-  it("Returns a copy of the array with a random item order", () => {
-    const arr = Array.from({ length: 100 }, (_, i) => i);
-    const randomized = randomizeArray(arr);
-
-    expect(randomized === arr).toBe(false);
-    expect(randomized).toHaveLength(arr.length);
-
-    const sameItems = arr.filter((item, i) => randomized[i] === item);
-    expect(sameItems.length).toBeLessThan(arr.length);
-  });
-
-  it("Returns an empty array as-is", () => {
-    const arr = [] as number[];
-    const randomized = randomizeArray(arr);
-
-    expect(randomized === arr).toBe(false);
-    expect(randomized).toHaveLength(0);
-  });
-});

+ 0 - 54
lib/array.ts

@@ -1,54 +0,0 @@
-/**
- * @module lib/array
- * This module contains various functions for working with arrays - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#arrays)
- */
-
-import { randRange } from "./math.js";
-
-/** Describes an array with at least one item */
-export type NonEmptyArray<TArray = unknown> = [TArray, ...TArray[]];
-
-/** Returns a random item from the passed array */
-export function randomItem<TItem = unknown>(array: TItem[]): TItem | undefined {
-  return randomItemIndex<TItem>(array)[0];
-}
-
-/**
- * Returns a tuple of a random item and its index from the passed array  
- * Returns `[undefined, undefined]` if the passed array is empty
- */
-export function randomItemIndex<TItem = unknown>(array: TItem[]): [item?: TItem, index?: number] {
-  if(array.length === 0)
-    return [undefined, undefined];
-
-  const idx = randRange(array.length - 1);
-
-  return [array[idx]!, idx];
-}
-
-/** Returns a random item from the passed array and mutates the array to remove the item */
-export function takeRandomItem<TItem = unknown>(arr: TItem[]): TItem | undefined {
-  const [itm, idx] = randomItemIndex<TItem>(arr);
-
-  if(idx === undefined)
-    return undefined;
-
-  arr.splice(idx, 1);
-  return itm as TItem;
-}
-
-/** Returns a copy of the array with its items in a random order */
-export function randomizeArray<TItem = unknown>(array: TItem[]): TItem[] {
-  const retArray = [...array]; // so array and retArray don't point to the same memory address
-
-  if(array.length === 0)
-    return retArray;
-
-  // shamelessly stolen from https://javascript.info/task/shuffle
-  for(let i = retArray.length - 1; i > 0; i--) {
-    const j = Math.floor((Math.random() * (i + 1)));
-    [retArray[i], retArray[j]] = [retArray[j], retArray[i]] as [TItem, TItem];
-  }
-
-  return retArray;
-}

+ 0 - 69
lib/colors.spec.ts

@@ -1,69 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { darkenColor, hexToRgb, lightenColor, rgbToHex } from "./colors.js";
-
-//#region hexToRgb
-describe("colors/hexToRgb", () => {
-  it("Converts a hex color string to an RGB tuple", () => {
-    const hex = "#FF0000";
-    const [r, g, b, a] = hexToRgb(hex);
-
-    expect(r).toBe(255);
-    expect(g).toBe(0);
-    expect(b).toBe(0);
-    expect(a).toBeUndefined();
-  });
-
-  it("Converts a hex color string with an alpha channel to an RGBA tuple", () => {
-    const hex = "#FF0000FF";
-    const [r, g, b, a] = hexToRgb(hex);
-
-    expect(r).toBe(255);
-    expect(g).toBe(0);
-    expect(b).toBe(0);
-    expect(a).toBe(1);
-  });
-
-  it("Works as expected with invalid input", () => {
-    expect(hexToRgb("")).toEqual([0, 0, 0, undefined]);
-  });
-});
-
-//#region rgbToHex
-describe("colors/rgbToHex", () => {
-  it("Converts an RGB tuple to a hex color string", () => {
-    expect(rgbToHex(255, 0, 0, undefined, true, true)).toBe("#FF0000");
-    expect(rgbToHex(255, 0, 0, undefined, true, false)).toBe("#ff0000");
-    expect(rgbToHex(255, 0, 0, undefined, false, false)).toBe("ff0000");
-    expect(rgbToHex(255, 0, 127, 0.5, false, false)).toBe("ff007f80");
-    expect(rgbToHex(0, 0, 0, 1)).toBe("#000000ff");
-  });
-
-  it("Handles special values as expected", () => {
-    expect(rgbToHex(NaN, Infinity, -1, 255)).toBe("#nanff00ff");
-    expect(rgbToHex(256, -1, 256, -1, false, true)).toBe("FF00FF00");
-  });
-
-  it("Works as expected with invalid input", () => {
-    expect(rgbToHex(0, 0, 0, 0)).toBe("#000000");
-    //@ts-ignore
-    expect(rgbToHex(NaN, "ello", 0, -1)).toBe("#nannan0000");
-  });
-});
-
-//#region lightenColor
-describe("colors/lightenColor", () => {
-  it("Lightens a color by a given percentage", () => {
-    expect(lightenColor("#ab35de", 50)).toBe("#ff50ff");
-    expect(lightenColor("ab35de", Infinity, true)).toBe("FFFFFF");
-    expect(lightenColor("rgba(255, 50, 127, 0.5)", 50)).toBe("rgba(255, 75, 190.5, 0.5)");
-    expect(lightenColor("rgb(255, 50, 127)", 50)).toBe("rgb(255, 75, 190.5)");
-  });
-});
-
-//#region darkenColor
-describe("colors/darkenColor", () => {
-  it("Darkens a color by a given percentage", () => {
-    // since both functions are the exact same but with a different sign, only one test is needed:
-    expect(darkenColor("#1affe3", 50)).toBe(lightenColor("#1affe3", -50));
-  });
-});

+ 0 - 89
lib/colors.ts

@@ -1,89 +0,0 @@
-/**
- * @module lib/colors
- * This module contains various functions for working with colors - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#colors)
- */
-
-import { clamp } from "./math.js";
-
-/**
- * Converts a hex color string in the format `#RRGGBB`, `#RRGGBBAA` (or even `RRGGBB` and `RGB`) to a tuple.  
- * @returns Returns a tuple array where R, G and B are an integer from 0-255 and alpha is a float from 0 to 1, or undefined if no alpha channel exists.
- */
-export function hexToRgb(hex: string): [red: number, green: number, blue: number, alpha?: number] {
-  hex = (hex.startsWith("#") ? hex.slice(1) : hex).trim();
-  const a = hex.length === 8 || hex.length === 4 ? parseInt(hex.slice(-(hex.length / 4)), 16) / (hex.length === 8 ? 255 : 15) : undefined;
-
-  if(!isNaN(Number(a)))
-    hex = hex.slice(0, -(hex.length / 4));
-
-  if(hex.length === 3 || hex.length === 4)
-    hex = hex.split("").map(c => c + c).join("");
-
-  const bigint = parseInt(hex, 16);
-
-  const r = (bigint >> 16) & 255;
-  const g = (bigint >> 8) & 255;
-  const b = bigint & 255;
-
-  return [clamp(r, 0, 255), clamp(g, 0, 255), clamp(b, 0, 255), typeof a === "number" ? clamp(a, 0, 1) : undefined];
-}
-
-/** Converts RGB or RGBA number values to a hex color string in the format `#RRGGBB` or `#RRGGBBAA` */
-export function rgbToHex(red: number, green: number, blue: number, alpha?: number, withHash = true, upperCase = false): string {
-  const toHexVal = (n: number): string =>
-    clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0")[(upperCase ? "toUpperCase" : "toLowerCase")]();
-  return `${withHash ? "#" : ""}${toHexVal(red)}${toHexVal(green)}${toHexVal(blue)}${alpha ? toHexVal(alpha * 255) : ""}`;
-}
-
-/**
- * Lightens a CSS color value (in #HEX, rgb() or rgba() format) by a given percentage.  
- * Will not exceed the maximum range (00-FF or 0-255).
- * @returns Returns the new color value in the same format as the input
- * @throws Throws if the color format is invalid or not supported
- */
-export function lightenColor(color: string, percent: number, upperCase = false): string {
-  return darkenColor(color, percent * -1, upperCase);
-}
-
-/**
- * Darkens a CSS color value (in #HEX, rgb() or rgba() format) by a given percentage.  
- * Will not exceed the maximum range (00-FF or 0-255).
- * @returns Returns the new color value in the same format as the input
- * @throws Throws if the color format is invalid or not supported
- */
-export function darkenColor(color: string, percent: number, upperCase = false): string {
-  color = color.trim();
-
-  const darkenRgb = (r: number, g: number, b: number, percent: number): [number, number, number] => {
-    r = Math.max(0, Math.min(255, r - (r * percent / 100)));
-    g = Math.max(0, Math.min(255, g - (g * percent / 100)));
-    b = Math.max(0, Math.min(255, b - (b * percent / 100)));
-    return [r, g, b];
-  };
-
-  let r: number, g: number, b: number, a: number | undefined;
-
-  const isHexCol = color.match(/^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/);
-
-  if(isHexCol)
-    [r, g, b, a] = hexToRgb(color);
-  else if(color.startsWith("rgb")) {
-    const rgbValues = color.match(/\d+(\.\d+)?/g)?.map(Number);
-    if(!rgbValues)
-      throw new TypeError("Invalid RGB/RGBA color format");
-    [r, g, b, a] = rgbValues as [number, number, number, number?];
-  }
-  else
-    throw new TypeError("Unsupported color format");
-
-  [r, g, b] = darkenRgb(r, g, b, percent);
-
-  if(isHexCol)
-    return rgbToHex(r, g, b, a, color.startsWith("#"), upperCase);
-  else if(color.startsWith("rgba"))
-    return `rgba(${r}, ${g}, ${b}, ${a ?? NaN})`;
-  else if(color.startsWith("rgb"))
-    return `rgb(${r}, ${g}, ${b})`;
-  else
-    throw new TypeError("Unsupported color format");
-}

+ 0 - 65
lib/crypto.spec.ts

@@ -1,65 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { compress, computeHash, decompress, randomId } from "./crypto.js";
-
-//#region compress
-describe("crypto/compress", () => {
-  it("Compresses strings and buffers as expected", async () => {
-    const input = "Hello, world!".repeat(100);
-
-    expect((await compress(input, "gzip", "string")).startsWith("H4sI")).toBe(true);
-    expect((await compress(input, "deflate", "string")).startsWith("eJzz")).toBe(true);
-    expect((await compress(input, "deflate-raw", "string")).startsWith("80jN")).toBe(true);
-    expect(await compress(input, "gzip", "arrayBuffer")).toBeInstanceOf(ArrayBuffer);
-  });
-});
-
-//#region decompress
-describe("crypto/decompress", () => {
-  it("Decompresses strings and buffers as expected", async () => {
-    const inputGz = "H4sIAAAAAAAACvNIzcnJ11Eozy/KSVH0GOWMckY5o5yRzQEAatVNcBQFAAA=";
-    const inputDf = "eJzzSM3JyddRKM8vyklR9BjljHJGOaOckc0BAOWGxZQ=";
-    const inputDfRaw = "80jNycnXUSjPL8pJUfQY5YxyRjmjnJHNAQA=";
-
-    const expectedDecomp = "Hello, world!".repeat(100);
-
-    expect(await decompress(inputGz, "gzip", "string")).toBe(expectedDecomp);
-    expect(await decompress(inputDf, "deflate", "string")).toBe(expectedDecomp);
-    expect(await decompress(inputDfRaw, "deflate-raw", "string")).toBe(expectedDecomp);
-  });
-});
-
-//#region computeHash
-describe("crypto/computeHash", () => {
-  it("Computes hashes as expected", async () => {
-    const input1 = "Hello, world!";
-    const input2 = input1.repeat(10);
-
-    expect(await computeHash(input1, "SHA-1")).toBe("943a702d06f34599aee1f8da8ef9f7296031d699");
-    expect(await computeHash(input1, "SHA-256")).toBe("315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3");
-    expect(await computeHash(input1, "SHA-512")).toBe("c1527cd893c124773d811911970c8fe6e857d6df5dc9226bd8a160614c0cd963a4ddea2b94bb7d36021ef9d865d5cea294a82dd49a0bb269f51f6e7a57f79421");
-    expect(await computeHash(input2, "SHA-256")).toBe(await computeHash(input2, "SHA-256"));
-  });
-});
-
-//#region randomId
-describe("crypto/randomId", () => {
-  it("Generates random IDs as expected", () => {
-    const id1 = randomId(32, 36, false, true);
-
-    expect(id1).toHaveLength(32);
-    expect(id1).toMatch(/^[0-9a-zA-Z]+$/);
-
-    const id2 = randomId(32, 36, true, true);
-
-    expect(id2).toHaveLength(32);
-    expect(id2).toMatch(/^[0-9a-zA-Z]+$/);
-
-    expect(randomId(32, 2, false, false)).toMatch(/^[01]+$/);
-  });
-
-  it("Handles all edge cases", () => {
-    expect(() => randomId(16, 1)).toThrow(RangeError);
-    expect(() => randomId(16, 37)).toThrow(RangeError);
-    expect(() => randomId(-1)).toThrow(RangeError);
-  });
-});

+ 0 - 112
lib/crypto.ts

@@ -1,112 +0,0 @@
-/**
- * @module lib/crypto
- * This module contains various cryptographic functions using the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#table-of-contents)
- */
-
-import { getUnsafeWindow } from "./dom.js";
-import { mapRange, randRange } from "./math.js";
-
-/** Compresses a string or an ArrayBuffer using the provided {@linkcode compressionFormat} and returns it as a base64 string */
-export async function compress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType?: "string"): Promise<string>
-/** Compresses a string or an ArrayBuffer using the provided {@linkcode compressionFormat} and returns it as an ArrayBuffer */
-export async function compress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType: "arrayBuffer"): Promise<ArrayBuffer>
-/** Compresses a string or an ArrayBuffer using the provided {@linkcode compressionFormat} and returns it as a base64 string or ArrayBuffer, depending on what {@linkcode outputType} is set to */
-export async function compress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType: "string" | "arrayBuffer" = "string"): Promise<ArrayBuffer | string> {
-  const byteArray = typeof input === "string" ? new TextEncoder().encode(input) : input;
-  const comp = new CompressionStream(compressionFormat);
-  const writer = comp.writable.getWriter();
-  writer.write(byteArray);
-  writer.close();
-  const buf = await (new Response(comp.readable).arrayBuffer());
-  return outputType === "arrayBuffer" ? buf : ab2str(buf);
-}
-
-/** Decompresses a previously compressed base64 string or ArrayBuffer, with the format passed by {@linkcode compressionFormat}, converted to a string */
-export async function decompress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType?: "string"): Promise<string>
-/** Decompresses a previously compressed base64 string or ArrayBuffer, with the format passed by {@linkcode compressionFormat}, converted to an ArrayBuffer */
-export async function decompress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType: "arrayBuffer"): Promise<ArrayBuffer>
-/** Decompresses a previously compressed base64 string or ArrayBuffer, with the format passed by {@linkcode compressionFormat}, converted to a string or ArrayBuffer, depending on what {@linkcode outputType} is set to */
-export async function decompress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType: "string" | "arrayBuffer" = "string"): Promise<ArrayBuffer | string> {
-  const byteArray = typeof input === "string" ? str2ab(input) : input;
-  const decomp = new DecompressionStream(compressionFormat);
-  const writer = decomp.writable.getWriter();
-  writer.write(byteArray);
-  writer.close();
-  const buf = await (new Response(decomp.readable).arrayBuffer());
-  return outputType === "arrayBuffer" ? buf : new TextDecoder().decode(buf);
-}
-
-/** Converts an ArrayBuffer to a base64-encoded string */
-function ab2str(buf: ArrayBuffer): string {
-  return getUnsafeWindow().btoa(
-    new Uint8Array(buf)
-      .reduce((data, byte) => data + String.fromCharCode(byte), "")
-  );
-}
-
-/** Converts a base64-encoded string to an ArrayBuffer representation of its bytes */
-function str2ab(str: string): ArrayBuffer {
-  return Uint8Array.from(getUnsafeWindow().atob(str), c => c.charCodeAt(0));
-}
-
-/**
- * Creates a hash / checksum of the given {@linkcode input} string or ArrayBuffer using the specified {@linkcode algorithm} ("SHA-256" by default).  
- *   
- * - ⚠️ Uses the SubtleCrypto API so it needs to run in a secure context (HTTPS).  
- * - ⚠️ If you use this for cryptography, make sure to use a secure algorithm (under no circumstances use SHA-1) and to [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) your input data.
- */
-export async function computeHash(input: string | ArrayBuffer, algorithm = "SHA-256"): Promise<string> {
-  let data: ArrayBuffer;
-  if(typeof input === "string") {
-    const encoder = new TextEncoder();
-    data = encoder.encode(input);
-  }
-  else
-    data = input;
-
-  const hashBuffer = await crypto.subtle.digest(algorithm, data);
-  const hashArray = Array.from(new Uint8Array(hashBuffer));
-  const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, "0")).join("");
-
-  return hashHex;
-}
-
-/**
- * Generates a random ID with the specified length and radix (16 characters and hexadecimal by default)  
- *   
- * - ⚠️ Not suitable for generating anything related to cryptography! Use [SubtleCrypto's `generateKey()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey) for that instead.
- * @param length The length of the ID to generate (defaults to 16)
- * @param radix The [radix](https://en.wikipedia.org/wiki/Radix) of each digit (defaults to 16 which is hexadecimal. Use 2 for binary, 10 for decimal, 36 for alphanumeric, etc.)
- * @param enhancedEntropy If set to true, uses [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) for better cryptographic randomness (this also makes it take longer to generate)  
- * @param randomCase If set to false, the generated ID will be lowercase only - also makes use of the `enhancedEntropy` parameter unless the output doesn't contain letters
- */
-export function randomId(length = 16, radix = 16, enhancedEntropy = false, randomCase = true): string {
-  if(length < 1)
-    throw new RangeError("The length argument must be at least 1");
-
-  if(radix < 2 || radix > 36)
-    throw new RangeError("The radix argument must be between 2 and 36");
-
-  let arr: string[] = [];
-  const caseArr = randomCase ? [0, 1] : [0];
-
-  if(enhancedEntropy) {
-    const uintArr = new Uint8Array(length);
-    crypto.getRandomValues(uintArr);
-    arr = Array.from(
-      uintArr,
-      (v) => mapRange(v, 0, 255, 0, radix).toString(radix).substring(0, 1),
-    );
-  }
-  else {
-    arr = Array.from(
-      { length },
-      () => Math.floor(Math.random() * radix).toString(radix),
-    );
-  }
-
-  if(!arr.some((v) => /[a-zA-Z]/.test(v)))
-    return arr.join("");
-
-  return arr.map((v) => caseArr[randRange(0, caseArr.length - 1, enhancedEntropy)] === 1 ? v.toUpperCase() : v).join("");
-}

+ 0 - 13
lib/errors.spec.ts

@@ -1,13 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { ChecksumMismatchError, MigrationError, PlatformError } from "./errors.js";
-
-describe("errors", () => {
-  it("Has a \"date\" property", () => {
-    expect(new ChecksumMismatchError("").name).toBe("ChecksumMismatchError");
-    expect(new MigrationError("").name).toBe("MigrationError");
-    expect(new PlatformError("").name).toBe("PlatformError");
-
-    expect(new PlatformError("").message).toBe("");
-    expect(new PlatformError("").date).toBeInstanceOf(Date);
-  });
-});

+ 2 - 25
lib/errors.ts

@@ -3,33 +3,10 @@
  * Contains custom error classes
  */
 
-/** Base class for all UserUtils errors - adds a `date` prop set to the error throw time */
-export class UUError extends Error {
-  public readonly date: Date;
-  constructor(message: string, options?: ErrorOptions) {
-    super(message, options);
-    this.date = new Date();
-  }
-}
-
-/** Error while validating checksum */
-export class ChecksumMismatchError extends UUError {
-  constructor(message: string, options?: ErrorOptions) {
-    super(message, options);
-    this.name = "ChecksumMismatchError";
-  }
-}
-
-/** Error while migrating data */
-export class MigrationError extends UUError {
-  constructor(message: string, options?: ErrorOptions) {
-    super(message, options);
-    this.name = "MigrationError";
-  }
-}
+import { DatedError } from "@sv443-network/coreutils";
 
 /** Error due to the platform, like using a feature that's not supported by the browser */
-export class PlatformError extends UUError {
+export class PlatformError extends DatedError {
   constructor(message: string, options?: ErrorOptions) {
     super(message, options);
     this.name = "PlatformError";

+ 0 - 11
lib/index.ts

@@ -3,19 +3,8 @@
  * UserUtils is a lightweight library with various utilities for userscripts, allowing you to register listeners for when CSS selectors exist, intercept events, create persistent & synchronous data stores, modify the DOM more easily and much more
  */
 
-export * from "./array.js";
-export * from "./colors.js";
-export * from "./crypto.js";
-export * from "./DataStore.js";
-export * from "./DataStoreSerializer.js";
-export * from "./Debouncer.js";
 export * from "./Dialog.js";
 export * from "./dom.js";
 export * from "./errors.js";
-export * from "./math.js";
-export * from "./misc.js";
 export * from "./Mixins.js";
-export * from "./NanoEmitter.js";
 export * from "./SelectorObserver.js";
-export * from "./translation.js";
-export * from "./types.js";

+ 0 - 117
lib/math.spec.ts

@@ -1,117 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { bitSetHas, clamp, digitCount, mapRange, randRange, roundFixed } from "./math.js";
-
-//#region clamp
-describe("math/clamp", () => {
-  it("Clamps a value between min and max", () => {
-    expect(clamp(5, 10)).toBe(5);
-    expect(clamp(-5, 0, 10)).toBe(0);
-    expect(clamp(15, 0, 10)).toBe(10);
-    expect(clamp(Number.MAX_SAFE_INTEGER, 0, Infinity)).toBe(Number.MAX_SAFE_INTEGER);
-  });
-
-  it("Handles edge cases", () => {
-    expect(clamp(0, 1, 0)).toThrow(TypeError);
-    // @ts-expect-error
-    expect(clamp("1", 0, 1)).toThrow(TypeError);
-  });
-});
-
-//#region mapRange
-describe("math/mapRange", () => {
-  it("Maps a value from one range to another", () => {
-    expect(mapRange(2, 0, 5, 0, 10)).toBe(4);
-    expect(mapRange(2, 5, 10, 0, 5)).toBe(-3);
-    expect(mapRange(2, 0, 5, 0, 10)).toBe(4);
-    expect(mapRange(3, 15, 100)).toBe(20);
-  });
-
-  it("Handles edge cases", () => {
-    expect(mapRange(0, 0, NaN, 0, 0)).toBe(NaN);
-    expect(mapRange(NaN, 0, 0)).toBe(NaN);
-    expect(mapRange(0, 0, 0)).toBe(NaN);
-    expect(mapRange(Infinity, 10, 1000)).toBe(Infinity);
-    expect(mapRange(-Infinity, -Infinity, Infinity)).toBe(NaN);
-  });
-});
-
-//#region randRange
-describe("math/randRange", () => {
-  it("Returns a random number between min and max", () => {
-    const nums: number[] = [];
-
-    const startTsA = Date.now();
-    for(let i = 0; i < 100_000; i++)
-      nums.push(randRange(0, 10));
-    const timeA = Date.now() - startTsA;
-
-    const startTsB = Date.now();
-    for(let i = 0; i < 100_000; i++)
-      nums.push(randRange(10, true));
-    const timeB = Date.now() - startTsB;
-
-    expect(nums.every(n => n >= 0 && n <= 10)).toBe(true);
-
-    // about a 5x speed difference
-    expect(timeA).toBeLessThanOrEqual(75);
-    expect(timeB).toBeGreaterThanOrEqual(150);
-
-    expect(randRange(0, 0)).toBe(0);
-    expect(randRange(0)).toBe(0);
-  });
-});
-
-//#region digitCount
-describe("math/digitCount", () => {
-  it("Counts the number of digits in a number", () => {
-    expect(digitCount(0)).toBe(1);
-    expect(digitCount(1)).toBe(1);
-    expect(digitCount(10)).toBe(2);
-    expect(digitCount(100_000_000.000_001)).toBe(15);
-    expect(digitCount(100_000_000.000_001, false)).toBe(9);
-  });
-
-  it("Handles edge cases", () => {
-    expect(digitCount(NaN)).toBe(NaN);
-    expect(digitCount(Infinity)).toBe(Infinity);
-  });
-});
-
-//#region roundFixed
-describe("math/roundFixed", () => {
-  it("Rounds a number to a fixed amount of decimal places", () => {
-    expect(roundFixed(1234.5678, -1)).toBe(1230);
-    expect(roundFixed(1234.5678, 0)).toBe(1235);
-    expect(roundFixed(1234.5678, 1)).toBe(1234.6);
-    expect(roundFixed(1234.5678, 3)).toBe(1234.568);
-    expect(roundFixed(1234.5678, 5)).toBe(1234.5678);
-  });
-
-  it("Handles edge cases", () => {
-    expect(roundFixed(NaN, 0)).toBe(NaN);
-    expect(roundFixed(1234.5678, NaN)).toBe(NaN);
-    expect(roundFixed(1234.5678, Infinity)).toBe(NaN);
-    expect(roundFixed(Infinity, 0)).toBe(Infinity);
-  });
-});
-
-//#region bitSetHas
-describe("math/bitSetHas", () => {
-  it("Checks if a bit is set in a number", () => {
-    expect(bitSetHas(0b1010, 0b1000)).toBe(true);
-    expect(bitSetHas(0b1010, 0b0100)).toBe(false);
-    expect(bitSetHas(0b1010, 0b0010)).toBe(true);
-    expect(bitSetHas(0b1010, 0b0001)).toBe(false);
-
-    expect(bitSetHas(BigInt(0b10), BigInt(0b10))).toBe(true);
-    expect(bitSetHas(BigInt(0b10), BigInt(0b01))).toBe(false);
-  });
-
-  it("Handles edge cases", () => {
-    expect(bitSetHas(0, 0)).toBe(true);
-    expect(bitSetHas(1, 0)).toBe(true);
-    expect(bitSetHas(0, 1)).toBe(false);
-    expect(bitSetHas(1, 1)).toBe(true);
-    expect(bitSetHas(NaN, NaN)).toBe(false);
-  });
-});

+ 0 - 176
lib/math.ts

@@ -1,176 +0,0 @@
-/**
- * @module lib/math
- * This module contains various math functions - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#math)
- */
-
-import type { Stringifiable } from "./types.js";
-
-/** Ensures the passed {@linkcode value} always stays between {@linkcode min} and {@linkcode max} */
-export function clamp(value: number, min: number, max: number): number
-/** Ensures the passed {@linkcode value} always stays between 0 and {@linkcode max} */
-export function clamp(value: number, max: number): number
-/** Ensures the passed {@linkcode value} always stays between {@linkcode min} and {@linkcode max} - if `max` isn't given, it defaults to `min` and `min` defaults to 0 */
-export function clamp(value: number, min: number, max?: number): number {
-  if(typeof max !== "number") {
-    max = min;
-    min = 0;
-  }
-  return Math.max(Math.min(value, max), min);
-}
-
-/**
- * Transforms the value parameter from the numerical range `range1min` to `range1max` to the numerical range `range2min` to `range2max`  
- * For example, you can map the value 2 in the range of 0-5 to the range of 0-10 and you'd get a 4 as a result.
- */
-export function mapRange(value: number, range1min: number, range1max: number, range2min: number, range2max: number): number;
-/**
- * Transforms the value parameter from the numerical range `0` to `range1max` to the numerical range `0` to `range2max`
- * For example, you can map the value 2 in the range of 0-5 to the range of 0-10 and you'd get a 4 as a result.
- */
-export function mapRange(value: number, range1max: number, range2max: number): number;
-/**
- * Transforms the value parameter from one numerical range to another.  
- * For example, you can map the value 2 in the range of 0-5 to the range of 0-10 and you'd get a 4 as a result.
- */
-export function mapRange(value: number, range1min: number, range1max: number, range2min?: number, range2max?: number): number {
-  if(typeof range2min === "undefined" || typeof range2max === "undefined") {
-    range2max = range1max;
-    range1max = range1min;
-    range2min = range1min = 0;
-  }
-
-  if(Number(range1min) === 0.0 && Number(range2min) === 0.0)
-    return value * (range2max / range1max);
-
-  return (value - range1min) * ((range2max - range2min) / (range1max - range1min)) + range2min;
-}
-
-/**
- * Returns a random number between {@linkcode min} and {@linkcode max} (inclusive)  
- * Set {@linkcode enhancedEntropy} to true to use `crypto.getRandomValues()` for better cryptographic randomness (this also makes it take longer to generate)
- */
-export function randRange(min: number, max: number, enhancedEntropy?: boolean): number
-/**
- * Returns a random number between 0 and {@linkcode max} (inclusive)  
- * Set {@linkcode enhancedEntropy} to true to use `crypto.getRandomValues()` for better cryptographic randomness (this also makes it take longer to generate)
- */
-export function randRange(max: number, enhancedEntropy?: boolean): number
-/**
- * Returns a random number between {@linkcode min} and {@linkcode max} (inclusive)  
- * Set {@linkcode enhancedEntropy} to true to use `crypto.getRandomValues()` for better cryptographic randomness (this also makes it take longer to generate)
- */
-export function randRange(...args: (number | boolean | undefined)[]): number {
-  let min: number, max: number, enhancedEntropy = false;
-
-  // using randRange(min, max)
-  if(typeof args[0] === "number" && typeof args[1] === "number")
-    [ min, max ] = args;
-  // using randRange(max)
-  else if(typeof args[0] === "number" && typeof args[1] !== "number") {
-    min = 0;
-    [ max ] = args;
-  }
-  else
-    throw new TypeError(`Wrong parameter(s) provided - expected (number, boolean|undefined) or (number, number, boolean|undefined) but got (${args.map(a => typeof a).join(", ")}) instead`);
-
-  if(typeof args[2] === "boolean")
-    enhancedEntropy = args[2];
-  else if(typeof args[1] === "boolean")
-    enhancedEntropy = args[1];
-
-  min = Number(min);
-  max = Number(max);
-
-  if(isNaN(min) || isNaN(max))
-    return NaN;
-
-  if(min > max)
-    throw new TypeError("Parameter \"min\" can't be bigger than \"max\"");
-
-  if(enhancedEntropy) {
-    const uintArr = new Uint8Array(1);
-    crypto.getRandomValues(uintArr);
-    return Number(Array.from(
-      uintArr,
-      (v) => Math.round(mapRange(v, 0, 255, min, max)).toString(10),
-    ).join(""));
-  }
-  else
-    return Math.floor(Math.random() * (max - min + 1)) + min;
-}
-
-/**
- * Calculates the amount of digits in the given number - the given number or string will be passed to the `Number()` constructor.  
- * Returns NaN if the number is invalid.  
- * @param num The number to count the digits of
- * @param withDecimals Whether to count the decimal places as well (defaults to true)
- * @example ```ts
- * digitCount();         // NaN
- * digitCount(0);        // 1
- * digitCount(123);      // 3
- * digitCount(123.456);  // 6
- * digitCount(Infinity); // Infinity
- * ```
- */
-export function digitCount(num: number | Stringifiable, withDecimals = true): number {
-  num = Number((!["string", "number"].includes(typeof num)) ? String(num) : num);
-
-  if(typeof num === "number" && isNaN(num))
-    return NaN;
-
-  const [intPart, decPart] = num.toString().split(".");
-
-  const intDigits = intPart === "0"
-    ? 1
-    : Math.floor(Math.log10(Math.abs(Number(intPart))) + 1);
-  const decDigits = withDecimals && decPart
-    ? decPart.length
-    : 0;
-
-  return intDigits + decDigits;
-}
-
-/**
- * Rounds {@linkcode num} to a fixed amount of decimal places, specified by {@linkcode fractionDigits} (supports negative values to round to the nearest power of 10).
- * @example ```ts
- * roundFixed(234.567, -2); // 200
- * roundFixed(234.567, -1); // 230
- * roundFixed(234.567, 0);  // 235
- * roundFixed(234.567, 1);  // 234.6
- * roundFixed(234.567, 2);  // 234.57
- * roundFixed(234.567, 3);  // 234.567
- * ```
- */
-export function roundFixed(num: number, fractionDigits: number): number {
-  const scale = 10 ** fractionDigits;
-  return Math.round(num * scale) / scale;
-}
-
-/**
- * Checks if the {@linkcode bitSet} has the {@linkcode checkVal} set
- * @example ```ts
- * // the two vertically adjacent bits are tested for:
- * bitSetHas(
- *   0b1110,
- *   0b0010,
- * ); // true
- * 
- * bitSetHas(
- *   0b1110,
- *   0b0001,
- * ); // false
- * 
- * // with TS enums (or JS maps):
- * enum MyEnum {
- *   A = 1, B = 2, C = 4,
- *   D = 8, E = 16, F = 32,
- * }
- * 
- * const myBitSet = MyEnum.A | MyEnum.B;
- * bitSetHas(myBitSet, MyEnum.B); // true
- * bitSetHas(myBitSet, MyEnum.F); // false
- * ```
- */
-export function bitSetHas<TType extends number | bigint>(bitSet: TType, checkVal: TType): boolean {
-  return (bitSet & checkVal) === checkVal;
-}

+ 0 - 146
lib/misc.spec.ts

@@ -1,146 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { autoPlural, consumeGen, consumeStringGen, fetchAdvanced, getListLength, insertValues, pauseFor, purifyObj } from "./misc.js";
-import { randomId } from "./crypto.js";
-
-//#region autoPlural
-describe("misc/autoPlural", () => {
-  it("Tests if autoPlural uses the correct forms", () => {
-    expect(autoPlural("apple", -1)).toBe("apples");
-    expect(autoPlural("apple", 0)).toBe("apples");
-    expect(autoPlural("apple", 1)).toBe("apple");
-    expect(autoPlural("apple", 2)).toBe("apples");
-
-    expect(autoPlural("cherry", -1)).toBe("cherries");
-    expect(autoPlural("cherry", 0)).toBe("cherries");
-    expect(autoPlural("cherry", 1)).toBe("cherry");
-    expect(autoPlural("cherry", 2)).toBe("cherries");
-
-    const cont = document.createElement("div");
-    for(let i = 0; i < 3; i++) {
-      const span = document.createElement("span");
-      cont.append(span);
-    }
-
-    expect(autoPlural("cherry", [1])).toBe("cherry");
-    expect(autoPlural("cherry", cont.querySelectorAll("span"))).toBe("cherries");
-    expect(autoPlural("cherry", { count: 3 })).toBe("cherries");
-  });
-
-  it("Handles edge cases", () => {
-    expect(autoPlural("apple", 2, "-ies")).toBe("applies");
-    expect(autoPlural("cherry", 2, "-s")).toBe("cherrys");
-  });
-});
-
-//#region insertValues
-describe("misc/insertValues", () => {
-  it("Stringifies and inserts values correctly", () => {
-    expect(insertValues("a:%1,b:%2,c:%3", "A", "B", "C")).toBe("a:A,b:B,c:C");
-    expect(insertValues("a:%1,b:%2,c:%3", "A", 2, true)).toBe("a:A,b:2,c:true");
-    expect(insertValues("a:%1,b:%2,c:%3", { toString: () => "[A]" }, {})).toBe("a:[A],b:[object Object],c:%3");
-  });
-});
-
-//#region pauseFor
-describe("misc/pauseFor", () => {
-  it("Pauses for the correct time and can be aborted", async () => {
-    const startTs = Date.now();
-    await pauseFor(100);
-
-    expect(Date.now() - startTs).toBeGreaterThanOrEqual(80);
-
-    const ac = new AbortController();
-    const startTs2 = Date.now();
-
-    setTimeout(() => ac.abort(), 20);
-    await pauseFor(100, ac.signal);
-
-    expect(Date.now() - startTs2).toBeLessThan(100);
-  });
-});
-
-//#region fetchAdvanced
-describe("misc/fetchAdvanced", () => {
-  it("Fetches a resource correctly", async () => {
-    try {
-      const res = await fetchAdvanced("https://jsonplaceholder.typicode.com/todos/1");
-      const json = await res.json();
-
-      expect(json?.id).toBe(1);
-    }
-    catch(e) {
-      expect(e).toBeUndefined();
-    }
-  });
-
-  it("Throws error on invalid resource", async () => {
-    try {
-      const res = await fetchAdvanced("invalid-url");
-    }
-    catch(e) {
-      expect(e).toBeInstanceOf(Error);
-    }
-  });
-});
-
-//#region consumeGen
-describe("misc/consumeGen", () => {
-  it("Consumes a ValueGen properly", async () => {
-    expect(await consumeGen(() => 1)).toBe(1);
-    expect(await consumeGen(() => Promise.resolve(1))).toBe(1);
-    expect(await consumeGen(1)).toBe(1);
-
-    expect(await consumeGen(() => true)).toBe(true);
-    expect(await consumeGen(async () => false)).toBe(false);
-
-    // @ts-expect-error
-    expect(await consumeGen()).toThrow(TypeError);
-  });
-});
-
-//#region consumeStringGen
-describe("misc/consumeStringGen", () => {
-  it("Consumes a StringGen properly", async () => {
-    expect(await consumeStringGen("a")).toBe("a");
-    expect(await consumeStringGen(() => "b")).toBe("b");
-    expect(await consumeStringGen(() => Promise.resolve("c"))).toBe("c");
-    expect(await consumeStringGen({ toString: () => "d" })).toBe("d");
-  });
-});
-
-//#region getListLength
-describe("misc/getListLength", () => {
-  it("Resolves all types of ListWithLength", () => {
-    const cont = document.createElement("div");
-    for(let i = 0; i < 3; i++) {
-      const span = document.createElement("span");
-      cont.append(span);
-    }
-    expect(getListLength(cont.querySelectorAll("span"))).toBe(3);
-    expect(getListLength([1, 2, 3])).toBe(3);
-    expect(getListLength({ length: 3 })).toBe(3);
-    expect(getListLength({ size: 3 })).toBe(3);
-    expect(getListLength({ count: 3 })).toBe(3);
-
-    // @ts-expect-error
-    expect(getListLength({}, true)).toBe(0);
-    // @ts-expect-error
-    expect(getListLength({}, false)).toBe(NaN);
-  });
-});
-
-//#region purifyObj
-describe("misc/purifyObj", () => {
-  it("Removes the prototype chain of a passed object", () => {
-    const obj = { a: 1, b: 2 };
-    const pure = purifyObj(obj);
-
-    // @ts-expect-error
-    expect(obj.__proto__).toBeDefined();
-    // @ts-expect-error
-    expect(pure.__proto__).toBeUndefined();
-
-    expect(pure.a).toBe(1);
-    expect(pure.b).toBe(2);
-  });
-});

+ 0 - 174
lib/misc.ts

@@ -1,174 +0,0 @@
-/**
- * @module lib/misc
- * This module contains miscellaneous functions that don't fit in another category - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#misc)
- */
-
-import type { ListWithLength, Prettify, Stringifiable } from "./types.js";
-
-/** Which plural form to use when auto-pluralizing */
-export type PluralType = "auto" | "-s" | "-ies";
-
-/**
- * Automatically pluralizes the given string by adding an `-s` or `-ies` to the passed {@linkcode term}, if {@linkcode num} is not equal to 1.  
- * By default, words ending in `-y` will have it replaced with `-ies`, and all other words will simply have `-s` appended.  
- * {@linkcode pluralType} will default to `auto` if invalid and {@linkcode num} is set to 2 if it resolves to `NaN`.
- * @param term The term, written in singular form, to auto-convert to plural form
- * @param num A number, or list-like value that has either a `length`, `count` or `size` property, like an array, Map or NodeList - does not support iterables, they need to be converted to an array first
- * @param pluralType Which plural form to use when auto-pluralizing. Defaults to `"auto"`, which removes the last char and uses `-ies` for words ending in `y` and simply appends `-s` for all other words
- */
-export function autoPlural(term: Stringifiable, num: number | ListWithLength, pluralType: PluralType = "auto"): string {
-  let n = num;
-  if(typeof n !== "number")
-    n = getListLength(n, false);
-
-  if(!["-s", "-ies"].includes(pluralType))
-    pluralType = "auto";
-
-  if(isNaN(n))
-    n = 2;
-
-  const pType: Exclude<PluralType, "auto"> = pluralType === "auto"
-    ? String(term).endsWith("y") ? "-ies" : "-s"
-    : pluralType;
-
-  switch(pType) {
-  case "-s":
-    return `${term}${n === 1 ? "" : "s"}`;
-  case "-ies":
-    return `${String(term).slice(0, -1)}${n === 1 ? "y" : "ies"}`;
-  }
-}
-
-/**
- * Inserts the passed values into a string at the respective placeholders.  
- * The placeholder format is `%n`, where `n` is the 1-indexed argument number.
- * @param input The string to insert the values into
- * @param values The values to insert, in order, starting at `%1`
- */
-export function insertValues(input: string, ...values: Stringifiable[]): string {
-  return input.replace(/%\d/gm, (match) => {
-    const argIndex = Number(match.substring(1)) - 1;
-    return (values[argIndex] ?? match)?.toString();
-  });
-}
-
-/**
- * Pauses async execution for the specified time in ms.  
- * If an `AbortSignal` is passed, the pause will be aborted when the signal is triggered.  
- * By default, this will resolve the promise, but you can set {@linkcode rejectOnAbort} to true to reject it instead.
- */
-export function pauseFor(time: number, signal?: AbortSignal, rejectOnAbort = false): Promise<void> {
-  return new Promise<void>((res, rej) => {
-    const timeout = setTimeout(() => res(), time);
-    signal?.addEventListener("abort", () => {
-      clearTimeout(timeout);
-      rejectOnAbort ? rej(new Error("The pause was aborted")) : res();
-    });
-  });
-}
-
-/** Options for the `fetchAdvanced()` function */
-export type FetchAdvancedOpts = Prettify<
-  Partial<{
-    /** Timeout in milliseconds after which the fetch call will be canceled with an AbortController signal */
-    timeout: number;
-  }> & RequestInit
->;
-
-/** Calls the fetch API with special options like a timeout */
-export async function fetchAdvanced(input: string | RequestInfo | URL, options: FetchAdvancedOpts = {}): Promise<Response> {
-  const { timeout = 10000 } = options;
-  const ctl = new AbortController();
-
-  const { signal, ...restOpts } = options;
-
-  signal?.addEventListener("abort", () => ctl.abort());
-
-  let sigOpts: Partial<RequestInit> = {},
-    id: ReturnType<typeof setTimeout> | undefined = undefined;
-
-  if(timeout >= 0) {
-    id = setTimeout(() => ctl.abort(), timeout);
-    sigOpts = { signal: ctl.signal };
-  }
-
-  try {
-    const res = await fetch(input, {
-      ...restOpts,
-      ...sigOpts,
-    });
-    typeof id !== "undefined" && clearTimeout(id);
-    return res;
-  }
-  catch(err) {
-    typeof id !== "undefined" && clearTimeout(id);
-    throw new Error("Error while calling fetch", { cause: err });
-  }
-}
-
-/**
- * A ValueGen value is either its type, a promise that resolves to its type, or a function that returns its type, either synchronous or asynchronous.  
- * ValueGen allows for the utmost flexibility when applied to any type, as long as {@linkcode consumeGen()} is used to get the final value.
- * @template TValueType The type of the value that the ValueGen should yield
- */
-export type ValueGen<TValueType> = TValueType | Promise<TValueType> | (() => TValueType | Promise<TValueType>);
-
-/**
- * Turns a {@linkcode ValueGen} into its final value.  
- * @template TValueType The type of the value that the ValueGen should yield
- */
-export async function consumeGen<TValueType>(valGen: ValueGen<TValueType>): Promise<TValueType> {
-  return await (typeof valGen === "function"
-    ? (valGen as (() => Promise<TValueType> | TValueType))()
-    : valGen
-  ) as TValueType;
-}
-
-/**
- * A StringGen value is either a string, anything that can be converted to a string, or a function that returns one of the previous two, either synchronous or asynchronous, or a promise that returns a string.  
- * StringGen allows for the utmost flexibility when dealing with strings, as long as {@linkcode consumeStringGen()} is used to get the final string.
- */
-export type StringGen = ValueGen<Stringifiable>;
-
-/**
- * Turns a {@linkcode StringGen} into its final string value.  
- * @template TStrUnion The union of strings that the StringGen should yield - this allows for finer type control compared to {@linkcode consumeGen()}
- */
-export async function consumeStringGen<TStrUnion extends string>(strGen: StringGen): Promise<TStrUnion> {
-  return (
-    typeof strGen === "string"
-      ? strGen
-      : String(
-        typeof strGen === "function"
-          ? await strGen()
-          : strGen
-      )
-  ) as TStrUnion;
-}
-
-/**
- * Returns the length of the given list-like object (anything with a numeric `length`, `size` or `count` property, like an array, Map or NodeList).  
- * If the object doesn't have any of these properties, it will return 0 by default.  
- * Set {@linkcode zeroOnInvalid} to false to return NaN instead of 0 if the object doesn't have any of the properties.
- */
-export function getListLength(obj: ListWithLength, zeroOnInvalid = true): number {
-  // will I go to ternary hell for this?
-  return "length" in obj
-    ? obj.length
-    : "size" in obj
-      ? obj.size
-      : "count" in obj
-        ? obj.count
-        : zeroOnInvalid
-          ? 0
-          : NaN;
-}
-
-/**
- * Turns the passed object into a "pure" object without a prototype chain, meaning it won't have any default properties like `toString`, `__proto__`, `__defineGetter__`, etc.  
- * This makes the object immune to prototype pollution attacks and allows for cleaner object literals, at the cost of being harder to work with in some cases.  
- * It also effectively transforms a `Stringifiable` value into one that will throw a TypeError when stringified instead of defaulting to `[object Object]`
- */
-export function purifyObj<TObj extends object>(obj: TObj): TObj {
-  return Object.assign(Object.create(null), obj);
-}

+ 0 - 71
lib/translation.spec.ts

@@ -1,71 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { tr, TrKeys } from "./translation.js";
-
-describe("Translation", () => {
-  //#region base
-  it("Base translation", () => {
-    const trEn = {
-      hello: "Hello",
-      goodbye: "Goodbye",
-      nested: {
-        foo: {
-          bar: "Baz",
-        },
-      },
-    } as const;
-    tr.addTranslations("en", trEn);
-    tr.addTranslations("de", {
-      hello: "Hallo",
-    });
-    expect(tr.getFallbackLanguage()).toBeUndefined();
-    tr.setFallbackLanguage("en");
-    expect(tr.getFallbackLanguage()).toBe("en");
-
-    expect(tr.for("en", "hello")).toBe("Hello");
-    expect(tr.for("de", "hello")).toBe("Hallo");
-    expect(tr.for("de", "goodbye")).toBe("Goodbye");
-
-    expect(tr.deleteTranslations("de")).toBe(true);
-    expect(tr.for("de", "hello")).toBe("Hello");
-    expect(tr.deleteTranslations("de")).toBe(false);
-
-    tr.setFallbackLanguage();
-    expect(tr.for("de", "hello")).toBe("hello");
-
-    expect(tr.getTranslations("en")?.hello).toBe("Hello");
-    expect(tr.getTranslations("de")?.hello).toBeUndefined();
-
-    const t = tr.use<TrKeys<typeof trEn>>("en");
-    expect(t("hello")).toBe("Hello");
-
-    expect(t("nested.foo.bar")).toBe("Baz");
-    expect(tr.hasKey("en", "nested.foo.bar")).toBe(true);
-  });
-
-  //#region transforms
-  it("Transforms", () => {
-    tr.addTranslations("en", {
-      percent: "Hello, %1",
-      templateLiteral: "Hello, ${name}",
-    });
-
-    expect(tr.for("en", "percent", "Jeff")).toBe("Hello, %1");
-
-    tr.addTransform(tr.transforms.percent);
-    expect(tr.for("en", "percent")).toBe("Hello, %1");
-    expect(tr.for("en", "percent", "Jeff")).toBe("Hello, Jeff");
-
-    tr.addTransform(tr.transforms.templateLiteral);
-    expect(tr.for("en", "templateLiteral")).toBe("Hello, ${name}");
-    expect(tr.for("en", "templateLiteral", "Jeff")).toBe("Hello, Jeff");
-    expect(tr.for("en", "templateLiteral", { name: "Jeff" })).toBe("Hello, Jeff");
-    expect(tr.for("en", "templateLiteral", { toString: () => "Jeff" })).toBe("Hello, Jeff");
-
-    expect(tr.deleteTransform(tr.transforms.percent[0])).toBe(true);
-    expect(tr.for("en", "percent", "Jeff")).toBe("Hello, %1");
-    expect(tr.deleteTransform(tr.transforms.percent[0])).toBe(false);
-
-    tr.deleteTransform(tr.transforms.templateLiteral[1]);
-    expect(tr.for("en", "templateLiteral", "Jeff")).toBe("Hello, ${name}");
-  });
-});

+ 0 - 462
lib/translation.ts

@@ -1,462 +0,0 @@
-/**
- * @module lib/translation
- * This module contains a translation system that supports flat and nested JSON objects and value transformation functions - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#translation)
- */
-
-import type { Stringifiable } from "./types.js";
-
-//#region types
-
-/**
- * Translation object to pass to {@linkcode tr.addTranslations()}  
- * Can be a flat object of identifier keys and the translation text as the value, or an infinitely nestable object containing the same.  
- *   
- * @example
- * // Flat object:
- * const tr_en: TrObject = {
- *   hello: "Hello, %1!",
- *   foo: "Foo",
- * };
- * 
- * // Nested object:
- * const tr_de: TrObject = {
- *   hello: "Hallo, %1!",
- *   foo: {
- *     bar: "Foo bar",
- *   },
- * };
- */
-export interface TrObject {
-  [key: string]: string | TrObject;
-}
-
-/** Properties for the transform function that transforms a matched translation string into something else */
-export type TransformFnProps<TTrKey extends string = string> = {
-  /** The current language - empty string if not set yet */
-  language: string;
-  /** The matches as returned by `RegExp.exec()` */
-  matches: RegExpExecArray[];
-  /** The translation key */
-  trKey: TTrKey;
-  /** Translation value before any transformations */
-  trValue: string;
-  /** Current value, possibly in-between transformations */
-  currentValue: string;
-  /** Arguments passed to the translation function */
-  trArgs: (Stringifiable | Record<string, Stringifiable>)[];
-};
-
-/** Function that transforms a matched translation string into another string */
-export type TransformFn<TTrKey extends string = string> = (props: TransformFnProps<TTrKey>) => Stringifiable;
-
-/** Transform pattern and function in tuple form */
-export type TransformTuple<TTrKey extends string = string> = [RegExp, TransformFn<TTrKey>];
-
-/**
- * Pass a recursive or flat translation object to this generic type to get all keys in the object.  
- * @example ```ts
- * type Keys = TrKeys<{ a: { b: "foo" }, c: "bar" }>;
- * // result: type Keys = "a.b" | "c"
- * ```
- */
-export type TrKeys<TTrObj, P extends string = ""> = {
-  [K in keyof TTrObj]: K extends string | number | boolean | null | undefined
-    ? TTrObj[K] extends object
-      ? TrKeys<TTrObj[K], `${P}${K}.`>
-      : `${P}${K}`
-    : never
-}[keyof TTrObj];
-
-//#region vars
-
-/** All translations loaded into memory */
-const trans: {
-  [language: string]: TrObject;
-} = {};
-
-/** All registered value transformers */
-const valTransforms: Array<{
-  regex: RegExp;
-  fn: TransformFn;
-}> = [];
-
-/** Fallback language - if undefined, the trKey itself will be returned if the translation is not found */
-let fallbackLang: string | undefined;
-
-//#region tr backend
-
-/** Common function to resolve the translation text in a specific language and apply transform functions. */
-function translate<TTrKey extends string = string>(language: string, key: TTrKey, ...trArgs: (Stringifiable | Record<string, Stringifiable>)[]): string {
-  if(typeof language !== "string")
-    language = fallbackLang ?? "";
-
-  const trObj = trans[language];
-
-  if(typeof language !== "string" || language.length === 0 || typeof trObj !== "object" || trObj === null)
-    return fallbackLang ? translate(fallbackLang, key, ...trArgs) : key;
-
-  /** Apply all transforms that match the translation string */
-  const transformTrVal = (trKey: TTrKey, trValue: string): string => {
-    const tfs = valTransforms.filter(({ regex }) => new RegExp(regex).test(String(trValue)));
-
-    if(tfs.length === 0)
-      return String(trValue);
-
-    let retStr = String(trValue);
-
-    for(const tf of tfs) {
-      const re = new RegExp(tf.regex);
-
-      const matches: RegExpExecArray[] = [];
-      let execRes: RegExpExecArray | null;
-      while((execRes = re.exec(trValue)) !== null) {
-        if(matches.some(m => m[0] === execRes?.[0]))
-          break;
-        matches.push(execRes);
-      }
-
-      retStr = String(tf.fn({
-        language,
-        trValue,
-        currentValue: retStr,
-        matches,
-        trKey,
-        trArgs,
-      }));
-    }
-
-    return retStr;
-  };
-
-  // try to resolve via traversal (e.g. `trObj["key"]["parts"]`)
-  const keyParts = key.split(".");
-  let value: string | TrObject | undefined = trObj;
-  for(const part of keyParts) {
-    if(typeof value !== "object" || value === null) {
-      value = undefined;
-      break;
-    }
-    value = value?.[part];
-  }
-
-  if(typeof value === "string")
-    return transformTrVal(key, value);
-
-  // try falling back to `trObj["key.parts"]`
-  value = trObj?.[key];
-  if(typeof value === "string")
-    return transformTrVal(key, value);
-
-  // default to fallbackLang or translation key
-  return fallbackLang
-    ? translate(fallbackLang, key, ...trArgs)
-    : key;
-}
-
-//#region tr funcs
-
-/**
- * Returns the translated text for the specified key in the specified language.  
- * If the key is not found in the specified previously registered translation, the key itself is returned.  
- *   
- * ⚠️ Remember to register a language with {@linkcode tr.addTranslations()} before using this function, otherwise it will always return the key itself.
- * @param language Language code or name to use for the translation
- * @param key Key of the translation to return
- * @param args Optional arguments to be passed to the translated text. They will replace placeholders in the format `%n`, where `n` is the 1-indexed argument number
- */
-function trFor<TTrKey extends string = string>(language: string, key: TTrKey, ...args: (Stringifiable | Record<string, Stringifiable>)[]): string {
-  const txt = translate(language, key, ...args);
-  if(txt === key)
-    return fallbackLang
-      ? translate(fallbackLang, key, ...args)
-      : key;
-  return txt;
-}
-
-/**
- * Prepares a translation function for a specific language.
- * @example ```ts
- * tr.addTranslations("en", {
- *   hello: "Hello, %1!",
- * });
- * const t = tr.useTr("en");
- * t("hello", "John"); // "Hello, John!"
- * ```
- */
-function useTr<TTrKey extends string = string>(language: string): (key: TTrKey, ...args: (Stringifiable | Record<string, Stringifiable>)[]) => ReturnType<typeof trFor<TTrKey>> {
-  return (key: TTrKey, ...args: (Stringifiable | Record<string, Stringifiable>)[]) =>
-    translate<TTrKey>(language, key, ...args);
-}
-
-/**
- * Checks if a translation exists given its {@linkcode key} in the specified {@linkcode language} or the set fallback language.  
- * If the given language was not registered with {@linkcode tr.addTranslations()}, this function will return `false`.  
- * @param key Key of the translation to check for
- * @param language Language code or name to check in - defaults to the currently active language (set by {@linkcode tr.setLanguage()})
- * @returns Whether the translation key exists in the specified language - always returns `false` if no language is given and no active language was set
- */
-function hasKey<TTrKey extends string = string>(language = fallbackLang ?? "", key: TTrKey): boolean {
-  return tr.for(language, key) !== key;
-}
-
-//#region manage translations
-
-/**
- * Registers a new language and its translations - if the language already exists, it will be overwritten.  
- * The translations are a key-value pair where the key is the translation key and the value is the translated text.  
- * The translations can also be infinitely nested objects, resulting in a dot-separated key.
- * @param language Language code or name to register - I recommend sticking to a standard like [ISO 639](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) or [BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag)
- * @param translations Translations for the specified language
- * @example ```ts
- * tr.addTranslations("en", {
- *   hello: "Hello, %1!",
- *   foo: {
- *     bar: "Foo bar",
- *   },
- * });
- * ```
- */
-function addTranslations(language: string, translations: TrObject): void {
-  trans[language] = JSON.parse(JSON.stringify(translations));
-}
-
-/**
- * Returns the translation object for the specified language or currently active one.  
- * If the language is not registered with {@linkcode tr.addTranslations()}, this function will return `undefined`.  
- * @param language Language code or name to get translations for - defaults to the currently active language (set by {@linkcode tr.setLanguage()})
- * @returns Translations for the specified language
- */
-function getTranslations(language = fallbackLang ?? ""): TrObject | undefined {
-  return trans[language];
-}
-
-/**
- * Deletes the translations for the specified language from memory.  
- * @param language Language code or name to delete
- * @returns Whether the translations for the passed language were successfully deleted
- */
-const deleteTranslations = (language: string): boolean => {
-  if(language in trans) {
-    delete trans[language];
-    return true;
-  }
-  return false;
-};
-
-//#region set fb lang
-
-/**
- * The fallback language to use when a translation key is not found in the currently active language.  
- * Leave undefined to disable fallbacks and just return the translation key if translations are not found.
- */
-function setFallbackLanguage(fallbackLanguage?: string): void {
-  fallbackLang = fallbackLanguage;
-}
-
-/** Returns the fallback language set by {@linkcode tr.setFallbackLanguage()} */
-function getFallbackLanguage(): string | undefined {
-  return fallbackLang;
-}
-
-//#region transforms
-
-/**
- * Adds a transform function that gets called after resolving a translation for any language.  
- * Use it to enable dynamic values in translations, for example to insert custom global values from the application or to denote a section that could be encapsulated by rich text.  
- * Each function will receive the RegExpMatchArray [see MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match) and the current language as arguments.  
- * After all %n-formatted values have been injected, the transform functions will be called sequentially in the order they were added.
- * @example
- * ```ts
- * import { tr, type TrKeys } from "@sv443-network/userutils";
- * 
- * const transEn = {
- *    "headline": {
- *      "basic": "Hello, ${USERNAME}",
- *      "html": "Hello, ${USERNAME}<br><c=red>You have ${UNREAD_NOTIFS} unread notifications.</c>"
- *    }
- * } as const;
- * 
- * tr.addTranslations("en", transEn);
- * 
- * // replace ${PATTERN} with predefined values
- * tr.addTransform(/\$\{([A-Z_]+)\}/g, ({ matches }) => {
- *   switch(matches?.[1]) {
- *     default:
- *       return `[UNKNOWN: ${matches?.[1]}]`;
- *     // these would be grabbed from elsewhere in the application, like a DataStore, global state or variable:
- *     case "USERNAME":
- *       return "JohnDoe45";
- *     case "UNREAD_NOTIFS":
- *       return 5;
- *   }
- * });
- * 
- * // replace <c=red>...</c> with <span style="color: red;">...</span>
- * tr.addTransform(/<c=([a-z]+)>(.*?)<\/c>/g, ({ matches }) => {
- *   const color = matches?.[1];
- *   const content = matches?.[2];
- * 
- *   return `<span style="color: ${color};">${content}</span>`;
- * });
- * 
- * const t = tr.use<TrKeys<typeof transEn>>("en");
- * 
- * t("headline.basic"); // "Hello, JohnDoe45"
- * t("headline.html");  // "Hello, JohnDoe45<br><span style="color: red;">You have 5 unread notifications.</span>"
- * ```
- * @param args A tuple containing the regular expression to match and the transform function to call if the pattern is found in a translation string
- */
-function addTransform<TTrKey extends string = string>(transform: TransformTuple<TTrKey>): void {
-  const [regex, fn] = transform;
-  valTransforms.push({
-    fn: fn as TransformFn,
-    regex,
-  });
-}
-
-/**
- * Deletes the first transform function from the list of registered transform functions.  
- * @param patternOrFn A reference to the regular expression of the transform function, a string matching the original pattern, or a reference to the transform function to delete
- * @returns Returns true if the transform function was found and deleted, false if it wasn't found
- */
-function deleteTransform(patternOrFn: RegExp | TransformFn): boolean {
-  const idx = valTransforms.findIndex((t) =>
-    typeof patternOrFn === "function"
-      ? t.fn === patternOrFn
-      : t.regex === patternOrFn
-  );
-  if(idx !== -1) {
-    valTransforms.splice(idx, 1);
-    return true;
-  }
-  return false;
-}
-
-//#region predef transforms
-
-/**
- * This transform will replace placeholders matching `${key}` with the value of the passed argument(s).  
- * The arguments can be passed in keyed object form or positionally via the spread operator:
- * - Keyed: If the first argument is an object and `key` is found in it, the value will be used for the replacement.
- * - Positional: If the first argument is not an object or has a `toString()` method that returns something that doesn't start with `[object`, the values will be positionally inserted in the order they were passed.
- *   
- * @example ```ts
- * tr.addTranslations("en", {
- *  "greeting": "Hello, ${user}!\nYou have ${notifs} notifications.",
- * });
- * tr.addTransform(tr.transforms.templateLiteral);
- * 
- * const t = tr.use("en");
- * 
- * // both calls return the same result:
- * t("greeting", { user: "John", notifs: 5 }); // "Hello, John!\nYou have 5 notifications."
- * t("greeting", "John", 5);                   // "Hello, John!\nYou have 5 notifications."
- * 
- * // when a key isn't found in the object, it will be left as-is:
- * t("greeting", { user: "John" }); // "Hello, John!\nYou have ${notifs} notifications."
- * ```
- */
-const templateLiteralTransform: TransformTuple<string> = [
-  /\$\{([a-zA-Z0-9$_-]+)\}/gm,
-  ({ matches, trArgs, trValue }) => {
-    const patternStart = "${",
-      patternEnd = "}",
-      patternRegex = /\$\{.+\}/m;
-
-    let str = String(trValue);
-
-    const eachKeyInTrString = (keys: string[]): boolean => keys.every((key) => trValue.includes(`${patternStart}${key}${patternEnd}`));
-
-    const namedMapping = (): void => {
-      if(!str.includes(patternStart) || typeof trArgs[0] === "undefined" || typeof trArgs[0] !== "object" || !eachKeyInTrString(Object.keys(trArgs[0] ?? {})))
-        return;
-      for(const match of matches) {
-        const repl = match[1] !== undefined ? (trArgs[0] as Record<string, string>)[match[1]] : undefined;
-        if(typeof repl !== "undefined")
-          str = str.replace(match[0], String(repl));
-      }
-    };
-
-    const positionalMapping = (): void => {
-      if(!(patternRegex.test(str)) || !trArgs[0])
-        return;
-      let matchNum = -1;
-      for(const match of matches) {
-        matchNum++;
-        if(typeof trArgs[matchNum] !== "undefined")
-          str = str.replace(match[0], String(trArgs[matchNum]));
-      }
-    };
-
-    /** Whether the first args parameter is an object that doesn't implement a custom `toString` method */
-    const isArgsObject = trArgs[0] && typeof trArgs[0] === "object" && trArgs[0] !== null && String(trArgs[0]).startsWith("[object");
-
-    if(isArgsObject && eachKeyInTrString(Object.keys(trArgs[0]!)))
-      namedMapping();
-    else
-      positionalMapping();
-
-    return str;
-  },
-] as const;
-
-/**
- * This transform will replace `%n` placeholders with the value of the passed arguments.  
- * The `%n` placeholders are 1-indexed, meaning `%1` will be replaced by the first argument (index 0), `%2` by the second (index 1), and so on.  
- * Objects will be stringified via `String()` before being inserted.  
- *   
- * @example ```ts
- * tr.addTranslations("en", {
- *  "greeting": "Hello, %1!\nYou have %2 notifications.",
- * });
- * tr.addTransform(tr.transforms.percent);
- * 
- * const t = tr.use("en");
- * 
- * // arguments are inserted in the order they're passed:
- * t("greeting", "John", 5); // "Hello, John!\nYou have 5 notifications."
- * 
- * // when a value isn't found, the placeholder will be left as-is:
- * t("greeting", "John"); // "Hello, John!\nYou have %2 notifications."
- * ```
- */
-const percentTransform: TransformTuple<string> = [
-  /%(\d+)/gm,
-  ({ matches, trArgs, trValue }) => {
-    let str = String(trValue);
-
-    for(const match of matches) {
-      const repl = match[1] !== undefined
-        ? (trArgs as Stringifiable[])?.[Number(match[1]) - 1]
-        : undefined;
-      if(typeof repl !== "undefined")
-        str = str.replace(match[0], String(repl));
-    }
-
-    return str;
-  },
-] as const;
-
-//#region exports
-
-const tr = {
-  for: <TTrKey extends string = string>(...params: Parameters<typeof trFor<TTrKey>>): ReturnType<typeof trFor<TTrKey>> =>
-    trFor<TTrKey>(...params as Parameters<typeof trFor<TTrKey>>),
-  use: <TTrKey extends string = string>(...params: Parameters<typeof useTr<TTrKey>>): ReturnType<typeof useTr<TTrKey>> =>
-    useTr<TTrKey>(...params as Parameters<typeof useTr<TTrKey>>),
-  hasKey: <TTrKey extends string = string>(language = fallbackLang ?? "", key: TTrKey): ReturnType<typeof hasKey<TTrKey>> =>
-    hasKey<TTrKey>(language, key),
-  addTranslations,
-  getTranslations,
-  deleteTranslations,
-  setFallbackLanguage,
-  getFallbackLanguage,
-  addTransform,
-  deleteTransform,
-  transforms: {
-    templateLiteral: templateLiteralTransform,
-    percent: percentTransform,
-  },
-};
-
-export { tr };

+ 0 - 49
lib/types.ts

@@ -1,49 +0,0 @@
-/**
- * @module lib/types
- * This module contains various TS types - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#utility-types)
- */
-
-//#region UU types
-
-/** Represents any value that is either a string itself or can be converted to one (implicitly and explicitly) because it has a toString() method */
-export type Stringifiable = string | { toString(): string } | { [Symbol.toStringTag]: string } | number | boolean | null | undefined;
-
-/**
- * A type that offers autocomplete for the passed union but also allows any arbitrary value of the same type to be passed.  
- * Supports unions of strings, numbers and objects.
- */
-export type LooseUnion<TUnion extends string | number | object> =
-  (TUnion) | (
-    TUnion extends string
-      ? (string & {})
-      : (
-        TUnion extends number
-          ? (number & {})
-          : (
-            // eslint-disable-next-line @typescript-eslint/no-explicit-any
-            TUnion extends Record<keyof any, unknown>
-            ? (object & {})
-            : never
-          )
-      )
-  );
-
-/**
- * A type that allows all strings except for empty ones
- * @example
- * function foo<T extends string>(bar: NonEmptyString<T>) {
- *   console.log(bar);
- * }
- */
-export type NonEmptyString<TString extends string> = TString extends "" ? never : TString;
-
-/**
- * Makes the structure of a type more readable by expanding it.  
- * This can be useful for debugging or for improving the readability of complex types.
- */
-export type Prettify<T> = {
-  [K in keyof T]: T[K];
-} & {};
-
-/** Any value that is list-like, i.e. has a numeric length, count or size property */
-export type ListWithLength = { length: number } | { count: number } | { size: number };

+ 2 - 2
package.json

@@ -37,8 +37,8 @@
     "test-gm-serve": "node --import tsx ./test/TestPage/server.mts",
     "test-gm-dev": "cd test/TestScript && pnpm dev",
     "test-gm": "concurrently \"pnpm test-gm-serve\" \"pnpm test-gm-dev\"",
-    "test": "vitest --passWithNoTests",
-    "test-coverage": "vitest --passWithNoTests --coverage"
+    "test": "vitest",
+    "test-coverage": "vitest --coverage"
   },
   "repository": {
     "type": "git",

+ 1 - 1
vitest.config.ts

@@ -10,8 +10,8 @@ export default defineConfig({
         "lib/**/*.spec.ts",
         "lib/Dialog.ts", // DOM-only features can't be tested for now cause they aren't rendered
         "lib/SelectorObserver.ts", // ^
+        "lib/errors.ts",
         "lib/index.ts",
-        "lib/types.ts",
       ],
       reporter: ["text", "text-summary", "lcov"],
     },