Source: smart-collections/collection.js

import { CollectionItem } from './collection_item.js';
import { deep_merge } from './helpers.js';
import {render as render_settings_component} from "./components/settings.js";

const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; // for checking if function is async

/**
 * Base class representing a collection of items with various methods to manipulate and retrieve these items.
 */
export class Collection {
  /**
   * Constructs a new Collection instance.
   * @param {Object} env - The environment context containing configurations and adapters.
   */
  constructor(env, opts = {}) {
    this.env = env;
    this.opts = opts;
    if(opts.custom_collection_key) this.collection_key = opts.custom_collection_key;
    this.env[this.collection_key] = this;
    this.config = this.env.config;
    this.items = {};
    this.merge_defaults();
    this.loaded = null;
    this._loading = false;
    this.load_time_ms = null;
    this.settings_container = null;
  }
  static async init(env, opts = {}) {
    env[this.collection_key] = new this(env, opts);
    await env[this.collection_key].init();
    env.collections[this.collection_key] = 'init';
  }

  /**
   * Gets the collection name derived from the class name.
   * @return {String} The collection name.
   */
  static get collection_key() { return this.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(); }

  // INSTANCE METHODS
  async init() {}

  /**
   * Creates or updates an item in the collection based on the provided data.
   * @param {Object} data - The data to create or update an item.
   * @returns {Promise<CollectionItem>|CollectionItem} The newly created or updated item.
   */
  create_or_update(data = {}) {
    const existing = this.find_by(data);
    const item = existing ? existing : new this.item_type(this.env);
    item._queue_save = !!!existing;
    const changed = item.update_data(data); // handles this.data
    if (!existing) {
      if (item.validate_save()) this.set(item); // make it available in collection (if valid)
      else {
        console.warn("Invalid item, skipping adding to collection: ", item);
        return item;
      }
    }
    if (existing && !changed) return existing; // if existing item and no changes, return existing item (no need to save)
    // dynamically handle async init functions
    if (item.init instanceof AsyncFunction) return new Promise((resolve, reject) => { item.init(data).then(() => resolve(item)); });
    item.init(data); // handles functions that involve other items
    return item;
  }

  /**
   * Finds an item in the collection that matches the given data.
   * @param {Object} data - The criteria used to find the item.
   * @returns {CollectionItem|null} The found item or null if not found.
   */
  find_by(data) {
    if(data.key) return this.get(data.key);
    const temp = new this.item_type(this.env);
    const temp_data = JSON.parse(JSON.stringify(data, temp.sanitize_data(data)));
    deep_merge(temp.data, temp_data); // deep merge data
    return temp.key ? this.get(temp.key) : null;
  }
  // READ
  /**
   * Filters the items in the collection based on the provided options.
   * @param {Object} filter_opts - The options used to filter the items.
   * @return {CollectionItem[]} The filtered items.
   */
  filter(filter_opts={}) {
    this.filter_opts = this.prepare_filter(filter_opts);
    const results = [];
    const { limit } = this.filter_opts;
    for (const item of Object.values(this.items)) {
      if (limit && results.length >= limit) break;
      if (item.filter(filter_opts)) {
        results.push(item);
      }
    }
    return results;
  }
  // alias for filter
  list(filter_opts) { return this.filter(filter_opts); }

