Source: smart-collections/collection.js

  1. import { CollectionItem } from './collection_item.js';
  2. import { deep_merge } from './helpers.js';
  3. import {render as render_settings_component} from "./components/settings.js";
  4. const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; // for checking if function is async
  5. /**
  6. * Base class representing a collection of items with various methods to manipulate and retrieve these items.
  7. */
  8. export class Collection {
  9. /**
  10. * Constructs a new Collection instance.
  11. * @param {Object} env - The environment context containing configurations and adapters.
  12. */
  13. constructor(env, opts = {}) {
  14. this.env = env;
  15. this.opts = opts;
  16. if(opts.custom_collection_key) this.collection_key = opts.custom_collection_key;
  17. this.env[this.collection_key] = this;
  18. this.config = this.env.config;
  19. this.items = {};
  20. this.merge_defaults();
  21. this.loaded = null;
  22. this._loading = false;
  23. this.load_time_ms = null;
  24. this.settings_container = null;
  25. }
  26. static async init(env, opts = {}) {
  27. env[this.collection_key] = new this(env, opts);
  28. await env[this.collection_key].init();
  29. env.collections[this.collection_key] = 'init';
  30. }
  31. /**
  32. * Gets the collection name derived from the class name.
  33. * @return {String} The collection name.
  34. */
  35. static get collection_key() { return this.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(); }
  36. // INSTANCE METHODS
  37. async init() {}
  38. /**
  39. * Creates or updates an item in the collection based on the provided data.
  40. * @param {Object} data - The data to create or update an item.
  41. * @returns {Promise<CollectionItem>|CollectionItem} The newly created or updated item.
  42. */
  43. create_or_update(data = {}) {
  44. const existing = this.find_by(data);
  45. const item = existing ? existing : new this.item_type(this.env);
  46. item._queue_save = !!!existing;
  47. const changed = item.update_data(data); // handles this.data
  48. if (!existing) {
  49. if (item.validate_save()) this.set(item); // make it available in collection (if valid)
  50. else {
  51. console.warn("Invalid item, skipping adding to collection: ", item);
  52. return item;
  53. }
  54. }
  55. if (existing && !changed) return existing; // if existing item and no changes, return existing item (no need to save)
  56. // dynamically handle async init functions
  57. if (item.init instanceof AsyncFunction) return new Promise((resolve, reject) => { item.init(data).then(() => resolve(item)); });
  58. item.init(data); // handles functions that involve other items
  59. return item;
  60. }
  61. /**
  62. * Finds an item in the collection that matches the given data.
  63. * @param {Object} data - The criteria used to find the item.
  64. * @returns {CollectionItem|null} The found item or null if not found.
  65. */
  66. find_by(data) {
  67. if(data.key) return this.get(data.key);
  68. const temp = new this.item_type(this.env);
  69. const temp_data = JSON.parse(JSON.stringify(data, temp.sanitize_data(data)));
  70. deep_merge(temp.data, temp_data); // deep merge data
  71. return temp.key ? this.get(temp.key) : null;
  72. }
  73. // READ
  74. /**
  75. * Filters the items in the collection based on the provided options.
  76. * @param {Object} filter_opts - The options used to filter the items.
  77. * @return {CollectionItem[]} The filtered items.
  78. */
  79. filter(filter_opts={}) {
  80. this.filter_opts = this.prepare_filter(filter_opts);
  81. const results = [];
  82. const { limit } = this.filter_opts;
  83. for (const item of Object.values(this.items)) {
  84. if (limit && results.length >= limit) break;
  85. if (item.filter(filter_opts)) {
  86. results.push(item);
  87. }
  88. }
  89. return results;
  90. }
  91. // alias for filter
  92. list(filter_opts) { return this.filter(filter_opts); }
  93. /**
  94. * Prepares filter options for use in the filter implementation.
  95. * Used by sub-classes to convert simplified filter options into filter_opts compatible with the filter implementation.
  96. * @param {Object} filter_opts - The original filter options provided.
  97. * @returns {Object} The prepared filter options compatible with the filter implementation.
  98. */
  99. prepare_filter(filter_opts) { return filter_opts; }
  100. /**
  101. * Retrieves a single item from the collection based on the provided strategy and options.
  102. * @param {String} key - The key of the item to retrieve.
  103. * @return {CollectionItem} The retrieved item.
  104. */
  105. get(key) { return this.items[key]; }
  106. /**
  107. * Retrieves multiple items from the collection based on the provided keys.
  108. * @param {String[]} keys - The keys of the items to retrieve.
  109. * @return {CollectionItem[]} The retrieved items.
  110. */
  111. get_many(keys = []) {
  112. if (Array.isArray(keys)) return keys.map((key) => this.get(key)).filter(Boolean);
  113. console.error("get_many called with non-array keys: ", keys);
  114. }
  115. /**
  116. * Retrieves a random item from the collection based on the provided options.
  117. * @param {Object} opts - The options used to retrieve the item.
  118. * @return {CollectionItem} The retrieved item.
  119. */
  120. get_rand(opts = null) {
  121. if (opts) {
  122. const filtered = this.filter(opts);
  123. return filtered[Math.floor(Math.random() * filtered.length)];
  124. }
  125. return this.items[this.keys[Math.floor(Math.random() * this.keys.length)]];
  126. }
  127. // UPDATE
  128. /**
  129. * Adds or updates an item in the collection.
  130. * @param {CollectionItem} item - The item to add or update.
  131. */
  132. set(item) {
  133. if (!item.key) throw new Error("Item must have key property");
  134. this.items[item.key] = item;
  135. }
  136. /**
  137. * Updates multiple items in the collection based on the provided keys and data.
  138. * @param {String[]} keys - The keys of the items to update.
  139. * @param {Object} data - The data to update the items with.
  140. */
  141. update_many(keys = [], data = {}) { this.get_many(keys).forEach((item) => item.update_data(data)); }
  142. // DESTROY
  143. /**
  144. * Clears all items from the collection.
  145. */
  146. clear() {
  147. this.items = {};
  148. }
  149. /**
  150. * Deletes an item from the collection based on its key.
  151. * Does not trigger save or delete from adapter data.
  152. * @param {String} key - The key of the item to delete.
  153. */
  154. delete_item(key) {
  155. delete this.items[key];
  156. }
  157. /**
  158. * Deletes multiple items from the collection based on their keys.
  159. * @param {String[]} keys - The keys of the items to delete.
  160. */
  161. delete_many(keys = []) {
  162. // keys.forEach((key) => delete this.items[key]);
  163. keys.forEach((key) => {
  164. this.items[key].delete();
  165. });
  166. }
  167. // CONVENIENCE METHODS (namespace getters)
  168. /**
  169. * Gets or sets the collection name. If a name is set, it overrides the default name.
  170. * @param {String} name - The new collection name.
  171. */
  172. get collection_key() { return (this._collection_key) ? this._collection_key : this.constructor.collection_key; }
  173. set collection_key(name) { this._collection_key = name; }
  174. // DATA ADAPTER
  175. get data_adapter() {
  176. if(!this._data_adapter){
  177. const config = this.env.opts.collections?.[this.collection_key];
  178. const data_adapter_class = config?.data_adapter
  179. ?? this.env.opts.collections?.smart_collections?.data_adapter
  180. ;
  181. if(!data_adapter_class) throw new Error("No data adapter class found for " + this.collection_key + " or smart_collections");
  182. this._data_adapter = new data_adapter_class(this);
  183. }
  184. return this._data_adapter;
  185. }
  186. get data_dir() { return 'multi'; }
  187. get data_fs() { return this.env.data_fs; }
  188. /**
  189. * Gets the class name of the item type the collection manages.
  190. * @return {String} The item class name.
  191. */
  192. get item_class_name() {
  193. const name = this.constructor.name;
  194. if (name.endsWith('ies')) return name.slice(0, -3) + 'y'; // Entities -> Entity
  195. else if (name.endsWith('s')) return name.slice(0, -1); // Sources -> Source
  196. else return name + "Item"; // Collection -> CollectionItem
  197. }
  198. /**
  199. * Gets the name of the item type the collection manages, derived from the class name.
  200. * @return {String} The item name.
  201. */
  202. get item_name() { return this.item_class_name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(); }
  203. /**
  204. * Gets the constructor of the item type the collection manages.
  205. * @return {Function} The item type constructor.
  206. */
  207. get item_type() { return this.env.item_types[this.item_class_name]; }
  208. /**
  209. * Gets the keys of the items in the collection.
  210. * @return {String[]} The keys of the items.
  211. */
  212. get keys() { return Object.keys(this.items); }
  213. /**
  214. * @deprecated use data_adapter instead (2024-09-14)
  215. */
  216. get adapter(){ return this.data_adapter; }
  217. /**
  218. * Gets the data path from the environment.
  219. * @deprecated use env.env_data_dir
  220. * @returns {string} The data path.
  221. */
  222. get data_path() { return this.env.data_path; } // DEPRECATED
  223. // ADAPTER METHODS
  224. /**
  225. * Saves the current state of the collection.
  226. */
  227. async save() { await this.data_adapter.save(); }
  228. async save_queue() { await this.process_save_queue(); }
  229. // UTILITY METHODS
  230. /**
  231. * Merges default configurations from all classes in the inheritance chain for Collection types;
  232. * e.g. EntityCollection, NoteCollection, etc.
  233. */
  234. merge_defaults() {
  235. let current_class = this.constructor;
  236. while (current_class) { // merge collection config into item config
  237. const col_conf = this.config?.collections?.[current_class.collection_key];
  238. Object.entries((typeof col_conf === 'object') ? col_conf : {})
  239. .forEach(([key, value]) => this[key] = value)
  240. ;
  241. current_class = Object.getPrototypeOf(current_class);
  242. }
  243. }
  244. async process_save_queue() {
  245. this.notices?.show('saving', "Saving " + this.collection_key + "...", { timeout: 0 });
  246. if(this._saving) return console.log("Already saving");
  247. this._saving = true;
  248. setTimeout(() => { this._saving = false; }, 10000); // set _saving to false after 10 seconds
  249. const save_queue = Object.values(this.items).filter(item => item._queue_save);
  250. console.log("Saving " + this.collection_key + ": ", save_queue.length + " items");
  251. const time_start = Date.now();
  252. await Promise.all(save_queue.map(item => item.save()));
  253. console.log("Saved " + this.collection_key + " in " + (Date.now() - time_start) + "ms");
  254. this._saving = false;
  255. this.notices?.remove('saving');
  256. }
  257. async process_load_queue() {
  258. this.notices?.show('loading', "Loading " + this.collection_key + "...", { timeout: 0 });
  259. if(this._loading) return console.log("Already loading");
  260. this._loading = true;
  261. setTimeout(() => { this._loading = false; }, 10000); // set _loading to false after 10 seconds
  262. const load_queue = Object.values(this.items).filter(item => item._queue_load);
  263. console.log("Loading " + this.collection_key + ": ", load_queue.length + " items");
  264. const time_start = Date.now();
  265. const batch_size = 100;
  266. for (let i = 0; i < load_queue.length; i += batch_size) {
  267. const batch = load_queue.slice(i, i + batch_size);
  268. await Promise.all(batch.map(item => item.load()));
  269. }
  270. this.env.collections[this.collection_key] = 'loaded';
  271. this.load_time_ms = Date.now() - time_start;
  272. console.log("Loaded " + this.collection_key + " in " + this.load_time_ms + "ms");
  273. this._loading = false;
  274. this.loaded = load_queue.length;
  275. this.notices?.remove('loading');
  276. }
  277. get settings_config() { return this.process_settings_config({}); }
  278. process_settings_config(_settings_config, prefix = '') {
  279. const add_prefix = (key) =>
  280. prefix && !key.includes(`${prefix}.`) ? `${prefix}.${key}` : key;
  281. return Object.entries(_settings_config).reduce((acc, [key, val]) => {
  282. // Create a shallow copy to avoid mutating the original _settings_config
  283. let new_val = { ...val };
  284. if (new_val.conditional) {
  285. if (!new_val.conditional(this)) return acc;
  286. delete new_val.conditional; // Remove conditional to prevent re-checking downstream
  287. }
  288. if (new_val.callback) {
  289. new_val.callback = add_prefix(new_val.callback);
  290. }
  291. if (new_val.btn_callback) {
  292. new_val.btn_callback = add_prefix(new_val.btn_callback);
  293. }
  294. if (new_val.options_callback) {
  295. new_val.options_callback = add_prefix(new_val.options_callback);
  296. }
  297. const new_key = add_prefix(this.process_setting_key(key));
  298. acc[new_key] = new_val;
  299. return acc;
  300. }, {});
  301. }
  302. process_setting_key(key) { return key; } // override in sub-class if needed for prefixes and variable replacements
  303. get default_settings() { return {}; }
  304. get settings() {
  305. if(!this.env.settings[this.collection_key]){
  306. this.env.settings[this.collection_key] = this.default_settings;
  307. }
  308. return this.env.settings[this.collection_key];
  309. }
  310. get render_settings_component() {
  311. return (typeof this.opts.components?.settings === 'function'
  312. ? this.opts.components.settings
  313. : render_settings_component
  314. ).bind(this.smart_view);
  315. }
  316. get smart_view() {
  317. if(!this._smart_view) this._smart_view = this.env.init_module('smart_view');
  318. return this._smart_view;
  319. }
  320. /**
  321. * Renders the settings for the collection.
  322. * @param {HTMLElement} container - The container element to render the settings into.
  323. * @param {Object} opts - Additional options for rendering.
  324. * @param {Object} opts.settings_keys - An array of keys to render.
  325. */
  326. async render_settings(container=this.settings_container, opts = {}) {
  327. if(!this.settings_container || container !== this.settings_container) this.settings_container = container;
  328. if(!container) throw new Error("Container is required");
  329. container.innerHTML = '';
  330. container.innerHTML = '<div class="sc-loading">Loading ' + this.collection_key + ' settings...</div>';
  331. const frag = await this.render_settings_component(this, opts);
  332. container.innerHTML = '';
  333. container.appendChild(frag);
  334. this.smart_view.on_open_overlay(container);
  335. return container;
  336. }
  337. unload() {
  338. this.clear();
  339. }
  340. async run_load() {
  341. this.loaded = null;
  342. this.load_time_ms = null;
  343. Object.values(this.items).forEach((item) => item.queue_load());
  344. this.notices?.show(`loading ${this.collection_key}`, `Loading ${this.collection_key}...`, { timeout: 0 });
  345. await this.process_load_queue();
  346. this.notices?.remove(`loading ${this.collection_key}`);
  347. this.notices?.show('done loading', `${this.collection_key} loaded`, { timeout: 3000 });
  348. this.render_settings(); // re-render settings
  349. }
  350. }