List.js

import Resource from './Resource.js';
import factory from './factory.js';

/**
 * List is a container for other entries in the same context.
 * A single entry may appear in multiple lists (multiple parent lists) unless if it is
 * a list itself. To avoid circular references list entries are only allowed to appear
 * in one parent list.
 *
 * @exports store/List
 */
export default class List extends Resource {
  /**
   * @param {string} entryURI - URI to an entry where this resource is contained.
   * @param {string} resourceURI - URI to the resource.
   * @param {EntryStore} entryStore - the API's repository instance.
   */
  constructor(entryURI, resourceURI, entryStore) {
    super(entryURI, resourceURI, entryStore);
    this._cache = entryStore.getCache();
    /**
     * Array of entry URIs
     * @type {Array.<string>}
     * @private
     */
    this._sortedChildren = [];
  }

  /**
   * Set the max amount of entries to include in each page during pagination.
   *
   * @param {integer} limit
   * @returns {List} allows chaining of set-operations.
   */
  setLimit(limit) {
    this._limit = limit;
    return this;
  }

  /**
   * Get the max amount of entries to include in each page during pagination.
   *
   * @returns {integer}
   */
  getLimit() {
    return this._limit || factory.getDefaultLimit();
  }

  /**
   * Sets the sort order when loading entries contained in this list.
   *
   * @param {Object} sortParams - same object structure as the sort parameter in
   * optionalLoadParameters in {@link EntryStore#getEntry} method.
   * @returns {List} allows chaining of set-operations.
   */
  setSort(sortParams) {
    this._clearSortedEntries();
    this._sortParams = sortParams;
    return this;
  }

  /**
   * Retrieves an array of entries contained in this list according to the current page and
   * pagination settings.
   * @param {number} page - the page to request an array of entries for,
   * first page is numbered 0.
   * @returns {Promise.<Entry[]>} the promise will return an entry-array.
   */
  getEntries(page = 0) {
    const results = this._getEntries(page);
    if (results != null) {
      return Promise.resolve(results);
    }
    return this._forceLoadEntries(page);
  }

  /**
   * Executes a callback on each list members in the order provided by the list.
   * If the provided function return false for one entry the iteration is stopped and
   * the function is not called for consecutive entries.
   *
   * @param {Function} func
   * @return {Promise} called with two parameters, the first a boolean saying if all entries
   * where passed, the second an index telling how many entries iterated over.
   */
  forEach(func) {
    let page = 0;
    let idx = 0;
    let g;
    let h;
    const limit = this.getLimit();

    const f = (entries) => {
      const entriesLength = entries.length;
      g = (res) => {
        if (res === false) {
          return Promise.resolve(false);
        }
        return h();
      };
      h = () => {
        if (entries.length === 0) {
          return Promise.resolve(true);
        }
        const res = func(entries.shift(), idx);
        idx += 1;
        if (typeof res !== 'undefined' && typeof res.then === 'function') {
          return res.then(g);
        }
        return g(res);
      };
      return h().then((cont) => {
        if (cont !== false && entriesLength === limit) {
          page += 1;
          return this.getEntries(page).then(f);
        }
        return Promise.resolve(idx);
      });
    };

    return this.getEntries(0).then(f);
  }

  /**
   * Loops through with forEach and accumulates the entries in a single array.
   * Note! this might be memory intensive for large lists
   * @see forEach
   * @returns {Promise.<Entry[]>}
   * @todo add tests
   */
  async getAllEntries() {
    const entries = [];
    await this.forEach(entry => entries.push(entry));

    return entries;
  }

  /**
   * Adds an entry to this list, on success the List entry will be returned (updated with
   * latest modification date). The added entry will be marked as in need of a
   * refresh due to stale inv-rel cache. However,since List entry is loaded it may be refreshed
   * already when method is resolved, it depends if it is in the first page of the list.
   *
   * @param {Entry} entry - entry to add to the list.
   * @returns {Promise.<Entry>}
   */
  async addEntry(entry) {
    const entries = await this.getAllEntryIds();
    entries.push(entry.getId());
    await this.setAllEntryIds(entries, 'addToList');
    return this.getEntry();
  }

