Source: src/js/models/portals/PortalModel.js

/**
 * @exports PortalModel
 */
/* global define */
define(["jquery",
        "underscore",
        "backbone",
        "gmaps",
        "uuid",
        "collections/Filters",
        "collections/SolrResults",
        "models/filters/Filter",
        "models/portals/PortalSectionModel",
        "models/portals/PortalVizSectionModel",
        "models/portals/PortalImage",
        "models/metadata/eml211/EMLParty",
        "models/metadata/eml220/EMLText",
        "models/CollectionModel",
        "models/Search",
        "models/filters/FilterGroup",
        "models/Map",
    ],
    function($, _, Backbone, gmaps, uuid, Filters, SolrResults, FilterModel,
        PortalSectionModel, PortalVizSectionModel, PortalImage,
        EMLParty, EMLText, CollectionModel, SearchModel, FilterGroup, MapModel ) {
        /**
         * @classdesc A PortalModel is a specialized collection that represents a portal,
         * including the associated data, people, portal descriptions, results and
         * visualizations.  It also includes settings for customized filtering of the
         * associated data, and properties used to customized the map display and the
         * overall branding of the portal.
         *
         * @class PortalModel
         * @classcategory Models/Portals
         * @extends CollectionModel
         * @module models/PortalModel
         * @name PortalModel
         * @constructor
        */
        var PortalModel = CollectionModel.extend(
            /** @lends PortalModel.prototype */{

            /**
            * The name of this type of model
            * @type {string}
            */
            type: "Portal",

            /**
             * Overrides the default Backbone.Model.defaults() function to
             * specify default attributes for the portal model
            * @type {object}
            */
            defaults: function() {
                return _.extend(CollectionModel.prototype.defaults(), {
                    id: null,
                    objectXML: null,
                    formatId: MetacatUI.appModel.get("portalEditorSerializationFormat"),
                    formatType: "METADATA",
                    type: "portal",
                    //Is true if the last fetch was sent with user credentials. False if not.
                    fetchedWithAuth: null,
                    logo: null,
                    sections: [],
                    associatedParties: [],
                    acknowledgments: null,
                    acknowledgmentsLogos: [],
                    awards: [],
                    checkedNodeLabels: false,
                    labelDoubleChecked: false,
                    literatureCited: [],
                    filterGroups: [],
                    createSeriesId: true, //If true, a seriesId will be created when this object is saved.
                    // The portal document options may specify section to hide
                    edit: false, // Set to true if this model is being used in a portal editor view
                    hideMetrics: null,
                    hideData: null,
                    hideMembers: null,
                    hideMap: null,
                    // List of section labels indicating the order in which to display the sections.
                    // Labels must exactly match the labels set on sections, or the values set on the
                    // metricsLabel, dataLabel, and membersLabel options.
                    pageOrder: null,
                    //Options for the custom section labels
                    //NOTE: This are not fully supported yet.
                    metricsLabel: "Metrics",
                    dataLabel: "Data",
                    membersLabel: "Members",
                    // Map options, as specified in the portal document options
                    mapZoomLevel: 3,
                    mapCenterLatitude: null,
                    mapCenterLongitude: null,
                    mapShapeHue: 200,
                    // The MapModel
                    mapModel: gmaps ? new MapModel() : null,
                    optionNames: ["primaryColor", "secondaryColor", "accentColor",
                            "mapZoomLevel", "mapCenterLatitude", "mapCenterLongitude",
                            "mapShapeHue", "hideData", "hideMetrics", "hideMembers",
                            "pageOrder", "layout", "theme"
                    ],
                    // Portal view colors, as specified in the portal document options
                    primaryColor: MetacatUI.appModel.get("portalDefaults").primaryColor || "#006699",
                    secondaryColor: MetacatUI.appModel.get("portalDefaults").secondaryColor || "#009299",
                    accentColor: MetacatUI.appModel.get("portalDefaults").accentColor || "#f89406",
                    primaryColorRGB: null,
                    secondaryColorRGB: null,
                    accentColorRGB: null,
                    primaryColorTransparent: MetacatUI.appModel.get("portalDefaults").primaryColorTransparent || "rgba(0, 102, 153, .7)",
                    secondaryColorTransparent: MetacatUI.appModel.get("portalDefaults").secondaryColorTransparent || "rgba(0, 146, 153, .7)",
                    accentColorTransparent: MetacatUI.appModel.get("portalDefaults").accentColorTransparent || "rgba(248, 148, 6, .7)",
                    theme: null,
                    layout: null
                });
            },

            /**
             * The default text to use for a new section label added by the user
             * @type {string}
            */
            newSectionLabel: "Untitled",

            /**
             * Overrides the default Backbone.Model.initialize() function to
             * provide some custom initialize options
             *
             * @param {} options -
            */
            initialize: function(attrs) {

              //Call the super class initialize function
              CollectionModel.prototype.initialize.call(this, attrs);

              // Generate transparent colours from the primary, secondary, and accent colors
              // TODO

              if( attrs.isNew ){
                this.set("synced", true);
                //Create an isPartOf filter for this new Portal
                this.addIsPartOfFilter();

                var model = this;

                // Insert new sections if any are set in the appModel

                var portalDefaults = MetacatUI.appModel.get("portalDefaults"),
                    defaultSections = portalDefaults ? portalDefaults.sections : [];

                if(defaultSections && defaultSections.length && Array.isArray(defaultSections)){
                  defaultSections.forEach(function(section, index){
                    // If there is at least one section default set...
                    if(section.title || section.label){
                      var newDefaultSection = new PortalSectionModel({
                        title: section.title || "",
                        label: section.label || this.newSectionLabel,
                        // Set a default image on new markdown sections
                        image: model.getRandomSectionImage(),
                        portalModel: model
                      });
                      model.addSection(newDefaultSection);
                    }
                  });
                }
              }

              // check for info received from Bookkeeper
              if( MetacatUI.appModel.get("enableBookkeeperServices") ){

                this.listenTo( MetacatUI.appUserModel, "change:dataoneSubscription", function(){
                  if(MetacatUI.appUserModel.get("dataoneSubscription").isTrialing()) {
                    this.setRandomLabel();
                  }
                });

                //Fetch the user subscription info
                MetacatUI.appUserModel.fetchSubscription();
              }

              // Cache this model for later use
              this.cachePortal();

            },

            /**
             * getRandomSectionImage - Using the list of image identifiers set
             * in the app config, select an image to use for a portal section.
             * The function will not return the same image until all the images
             * have been returned at least once. If an image would return a 404
             * error, it is skipped. If all images give 404s, an empty string
             * is returned.
             *
             * @return {PortalImage}  A portal image model to use in a section model
             */
            getRandomSectionImage: function(){

              // This variable will hold the section image to return, if any
              var newSectionImage = "",
                  // The default portal values set in the config
                  portalDefaults = MetacatUI.appModel.get("portalDefaults"),
                  // Check if default images are set on the model already
                  defaultImageIds = this.get("defaultSectionImageIds"),
                  // Keep track of where we are in the list of default images,
                  // so there's not too much repetition
                  runningNumber = this.get("defaultImageRunningNumber") || 0;

              // If none are set, get the configured default image IDs,
              // shuffle them, and set them on the model.
              if(!defaultImageIds || !defaultImageIds.length){

                // Get the list of default section image IDs from the appModel
                defaultImageIds = portalDefaults ? portalDefaults.sectionImageIdentifiers : false;

                // If some are configured...
                if(defaultImageIds && defaultImageIds.length){
                  // ...Shuffle the images...
                  for (let i = defaultImageIds.length - 1; i > 0; i--) {
                    let j = Math.floor(Math.random() * (i + 1));
                    [defaultImageIds[i], defaultImageIds[j]] = [defaultImageIds[j], defaultImageIds[i]];
                  }
                  // ... and save the shuffled list to the portal model
                  this.set("defaultSectionImageIds", defaultImageIds);
                }
              }

              // Can't get a random image if none are configured
              if(!defaultImageIds){
                console.log("Can't set a default image on new markdown sections because there are no default image IDs set. Check portalDefaults.sectionImageIdentifiers in the config file.");
                return
              }

              // Select one of the image IDs
              if(defaultImageIds && defaultImageIds.length > 0){

                if(runningNumber >= defaultImageIds.length){
                  runningNumber = 0
                }

                // Go through the shuffled array of image IDs in order
                for (i = runningNumber; i < defaultImageIds.length; i++) {

                  // Skip images that have already returned 404 errors
                  if(defaultImageIds[i] == "NOT FOUND"){
                    continue;
                  }

                  // Section images are PortalImage models
                  var newSectionImage = new PortalImage({
                    identifier: defaultImageIds[i],
                    portalModel: this.get("portalModel")
                  });

                  // Skip adding an image if it doesn't exist given the identifer and baseUrl found in the image model
                  if(newSectionImage.imageExists()){
                    break;
                  // If the image doesn't exist, mark it so we don't have to
                  // check again next time
                  } else {
                    defaultImageIds[i] = "NOT FOUND";
                    newSectionImage = "";
                  }
                }
              }

              this.set("defaultImageRunningNumber", i + 1);
              this.set("defaultSectionImageIds", defaultImageIds);

              return newSectionImage
            },

            /**
             * Returns the portal URL
             *
             * @return {string} The portal URL
            */
            url: function() {

              //Start the base URL string
              // use the resolve service if there is no object service url
              // (e.g. in DataONE theme)
              var urlBase = MetacatUI.appModel.get("objectServiceUrl") ||
                MetacatUI.appModel.get("resolveServiceUrl");

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

              if( activeAltRepo ){
                urlBase = activeAltRepo.objectServiceUrl;
              }

              //If this object is being updated, use the old pid in the URL
              if ( !this.isNew() && this.get("oldPid") ) {
                return urlBase +
                    encodeURIComponent(this.get("oldPid"));
              }
              //If this object is new, use the new pid in the URL
              else {
                return urlBase +
                    encodeURIComponent(this.get("seriesId") || this.get("id"));
              }
            },

            /**
             * Overrides the default Backbone.Model.fetch() function to provide some custom
             * fetch options
             * @param [options] {object} - Options for this fetch
             * @property [options.objectOnly] {Boolean} - If true, only the object will be retrieved and not the system metadata
             * @property [options.systemMetadataOnly] {Boolean} - If true, only the system metadata will be retrieved
             * @return {XMLDocument} The XMLDocument returned from the fetch() AJAX call
            */
            fetch: function(options) {

              if ( ! options ) var options = {};
              else var options = _.clone(options);

              //If the seriesId has not been found yet, get it from Solr
              if( !this.get("id") && !this.get("seriesId") && this.get("label") ){

                this.once("change:seriesId", function(){
                  this.fetch(options)
                });
                this.once("latestVersionFound", function(){
                  this.fetch(options)
                });

                //Get the series ID of this object
                this.getSeriesIdByLabel();

                return;
              }
              //If we found the latest version in this pid version chain,
              else if( this.get("id") && this.get("latestVersion") ){
                //Set it as the id of this model
                this.set("id", this.get("latestVersion"));

                //Stop listening to the change of seriesId and the latest version found
                this.stopListening("change:seriesId", this.fetch);
                this.stopListening("latestVersionFound", this.fetch);
              }

              //If this MetacatUI instance is pointing to a CN, use the origin MN
              // to fetch the Portal, if available as an alt repo.
              if( MetacatUI.appModel.get("isCN") && this.get("datasource") ){
                //Check if the origin MN (datasource) is an alt repo option
                var altRepo = _.findWhere(MetacatUI.appModel.get("alternateRepositories"), { identifier: this.get("datasource") });

                if( altRepo ){
                  //Set the origin MN (datasource) as the active alt repo
                  MetacatUI.appModel.set("activeAlternateRepositoryId", this.get("datasource"));
                }

              }

              //Fetch the system metadata
              if( !options.objectOnly || options.systemMetadataOnly ){
                this.fetchSystemMetadata();

                if( options.systemMetadataOnly ){
                  return;
                }
              }

              var requestSettings = {
                  dataType: "xml",
                  error: function(model, response) {

                      model.trigger("error", model, response);

                      if( response && response.status == 404 ){
                        model.trigger("notFound");
                      }
                  }
              };

              //Save a boolean flag for whether or not this fetch was done with user authentication.
              //This is helpful when the app is dealing with potentially private data
              this.set("fetchedWithAuth", MetacatUI.appUserModel.get("loggedIn"));

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

              // Call Backbone.Model.fetch()
              return Backbone.Model.prototype.fetch.call(this, requestSettings);

            },

            /**
            * Get the portal seriesId by searching for the portal by its label in Solr
            */
            getSeriesIdByLabel: function(){

              //Exit if there is no portal name set
              if( !this.get("label") )
                return;

              var model = this;

              //Start the base URL for the query service
              var baseUrl = "";

              try{
                //If this app instance is pointing to the CN, find the Portal series ID on the MN
                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 CN query service
                  if( !possibleAuthMNs.length ){
                    baseUrl = MetacatUI.appModel.get("queryServiceUrl");
                  }
                  else{
                    baseUrl = possibleAuthMNs[0].queryServiceUrl;
                  }

                }
                else{
                  //Get the query service URL
                  baseUrl = MetacatUI.appModel.get("queryServiceUrl");
                }
              }
              catch(e){
                console.error("Error in trying to determine the query service URL. Going to try to use the AppModel setting. ", e);
              }
              finally{
                //Default to the query service URL configured in the AppModel, if one wasn't set earlier
                if( !baseUrl ){
                  baseUrl = MetacatUI.appModel.get("queryServiceUrl");
                  //If there isn't a query service URL, trigger a "not found" error and exit
                  if( !baseUrl ){
                    this.trigger("notFound");
                    return;
                  }
                }
              }

              var requestSettings = {
                  url: baseUrl +
                       "q=label:\"" + this.get("label") + "\" OR " +
                       "seriesId:\"" + this.get("label") + "\"" +
                       "&fl=seriesId,id,label,datasource" +
                       "&sort=dateUploaded%20asc" +
                       "&rows=1" +
                       "&wt=json",
                  dataType: "json",
                  error: function(response) {
                      model.trigger("error", model, response);

                      if( response.status == 404 ){
                        model.trigger("notFound");
                      }
                  },
                  success: function(response){
                    if( response.response.numFound > 0 ){

                      //Set the label and datasource
                      model.set("label", response.response.docs[0].label);
                      model.set("datasource", response.response.docs[0].datasource);

                      //Save the seriesId, if one is found
                      if( response.response.docs[0].seriesId ){
                        model.set("seriesId", response.response.docs[0].seriesId);
                      }
                      //If this portal doesn't have a seriesId,
                      //but id has been found
                      else if ( response.response.docs[0].id ){
                        //Save the id
                        model.set("id", response.response.docs[0].id);

                        //Find the latest version in this version chain
                        model.findLatestVersion(response.response.docs[0].id);
                      }
                      // if we don't have Id or SeriesId
                      else {
                        model.trigger("notFound");
                      }

                    }
                    else{

                      var possibleAuthMNs = model.get("possibleAuthMNs");
                      if( possibleAuthMNs.length ){
                        //Remove the first MN from the array, since it didn't contain the Portal, so it's not the auth MN
                        possibleAuthMNs.shift();
                      }

                      //If there are no other possible auth MNs to check, trigger this Portal as Not Found.
                      if( possibleAuthMNs.length == 0 || !possibleAuthMNs ){
                        model.trigger("notFound");
                      }
                      //If there's more MNs to check, try again
                      else{
                        model.getSeriesIdByLabel();
                      }

                    }
                  }
              }

              //Save a boolean flag for whether or not this fetch was done with user authentication.
              //This is helpful when the app is dealing with potentially private data
              this.set("fetchedWithAuth", MetacatUI.appUserModel.get("loggedIn"));

              requestSettings = _.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings());

              $.ajax(requestSettings);

            },

            /**
            * This function has been renamed `getSeriesIdByLabel` and may be removed in future releases.
            * @deprecated This function has been renamed `getSeriesIdByLabel` and may be removed in future releases.
            * @see PortalModel#getSeriesIdByLabel
            */
            getSeriesIdByName: function(){ this.getSeriesIdByLabel() },

            /**
             * Overrides the default Backbone.Model.parse() function to parse the custom
             * portal XML document
             *
             * @param {XMLDocument} response - The XMLDocument returned from the fetch() AJAX call
             * @return {JSON} The result of the parsed XML, in JSON. To be set directly on the model.
            */
            parse: function(response) {

                //Start the empty JSON object
                var modelJSON = {},
                    modelRef = this,
                    portalNode;

                // Iterate over each root XML node to find the portal node
                $(response).children().each(function(i, el) {
                    if (el.tagName.indexOf("portal") > -1) {
                        portalNode = el;
                        return false;
                    }
                });

                // If a portal XML node wasn't found, return an empty JSON object
                if (typeof portalNode == "undefined" || !portalNode) {
                    return {};
                }

                // Parse the collection elements
                modelJSON = this.parseCollectionXML(portalNode);

                // Save the xml for serialize
                modelJSON.objectXML = response;

                // Parse the portal logo
                var portLogo = $(portalNode).children("logo")[0];
                if (portLogo) {
                  var portImageModel = new PortalImage({
                    objectDOM: portLogo,
                    portalModel: this
                  });
                  portImageModel.set(portImageModel.parse());
                  modelJSON.logo = portImageModel
                };

                // Parse acknowledgement logos into urls
                var logos = $(portalNode).children("acknowledgmentsLogo");
                modelJSON.acknowledgmentsLogos = [];
                _.each(logos, function(logo, i) {
                    if ( !logo ) return;

                    var imageModel = new PortalImage({
                      objectDOM: logo,
                      portalModel: this
                    });
                    imageModel.set(imageModel.parse());

                    if( imageModel.get("imageURL") ){
                      modelJSON.acknowledgmentsLogos.push( imageModel );
                    }
                }, this);

                // Parse the literature cited
                // This will only work for bibtex at the moment
                var bibtex = $(portalNode).children("literatureCited").children("bibtex");
                if (bibtex.length > 0) {
                    modelJSON.literatureCited = this.parseTextNode(portalNode, "literatureCited");
                }

                // Parse the portal content sections
                modelJSON.sections = [];
                $(portalNode).children("section").each(function(i, section){

                  //Get the section type, if there is one
                  var sectionTypeNode = $(section).find("optionName:contains(sectionType)"),
                      sectionType = "";

                  if( sectionTypeNode.length ){
                    var optionValueNode = sectionTypeNode.first().siblings("optionValue");
                    if( optionValueNode.length ){
                      sectionType = optionValueNode[0].textContent;
                    }
                  }

                  if( sectionType == "visualization" ){
                    // Create a new PortalVizSectionModel
                    modelJSON.sections.push( new PortalVizSectionModel({
                      objectDOM: section,
                      literatureCited: modelJSON.literatureCited
                    }) );
                  }
                  else{
                    // Create a new PortalSectionModel
                    modelJSON.sections.push( new PortalSectionModel({
                      objectDOM: section,
                      literatureCited: modelJSON.literatureCited,
                      portalModel: modelRef
                    }) );
                  }

                  //Parse the PortalSectionModel
                  modelJSON.sections[i].set( modelJSON.sections[i].parse(section) );
                });

                // Parse the EMLText elements
                modelJSON.acknowledgments = this.parseEMLTextNode(portalNode, "acknowledgments");

                // Parse the awards
                modelJSON.awards = [];
                var parse_it = this.parseTextNode;
                $(portalNode).children("award").each(function(i, award) {
                    var award_parsed = {};
                    $(award).children().each(function(i, award_attr) {
                        if(award_attr.nodeName != "funderLogo"){
                          // parse the text nodes
                          award_parsed[award_attr.nodeName] = parse_it(award, award_attr.nodeName);
                        } else {
                          // parse funderLogo which is type ImageType
                          var imageModel = new PortalImage({ objectDOM: award_attr });
                          imageModel.set(imageModel.parse());
                          award_parsed[award_attr.nodeName] = imageModel;
                        }
                    });
                    modelJSON.awards.push(award_parsed);
                });

                // Parse the associatedParties
                modelJSON.associatedParties = [];
                $(portalNode).children("associatedParty").each(function(i, associatedParty) {

                    modelJSON.associatedParties.push(new EMLParty({
                        objectDOM: associatedParty
                    }));

                });

                // Parse the options. Use children() and not find() because we only want
                // option nodes that are direct children of the portal node. Option nodes
                // can also be found within section nodes.
                $(portalNode).children("option").each(function(i, option) {

                    var optionName = $(option).find("optionName")[0].textContent,
                        optionValue = $(option).find("optionValue")[0].textContent;

                    if (optionValue === "true") {
                        optionValue = true;
                    } else if (optionValue === "false") {
                        optionValue = false;
                    }

                    // TODO: keep a list of optionNames so that in the case of
                    // custom options, we can serialize them in serialize()
                    // otherwise it's not saved in the model which attributes
                    // are <option></option>s

                    // Convert the comma separated list of pages into an array
                    if(optionName === "pageOrder" && optionValue && optionValue.length){
                      optionValue = optionValue.split(',');
                    }

                    if( !_.has(modelJSON, optionName) ){
                      modelJSON[optionName] = optionValue;
                    }

                });

                // Convert all the hex colors to rgb
                if(modelJSON.primaryColor){
                  modelJSON.primaryColorRGB = this.hexToRGB(modelJSON.primaryColor);
                  modelJSON.primaryColorTransparent = "rgba(" +  modelJSON.primaryColorRGB.r +
                    "," + modelJSON.primaryColorRGB.g + "," + modelJSON.primaryColorRGB.b +
                    ", .7)";
                }
                if(modelJSON.secondaryColor){
                  modelJSON.secondaryColorRGB = this.hexToRGB(modelJSON.secondaryColor);
                  modelJSON.secondaryColorTransparent = "rgba(" +  modelJSON.secondaryColorRGB.r +
                    "," + modelJSON.secondaryColorRGB.g + "," + modelJSON.secondaryColorRGB.b +
                    ", .5)";
                }
                if(modelJSON.accentColor){
                  modelJSON.accentColorRGB = this.hexToRGB(modelJSON.accentColor);
                  modelJSON.accentColorTransparent = "rgba(" +  modelJSON.accentColorRGB.r +
                    "," + modelJSON.accentColorRGB.g + "," + modelJSON.accentColorRGB.b +
                    ", .5)";
                }

                if (gmaps) {
                    // Create a MapModel with all the map options
                    modelJSON.mapModel = new MapModel();
                    var mapOptions = modelJSON.mapModel.get("mapOptions");

                    if (modelJSON.mapZoomLevel) {
                        mapOptions.zoom = parseInt(modelJSON.mapZoomLevel);
                        mapOptions.minZoom = parseInt(modelJSON.mapZoomLevel);
                    }
                    if ((modelJSON.mapCenterLatitude || modelJSON.mapCenterLatitude === 0) &&
                        (modelJSON.mapCenterLongitude || modelJSON.mapCenterLongitude === 0)) {
                        mapOptions.center = modelJSON.mapModel.createLatLng(modelJSON.mapCenterLatitude, modelJSON.mapCenterLongitude);
                    }
                    if (modelJSON.mapShapeHue) {
                        modelJSON.mapModel.set("tileHue", modelJSON.mapShapeHue);
                    }
                }

                // Parse the UIFilterGroups
                modelJSON.filterGroups = [];
                var allFilters = modelJSON.searchModel.get("filters");
                $(portalNode).children("filterGroup").each(function(i, filterGroup) {

                  // Create a FilterGroup model
                  var filterGroupModel = new FilterGroup({
                      objectDOM: filterGroup,
                      isUIFilterType: true
                  });
                  modelJSON.filterGroups.push(filterGroupModel);

                  // Add the Filters from this FilterGroup to the portal's Search model,
                  // unless this portal model is being edited. Then we only want the
                  // definition filters to be included in the search model.
                  if (!modelRef.get("edit")){
                    allFilters.add(filterGroupModel.get("filters").models);
                  }
                  

                });

                return modelJSON;
            },

            /**
             * Parses the XML nodes that are of type EMLText
             *
             * @param {Element} parentNode - The XML Element that contains all the EMLText nodes
             * @param {string} nodeName - The name of the XML node to parse
             * @param {boolean} isMultiple - If true, parses the nodes into an array
             * @return {(string|Array)} A string or array of strings comprising the text content
            */
            parseEMLTextNode: function(parentNode, nodeName, isMultiple) {

                var node = $(parentNode).children(nodeName);

                // If no matching nodes were found, return falsey values
                if (!node || !node.length) {

                    // Return an empty array if the isMultiple flag is true
                    if (isMultiple)
                        return [];
                    // Return null if the isMultiple flag is false
                    else
                        return null;
                }
                // If exactly one node is found and we are only expecting one, return the text content
                else if (node.length == 1 && !isMultiple) {
                    return new EMLText({
                        objectDOM: node[0]
                    });
                } else {
                // If more than one node is found, parse into an array
                    return _.map(node, function(node) {
                        return new EMLText({
                            objectDOM: node
                        });
                    });

                }

            },

            /**
            * Sets the fileName attribute on this model using the portal label
            * @override
            */
            setMissingFileName: function(){

              var fileName = this.get("label");

              if( !fileName ){
                fileName = "portal.xml";
              }
              else{
                fileName = fileName.replace(/[^a-zA-Z0-9]/g, "_") + ".xml";
              }

              this.set("fileName", fileName);

            },

            /**
             * @typedef {Object} PortalModel#rgb - An RGB color value
             * @property {number} r - A value between 0 and 255 defining the intensity of red
             * @property {number} g - A value between 0 and 255 defining the intensity of green
             * @property {number} b - A value between 0 and 255 defining the intensity of blue
            */

            /**
             * Converts hex color values to RGB
             *
             * @param {string} hex - a color in hexadecimal format
             * @return {rgb} a color in RGB format
            */
            hexToRGB: function(hex){
              var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
              return result ? {
                  r: parseInt(result[1], 16),
                  g: parseInt(result[2], 16),
                  b: parseInt(result[3], 16)
              } : null;
              },

            /**
             * Finds the node in the given portal XML document afterwhich the
             * given node type should be inserted
             *
             * @param {Element} portalNode - The portal element of an XML document
             * @param {string} nodeName - The name of the node to be inserted
             *                             into xml
             * @return {(jQuery|boolean)} A jQuery object indicating a position,
             *                            or false when nodeName is not in the
             *                            portal schema
            */
            getXMLPosition: function(portalNode, nodeName){

              var nodeOrder = [ "label", "name", "description", "definition",
                                "logo", "section", "associatedParty",
                                "acknowledgments", "acknowledgmentsLogo",
                                "award", "literatureCited", "filterGroup",
                                "option"];

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

              // First check that nodeName is in the list of nodes
              if ( position == -1 ) {
                  return false;
              };

              // If there's already an occurence of nodeName...
              if($(portalNode).children(nodeName).length > 0){
                // ...insert it after the last occurence
                return $(portalNode).children(nodeName).last();
              } else {
                // Go through each node in the node list and find the position
                // after which this node will be inserted
                for (var i = position - 1; i >= 0; i--) {
                  if ( $(portalNode).children(nodeOrder[i]).length ) {
                    return $(portalNode).children(nodeOrder[i]).last();
                  }
                }
              }

              return false;
            },

            /**
             * Retrieves the model attributes and serializes into portal XML,
             * to produce the new or modified portal document.
             *
             * @return {string} - Returns the portal XML as a string.
            */
            serialize: function(){

              try{

                // So we can call getXMLPosition() from within if{}
                var model = this;

                var xmlDoc,
                    portalNode,
                    xmlString;

                xmlDoc = this.get("objectXML");

                // Check if there is a portal doc already
                if (xmlDoc == null){
                  // If not create one
                  xmlDoc = this.createXML();
                } else {
                  // If yes, clone it
                  xmlDoc = xmlDoc.cloneNode(true);
                };

                // Iterate over each root XML node to find the portal node
                $(xmlDoc).children().each(function(i, el) {
                    if (el.tagName.indexOf("portal") > -1) {
                        portalNode = el;
                    }
                });

                // Serialize the collection elements
                // ("name", "label", "description", "definition")
                portalNode = this.updateCollectionDOM(portalNode);
                xmlDoc = portalNode.getRootNode();
                var $portalNode = $(portalNode);

                // Set formatID
                this.set("formatId",
                  MetacatUI.appModel.get("portalEditorSerializationFormat") ||
                  "https://purl.dataone.org/portals-1.1.0");

                /* ==== Serialize portal logo ==== */

                // Remove node if it exists already
                $(xmlDoc).find("logo").remove();

                // Get new values
                var logo = this.get("logo");

                // Don't serialize falsey values or empty logos
                if(logo && logo.get("identifier")){

                  // Make new node
                  var logoSerialized = logo.updateDOM("logo");

                  //Add the logo node to the XMLDocument
                  xmlDoc.adoptNode(logoSerialized);

                  // Insert new node at correct position
                  var insertAfter = this.getXMLPosition(portalNode, "logo");
                  if(insertAfter){
                    insertAfter.after(logoSerialized);
                  }
                  else{
                    portalNode.appendChild(logoSerialized);
                  }

                };

                /* ==== Serialize acknowledgment logos ==== */

                // Remove element if it exists already
                $(xmlDoc).find("acknowledgmentsLogo").remove();

                var acknowledgmentsLogos = this.get("acknowledgmentsLogos");

                // Don't serialize falsey values
                if(acknowledgmentsLogos){

                  _.each(acknowledgmentsLogos, function(imageModel) {

                    // Don't serialize empty imageModels
                    if(
                      imageModel.get("identifier") ||
                      imageModel.get("label") ||
                      imageModel.get("associatedURL")
                    ){

                      var ackLogosSerialized = imageModel.updateDOM();

                      //Add the logo node to the XMLDocument
                      xmlDoc.adoptNode(ackLogosSerialized);

                      // Insert new node at correct position
                      var insertAfter = model.getXMLPosition(portalNode, "acknowledgmentsLogo");
                      if(insertAfter){
                        insertAfter.after(ackLogosSerialized);
                      }
                      else {
                        portalNode.appendChild(ackLogosSerialized);
                      }
                    }
                  })
                };

                /* ==== Serialize literature cited ==== */
                // Assumes the value of literatureCited is a block of bibtex text

                // Remove node if it exists already
                $(xmlDoc).find("literatureCited").remove();

                // Get new values
                var litCit = this.get("literatureCited");

                // Don't serialize falsey values
                if( litCit.length ){

                  // If there's only one element in litCited, it will be a string
                  // turn it into an array so that we can use _.each
                  if(typeof litCit == "string"){
                    litCit = [litCit]
                  }

                  // Make new <literatureCited> element
                  var litCitSerialized = xmlDoc.createElement("literatureCited");

                  _.each(litCit, function(bibtex){

                    // Wrap in literature cited in cdata tags
                    var cdataLitCit = xmlDoc.createCDATASection(bibtex);
                    var bibtexSerialized = xmlDoc.createElement("bibtex");
                    // wrap in CDATA tags so that bibtex characters aren't escaped
                    bibtexSerialized.appendChild(cdataLitCit);
                    // <bibxtex> is a subelement of <literatureCited>
                    litCitSerialized.appendChild(bibtexSerialized);

                  });

                  // Insert new element at correct position
                  var insertAfter = this.getXMLPosition(portalNode, "literatureCited");
                  if(insertAfter){
                    insertAfter.after(litCitSerialized);
                  }
                  else{
                    portalNode.appendChild(litCitSerialized);
                  }
                }

                /* ==== Serialize portal content sections ==== */

                // Remove node if it exists already
                $portalNode.children("section").remove();

                var sections = this.get("sections");

                // Don't serialize falsey values
                if(sections){

                  _.each(sections, function(sectionModel) {

                    // Don't serialize sections with default values
                    if(!this.sectionIsDefault(sectionModel)){

                      var sectionSerialized = sectionModel.updateDOM();

                      //If there was an error serializing this section, or if
                      // nothing was returned, don't do anythiing further
                      if( !sectionSerialized ){
                        return;
                      }

                      //Add the section node to the XMLDocument
                      xmlDoc.adoptNode(sectionSerialized);

                      // Remove sections entirely if the content is blank
                      var newMD = $(sectionSerialized).find("markdown")[0];
                      if( !newMD || newMD.textContent == "" ){
                        $(sectionSerialized).find("markdown").remove();
                      }

                      // Remove the <content> element if it's empty.
                      // This will trigger a validation error, prompting user to
                      // enter content.
                      if($(sectionSerialized).find("content").is(':empty')){
                        $(sectionSerialized).find("content").remove();
                      }

                      // Insert new node at correct position
                      var insertAfter = model.getXMLPosition(portalNode, "section");
                      if(insertAfter){
                        insertAfter.after(sectionSerialized);
                      }
                      else {
                        portalNode.appendChild(sectionSerialized);
                      }

                    }

                  }, this)
                };

                /* ====  Serialize the EMLText elements ("acknowledgments") ==== */

                var textFields = ["acknowledgments"];

                _.each(textFields, function(field){

                  var fieldName = field;

                  // Get the EMLText model
                  var emlTextModels = Array.isArray(this.get(field)) ? this.get(field) : [this.get(field)];
                  if( ! emlTextModels.length ) return;

                  // Get the node from the XML doc
                  var nodes = $portalNode.children(fieldName);

                  // Update the DOMs for each model
                  _.each(emlTextModels, function(thisTextModel, i){
                    //Don't serialize falsey values
                    if(!thisTextModel) return;

                    var node;

                    //Get the existing node or create a new one
                    if(nodes.length < i+1){
                      node = xmlDoc.createElement(fieldName);
                      this.getXMLPosition(portalNode, fieldName).after(node);
                    }
                    else {
                       node = nodes[i];
                    }

                    var textModelSerialized = thisTextModel.updateDOM();

                    //If the text model wasn't serialized correctly or resulted in nothing
                    if(typeof textModelSerialized == "undefined" || !textModelSerialized){
                      //Remove the existing node
                      $(node).remove();
                    }
                    else{
                      xmlDoc.adoptNode(textModelSerialized);
                      $(node).replaceWith(textModelSerialized);
                    }

                  }, this);

                  // Remove the extra nodes
                  this.removeExtraNodes(nodes, emlTextModels);

                }, this);

                /* ====  Serialize awards ==== */

                // Remove award node if it exists already
                $portalNode.children("award").remove();

                // Get new values
                var awards = this.get("awards");

                // Don't serialize falsey values
                if(awards && awards.length>0){

                  _.each(awards, function(award){

                    // Make new node
                    var awardSerialized = xmlDoc.createElement("award");

                    // create the <award> subnodes
                    _.map(award, function(value, nodeName){

                      // serialize the simple text nodes
                      if(nodeName != "funderLogo"){
                        // Don't serialize falsey values
                        if(value){
                          // Make new sub-nodes
                          var awardSubnodeSerialized = xmlDoc.createElement(nodeName);
                          $(awardSubnodeSerialized).text(value);
                          $(awardSerialized).append(awardSubnodeSerialized);
                        }
                      } else {
                        // serialize "funderLogo" which is ImageType
                        var funderLogoSerialized = value.updateDOM();
                        xmlDoc.adoptNode(funderLogoSerialized);
                        $(awardSerialized).append(funderLogoSerialized);
                      }

                    });

                    // Insert new node at correct position
                    var insertAfter = model.getXMLPosition(portalNode, "award");
                    if(insertAfter){
                      insertAfter.after(awardSerialized);
                    }
                    else{
                      portalNode.appendChild(awardSerialized);
                    }

                  });

                }

                /* ====  Serialize associatedParties ==== */

                // Remove element if it exists already
                $portalNode.children("associatedParty").remove();

                // Get new values
                var parties = this.get("associatedParties");

                // Don't serialize falsey values
                if(parties){

                  // Serialize each associatedParty
                  _.each(parties, function(party){

                    // Update the DOM of the EMLParty
                    var partyEl  = party.updateDOM();
                        partyDoc = $.parseXML(party.formatXML( $(partyEl)[0] ));

                    // Make sure we don't insert empty EMLParty nodes into the EML
                    if(partyDoc.childNodes.length){
                      //Save a reference to the associated party element in the NodeList
                      var assocPartyEl = partyDoc.childNodes[0];
                      //Add the associated part element to the portal XML doc
                      xmlDoc.adoptNode(assocPartyEl);

                      // Get the last node of this type to insert after
                      var insertAfter = $portalNode.children("associatedParty").last();

                      // If there isn't a node found, find the EML position to insert after
                      if( !insertAfter.length ) {
                        insertAfter = model.getXMLPosition(portalNode, "associatedParty");
                      }

                      //Insert the party DOM at the insert position
                      if ( insertAfter && insertAfter.length ){
                        insertAfter.after(assocPartyEl);
                      } else {
                        portalNode.appendChild(assocPartyEl);
                      }
                    }
                  });
                }

                try{
                  /* ====  Serialize options (including map options) ==== */
                  // This will only serialize the options named in `optNames` (below)
                  // Functionality needed in order to serialize new or custom options

                  // The standard list of options used in portals
                  var optNames = this.get("optionNames");

                  _.each(optNames, function(optName){

                    //Get the value on the model
                    var optValue = model.get(optName),
                        existingValue;

                    //Get the existing optionName element
                    var matchingOption = $portalNode.children("option")
                                                   .find("optionName:contains('" + optName + "')");

                    //
                    if( !matchingOption.length || matchingOption.first().text() != optName ){
                      matchingOption = false;
                    }
                    else{
                      //Get the value for this option from the Portal doc
                      existingValue = matchingOption.siblings("optionValue").text();
                    }

                    // Don't serialize null or undefined values. Also don't serialize values that match the default model value
                    if( (optValue || optValue === 0 || optValue === false) &&
                        (optValue != model.defaults()[optName]) ){

                      //Replace the existing option, if it exists
                      if( matchingOption ){
                        matchingOption.siblings("optionValue").text(optValue);
                      }
                      else{
                        // Make new node
                        // <optionName> and <optionValue> are subelements of <option>
                        var optionSerialized   = xmlDoc.createElement("option"),
                            optNameSerialized  = xmlDoc.createElement("optionName"),
                            optValueSerialized = xmlDoc.createElement("optionValue");

                        $(optNameSerialized).text(optName);
                        $(optValueSerialized).text(optValue);

                        $(optionSerialized).append(optNameSerialized, optValueSerialized);

                        // Insert new node at correct position
                        var insertAfter = model.getXMLPosition(portalNode, "option");

                        if(insertAfter){
                          insertAfter.after(optionSerialized);
                        }

                      }

                    }
                    else{
                      //Remove the elements from the portal XML when the value is invalid
                      if( matchingOption ){
                        matchingOption.parent("option").remove();
                      }
                    }
                  });
                }
                catch(e){
                  console.error(e);
                }

                /* ====  Serialize UI FilterGroups (aka custom search filters) ==== */

                // Get new filter group values
                var filterGroups = this.get("filterGroups");

                // Remove filter groups in the current objectDOM that are at the portal
                // level. (don't use .find("filterGroup") as that would remove
                // filterGroups that are nested in the definition
                $portalNode.children("filterGroup").remove();

                // Make a new node for each filter group in the model
                _.each(filterGroups, function(filterGroup){

                  filterGroupSerialized = filterGroup.updateDOM();

                  if (filterGroupSerialized){
                    //Add the new element to the XMLDocument
                    xmlDoc.adoptNode(filterGroupSerialized);

                    // Insert new node at correct position
                    var insertAfter = model.getXMLPosition(portalNode, "filterGroup");

                    if (insertAfter) {
                      insertAfter.after(filterGroupSerialized);
                    }
                    else {
                      portalNode.appendChild(filterGroupSerialized);
                    }
                  }

                  
                });

                /* ====  Remove duplicates ==== */

                //Do a final check to make sure there are no duplicate ids in the XML
                var elementsWithIDs = $(xmlDoc).find("[id]"),
                //Get an array of all the ids in this EML doc
                    allIDs = _.map(elementsWithIDs, function(el){ return $(el).attr("id") });

                //If there is at least one id in the EML...
                if(allIDs && allIDs.length){
                  //Boil the array down to just the unique values
                  var uniqueIDs = _.uniq(allIDs);

                  //If the unique array is shorter than the array of all ids,
                  // then there is a duplicate somewhere
                  if(uniqueIDs.length < allIDs.length){

                    //For each element in the EML that has an id,
                    _.each(elementsWithIDs, function(el){

                      //Get the id for this element
                      var id = $(el).attr("id");

                      //If there is more than one element in the EML with this id,
                      if( $(xmlDoc).find("[id='" + id + "']").length > 1 ){
                        //And if it is not a unit node, which we don't want to change,
                        if( !$(el).is("unit") )
                          //Then change the id attribute to a random uuid
                          $(el).attr("id", "urn-uuid-" + uuid.v4());
                      }

                    });

                  }
                }

                // Convert xml to xmlString and return xmlString
                xmlString = new XMLSerializer().serializeToString(xmlDoc);

                //If there isn't an XML declaration, add one
                if( xmlString.indexOf("<?xml") == -1 ){
                  xmlString = '<?xml version="1.0" encoding="UTF-8"?>' + xmlString;
                }

                return xmlString;
              }
              catch(e){
                console.error("Error while serializing the Portal XML document: ", e);
                this.set("errorMessage", e.stack);
                this.trigger("errorSaving", MetacatUI.appModel.get("portalEditSaveErrorMsg"));
                return;
              }
            },

            /**
             * Checks whether the given sectionModel has been updated by the
             * user, or whether all attributes match their default values.
             * For a section's markdown, the default value is either an empty
             * string or null. For a section's label, the default
             * value is either an empty string or a string that begins with the
             * value set to PortalModel.newSectionLabel. For all other attributes,
             * the defaults are set in PortalSectionModel.defaults.
             * @param {PortalSectionModel} sectionModel - The model to check against a default model
             * @return {boolean} returns true if the sectionModel matches a default model, and false when at least one attribute differs
            */
            sectionIsDefault: function(sectionModel){

              try{

                var defaults = sectionModel.defaults(),
                    currentMarkdown = sectionModel.get("content").get("markdown"),
                    labelRegex = new RegExp("^" + this.newSectionLabel, "i");

                // For each attribute, check whether it matches the default
                if(
                  // Check whether markdown matches the content that's
                  // auto-filled or whether it's empty
                  ( //currentMarkdown === this.markdownExample ||
                    currentMarkdown == "" ||
                    currentMarkdown == null
                  ) &&
                  ( sectionModel.get("image") === defaults.image ) &&
                  ( sectionModel.get("introduction") === defaults.introduction ) &&
                  // Check whether label starts with the default new page name,
                  // or whether it's empty
                  (
                    labelRegex.test( sectionModel.get("label") ) ||
                    sectionModel.get("label") == "" ||
                    sectionModel.get("label") == null
                  ) &&
                  ( sectionModel.get("literatureCited") === defaults.literatureCited ) &&
                  ( sectionModel.get("title") === defaults.title )
                ){
                  // All elements of the section match the default
                  return true
                } else {
                  // At least one attribute of the section has been updated
                  return false
                }

              }
              catch(e){
                // If there's a problem with this function for some reason,
                // return false so that the section is serialized to avoid
                // losing information
                console.log("Failed to check whether section model is default. Serializing it anyway. Error message:" + e);
                return false
              }

            },

            /**
             * Initialize the object XML for a brand spankin' new portal
             * @inheritdoc
             *
            */
            createXML: function() {
              var format = MetacatUI.appModel.get("portalEditorSerializationFormat") ||
                "https://purl.dataone.org/portals-1.1.0";
              var xmlString = "<por:portal xmlns:por=\"" + format + "\"></por:portal>";
              var xmlNew = $.parseXML(xmlString);
              var portalNode = xmlNew.getElementsByTagName("por:portal")[0];

              this.set("ownerDocument", portalNode.ownerDocument);
              return(xmlNew);
            },

            /**
             * Overrides the default Backbone.Model.validate.function() to
             * check if this portal model has all the required values necessary
             * to save to the server.
             *
             * @param {Object} [attrs] - A literal object of model attributes to validate.
             * @param {Object} [options] - A literal object of options for this validation process
             * @return {Object} If there are errors, an object comprising error
             *                   messages. If no errors, returns nothing.
            */
            validate: function(attrs, options) {

              try{

                var errors = {},
                    requiredFields = MetacatUI.appModel.get("portalEditorRequiredFields") || {};

                //Execute the superclass validate() function
                var collectionErrors = this.constructor.__super__.validate.call(this);
                if( typeof collectionErrors == "object" && Object.keys(collectionErrors).length ){
                  //Use the errors messages from the CollectionModel for this PortalModel
                  errors = collectionErrors;
                }

                // ---- Validate the description and name ----
                //Map the model attributes to the user-facing attribute name
                var textFields = {
                  description: "description",
                  name: "title"
                }
                //Iterate over each text field
                _.each( Object.keys(textFields), function(field){
                  //If this field is required, and it is a string
                  if( requiredFields[field] && typeof this.get(field) == "string" ){
                    //If this is an empty string, set an error message
                    if( !this.get(field).trim().length ){
                      errors[field] = "A " + textFields[field] + " is required.";
                    }
                  }
                  //If this field is required, and it's not a string at all, set an error message
                  else if( requiredFields[field] ){
                    errors[field] = "A " + textFields[field] + " is required.";
                  }
                }, this);

                //---Validate the sections---
                //Iterate over each section model
                _.each( this.get("sections"), function(section){

                  //Validate the section model
                  var sectionErrors = section.validate();

                  //If there is at least one error, then add an error to the PortalModel error list
                  if( sectionErrors && Object.keys(sectionErrors).length ){
                    errors.sections = "At least one section has an error";
                  }

                }, this);

                //----Validate the logo----
                if(requiredFields.logo && (!this.get("logo") ||
                    !this.get("logo").get("identifier")))
                {
                  errors.logo = "A logo image is required";
                } else if(this.get("logo")){
                  logoErrors = this.get("logo").validate();
                  if(logoErrors && Object.keys(logoErrors).length ){
                    errors.logo = "A logo image is required";
                  }
                }

                //---Validate the acknowledgmentsLogo---

                var nonEmptyAckLogos = this.get("acknowledgmentsLogos").filter(function(portalImage){
                  return(!portalImage.isEmpty())
                });

                if(
                  requiredFields.acknowledgmentsLogos &&
                  !nonEmptyAckLogos.length
                ){
                  errors.acknowledgmentsLogos = "At least one partner logo image is required.";
                }
                else if (
                  nonEmptyAckLogos &&
                  nonEmptyAckLogos.length
                ){

                  _.each( nonEmptyAckLogos, function(ackLogo){

                    // Validate the portal image model
                    var ackLogoErrors = ackLogo.validate();

                    // If there is at least one error, then add an error to the PortalModel error list
                    if( ackLogoErrors && Object.keys(ackLogoErrors).length ){
                      errors.acknowledgmentsLogosImages = "At least one acknowledgment logo has an error";
                    }

                  }, this);

                }

                //TODO: Validate these other elements, listed below, as they are added to the portal editor

                //---Validate the associatedParties---

                //---Validate the acknowledgments---

                //---Validate the award---

                //---Validate the literatureCited---

                //---Validate the filterGroups---

                //Return the errors object
                if( Object.keys(errors).length )
                  return errors;
                else{
                  return;
                }

              }
              catch(e){
                console.error(e);
              }

            },

            /**
            * Checks for the existing block list for repository labels
            * If at least one other Portal has the same label, then it is not available.
            * @param {string} label - The label to query for
            */
            checkLabelAvailability: function(label){

              //Validate the label set on the model if one isn't given
              if(!label || typeof label != "string" ){
                var label = this.get("label");
                if(!label || typeof label != "string" ){
                  //Trigger an error event
                  this.trigger("errorValidatingLabel");
                  console.error("error validating label, no label provided");
                  return
                }
              }

              var model = this;

              if (!this.get("checkedNodeLabels")) {
                // query CN to fetch the latest node data
                model.updateNodeBlockList();

                this.listenTo(this, "change:checkedNodeLabels", function(){
                  this.checkPortalLabelAvailability(label);
                });
              }
              else {
                this.checkPortalLabelAvailability(label);
              }

            },

            /**
             * Queries the Solr discovery index for other Portal objects with this same label.
             * Also, checks for the existing block list for repository labels
             * If at least one other Portal has the same label, then it is not available.
             * @param {string} label - The label to query for
             */
            checkPortalLabelAvailability: function(label) {
              var model = this;

              // Stop Listening to the node model. We only need to retrieve this node label once.
              this.stopListening(this, "change:checkedNodeLabels", function(){
                this.checkPortalLabelAvailability(label);
              });

              // Convert the block list to lower case for case insensitive match
              var lowerCaseBlockList = this.get("labelBlockList").map(function(value) {
                return value.toLowerCase();
              });

              // Check the existing blockList before making a Solr call
              if (lowerCaseBlockList.indexOf(label.toLowerCase()) > -1) {
                model.trigger("labelTaken");
                return
              }

              // Query solr to see if other portals already use this label
              var requestSettings = {
                url: MetacatUI.appModel.get("queryServiceUrl") +
                     "q=label:\"" + label + "\"" +
                     " AND formatId:\"" + this.get("formatId") + "\"" +
                     "&rows=0" +
                     "&wt=json",
                error: function(response) {
                  model.trigger("errorValidatingLabel");
                },
                success: function(response){
                  if( response.response.numFound > 0 ){
                    //Add this label to the blockList so we don't have to query for it later
                    var blockList = model.get("labelBlockList");
                    if( Array.isArray(blockList) ){
                      blockList.push(label);
                    }

                    model.trigger("labelTaken");
                  } else {
                    if( MetacatUI.appModel.get("alternateRepositories").length ){

                      MetacatUI.appModel.setActiveAltRepo();
                      var activeAltRepo = MetacatUI.appModel.getActiveAltRepo();
                      if( activeAltRepo ){
                        var requestSettings = {
                          url: activeAltRepo.queryServiceUrl +
                               "q=label:\"" + label + "\"" +
                               " AND formatId:\"" + model.get("formatId") + "\"" +
                               "&rows=0" +
                               "&wt=json",
                          error: function(response) {
                            model.trigger("errorValidatingLabel");
                          },
                          success: function(response){
                            if( response.response.numFound > 0 ){
                              //Add this label to the blockList so we don't have to query for it later
                              var blockList = model.get("labelBlockList");
                              if( Array.isArray(blockList) ){
                                blockList.push(label);
                              }

                              model.trigger("labelTaken");
                            } else {
                              model.trigger("labelAvailable");
                            }
                          }
                        }
                        //Attach the User auth info and send the request
                        requestSettings = _.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings());
                        $.ajax(requestSettings);
                      }

                    }
                    else{
                      model.trigger("labelAvailable");
                    }
                  }
                }
              }
              //Attach the User auth info and send the request
              requestSettings = _.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings());
              $.ajax(requestSettings);
            },



            /**
             * Queries the CN Solr to retrieve the updated BlockList
             */
            updateNodeBlockList: function(){
              var model  = this;

              $.ajax({
                url: MetacatUI.appModel.get('nodeServiceUrl'),
                dataType: "text",
                error:  function(data, textStatus, xhr) {
                  // if there is an error in retrieving the node list;
                  // proceed with the existing node list to perform the checks
                  model.checkPortalLabelAvailability()
                },
                success: function(data, textStatus, xhr) {

                  var xmlResponse = $.parseXML(data) || null;
                  if(!xmlResponse) return;

                  // update the node block list on success
                  model.saveNodeBlockList(xmlResponse);
                }
              });
            },

            /**
             * Parses the retrieved XML document and saves the node information to the BlockList
             *
             * @param {XMLDocument} The XMLDocument returned from the fetch() AJAX call
             */
            saveNodeBlockList: function(xml){
              var model = this,
                children   = xml.children || xml.childNodes;

              //Traverse the XML response to get the MN info
              _.each(children, function(d1NodeList){

                var d1NodeListChildren = d1NodeList.children || d1NodeList.childNodes;

                //The first (and only) child should be the d1NodeList
                _.each(d1NodeListChildren, function(thisNode){

                  //Ignore parts of the XML that is not MN info
                  if(!thisNode.attributes) return;

                  //'node' will be a single node
                  var node = {},
                    nodeProperties = thisNode.children || thisNode.childNodes;

                  //Grab information about this node from XML nodes
                  _.each(nodeProperties, function(nodeProperty){

                    if(nodeProperty.nodeName == "property")
                      node[$(nodeProperty).attr("key")] = nodeProperty.textContent;
                    else
                      node[nodeProperty.nodeName] = nodeProperty.textContent;

                    //Check if this member node has v2 read capabilities - important for the Package service
                    if((nodeProperty.nodeName == "services") && nodeProperty.childNodes.length){
                      var v2 = $(nodeProperty).find("service[name='MNRead'][version='v2'][available='true']").length;
                      node["readv2"] = v2;
                    }
                  });

                  //Grab information about this node from XLM attributes
                  _.each(thisNode.attributes, function(attribute){
                    node[attribute.nodeName] = attribute.nodeValue;
                  });

                  // Append Node name, node identifier and node short identifier to the array.
                  // node identifier
                  if (Array.isArray(model.get("labelBlockList")) && ((model.get("labelBlockList")).indexOf(node.identifier) < 0)) {
                    model.get("labelBlockList").push(node.identifier);
                  }

                  // node name
                  if(node.CN_node_name) {
                    node.name = node.CN_node_name;
                    if (Array.isArray(model.get("labelBlockList")) && ((model.get("labelBlockList")).indexOf(node.name) < 0)) {
                      model.get("labelBlockList").push(node.name);
                    }
                  }

                  // node short identifier
                  node.shortIdentifier = node.identifier.substring(node.identifier.lastIndexOf(":") + 1);
                  if (Array.isArray(model.get("labelBlockList")) && ((model.get("labelBlockList")).indexOf(node.shortIdentifier) < 0)) {
                    model.get("labelBlockList").push(node.shortIdentifier);
                  }

                });
              });

              this.set("checkedNodeLabels", "true");
            },

            /**
             * Removes nodes from the XML that do not have an accompanying model
             * (i.e. nodes which were probably removed by the user during editing)
             *
             * @param {jQuery} nodes - The nodes to potentially remove
             * @param {Model[]} models - The model to compare to
            */
            removeExtraNodes: function(nodes, models){
              // Remove the extra nodes
               var extraNodes =  nodes.length - models.length;
               if(extraNodes > 0){
                 for(var i = models.length; i < nodes.length; i++){
                   $(nodes[i]).remove();
                 }
               }
            },

            /**
             * Saves the portal XML document to the server using the DataONE API
            */
            save: function(){

              var model = this;

              // Remove empty filters from the custom portal search filters.
              this.get("filterGroups").forEach(function(filterGroupModel){
                filterGroupModel.get("filters").removeEmptyFilters();
              }, this);

              // Ensure empty filters (rule groups) are removed, including from
              // within any nested filter groups
              this.get("definitionFilters").removeEmptyFilters(true);

              // Validate before we try anything else
              if(!this.isValid()){
                //Trigger the invalid and cancelSave events
                this.trigger("invalid");
                this.trigger("cancelSave");
                //Don't save the model since it's invalid
                return false;
              }
              else{
                //Double-check that the label is available, if it was changed
                if( (this.isNew() || this.get("originalLabel") != this.get("label")) && !this.get("labelDoubleChecked") ){
                  //If the label is taken
                  this.once("labelTaken", function(){

                    //Stop listening to the label availability
                    this.stopListening("labelAvailable");

                    //Set that the label has been double-checked
                    this.set("labelDoubleChecked", true);

                    //If this portal is in a free trial of DataONE Plus, generate a new random label
                    // and start the save process again
                    if( MetacatUI.appModel.get("enableBookkeeperServices") ){

                      var subscription = MetacatUI.appUserModel.get("dataoneSubscription");
                      if(subscription && subscription.isTrialing()) {
                        this.setRandomLabel();

                        this.set("labelDoubleChecked", true);

                        // Start the save process again
                        this.save();

                        return;
                      }

                    }
                    else{
                      //If the label is taken, trigger an invalid event
                      this.trigger("invalid");
                      //Trigger a cancellation of the save event
                      this.trigger("cancelSave");
                    }

                  });

                  this.once("labelAvailable", function(){
                    this.stopListening("labelTaken");
                    this.set("labelDoubleChecked", true);
                    this.save();
                  });

                  // Check label availability
                  this.checkLabelAvailability(this.get("label"));

                  // console.log("Double checking label");

                  //Don't proceed with the rest of the save
                  return;
                }
                else{
                  this.trigger("valid");
                }

              }

              //Check if the checksum has been calculated yet.
              if( !this.get("checksum") ){
                // Serialize the XML
                var xml = this.serialize();

                //If there is no xml returned from the serialize() function, then there
                // was an error, so don't save.
                if( typeof xml === "undefined" || !xml ){
                  //If no error message is set on the model, trigger an error now.
                  // If there is an error message already, it means the error has already
                  // been triggered inside the serialize() function.
                  if( !this.get("errorMessage") ){
                    this.trigger("errorSaving", MetacatUI.appModel.get("portalEditSaveErrorMsg"));
                  }

                  return;
                }

                var xmlBlob = new Blob([xml], {type : 'application/xml'});

                //Set the Blob as the upload file
                this.set("uploadFile", xmlBlob);

                //When it is calculated, restart this function
                this.off("checksumCalculated", this.save);
                this.on("checksumCalculated", this.save);
                //Calculate the checksum for this file
                this.calculateChecksum();

                //Exit this function until the checksum is done
                return;
              }

              this.constructor.__super__.save.call(this);
            },

            /**
            * Removes or hides the given section from this Portal
            * @param {PortalSectionModel|string} section - Either the PortalSectionModel
            * to remove, or the name of the section to remove. Some sections in the portals
            * are not tied to PortalSectionModels, because they are created from other parts of the Portal
            * document. For example, the Data, Metrics, and Members sections.
            */
            removeSection: function(section){

              try{

                //If this section is a string, remove it by adding custom options
                if(typeof section == "string"){
                  switch( section.toLowerCase() ){
                    case "data":
                      this.set("hideData", true);
                      break;
                    case "metrics":
                      this.set("hideMetrics", true);
                      break;
                    case "members":
                      this.set("hideMembers", true);
                      break;
                  }
                }
                //If this section is a section model, delete it from this Portal
                else if( PortalSectionModel.prototype.isPrototypeOf(section) ){

                  // Remove the section from the model's sections array object.
                  // Use clone() to create new array reference and ensure change
                  // event is tirggered.
                  var sectionModels = _.clone(this.get("sections"));
                  sectionModels.splice( $.inArray(section, sectionModels), 1);
                  this.set({sections: sectionModels});
                }
                else{
                  return;
                }
              }
              catch(e){
                console.error(e);
              }

            },

            /**
            * Adds the given section to this Portal
            * @param {PortalSectionModel|string} section - Either the PortalSectionModel
            * to add, or the name of the section to add. Some sections in the portals
            * are not tied to PortalSectionModels, because they are created from other parts of the Portal
            * document. For example, the Data, Metrics, and Members sections.
            */
            addSection: function(section){
              try{
                //If this section is a string, add it by adding custom options
                if(typeof section == "string"){
                  switch( section.toLowerCase() ){
                    case "data":
                      this.set("hideData", null);
                      break;
                    case "metrics":
                      this.set("hideMetrics", null);
                      break;
                    case "members":
                      this.set("hideMembers", null);
                      break;
                    case "freeform":

                      // Add a new, blank markdown section with a default image
                      var sectionModels = _.clone(this.get("sections")),
                          newSection = new PortalSectionModel({
                            portalModel: this,
                            // Include a default image if some are configured.
                            image: this.getRandomSectionImage()
                          });

                      sectionModels.push( newSection );
                      this.set("sections", sectionModels);
                      // Trigger event manually so we can just pass newSection
                      this.trigger("addSection", newSection);
                      break;
                  }
                }
                // If this section is a section model, add it to this Portal
                else if( PortalSectionModel.prototype.isPrototypeOf(section) ){
                  var sectionModels = _.clone(this.get("sections"));
                  sectionModels.push( section );
                  this.set({sections: sectionModels});
                  // trigger event manually so we can just pass newSection
                  this.trigger("addSection", section);
                }
                else{
                  return;
                }
              }
              catch(e){
                console.error(e);
              }
            },

            /**
             * removePortalImage - remove a PortalImage model from either the
             * logo, sections, or acknowledgmentsLogos node of the portal model.
             *
             * @param  {Image} portalImage the portalImage model to remove
             */
            removePortalImage: function(portalImage){
              try{
                // find the portalImage to remove
                switch (portalImage.get("nodeName")) {
                  case "logo":
                    if(portalImage === this.get("logo")){
                      this.set("logo", this.defaults().logo);
                    }
                    break;
                  case "image":
                    _.each(this.get("sections"), function(section, i) {
                      if(portalImage === section.get("image")){
                        section.set("image", section.defaults().image)
                      }
                    });
                    break;
                  case "acknowledgmentsLogo":
                    var ackLogos = _.clone(this.get("acknowledgmentsLogos"));
                    ackLogos.splice( $.inArray(portalImage, ackLogos), 1);
                    this.set({acknowledgmentsLogos: ackLogos});
                    break;
                }

              } catch(e){
                console.log("Failed to remove a portalImage model, error message: " + e);
              }

            },

            /**
            * Saves a reference to this Portal on the MetacatUI global object
            */
            cachePortal: function(){

              if( this.get("id") ){
                MetacatUI.portals = MetacatUI.portals || {};
                MetacatUI.portals[this.get("id")] = this;
              }

              this.on("change:id", this.cachePortal);
            },

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

            /**
            * Sets attributes on this Portal using the given Member Node data
            * @param {object} nodeInfoObject - A literal object taken from the NodeModel 'members' array
            */
            createNodeAttributes: function(nodeInfoObject) {
              var nodePortalModel = {};

              if (nodeInfoObject === undefined) {
                nodeInfoObject = {}
              }

              //TODO - check for undefined for each of the nodeInfo properties

              // Setting basic properties from the node info object
              this.set("name", nodeInfoObject.name);
              this.set("logo", nodeInfoObject.logo);
              this.set("description", nodeInfoObject.description);

              // Creating repo specific Filters
              var nodeFilterModel = new FilterModel({
                fields: ["datasource"],
                values: [nodeInfoObject.identifier],
                label: "Datasets for a repository",
                matchSubstring: false,
                operator: "OR"
              });

              // adding the filter in the node model
              this.get("definitionFilters").add(nodeFilterModel);

              // Set up the search model
              this.get("searchModel").get("filters").add(nodeFilterModel);

            },

            /**
            * Cleans up the given text so that it is XML-valid by escaping reserved characters, trimming white space, etc.
            *
            * @param {string} textString - The string to clean up
            * @return {string} - The cleaned up string
            */
            cleanXMLText: function(textString){

              if( typeof textString != "string" )
                return;

              textString = textString.trim();

              //Check for XML/HTML elements
              _.each(textString.match(/<\s*[^>]*>/g), function(xmlNode){

                //Encode <, >, and </ substrings
                var tagName = xmlNode.replace(/>/g, "&gt;");
                tagName = tagName.replace(/</g, "&lt;");

                //Replace the xmlNode in the full text string
                textString = textString.replace(xmlNode, tagName);

              });

              //Remove Unicode characters that are not valid XML characters
              //Create a regular expression that matches any character that is not a valid XML character
              // (see https://www.w3.org/TR/xml/#charsets)
              var invalidCharsRegEx = /[^\u0009\u000a\u000d\u0020-\uD7FF\uE000-\uFFFD]/g;
              textString = textString.replace(invalidCharsRegEx, "");

              return textString;

            },

            /**
            * Generates a random portal label for free trial portals
            * @fires PortalModel#change:label
            * @since 2.14.0
            */
            setRandomLabel: function() {

              if( this.isNew() ){
                var labelLength = MetacatUI.appModel.get("randomLabelNumericLength");
                var randomGeneratedLabel = Math.floor(Math.pow(10,labelLength - 1) + Math.random() * ( 9 * Math.pow(10,labelLength - 1)));
                randomGeneratedLabel = randomGeneratedLabel.toString();
                this.set("label", randomGeneratedLabel);
              }

            }

        });

        return PortalModel;
    });