Source: src/js/collections/metadata/eml/EMLEntities.js

"use strict";

define([
  "backbone",
  "models/metadata/eml211/EMLEntity",
  "models/metadata/eml211/EMLDataTable",
  "models/metadata/eml211/EMLOtherEntity",
], (Backbone, EMLEntity, EMLDataTable, EMLOtherEntity) => {
  // The names of the nodes that are considered entities in EML
  const ENTITY_NODE_NAMES = [
    "otherEntity",
    "dataTable",
    "spatialRaster",
    "spatialVector",
    "storedProcedure",
    "view",
  ];

  /**
   * @class InvalidAttributeListError
   * @classdesc An error that is thrown when an invalid attribute list is
   * encountered.
   * @classcategory Errors
   */
  class InvalidAttributeListError extends Error {
    constructor(message) {
      super(message);
      this.name = "InvalidAttributeListError";
    }
  }

  /**
   * @class EMLEntities
   * @classdesc A collection of EMLEntities.
   * @classcategory Collections/Metadata/EML
   * @since 2.33.0
   * @augments Backbone.Collection
   */
  const EMLEntities = Backbone.Collection.extend(
    /** @lends EMLEntities.prototype */
    {
      /** @inheritdoc */
      // eslint-disable-next-line object-shorthand, func-names
      model: function (attrs, options = {}) {
        // the name of the eml node determines the model to use
        const objDOM = attrs.objectDOM;
        const type =
          attrs.type?.toLowerCase() ||
          objDOM?.localName?.toLowerCase() ||
          objDOM?.nodeName?.toLowerCase();
        // the parent model is needed for functionality in the model
        const modifiedAttrs = { ...attrs };
        if (!attrs?.parentModel && options?.parentModel) {
          modifiedAttrs.parentModel = options.parentModel;
        }
        switch (type) {
          case "otherentity":
            return new EMLOtherEntity(modifiedAttrs, options);
          case "datatable":
            return new EMLDataTable(modifiedAttrs, options);
          default:
            return new EMLEntity(
              {
                entityType: "application/octet-stream",
                type,
                ...modifiedAttrs,
              },
              options,
            );
        }
      },

      /** @inheritdoc */
      parse(response, _options) {
        const entities =
          response.datasetNode?.querySelectorAll(ENTITY_NODE_NAMES.join(",")) ||
          [];
        return Array.from(entities).map((entity) => ({
          objectDOM: entity,
          parentModel: response.parentModel,
        }));
      },

      /** @inheritdoc */
      validate() {
        const errors = [];

        // Validate each of the EMLEntities
        this.each((model) => {
          if (!model.isValid()) {
            errors.push(model.validationError);
          }
        });
        return errors;
      },

      /**
       * Update an EML dataset node with the entities in this collection. Will
       * add, remove, or update entities and re-order according to the order in
       * this collection.
       * @param {Element} datasetNode The dataset node to update
       * @param {EML211} eml The EML model that contains the dataset node
       */
      updateDatasetDOM(datasetNode, eml) {
        const existingEntities = Array.from(
          datasetNode.querySelectorAll(ENTITY_NODE_NAMES.join(",")),
        );
        const emlModel = this.getParentModel();

        this.each((entity, i) => {
          // Replace or append node
          const existingEntity = existingEntities[i];
          if (existingEntity) existingEntity.remove();

          const nodeName = entity.get("type").toLowerCase();
          const position = emlModel.getEMLPosition(eml, nodeName);
          const updatedEntityDOM = entity.updateDOM();
          if (position?.length) {
            // position is a jQuery object
            position.after(updatedEntityDOM);
          } else {
            datasetNode.appendChild(updatedEntityDOM);
          }
        });

        // Remove extra nodes if any
        const extraEntities = existingEntities.length - this.length;
        if (extraEntities > 0) {
          const startIndex = existingEntities.length - extraEntities;
          existingEntities.slice(startIndex).forEach((node) => node.remove());
        }
      },

      /**
       * Add a new entity to the collection using info from a DataONE object.
       * Sets listeners to remove the entity if the DataONE object fails to
       * save, and to add it back if it later saves successfully.
       * @param {DataONEObject} dataONEObject DataONE object model
       * @param {object} options Options for the entity
       * @param {EMLModel} options.parentModel The parent model of the entity
       * @returns {EMLEntity} The new entity that was added to the collection
       */
      addFromDataONEObject(dataONEObject, options = {}) {
        const entity = this.add({
          entityName: dataONEObject.get("fileName"),
          entityType:
            dataONEObject.get("formatId") ||
            dataONEObject.get("mediaType") ||
            "application/octet-stream",
          dataONEObject,
          parentModel: options.parentModel || this.getParentModel(),
          xmlID: dataONEObject.getXMLSafeID(),
          // Important: Adding as a generic entity creates invalid EML
          type: "otherEntity",
        });
        this.stopListening(dataONEObject);
        this.listenTo(dataONEObject, "errorSaving", () => {
          this.remove(entity);
          // Listen for a successful save so the entity can be added back
          this.listenToOnce(dataONEObject, "successSaving", () => {
            this.add(entity);
          });
        });
        return entity;
      },

      /**
       * Search the collection for an entity that matches the given DataONE
       * object. Matches are made based on the DataONE object's identifier,
       * checksum, file name, or format type. Optionally, a DataPackage
       * collection can be provided to assess whether the entity is the only one
       * in the package, and therefore must be the entity for the given DataONE
       * object.
       * @param {DataONEObject} dataONEObject The DataONE object to match
       * @param {DataPackage} [dataPackage] The DataPackage collection to check
       * @returns {EMLEntity|boolean} The matching EMLEntity model or false if
       * no match is found
       */
      getByDataONEObject(dataONEObject, dataPackage) {
        // If an EMLEntity model has been found for this object before, consider
        // it a match.
        let foundEntity =
          dataONEObject.get("metadataEntity") ||
          this.find((ent) => ent.get("dataONEObject") === dataONEObject);

        const objFormatName =
          dataONEObject.get("formatId")?.toLowerCase() ||
          dataONEObject.get("mediaType")?.toLowerCase();

        if (!foundEntity) {
          // Gather information about the DataONE object
          const objID = dataONEObject.get("id");
          const objXMLID = dataONEObject.getXMLSafeID();
          const objCheckSum = dataONEObject.get("checksum");
          const objCheckSumIsMD5 =
            dataONEObject.get("checksumAlgorithm")?.toUpperCase() === "MD5";
          const objFileName = dataONEObject.get("fileName")?.toLowerCase();

          foundEntity = this.find((ent) => {
            // Matches of the checksum or identifier are definite matches
            if (objXMLID && objXMLID === ent.get("xmlID")) return true;

            const entCheckSum = ent.get("physicalMD5Checksum");
            if (objCheckSumIsMD5 && objCheckSum && objCheckSum === entCheckSum)
              return true;

            if (objID && objID === ent.get("downloadID")) return true;

            // If this entity name matches the dataone object file name, AND no
            // other dataone object file name matches, then we can assume this
            // is the entity element for this file.
            if (objFileName) {
              const fileNameMatches = this.getByFileName(objFileName);
              if (fileNameMatches?.length === 1 && fileNameMatches[0] === ent) {
                return true;
              }
            }

            return false;
          });
        }

        // Check if one data object is of this type in the package
        if (!foundEntity) {
          const formatMatches = this.getByFormatName(objFormatName);
          if (formatMatches?.length === 1) [foundEntity] = formatMatches;
        }

        // If this EML is in a DataPackage with only one other DataONEObject,
        // and there is only one entity in the EML, then we can assume they are
        // the same entity
        if (
          !foundEntity &&
          this.length === 1 &&
          dataPackage?.length === 2 &&
          dataPackage.models.includes(dataONEObject)
        ) {
          foundEntity = this.at(0);
          // TODO: Should we ensure that the entity is in this collection?
        }

        // If this entity has been matched to a different DataONEObject already,
        // then don't match it again. i.e. We will not override existing
        // entity<->DataONEObject pairings
        const entityDataONEObj = foundEntity?.get("dataONEObject");
        if (entityDataONEObj && entityDataONEObj !== dataONEObject) {
          foundEntity = false;
        }

        if (foundEntity) {
          foundEntity.set("dataONEObject", dataONEObject);
          // TODO: why are we setting an xmlID here? Should we check if it's
          // already set?
          const xmlID =
            this.getParentModel()?.getUniqueEntityId(dataONEObject) ||
            dataONEObject.getXMLSafeID();
          // TODO: should we check if these attrs are already set before
          // replacing?
          foundEntity.set("xmlID", xmlID);
          dataONEObject.set("metadataEntity", foundEntity);
        }

        return foundEntity || false;
      },

      /**
       * Get all entities in the collection that have the given format name set
       * as the entity type.
       * @param {string} formatName The format name to search for
       * @returns {EMLEntity[]} The entities that have the given format name
       */
      getByFormatName(formatName) {
        if (!formatName) return null;
        return this.filter((entity) => {
          const entFormatName = entity.get("entityType")?.toLowerCase();
          return entFormatName === formatName.toLowerCase();
        });
      },

      /**
       * Get all entities in the collection that have the given file name set as
       * the entity name or physical object name.
       * @param {string} fileName The file name to search for
       * @returns {EMLEntity[]} The entities that have the given file name
       */
      getByFileName(fileName) {
        const standardFileName = fileName.toLowerCase();
        return this.filter((entity) => {
          // Get the entity's file name in a standard format
          const entFileName = (
            entity.get("physicalObjectName") || entity.get("entityName")
          )?.toLowerCase();
          if (!entFileName) return false;
          const entFileNameUnderscored = entFileName?.replace(/ /g, "_");
          // Check if the entity's file name matches the given file name
          return (
            entFileName === standardFileName ||
            entFileNameUnderscored === standardFileName
          );
        });
      },

      /**
       * Get the model that contains this collection. Searches through all of
       * the entities in the collection to find the one that has the parentModel
       * set.
       * @returns {EMLEntity} The model that contains this collection or null if
       * no parent model is found
       */
      getParentModel() {
        // Iterate through the collection until the parent model is found
        const attrWithParent = this.find((attr) => attr.get("parentModel"));
        return attrWithParent ? attrWithParent.get("parentModel") : null;
      },

      /**
       * Check that the collection has at least one entity that has data.
       * @returns {boolean} True if the collection has at least one entity that
       * is not empty, false otherwise
       */
      hasNonEmptyEntity() {
        return this.some((model) => !model.isEmpty());
      },

      /**
       * Get the names of all the entities in the collection.
       * @returns {string[]} The names of all the entities in the collection,
       * either the entityName or physicalObjectName for each entity
       */
      getAllFileNames() {
        return this.map(
          (entity) =>
            entity.get("entityName") || entity.get("physicalObjectName"),
        );
      },

      /**
       * Get all entities in the collection that have valid attributes. An
       * entity with no attributes is considered valid.
       * @returns {EMLEntity[]} The entities that have valid attributes
       */
      getEntitiesWithValidAttributes() {
        return this.filter((entity) => {
          const attrList = entity.get("attributeList");
          return attrList && attrList.isValid();
        });
      },

      /**
       * Duplicate the attribute list from a source entity to given target
       * entities in this collection. Any attributes in the target entities will
       * be removed and replaced with the source attributes. Remove events will
       * be triggered on the target entities. The attributes are copied over as
       * a deep copy, so changes to the new target attributes will not affect
       * the source attributes. If any xmlIDs are present in the copied
       * attributes, they will be removed.
       * @param {EMLEntity} source - The entity to copy attributes from. Must
       * contain at least one non-empty attribute.
       * @param {EMLEntity[]} targets - The entities to copy attributes to
       * @param {boolean} [errorIfInvalid] - If true (default), an error will be
       * thrown if any attributes from the source entity are invalid. If set to
       * false, only valid attributes will be copied over, and invalid
       * attributes will be ignored.
       */
      copyAttributeList(source, targets, errorIfInvalid = true) {
        if (!source || !targets) return;

        const sourceAttrs = source.get("attributeList");
        if (!sourceAttrs?.length || !sourceAttrs?.hasNonEmptyAttributes())
          return;

        // Invalid attributes can't be serialized and so can't be copied. Must
        // serialize to create deep copies of the attributes.
        if (
          errorIfInvalid &&
          source.get("attributeList").all((attr) => !attr.isValid())
        ) {
          const errors = source.get("attributeList").validate();
          throw new InvalidAttributeListError(
            errors.join ? errors.join("\n") : "Invalid attribute list",
          );
        }
        const attrsStr = source.get("attributeList").serialize();
        targets.forEach((entity) => {
          const attrList = entity.get("attributeList");
          // Use remove rather than reset to trigger events
          attrList.remove(attrList.models);
          attrList.add(attrsStr, { parse: true });
          // remove xmlID from the target attributes
          attrList.each((attr) => attr.unset("xmlID"));
          // Reference to entity model required for attr & sub-models
          attrList.each((attr) => attr.set("parentModel", entity));
        });
      },
    },
  );

  return EMLEntities;
});