Source: src/js/models/metadata/eml211/EMLDistribution.js

/* global define */
define(["jquery", "underscore", "backbone", "models/DataONEObject"], function (
  $,
  _,
  Backbone,
  DataONEObject
) {
  /**
   * @class EMLDistribution
   * @classdesc Information on how the resource is distributed online and
   * offline
   * @classcategory Models/Metadata/EML211
   * @see
   * https://eml.ecoinformatics.org/schema/eml-resource_xsd.html#DistributionType
   * @extends Backbone.Model
   * @constructor
   */
  var EMLDistribution = Backbone.Model.extend(
    /** @lends EMLDistribution.prototype */{
      /**
       * Default values for an EML 211 Distribution model. This is essentially a
       * flattened version of the EML 2.1.1 DistributionType, including nodes and
       * node attributes. Not all nodes are supported by this model yet.
       * @type {Object}
       * @property {string} type - The name of the top-level XML element that this
       * model represents (distribution)
       * @property {string} objectXML - The XML string representation of the
       * distribution
       * @property {Element} objectDOM - The DOM representation of the
       * distribution
       * @property {string} mediumName - The name of the medium on which the
       * offline distribution is stored
       * @property {string} mediumVolume - The volume number of the medium on
       * which the offline distribution is stored
       * @property {string} mediumFormat - The format of the medium on which the
       * offline distribution is stored
       * @property {string} mediumNote - A note about the medium on which the
       * offline distribution is stored
       * @property {string} url - The URL of the online distribution
       * @property {string} urlFunction - The purpose of the URL. May be either
       * "information" or "download".
       * @property {string} onlineDescription - A description of the online
       * distribution
       * @property {EML211} parentModel - The parent model of this distribution
       * model
       */
      defaults: {
        type: "distribution",
        objectXML: null,
        objectDOM: null,
        mediumName: null,
        mediumVolume: null,
        mediumFormat: null,
        mediumNote: null,
        url: null,
        urlFunction: null,
        onlineDescription: null,
        parentModel: null,
      },

      /**
       * The direct children of the <distribution> node that can have values, and
       * that are supported by this model. "inline" is not supported yet. A
       * distribution may have ONE of these nodes.
       * @type {string[]}
       * @since 2.26.0
       */
      distLocations: ["offline", "online"],

      /**
       * lower-case EML node names that belong within the <offline> node. These
       * must be in the correct order.
       * @type {string[]}
       * @since 2.26.0
       */
      offlineNodes: ["mediumname", "mediumvolume", "mediumformat", "mediumnote"],

      /**
       * lower-case EML node names that belong within the <online> node. These
       * must be in the correct order.
       * @type {string[]}
       * @since 2.26.0
       */
      onlineNodes: ["url"],

      /**
       * the allowed values for the urlFunction attribute
       * @type {string[]}
       * @since 2.26.0
       */
      urlFunctionTypes: ["information", "download"],

      /**
       * Initializes this EMLDistribution object
       * @param {Object} options - A literal object with options to pass to the
       * model
       */
      initialize: function (attributes, options) {
        const nodeAttr = Object.values(this.nodeNameMap());
        this.listenTo(
          this,
          "change:" + nodeAttr.join(" change:"),
          this.trickleUpChange
        );
      },

      /*
       * Maps the lower-case EML node names (valid in HTML DOM) to the camel-cased
       * EML node names (valid in EML). Used during parse() and serialize()
       */
      nodeNameMap: function () {
        return {
          authsystem: "authSystem",
          connectiondefinition: "connectionDefinition",
          mediumdensity: "mediumDensity",
          mediumdensityunits: "mediumDensityUnits",
          mediumformat: "mediumFormat",
          mediumname: "mediumName",
          mediumnote: "mediumNote",
          mediumvolume: "mediumVolume",
          url: "url",
        };
      },

      /**
       * Parses the given XML node or object and sets the model's attributes
       * @param {Object} attributes - the attributes passed in when the model is
       * instantiated. Should include objectDOM or objectXML to be parsed.
       */
      parse: function (attributes) {
        if (!attributes) attributes = {};
        const objectDOM = attributes.objectDOM || attributes.objectXML;
        if (!objectDOM) return attributes;
        const $objectDOM = $(objectDOM);

        const nodeNameMap = this.nodeNameMap();
        this.distLocations.forEach((distLocation) => {
          const location = $objectDOM.find(distLocation);
          if (location.length) {
            this[`${distLocation}Nodes`].forEach((nodeName) => {
              const value = location.children(nodeName)?.text()?.trim();
              if (value.length) {
                attributes[nodeNameMap[nodeName]] = value;
              }
            });
          }
        });

        // Check for a urlFunction attribute if there is a url node
        const url = $objectDOM.find("url");
        if (url.length) {
          attributes.urlFunction = url.attr("function") || null;
        }

        return attributes;
      },

      /**
       * Returns the XML string representation of this model
       * @returns {string}
       */
      serialize: function () {
        const objectDOM = this.updateDOM();
        const xmlString = objectDOM.outerHTML;

        // Camel-case the XML
        xmlString = this.formatXML(xmlString);

        return xmlString;
      },

      /**
       * Check if the model has values for the given distribution location.
       * @param {string} location - one of the names of the direct children of the
       * <distribution> node, i.e. any of the values in this.distLocations.
       * @returns {boolean} - true if the model has values for the given location,
       * false otherwise.
       * @since 2.26.0
       */
      hasValuesForDistributionLocation(location) {
        const nodeNameMap = this.nodeNameMap();
        return this[`${location}Nodes`].some((nodeName) => {
          return this.get(nodeNameMap[nodeName]);
        });
      },

      /*
       * Makes a copy of the original XML DOM and updates it with the new values
       * from the model.
       */
      updateDOM: function () {
        const objectDOM =
          this.get("objectDOM") || document.createElement(this.get("type"));
        const $objectDOM = $(objectDOM);

        // Remove empty (zero-length or whitespace-only) nodes
        $objectDOM
          .find("*")
          .filter(function () {
            return !$.trim($(this).text());
          })
          .remove();

        const nodeNameMap = this.nodeNameMap();

        // Determine if this is an online, offline, or inline distribution
        const distLocation = this.distLocations.find((location) => {
          return this.hasValuesForDistributionLocation(location);
        });

        // Remove all other distribution locations
        this.distLocations.forEach((location) => {
          if (location !== distLocation) {
            $objectDOM.find(location).remove();
          }
        });

        // If there is no distribution location, return the DOM
        if (!distLocation) return objectDOM;

        // Add the distribution location if it doesn't exist
        if (!$objectDOM.find(distLocation).length) {
          $objectDOM.append(`<${distLocation}></${distLocation}>`);
        }

        // For each node in the distribution location, add the value from the
        // model. If the model value is empty, remove the node. Make sure that we
        // don't replace any existing nodes, since not all nodes are supported by
        // this model yet. We also need to ensure that the nodes are in the
        // correct order.
        this[`${distLocation}Nodes`].forEach((nodeName) => {
          const nodeValue = this.get(nodeNameMap[nodeName]);
          if (nodeValue) {
            const node = $objectDOM.find(`${distLocation} > ${nodeName}`);
            if (node.length) {
              node.text(nodeValue);
            } else {
              const newNode = $(`<${nodeName}>${nodeValue}</${nodeName}>`);
              const position = this.getEMLPosition(objectDOM, nodeName);
              if (position) {
                newNode.insertAfter(position);
              } else {
                $objectDOM.children(distLocation).append(newNode);
              }
            }
          } else {
            $objectDOM.find(`${distLocation} > ${nodeName}`).remove();
          }
        });

        // Add the urlFunction attribute if one is set in the model. Remove it if
        // it's not set.
        const url = $objectDOM.find("url")
        if (url) {
          const urlFunction = this.get("urlFunction");
          if (urlFunction) {
            url.attr("function", urlFunction);
          } else {
            url.removeAttr("function");
          }
        }


        return objectDOM;
      },

      /*
       * Returns the node in the object DOM that the given node type should be
       * inserted after. @param {string} nodeName - The name of the node to find
       * the position for @return {jQuery} - The jQuery object of the node that
       * the given node should be inserted after, or false if the node is not
       * supported by this model. @since 2.26.0
       */
      getEMLPosition: function (objectDOM, nodeName) {
        // If this is a top level node, return false since it should be inserted
        // within the <distribution> node, and there must only be one.
        if (this.distLocations.includes(nodeName)) return false;

        // Handle according to whether it's an online or offline node
        const nodeNameMap = this.nodeNameMap();
        this.distLocations.forEach((distLocation) => {
          const nodeOrder = this[`${distLocation}Nodes`];
          const siblingNodes = $(objectDOM).find(distLocation).children();
          let position = nodeOrder.indexOf(nodeName);
          if (position > -1) {
            // Go through each node in the node list and find the position where
            // this node will be inserted after
            for (var i = position - 1; i >= 0; i--) {
              const checkNode = siblingNodes.filter(nodeOrder[i]);
              if (checkNode.length) {
                return checkNode.last();
              }
            }
          }
        });

        // If we get here, the node is not supported by this model
        return false;
      },

      /*
       * Climbs up the model hierarchy until it finds the EML model
       *
       * @return {EML211 or false} - Returns the EML 211 Model or false if not
       * found
       */
      getParentEML: function () {
        var emlModel = this.get("parentModel"),
          tries = 0;

        while (emlModel.type !== "EML" && tries < 6) {
          emlModel = emlModel.get("parentModel");
          tries++;
        }

        if (emlModel && emlModel.type == "EML") return emlModel;
        else return false;
      },

      trickleUpChange: function () {
        MetacatUI.rootDataPackage?.packageModel?.set("changed", true);
      },

      formatXML: function (xmlString) {
        return DataONEObject.prototype.formatXML.call(this, xmlString);
      },
    });

  return EMLDistribution;
});