Models/Catalog.js

"use strict";

/*global require*/
var defined = require("terriajs-cesium/Source/Core/defined").default;
var defaultValue = require("terriajs-cesium/Source/Core/defaultValue").default;

var DeveloperError = require("terriajs-cesium/Source/Core/DeveloperError")
  .default;
var knockout = require("terriajs-cesium/Source/ThirdParty/knockout").default;
var CsvCatalogItem = require("./CsvCatalogItem");
var CatalogGroup = require("./CatalogGroup");
var USER_ADDED_CATEGORY_NAME = require("../Core/addedByUser")
  .USER_ADDED_CATEGORY_NAME;
var CHART_DATA_CATEGORY_NAME = require("../Core/addedForCharts")
  .CHART_DATA_CATEGORY_NAME;
var when = require("terriajs-cesium/Source/ThirdParty/when").default;
var i18next = require("i18next").default;

/**
 * The view model for the data catalog.
 *
 * @param {Terria} terria The Terria instance.
 *
 * @alias Catalog
 * @constructor
 */
var Catalog = function(terria) {
  if (!defined(terria)) {
    throw new DeveloperError("terria is required");
  }

  this._terria = terria;
  this._shareKeyIndex = {};

  this._group = new CatalogGroup(terria);
  this._group.name = "Root Group";
  this._group.preserveOrder = true;

  /**
   * Gets or sets a flag indicating whether the catalog is currently loading.
   * @type {Boolean}
   */
  this.isLoading = false;

  this._chartableItems = [];

  knockout.track(this, ["isLoading", "_chartableItems"]);

  knockout.defineProperty(this, "userAddedDataGroup", {
    get: this.upsertCatalogGroup.bind(
      this,
      CatalogGroup,
      USER_ADDED_CATEGORY_NAME,
      i18next.t("models.catalog.userAddedDataGroup")
    )
  });

  knockout.defineProperty(this, "chartDataGroup", {
    get: this.upsertCatalogGroup.bind(
      this,
      CatalogGroup,
      CHART_DATA_CATEGORY_NAME,
      i18next.t("models.catalog.chartDataGroup")
    )
  });

  /**
   * Array of the items that should be shown as a chart.
   */
  knockout.defineProperty(this, "chartableItems", {
    get: function() {
      return this._chartableItems;
    }
  });
};

Object.defineProperties(Catalog.prototype, {
  /**
   * Gets the Terria instance.
   * @memberOf Catalog.prototype
   * @type {Terria}
   */
  terria: {
    get: function() {
      return this._terria;
    }
  },

  /**
   * Gets the catalog's top-level group.
   * @memberOf Catalog.prototype
   * @type {CatalogGroup}
   */
  group: {
    get: function() {
      return this._group;
    }
  },

  /**
   * A flat index of all catalog member in this catalog by their share keys. Because items can have multiple share keys
   * to preserve backwards compatibility, multiple entries in this index will lead to the same catalog member.
   *
   * @type {Object}
   */
  shareKeyIndex: {
    get: function() {
      return this._shareKeyIndex;
    }
  }
});

/**
 * Updates the catalog from a JSON object-literal description of the available collections.
 * Existing collections with the same name as a collection in the JSON description are
 * updated.  If the description contains a collection with a name that does not yet exist,
 * it is created.  Because parts of the update may happen asynchronously, this method
 * returns at Promise that will resolve when the update is completely done.
 *
 * @param {Object} json The JSON description.  The JSON should be in the form of an object literal, not a string.
 * @param {Object} [options] Object with the following properties:
 * @param {Boolean} [options.onlyUpdateExistingItems] true to only update existing items and never create new ones, or false is new items
 *                                                    may be created by this update.
 * @param {Boolean} [options.isUserSupplied] If specified, sets the {@link CatalogMember#isUserSupplied} property of updated catalog members
 *                                           to the given value.  If not specified, the property is left unchanged.
 * @returns {Promise} A promise that resolves when the update is complete.
 */
Catalog.prototype.updateFromJson = function(json, options) {
  var that = this;
  options = defaultValue(options, {});

  return CatalogGroup.updateItems(json, options, this.group).then(function() {
    that.terria.nowViewing.sortByNowViewingIndices();
  });
};

/**
 * Serializes the catalog to JSON.
 *
 * @param {Object} [options] Object with the following properties:
 * @param {Function} [options.propertyFilter] Filter function that will be executed to determine whether a property
 *          should be serialized.
 * @param {Function} [options.itemFilter] Filter function that will be executed for each item in a group to determine
 *          whether that item should be serialized.
 * @return {Object} The serialized JSON object-literal.
 */
Catalog.prototype.serializeToJson = function(options) {
  this.terria.nowViewing.recordNowViewingIndices();

  return this.group.serializeToJson(options).items;
};

