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

/* global define */
define(["jquery", "underscore", "backbone", "models/DataONEObject"], function (
  $,
  _,
  Backbone,
  DataONEObject
) {
  /**
   * @name taxonomicClassification
   * @type {Object}
   * @property {string} taxonRankName - The name of the taxonomic rank, for
   * example, Domain, Kingdom, etc.
   * @property {string} taxonRankValue - The value for the given taxonomic rank,
   * for example, Animalia, Chordata, etc.
   * @property {string[]} commonName - Common name(s) for the taxon, for example
   * ["Animal"]
   * @property {Object[]} taxonId - A taxon identifier from a controlled
   * vocabulary, for example, ITIS, NCBI, etc.
   * @property {string} taxonId.provider - The provider of the taxon identifier,
   * given as a URI, for example http://www.itis.gov
   * @property {string} taxonId.value - The identifier from the provider, for
   * example, 180092
   * @property {Object[]} taxonomicClassification - A nested taxonomic
   * classification, since taxonomy is represented as a hierarchy in EML.
   */

  /**
   * @class EMLTaxonCoverage
   * @classdesc The EMLTaxonCoverage model represents the taxonomic coverage of
   * a dataset. It includes a general description of the taxonomic coverage, as
   * well as a list of taxonomic classifications.
   * @classcategory Models/Metadata/EML
   * @extends Backbone.Model
   * @constructor
  */
  var EMLTaxonCoverage = Backbone.Model.extend(
    /** @lends EMLTaxonCoverage.prototype */{
      /**
       * Returns the default properties for this model. Defined here.
       * @type {Object}
       * @property {string} objectXML - The XML string for this model
       * @property {Element} objectDOM - The XML DOM for this model
       * @property {EML211} parentModel - The parent EML211 model
       * @property {taxonomicClassification[]} taxonomicClassification - An array
       * of taxonomic classifications, defining the taxonomic coverage of the
       * dataset
       * @property {string} generalTaxonomicCoverage - A general description of the
       * taxonomic coverage of the dataset
       */
      defaults: {
        objectXML: null,
        objectDOM: null,
        parentModel: null,
        taxonomicClassification: [],
        generalTaxonomicCoverage: null,
      },

      initialize: function (attributes) {
        if (attributes.objectDOM) this.set(this.parse(attributes.objectDOM));

        this.on("change:taxonomicClassification", this.trickleUpChange);
        this.on("change:taxonomicClassification", this.updateDOM);
      },

      /*
       * 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 {
          generaltaxonomiccoverage: "generalTaxonomicCoverage",
          taxonomicclassification: "taxonomicClassification",
          taxonrankname: "taxonRankName",
          taxonrankvalue: "taxonRankValue",
          taxonomiccoverage: "taxonomicCoverage",
          taxonomicsystem: "taxonomicSystem",
          classificationsystem: "classificationSystem",
          classificationsystemcitation: "classificationSystemCitation",
          classificationsystemmodifications: "classificationSystemModifications",
          identificationreference: "identificationReference",
          identifiername: "identifierName",
          taxonomicprocedures: "taxonomicProcedures",
          taxonomiccompleteness: "taxonomicCompleteness",
          taxonid: "taxonId",
          commonname: "commonName",
        };
      },

      parse: function (objectDOM) {
        if (!objectDOM) var xml = this.get("objectDOM");

        var model = this,
          taxonomicClassifications = $(objectDOM).children(
            "taxonomicclassification"
          ),
          modelJSON = {
            taxonomicClassification: _.map(
              taxonomicClassifications,
              function (tc) {
                return model.parseTaxonomicClassification(tc);
              }
            ),
            generalTaxonomicCoverage: $(objectDOM)
              .children("generaltaxonomiccoverage")
              .first()
              .text(),
          };

        return modelJSON;
      },

      parseTaxonomicClassification: function (classification) {
        var id = $(classification).attr("id");
        var rankName = $(classification).children("taxonrankname");
        var rankValue = $(classification).children("taxonrankvalue");
        var commonName = $(classification).children("commonname");
        var taxonId = $(classification).children("taxonId");
        var taxonomicClassification = $(classification).children(
          "taxonomicclassification"
        );

        var model = this,
          modelJSON = {
            id: id,
            taxonRankName: $(rankName).text().trim(),
            taxonRankValue: $(rankValue).text().trim(),
            commonName: _.map(commonName, function (cn) {
              return $(cn).text().trim();
            }),
            taxonId: _.map(taxonId, function (tid) {
              return {
                provider: $(tid).attr("provider").trim(),
                value: $(tid).text().trim(),
              };
            }),
            taxonomicClassification: _.map(
              taxonomicClassification,
              function (tc) {
                return model.parseTaxonomicClassification(tc);
              }
            ),
          };

        if (
          Array.isArray(modelJSON.taxonomicClassification) &&
          !modelJSON.taxonomicClassification.length
        )
          modelJSON.taxonomicClassification = {};

        return modelJSON;
      },

      serialize: function () {
        var objectDOM = this.updateDOM(),
          xmlString = objectDOM.outerHTML;

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

        return xmlString;
      },

      /*
       * Makes a copy of the original XML DOM and updates it with the new values
       * from the model.
       */
      updateDOM: function () {
        var objectDOM = this.get("objectDOM")
          ? this.get("objectDOM").cloneNode(true)
          : document.createElement("taxonomiccoverage");

        $(objectDOM).empty();

        // generalTaxonomicCoverage
        var generalCoverage = this.get("generalTaxonomicCoverage");
        if (_.isString(generalCoverage) && generalCoverage.length > 0) {
          $(objectDOM).append(
            $(document.createElement("generaltaxonomiccoverage")).text(
              this.get("generalTaxonomicCoverage")
            )
          );
        }

        // taxonomicClassification(s)
        var classifications = this.get("taxonomicClassification");

        if (
          typeof classifications === "undefined" ||
          classifications.length === 0
        ) {
          return objectDOM;
        }

        for (var i = 0; i < classifications.length; i++) {
          $(objectDOM).append(
            this.createTaxonomicClassificationDOM(classifications[i])
          );
        }

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

        return objectDOM;
      },

      /*
       * Create the DOM for a single EML taxonomicClassification.
       * This function is currently recursive!
       */
      createTaxonomicClassificationDOM: function (classification) {
        var id = classification.id,
          taxonRankName = classification.taxonRankName || "",
          taxonRankValue = classification.taxonRankValue || "",
          commonName = classification.commonName || "",
          taxonId = classification.taxonId,
          finishedEl;

        if (!taxonRankName || !taxonRankValue) return "";

        finishedEl = $(document.createElement("taxonomicclassification"));

        if (typeof id === "string" && id.length > 0) {
          $(finishedEl).attr("id", id);
        }

        if (taxonRankName && taxonRankName.length > 0) {
          $(finishedEl).append(
            $(document.createElement("taxonrankname")).text(taxonRankName)
          );
        }

        if (taxonRankValue && taxonRankValue.length > 0) {
          $(finishedEl).append(
            $(document.createElement("taxonrankvalue")).text(taxonRankValue)
          );
        }

        if (commonName && commonName.length > 0) {
          $(finishedEl).append(
            $(document.createElement("commonname")).text(commonName)
          );
        }

        if (taxonId) {
          if (!Array.isArray(taxonId)) taxonId = [taxonId];
          _.each(taxonId, function (el) {
            var taxonIdEl = $(document.createElement("taxonId")).text(el.value);

            if (el.provider) {
              $(taxonIdEl).attr("provider", el.provider);
            }

            $(finishedEl).append(taxonIdEl);
          });
        }

        if (classification.taxonomicClassification) {
          _.each(
            classification.taxonomicClassification,
            function (tc) {
              $(finishedEl).append(this.createTaxonomicClassificationDOM(tc));
            },
            this
          );
        }

        return finishedEl;
      },

      /* Validate this model */
      validate: function () {
        var errors = {};

        if (
          !this.get("generalTaxonomicCoverage") &&
          MetacatUI.appModel.get("emlEditorRequiredFields")
            .generalTaxonomicCoverage
        )
          errors.generalTaxonomicCoverage =
            "Provide a description of the taxonomic coverage.";

        //If there are no taxonomic classifications and it is either required in
        // the AppModel OR a general coverage was given, then require it
        if (
          !this.get("taxonomicClassification").length &&
          (MetacatUI.appModel.get("emlEditorRequiredFields").taxonCoverage ||
            this.get("generalTaxonomicCoverage"))
        ) {
          errors.taxonomicClassification =
            "Provide at least one complete taxonomic classification.";
        } else {
          //Every taxonomic classification should be valid
          if (
            !_.every(
              this.get("taxonomicClassification"),
              this.isClassificationValid,
              this
            )
          )
            errors.taxonomicClassification =
              "Every classification row should have a rank and value.";
        }

        // Check for and remove duplicate classifications
        this.removeDuplicateClassifications();

        if (Object.keys(errors).length) return errors;
      },

      isEmpty: function () {
        return (
          !this.get("generalTaxonomicCoverage") &&
          !this.get("taxonomicClassification").length
        );
      },

      isClassificationValid: function (taxonomicClassification) {
        if (!Object.keys(taxonomicClassification).length) return true;
        if (Array.isArray(taxonomicClassification)) {
          if (
            !taxonomicClassification[0].taxonRankName ||
            !taxonomicClassification[0].taxonRankValue
          ) {
            return false;
          }
        } else if (
          !taxonomicClassification.taxonRankName ||
          !taxonomicClassification.taxonRankValue
        ) {
          return false;
        }

        if (taxonomicClassification.taxonomicClassification)
          return this.isClassificationValid(
            taxonomicClassification.taxonomicClassification
          );
        else return true;
      },

      /**
       * Check if two classifications are equal. Two classifications are equal if
       * they have the same rankName, rankValue, commonName, and taxonId, as well
       * as the same nested classifications. This function is recursive.
       * @param {taxonomicClassification} c1
       * @param {taxonomicClassification} c2
       * @returns {boolean} - True if the two classifications are equal
       * @since 2.24.0
       */
      classificationsAreEqual: function (c1, c2) {
        if (!c1 && !c2) return true;
        if (!c1 && c2) return false;
        if (c1 && !c2) return false;

        // stringify the two classifications for
        const stringKeys = ["taxonRankName", "taxonRankValue", "commonName"];

        // Recursively stringify the nested classifications for comparison
        stringifyClassification = function (c) {
          const stringified = {};
          for (let key of stringKeys) {
            if (c[key]) stringified[key] = c[key];
          }
          if (c.taxonId) stringified.taxonId = c.taxonId;
          if (c.taxonomicClassification) {
            stringified.taxonomicClassification = stringifyClassification(
              c.taxonomicClassification
            );
          }
          const st = JSON.stringify(stringified);
          // convert all to uppercase for comparison
          return st.toUpperCase();
        };

        return stringifyClassification(c1) === stringifyClassification(c2);
      },

      /**
       * Returns true if the given classification is a duplicate of another
       * classification in this model. Duplicates are considered those that have
       * all values identical, including rankName, rankValue, commonName, and
       * taxonId. If there are any nested classifications, then they too must
       * be identical for the classification to be considered a duplicate, this
       * this function is recursive. Only checks one classification at a time.
       * @param {taxonomicClassification} classification
       * @param {number} indexToSkip - The index of the classification to skip
       * when checking for duplicates. This is useful when checking if a
       * classification is a duplicate of another classification in the same
       * model, but not itself.
       * @returns {boolean} - True if the given classification is a duplicate
       * @since 2.24.0
       */
      isDuplicate: function (classification, indexToSkip) {
        const classifications = this.get("taxonomicClassification");
        for (let i = 0; i < classifications.length; i++) {
          if (typeof indexToSkip === "number" && i === indexToSkip) continue;
          if (this.classificationsAreEqual(classifications[i], classification)) {
            return true;
          }
        }
        return false;
      },

      /**
       * Remove any duplicated classifications from this model. See
       * {@link isDuplicate} for more information on what is considered a
       * duplicate. If any classifications are removed, then a
       * "duplicateClassificationsRemoved" event is triggered, passing the
       * removed classifications as an argument.
       * @fires duplicateClassificationsRemoved
       * @since 2.24.0
       */
      removeDuplicateClassifications: function () {
        const classifications = this.get("taxonomicClassification");
        const removed = [];
        for (let i = 0; i < classifications.length; i++) {
          const classification = classifications[i];
          if (this.isDuplicate(classification, i)) {
            classifications.splice(i, 1);
            this.set("taxonomicClassification", classifications);
            removed.push(classification);
            i--;
          }
        }
        if (removed.length) {
          this.trigger("duplicateClassificationsRemoved", removed);
        }
      },

      /*
       * 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 EMLTaxonCoverage;
});