  /**
   * Prepares filter options for use in the filter implementation.
   * Used by sub-classes to convert simplified filter options into filter_opts compatible with the filter implementation.
   * @param {Object} filter_opts - The original filter options provided.
   * @returns {Object} The prepared filter options compatible with the filter implementation.
   */
  prepare_filter(filter_opts) { return filter_opts; }
  /**
   * Retrieves a single item from the collection based on the provided strategy and options.
   * @param {String} key - The key of the item to retrieve.
   * @return {CollectionItem} The retrieved item.
   */
  get(key) { return this.items[key]; }
  /**
   * Retrieves multiple items from the collection based on the provided keys.
   * @param {String[]} keys - The keys of the items to retrieve.
   * @return {CollectionItem[]} The retrieved items.
   */
  get_many(keys = []) {
    if (Array.isArray(keys)) return keys.map((key) => this.get(key)).filter(Boolean);
    console.error("get_many called with non-array keys: ", keys);
  }
  /**
   * Retrieves a random item from the collection based on the provided options.
   * @param {Object} opts - The options used to retrieve the item.
   * @return {CollectionItem} The retrieved item.
   */
  get_rand(opts = null) {
    if (opts) {
      const filtered = this.filter(opts);
      return filtered[Math.floor(Math.random() * filtered.length)];
    }
    return this.items[this.keys[Math.floor(Math.random() * this.keys.length)]];
  }
  // UPDATE
  /**
   * Adds or updates an item in the collection.
   * @param {CollectionItem} item - The item to add or update.
   */
  set(item) {
    if (!item.key) throw new Error("Item must have key property");
    this.items[item.key] = item;
  }
  /**
   * Updates multiple items in the collection based on the provided keys and data.
   * @param {String[]} keys - The keys of the items to update.
   * @param {Object} data - The data to update the items with.
   */
  update_many(keys = [], data = {}) { this.get_many(keys).forEach((item) => item.update_data(data)); }
  // DESTROY
  /**
   * Clears all items from the collection.
   */
  clear() {
    this.items = {};
  }
  /**
   * Deletes an item from the collection based on its key.
   * Does not trigger save or delete from adapter data.
   * @param {String} key - The key of the item to delete.
   */
  delete_item(key) {
    delete this.items[key];
  }
  /**
   * Deletes multiple items from the collection based on their keys.
   * @param {String[]} keys - The keys of the items to delete.
   */
  delete_many(keys = []) {
    // keys.forEach((key) => delete this.items[key]);
    keys.forEach((key) => {
      this.items[key].delete();
    });
  }
  // CONVENIENCE METHODS (namespace getters)
  /**
   * Gets or sets the collection name. If a name is set, it overrides the default name.
   * @param {String} name - The new collection name.
   */
  get collection_key() { return (this._collection_key) ? this._collection_key : this.constructor.collection_key; }
  set collection_key(name) { this._collection_key = name; }

  // DATA ADAPTER
  get data_adapter() {
    if(!this._data_adapter){
      const config = this.env.opts.collections?.[this.collection_key];
      const data_adapter_class = config?.data_adapter
        ?? this.env.opts.collections?.smart_collections?.data_adapter
      ;
      if(!data_adapter_class) throw new Error("No data adapter class found for " + this.collection_key + " or smart_collections");
      this._data_adapter = new data_adapter_class(this);
    }
    return this._data_adapter;
  }
  get data_dir() { return 'multi'; }
  get data_fs() { return this.env.data_fs; }
  /**
   * Gets the class name of the item type the collection manages.
   * @return {String} The item class name.
   */
  get item_class_name() {
    const name = this.constructor.name;
    if (name.endsWith('ies')) return name.slice(0, -3) + 'y'; // Entities -> Entity
    else if (name.endsWith('s')) return name.slice(0, -1); // Sources -> Source
    else return name + "Item"; // Collection -> CollectionItem
  }
  /**
   * Gets the name of the item type the collection manages, derived from the class name.
   * @return {String} The item name.
   */
  get item_name() { return this.item_class_name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(); }
  /**
   * Gets the constructor of the item type the collection manages.
   * @return {Function} The item type constructor.
   */
  get item_type() { return this.env.item_types[this.item_class_name]; }
  /**
   * Gets the keys of the items in the collection.
   * @return {String[]} The keys of the items.
   */
  get keys() { return Object.keys(this.items); }

  /**
   * @deprecated use data_adapter instead (2024-09-14)
   */
  get adapter(){ return this.data_adapter; }
  /**
   * Gets the data path from the environment.
   * @deprecated use env.env_data_dir
   * @returns {string} The data path.
   */
  get data_path() { return this.env.data_path; } // DEPRECATED
  // ADAPTER METHODS
  /**
   * Saves the current state of the collection.
   */
  async save() { await this.data_adapter.save(); }
  async save_queue() { await this.process_save_queue(); }