  /**
   * Removes an entry from this list, on success the List entry will be returned (updated with
   * latest modification date). The removed entry will not be updated but marked as in need
   * of a refresh due to stale inv-rel cache. However,since List entry is loaded it may be
   * refreshed already when method is resolved, it depends if it is in the first page of the list.
   *
   * @param {Entry} entry - entry to be removed from the list.
   * @returns {Promise}
   */
  async removeEntry(entry) {
    const entries = await this.getAllEntryIds();
    entries.splice(entries.indexOf(entry.getId()), 1);
    await this.setAllEntryIds(entries, 'removeFromList');
    return this.getEntry();
  }

  /**
   * Will unset things since the cache is stale...
   */
  needRefresh() {
    delete this._unsortedChildren;
    this._sortedChildren = [];
    delete this._size;
  }

  /**
   * Get a list of entry ids contained in this list.
   *
   * @returns {Promise.<Array.<string>>} the promise will deliver an array of children entries in this
   * list as ids
   * (strings, not full URIs).
   */
  getAllEntryIds() {
    if (this._unsortedChildren != null) {
      return Promise.resolve(this._unsortedChildren);
    }
    return this.getEntries().then(() => this._unsortedChildren);
  }

  /**
   * Set a list of entry ids to be contained in this list.
   *
   * @param {string[]} entries - array of entry ids (as strings, not full URIs).
   * @param {string} callType
   * @returns {Promise.<Entry>}
   */
  async setAllEntryIds(entries, callType) {
    const es = this.getEntryStore();
    const entry = await this.getEntry();
    const entryInfo = entry.getEntryInfo();
    const promise = es.getREST().put(this._resourceURI, JSON.stringify(entries));
    es.handleAsync(promise, callType || 'setList');
    const response = await promise;
    entryInfo.setModificationDate(response.header['last-modified']);
    return entry;
  }

  /**
   * Get size of list.
   *
   * @returns {number} the amount of entries in the list, -1 if unknown.
   */
  getSize() {
    return typeof this._size === 'number' ? this._size : -1;
  }

  //= ========Helper methods===============

  _clearSortedEntries() {
    this._sortedChildren = [];
  }

  /**
   *
   * @param page
   * @param careAboutFresh
   * @return {null|Array}
   * @private
   */
  _getEntries(page = 0, careAboutFresh = false) {
    if (this._size == null) {
      return null;
    }
    const limit = this.getLimit();
    const offset = page * limit;

    let needRefresh = false;
    const results = [];
    let entryURI;
    for (let i = offset; i < offset + limit && i < this._size; i++) {
      entryURI = this._sortedChildren[i];
      if (entryURI) {
        const e = this._entryStore.getCache().get(entryURI);
        if (careAboutFresh === false || (e != null && !e.needRefresh())) {
          results.push(e);
        } else {
          needRefresh = true;
          break;
        }
      } else {
        needRefresh = true;
        break;
      }
    }
    if (needRefresh) {
      return null;
    }
    return results;
  }

  /**
   *
   * @param page
   * @return {*|Promise<Array | never>}
   * @private
   */
  _forceLoadEntries(page = 0) {
    const limit = this.getLimit();
    const offset = page * limit;
    return this._entryStore.getEntry(this._entryURI, {
      forceLoad: true,
      offset,
      limit,
      sort: this._sort,
      asyncContext: 'loadListEntries',
    }).then(() => this._getEntries(page, false));
  }


  /**
   * Data contains allUnsorted array, size, and children.
   *
   * @param data
   * @param children
   * @private
   */
  _update(data, children) {
    const offset = data.offset || 0;
    for (let i = 0; i < children.length; i++) {
      this._sortedChildren[offset + i] = children[i].getURI();
    }
    this._size = data.size || children.length;
    this._unsortedChildren = data.allUnsorted || children.map(entry => entry.getId());
  }
};