Source: src/js/models/DataONEObject.js

/* global define */
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;
            }
            else
                // Default to returning the raw response
                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;
});