Models/WebMapServiceCatalogGroup.js

"use strict";

/*global require*/
var URI = require("urijs");

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

var knockout = require("terriajs-cesium/Source/ThirdParty/knockout").default;
var loadXML = require("../Core/loadXML");

var TerriaError = require("../Core/TerriaError");
var CatalogGroup = require("./CatalogGroup");
var inherit = require("../Core/inherit");
var proxyCatalogItemUrl = require("./proxyCatalogItemUrl");
var WebMapServiceCatalogItem = require("./WebMapServiceCatalogItem");
var xml2json = require("../ThirdParty/xml2json");
var i18next = require("i18next").default;

/**
 * A {@link CatalogGroup} representing a collection of layers from a Web Map Service (WMS) server.
 *
 * @alias WebMapServiceCatalogGroup
 * @constructor
 * @extends CatalogGroup
 *
 * @param {Terria} terria The Terria instance.
 */
var WebMapServiceCatalogGroup = function(terria) {
  CatalogGroup.call(this, terria, "wms-getCapabilities");

  /**
   * Gets or sets the URL of the WMS server.  This property is observable.
   * @type {String}
   */
  this.url = "";

  /**
   * Gets or sets a description of the custodian of the data sources in this group.
   * This property is an HTML string that must be sanitized before display to the user.
   * This property is observable.
   * @type {String}
   * @editorformat textarea
   */
  this.dataCustodian = undefined;

  /**
   * Gets or sets the additional parameters to pass to the WMS server when requesting images.
   * All parameter names must be entered in lowercase in order to be consistent with references in TerrisJS code.
   * If this property is undefiend, {@link WebMapServiceCatalogItem.defaultParameters} is used.
   * @type {Object}
   */
  this.parameters = undefined;

  /**
   * Gets or sets a hash of names of blacklisted data layers.  A layer that appears in this hash
   * will not be shown to the user.  In this hash, the keys should be the Title of the layers to blacklist,
   * and the values should be "true".  This property is observable.
   * @type {Object}
   */
  this.blacklist = undefined;

  /**
   * Gets or sets the field name to use as the primary title in the catalog view: each WMS layer's
   * "title" (default), "name", or "abstract".
   * @type {String}
   */
  this.titleField = "title";

  /**
   * Gets or sets a hash of properties that will be set on each child item.
   * For example, { 'treat404AsError': false }
   * @type {Object}
   */
  this.itemProperties = undefined;

  /**
   * Gets or sets a value indicating whether the list of layers queried from GetCapabilities should be
   * flattened into a list with no hierarchy.
   * @type {Boolean}
   * @default false
   */
  this.flatten = false;

  knockout.track(this, [
    "url",
    "dataCustodian",
    "parameters",
    "blacklist",
    "titleField",
    "itemProperties",
    "flatten"
  ]);
};

inherit(CatalogGroup, WebMapServiceCatalogGroup);

Object.defineProperties(WebMapServiceCatalogGroup.prototype, {
  /**
   * Gets the type of data member represented by this instance.
   * @memberOf WebMapServiceCatalogGroup.prototype
   * @type {String}
   */
  type: {
    get: function() {
      return "wms-getCapabilities";
    }
  },

  /**
   * Gets a human-readable name for this type of data source, such as 'Web Map Service (WMS)'.
   * @memberOf WebMapServiceCatalogGroup.prototype
   * @type {String}
   */
  typeName: {
    get: function() {
      return i18next.t("models.webMapServiceCatalogGroup.wmsServer");
    }
  },

  /**
   * Gets the set of functions used to serialize individual properties in {@link CatalogMember#serializeToJson}.
   * When a property name on the model matches the name of a property in the serializers object literal,
   * the value will be called as a function and passed a reference to the model, a reference to the destination
   * JSON object literal, and the name of the property.
   * @memberOf WebMapServiceCatalogGroup.prototype
   * @type {Object}
   */
  serializers: {
    get: function() {
      return WebMapServiceCatalogGroup.defaultSerializers;
    }
  }
});

/**
 * Gets or sets the set of default serializer functions to use in {@link CatalogMember#serializeToJson}.  Types derived from this type
 * should expose this instance - cloned and modified if necesary - through their {@link CatalogMember#serializers} property.
 * @type {Object}
 */
WebMapServiceCatalogGroup.defaultSerializers = clone(
  CatalogGroup.defaultSerializers
);

WebMapServiceCatalogGroup.defaultSerializers.items =
  CatalogGroup.enabledShareableItemsSerializer;

WebMapServiceCatalogGroup.defaultSerializers.isLoading = function(
  wmsGroup,
  json,
  propertyName,
  options
) {};

Object.freeze(WebMapServiceCatalogGroup.defaultSerializers);

WebMapServiceCatalogGroup.prototype._getValuesThatInfluenceLoad = function() {
  return [this.url, this.blacklist, this.titleField];
};

