config.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  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 = <TOldData = any>(oldData: TOldData) => any | Promise<any>;
  4. /** Dictionary of format version numbers and the function that migrates to them from the previous whole integer */
  5. type MigrationsDict = Record<number, MigrationFunc>;
  6. /** Options for the ConfigManager instance */
  7. export interface 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, 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?: MigrationsDict;
  32. }
  33. /**
  34. * Manages a user configuration that is cached in memory and persistently saved across sessions.
  35. * 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.
  36. *
  37. * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue`
  38. *
  39. * @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`
  40. */
  41. export class ConfigManager<TData = any> {
  42. public readonly id: string;
  43. public readonly formatVersion: number;
  44. public readonly defaultConfig: TData;
  45. private cachedConfig: TData;
  46. private migrations?: MigrationsDict;
  47. /**
  48. * Creates an instance of ConfigManager.
  49. *
  50. * ⚠️ Make sure to call `loadData()` at least once after creating an instance, or the returned data will be the same as `options.defaultConfig`
  51. * @param options The options for this ConfigManager instance
  52. */
  53. constructor(options: ConfigManagerOptions<TData>) {
  54. this.id = options.id;
  55. this.formatVersion = options.formatVersion;
  56. this.defaultConfig = options.defaultConfig;
  57. this.cachedConfig = options.defaultConfig;
  58. this.migrations = options.migrations;
  59. }
  60. /**
  61. * Loads the data saved in persistent storage into the in-memory cache and also returns it.
  62. * Automatically populates persistent storage with default data if it doesn't contain any data yet.
  63. * Also runs all necessary migration functions if the data format has changed since the last time the data was saved.
  64. */
  65. public async loadData(): Promise<TData> {
  66. try {
  67. const gmData = await GM.getValue(this.id, this.defaultConfig);
  68. let gmFmtVer = Number(await GM.getValue(`_uufmtver-${this.id}`));
  69. if(typeof gmData !== "string")
  70. return await this.saveDefaultData();
  71. if(isNaN(gmFmtVer))
  72. await GM.setValue(`_uufmtver-${this.id}`, gmFmtVer = this.formatVersion);
  73. let parsed = JSON.parse(gmData);
  74. if(gmFmtVer < this.formatVersion && this.migrations)
  75. parsed = await this.runMigrations(parsed, gmFmtVer);
  76. return this.cachedConfig = typeof parsed === "object" ? parsed : undefined;
  77. }
  78. catch(err) {
  79. return await this.saveDefaultData();
  80. }
  81. }
  82. /** Returns a copy of the data from the in-memory cache. Use `loadData()` to get fresh data from persistent storage (usually not necessary since the cache should always exactly reflect persistent storage). */
  83. public getData(): TData {
  84. return this.deepCopy(this.cachedConfig);
  85. }
  86. /** Saves the data synchronously to the in-memory cache and asynchronously to the persistent storage */
  87. public setData(data: TData) {
  88. this.cachedConfig = data;
  89. return new Promise<TData>(async (resolve) => {
  90. await GM.setValue(this.id, JSON.stringify(data));
  91. await GM.setValue(`_uufmtver-${this.id}`, this.formatVersion);
  92. resolve(data);
  93. });
  94. }
  95. /** Saves the default configuration data passed in the constructor synchronously to the in-memory cache and asynchronously to persistent storage */
  96. public async saveDefaultData() {
  97. this.cachedConfig = this.defaultConfig;
  98. return new Promise<TData>(async (resolve) => {
  99. await GM.setValue(this.id, JSON.stringify(this.defaultConfig));
  100. await GM.setValue(`_uufmtver-${this.id}`, this.formatVersion);
  101. resolve(this.defaultConfig);
  102. });
  103. }
  104. /**
  105. * Call this method to clear all persistently stored data associated with this ConfigManager instance.
  106. * The in-memory cache will be left untouched, so you may still access the data with `getData()`
  107. * Calling `loadData()` or `setData()` after this method was called will recreate persistent storage with the cached or default data.
  108. *
  109. * ⚠️ This requires the additional directive `@grant GM.deleteValue`
  110. */
  111. public async deleteConfig() {
  112. await GM.deleteValue(this.id);
  113. await GM.deleteValue(`_uufmtver-${this.id}`);
  114. }
  115. /** Runs all necessary migration functions consecutively - may be overwritten in a subclass */
  116. protected async runMigrations(oldData: any, oldFmtVer: number): Promise<TData> {
  117. if(!this.migrations)
  118. return oldData as TData;
  119. console.info("#DEBUG - RUNNING MIGRATIONS", oldFmtVer, "->", this.formatVersion, "- oldData:", oldData);
  120. // TODO: verify
  121. let newData = oldData;
  122. const sortedMigrations = Object.entries(this.migrations)
  123. .sort(([a], [b]) => Number(a) - Number(b));
  124. for(const [fmtVer, migrationFunc] of sortedMigrations) {
  125. const ver = Number(fmtVer);
  126. if(oldFmtVer < this.formatVersion && oldFmtVer < ver) {
  127. try {
  128. const migRes = migrationFunc(newData);
  129. newData = migRes instanceof Promise ? await migRes : migRes;
  130. oldFmtVer = ver;
  131. }
  132. catch(err) {
  133. console.error(`Error while running migration function for format version ${fmtVer}:`, err);
  134. }
  135. }
  136. }
  137. await GM.setValue(`_uufmtver-${this.id}`, this.formatVersion);
  138. return newData as TData;
  139. }
  140. /** Copies a JSON-compatible object and loses its internal references */
  141. private deepCopy<T>(obj: T): T {
  142. return JSON.parse(JSON.stringify(obj));
  143. }
  144. }