/**
 * Resolves items in the catalog based on the share keys provided, and updates them with the passed info and
 * enables them along with all their ancestors in the catalog hierarchy. This is asynchronous as it may involve a number
 * of CatalogItem#load calls.
 *
 * Note that because of the lazily-loaded nature of the catalog, items within it may not be resolvable by shareKey until
 * their parents have loaded. As a result this loads sharedObjects in serial from left to right. If a catalog member is the
 * child of an asynchronously-loaded catalog group (like a ckan or socrata group), then that group's shareKey precede the
 * child member.
 *
 * @param {Object} sharedObjects A flat map of string-based share keys with data to update on the resolved object. It is
 *      possible to pass an empty object if nothing needs updating.
 * @returns {Promise} A promise that will resolve when all the items have been loaded and enabled.
 */
Catalog.prototype.updateByShareKeys = function(sharedObjects) {
  const that = this;

  return Object.keys(sharedObjects)
    .reduce(
      function(aggregatedPromise, shareKey) {
        var itemJson = sharedObjects[shareKey];

        return aggregatedPromise
          .then(this._loadInSerial.bind(this, itemJson.parents || []))
          .then(this._updateAndEnable.bind(this, shareKey, itemJson));
      }.bind(this),
      when()
    )
    .then(() => {
      that.terria.nowViewing.sortByNowViewingIndices();
    });
};

/**
 * Calls {@link CatalogMember#load} on a number of catalog members, identified by their share key, one after the other
 * from left to right. Doing this in serial is important because sometimes calling load() on a catalog group will
 * reveal more catalog items under it, which will be added to the catalog's shareKeyIndex - if these were done in
 * parallel, share keys towards the right of the array would not able to be resolved at all.
 *
 * @param shareKeys An array of catalog member share keys to use for resolving catalog members from the catalog
 * @returns {Promise} A promise that will be resolved when all the catalog members have either loaded or failed to load.
 * @private
 */
Catalog.prototype._loadInSerial = function(shareKeys) {
  return shareKeys.reduce(
    function(aggregatedPromise, shareKey) {
      return aggregatedPromise.then(
        function() {
          if (this.shareKeyIndex[shareKey]) {
            return this.shareKeyIndex[shareKey].load();
          }
        }.bind(this)
      );
    }.bind(this),
    when()
  );
};

/**
 * Finds the an item in the catalog with the passed shareKey, updates it with the passed itemJson, THEN calls load on
 * the item, THEN enables it and all its parents. If no item for the passed shareKey can be found, logs a warning but
 * proceeds without an exception.
 *
 * @returns {Promise} A promise that will be resolved when all of this is done.
 * @private
 */
Catalog.prototype._updateAndEnable = function(shareKey, itemJson) {
  var existingMember = this.shareKeyIndex[shareKey];
  if (existingMember) {
    // Update THEN load is the expected behaviour for some catalog items (e.g. CSV) - if these operations aren't
    // executed in this order then bugs happen in auto-generated legends.

    return existingMember
      .updateFromJson(itemJson)
      .then(existingMember.load.bind(existingMember))
      .then(existingMember.enableWithParents.bind(existingMember));
  } else if (
    itemJson.isCsvForCharting === true &&
    (defined(itemJson.url) || defined(itemJson.dataUrl))
  ) {
    return CsvCatalogItem.regenerateChartItem(itemJson, this.terria);
  } else {
    console.warn(
      "Share link has a catalog member with shareKey " +
        shareKey +
        " but could not find " +
        "this in the catalog"
    );
  }
};

/**
 * If a catalog group exists with this name, update it, otherwise create it.
 * @param  {Function} CatalogGroupConstructor The constructor function for the catalog group (typically CatalogGroup).
 * @param  {String} name        The catalog group's name.
 * @param  {String} description The catalog group's description.
 * @return {CatalogGroup}       The new or updated catalog group.
 */
Catalog.prototype.upsertCatalogGroup = function(
  CatalogGroupConstructor,
  name,
  description
) {
  var group;
  var groups = this.group.items;
  for (var i = 0; i < groups.length; ++i) {
    group = groups[i];
    if (group.name === name) {
      return group;
    }
  }
  group = new CatalogGroupConstructor(this.terria);
  group.name = name;
  group.description = description;
  group.isUserSupplied = true;
  this.group.add(group);
  return group;
};

Catalog.prototype.addChartableItem = function(item) {
  if (this._chartableItems.indexOf(item) === -1) {
    this._chartableItems.push(item);
  }
};

Catalog.prototype.removeChartableItem = function(item) {
  const index = this._chartableItems.indexOf(item);
  if (index >= 0) {
    this._chartableItems.splice(index, 1);
  }
};

module.exports = Catalog;