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

/** @exports PortalModel */
define([
  "jquery",
  "underscore",
  "gmaps",
  "common/ValueUtilities",
  "models/filters/Filter",
  "models/portals/PortalSectionModel",
  "models/portals/PortalVizSectionModel",
  "models/portals/PortalOption",
  "models/portals/PortalImage",
  "models/metadata/eml211/EMLParty",
  "models/metadata/eml220/EMLText",
  "models/CollectionModel",
  "models/filters/FilterGroup",
  "models/Map",
  "common/QueryService",
], (
  $,
  _,
  gmaps,
  ValueUtilities,
  FilterModel,
  PortalSectionModel,
  PortalVizSectionModel,
  PortalOption,
  PortalImage,
  EMLParty,
  EMLText,
  CollectionModel,
  FilterGroup,
  MapModel,
  QueryService,
) => {
  /**
   * @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.
   * @classcategory Models/Portals
   * @augments CollectionModel
   * @module models/PortalModel
   * @name PortalModel
   * @class PortalModel
   */
  const 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
       * @returns {object} The default portal attributes
       */
      defaults() {
        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 {object} attrs - Initial model attributes.
       */
      initialize(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();

          const model = this;

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

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

          if (
            defaultSections &&
            defaultSections.length &&
            Array.isArray(defaultSections)
          ) {
            defaultSections.forEach(function addDefaultSection(section) {
              // If there is at least one section default set...
              if (section.title || section.label) {
                const 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);
              }
            });
          }
        }

        // 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.
       * @returns {(PortalImage|string|undefined)} A portal image model, an
       * empty string when configured images are unavailable, or undefined when
       * no images are configured.
       */
      getRandomSectionImage() {
        // This variable will hold the section image to return, if any
        let newSectionImage = "";
        // The default portal values set in the config
        const portalDefaults = MetacatUI.appModel.get("portalDefaults");
        // Check if default images are set on the model already
        let defaultImageIds = this.get("defaultSectionImageIds");
        // Keep track of where we are in the list of default images, so there's
        // not too much repetition
        let 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 imageIndex = defaultImageIds.length - 1;
              imageIndex > 0;
              imageIndex -= 1
            ) {
              const shuffledIndex = Math.floor(
                Math.random() * (imageIndex + 1),
              );
              [defaultImageIds[imageIndex], defaultImageIds[shuffledIndex]] = [
                defaultImageIds[shuffledIndex],
                defaultImageIds[imageIndex],
              ];
            }
            // ... 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 || !defaultImageIds.length) {
          return undefined;
        }

        // 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 (
            let imageIndex = runningNumber;
            imageIndex < defaultImageIds.length;
            imageIndex += 1
          ) {
            // Skip images that have already returned 404 errors
            if (defaultImageIds[imageIndex] !== "NOT FOUND") {
              // Section images are PortalImage models
              newSectionImage = new PortalImage({
                identifier: defaultImageIds[imageIndex],
                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()) {
                runningNumber = imageIndex;
                break;
              }

              // If the image doesn't exist, mark it so we don't have to check
              // again next time
              defaultImageIds[imageIndex] = "NOT FOUND";
              newSectionImage = "";
            }
            runningNumber = imageIndex + 1;
          }
        }

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

        return newSectionImage;
      },

      /**
       * Returns the portal URL
       * @returns {string} The portal URL
       */
      url() {
        // Start the base URL string use the resolve service if there is no
        // object service url (e.g. in DataONE theme)
        let urlBase =
          MetacatUI.appModel.get("objectServiceUrl") ||
          MetacatUI.appModel.get("resolveServiceUrl");

        // Get the active alternative repository, if one is configured
        const 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

        return (
          urlBase + encodeURIComponent(this.get("seriesId") || this.get("id"))
        );
      },

      /**
       * Overrides the default Backbone.Model.fetch() function to provide some
       * custom fetch options
       * @param {object} [options] - Options for this fetch.
       * @param {boolean} [options.objectOnly] - If true, only the object
       * will be retrieved and not the system metadata
       * @param {boolean} [options.systemMetadataOnly] - If true, only the
       * system metadata will be retrieved
       * @returns {(jqXHR|undefined)} The fetch request, or undefined when the
       * request is deferred or only system metadata is fetched.
       */
      fetch(options) {
        const fetchOptions = options ? { ...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 fetchWhenSeriesIdChanges() {
            this.fetch(fetchOptions);
          });
          this.once(
            "latestVersionFound",
            function fetchWhenLatestVersionFound() {
              this.fetch(fetchOptions);
            },
          );

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

          return undefined;
        }
        // If we found the latest version in this pid version chain,
        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
          const altRepo = MetacatUI.appModel
            .get("alternateRepositories")
            .find(
              (repository) => repository.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 (!fetchOptions.objectOnly || fetchOptions.systemMetadataOnly) {
          this.fetchSystemMetadata();

          if (fetchOptions.systemMetadataOnly) {
            return undefined;
          }
        }

        // Record whether this fetch uses authentication for private portals.
        this.set("fetchedWithAuth", MetacatUI.appUserModel.get("loggedIn"));

        const requestSettings = {
          dataType: "xml",
          success: (response) => {
            let attributes;
            try {
              attributes = this.parse(response);
            } catch (error) {
              this.trigger("error", this, error.message);
              return;
            }

            this.set(attributes);
            this.trigger("sync", this, response, requestSettings);
          },
          error: (response) => {
            this.trigger("error", this, response);

            if (response?.status === 404) {
              this.trigger("notFound");
            }
          },
          ...MetacatUI.appUserModel.createAjaxSettings(),
        };

        // Parse before setting attributes or emitting sync so a failed load
        // cannot open the editor with incomplete data.
        return this.sync("read", this, requestSettings);
      },

      /**
       * Get the portal seriesId by searching for the portal by its label in
       * Solr
       */
      getSeriesIdByLabel() {
        const model = this;
        const label = this.get("label");
        if (!label) return;

        // Save a boolean flag for whether or not this fetch was done with user
        // authentication.
        this.set("fetchedWithAuth", MetacatUI.appUserModel.get("loggedIn"));

        // Build candidate query service URLs to try
        let candidates = [];
        try {
          const possibleAuthMNs = this.get("possibleAuthMNs") || [];
          if (
            MetacatUI.appModel.get("alternateRepositories").length &&
            possibleAuthMNs.length
          ) {
            candidates = possibleAuthMNs
              .map((mn) => mn && mn.queryServiceUrl)
              .filter(Boolean);
          } else {
            candidates = [MetacatUI.appModel.get("queryServiceUrl")];
          }
        } catch (e) {
          // eslint-disable-next-line no-console
          console.error(
            "Error determining queryServiceUrl candidates; falling back to AppModel setting.",
            e,
          );
        }
        if (!candidates.length && MetacatUI.appModel.get("queryServiceUrl")) {
          candidates = [MetacatUI.appModel.get("queryServiceUrl")];
        }
        candidates = [...new Set(candidates.filter(Boolean))];
        if (!candidates.length) {
          this.trigger("notFound");
          return;
        }

        const tryIndex = function tryQueryServiceUrl(i) {
          if (i >= candidates.length) {
            model.trigger("notFound");
            return;
          }
          QueryService.queryWithFetch({
            q: `label:"${label}" OR seriesId:"${label}"`,
            fields: ["seriesId", "id", "label", "datasource"],
            sort: "dateUploaded asc",
            rows: 1,
            urlBase: candidates[i],
            useAuth: true,
          })
            .then((response) => QueryService.parseResponse(response))
            .then((docs) => {
              if (!docs.length) {
                tryIndex(i + 1);
                return;
              }
              const doc = docs[0];
              model.set("label", doc.label);
              model.set("datasource", doc.datasource);
              if (doc.seriesId) {
                model.set("seriesId", doc.seriesId);
              } else if (doc.id) {
                model.set("id", doc.id);
                model.findLatestVersion(doc.id);
              } else {
                model.trigger("notFound");
              }
            })
            .catch((e) => {
              // eslint-disable-next-line no-console
              console.error(e);
              tryIndex(i + 1);
            });
        };

        tryIndex(0);
      },

      /**
       * 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() {
        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
       * @returns {object} The parsed portal attributes, to be set
       * directly on the model.
       */
      parse(response) {
        // Start the empty JSON object
        let modelJSON = {};
        const modelRef = this;
        let portalNode;

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

        // 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
        const portLogo = $(portalNode).children("logo")[0];
        if (portLogo) {
          const portImageModel = new PortalImage({
            objectDOM: portLogo,
            portalModel: this,
          });
          portImageModel.set(portImageModel.parse());
          modelJSON.logo = portImageModel;
        }

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

            const 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
        const 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((i, section) => {
            // Get the section type, if there is one
            const sectionTypeOption = PortalOption.findDirectChild(
              section,
              "sectionType",
            );
            const sectionType = sectionTypeOption
              ? PortalOption.fromElement(sectionTypeOption).optionValue[0]
              : "";

            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 = [];
        const { parseTextNode } = this;
        $(portalNode)
          .children("award")
          .each((_awardIndex, award) => {
            const awardParsed = {};
            $(award)
              .children()
              .each((_attributeIndex, awardAttribute) => {
                if (awardAttribute.nodeName !== "funderLogo") {
                  // parse the text nodes
                  awardParsed[awardAttribute.nodeName] = parseTextNode(
                    award,
                    awardAttribute.nodeName,
                  );
                } else {
                  // parse funderLogo which is type ImageType
                  const imageModel = new PortalImage({
                    objectDOM: awardAttribute,
                  });
                  imageModel.set(imageModel.parse());
                  awardParsed[awardAttribute.nodeName] = imageModel;
                }
              });
            modelJSON.awards.push(awardParsed);
          });

        // Parse the associatedParties
        modelJSON.associatedParties = [];
        $(portalNode)
          .children("associatedParty")
          .each((i, associatedParty) => {
            modelJSON.associatedParties.push(
              new EMLParty({
                objectDOM: associatedParty,
              }),
            );
          });

        // Parse only direct root options; section options are nested in sections.
        Array.from(portalNode.children)
          .filter((child) => child.localName === "option")
          .forEach((element) => {
            const option = PortalOption.fromElement(element);
            const { optionName } = option;
            let optionValue = option.optionValue[0];

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

            // 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();
          const mapOptions = modelJSON.mapModel.get("mapOptions");

          if (modelJSON.mapZoomLevel) {
            mapOptions.zoom = parseInt(modelJSON.mapZoomLevel, 10);
            mapOptions.minZoom = parseInt(modelJSON.mapZoomLevel, 10);
          }
          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 = [];
        const allFilters = modelJSON.searchModel.get("filters");
        $(portalNode)
          .children("filterGroup")
          .each((i, filterGroup) => {
            // Create a FilterGroup model
            const 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
       * @returns {(EMLText|EMLText[]|null)} The parsed EMLText model or models,
       * or null when a single matching node is not found
       */
      parseEMLTextNode(parentNode, nodeName, isMultiple) {
        const 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
          return null;
        }
        // If exactly one node is found and we are only expecting one, return
        // the text content
        if (node.length === 1 && !isMultiple) {
          return new EMLText({
            objectDOM: node[0],
          });
        }
        // If more than one node is found, parse into an array
        return _.map(
          node,
          (emlNode) =>
            new EMLText({
              objectDOM: emlNode,
            }),
        );
      },

      /**
       * Sets the fileName attribute on this model using the portal label
       * @override
       */
      setMissingFileName() {
        let 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
       * @returns {(PortalModel#rgb|null)} The RGB color, or null if the hex
       * value is invalid
       */
      hexToRGB(hex) {
        const 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
       * @returns {(jQuery|boolean)} A jQuery object indicating a position, or
       *                            false when nodeName is not in the portal
       *                            schema
       */
      getXMLPosition(portalNode, nodeName) {
        const nodeOrder = [
          "label",
          "name",
          "description",
          "definition",
          "logo",
          "section",
          "associatedParty",
          "acknowledgments",
          "acknowledgmentsLogo",
          "award",
          "literatureCited",
          "filterGroup",
          "option",
        ];

        const position = nodeOrder.indexOf(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();
        }
        // Go through each node in the node list and find the position after
        // which this node will be inserted
        for (let i = position - 1; i >= 0; i -= 1) {
          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.
       * @returns {(string|undefined)} The portal XML, or undefined when
       * serialization fails.
       */
      serialize() {
        try {
          // So we can call getXMLPosition() from within if{}
          const model = this;

          let xmlDoc;
          let portalNode;
          let 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((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();
          const $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
          const logo = this.get("logo");

          // Don't serialize falsey values or empty logos
          if (logo && logo.get("identifier")) {
            // Make new node
            const logoSerialized = logo.updateDOM("logo");

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

            // Insert new node at correct position
            const 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();

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

          // Don't serialize falsey values
          if (acknowledgmentsLogos) {
            _.each(acknowledgmentsLogos, (imageModel) => {
              // Don't serialize empty imageModels
              if (
                imageModel.get("identifier") ||
                imageModel.get("label") ||
                imageModel.get("associatedURL")
              ) {
                const ackLogosSerialized = imageModel.updateDOM();

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

                // Insert new node at correct position
                const 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
          let 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
            const litCitSerialized = xmlDoc.createElement("literatureCited");

            _.each(litCit, (bibtex) => {
              // Wrap in literature cited in cdata tags
              const cdataLitCit = xmlDoc.createCDATASection(bibtex);
              const 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
            const 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();

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

          // Don't serialize falsey values
          if (sections) {
            _.each(
              sections,
              function serializeSection(sectionModel) {
                // Don't serialize sections with default values
                if (!this.sectionIsDefault(sectionModel)) {
                  const 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
                  const 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
                  const insertAfter = model.getXMLPosition(
                    portalNode,
                    "section",
                  );
                  if (insertAfter) {
                    insertAfter.after(sectionSerialized);
                  } else {
                    portalNode.appendChild(sectionSerialized);
                  }
                }
              },
              this,
            );
          }

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

          const textFields = ["acknowledgments"];

          _.each(
            textFields,
            function serializeTextField(field) {
              const fieldName = field;

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

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

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

                  let 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];
                  }

                  const 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
          const awards = this.get("awards");

          // Don't serialize falsey values
          if (awards && awards.length > 0) {
            _.each(awards, (award) => {
              // Make new node
              const awardSerialized = xmlDoc.createElement("award");

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

              // Insert new node at correct position
              const 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
          const parties = this.get("associatedParties");

          // Don't serialize falsey values
          if (parties) {
            // Serialize each associatedParty
            _.each(parties, (party) => {
              // Update the DOM of the EMLParty
              const partyEl = party.updateDOM();
              const 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
                const 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
                let 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);
                }
              }
            });
          }

          /* ====  Serialize options (including map options) ==== */
          // Only the standard option names are written from model attributes.
          // Existing custom options remain in the cloned XML, but new or edited
          // custom option attributes are not saved until their names are tracked.
          // TODO: Track custom option names if editing them is supported.
          _.each(this.get("optionNames"), (optName) => {
            const optValue = model.get(optName);
            const matchingOption = PortalOption.findDirectChild(
              portalNode,
              optName,
            );

            if (
              (optValue || optValue === 0 || optValue === false) &&
              // XML option values are strings, while model defaults can be numbers.
              // eslint-disable-next-line eqeqeq
              optValue != model.defaults()[optName]
            ) {
              const option = matchingOption
                ? PortalOption.fromElement(matchingOption)
                : new PortalOption({
                    optionName: optName,
                    optionValue: [String(optValue)],
                  });
              if (matchingOption) option.optionValue[0] = String(optValue);
              const optionElement = option.toElement(xmlDoc);

              if (matchingOption) {
                matchingOption.replaceWith(optionElement);
              } else {
                const insertAfter = model.getXMLPosition(portalNode, "option");
                if (insertAfter) {
                  insertAfter.after(optionElement);
                } else {
                  portalNode.appendChild(optionElement);
                }
              }
            } else if (matchingOption) {
              matchingOption.remove();
            }
          });

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

          // Get new filter group values
          const 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, (filterGroup) => {
            const filterGroupSerialized = filterGroup.updateDOM();

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

              // Insert new node at correct position
              const 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
          const elementsWithIDs = $(xmlDoc).find("[id]");
          // Get an array of all the ids in this EML doc
          const allIDs = _.map(elementsWithIDs, (el) => $(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
            const uniqueIDs = new Set(allIDs);

            // If the unique array is shorter than the array of all ids, then
            // there is a duplicate somewhere
            if (uniqueIDs.size < allIDs.length) {
              // For each element in the EML that has an id,
              _.each(elementsWithIDs, (el) => {
                // Get the id for this element
                const 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", ValueUtilities.makeUUID({ sep: "-" }));
                }
              });
            }
          }

          // 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) {
          // eslint-disable-next-line no-console
          console.error("Error while serializing the Portal XML document: ", e);
          this.set("errorMessage", e.stack);
          this.trigger(
            "errorSaving",
            MetacatUI.appModel.get("portalEditSaveErrorMsg"),
          );
          return undefined;
        }
      },

      /**
       * 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
       * @returns {boolean} returns true if the sectionModel matches a default
       * model, and false when at least one attribute differs
       */
      sectionIsDefault(sectionModel) {
        try {
          const defaults = sectionModel.defaults();
          const currentMarkdown = sectionModel.get("content").get("markdown");
          const 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("visualizationType") ===
              defaults.visualizationType &&
            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;
          }
          // 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
          // eslint-disable-next-line no-console
          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() {
        const format =
          MetacatUI.appModel.get("portalEditorSerializationFormat") ||
          "https://purl.dataone.org/portals-1.1.0";
        const xmlString = `<por:portal xmlns:por="${format}"></por:portal>`;
        const xmlNew = $.parseXML(xmlString);
        const 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
       * @returns {(object|undefined)} An object of error messages, or undefined
       * when there are no errors.
       */
      validate(_attrs, _options) {
        try {
          let errors = {};
          const requiredFields =
            MetacatUI.appModel.get("portalEditorRequiredFields") || {};

          // Execute the superclass validate() function
          const collectionErrors =
            CollectionModel.prototype.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
          const textFields = {
            description: "description",
            name: "title",
          };
          // Iterate over each text field
          _.each(
            Object.keys(textFields),
            function validateTextField(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"),
            (section) => {
              // Validate the section model
              const 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")) {
            const logoErrors = this.get("logo").validate();
            if (logoErrors && Object.keys(logoErrors).length) {
              errors.logo = "A logo image is required";
            }
          }

          // ---Validate the acknowledgmentsLogo---

          const nonEmptyAckLogos = this.get("acknowledgmentsLogos").filter(
            (portalImage) => !portalImage.isEmpty(),
          );

          if (requiredFields.acknowledgmentsLogos && !nonEmptyAckLogos.length) {
            errors.acknowledgmentsLogos =
              "At least one partner logo image is required.";
          } else if (nonEmptyAckLogos && nonEmptyAckLogos.length) {
            _.each(
              nonEmptyAckLogos,
              (ackLogo) => {
                // Validate the portal image model
                const 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;
        } catch (e) {
          // eslint-disable-next-line no-console
          console.error(e);
        }
        return undefined;
      },

      /**
       * 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} l - The label to query for
       */
      checkLabelAvailability(l) {
        // Validate the label set on the model if one isn't given
        const label = typeof l === "string" ? l.trim() : this.get("label");
        if (typeof label !== "string") {
          this.errorValidatingLabel();
          return;
        }
        // Check that we have a node blockList
        if (!this.get("checkedNodeLabels")) {
          // query CN to fetch the latest node data
          this.updateNodeBlockList();
          this.listenToOnce(this, "change:checkedNodeLabels", () =>
            this.checkLabelAvailability(label),
          );
          return;
        }
        // Check existing blockList before making a Solr call (case insensitive)
        const blockList = this.get("labelBlockList") || [];
        if (blockList.find((b) => b.toLowerCase() === label.toLowerCase())) {
          this.labelTaken(label);
          return;
        }

        // Query Solr for this label
        this.queryForLabel(label);
      },

      /** Reports that the current portal label is available */
      labelAvailable() {
        this.trigger("labelAvailable");
      },

      /**
       * Adds a label to the local block list and reports that it is taken.
       * @param {string} label - The unavailable portal label
       */
      labelTaken(label) {
        const blockList = this.get("labelBlockList");
        if (Array.isArray(blockList)) blockList.push(label);
        this.trigger("labelTaken");
      },

      /**
       * Reports a failure while checking portal label availability.
       * @param {(Error|string)} [e] - The error to log
       */
      errorValidatingLabel(e) {
        const msg = e || "There was an error validating the portal label.";
        // eslint-disable-next-line no-console
        console.error(msg);
        this.trigger("errorValidatingLabel");
      },

      /**
       * Queries a repository for an existing portal label and reports whether
       * it is available.
       * @param {string} label - The portal label to query for
       * @param {string} [urlBase] - The query service URL
       * @param {boolean} [tryAltRepo] - Whether to query an alternate
       * repository when the label is not found. Defaults to true.
       */
      queryForLabel(label, urlBase, tryAltRepo = true) {
        const q = `label:"${label}" AND formatId:"${this.get("formatId")}"`;
        const opts = { q, rows: 0, useAuth: true };
        if (urlBase) opts.urlBase = urlBase;
        QueryService.queryWithFetch(opts)
          .then((response) => {
            if (response?.response?.numFound > 0) {
              this.labelTaken(label);
            } else if (
              tryAltRepo &&
              MetacatUI.appModel.get("alternateRepositories").length
            ) {
              MetacatUI.appModel.setActiveAltRepo();
              const activeAltRepo = MetacatUI.appModel.getActiveAltRepo();
              if (!activeAltRepo) {
                this.labelAvailable();
              } else {
                this.queryForLabel(label, activeAltRepo.queryServiceUrl, false);
              }
            } else {
              this.labelAvailable();
            }
          })
          .catch((e) => this.errorValidatingLabel(e));
      },

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

        $.ajax({
          url: MetacatUI.appModel.get("nodeServiceUrl"),
          dataType: "text",
          error(_data, _textStatus, _xhr) {
            // if there is an error in retrieving the node list; proceed with
            // the existing node list to perform the checks
            this.set("checkedNodeLabels", "true");
          },
          success(data, _textStatus, _xhr) {
            const 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} xml - The XMLDocument returned from the fetch()
       * AJAX call.
       */
      saveNodeBlockList(xml) {
        const model = this;
        const children = xml.children || xml.childNodes;

        // Traverse the XML response to get the MN info
        _.each(children, (d1NodeList) => {
          const d1NodeListChildren =
            d1NodeList.children || d1NodeList.childNodes;

          // The first (and only) child should be the d1NodeList
          _.each(d1NodeListChildren, (thisNode) => {
            // Ignore parts of the XML that is not MN info
            if (!thisNode.attributes) return;

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

            // Grab information about this node from XML nodes
            _.each(nodeProperties, (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
              ) {
                const 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, (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(nodes, models) {
        // Remove the extra nodes
        const extraNodes = nodes.length - models.length;
        if (extraNodes > 0) {
          for (let i = models.length; i < nodes.length; i += 1) {
            $(nodes[i]).remove();
          }
        }
      },

      /**
       * Saves the portal XML document to the server using the DataONE API.
       * @returns {(false|undefined)} False when validation fails; otherwise
       * undefined.
       */
      save() {
        // Remove empty filters from the custom portal search filters.
        this.get("filterGroups").forEach((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;
        }
        // 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.listenToOnce(this, "labelTaken", function handleLabelTaken() {
            // Stop listening to the label availability
            this.stopListening(this, "labelAvailable");

            this.trigger("invalid");
            this.trigger("cancelSave");
          });

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

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

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

        // Check if the checksum has been calculated yet.
        if (!this.get("checksum")) {
          // Serialize the XML
          const 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 undefined;
          }

          const 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 undefined;
        }

        CollectionModel.prototype.save.call(this);
        return undefined;
      },

      /**
       * 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(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;
              default:
                break;
            }
          }
          // If this section is a section model, delete it from this Portal
          else if (
            Object.prototype.isPrototypeOf.call(
              PortalSectionModel.prototype,
              section,
            )
          ) {
            // Copy the sections so setting the updated array triggers a change.
            const sectionModels = [...this.get("sections")];
            sectionModels.splice(sectionModels.indexOf(section), 1);
            this.set({ sections: sectionModels });
          }
        } catch (e) {
          // eslint-disable-next-line no-console
          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.
       * @returns {(PortalSectionModel|null)} The section that was added if it
       * was a markdown or cesium section, or null if it was a data, metrics, or
       * members section.
       */
      addSection(section) {
        let addedSection = null;
        // 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
              const newSection = new PortalSectionModel({
                portalModel: this,
                // Include a default image if some are configured.
                image: this.getRandomSectionImage(),
              });
              this.pushSectionToSectionsArray(newSection);
              addedSection = newSection;
              break;
            }
            case "cesium": {
              const newSection = new PortalVizSectionModel({
                portalModel: this,
                visualizationType: "cesium",
              });
              this.pushSectionToSectionsArray(newSection);
              addedSection = newSection;
              break;
            }
            default:
              break;
          }
        }
        // If this section is a section model, add it to this Portal
        else if (
          Object.prototype.isPrototypeOf.call(
            PortalSectionModel.prototype,
            section,
          )
        ) {
          this.pushSectionToSectionsArray(section);
          addedSection = section;
        }
        return addedSection;
      },

      /**
       * Adds the given section to the sections array on the model and triggers
       * an "addSection" event with the new section as an argument. Necessary
       * because Backbone doesn't trigger a "change" event when an array is
       * updated. Sections should be a collection instead of an array, but this
       * is a workaround for now.
       * @param {PortalSectionModel} newSection - The section to add to the
       * sections array on the model.
       * @since 2.41.0
       */
      pushSectionToSectionsArray(newSection) {
        const sectionModels = [...this.get("sections")];
        sectionModels.push(newSection);
        this.set("sections", sectionModels);
        // Trigger event manually so we can just pass newSection
        this.trigger("addSection", newSection);
      },

      /**
       * removePortalImage - remove a PortalImage model from either the logo,
       * sections, or acknowledgmentsLogos node of the portal model.
       * @param {PortalImage} portalImage - The portal image model to remove
       */
      removePortalImage(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"), (section, _i) => {
                if (portalImage === section.get("image")) {
                  section.set("image", section.defaults().image);
                }
              });
              break;
            case "acknowledgmentsLogo": {
              const ackLogos = [...this.get("acknowledgmentsLogos")];
              ackLogos.splice(ackLogos.indexOf(portalImage), 1);
              this.set({ acknowledgmentsLogos: ackLogos });
              break;
            }
            default:
              break;
          }
        } catch (e) {
          // eslint-disable-next-line no-console
          console.log(
            `Failed to remove a portalImage model, error message: ${e}`,
          );
        }
      },

      /** Saves a reference to this Portal on the MetacatUI global object */
      cachePortal() {
        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
       * @returns {string} The URL for viewing this portal.
       */
      createViewURL() {
        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(nodeInfoObject) {
        const nodeInfo = nodeInfoObject === undefined ? {} : nodeInfoObject;

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

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

        // Creating repo specific Filters
        const nodeFilterModel = new FilterModel({
          fields: ["datasource"],
          values: [nodeInfo.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
       * @returns {(string|undefined)} The cleaned string, or undefined for a
       * non-string value.
       */
      cleanXMLText(textString) {
        if (typeof textString !== "string") return undefined;

        let cleanText = textString.trim();

        // Check for XML/HTML elements
        _.each(cleanText.match(/<\s*[^>]*>/g), (xmlNode) => {
          // Encode <, >, and </ substrings
          let tagName = xmlNode.replace(/>/g, "&gt;");
          tagName = tagName.replace(/</g, "&lt;");

          // Replace the xmlNode in the full text string
          cleanText = cleanText.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)
        const invalidCharsRegEx =
          // eslint-disable-next-line no-control-regex -- XML 1.0 valid-character range.
          /[^\u0009\u000a\u000d\u0020-\uD7FF\uE000-\uFFFD]/g;
        cleanText = cleanText.replace(invalidCharsRegEx, "");

        return cleanText;
      },

      /**
       * Reports which portal section is active to every section model.
       * @param {PortalSectionModel} [model] - The active section model
       */
      reportSectionChange(model) {
        this.get("sections").forEach((section) => {
          section.reportSectionChange(model === section);
        });
      },
    },
  );

  return PortalModel;
});