  // UTILITY METHODS
  /**
   * Merges default configurations from all classes in the inheritance chain for Collection types; 
   * e.g. EntityCollection, NoteCollection, etc.
   */
  merge_defaults() {
    let current_class = this.constructor;
    while (current_class) { // merge collection config into item config
      const col_conf = this.config?.collections?.[current_class.collection_key];
      Object.entries((typeof col_conf === 'object') ? col_conf : {})
        .forEach(([key, value]) => this[key] = value)
      ;
      current_class = Object.getPrototypeOf(current_class);
    }
  }
  async process_save_queue() {
    this.notices?.show('saving', "Saving " + this.collection_key + "...", { timeout: 0 });
    if(this._saving) return console.log("Already saving");
    this._saving = true;
    setTimeout(() => { this._saving = false; }, 10000); // set _saving to false after 10 seconds
    const save_queue = Object.values(this.items).filter(item => item._queue_save);
    console.log("Saving " + this.collection_key + ": ", save_queue.length + " items");
    const time_start = Date.now();
    await Promise.all(save_queue.map(item => item.save()));
    console.log("Saved " + this.collection_key + " in " + (Date.now() - time_start) + "ms");
    this._saving = false;
    this.notices?.remove('saving');
  }
  async process_load_queue() {
    this.notices?.show('loading', "Loading " + this.collection_key + "...", { timeout: 0 });
    if(this._loading) return console.log("Already loading");
    this._loading = true;
    setTimeout(() => { this._loading = false; }, 10000); // set _loading to false after 10 seconds
    const load_queue = Object.values(this.items).filter(item => item._queue_load);
    console.log("Loading " + this.collection_key + ": ", load_queue.length + " items");
    const time_start = Date.now();
    const batch_size = 100;
    for (let i = 0; i < load_queue.length; i += batch_size) {
      const batch = load_queue.slice(i, i + batch_size);
      await Promise.all(batch.map(item => item.load()));
    }
    this.env.collections[this.collection_key] = 'loaded';
    this.load_time_ms = Date.now() - time_start;
    console.log("Loaded " + this.collection_key + " in " + this.load_time_ms + "ms");
    this._loading = false;
    this.loaded = load_queue.length;
    this.notices?.remove('loading');
  }
  get settings_config() { return this.process_settings_config({}); }
  process_settings_config(_settings_config, prefix = '') {
    const add_prefix = (key) =>
      prefix && !key.includes(`${prefix}.`) ? `${prefix}.${key}` : key;

    return Object.entries(_settings_config).reduce((acc, [key, val]) => {
      // Create a shallow copy to avoid mutating the original _settings_config
      let new_val = { ...val };

      if (new_val.conditional) {
        if (!new_val.conditional(this)) return acc;
        delete new_val.conditional; // Remove conditional to prevent re-checking downstream
      }

      if (new_val.callback) {
        new_val.callback = add_prefix(new_val.callback);
      }

      if (new_val.btn_callback) {
        new_val.btn_callback = add_prefix(new_val.btn_callback);
      }

      if (new_val.options_callback) {
        new_val.options_callback = add_prefix(new_val.options_callback);
      }

      const new_key = add_prefix(this.process_setting_key(key));
      acc[new_key] = new_val;
      return acc;
    }, {});
  }
  process_setting_key(key) { return key; } // override in sub-class if needed for prefixes and variable replacements
  get default_settings() { return {}; }
  get settings() {
    if(!this.env.settings[this.collection_key]){
      this.env.settings[this.collection_key] = this.default_settings;
    }
    return this.env.settings[this.collection_key];
  }
  get render_settings_component() {
    return (typeof this.opts.components?.settings === 'function'
      ? this.opts.components.settings
      : render_settings_component
    ).bind(this.smart_view);
  }
  get smart_view() {
    if(!this._smart_view) this._smart_view = this.env.init_module('smart_view');
    return this._smart_view;
  }
  /**
   * Renders the settings for the collection.
   * @param {HTMLElement} container - The container element to render the settings into.
   * @param {Object} opts - Additional options for rendering.
   * @param {Object} opts.settings_keys - An array of keys to render.
   */
  async render_settings(container=this.settings_container, opts = {}) {
    if(!this.settings_container || container !== this.settings_container) this.settings_container = container;
    if(!container) throw new Error("Container is required");
    container.innerHTML = '';
    container.innerHTML = '<div class="sc-loading">Loading ' + this.collection_key + ' settings...</div>';
    const frag = await this.render_settings_component(this, opts);
    container.innerHTML = '';
    container.appendChild(frag);
    this.smart_view.on_open_overlay(container);
    return container;
  }

  unload() {
    this.clear();
  }
  async run_load() {
    this.loaded = null;
    this.load_time_ms = null;
    Object.values(this.items).forEach((item) => item.queue_load());
    this.notices?.show(`loading ${this.collection_key}`, `Loading ${this.collection_key}...`, { timeout: 0 });
    await this.process_load_queue();
    this.notices?.remove(`loading ${this.collection_key}`);
    this.notices?.show('done loading', `${this.collection_key} loaded`, { timeout: 3000 });
    this.render_settings(); // re-render settings
  }

}