ConfigManager.ts 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. /** Function that takes the data in the old format and returns the data in the new format. Also supports an asynchronous migration. */
  3. type MigrationFunc = (oldData: any) => any | Promise<any>;
  4. /** Dictionary of format version numbers and the function that migrates to them from the previous whole integer */
  5. export type ConfigMigrationsDict = Record<number, MigrationFunc>;
  6. /** Options for the ConfigManager instance */
  7. export type ConfigManagerOptions<TData> = {
  8. /** A unique internal ID for this configuration - choose wisely as changing it is not supported yet. */
  9. id: string;
  10. /**
  11. * The default config data object to use if no data is saved in persistent storage yet.
  12. * Until the data is loaded from persistent storage with `loadData()`, this will be the data returned by `getData()`
  13. *
  14. * ⚠️ This has to be an object that can be serialized to JSON, so no functions or circular references are allowed, they will cause unexpected behavior.
  15. */
  16. defaultConfig: TData;
  17. /**
  18. * An incremental, whole integer version number of the current format of config data.
  19. * 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.
  20. *
  21. * ⚠️ Never decrement this number and optimally don't skip any numbers either!
  22. */
  23. formatVersion: number;
  24. /**
  25. * A dictionary of functions that can be used to migrate data from older versions of the configuration to newer ones.
  26. * The keys of the dictionary should be the format version that the functions can migrate to, from the previous whole integer value.
  27. * The values should be functions that take the data in the old format and return the data in the new format.
  28. * The functions will be run in order from the oldest to the newest version.
  29. * If the current format version is not in the dictionary, no migrations will be run.
  30. */
  31. migrations?: ConfigMigrationsDict;
  32. }
  33. & ({
  34. /**
  35. * Function to use to encode the data prior to saving it in persistent storage.
  36. * The input data is a serialized JSON object.
  37. *
  38. * 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.
  39. */
  40. encodeData: (data: string) => string | Promise<string>,
  41. /**
  42. * Function to use to decode the data after reading it from persistent storage.
  43. * The result should be a valid JSON object.
  44. *
  45. * 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.
  46. */
  47. decodeData: (data: string) => string | Promise<string>,
  48. } | {
  49. encodeData?: never,
  50. decodeData?: never,
  51. });
  52. /**
  53. * Manages a user configuration that is cached in memory and persistently saved across sessions.
  54. * Supports migrating data from older versions of the configuration to newer ones and populating the cache with default data if no persistent data is found.
  55. *
  56. * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue`
  57. * ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultConfig`
  58. *
  59. * @template TData The type of the data that is saved in persistent storage (will be automatically inferred from `config.defaultConfig`) - this should also be the type of the data format associated with the current `options.formatVersion`
  60. */
  61. export class ConfigManager<TData = any> {
  62. public readonly id: string;
  63. public readonly formatVersion: number;
  64. public readonly defaultConfig: TData;
  65. private cachedConfig: TData;
  66. private migrations?: ConfigMigrationsDict;
  67. private encodeData: ConfigManagerOptions<TData>["encodeData"];
  68. private decodeData: ConfigManagerOptions<TData>["decodeData"];
  69. /**
  70. * Creates an instance of ConfigManager to manage a user configuration that is cached in memory and persistently saved across sessions.
  71. * Supports migrating data from older versions of the configuration to newer ones and populating the cache with default data if no persistent data is found.
  72. *
  73. * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue`
  74. * ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultConfig`
  75. *
  76. * @template TData The type of the data that is saved in persistent storage (will be automatically inferred from `config.defaultConfig`) - this should also be the type of the data format associated with the current `options.formatVersion`
  77. * @param options The options for this ConfigManager instance
  78. */
  79. constructor(options: ConfigManagerOptions<TData>) {
  80. this.id = options.id;
  81. this.formatVersion = options.formatVersion;
  82. this.defaultConfig = options.defaultConfig;
  83. this.cachedConfig = options.defaultConfig;
  84. this.migrations = options.migrations;
  85. this.encodeData = options.encodeData;
  86. this.decodeData = options.decodeData;
  87. }
  88. /**
  89. * Loads the data saved in persistent storage into the in-memory cache and also returns it.
  90. * Automatically populates persistent storage with default data if it doesn't contain any data yet.
  91. * Also runs all necessary migration functions if the data format has changed since the last time the data was saved.
  92. */
  93. public async loadData(): Promise<TData> {
  94. try {
  95. const gmData = await GM.getValue(`_uucfg-${this.id}`, this.defaultConfig);
  96. let gmFmtVer = Number(await GM.getValue(`_uucfgver-${this.id}`));
  97. if(typeof gmData !== "string") {
  98. await this.saveDefaultData();
  99. return this.defaultConfig;
  100. }
  101. if(isNaN(gmFmtVer))
  102. await GM.setValue(`_uucfgver-${this.id}`, gmFmtVer = this.formatVersion);
  103. let parsed = await this.deserializeData(gmData);
  104. if(gmFmtVer < this.formatVersion && this.migrations)
  105. parsed = await this.runMigrations(parsed, gmFmtVer);
  106. return this.cachedConfig = parsed;
  107. }
  108. catch(err) {
  109. await this.saveDefaultData();
  110. return this.defaultConfig;
  111. }
  112. }
  113. /**
  114. * Returns a copy of the data from the in-memory cache.
  115. * Use {@linkcode loadData()} to get fresh data from persistent storage (usually not necessary since the cache should always exactly reflect persistent storage).
  116. */
  117. public getData(): TData {
  118. return this.deepCopy(this.cachedConfig);
  119. }
  120. /** Saves the data synchronously to the in-memory cache and asynchronously to the persistent storage */
  121. public setData(data: TData) {
  122. this.cachedConfig = data;
  123. return new Promise<void>(async (resolve) => {
  124. await Promise.all([
  125. GM.setValue(`_uucfg-${this.id}`, await this.serializeData(data)),
  126. GM.setValue(`_uucfgver-${this.id}`, this.formatVersion),
  127. ]);
  128. resolve();
  129. });
  130. }
  131. /** Saves the default configuration data passed in the constructor synchronously to the in-memory cache and asynchronously to persistent storage */
  132. public async saveDefaultData() {
  133. this.cachedConfig = this.defaultConfig;
  134. return new Promise<void>(async (resolve) => {
  135. await Promise.all([
  136. GM.setValue(`_uucfg-${this.id}`, await this.serializeData(this.defaultConfig)),
  137. GM.setValue(`_uucfgver-${this.id}`, this.formatVersion),
  138. ]);
  139. resolve();
  140. });
  141. }
  142. /**
  143. * Call this method to clear all persistently stored data associated with this ConfigManager instance.
  144. * The in-memory cache will be left untouched, so you may still access the data with {@linkcode getData()}
  145. * Calling {@linkcode loadData()} or {@linkcode setData()} after this method was called will recreate persistent storage with the cached or default data.
  146. *
  147. * ⚠️ This requires the additional directive `@grant GM.deleteValue`
  148. */
  149. public async deleteConfig() {
  150. await Promise.all([
  151. GM.deleteValue(`_uucfg-${this.id}`),
  152. GM.deleteValue(`_uucfgver-${this.id}`),
  153. ]);
  154. }
  155. /** Runs all necessary migration functions consecutively - may be overwritten in a subclass */
  156. protected async runMigrations(oldData: any, oldFmtVer: number): Promise<TData> {
  157. if(!this.migrations)
  158. return oldData as TData;
  159. let newData = oldData;
  160. const sortedMigrations = Object.entries(this.migrations)
  161. .sort(([a], [b]) => Number(a) - Number(b));
  162. let lastFmtVer = oldFmtVer;
  163. for(const [fmtVer, migrationFunc] of sortedMigrations) {
  164. const ver = Number(fmtVer);
  165. if(oldFmtVer < this.formatVersion && oldFmtVer < ver) {
  166. try {
  167. const migRes = migrationFunc(newData);
  168. newData = migRes instanceof Promise ? await migRes : migRes;
  169. lastFmtVer = oldFmtVer = ver;
  170. }
  171. catch(err) {
  172. console.error(`Error while running migration function for format version ${fmtVer}:`, err);
  173. }
  174. }
  175. }
  176. await Promise.all([
  177. GM.setValue(`_uucfg-${this.id}`, await this.serializeData(newData)),
  178. GM.setValue(`_uucfgver-${this.id}`, lastFmtVer),
  179. ]);
  180. return newData as TData;
  181. }
  182. /** Serializes the data using the optional this.encodeData() and returns it as a string */
  183. private async serializeData(data: TData) {
  184. const stringData = JSON.stringify(data);
  185. if(!this.encodeData)
  186. return stringData;
  187. const encRes = this.encodeData(stringData);
  188. if(encRes instanceof Promise)
  189. return await encRes;
  190. return encRes;
  191. }
  192. /** Deserializes the data using the optional this.decodeData() and returns it as a JSON object */
  193. private async deserializeData(data: string) {
  194. let decRes = this.decodeData ? this.decodeData(data) : undefined;
  195. if(decRes instanceof Promise)
  196. decRes = await decRes;
  197. return JSON.parse(decRes ?? data) as TData;
  198. }
  199. /** Copies a JSON-compatible object and loses its internal references */
  200. private deepCopy<T>(obj: T): T {
  201. return JSON.parse(JSON.stringify(obj));
  202. }
  203. }