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

/* eslint-disable max-classes-per-file */

"use strict";

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

  /**
   * Snapshot the fields needed to match an EML entity. The source may be a
   * legacy Backbone DataONEObject or a property-based package member.
   * @param {DataONEObject|object} obj The data object
   * @returns {object} Stable values used by the EML models
   */
  const getObjectDescriptor = (obj) => {
    if (!obj) return {};
    const isBackboneModel = typeof obj.get === "function";
    const id = isBackboneModel
      ? obj.get("id")
      : obj.pid || obj.id || obj.identifier;
    const fileName = isBackboneModel
      ? obj.get("fileName")
      : obj.getFileName?.() || obj.fileName || obj.filename;
    const formatId = isBackboneModel
      ? obj.get("formatId") || obj.get("mediaType")
      : obj.getFormatId?.() ||
        obj.formatId ||
        obj.mediaType ||
        obj.sysMeta?.formatId;

    return {
      id,
      fileName,
      formatId,
      xmlID: XMLUtilities.getXMLSafeID(id),
      checksum: isBackboneModel
        ? obj.get("checksum")
        : obj.checksum || obj.sysMeta?.checksum?.value || obj.sysMeta?.checksum,
      checksumAlgorithm: isBackboneModel
        ? obj.get("checksumAlgorithm")
        : obj.checksumAlgorithm ||
          obj.sysMeta?.checksum?.algorithm ||
          obj.sysMeta?.checksumAlgorithm,
      metadataEntity: isBackboneModel ? obj.get("metadataEntity") : null,
      previousFileName: isBackboneModel
        ? obj.previous("fileName")
        : obj.remoteSysMeta?.fileName,
      previousFormatId: isBackboneModel
        ? obj.previous("formatId") || obj.previous("mediaType")
        : obj.remoteSysMeta?.formatId,
    };
  };

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

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

  /**
   * @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: DEFAULT_FORMAT_ID,
                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 using a data object or package member descriptor.
       * Sets listeners to remove the entity if a legacy DataONE object fails to
       * save, and to add it back if it later saves successfully.
       * @param {DataONEObject|object} dataObject Data object or member descriptor
       * @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(dataObject, options = {}) {
        const descriptor = getObjectDescriptor(dataObject);
        const entity = this.add({
          entityName: descriptor.fileName,
          entityType: descriptor.formatId || DEFAULT_FORMAT_ID,
          downloadID: descriptor.id || null,
          fileName: descriptor.fileName || null,
          formatId: descriptor.formatId || null,
          parentModel: options.parentModel || this.getParentModel(),
          xmlID: options.xmlID || descriptor.xmlID || null,
          // Important: Adding as a generic entity creates invalid EML
          type: "otherEntity",
        });
        if (
          typeof dataObject?.on === "function" &&
          typeof dataObject?.off === "function"
        ) {
          this.stopListening(dataObject);
          this.listenTo(dataObject, "errorSaving", () => {
            this.remove(entity);
            // Listen for a successful save so the entity can be added back
            this.listenToOnce(dataObject, "successSaving", () => {
              this.add(entity);
            });
          });
        }
        return entity;
      },

      /**
       * Search the collection for an entity that matches a data object or
       * package member. Matches are made based on its 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 object.
       * @param {DataONEObject|object} dataObject Data object or member descriptor
       * @param {DataPackage} [dataPackage] The DataPackage collection to check
       * @returns {EMLEntity|boolean} The matching EMLEntity model or false if
       * no match is found
       */
      getByDataONEObject(dataObject, dataPackage) {
        const descriptor = getObjectDescriptor(dataObject);

        // If an EMLEntity model has been found for this object before, consider
        // it a match.
        const objID = descriptor.id;
        const objXMLID = descriptor.xmlID;
        const objFileName = descriptor.fileName;
        let foundEntity =
          descriptor.metadataEntity ||
          (objID ? this.find((ent) => ent.getDataPid() === objID) : null);

        const objFormatName = descriptor.formatId?.toLowerCase();

        if (!foundEntity) {
          // Gather information about the DataONE object
          const objCheckSum = descriptor.checksum;
          const objCheckSumIsMD5 =
            descriptor.checksumAlgorithm?.toUpperCase() === "MD5";
          const standardFileName = objFileName?.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 (standardFileName) {
              const fileNameMatches = this.getByFileName(standardFileName);
              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(dataObject)
        ) {
          foundEntity = this.at(0);
          // TODO: Should we ensure that the entity is in this collection?
        }

        // Do not replace an existing entity-to-member PID match.
        const entityDataPid = foundEntity?.getDataPid();
        if (entityDataPid && objID && entityDataPid !== objID) {
          foundEntity = false;
        }

        if (foundEntity) {
          if (!foundEntity.get("xmlID")) {
            descriptor.xmlID =
              this.getParentModel()?.getUniqueEntityId(descriptor) ||
              descriptor.xmlID;
          }
          foundEntity.setMemberDescriptor(descriptor);
          if (typeof dataObject.set === "function") {
            dataObject.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.
       * @param {boolean} [byRef] - If true, the attributes will be copied as a
       * reference to the source attributes. Meaning that changes to the source
       * attributes will affect the target attributes. If false (default), the
       * attributes will have the same values as the source attributes, but will
       * be a deep copy. This means that changes to the source attributes will
       * not affect the target attributes.
       */
      copyAttributeList(source, targets, errorIfInvalid = true, byRef = false) {
        if (!source || !targets) return;

        const sourceAttrs = source.get("attributeList");
        if (!sourceAttrs?.hasNonEmptyAttributes()) {
          throw new EmptyAttributeListError(
            "Source entity has no attributes or all attributes are empty",
          );
        }

        // Invalid attributes can't be serialized and so can't be copied. Must
        // serialize to create deep copies of the attributes.
        if (errorIfInvalid && sourceAttrs.validate()) {
          const errors = source.get("attributeList").validate();
          throw new InvalidAttributeListError(
            errors.join ? errors.join("\n") : "Invalid attribute list",
          );
        }

        targets.forEach((target) => {
          if (!target) return;
          if (byRef) {
            this.copyAttributeListByRef(source, target);
          } else {
            this.deepCopyAttributeList(source, target);
          }
        });
      },

      /**
       * Copy the attribute list from a source entity to a target entity by
       * reference. This means that changes to the source will be reflected in
       * the target.
       * @param {EMLEntity} source - The entity to copy attributes from
       * @param {EMLEntity} target - The entity to copy attributes to
       */
      copyAttributeListByRef(source, target) {
        const sourceAttrList = source.get("attributeList");
        let sourceId = sourceAttrList.get("xmlID");

        if (!sourceId) {
          sourceId = DataONEObject.generateId();
          sourceAttrList.set("xmlID", sourceId);
        }

        // empty the target attribute list
        const targetAttrList = target.get("attributeList");
        targetAttrList.setReferences(sourceId);
      },

      /**
       * Deep copy the attribute list from a source entity to a target entity.
       * This means that changes to the source will not be reflected in the
       * target.
       * @param {EMLEntity} source - The entity to copy attributes from
       * @param {EMLEntity} target - The entity to copy attributes to
       */
      deepCopyAttributeList(source, target) {
        const attrsStr = source.get("attributeList").serialize();
        const attrList = target.get("attributeList");
        const emlAttrs = attrList.get("emlAttributes");
        // Use remove rather than reset to trigger events
        emlAttrs.remove(emlAttrs.models);
        const attrDOM = new DOMParser().parseFromString(attrsStr, "text/xml");
        emlAttrs.add(attrDOM, { parse: true });
        // remove xmlID from the target attributes
        emlAttrs.each((attr) => attr.unset("xmlID"));
        // Reference to entity model required for attr & sub-models
        emlAttrs.each((attr) => attr.set("parentModel", target));

        // Invalid to have both attributes and references
        if (attrList.hasReferences()) {
          attrList.removeReferences();
        }
      },

      /**
       * Get the attribute lists of all entities in the collection.
       * @returns {EMLAttributeList[]} The attribute lists of all entities in the
       * collection. If an entity does not have an attribute list, null is
       * returned for that entity.
       */
      getAllAttributeLists() {
        return this.map((entity) => {
          const attrList = entity.get("attributeList");
          return attrList || null;
        });
      },
    },
  );

  return EMLEntities;
});