WebMapServiceCatalogGroup.prototype._load = function() {
  var url =
    cleanAndProxyUrl(this, this.url) +
    "?service=WMS&request=GetCapabilities&version=1.3.0&tiled=true";

  var that = this;
  return loadXML(url)
    .then(function(xml) {
      // Is this really a GetCapabilities response?
      if (
        !xml ||
        !xml.documentElement ||
        (xml.documentElement.localName !== "WMS_Capabilities" &&
          xml.documentElement.localName !== "WMT_MS_Capabilities")
      ) {
        throw new TerriaError({
          title: i18next.t(
            "models.webMapServiceCatalogGroup.invalidWMSServerTitle"
          ),
          message: i18next.t(
            "models.webMapServiceCatalogGroup.invalidWMSServerMessage",
            {
              email:
                '<a href="mailto:' +
                that.terria.supportEmail +
                '">' +
                that.terria.supportEmail +
                "</a>."
            }
          )
        });
      }

      var json = xml2json(xml);

      // Skip the root layer, if there's only one.
      // But use it for the name of this catalog item if the item is currently named by the URL.
      var parentLayer;
      var rootLayers = json.Capability.Layer;
      if (rootLayers) {
        if (!(rootLayers instanceof Array)) {
          rootLayers = [rootLayers];
        }
        if (rootLayers.length === 1 && rootLayers[0].Layer) {
          var singleRoot = rootLayers[0];
          if (that.name === that.url) {
            that.name = getNameFromLayer(that, singleRoot);
          }
          parentLayer = singleRoot;
          rootLayers = singleRoot.Layer;
        }

        var infoDerivedFromCapabilities = {
          availableStyles: WebMapServiceCatalogItem.getAllAvailableStylesFromCapabilities(
            json
          ),
          availableDimensions: WebMapServiceCatalogItem.getAllAvailableDimensionsFromCapabilities(
            json
          )
        };

        addLayersRecursively(
          that,
          that,
          json,
          rootLayers,
          parentLayer,
          infoDerivedFromCapabilities
        );
      }
    })
    .otherwise(function(e) {
      throw new TerriaError({
        sender: that,
        title: i18next.t(
          "models.webMapServiceCatalogGroup.groupNotAvailableTitle"
        ),
        message: i18next.t(
          "models.webMapServiceCatalogGroup.groupNotAvailableMessage",
          {
            email:
              '<a href="mailto:' +
              that.terria.supportEmail +
              '">' +
              that.terria.supportEmail +
              "</a>.",
            appName: that.terria.appName
          }
        )
      });
    });
};

function cleanAndProxyUrl(catalogGroup, url) {
  // Strip off the search portion of the URL
  var uri = new URI(url);
  uri.search("");

  var cleanedUrl = uri.toString();
  return proxyCatalogItemUrl(catalogGroup, cleanedUrl, "1d");
}

function getNameFromLayer(wmsGroup, layer) {
  if (wmsGroup.titleField === "name") {
    return layer.Name;
  } else if (wmsGroup.titleField === "abstract") {
    return layer.Abstract;
  } else {
    return layer.Title;
  }
}

function addLayersRecursively(
  wmsGroup,
  parentGroup,
  capabilities,
  layers,
  parent,
  infoDerivedFromCapabilities
) {
  if (!(layers instanceof Array)) {
    layers = [layers];
  }

  for (var i = 0; i < layers.length; ++i) {
    var layer = layers[i];

    // Record this layer's parent, so we can walk up the layer hierarchy looking for inherited properties.
    layer._parent = parent;

    if (wmsGroup.blacklist && wmsGroup.blacklist[layer.Title]) {
      console.log(
        "Provider Feedback: Filtering out " +
          layer.Title +
          " (" +
          layer.Name +
          ") because it is blacklisted."
      );
      continue;
    }

    if (defined(layer.Layer)) {
      var group = parentGroup;
      if (!wmsGroup.flatten) {
        // Create a group for this layer
        group = createWmsSubGroup(wmsGroup, layer);
      }

      // WMS 1.1.1 spec section 7.1.4.5.2 says any layer with a Name property can be used
      // in the 'layers' parameter of a GetMap request.  This is true in 1.0.0 and 1.3.0 as well.
      var allName = "(All)";
      var originalNameForAll;
      if (defined(layer.Name) && layer.Name.length > 0) {
        var all = createWmsDataSource(
          wmsGroup,
          capabilities,
          layer,
          infoDerivedFromCapabilities
        );

        if (!wmsGroup.flatten) {
          originalNameForAll = all.name;
          all.name = allName + " " + all.name;
        }

        group.add(all);
      }

      addLayersRecursively(
        wmsGroup,
        group,
        capabilities,
        layer.Layer,
        layer,
        infoDerivedFromCapabilities
      );

      if (!wmsGroup.flatten) {
        if (
          group.items.length === 1 &&
          group.items[0].name.indexOf(allName) === 0
        ) {
          group.items[0].name = originalNameForAll;
          parentGroup.add(group.items[0]);
        } else if (group.items.length > 0) {
          parentGroup.add(group);
        }
      }
    } else {
      parentGroup.add(
        createWmsDataSource(
          wmsGroup,
          capabilities,
          layer,
          infoDerivedFromCapabilities
        )
      );
    }
  }
}

function createWmsDataSource(
  wmsGroup,
  capabilities,
  layer,
  infoDerivedFromCapabilities
) {
  var result = new WebMapServiceCatalogItem(wmsGroup.terria);

  result.name = getNameFromLayer(wmsGroup, layer);
  result.layers = layer.Name;
  result.url = wmsGroup.url;

  result.updateFromCapabilities(
    capabilities,
    false,
    layer,
    infoDerivedFromCapabilities
  );

  if (typeof wmsGroup.itemProperties === "object") {
    result.updateFromJson(wmsGroup.itemProperties);
  }

  return result;
}

function createWmsSubGroup(wmsGroup, layer) {
  var result = new CatalogGroup(wmsGroup.terria);

  if (wmsGroup.titleField === "name") {
    result.name = layer.Name;
  } else if (wmsGroup.titleField === "abstract") {
    result.name = layer.Abstract;
  } else {
    result.name = layer.Title;
  }

  return result;
}

module.exports = WebMapServiceCatalogGroup;