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

define([
  "jquery",
  "underscore",
  "backbone",
  "uuid",
  "models/metadata/eml211/EMLMeasurementScale",
  "models/metadata/eml211/EMLAnnotation",
  "collections/metadata/eml/EMLMissingValueCodes",
  "models/DataONEObject",
], function (
  $,
  _,
  Backbone,
  uuid,
  EMLMeasurementScale,
  EMLAnnotation,
  EMLMissingValueCodes,
  DataONEObject,
) {
  /**
   * @class EMLAttribute
   * @classdesc EMLAttribute represents a data attribute within an entity, such as
   * a column variable in a data table, or a feature attribute in a shapefile.
   * @see https://eml.ecoinformatics.org/schema/eml-attribute_xsd.html
   * @classcategory Models/Metadata/EML211
   */
  var EMLAttribute = Backbone.Model.extend(
    /** @lends EMLAttribute.prototype */ {
      /* Attributes of an EML attribute object */
      defaults: function () {
        return {
          /* Attributes from EML */
          xmlID: null, // The XML id of the attribute
          attributeName: null,
          attributeLabel: [], // Zero or more human readable labels
          attributeDefinition: null,
          storageType: [], // Zero or more storage types
          typeSystem: [], // Zero or more system types for storage type
          measurementScale: null, // An EML{Non}NumericDomain or EMLDateTimeDomain object
          missingValueCodes: new EMLMissingValueCodes(), // An EMLMissingValueCodes collection
          accuracy: null, // An EMLAccuracy object
          coverage: null, // an EMLCoverage object
          methods: [], // Zero or more EMLMethods objects
          references: null, // A reference to another EMLAttribute by id (needs work)
          annotation: [], // Zero or more EMLAnnotation objects

          /* Attributes not from EML */
          type: "attribute", // The element type in the DOM
          parentModel: null, // The parent model this attribute belongs to
          objectXML: null, // The serialized XML of this EML attribute
          objectDOM: null, // The DOM of this EML attribute
          nodeOrder: [
            // The order of the top level XML element nodes
            "attributeName",
            "attributeLabel",
            "attributeDefinition",
            "storageType",
            "measurementScale",
            "missingValueCode",
            "accuracy",
            "coverage",
            "methods",
            "annotation",
          ],
        };
      },

      /*
       * The map of lower case to camel case node names
       * needed to deal with parsing issues with $.parseHTML().
       * Use this until we can figure out issues with $.parseXML().
       */
      nodeNameMap: {
        attributename: "attributeName",
        attributelabel: "attributeLabel",
        attributedefinition: "attributeDefinition",
        sourced: "source",
        storagetype: "storageType",
        typesystem: "typeSystem",
        measurementscale: "measurementScale",
        missingvaluecode: "missingValueCode",
        propertyuri: "propertyURI",
        valueuri: "valueURI",
      },

      /* Initialize an EMLAttribute object */
      initialize: function (attributes, options) {
        if (!attributes) {
          var attributes = {};
        }

        // If initialized with missingValueCode as an array, convert it to a collection
        if (
          attributes.missingValueCodes &&
          attributes.missingValueCodes instanceof Array
        ) {
          this.missingValueCodes = new EMLMissingValueCodes(
            attributes.missingValueCode,
          );
        }

        this.stopListening(this.get("missingValueCodes"));
        this.listenTo(
          this.get("missingValueCodes"),
          "update",
          this.trickleUpChange,
        );
        this.on(
          "change:attributeName " +
            "change:attributeLabel " +
            "change:attributeDefinition " +
            "change:storageType " +
            "change:measurementScale " +
            "change:missingValueCodes " +
            "change:accuracy " +
            "change:coverage " +
            "change:methods " +
            "change:references " +
            "change:annotation",
          this.trickleUpChange,
        );
      },

      /*
       * Parse the incoming attribute's XML elements
       */
      parse: function (attributes, options) {
        var $objectDOM;

        if (attributes.objectDOM) {
          $objectDOM = $(attributes.objectDOM);
        } else if (attributes.objectXML) {
          $objectDOM = $(attributes.objectXML);
        } else {
          return {};
        }

        // Add the XML id
        if (typeof $objectDOM.attr("id") !== "undefined") {
          attributes.xmlID = $objectDOM.attr("id");
        }

        // Add the attributeName
        attributes.attributeName = $objectDOM.children("attributename").text();

        // Add the attributeLabel
        attributes.attributeLabel = [];
        var attributeLabels = $objectDOM.children("attributelabel");
        _.each(attributeLabels, function (attributeLabel) {
          attributes.attributeLabel.push(attributeLabel.textContent);
        });

        // Add the attributeDefinition
        attributes.attributeDefinition = $objectDOM
          .children("attributedefinition")
          .text();

        // Add the storageType
        attributes.storageType = [];
        attributes.typeSystem = [];
        var storageTypes = $objectDOM.children("storagetype");
        _.each(storageTypes, function (storageType) {
          attributes.storageType.push(storageType.textContent);
          var type = $(storageType).attr("typesystem");
          attributes.typeSystem.push(type || null);
        });

        var measurementScale = $objectDOM.find("measurementscale")[0];
        if (measurementScale) {
          attributes.measurementScale = EMLMeasurementScale.getInstance(
            measurementScale.outerHTML,
          );
          attributes.measurementScale.set("parentModel", this);
        }

        // Add annotations
        var annotations = $objectDOM.children("annotation");
        attributes.annotation = [];

        _.each(
          annotations,
          function (anno) {
            annotation = new EMLAnnotation(
              {
                objectDOM: anno,
                objectXML: anno.outerHTML,
              },
              { parse: true },
            );

            attributes.annotation.push(annotation);
          },
          this,
        );

        // Add the missingValueCodes as a collection
        attributes.missingValueCodes = new EMLMissingValueCodes();
        attributes.missingValueCodes.parse(
          $objectDOM.children("missingvaluecode"),
        );

        attributes.objectDOM = $objectDOM[0];

        return attributes;
      },

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

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

        return xmlString;
      },

      /* Copy the original XML and update fields in a DOM object */
      updateDOM: function (objectDOM) {
        var nodeToInsertAfter;
        var type = this.get("type") || "attribute";
        if (!objectDOM) {
          objectDOM = this.get("objectDOM");
        }
        var objectXML = this.get("objectXML");

        // If present, use the cached DOM
        if (objectDOM) {
          objectDOM = objectDOM.cloneNode(true);

          // otherwise, use the cached XML
        } else if (objectXML) {
          objectDOM = $(objectXML)[0].cloneNode(true);

          // This is new, create it
        } else {
          objectDOM = document.createElement(type);
        }

        // update the id attribute
        var xmlID = this.get("xmlID");
        if (xmlID) {
          $(objectDOM).attr("id", xmlID);
        }

        // Update the attributeName
        if (
          typeof this.get("attributeName") == "string" &&
          this.get("attributeName").trim().length
        ) {
          if ($(objectDOM).find("attributename").length) {
            $(objectDOM).find("attributename").text(this.get("attributeName"));
          } else {
            nodeToInsertAfter = this.getEMLPosition(objectDOM, "attributeName");

            if (!nodeToInsertAfter) {
              $(objectDOM).append(
                $(document.createElement("attributename")).text(
                  this.get("attributeName"),
                )[0],
              );
            } else {
              $(nodeToInsertAfter).after(
                $(document.createElement("attributename")).text(
                  this.get("attributeName"),
                )[0],
              );
            }
          }
        }
        //If there is no attribute name, return an empty string because it
        // is invalid
        else {
          return "";
        }

        // Update the attributeLabels
        nodeToInsertAfter = undefined;
        var attributeLabels = this.get("attributeLabel");
        if (attributeLabels) {
          if (attributeLabels.length) {
            // Copy and reverse the array for inserting
            attributeLabels = Array.from(attributeLabels).reverse();
            // Remove all current attributeLabels
            $(objectDOM).find("attributelabel").remove();
            nodeToInsertAfter = this.getEMLPosition(
              objectDOM,
              "attributeLabel",
            );

            if (!nodeToInsertAfter) {
              // Add the new list back in
              _.each(attributeLabels, function (attributeLabel) {
                //If there is an empty string or falsey value in the label, don't add it to the XML
                // We check purposefuly for falsey types (instead of just doing !attributeLabel) because
                // it's ok to serialize labels that are the number 0.
                if (
                  (typeof attributeLabel == "string" &&
                    !attributeLabel.trim().length) ||
                  attributeLabel === false ||
                  attributeLabel === null ||
                  typeof attributeLabel == "undefined"
                ) {
                  return;
                }

                $(objectDOM).append(
                  $(document.createElement("attributelabel")).text(
                    attributeLabel,
                  )[0],
                );
              });
            } else {
              // Add the new list back in after its previous sibling
              _.each(attributeLabels, function (attributeLabel) {
                //If there is an empty string or falsey value in the label, don't add it to the XML
                // We check purposefuly for falsey types (instead of just doing !attributeLabel) because
                // it's ok to serialize labels that are the number 0.
                if (
                  (typeof attributeLabel == "string" &&
                    !attributeLabel.trim().length) ||
                  attributeLabel === false ||
                  attributeLabel === null ||
                  typeof attributeLabel == "undefined"
                ) {
                  return;
                }

                $(nodeToInsertAfter).after(
                  $(document.createElement("attributelabel")).text(
                    attributeLabel,
                  )[0],
                );
              });
            }
          }
          //If the label array is empty, remove all the labels from the DOM
          else {
            $(objectDOM).find("attributelabel").remove();
          }
        }
        //If there is no attribute label, remove them from the DOM
        else {
          $(objectDOM).find("attributelabel").remove();
        }

        // Update the attributeDefinition
        nodeToInsertAfter = undefined;
        if (this.get("attributeDefinition")) {
          if ($(objectDOM).find("attributedefinition").length) {
            $(objectDOM)
              .find("attributedefinition")
              .text(this.get("attributeDefinition"));
          } else {
            nodeToInsertAfter = this.getEMLPosition(
              objectDOM,
              "attributeDefinition",
            );

            if (!nodeToInsertAfter) {
              $(objectDOM).append(
                $(document.createElement("attributedefinition")).text(
                  this.get("attributeDefinition"),
                )[0],
              );
            } else {
              $(nodeToInsertAfter).after(
                $(document.createElement("attributedefinition")).text(
                  this.get("attributeDefinition"),
                )[0],
              );
            }
          }
        }
        // If there is no attribute definition, then return an empty String
        // because it is invalid
        else {
          return "";
        }

        // Update the storageTypes
        nodeToInsertAfter = undefined;
        var storageTypes = this.get("storageTypes");
        if (storageTypes) {
          if (storageTypes.length) {
            // Copy and reverse the array for inserting
            storageTypes = Array.from(storageTypes).reverse();
            // Remove all current attributeLabels
            $(objectDOM).find("storagetype").remove();
            nodeToInsertAfter = this.getEMLPosition(objectDOM, "storageType");

            if (!nodeToInsertAfter) {
              // Add the new list back in
              _.each(storageTypes, function (storageType) {
                if (!storageType) return;

                $(objectDOM).append(
                  $(document.createElement("storagetype")).text(storageType)[0],
                );
              });
            } else {
              // Add the new list back in after its previous sibling
              _.each(storageTypes, function (storageType) {
                if (!storageType) return;

                $(nodeToInsertAfter).after(
                  $(document.createElement("storagetype")).text(storageType)[0],
                );
              });
            }
          }
        }
        /*If there are no storage types, remove them all from the DOM.
                TODO: Uncomment this out when storage type is supported in editor
                else{
                  $(objectDOM).find("storagetype").remove();
                }
                */

        // Update the measurementScale
        nodeToInsertAfter = undefined;
        var measurementScale = this.get("measurementScale");
        var measurementScaleNodes;
        var measurementScaleNode;
        var domainNode;
        if (typeof measurementScale !== "undefined" && measurementScale) {
          // Find the measurementScale child or create a new one
          measurementScaleNodes = $(objectDOM).children("measurementscale");
          if (measurementScaleNodes.length) {
            measurementScaleNode = measurementScaleNodes[0];
          } else {
            measurementScaleNode = document.createElement("measurementscale");
            nodeToInsertAfter = this.getEMLPosition(
              objectDOM,
              "measurementScale",
            );

            if (typeof nodeToInsertAfter === "undefined") {
              $(objectDOM).append(measurementScaleNode);
            } else {
              $(nodeToInsertAfter).after(measurementScaleNode);
            }
          }

          // Append the measurementScale domain content
          domainNode = measurementScale.updateDOM();
          if (typeof domainNode !== "undefined") {
            $(measurementScaleNode).children().remove();
            $(measurementScaleNode).append(domainNode);
          }
        } else {
          console.log("No measurementScale object has been defined.");
        }

        // Update annotations
        var annotation = this.get("annotation");

        // Always remove all annotations to start with
        $(objectDOM).children("annotation").remove();

        _.each(
          annotation,
          function (anno) {
            if (anno.isEmpty()) {
              return;
            }

            var after = this.getEMLPosition(objectDOM, "annotation");
            $(after).after(anno.updateDOM());
          },
          this,
        );

        // Update the missingValueCodes
        nodeToInsertAfter = undefined;
        var missingValueCodes = this.get("missingValueCodes");
        $(objectDOM).children("missingvaluecode").remove();
        if (missingValueCodes) {
          var missingValueCodeNodes = missingValueCodes.updateDOM();
          if (missingValueCodeNodes) {
            nodeToInsertAfter = this.getEMLPosition(
              objectDOM,
              "missingValueCode",
            );
            if (typeof nodeToInsertAfter === "undefined") {
              $(objectDOM).append(missingValueCodeNodes);
            } else {
              $(nodeToInsertAfter).after(missingValueCodeNodes);
            }
          }
        }

        return objectDOM;
      },

      /*
       * Get the DOM node preceding the given nodeName
       * to find what position in the EML document
       * the named node should be appended
       */
      getEMLPosition: function (objectDOM, nodeName) {
        var nodeOrder = this.get("nodeOrder");

        var position = _.indexOf(nodeOrder, nodeName);

        // Append to the bottom if not found
        if (position == -1) {
          return $(objectDOM).children().last()[0];
        }

        // Otherwise, 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--) {
          if ($(objectDOM).find(nodeOrder[i].toLowerCase()).length) {
            return $(objectDOM).find(nodeOrder[i].toLowerCase()).last()[0];
          }
        }
      },

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

      validate: function () {
        var errors = {};

        //If there is no attribute name, add that error message
        if (!this.get("attributeName"))
          errors.attributeName = "Provide a name for this attribute.";

        //If there is no attribute definition, add that error message
        if (!this.get("attributeDefinition"))
          errors.attributeDefinition =
            "Provide a definition for this attribute.";

        //Get the EML measurement scale model
        var measurementScaleModel = this.get("measurementScale");

        // If there is no measurement scale model, then add that error message
        if (!measurementScaleModel) {
          errors.measurementScale =
            "Choose a measurement scale category for this attribute.";
        } else {
          if (!measurementScaleModel.isValid()) {
            errors.measurementScale = "More information is needed.";
          }
        }

        // Validate the missing value codes
        var missingValueCodesErrors = this.get("missingValueCodes")?.validate();
        if (missingValueCodesErrors) {
          // Just display the first error message
          errors.missingValueCodes = Object.values(missingValueCodesErrors)[0];
        }

        // If there is a measurement scale model and it is valid and there are no other
        // errors, then trigger this model as valid and exit.
        if (!Object.keys(errors).length) {
          this.trigger("valid", this);
          return;
        } else {
          //If there is at least one error, then return the errors object
          return errors;
        }
      },

      /*
       * Validates each of the EMLAnnotation models on this model
       *
       * @return {Array} - Returns an array of error messages for all the EMLAnnotation models
       */
      validateAnnotations: function () {
        var errors = [];

        //Validate each of the EMLAttributes
        _.each(this.get("annotation"), function (anno) {
          if (anno.isValid()) {
            return;
          }

          errors.push(anno.validationError);
        });

        return errors;
      },

      /*
       * Climbs up the model heirarchy 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;
      },

      /* Let the top level package know of attribute changes from this object */
      trickleUpChange: function () {
        MetacatUI.rootDataPackage.packageModel.set("changed", true);
      },

      createID: function () {
        this.set("xmlID", uuid.v4());
      },
    },
  );

  return EMLAttribute;
});