Source: src/js/models/DataONEObject.js

define([
  "jquery",
  "underscore",
  "backbone",
  "uuid",
  "he",
  "collections/AccessPolicy",
  "collections/ObjectFormats",
  "md5",
], function ($, _, Backbone, uuid, he, AccessPolicy, ObjectFormats, md5) {
  /**
        * @class DataONEObject
        * @classdesc A DataONEObject represents a DataONE object, such as a data file,
        a science metadata object, or a resource map. It stores the system
         metadata attributes for the object, performs updates to the system metadata,
         and other basic DataONE API functions. This model can be extended to provide
         specific functionality for different object types, such as the {@link ScienceMetadata}
         model and the {@link EML211} model.
         * @classcategory Models
        * @augments Backbone.Model
        */
  var DataONEObject = Backbone.Model.extend(
    /** @lends DataONEObject.prototype */ {
      type: "DataONEObject",
      selectedInEditor: false, // Has this package member been selected and displayed in the provenance editor?
      PROV: "http://www.w3.org/ns/prov#",
      PROVONE: "http://purl.dataone.org/provone/2015/01/15/ontology#",

      defaults: function () {
        return {
          // System Metadata attributes
          serialVersion: null,
          identifier: null,
          formatId: null,
          size: null,
          checksum: null,
          originalChecksum: null,
          checksumAlgorithm: "MD5",
          submitter: null,
          rightsHolder: null,
          accessPolicy: [], //An array of accessPolicy literal JS objects
          replicationAllowed: null,
          replicationPolicy: [],
          obsoletes: null,
          obsoletedBy: null,
          archived: null,
          dateUploaded: null,
          dateSysMetadataModified: null,
          originMemberNode: null,
          authoritativeMemberNode: null,
          replica: [],
          seriesId: null, // uuid.v4(), (decide if we want to auto-set this)
          mediaType: null,
          fileName: null,
          // Non-system metadata attributes:
          isNew: null,
          datasource: null,
          insert_count_i: null,
          read_count_i: null,
          changePermission: null,
          writePermission: null,
          readPermission: null,
          isPublic: null,
          dateModified: null,
          id: "urn:uuid:" + uuid.v4(),
          sizeStr: null,
          type: "", // Data, Metadata, or DataPackage
          formatType: "",
          metadataEntity: null, // A model that represents the metadata for this file, e.g. an EMLEntity model
          latestVersion: null,
          isDocumentedBy: null,
          documents: [],
          members: [],
          resourceMap: [],
          nodeLevel: 0, // Indicates hierarchy level in the view for indentation
          sortOrder: 2, // Metadata: 1, Data: 2, DataPackage: 3
          synced: false, // True if the full model has been synced
          uploadStatus: null, //c=complete, p=in progress, q=queued, e=error, w=warning, no upload status=not in queue
          uploadProgress: null,
          sysMetaUploadStatus: null, //c=complete, p=in progress, q=queued, e=error, l=loading, no upload status=not in queue
          percentLoaded: 0, // Percent the file is read before caclculating the md5 sum
          uploadFile: null, // The file reference to be uploaded (JS object: File)
          errorMessage: null,
          sysMetaErrorCode: null, // The status code given when there is an error updating the system metadata
          numSaveAttempts: 0,
          notFound: false, //Whether or not this object was found in the system
          originalAttrs: [], // An array of original attributes in a DataONEObject
          changed: false, // If any attributes have been changed, including attrs in nested objects
          hasContentChanges: false, // If attributes outside of originalAttrs have been changed
          sysMetaXML: null, // A cached original version of the fetched system metadata document
          objectXML: null, // A cached version of the object fetched from the server
          isAuthorized: null, // If the stated permission is authorized by the user
          isAuthorized_read: null, //If the user has permission to read
          isAuthorized_write: null, //If the user has permission to write
          isAuthorized_changePermission: null, //If the user has permission to changePermission
          createSeriesId: false, //If true, a seriesId will be created when this object is saved.
          collections: [], //References to collections that this model is in
          possibleAuthMNs: [], //A list of possible authoritative MNs of this object
          useAltRepo: false,
          isLoadingFiles: false, //Only relevant to Resource Map objects. Is true if there is at least one file still loading into the package.
          numLoadingFiles: 0, //Only relevant to Resource Map objects. The number of files still loading into the package.
          provSources: [],
          provDerivations: [],
          prov_generated: [],
          prov_generatedByExecution: [],
          prov_generatedByProgram: [],
          prov_generatedByUser: [],
          prov_hasDerivations: [],
          prov_hasSources: [],
          prov_instanceOfClass: [],
          prov_used: [],
          prov_usedByExecution: [],
          prov_usedByProgram: [],
          prov_usedByUser: [],
          prov_wasDerivedFrom: [],
          prov_wasExecutedByExecution: [],
          prov_wasExecutedByUser: [],
          prov_wasInformedBy: [],
        };
      },

      initialize: function (attrs, options) {
        if (typeof attrs == "undefined") var attrs = {};

        this.set("accessPolicy", this.createAccessPolicy());

        this.on("change:size", this.bytesToSize);
        if (attrs.size) this.bytesToSize();

        // Cache an array of original attribute names to help in handleChange()
        if (this.type == "DataONEObject")
          this.set("originalAttrs", Object.keys(this.attributes));
        else
          this.set(
            "originalAttrs",
            Object.keys(DataONEObject.prototype.defaults()),
          );

        this.on("successSaving", this.updateRelationships);

        //Save a reference to this DataONEObject model in the metadataEntity model
        //whenever the metadataEntity is set
        this.on("change:metadataEntity", function () {
          var entityMetadataModel = this.get("metadataEntity");

          if (entityMetadataModel)
            entityMetadataModel.set("dataONEObject", this);
        });

        this.on("sync", function () {
          this.set("synced", true);
        });

        //Find Member Node object that might be the authoritative MN
        //This is helpful when MetacatUI may be displaying content from multiple MNs
        this.setPossibleAuthMNs();
      },

      /**
       * Maps the lower-case sys meta node names (valid in HTML DOM) to the
       * camel-cased sys meta node names (valid in DataONE).
       * Used during parse() and serialize()
       */
      nodeNameMap: function () {
        return {
          accesspolicy: "accessPolicy",
          accessrule: "accessRule",
          authoritativemembernode: "authoritativeMemberNode",
          checksumalgorithm: "checksumAlgorithm",
          dateuploaded: "dateUploaded",
          datesysmetadatamodified: "dateSysMetadataModified",
          formatid: "formatId",
          filename: "fileName",
          nodereference: "nodeReference",
          numberreplicas: "numberReplicas",
          obsoletedby: "obsoletedBy",
          originmembernode: "originMemberNode",
          replicamembernode: "replicaMemberNode",
          replicationallowed: "replicationAllowed",
          replicationpolicy: "replicationPolicy",
          replicationstatus: "replicationStatus",
          replicaverified: "replicaVerified",
          rightsholder: "rightsHolder",
          serialversion: "serialVersion",
          seriesid: "seriesId",
        };
      },

      /**
       * Returns the URL string where this DataONEObject can be fetched from or saved to
       * @returns {string}
       */
      url: function () {
        // With no id, we can't do anything
        if (!this.get("id") && !this.get("seriesid")) return "";

        //Get the active alternative repository, if one is configured
        var activeAltRepo = MetacatUI.appModel.getActiveAltRepo();

        //Start the base URL string
        var baseUrl = "";

        // Determine if we're updating a new/existing object,
        // or just its system metadata
        // New uploads use the object service URL
        if (this.isNew()) {
          //Use the object service URL from the alt repo
          if (this.get("useAltRepo") && activeAltRepo) {
            baseUrl = activeAltRepo.objectServiceUrl;
          }
          //If this MetacatUI deployment is pointing to a MN, use the object service URL from the AppModel
          else {
            baseUrl = MetacatUI.appModel.get("objectServiceUrl");
          }

          //Return the full URL
          return baseUrl;
        } else {
          if (this.hasUpdates()) {
            if (this.get("hasContentChanges")) {
              //Use the object service URL from the alt repo
              if (this.get("useAltRepo") && activeAltRepo) {
                baseUrl = activeAltRepo.objectServiceUrl;
              } else {
                baseUrl = MetacatUI.appModel.get("objectServiceUrl");
              }

              // Exists on the server, use MN.update()
              return baseUrl + encodeURIComponent(this.get("oldPid"));
            } else {
              //Use the meta service URL from the alt repo
              if (this.get("useAltRepo") && activeAltRepo) {
                baseUrl = activeAltRepo.metaServiceUrl;
              } else {
                baseUrl = MetacatUI.appModel.get("metaServiceUrl");
              }

              // Exists on the server, use MN.updateSystemMetadata()
              return baseUrl + encodeURIComponent(this.get("id"));
            }
          } else {
            //Use the meta service URL from the alt repo
            if (this.get("useAltRepo") && activeAltRepo) {
              baseUrl = activeAltRepo.metaServiceUrl;
            } else {
              baseUrl = MetacatUI.appModel.get("metaServiceUrl");
            }

            // Use MN.getSystemMetadata()
            return (
              baseUrl +
              (encodeURIComponent(this.get("id")) ||
                encodeURIComponent(this.get("seriesid")))
            );
          }
        }
      },

      /**
       * Create the URL string that is used to download this package
       * @returns PackageURL string for this DataONE Object
       * @since 2.28.0
       */
      getPackageURL: function () {
        var url = null;

        // With no id, we can't do anything
        if (!this.get("id") && !this.get("seriesid")) return url;

        //If we haven't set a packageServiceURL upon app initialization and we are querying a CN, then the packageServiceURL is dependent on the MN this package is from
        if (
          MetacatUI.appModel.get("d1Service").toLowerCase().indexOf("cn/") >
            -1 &&
          MetacatUI.nodeModel.get("members").length
        ) {
          var source = this.get("datasource"),
            node = _.find(MetacatUI.nodeModel.get("members"), {
              identifier: source,
            });

          //If this node has MNRead v2 services...
          if (node && node.readv2)
            url =
              node.baseURL +
              "/v2/packages/application%2Fbagit-097/" +
              encodeURIComponent(this.get("id"));
        } else if (MetacatUI.appModel.get("packageServiceUrl"))
          url =
            MetacatUI.appModel.get("packageServiceUrl") +
            encodeURIComponent(this.get("id"));

        return url;
      },

      /**
       * Overload Backbone.Model.fetch, so that we can set custom options for each fetch() request
       */
      fetch: function (options) {
        if (!options) var options = {};
        else var options = _.clone(options);

        options.url = this.url();

        //If we are using the Solr service to retrieve info about this object, then construct a query
        if (typeof options != "undefined" && options.solrService) {
          //Get basic information
          var query = "";

          //Do not search for seriesId when it is not configured in this model/app
          if (typeof this.get("seriesid") === "undefined")
            query += 'id:"' + encodeURIComponent(this.get("id")) + '"';
          //If there is no seriesid set, then search for pid or sid
          else if (!this.get("seriesid"))
            query +=
              '(id:"' +
              encodeURIComponent(this.get("id")) +
              '" OR seriesId:"' +
              encodeURIComponent(this.get("id")) +
              '")';
          //If a seriesId is specified, then search for that
          else if (this.get("seriesid") && this.get("id").length > 0)
            query +=
              '(seriesId:"' +
              encodeURIComponent(this.get("seriesid")) +
              '" AND id:"' +
              encodeURIComponent(this.get("id")) +
              '")';
          //If only a seriesId is specified, then just search for the most recent version
          else if (this.get("seriesid") && !this.get("id"))
            query +=
              'seriesId:"' +
              encodeURIComponent(this.get("id")) +
              '" -obsoletedBy:*';

          //The fields to return
          var fl = "formatId,formatType,documents,isDocumentedBy,id,seriesId";

          //Use the Solr query URL
          var solrOptions = {
            url:
              MetacatUI.appModel.get("queryServiceUrl") +
              "q=" +
              query +
              "&fl=" +
              fl +
              "&wt=json",
          };

          //Merge with the options passed to this function
          var fetchOptions = _.extend(options, solrOptions);
        } else if (typeof options != "undefined") {
          //Use custom options for retreiving XML
          //Merge with the options passed to this function
          var fetchOptions = _.extend(
            {
              dataType: "text",
            },
            options,
          );
        } else {
          //Use custom options for retreiving XML
          var fetchOptions = _.extend({
            dataType: "text",
          });
        }

        //Add the authorization options
        fetchOptions = _.extend(
          fetchOptions,
          MetacatUI.appUserModel.createAjaxSettings(),
        );

        //Call Backbone.Model.fetch to retrieve the info
        return Backbone.Model.prototype.fetch.call(this, fetchOptions);
      },

      /**
       * This function is called by Backbone.Model.fetch.
       * It deserializes the incoming XML from the /meta REST endpoint and converts it into JSON.
       */
      parse: function (response) {
        // If the response is XML
        if (typeof response == "string" && response.indexOf("<") == 0) {
          var responseDoc = $.parseHTML(response),
            systemMetadata;

          //Save the raw XML in case it needs to be used later
          this.set("sysMetaXML", response);

          //Find the XML node for the system metadata
          for (var i = 0; i < responseDoc.length; i++) {
            if (
              responseDoc[i].nodeType == 1 &&
              responseDoc[i].localName.indexOf("systemmetadata") > -1
            ) {
              systemMetadata = responseDoc[i];
              break;
            }
          }

          //Parse the XML to JSON
          var sysMetaValues = this.toJson(systemMetadata);

          //Convert the JSON to a camel-cased version, which matches Solr and is easier to work with in code
          _.each(
            Object.keys(sysMetaValues),
            function (key) {
              var camelCasedKey = this.nodeNameMap()[key];
              if (camelCasedKey) {
                sysMetaValues[camelCasedKey] = sysMetaValues[key];
                delete sysMetaValues[key];
              }
            },
            this,
          );

          //Save the checksum from the system metadata in a separate attribute on the model
          sysMetaValues.originalChecksum = sysMetaValues.checksum;
          sysMetaValues.checksum = this.defaults().checksum;

          //Save the identifier as the id attribute
          sysMetaValues.id = sysMetaValues.identifier;

          //Parse the Access Policy
          if (
            this.get("accessPolicy") &&
            AccessPolicy.prototype.isPrototypeOf(this.get("accessPolicy"))
          ) {
            this.get("accessPolicy").parse(
              $(systemMetadata).find("accesspolicy"),
            );
            sysMetaValues.accessPolicy = this.get("accessPolicy");
          } else {
            //Create a new AccessPolicy collection, if there isn't one already.
            sysMetaValues.accessPolicy = this.createAccessPolicy(
              $(systemMetadata).find("accesspolicy"),
            );
          }

          return sysMetaValues;

          // If the response is a list of Solr docs
        } else if (
          typeof response === "object" &&
          response.response &&
          response.response.docs
        ) {
          //If no objects were found in the index, mark as notFound and exit
          if (!response.response.docs.length) {
            this.set("notFound", true);
            this.trigger("notFound");
            return;
          }

          //Get the Solr document (there should be only one)
          var doc = response.response.docs[0];

          //Take out any empty values
          _.each(Object.keys(doc), function (field) {
            if (!doc[field] && doc[field] !== 0) delete doc[field];
          });

          //Remove any erroneous white space from fields
          this.removeWhiteSpaceFromSolrFields(doc);

          return doc;
        }
        // Default to returning the raw response
        else return response;
      },

      /** A utility function for converting XML to JSON */
      toJson: function (xml) {
        // Create the return object
        var obj = {};

        // do children
        if (xml.hasChildNodes()) {
          for (var i = 0; i < xml.childNodes.length; i++) {
            var item = xml.childNodes.item(i);

            //If it's an empty text node, skip it
            if (item.nodeType == 3 && !item.nodeValue.trim()) continue;

            //Get the node name
            var nodeName = item.localName;

            //If it's a new container node, convert it to JSON and add as a new object attribute
            if (typeof obj[nodeName] == "undefined" && item.nodeType == 1) {
              obj[nodeName] = this.toJson(item);
            }
            //If it's a new text node, just store the text value and add as a new object attribute
            else if (
              typeof obj[nodeName] == "undefined" &&
              item.nodeType == 3
            ) {
              obj =
                item.nodeValue == "false"
                  ? false
                  : item.nodeValue == "true"
                    ? true
                    : item.nodeValue;
            }
            //If this node name is already stored as an object attribute...
            else if (typeof obj[nodeName] != "undefined") {
              //Cache what we have now
              var old = obj[nodeName];
              if (!Array.isArray(old)) old = [old];

              //Create a new object to store this node info
              var newNode = {};

              //Add the new node info to the existing array we have now
              if (item.nodeType == 1) {
                newNode = this.toJson(item);
                var newArray = old.concat(newNode);
              } else if (item.nodeType == 3) {
                newNode = item.nodeValue;
                var newArray = old.concat(newNode);
              }

              //Store the attributes for this node
              _.each(item.attributes, function (attr) {
                newNode[attr.localName] = attr.nodeValue;
              });

              //Replace the old array with the updated one
              obj[nodeName] = newArray;

              //Exit
              continue;
            }

            //Store the attributes for this node
            /*_.each(item.attributes, function(attr){
                    obj[nodeName][attr.localName] = attr.nodeValue;
                  });*/
          }
        }
        return obj;
      },

      /**
          Serialize the DataONE object JSON to XML
          @param {object} json - the JSON object to convert to XML
          @param {Element} containerNode - an HTML element to insertt the resulting XML into
          @returns {Element} The updated HTML Element
         */
      toXML: function (json, containerNode) {
        if (typeof json == "string") {
          containerNode.textContent = json;
          return containerNode;
        }

        for (var i = 0; i < Object.keys(json).length; i++) {
          var key = Object.keys(json)[i],
            contents = json[key] || json[key];

          var node = document.createElement(key);

          //Skip this attribute if it is not populated
          if (!contents || (Array.isArray(contents) && !contents.length))
            continue;

          //If it's a simple text node
          if (typeof contents == "string") {
            containerNode.textContent = contents;
            return containerNode;
          } else if (Array.isArray(contents)) {
            var allNewNodes = [];

            for (var ii = 0; ii < contents.length; ii++) {
              allNewNodes.push(this.toXML(contents[ii], $(node).clone()[0]));
            }

            if (allNewNodes.length) node = allNewNodes;
          } else if (typeof contents == "object") {
            $(node).append(this.toXML(contents, node));
            var attributeNames = _.without(Object.keys(json[key]), "content");
          }

          $(containerNode).append(node);
        }

        return containerNode;
      },

      /**
       * Saves the DataONEObject System Metadata to the server
       */
      save: function (attributes, options) {
        // Set missing file names before saving
        if (!this.get("fileName")) {
          this.setMissingFileName();
        } else {
          //Replace all non-alphanumeric characters with underscores
          var fileNameWithoutExt = this.get("fileName").substring(
              0,
              this.get("fileName").lastIndexOf("."),
            ),
            extension = this.get("fileName").substring(
              this.get("fileName").lastIndexOf("."),
              this.get("fileName").length,
            );
          this.set(
            "fileName",
            fileNameWithoutExt.replace(/[^a-zA-Z0-9]/g, "_") + extension,
          );
        }

        if (!this.hasUpdates()) {
          this.set("uploadStatus", null);
          return;
        }

        //Set the upload transfer as in progress
        this.set("uploadProgress", 2);
        this.set("uploadStatus", "p");

        //Check if the checksum has been calculated yet.
        if (!this.get("checksum")) {
          //When it is calculated, restart this function
          this.on("checksumCalculated", this.save);
          //Calculate the checksum for this file
          this.calculateChecksum();
          //Exit this function until the checksum is done
          return;
        }

        //Create a FormData object to send data with our XHR
        var formData = new FormData();

        //If this is not a new object, update the id. New DataONEObjects will have an id
        // created during initialize.
        if (!this.isNew()) {
          this.updateID();
          formData.append("pid", this.get("oldPid"));
          formData.append("newPid", this.get("id"));
        } else {
          //Create an ID if there isn't one
          if (!this.get("id")) {
            this.set("id", "urn:uuid:" + uuid.v4());
          }

          //Add the identifier to the XHR data
          formData.append("pid", this.get("id"));
        }

        //Create the system metadata XML
        var sysMetaXML = this.serializeSysMeta();

        //Send the system metadata as a Blob
        var xmlBlob = new Blob([sysMetaXML], { type: "application/xml" });
        //Add the system metadata XML to the XHR data
        formData.append("sysmeta", xmlBlob, "sysmeta.xml");

        // Create the new object (MN.create())
        formData.append("object", this.get("uploadFile"), this.get("fileName"));

        var model = this;

        // On create(), add to the package and the metadata
        // Note: This should be added to the parent collection
        // but for now we are using the root collection
        _.each(
          this.get("collections"),
          function (collection) {
            if (collection.type == "DataPackage") {
              this.off("successSaving", collection.addNewModel);
              this.once("successSaving", collection.addNewModel, collection);
            }
          },
          this,
        );

        //Put together the AJAX and Backbone.save() options
        var requestSettings = {
          url: this.url(),
          cache: false,
          contentType: false,
          dataType: "text",
          processData: false,
          data: formData,
          parse: false,
          xhr: function () {
            var xhr = new window.XMLHttpRequest();

            //Upload progress
            xhr.upload.addEventListener(
              "progress",
              function (evt) {
                if (evt.lengthComputable) {
                  var percentComplete = (evt.loaded / evt.total) * 100;

                  model.set("uploadProgress", percentComplete);
                }
              },
              false,
            );

            return xhr;
          },
          success: this.onSuccessfulSave,
          error: function (model, response, xhr) {
            //Reset the identifier changes
            model.resetID();
            //Reset the checksum, if this is a model that needs to be serialized with each save.
            if (model.serialize) {
              model.set("checksum", model.defaults().checksum);
            }

            model.set("numSaveAttempts", model.get("numSaveAttempts") + 1);
            var numSaveAttempts = model.get("numSaveAttempts");

            if (
              numSaveAttempts < 3 &&
              (response.status == 408 || response.status == 0)
            ) {
              //Try saving again in 10, 40, and 90 seconds
              setTimeout(
                function () {
                  model.save.call(model);
                },
                numSaveAttempts * numSaveAttempts * 10000,
              );
            } else {
              model.set("numSaveAttempts", 0);

              var parsedResponse = $(response.responseText)
                .not("style, title")
                .text();

              //When there is no network connection (status == 0), there will be no response text
              if (!parsedResponse)
                parsedResponse =
                  "There was a network issue that prevented this file from uploading. " +
                  "Make sure you are connected to a reliable internet connection.";

              model.set("errorMessage", parsedResponse);

              //Set the model status as e for error
              model.set("uploadStatus", "e");

              //Trigger a custom event for the model save error
              model.trigger("errorSaving", parsedResponse);

              // Track this error in our analytics
              MetacatUI.analytics?.trackException(
                `DataONEObject save error: ${parsedResponse}`,
                model.get("id"),
                true,
              );
            }
          },
        };

        //Add the user settings
        requestSettings = _.extend(
          requestSettings,
          MetacatUI.appUserModel.createAjaxSettings(),
        );

        //Send the Save request
        Backbone.Model.prototype.save.call(this, null, requestSettings);
      },

      /**
       * This function is executed when the XHR that saves this DataONEObject has
       * successfully completed. It can be called directly if a DataONEObject is saved
       * without directly using the DataONEObject.save() function.
       * @param {DataONEObject} [model] A reference to this DataONEObject model
       * @param {XMLHttpRequest.response} [response] The XHR response object
       * @param {XMLHttpRequest} [xhr] The XHR that was just completed successfully
       */
      onSuccessfulSave: function (model, response, xhr) {
        if (typeof model == "undefined") {
          var model = this;
        }

        model.set("numSaveAttempts", 0);
        model.set("uploadStatus", "c");
        model.set("isNew", false);
        model.trigger("successSaving", model);

        // Get the newest sysmeta set by the MN
        model.fetch({
          merge: true,
          systemMetadataOnly: true,
        });

        // Reset the content changes status
        model.set("hasContentChanges", false);

        //Reset the model isNew attribute
        model.set("isNew", false);

        // Reset oldPid so we can replace again
        model.set("oldPid", null);

        //Set the last-calculated checksum as the original checksum
        model.set("originalChecksum", model.get("checksum"));
        model.set("checksum", model.defaults().checksum);
      },

      /**
       * Updates the DataONEObject System Metadata to the server
       */
      updateSysMeta: function () {
        //Update the upload status to "p" for "in progress"
        this.set("uploadStatus", "p");
        //Update the system metadata upload status to "p" as well, so the app
        // knows that the system metadata, specifically, is being updated.
        this.set("sysMetaUploadStatus", "p");

        var formData = new FormData();

        //Add the identifier to the XHR data
        formData.append("pid", this.get("id"));

        var sysMetaXML = this.serializeSysMeta();

        //Send the system metadata as a Blob
        var xmlBlob = new Blob([sysMetaXML], { type: "application/xml" });
        //Add the system metadata XML to the XHR data
        formData.append("sysmeta", xmlBlob, "sysmeta.xml");

        var model = this;

        var baseUrl = "",
          activeAltRepo = MetacatUI.appModel.getActiveAltRepo();
        //Use the meta service URL from the alt repo
        if (activeAltRepo) {
          baseUrl = activeAltRepo.metaServiceUrl;
        }
        //If this MetacatUI deployment is pointing to a MN, use the meta service URL from the AppModel
        else {
          baseUrl = MetacatUI.appModel.get("metaServiceUrl");
        }

        var requestSettings = {
          url: baseUrl + encodeURIComponent(this.get("id")),
          cache: false,
          contentType: false,
          dataType: "text",
          type: "PUT",
          processData: false,
          data: formData,
          parse: false,
          success: function () {
            model.set("numSaveAttempts", 0);

            //Fetch the system metadata from the server so we have a fresh copy of the newest sys meta.
            model.fetch({ systemMetadataOnly: true });

            model.set("sysMetaErrorCode", null);

            //Update the upload status to "c" for "complete"
            model.set("uploadStatus", "c");
            model.set("sysMetaUploadStatus", "c");

            //Trigger a custom event that the sys meta was updated
            model.trigger("sysMetaUpdated");
          },
          error: function (xhr, status, statusCode) {
            model.set("numSaveAttempts", model.get("numSaveAttempts") + 1);
            var numSaveAttempts = model.get("numSaveAttempts");

            if (numSaveAttempts < 3 && (statusCode == 408 || statusCode == 0)) {
              //Try saving again in 10, 40, and 90 seconds
              setTimeout(
                function () {
                  model.updateSysMeta.call(model);
                },
                numSaveAttempts * numSaveAttempts * 10000,
              );
            } else {
              model.set("numSaveAttempts", 0);

              var parsedResponse = $(xhr.responseText)
                .not("style, title")
                .text();

              //When there is no network connection (status == 0), there will be no response text
              if (!parsedResponse)
                parsedResponse =
                  "There was a network issue that prevented this file from updating. " +
                  "Make sure you are connected to a reliable internet connection.";

              model.set("errorMessage", parsedResponse);

              model.set("sysMetaErrorCode", statusCode);

              model.set("uploadStatus", "e");
              model.set("sysMetaUploadStatus", "e");

              // Trigger a custom event for the sysmeta update that
              // errored
              model.trigger("sysMetaUpdateError");

              // Track this error in our analytics
              MetacatUI.analytics?.trackException(
                `DataONEObject update system metadata ` +
                  `error: ${parsedResponse}`,
                model.get("id"),
                true,
              );
            }
          },
        };

        //Add the user settings
        requestSettings = _.extend(
          requestSettings,
          MetacatUI.appUserModel.createAjaxSettings(),
        );

        //Send the XHR
        $.ajax(requestSettings);
      },

      /**
       * Check if the current user is authorized to perform an action on this object. This function doesn't return
       * the result of the check, but it sends an XHR, updates this model, and triggers a change event.
       * @param {string} [action=changePermission] - The action (read, write, or changePermission) to check
       * if the current user has authorization to perform. By default checks for the highest level of permission.
       * @param {object} [options] Additional options for this function. See the properties below.
       * @property {function} options.onSuccess - A function to execute when the checkAuthority API is successfully completed
       * @property {function} options.onError - A function to execute when the checkAuthority API returns an error, or when no PID or SID can be found for this object.
       * @return {boolean}
       */
      checkAuthority: function (action = "changePermission", options) {
        try {
          // return false - if neither PID nor SID is present to check the authority
          if (this.get("id") == null && this.get("seriesId") == null) {
            return false;
          }

          if (typeof options == "undefined") {
            var options = {};
          }

          // If onError or onSuccess options were provided by the user,
          // check that they are functions first, so we don't try to use
          // some other type of variable as a function later on.
          ["onError", "onSuccess"].forEach(function (userFunction) {
            if (typeof options[userFunction] !== "function") {
              options[userFunction] = null;
            }
          });

          // If PID is not present - check authority with seriesId
          var identifier = this.get("id");
          if (identifier == null) {
            identifier = this.get("seriesId");
          }

          //If there are alt repositories configured, find the possible authoritative
          // Member Node for this DataONEObject.
          if (MetacatUI.appModel.get("alternateRepositories").length) {
            //Get the array of possible authoritative MNs
            var possibleAuthMNs = this.get("possibleAuthMNs");

            //If there are no possible authoritative MNs, use the auth service URL from the AppModel
            if (!possibleAuthMNs.length) {
              baseUrl = MetacatUI.appModel.get("authServiceUrl");
            } else {
              //Use the auth service URL from the top possible auth MN
              baseUrl = possibleAuthMNs[0].authServiceUrl;
            }
          } else {
            //Get the auth service URL from the AppModel
            baseUrl = MetacatUI.appModel.get("authServiceUrl");
          }

          if (!baseUrl) {
            return false;
          }

          var onSuccess =
              options.onSuccess ||
              function (data, textStatus, xhr) {
                model.set("isAuthorized_" + action, true);
                model.set("isAuthorized", true);
                model.trigger("change:isAuthorized");
              },
            onError =
              options.onError ||
              function (xhr, textStatus, errorThrown) {
                if (errorThrown == 404) {
                  var possibleAuthMNs = model.get("possibleAuthMNs");
                  if (possibleAuthMNs.length) {
                    //Remove the first MN from the array, since it didn't contain the object, so it's not the auth MN
                    possibleAuthMNs.shift();
                  }

                  //If there are no other possible auth MNs to check, trigger this model as Not Found.
                  if (possibleAuthMNs.length == 0 || !possibleAuthMNs) {
                    model.set("notFound", true);
                    model.trigger("notFound");
                  }
                  //If there's more MNs to check, try again
                  else {
                    model.checkAuthority(action, options);
                  }
                } else {
                  model.set("isAuthorized_" + action, false);
                  model.set("isAuthorized", false);
                }
              };

          var model = this;
          var requestSettings = {
            url: baseUrl + encodeURIComponent(identifier) + "?action=" + action,
            type: "GET",
            success: onSuccess,
            error: onError,
          };
          $.ajax(
            _.extend(
              requestSettings,
              MetacatUI.appUserModel.createAjaxSettings(),
            ),
          );
        } catch (e) {
          //Log an error to the console
          console.error("Couldn't check the authority for this user: ", e);

          // Track this error in our analytics
          const name = MetacatUI.appModel.get("username");
          MetacatUI.analytics?.trackException(
            `Couldn't check the authority for the user ${name}: ${e}`,
            this.get("id"),
            true,
          );

          //Set the user as unauthorized
          model.set("isAuthorized_" + action, false);
          model.set("isAuthorized", false);
          return false;
        }
      },

      /**
       * Using the attributes set on this DataONEObject model, serializes the system metadata XML
       * @returns {string}
       */
      serializeSysMeta: function () {
        //Get the system metadata XML that currently exists in the system
        var sysMetaXML = this.get("sysMetaXML"), // sysmeta as string
          xml, // sysmeta as DOM object
          accessPolicyXML, // The generated access policy XML
          previousSiblingNode, // A DOM node indicating any previous sibling
          rightsHolderNode, // A DOM node for the rights holder field
          accessPolicyNode, // A DOM node for the access policy
          replicationPolicyNode, // A DOM node for the replication policy
          obsoletesNode, // A DOM node for the obsoletes field
          obsoletedByNode, // A DOM node for the obsoletedBy field
          fileNameNode, // A DOM node for the file name
          xmlString, // The system metadata document as a string
          nodeNameMap, // The map of camelCase to lowercase attributes
          extension; // the file name extension for this object

        if (typeof sysMetaXML === "undefined" || sysMetaXML === null) {
          xml = this.createSysMeta();
        } else {
          xml = $($.parseHTML(sysMetaXML));
        }

        //Update the system metadata values
        xml.find("serialversion").text(this.get("serialVersion") || "0");
        xml.find("identifier").text(this.get("newPid") || this.get("id"));
        xml
          .find("submitter")
          .text(
            this.get("submitter") || MetacatUI.appUserModel.get("username"),
          );
        xml.find("formatid").text(this.get("formatId") || this.getFormatId());

        //If there is a seriesId, add it
        if (this.get("seriesId")) {
          //Get the seriesId XML node
          var seriesIdNode = xml.find("seriesId");

          //If it doesn't exist, create one
          if (!seriesIdNode.length) {
            seriesIdNode = $(document.createElement("seriesid"));
            xml.find("identifier").before(seriesIdNode);
          }

          //Add the seriesId string to the XML node
          seriesIdNode.text(this.get("seriesId"));
        }

        //If there is no size, get it
        if (!this.get("size") && this.get("uploadFile")) {
          this.set("size", this.get("uploadFile").size);
        }

        //Get the size of the file, if there is one
        if (this.get("uploadFile")) {
          xml.find("size").text(this.get("uploadFile").size);
        }
        //Otherwise, use the last known size
        else {
          xml.find("size").text(this.get("size"));
        }

        //Save the original checksum
        if (!this.get("checksum") && this.get("originalChecksum")) {
          xml.find("checksum").text(this.get("originalChecksum"));
        }
        //Update the checksum and checksum algorithm
        else {
          xml.find("checksum").text(this.get("checksum"));
          xml.find("checksum").attr("algorithm", this.get("checksumAlgorithm"));
        }

        //Update the rightsholder
        xml
          .find("rightsholder")
          .text(
            this.get("rightsHolder") || MetacatUI.appUserModel.get("username"),
          );

        //Write the access policy
        accessPolicyXML = this.get("accessPolicy").serialize();

        // Get the access policy node, if it exists
        accessPolicyNode = xml.find("accesspolicy");

        previousSiblingNode = xml.find("rightsholder");

        // Create an access policy node if needed
        if (!accessPolicyNode.length && accessPolicyXML) {
          accessPolicyNode = $(document.createElement("accesspolicy"));
          previousSiblingNode.after(accessPolicyNode);
        }

        //Replace the old access policy with the new one if it exists
        if (accessPolicyXML) {
          accessPolicyNode.replaceWith(accessPolicyXML);
        } else {
          // Remove the node if it is empty
          accessPolicyNode.remove();
        }

        // Set the obsoletes node after replPolicy or accessPolicy, or rightsHolder
        replicationPolicyNode = xml.find("replicationpolicy");
        accessPolicyNode = xml.find("accesspolicy");
        rightsHolderNode = xml.find("rightsholder");

        if (replicationPolicyNode.length) {
          previousSiblingNode = replicationPolicyNode;
        } else if (accessPolicyNode.length) {
          previousSiblingNode = accessPolicyNode;
        } else {
          previousSiblingNode = rightsHolderNode;
        }

        obsoletesNode = xml.find("obsoletes");

        if (this.get("obsoletes")) {
          if (obsoletesNode.length) {
            obsoletesNode.text(this.get("obsoletes"));
          } else {
            obsoletesNode = $(document.createElement("obsoletes")).text(
              this.get("obsoletes"),
            );
            previousSiblingNode.after(obsoletesNode);
          }
        } else {
          if (obsoletesNode) {
            obsoletesNode.remove();
          }
        }

        if (obsoletesNode) {
          previousSiblingNode = obsoletesNode;
        }

        obsoletedByNode = xml.find("obsoletedby");

        //remove the obsoletedBy node if it exists
        // TODO: Verify this is what we want to do
        if (obsoletedByNode) {
          obsoletedByNode.remove();
        }

        xml.find("archived").text(this.get("archived") || "false");
        xml
          .find("dateuploaded")
          .text(this.get("dateUploaded") || new Date().toISOString());

        //Get the filename node
        fileNameNode = xml.find("filename");

        //If the filename node doesn't exist, then create one
        if (!fileNameNode.length) {
          fileNameNode = $(document.createElement("filename"));
          xml.find("dateuploaded").after(fileNameNode);
        }

        //Set the object file name
        $(fileNameNode).text(this.get("fileName"));

        xmlString = $(document.createElement("div")).append(xml.clone()).html();

        //Now camel case the nodes
        nodeNameMap = this.nodeNameMap();

        _.each(
          Object.keys(nodeNameMap),
          function (name, i) {
            var originalXMLString = xmlString;

            //Camel case node names
            var regEx = new RegExp("<" + name, "g");
            xmlString = xmlString.replace(regEx, "<" + nodeNameMap[name]);
            var regEx = new RegExp(name + ">", "g");
            xmlString = xmlString.replace(regEx, nodeNameMap[name] + ">");

            //If node names haven't been changed, then find an attribute
            if (xmlString == originalXMLString) {
              var regEx = new RegExp(" " + name + "=", "g");
              xmlString = xmlString.replace(
                regEx,
                " " + nodeNameMap[name] + "=",
              );
            }
          },
          this,
        );

        xmlString = xmlString.replace(/systemmetadata/g, "systemMetadata");

        return xmlString;
      },

      /**
       * Get the object format identifier for this object
       */
      getFormatId: function () {
        var formatId = "application/octet-stream", // default to untyped data
          objectFormats = {
            mediaTypes: [], // The list of potential formatIds based on mediaType matches
            extensions: [], // The list of possible formatIds based onextension matches
          },
          fileName = this.get("fileName"), // the fileName for this object
          ext; // The extension of the filename for this object

        objectFormats["mediaTypes"] = MetacatUI.objectFormats.where({
          formatId: this.get("mediaType"),
        });
        if (
          typeof fileName !== "undefined" &&
          fileName !== null &&
          fileName.length > 1
        ) {
          ext = fileName.substring(
            fileName.lastIndexOf(".") + 1,
            fileName.length,
          );
          objectFormats["extensions"] = MetacatUI.objectFormats.where({
            extension: ext,
          });
        }

        if (
          objectFormats["mediaTypes"].length > 0 &&
          objectFormats["extensions"].length > 0
        ) {
          var firstMediaType = objectFormats["mediaTypes"][0].get("formatId");
          var firstExtension = objectFormats["extensions"][0].get("formatId");
          // Check if they're equal
          if (firstMediaType === firstExtension) {
            formatId = firstMediaType;
            return formatId;
          }
          // Handle mismatched mediaType and extension cases - additional cases can be added below
          if (
            firstMediaType === "application/vnd.ms-excel" &&
            firstExtension === "text/csv"
          ) {
            formatId = firstExtension;
            return formatId;
          }
        }

        if (objectFormats["mediaTypes"].length > 0) {
          formatId = objectFormats["mediaTypes"][0].get("formatId");
          console.log("returning default mediaType");
          console.log(formatId);
          return formatId;
        }

        if (objectFormats["extensions"].length > 0) {
          //If this is a "nc" file, assume it is a netCDF-3 file.
          if (ext == "nc") {
            formatId = "netCDF-3";
          } else {
            formatId = objectFormats["extensions"][0].get("formatId");
          }
          return formatId;
        }

        return formatId;
      },

      /**
       * Looks up human readable format of the DataONE Object
       * @returns format String
       * @since 2.28.0
       */
      getFormat: function () {
        var formatMap = {
          "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
            "Microsoft Excel OpenXML",
          "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
            "Microsoft Word OpenXML",
          "application/vnd.ms-excel.sheet.binary.macroEnabled.12":
            "Microsoft Office Excel 2007 binary workbooks",
          "application/vnd.openxmlformats-officedocument.presentationml.presentation":
            "Microsoft Office OpenXML Presentation",
          "application/vnd.ms-excel": "Microsoft Excel",
          "application/msword": "Microsoft Word",
          "application/vnd.ms-powerpoint": "Microsoft Powerpoint",
          "text/html": "HTML",
          "text/plain": "plain text (.txt)",
          "video/avi": "Microsoft AVI file",
          "video/x-ms-wmv": "Windows Media Video (.wmv)",
          "audio/x-ms-wma": "Windows Media Audio (.wma)",
          "application/vnd.google-earth.kml xml":
            "Google Earth Keyhole Markup Language (KML)",
          "http://docs.annotatorjs.org/en/v1.2.x/annotation-format.html":
            "annotation",
          "application/mathematica": "Mathematica Notebook",
          "application/postscript": "Postscript",
          "application/rtf": "Rich Text Format (RTF)",
          "application/xml": "XML Application",
          "text/xml": "XML",
          "application/x-fasta": "FASTA sequence file",
          "nexus/1997": "NEXUS File Format for Systematic Information",
          "anvl/erc-v02":
            "Kernel Metadata and Electronic Resource Citations (ERCs), 2010.05.13",
          "http://purl.org/dryad/terms/":
            "Dryad Metadata Application Profile Version 3.0",
          "http://datadryad.org/profile/v3.1":
            "Dryad Metadata Application Profile Version 3.1",
          "application/pdf": "PDF",
          "application/zip": "ZIP file",
          "http://www.w3.org/TR/rdf-syntax-grammar": "RDF/XML",
          "http://www.w3.org/TR/rdfa-syntax": "RDFa",
          "application/rdf xml": "RDF",
          "text/turtle": "TURTLE",
          "text/n3": "N3",
          "application/x-gzip": "GZIP Format",
          "application/x-python": "Python script",
          "http://www.w3.org/2005/Atom": "ATOM-1.0",
          "application/octet-stream": "octet stream (application file)",
          "http://digir.net/schema/conceptual/darwin/2003/1.0/darwin2.xsd":
            "Darwin Core, v2.0",
          "http://rs.tdwg.org/dwc/xsd/simpledarwincore/": "Simple Darwin Core",
          "eml://ecoinformatics.org/eml-2.1.0": "EML v2.1.0",
          "eml://ecoinformatics.org/eml-2.1.1": "EML v2.1.1",
          "eml://ecoinformatics.org/eml-2.0.1": "EML v2.0.1",
          "eml://ecoinformatics.org/eml-2.0.0": "EML v2.0.0",
          "https://eml.ecoinformatics.org/eml-2.2.0": "EML v2.2.0",
        };

        return formatMap[this.get("formatId")] || this.get("formatId");
      },

      /**
       * Build a fresh system metadata document for this object when it is new
       * Return it as a DOM object
       */
      createSysMeta: function () {
        var sysmetaDOM, // The DOM
          sysmetaXML = []; // The document as a string array

        sysmetaXML.push(
          //'<?xml version="1.0" encoding="UTF-8"?>',
          "<d1_v2.0:systemmetadata",
          '    xmlns:d1_v2.0="http://ns.dataone.org/service/types/v2.0"',
          '    xmlns:d1="http://ns.dataone.org/service/types/v1">',
          "    <serialversion />",
          "    <identifier />",
          "    <formatid />",
          "    <size />",
          "    <checksum />",
          "    <submitter />",
          "    <rightsholder />",
          "    <filename />",
          "</d1_v2.0:systemmetadata>",
        );

        sysmetaDOM = $($.parseHTML(sysmetaXML.join("")));
        return sysmetaDOM;
      },

      /**
       * Create an access policy for this DataONEObject using the default access
       * policy set in the AppModel.
       *
       * @param {Element} [accessPolicyXML] - An <accessPolicy> XML node
       *   that contains a list of access rules.
       * @return {AccessPolicy} - an AccessPolicy collection that represents the
       *   given XML or the default policy set in the AppModel.
       */
      createAccessPolicy: function (accessPolicyXML) {
        //Create a new AccessPolicy collection
        var accessPolicy = new AccessPolicy();

        accessPolicy.dataONEObject = this;

        //If there is no access policy XML sent,
        if (this.isNew() && !accessPolicyXML) {
          try {
            //If the app is configured to inherit the access policy from the parent metadata,
            // then get the parent metadata and copy it's AccessPolicy
            let scienceMetadata = this.get("isDocumentedByModels");
            if (
              MetacatUI.appModel.get("inheritAccessPolicy") &&
              scienceMetadata &&
              scienceMetadata.length
            ) {
              let sciMetaAccessPolicy = scienceMetadata[0].get("accessPolicy");

              if (sciMetaAccessPolicy) {
                accessPolicy.copyAccessPolicy(sciMetaAccessPolicy);
              } else {
                accessPolicy.createDefaultPolicy();
              }
            }
            //Otherwise, set the default access policy using the AppModel configuration
            else {
              accessPolicy.createDefaultPolicy();
            }
          } catch (e) {
            console.error(
              "Could create access policy, so defaulting to default",
              e,
            );
            accessPolicy.createDefaultPolicy();
          }
        } else {
          //Parse the access policy XML to create AccessRule models from the XML
          accessPolicy.parse(accessPolicyXML);
        }

        //Listen to changes on the collection and trigger a change on this model
        var self = this;
        this.listenTo(accessPolicy, "change update", function () {
          self.trigger("change");
          this.addToUploadQueue();
        });

        return accessPolicy;
      },

      /**
       * Update identifiers for this object
       *
       * @param {string} id - Optional identifier to update with. Generated
       * automatically when not given.
       *
       * Note that this method caches the objects attributes prior to
       * updating so this.resetID() can be called in case of a failure
       * state.
       *
       * Also note that this method won't run if theh oldPid attribute is
       * set. This enables knowing before this.save is called what the next
       * PID will be such as the case where we want to update a matching
       * EML entity when replacing files.
       */
      updateID: function (id) {
        // Only run once until oldPid is reset
        if (this.get("oldPid")) {
          return;
        }

        //Save the attributes so we can reset the ID later
        this.attributeCache = this.toJSON();

        //Set the old identifier
        var oldPid = this.get("id"),
          selfDocuments,
          selfDocumentedBy,
          documentedModels,
          documentedModel,
          index;

        //Save the current id as the old pid
        this.set("oldPid", oldPid);

        //Create a new seriesId, if there isn't one, and if this model specifies that one is required
        if (!this.get("seriesId") && this.get("createSeriesId")) {
          this.set("seriesId", "urn:uuid:" + uuid.v4());
        }

        // Check to see if the old pid documents or is documented by itself
        selfDocuments = _.contains(this.get("documents"), oldPid);
        selfDocumentedBy = _.contains(this.get("isDocumentedBy"), oldPid);

        //Set the new identifier
        if (id) {
          this.set("id", id);
        } else {
          if (this.get("type") == "DataPackage") {
            this.set("id", "resource_map_urn:uuid:" + uuid.v4());
          } else {
            this.set("id", "urn:uuid:" + uuid.v4());
          }
        }

        // Remove the old pid from the documents list if present
        if (selfDocuments) {
          index = this.get("documents").indexOf(oldPid);
          if (index > -1) {
            this.get("documents").splice(index, 1);
          }
          // And add the new pid in
          this.get("documents").push(this.get("id"));
        }

        // Remove the old pid from the isDocumentedBy list if present
        if (selfDocumentedBy) {
          index = this.get("isDocumentedBy").indexOf(oldPid);
          if (index > -1) {
            this.get("isDocumentedBy").splice(index, 1);
          }
          // And add the new pid in
          this.get("isDocumentedBy").push(this.get("id"));
        }

        // Update all models documented by this pid with the new id
        _.each(
          this.get("documents"),
          function (id) {
            (documentedModels = MetacatUI.rootDataPackage.where({ id: id })),
              documentedModel;

            if (documentedModels.length > 0) {
              documentedModel = documentedModels[0];
            }
            if (typeof documentedModel !== "undefined") {
              // Find the oldPid in the array
              if (Array.isArray(documentedModel.get("isDocumentedBy"))) {
                index = documentedModel.get("isDocumentedBy").indexOf("oldPid");

                if (index > -1) {
                  // Remove it
                  documentedModel.get("isDocumentedBy").splice(index, 1);
                }
                // And add the new pid in
                documentedModel.get("isDocumentedBy").push(this.get("id"));
              }
            }
          },
          this,
        );

        this.trigger("change:id");

        //Update the obsoletes and obsoletedBy
        this.set("obsoletes", oldPid);
        this.set("obsoletedBy", null);

        // Update the latest version of this object
        this.set("latestVersion", this.get("id"));

        //Set the archived option to false
        this.set("archived", false);
      },

      /**
       * Resets the identifier for this model. This undos all of the changes made in {DataONEObject#updateID}
       */
      resetID: function () {
        if (!this.attributeCache) return false;

        this.set("oldPid", this.attributeCache.oldPid, { silent: true });
        this.set("id", this.attributeCache.id, { silent: true });
        this.set("obsoletes", this.attributeCache.obsoletes, { silent: true });
        this.set("obsoletedBy", this.attributeCache.obsoletedBy, {
          silent: true,
        });
        this.set("archived", this.attributeCache.archived, { silent: true });
        this.set("latestVersion", this.attributeCache.latestVersion, {
          silent: true,
        });

        //Reset the attribute cache
        this.attributeCache = {};
      },

      /**
       * Checks if this system metadata XML has updates that need to be synced with the server.
       * @returns {boolean}
       */
      hasUpdates: function () {
        if (this.isNew()) return true;

        // Compare the new system metadata XML to the old system metadata XML

        //Check if there is system metadata first
        if (!this.get("sysMetaXML")) {
          return false;
        }

        var D1ObjectClone = this.clone(),
          // Make sure we are using the parse function in the DataONEObject model.
          // Sometimes hasUpdates is called from extensions of the D1Object model,
          // (e.g. from the portal model), and the parse function is overwritten
          oldSysMetaAttrs = new DataONEObject().parse(
            D1ObjectClone.get("sysMetaXML"),
          );

        D1ObjectClone.set(oldSysMetaAttrs);

        var oldSysMeta = D1ObjectClone.serializeSysMeta();
        var newSysMeta = this.serializeSysMeta();

        if (oldSysMeta === "") return false;

        return !(newSysMeta == oldSysMeta);
      },

      /**
             Set the changed flag on any system metadata or content attribute changes,
             and set the hasContentChanges flag on content changes only
             @param {DataONEObject} [model]
             @param {object} options Furhter options for this function
             @property {boolean} options.force If true, a change will be handled regardless if the attribute actually changed
           */
      handleChange: function (model, options) {
        if (!model) var model = this;

        var sysMetaAttrs = [
            "serialVersion",
            "identifier",
            "formatId",
            "formatType",
            "size",
            "checksum",
            "checksumAlgorithm",
            "submitter",
            "rightsHolder",
            "accessPolicy",
            "replicationAllowed",
            "replicationPolicy",
            "obsoletes",
            "obsoletedBy",
            "archived",
            "dateUploaded",
            "dateSysMetadataModified",
            "originMemberNode",
            "authoritativeMemberNode",
            "replica",
            "seriesId",
            "mediaType",
            "fileName",
          ],
          nonSysMetaNonContentAttrs = _.difference(
            model.get("originalAttrs"),
            sysMetaAttrs,
          ),
          allChangedAttrs = Object.keys(model.changedAttributes()),
          changedSysMetaOrContentAttrs = [], //sysmeta or content attributes that have changed
          changedContentAttrs = []; // attributes from sub classes like ScienceMetadata or EML211 ...

        // Get a list of all changed sysmeta and content attributes
        changedSysMetaOrContentAttrs = _.difference(
          allChangedAttrs,
          nonSysMetaNonContentAttrs,
        );
        if (changedSysMetaOrContentAttrs.length > 0) {
          // For any sysmeta or content change, set the package dirty flag
          if (
            MetacatUI.rootDataPackage &&
            MetacatUI.rootDataPackage.packageModel &&
            !MetacatUI.rootDataPackage.packageModel.get("changed") &&
            model.get("synced")
          ) {
            MetacatUI.rootDataPackage.packageModel.set("changed", true);
          }
        }

        // And get a list of all changed content attributes
        changedContentAttrs = _.difference(
          changedSysMetaOrContentAttrs,
          sysMetaAttrs,
        );

        if (
          (changedContentAttrs.length > 0 &&
            !this.get("hasContentChanges") &&
            model.get("synced")) ||
          (options && options.force)
        ) {
          this.set("hasContentChanges", true);
          this.addToUploadQueue();
        }
      },

      /**
       * Returns true if this DataONE object is new. A DataONE object is new
       * if there is no upload date and it's been synced (i.e. been fetched)
       * @return {boolean}
       */
      isNew: function () {
        //If the model is explicitly marked as not new, return false
        if (this.get("isNew") === false) {
          return false;
        }
        //If the model is explicitly marked as new, return true
        else if (this.get("isNew") === true) {
          return true;
        }

        //Check if there is an upload date that was retrieved from the server
        return (
          this.get("dateUploaded") === this.defaults().dateUploaded &&
          this.get("synced")
        );
      },

      /**
       * Updates the upload status attribute on this model and marks the collection as changed
       */
      addToUploadQueue: function () {
        if (!this.get("synced")) {
          return;
        }

        //Add this item to the queue
        if (
          this.get("uploadStatus") == "c" ||
          this.get("uploadStatus") == "e" ||
          !this.get("uploadStatus")
        ) {
          this.set("uploadStatus", "q");

          //Mark each DataPackage collection this model is in as changed
          _.each(
            this.get("collections"),
            function (collection) {
              if (collection.packageModel)
                collection.packageModel.set("changed", true);
            },
            this,
          );
        }
      },

      /**
       * Updates the progress percentage when the model is getting uploaded
       * @param {ProgressEvent} e - The ProgressEvent when this file is being uploaded
       */
      updateProgress: function (e) {
        if (e.lengthComputable) {
          var max = e.total;
          var current = e.loaded;

          var Percentage = (current * 100) / max;

          if (Percentage >= 100) {
            // process completed
          }
        }
      },

      /**
       * Updates the relationships with other models when this model has been updated
       */
      updateRelationships: function () {
        _.each(
          this.get("collections"),
          function (collection) {
            //Get the old id for this model
            var oldId = this.get("oldPid");

            if (!oldId) return;

            //Find references to the old id in the documents relationship
            var outdatedModels = collection.filter(function (m) {
              return _.contains(m.get("documents"), oldId);
            });

            //Update the documents array in each model
            _.each(
              outdatedModels,
              function (model) {
                var updatedDocuments = _.without(model.get("documents"), oldId);
                updatedDocuments.push(this.get("id"));

                model.set("documents", updatedDocuments);
              },
              this,
            );
          },
          this,
        );
      },

      /**
           * Finds the latest version of this object by travesing the obsolescence chain
           * @param {string} [latestVersion] - The identifier of the latest known object in the version chain.
             If not supplied, this model's `id` will be used.
           * @param {string} [possiblyNewer] - The identifier of the object that obsoletes the latestVersion. It's "possibly" newer, because it may be private/inaccessible
           */
      findLatestVersion: function (latestVersion, possiblyNewer) {
        var baseUrl = "",
          activeAltRepo = MetacatUI.appModel.getActiveAltRepo();
        //Use the meta service URL from the alt repo
        if (activeAltRepo) {
          baseUrl = activeAltRepo.metaServiceUrl;
        }
        //If this MetacatUI deployment is pointing to a MN, use the meta service URL from the AppModel
        else {
          baseUrl = MetacatUI.appModel.get("metaServiceUrl");
        }

        if (!baseUrl) {
          return;
        }

        //If there is no system metadata, then retrieve it first
        if (!this.get("sysMetaXML")) {
          this.once("sync", this.findLatestVersion);
          this.once("systemMetadataSync", this.findLatestVersion);
          this.fetch({
            url: baseUrl + encodeURIComponent(this.get("id")),
            dataType: "text",
            systemMetadataOnly: true,
          });
          return;
        }

        //If no pid was supplied, use this model's id
        if (!latestVersion || typeof latestVersion != "string") {
          var latestVersion = this.get("id");
          var possiblyNewer = this.get("obsoletedBy");
        }

        //If this isn't obsoleted by anything, then there is no newer version
        if (!possiblyNewer || typeof latestVersion != "string") {
          this.set("latestVersion", latestVersion);

          //Trigger an event that will fire whether or not the latestVersion
          // attribute was actually changed
          this.trigger("latestVersionFound", this);

          //Remove the listeners now that we found the latest version
          this.stopListening("sync", this.findLatestVersion);
          this.stopListening("systemMetadataSync", this.findLatestVersion);

          return;
        }

        var model = this;

        //Get the system metadata for the possibly newer version
        var requestSettings = {
          url: baseUrl + encodeURIComponent(possiblyNewer),
          type: "GET",
          success: function (data) {
            // the response may have an obsoletedBy element
            var obsoletedBy = $(data).find("obsoletedBy").text();

            //If there is an even newer version, then get it and rerun this function
            if (obsoletedBy) {
              model.findLatestVersion(possiblyNewer, obsoletedBy);
            }
            //If there isn't a newer version, then this is it
            else {
              model.set("latestVersion", possiblyNewer);
              model.trigger("latestVersionFound", model);

              //Remove the listeners now that we found the latest version
              model.stopListening("sync", model.findLatestVersion);
              model.stopListening(
                "systemMetadataSync",
                model.findLatestVersion,
              );
            }
          },
          error: function (xhr) {
            //If this newer version isn't accessible, link to the latest version that is
            if (xhr.status == "401") {
              model.set("latestVersion", latestVersion);
              model.trigger("latestVersionFound", model);
            }
          },
        };

        $.ajax(
          _.extend(
            requestSettings,
            MetacatUI.appUserModel.createAjaxSettings(),
          ),
        );
      },

      /**
       * A utility function that will format an XML string or XML nodes by camel-casing the node names, as necessary
       * @param {string|Element} xml - The XML to format
       * @returns {string} The formatted XML string
       */
      formatXML: function (xml) {
        var nodeNameMap = this.nodeNameMap(),
          xmlString = "";

        //XML must be provided for this function
        if (!xml) return "";
        //Support XML strings
        else if (typeof xml == "string") xmlString = xml;
        //Support DOMs
        else if (typeof xml == "object" && xml.nodeType) {
          //XML comments should be formatted with start and end carets
          if (xml.nodeType == 8) xmlString = "<" + xml.nodeValue + ">";
          //XML nodes have the entire XML string available in the outerHTML attribute
          else if (xml.nodeType == 1) xmlString = xml.outerHTML;
          //Text node types are left as-is
          else if (xml.nodeType == 3) return xml.nodeValue;
        }

        //Return empty strings if something went wrong
        if (!xmlString) return "";

        _.each(
          Object.keys(nodeNameMap),
          function (name, i) {
            var originalXMLString = xmlString;

            //Check for this node name whe it's an opening XML node, e.g. `<name>`
            var regEx = new RegExp("<" + name + ">", "g");
            xmlString = xmlString.replace(regEx, "<" + nodeNameMap[name] + ">");

            //Check for this node name when it's an opening XML node, e.g. `<name `
            regEx = new RegExp("<" + name + " ", "g");
            xmlString = xmlString.replace(regEx, "<" + nodeNameMap[name] + " ");

            //Check for this node name when it's preceeded by a namespace, e.g. `:name `
            regEx = new RegExp(":" + name + " ", "g");
            xmlString = xmlString.replace(regEx, ":" + nodeNameMap[name] + " ");

            //Check for this node name when it's a closing tag preceeded by a namespace, e.g. `:name>`
            regEx = new RegExp(":" + name + ">", "g");
            xmlString = xmlString.replace(regEx, ":" + nodeNameMap[name] + ">");

            //Check for this node name when it's a closing XML tag, e.g. `</name>`
            regEx = new RegExp("</" + name + ">", "g");
            xmlString = xmlString.replace(
              regEx,
              "</" + nodeNameMap[name] + ">",
            );

            //If node names haven't been changed, then find an attribute, e.g. ` name=`
            if (xmlString == originalXMLString) {
              regEx = new RegExp(" " + name + "=", "g");
              xmlString = xmlString.replace(
                regEx,
                " " + nodeNameMap[name] + "=",
              );
            }
          },
          this,
        );

        //Take each XML node text value and decode any XML entities
        var regEx = new RegExp("&[0-9a-zA-Z]+;", "g");
        xmlString = xmlString.replace(regEx, function (match) {
          return he.encode(he.decode(match));
        });

        return xmlString;
      },

      /**
       * Converts the number of bytes into a human readable format and
       * updates the `sizeStr` attribute
       * @returns: None
       *
       */
      bytesToSize: function () {
        var kibibyte = 1024;
        var mebibyte = kibibyte * 1024;
        var gibibyte = mebibyte * 1024;
        var tebibyte = gibibyte * 1024;
        var precision = 0;

        var bytes = this.get("size");

        if (bytes >= 0 && bytes < kibibyte) {
          this.set("sizeStr", bytes + " B");
        } else if (bytes >= kibibyte && bytes < mebibyte) {
          this.set("sizeStr", (bytes / kibibyte).toFixed(precision) + " KiB");
        } else if (bytes >= mebibyte && bytes < gibibyte) {
          precision = 2;
          this.set("sizeStr", (bytes / mebibyte).toFixed(precision) + " MiB");
        } else if (bytes >= gibibyte && bytes < tebibyte) {
          precision = 2;
          this.set("sizeStr", (bytes / gibibyte).toFixed(precision) + " GiB");
        } else if (bytes >= tebibyte) {
          precision = 2;
          this.set("sizeStr", (bytes / tebibyte).toFixed(precision) + " TiB");
        } else {
          this.set("sizeStr", bytes + " B");
        }
      },

      /**
       * This method will download this object while
       * sending the user's auth token in the request.
       * @returns None
       * @since: 2.28.0
       */
      downloadWithCredentials: function () {
        //if(this.get("isPublic")) return;

        //Get info about this object
        var url = this.get("url"),
          model = this;

        //Create an XHR
        var xhr = new XMLHttpRequest();

        //Open and send the request with the user's auth token
        xhr.open("GET", url);

        if (MetacatUI.appUserModel.get("loggedIn")) xhr.withCredentials = true;

        //When the XHR is ready, create a link with the raw data (Blob) and click the link to download
        xhr.onload = function () {
          if (this.status == 404) {
            this.onerror.call(this);
            return;
          }

          //Get the file name to save this file as
          var filename = xhr.getResponseHeader("Content-Disposition");

          if (!filename) {
            filename =
              model.get("fileName") ||
              model.get("title") ||
              model.get("id") ||
              "download";
          } else
            filename = filename
              .substring(filename.indexOf("filename=") + 9)
              .replace(/"/g, "");

          //Replace any whitespaces
          filename = filename.trim().replace(/ /g, "_");

          //For IE, we need to use the navigator API
          if (navigator && navigator.msSaveOrOpenBlob) {
            navigator.msSaveOrOpenBlob(xhr.response, filename);
          }
          //Other browsers can download it via a link
          else {
            var a = document.createElement("a");
            a.href = window.URL.createObjectURL(xhr.response); // xhr.response is a blob

            // Set the file name.
            a.download = filename;

            a.style.display = "none";
            document.body.appendChild(a);
            a.click();
            a.remove();
          }

          model.trigger("downloadComplete");

          // Track this event
          MetacatUI.analytics?.trackEvent(
            "download",
            "Download DataONEObject",
            model.get("id"),
          );
        };

        xhr.onerror = function (e) {
          model.trigger("downloadError");

          // Track the error
          MetacatUI.analytics?.trackException(
            `Download DataONEObject error: ${e || ""}`,
            model.get("id"),
            true,
          );
        };

        xhr.onprogress = function (e) {
          if (e.lengthComputable) {
            var percent = (e.loaded / e.total) * 100;
            model.set("downloadPercent", percent);
          }
        };

        xhr.responseType = "blob";

        if (MetacatUI.appUserModel.get("loggedIn"))
          xhr.setRequestHeader(
            "Authorization",
            "Bearer " + MetacatUI.appUserModel.get("token"),
          );

        xhr.send();
      },

      /**
       * Creates a file name for this DataONEObject and updates the `fileName` attribute
       */
      setMissingFileName: function () {
        var objectFormats, filename, extension;

        objectFormats = MetacatUI.objectFormats.where({
          formatId: this.get("formatId"),
        });
        if (objectFormats.length > 0) {
          extension = objectFormats[0].get("extension");
        }

        //Science metadata file names will use the title
        if (this.get("type") == "Metadata") {
          filename =
            Array.isArray(this.get("title")) && this.get("title").length
              ? this.get("title")[0]
              : this.get("id");
        }
        //Resource maps will use a "resource_map_" prefix
        else if (this.get("type") == "DataPackage") {
          filename = "resource_map_" + this.get("id");
          extension = ".rdf.xml";
        }
        //All other object types will just use the id
        else {
          filename = this.get("id");
        }

        //Replace all non-alphanumeric characters with underscores
        filename = filename.replace(/[^a-zA-Z0-9]/g, "_");

        if (typeof extension !== "undefined") {
          filename = filename + "." + extension;
        }

        this.set("fileName", filename);
      },

      /**
       * Creates a URL for viewing more information about this object
       * @return {string}
       */
      createViewURL: function () {
        return (
          MetacatUI.root +
          "/view/" +
          encodeURIComponent(this.get("seriesId") || this.get("id"))
        );
      },

      /**
       * Check if the seriesID or PID matches a DOI regex, and if so, return
       * a canonical IRI for the DOI.
       * @return {string|null} - The canonical IRI for the DOI, or null if
       * neither the seriesId nor the PID match a DOI regex.
       * @since 2.26.0
       */
      getCanonicalDOIIRI: function () {
        const id = this.get("id");
        const seriesId = this.get("seriesId");
        let DOI = null;
        if (this.isDOI(seriesId)) DOI = seriesId;
        else if (this.isDOI(id)) DOI = id;
        return MetacatUI.appModel.DOItoURL(DOI);
      },

      /**
       * Converts the identifier string to a string safe to use in an XML id attribute
       * @param {string} [id] - The ID string
       * @return {string} - The XML-safe string
       */
      getXMLSafeID: function (id) {
        if (typeof id == "undefined") {
          var id = this.get("id");
        }

        //Replace XML id attribute invalid characters and patterns in the identifier
        id = id
          .replace(/</g, "-")
          .replace(/:/g, "-")
          .replace(/&[a-zA-Z0-9]+;/g);

        return id;
      },

      /**** Provenance-related functions ****/
      /**
       * Returns true if this provenance field points to a source of this data or metadata object
       * @param {string} field
       * @returns {boolean}
       */
      isSourceField: function (field) {
        if (typeof field == "undefined" || !field) return false;
        // Is the field we are checking a prov field?
        if (!_.contains(MetacatUI.appSearchModel.getProvFields(), field))
          return false;

        if (
          field == "prov_generatedByExecution" ||
          field == "prov_generatedByProgram" ||
          field == "prov_used" ||
          field == "prov_wasDerivedFrom" ||
          field == "prov_wasInformedBy"
        )
          return true;
        else return false;
      },

      /**
       * Returns true if this provenance field points to a derivation of this data or metadata object
       * @param {string} field
       * @returns {boolean}
       */
      isDerivationField: function (field) {
        if (typeof field == "undefined" || !field) return false;
        if (!_.contains(MetacatUI.appSearchModel.getProvFields(), field))
          return false;

        if (
          field == "prov_usedByExecution" ||
          field == "prov_usedByProgram" ||
          field == "prov_hasDerivations" ||
          field == "prov_generated"
        )
          return true;
        else return false;
      },

      /**
       * Returns a plain-english version of the general format - either image, program, metadata, PDF, annotation or data
       */
      getType: function () {
        //The list of formatIds that are images

        //The list of formatIds that are images
        var pdfIds = ["application/pdf"];
        var annotationIds = [
          "http://docs.annotatorjs.org/en/v1.2.x/annotation-format.html",
        ];

        // Type has already been set, use that.
        if (this.get("type").toLowerCase() == "metadata") return "metadata";

        //Determine the type via provONE
        var instanceOfClass = this.get("prov_instanceOfClass");
        if (
          typeof instanceOfClass !== "undefined" &&
          Array.isArray(instanceOfClass) &&
          instanceOfClass.length
        ) {
          var programClass = _.filter(instanceOfClass, function (className) {
            return className.indexOf("#Program") > -1;
          });
          if (typeof programClass !== "undefined" && programClass.length)
            return "program";
        } else {
          if (this.get("prov_generated").length || this.get("prov_used").length)
            return "program";
        }

        //Determine the type via file format
        if (this.isSoftware()) return "program";
        if (this.isData()) return "data";

        if (this.get("type").toLowerCase() == "metadata") return "metadata";
        if (this.isImage()) return "image";
        if (_.contains(pdfIds, this.get("formatId"))) return "PDF";
        if (_.contains(annotationIds, this.get("formatId")))
          return "annotation";
        else return "data";
      },

      /**
       * Checks the formatId of this model and determines if it is an image.
       * @returns {boolean} true if this data object is an image, false if it is other
       */
      isImage: function () {
        //The list of formatIds that are images
        var imageIds = ["image/gif", "image/jp2", "image/jpeg", "image/png"];

        //Does this data object match one of these IDs?
        if (_.indexOf(imageIds, this.get("formatId")) == -1) return false;
        else return true;
      },

      /**
       * Checks the formatId of this model and determines if it is a data file.
       * This determination is mostly used for display and the provenance editor. In the
       * DataONE API, many formatIds are considered `DATA` formatTypes, but they are categorized
       * as images {@link DataONEObject#isImage} or software {@link DataONEObject#isSoftware}.
       * @returns {boolean} true if this data object is a data file, false if it is other
       */
      isData: function () {
        var dataIds = [
          "application/atom+xml",
          "application/mathematica",
          "application/msword",
          "application/netcdf",
          "application/octet-stream",
          "application/pdf",
          "application/postscript",
          "application/rdf+xml",
          "application/rtf",
          "application/vnd.google-earth.kml+xml",
          "application/vnd.ms-excel",
          "application/vnd.ms-excel.sheet.binary.macroEnabled.12",
          "application/vnd.ms-powerpoint",
          "application/vnd.openxmlformats-officedocument.presentationml.presentation",
          "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
          "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
          "application/x-bzip2",
          "application/x-fasta",
          "application/x-gzip",
          "application/x-rar-compressed",
          "application/x-tar",
          "application/xhtml+xml",
          "application/xml",
          "application/zip",
          "audio/mpeg",
          "audio/x-ms-wma",
          "audio/x-wav",
          "image/svg xml",
          "image/svg+xml",
          "image/bmp",
          "image/tiff",
          "text/anvl",
          "text/csv",
          "text/html",
          "text/n3",
          "text/plain",
          "text/tab-separated-values",
          "text/turtle",
          "text/xml",
          "video/avi",
          "video/mp4",
          "video/mpeg",
          "video/quicktime",
          "video/x-ms-wmv",
        ];

        //Does this data object match one of these IDs?
        if (_.indexOf(dataIds, this.get("formatId")) == -1) return false;
        else return true;
      },

      /**
       * Checks the formatId of this model and determines if it is a software file.
       * This determination is mostly used for display and the provenance editor. In the
       * DataONE API, many formatIds are considered `DATA` formatTypes, but they are categorized
       * as images {@link DataONEObject#isImage} for display purposes.
       * @returns {boolean} true if this data object is a software file, false if it is other
       */
      isSoftware: function () {
        //The list of formatIds that are programs
        var softwareIds = [
          "text/x-python",
          "text/x-rsrc",
          "text/x-matlab",
          "text/x-sas",
          "application/R",
          "application/x-ipynb+json",
        ];
        //Does this data object match one of these IDs?
        if (_.indexOf(softwareIds, this.get("formatId")) == -1) return false;
        else return true;
      },

      /**
       * Checks the formatId of this model and determines if it a PDF.
       * @returns {boolean} true if this data object is a pdf, false if it is other
       */
      isPDF: function () {
        //The list of formatIds that are images
        var ids = ["application/pdf"];

        //Does this data object match one of these IDs?
        if (_.indexOf(ids, this.get("formatId")) == -1) return false;
        else return true;
      },

      /**
       * Set the DataONE ProvONE provenance class
       * param className - the shortened form of the actual classname value. The
       * shortname will be appened to the ProvONE namespace, for example,
       * the className "program" will result in the final class name
       * "http://purl.dataone.org/provone/2015/01/15/ontology#Program"
       * see https://github.com/DataONEorg/sem-prov-ontologies/blob/master/provenance/ProvONE/v1/provone.html
       * @param {string} className
       */
      setProvClass: function (className) {
        className = className.toLowerCase();
        className = className.charAt(0).toUpperCase() + className.slice(1);

        /* This function is intended to be used for the ProvONE classes that are
         * typically represented in DataONEObjects: "Data", "Program", and hopefully
         * someday "Execution", as we don't allow the user to set the namespace
         * e.g. to "PROV", so therefor we check for the currently known ProvONE classes.
         */
        if (
          _.contains(
            [
              "Program",
              "Data",
              "Visualization",
              "Document",
              "Execution",
              "User",
            ],
            className,
          )
        ) {
          this.set("prov_instanceOfClass", [this.PROVONE + className]);
        } else if (
          _.contains(
            ["Entity", "Usage", "Generation", "Association"],
            className,
          )
        ) {
          this.set("prov_instanceOfClass", [this.PROV + className]);
        } else {
          message =
            "The given class name: " +
            className +
            " is not in the known ProvONE or PROV classes.";
          throw new Error(message);
        }
      },

      /**
       *  Calculate a checksum for the object
       *  @param {string} [algorithm]  The algorithm to use, defaults to MD5
       *  @return {string} A checksum plain JS object with value and algorithm attributes
       */
      calculateChecksum: function (algorithm) {
        var algorithm = algorithm || "MD5";
        var checksum = { algorithm: undefined, value: undefined };
        var hash; // The checksum hash
        var file; // The file to be read by slicing
        var reader; // The FileReader used to read each slice
        var offset = 0; // Byte offset for reading slices
        var sliceSize = Math.pow(2, 20); // 1MB slices
        var model = this;

        // Do we have a file?
        if (this.get("uploadFile") instanceof Blob) {
          file = this.get("uploadFile");
          reader = new FileReader();
          /* Handle load errors */
          reader.onerror = function (event) {
            console.log("Error reading: " + event);
          };
          /* Show progress */
          reader.onprogress = function (event) {};
          /* Handle load finish */
          reader.onloadend = function (event) {
            if (event.target.readyState == FileReader.DONE) {
              hash.update(event.target.result);
            }
            offset += sliceSize;
            if (_seek()) {
              model.set("checksum", hash.hex());
              model.set("checksumAlgorithm", checksum.algorithm);
              model.trigger("checksumCalculated", model.attributes);
            }
          };
        } else {
          message = "The given object is not a blob or a file object.";
          throw new Error(message);
        }

        switch (algorithm) {
          case "MD5":
            checksum.algorithm = algorithm;
            hash = md5.create();
            _seek();
            break;
          case "SHA-1":
          // TODO: Support SHA-1
          // break;
          default:
            message =
              "The given algorithm: " + algorithm + " is not supported.";
            throw new Error(message);
        }

        /*
         *  A helper function internal to calculateChecksum() used to slice
         *  the file at the next offset by slice size
         */
        function _seek() {
          var calculated = false;
          var slice;
          // Digest the checksum when we're done calculating
          if (offset >= file.size) {
            hash.digest();
            calculated = true;
            return calculated;
          }
          // slice the file and read the slice
          slice = file.slice(offset, offset + sliceSize);
          reader.readAsArrayBuffer(slice);
          return calculated;
        }
      },

      /**
       * Checks if the pid or sid or given string is a DOI
       *
       * @param {string} customString - Optional. An identifier string to check instead of the id and seriesId attributes on the model
       * @returns {boolean} True if it is a DOI
       */
      isDOI: function (customString) {
        return (
          isDOI(customString) ||
          isDOI(this.get("id")) ||
          isDOI(this.get("seriesId"))
        );
      },

      /**
       * Creates an array of objects that represent Member Nodes that could possibly be this
       * object's authoritative MN. This function updates the `possibleAuthMNs` attribute on this model.
       */
      setPossibleAuthMNs: function () {
        //Only do this for Coordinating Node MetacatUIs.
        if (MetacatUI.appModel.get("alternateRepositories").length) {
          //Set the possibleAuthMNs attribute
          var possibleAuthMNs = [];

          //If a datasource is already found for this Portal, move that to the top of the list of auth MNs
          var datasource = this.get("datasource") || "";
          if (datasource) {
            //Find the MN object that matches the datasource node ID
            var datasourceMN = _.findWhere(
              MetacatUI.appModel.get("alternateRepositories"),
              { identifier: datasource },
            );
            if (datasourceMN) {
              //Clone the MN object and add it to the array
              var clonedDatasourceMN = Object.assign({}, datasourceMN);
              possibleAuthMNs.push(clonedDatasourceMN);
            }
          }

          //If there is an active alternate repo, move that to the top of the list of auth MNs
          var activeAltRepo =
            MetacatUI.appModel.get("activeAlternateRepositoryId") || "";
          if (activeAltRepo) {
            var activeAltRepoMN = _.findWhere(
              MetacatUI.appModel.get("alternateRepositories"),
              { identifier: activeAltRepo },
            );
            if (activeAltRepoMN) {
              //Clone the MN object and add it to the array
              var clonedActiveAltRepoMN = Object.assign({}, activeAltRepoMN);
              possibleAuthMNs.push(clonedActiveAltRepoMN);
            }
          }

          //Add all the other alternate repositories to the list of auth MNs
          var otherPossibleAuthMNs = _.reject(
            MetacatUI.appModel.get("alternateRepositories"),
            function (mn) {
              return (
                mn.identifier == datasource || mn.identifier == activeAltRepo
              );
            },
          );
          //Clone each MN object and add to the array
          _.each(otherPossibleAuthMNs, function (mn) {
            var clonedMN = Object.assign({}, mn);
            possibleAuthMNs.push(clonedMN);
          });

          //Update this model
          this.set("possibleAuthMNs", possibleAuthMNs);
        }
      },

      /**
       * Removes white space from string values returned by Solr when the white space causes issues.
       * For now this only effects the `resourceMap` field, which will index new line characters and spaces
       * when the RDF XML has those in the `identifier` XML element content. This was causing bugs where DataONEObject
       * models were created with `id`s with new line and white space characters (e.g. `\n urn:uuid:1234...`)
       * @param {object} json - The Solr document as a JS Object, which will be directly altered
       */
      removeWhiteSpaceFromSolrFields: function (json) {
        if (typeof json.resourceMap == "string") {
          json.resourceMap = json.resourceMap.trim();
        } else if (Array.isArray(json.resourceMap)) {
          let newResourceMapIds = [];
          _.each(json.resourceMap, function (rMapId) {
            if (typeof rMapId == "string") {
              newResourceMapIds.push(rMapId.trim());
            }
          });
          json.resourceMap = newResourceMapIds;
        }
      },
    },

    /** @lends DataONEObject.prototype */
    {
      /**
       * Generate a unique identifier to be used as an XML id attribute
       * @returns {string} The identifier string that was generated
       */
      generateId: function () {
        var idStr = ""; // the id to return
        var length = 30; // the length of the generated string
        var chars =
          "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz".split(
            "",
          );

        for (var i = 0; i < length; i++) {
          idStr += chars[Math.floor(Math.random() * chars.length)];
        }
        return idStr;
      },
    },
  );

  return DataONEObject;
});