Source: src/js/models/CollectionModel.js

define([
  "jquery",
  "underscore",
  "backbone",
  "common/ValueUtilities",
  "collections/Filters",
  "collections/SolrResults",
  "models/DataONEObject",
  "models/dataONEServices/IdentifierService",
  "models/filters/Filter",
  "models/filters/FilterGroup",
  "models/Search",
], (
  $,
  _,
  Backbone,
  ValueUtilities,
  Filters,
  SolrResults,
  DataONEObject,
  IdentifierService,
  Filter,
  FilterGroup,
  Search,
) => {
  /**
   * @class CollectionModel
   * @classdesc A collection of datasets, defined by one or more search filters
   * @classcategory Models
   * @name CollectionModel
   * @augments DataONEObject
   * @class
   */
  const CollectionModel = DataONEObject.extend(
    /** @lends CollectionModel.prototype */ {
      /**
       * The name of this Model
       * @type {string}
       * @readonly
       */
      type: "Collection",

      /**
       * Default attributes for CollectionModels
       * @type {object}
       * @property {string[]} ignoreQueryGroups - Deprecated
       * @property {FilterGroup} definition - The parent-level Filter Group
       * model that represents the collection definition.
       * @property {Filters} definitionFilters - A Filters collection that
       * stores definition filters that have been serialized to the Collection.
       * The same filters that are stored in the definition.
       * @property {Search} searchModel - A Search model with a Filters
       * collection that contains the filters associated with this collection
       * @property {SolrResults} searchResults - A SolrResults collection that
       * contains the filtered search results of datasets in this collection
       * @property {SolrResults} allSearchResults - A SolrResults collection
       * that contains the unfiltered search results of all datasets in this
       * collection
       */
      defaults() {
        return _.extend(DataONEObject.prototype.defaults(), {
          name: null,
          label: null,
          originalLabel: null,
          labelBlockList: ["new"],
          description: null,
          formatId: "https://purl.dataone.org/collections-1.1.0",
          formatType: "METADATA",
          type: "collection",
          definition: null,
          definitionFilters: null,
          searchModel: null,
          searchResults: new SolrResults(),
          allSearchResults: null,
        });
      },

      /**
       * The default Backbone.Model.initialize() function
       * @param {object} options - Initialization options
       */
      initialize(options) {
        // Call the super class initialize function
        DataONEObject.prototype.initialize.call(this, options);

        this.listenToOnce(
          this.get("searchResults"),
          "sync",
          this.cacheSearchResults,
        );

        // If the searchResults collection is replaced at any time, reset the
        // listener
        this.off("change:searchResults");
        this.on("change:searchResults", function onSearchResultsChange() {
          this.listenToOnce(
            this.get("searchResults"),
            "sync",
            this.cacheSearchResults,
          );
        });

        // Update the search model when the definition filters are updated.
        // Definition filters may be updated by the user in the Query Builder,
        // or they may be updated automatically by this model (e.g. when adding
        // an isPartOf filter).
        this.off("change:definition");
        this.on(
          "change:definition",
          function onDefinitionChange() {
            this.stopListening(this.get("definition"), "update change");
            this.listenTo(
              this.get("definition"),
              "update change",
              this.updateSearchModel,
            );
          },
          this,
        );

        // Create a search model
        this.set("searchModel", this.createSearchModel());

        // Create a Filters collection to store the definition filters. Add the
        // catalog search query fragment to the definition Filter Group model.
        this.set(
          "definition",
          new FilterGroup({ catalogSearch: true, nodeName: "definition" }),
        );
        this.set("definitionFilters", this.get("definition").get("filters"));

        // Update the blocklist with the node/repository labels
        const nodeBlockList = MetacatUI.appModel.get("portalLabelBlockList");
        if (nodeBlockList !== undefined && Array.isArray(nodeBlockList)) {
          this.set(
            "labelBlockList",
            this.get("labelBlockList").concat(nodeBlockList),
          );
        }
      },

      /**
       * updateSearchModel - This function is called when any changes are made
       * to the definition filters. The function adds, removes, or updates
       * models in the Search Model filters when models are changed in the
       * collection Definition Filters.
       */
      updateSearchModel() {
        try {
          // Merge the updated definition Filter Group model with the Search
          // Model collection.
          this.get("searchModel")
            .get("filters")
            .add(this.get("definition"), { merge: true });
        } catch (e) {
          // eslint-disable-next-line no-console
          console.log(
            `Failed to update the Search Model collection when the ` +
              `Definition Model collection changed, error message: ${e}`,
          );
        }
      },

      /**
       * Returns the object service URL for this collection.
       * @returns {string} The object URL
       */
      url() {
        return (
          MetacatUI.appModel.get("objectServiceUrl") +
          encodeURIComponent(this.get("id"))
        );
      },

      /**
       * Overrides the default Backbone.Model.fetch() function to provide some
       * custom fetch options
       * @returns {jqXHR} The fetch request
       */
      fetch() {
        const model = this;

        let requestSettings = {
          dataType: "xml",
          error() {
            model.trigger("error");
          },
        };

        // Add the authorization header and other AJAX settings
        requestSettings = _.extend(
          requestSettings,
          MetacatUI.appUserModel.createAjaxSettings(),
        );
        return Backbone.Model.prototype.fetch.call(this, requestSettings);
      },

      /**
       * Sends an AJAX request to fetch the system metadata for this object.
       * Will not trigger a sync event since it does not use
       * Backbone.Model.fetch
       * @param {object} [options] - AJAX settings to use for the request
       */
      fetchSystemMetadata(options) {
        const requestOptions = options ? _.clone(options) : {};

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

        const baseUrl = activeAltRepo
          ? activeAltRepo.metaServiceUrl
          : MetacatUI.appModel.get("metaServiceUrl");

        // Exit if no base URL was found
        if (!baseUrl) {
          return;
        }

        const model = this;
        const fetchOptions = _.extend(
          {
            url:
              baseUrl +
              encodeURIComponent(this.get("id") || this.get("seriesId")),
            dataType: "text",
            success(response) {
              model.set(DataONEObject.prototype.parse.call(model, response));
              model.trigger("systemMetadataSync");
            },
            error() {
              model.trigger("error");
            },
          },
          requestOptions,
        );

        // Add the authorization header and other AJAX settings
        _.extend(fetchOptions, MetacatUI.appUserModel.createAjaxSettings());

        $.ajax(fetchOptions);
      },

      /**
       * Overrides the default Backbone.Model.parse() function to parse the
       * custom collection XML document
       * @param {XMLDocument} response - The XMLDocument returned from the
       * fetch() AJAX call
       * @returns {JSON} The result of the parsed XML, in JSON. To be set
       * directly on the model.
       */
      parse(response) {
        // Start the empty JSON object
        let collectionNode;

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

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

        // Parse the collection XML and return it
        return this.parseCollectionXML(collectionNode);
      },

      /**
       * Parses the collection XML into a JSON object
       * @param {Element} rootNode - The XML Element that contains all the
       * collection nodes
       * @returns {JSON} The result of the parsed XML, in JSON. To be set
       * directly on the model.
       */
      parseCollectionXML(rootNode) {
        // Get and save the namespace version number. It should be 1.0.0 or
        // 1.1.0. Version 1.0.0 portals will be updated to 1.1.0 on save. We
        // need to know which version while parsing to keep rendering of the old
        // versions of collections/portals consistent with how they were
        // rendered before MetacatUI was updated to handle 1.1.0
        // collections/portals - e.g. the fieldsOperator attribute in filters.
        const namespace = rootNode.namespaceURI;
        const versionRegex = /\d\.\d\.\d$/g;
        const version = namespace.match(versionRegex);
        if (version && version.length && version[0] !== "") {
          this.set("originalVersion", version[0]);
        }

        let modelJSON = {};

        // Parse the simple text nodes
        modelJSON.name = this.parseTextNode(rootNode, "name");
        modelJSON.label = this.parseTextNode(rootNode, "label");
        modelJSON.description = this.parseTextNode(rootNode, "description");

        // Create a Filters collection to contain the collection definition
        // Filters
        const definitionXML = rootNode.getElementsByTagName("definition")[0];
        // Add the catalog search query fragment to the definition Filter Group
        // model
        modelJSON.definition = new FilterGroup({
          objectDOM: definitionXML,
          catalogSearch: true,
        });
        modelJSON.definitionFilters = modelJSON.definition.get("filters");

        // Create a Search model for this collection's filters
        modelJSON.searchModel = this.createSearchModel();
        // Add all the filters from the Collection definition to the search
        // model as a single Filter Group model.
        modelJSON.searchModel.get("filters").add(modelJSON.definition);

        // If we are parsing the first version of a collection or portal
        if (this.get("originalVersion") === "1.0.0") {
          modelJSON = this.updateTo110(modelJSON);
        }

        return modelJSON;
      },

      /**
       * Takes parsed Collections 1.0.0 XML in JSON format and makes any changes
       * required so that collections are still represented in MetacatUI as they
       * were before MetacatUI was updated to support 1.1.0 Collections.
       * @param {JSON} modelJSON Parsed 1.0.0 Collections XML, in JSON
       * @returns {JSON} The updated JSON, compatible with 1.1.0 changes
       */
      updateTo110(modelJSON) {
        try {
          // For version 1.0.0 filters, MetacatUI used the "operator" attribute
          // to set the operator between both fields and values. In 1.1.0, we
          // now have the "fieldsOperator" attribute. (Since "AND" was the
          // default, only the "OR" operator is ever serialized). Therefore, if
          // a version 1.0.0 filter has "OR" as the operator, we should also set
          // the "fieldOperator" to "OR".
          modelJSON.definitionFilters.each((filterModel) => {
            if (filterModel.get("operator") === "OR") {
              filterModel.set("fieldsOperator", "OR");
            }
          }, this);
          return modelJSON;
        } catch (error) {
          // eslint-disable-next-line no-console
          console.log(
            `Error trying to update a 1.0.0 Collection to 1.1.0 ` +
              `returning the JSON unchanged. Error details: ${error}`,
          );
          return modelJSON;
        }
      },

      /**
       * Generate a UUID, reserve it using the DataOne API, and set it on the
       * model
       * @returns {Promise<void>} Resolves when reservation finishes
       */

      async reserveSeriesId() {
        // Create a new series ID
        let seriesId = ValueUtilities.makeUUID();

        // Set the seriesId on the portal model right away, since reserving
        // takes time. This will be updated in the rare case that the first
        // seriesId was already taken.
        this.set("seriesId", seriesId);

        const identifierService = new IdentifierService();
        const retryLimit = 5;
        let tryCount = 0;
        let response;

        while (tryCount <= retryLimit) {
          try {
            // Each retry depends on the preceding collision response.
            // eslint-disable-next-line no-await-in-loop
            response = await identifierService.reserveIdentifier(seriesId);

            // If the first seriesId was taken, update the model with the
            // successfully reserved seriesId.
            if (tryCount > 0) {
              this.set("seriesId", response.data.identifier);
            }
            return;
          } catch (error) {
            const status = Number(error.status);

            if (status !== 409) {
              // If the user isn't logged in, or doesn't have write access.
              if (status === 401) {
                this.set("isAuthorized", false);
              } else {
                this.set("errorMessage", error.message);
              }
              return;
            }

            if (tryCount === retryLimit) return;
            seriesId = ValueUtilities.makeUUID();
            tryCount += 1;
          }
        }
      },

      /**
       * Creates a FilterModel with isPartOf as the field and this collection's
       * seriesId as the value, then adds it to the definitionFilters
       * collection. (which will also add it to the searchFilters collection)
       * @param {string} [seriesId] - the seriesId of the collection or portal
       * @returns {Filter|undefined} The new isPartOf filter, if created
       */
      addIsPartOfFilter(seriesId) {
        try {
          let filterSeriesId = seriesId;
          // If no seriesId is given
          if (!filterSeriesId) {
            // Use the seriesId set on the model
            if (this.get("seriesId")) {
              filterSeriesId = this.get("seriesId");
              // If there's no seriesId on the model, make and reserve one
            } else {
              // Create and reserve a new seriesId
              this.reserveSeriesId();
              filterSeriesId = this.get("seriesId");

              // Set a listener to create an isPartOf filter using the seriesId
              // once the series Id is set. Just in case the first seriesId
              // generated was already reserved, update the isPartOf filters on
              // the subsequent attempts to create and resere an ID.
              this.on("change:seriesId", function onSeriesIdChange() {
                this.addIsPartOfFilter();
              });
            }
          }

          // Create the new isPartOf filter attributes object NOTE: All other
          // attributes are set in Filter.initialize();
          const isPartOfAttributes = {
            fields: ["isPartOf"],
            values: [filterSeriesId],
            matchSubstring: false,
            operator: "OR",
          };

          // Remove any existing isPartOf filters, and add the new isPartOf
          // filter

          // NOTE:
          // 1. Changes to the definition filters will automatically update the
          //    Search Model filters because of the listener set in
          //    initialize().
          // 2. Add the new Filter model by passing a list of attributes to the
          //    Filters collection, instead of by passing a new Filter, in order
          //    to trigger 'update' and 'change' events for other models and
          //    views.

          this.get("definitionFilters").removeFiltersByField("isPartOf");
          const filterModel =
            this.get("definitionFilters").add(isPartOfAttributes);

          return filterModel;
        } catch (e) {
          // eslint-disable-next-line no-console
          console.log(
            `Failed to create and add a new isPartOf Filter, error message: ${e}`,
          );
          return undefined;
        }
      },

      /**
       * Gets the text content of the XML node matching the given node name
       * @param {Element} parentNode - The parent node to select from
       * @param {string} nodeName - The name of the XML node to parse
       * @param {boolean} isMultiple - If true, parses the nodes into an array
       * @returns {(string|Array)} - Returns a string or array of strings of the
       * text content
       */
      parseTextNode(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 node[0].textContent.trim();
        }
        // If more than one node is found, parse into an array

        return _.map(node, (childNode) => childNode.textContent.trim() || null);
      },

      /**
       * Updates collection XML with values in the collection model
       * @param {XMLDocument} objectDOM the XML element to be updated
       * @returns {XMLElement} An updated XML element
       */
      updateCollectionDOM(objectDOM) {
        // Get or make objectDOM
        let collectionDOM = objectDOM;
        if (!collectionDOM) {
          if (this.get("objectDOM")) {
            collectionDOM = this.get("objectDOM").cloneNode(true);
            $(collectionDOM).empty();
          } else {
            // create an XML collection element from scratch
            collectionDOM = $(this.createXML()).children().get(0);
          }
        }

        // Set schema version. May need to be updated from 1.0.0 to 1.1.0. The
        // formatId is the same as the namespace URI.
        const currentNamespace = this.defaults().formatId;

        // The NS attribute name could be xmlns:por or xmlns:col
        Array.from(collectionDOM.attributes).forEach((attr) => {
          if (attr.name.match(/^xmlns/)) {
            if (attr.value !== currentNamespace) {
              const newObjectDOM = this.createXML().documentElement;
              while (collectionDOM.firstChild) {
                newObjectDOM.appendChild(collectionDOM.firstChild);
              }
              collectionDOM = newObjectDOM;
            }
          }
        });

        // Remove definition node if it exists in XML already
        $(collectionDOM).find("definition").remove();

        // Get the filters that are currently applied to the search.
        const definitionSerialized = this.get("definition").updateDOM();
        collectionDOM.ownerDocument.adoptNode(definitionSerialized);

        // If at least one filter was serialized, add the <definition> node
        if (definitionSerialized.childNodes.length) {
          $(collectionDOM).prepend(definitionSerialized);
        }

        // Get and update the simple text strings (everything but definition) in
        // reverse order because we prepend them consecutively to objectDOM
        const collectionTextData = {
          description: this.get("description"),
          name: this.get("name"),
          label: this.get("label"),
        };

        _.map(collectionTextData, (value, nodeName) => {
          // Remove the node if it exists Use children() and not find() as there
          // are sub-children named label
          $(collectionDOM).children(nodeName).remove();

          // Don't serialize falsey values
          if (value && typeof value === "string" && value.trim().length) {
            // Make new sub-node
            const collectionSubnodeSerialized =
              collectionDOM.ownerDocument.createElement(nodeName);
            $(collectionSubnodeSerialized).text(value);
            // Append new sub-node to the start of the objectDOM
            $(collectionDOM).prepend(collectionSubnodeSerialized);
          }
        });

        return collectionDOM;
      },

      /**
       * Initialize the object XML for a brand spankin' new collection
       * @returns {XMLDocument} The new collection document
       */
      createXML() {
        const xmlString =
          '<col:collection xmlns:col="https://purl.dataone.org/collections-1.1.0"></col:collection>';
        const xmlNew = $.parseXML(xmlString);
        const colNode = xmlNew.getElementsByTagName("col:collection")[0];

        // set attributes
        colNode.setAttribute(
          "xmlns:xsi",
          "http://www.w3.org/2001/XMLSchema-instance",
        );
        colNode.setAttribute(
          "xsi:schemaLocation",
          "https://purl.dataone.org/collections-1.1.0",
        );

        this.set("ownerDocument", colNode.ownerDocument);

        return xmlNew;
      },

      /**
       * Creates a new instance of a Search model with a Filters collection. The
       * Search model is created and returned and NOT set directly on the model
       * in this function, because this function is called during parse(), when
       * we're not ready to set attributes directly on the model yet.
       * @returns {Search} A search model with an empty Filters collection
       */
      createSearchModel() {
        const search = new Search();
        // Do not set "catalogSearch" to true, even though the search model is
        // specifically created in order to do a catalog search. Instead, we set
        // the definition filterGroup model catalogSearch = true. This allows us
        // to append the query fragment with ID fields AFTER the catalog query
        // fragment.
        search.set("filters", new Filters());
        return search;
      },

      /**
       * This is a shortcut function that returns the query for the datasets in
       *  this portal, using the Search model for this portal. This is the full
       *  query that includes the filters not serialized to the portal XML, such
       *  as the filters used for the DataCatalogView.
       * @returns {string} The collection's search query
       */
      getQuery() {
        return this.get("definition").getQuery();
      },

      /**
       * Creates a copy of the SolrResults collection and saves it in this model
       * so that there is always access to the unfiltered list of datasets
       * @param {SolrResults} searchResults - The SolrResults collection to
       * cache
       */
      cacheSearchResults(searchResults) {
        // Save a copy of the SolrResults so that we always have a copy of the
        // unfiltered list of datasets
        this.set("allSearchResults", searchResults.clone());

        // Make a copy of the facetCounts object
        const allSearchResults = this.get("allSearchResults");
        allSearchResults.facetCounts = {
          ...searchResults.facetCounts,
        };
      },

      /**
       * 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} Error messages, if any
       */
      validate(attrs, options) {
        try {
          const errors = {};

          // Validate label
          const labelError = this.validateLabel(this.get("label"));
          if (labelError) {
            errors.label = labelError;
          }

          // Validate the definition
          const definitionError = this.get("definition").validate(
            attrs,
            options,
          );

          if (definitionError) {
            if (definitionError.noFilters) {
              const type = this.type.toLowerCase();
              errors.definition =
                `Your dataset collection hasn't been created. Add at ` +
                `least one query rule below to find datasets for this ${type}. For example, to create a ${type} for datasets from a specific ` +
                `research project, try using the project name field.`;
            } else {
              // Just show the first error for now.
              [errors.definition] = Object.values(definitionError);
            }
          }

          if (Object.keys(errors).length) {
            // eslint-disable-next-line no-console
            console.log(errors);
            return errors;
          }
        } catch (e) {
          // eslint-disable-next-line no-console
          console.error(e);
        }
        return undefined;
      },

      /**
       * Checks that a label does not equal a restricted value (e.g. new
       * temporary name), and that it's encoded properly for use as part of a
       * url
       * @param {string} label - The label to be validated
       * @returns {string|undefined} An error message if the label is invalid
       */
      validateLabel(label) {
        try {
          let labelToValidate = label;
          // Validate the label set on the model if one isn't given
          if (typeof labelToValidate !== "string") {
            labelToValidate = this.get("label");
          }

          // If the label is not a string or is an empty string
          if (
            typeof labelToValidate !== "string" ||
            !labelToValidate.trim().length
          ) {
            // Convert numbers to strings
            if (typeof labelToValidate === "number") {
              labelToValidate = labelToValidate.toString();
            } else {
              const type = this.type.toLowerCase();
              return `Please choose a name for this ${type} to use in the URL.`;
            }
          }

          // If the label is a restricted string
          const blockList = this.get("labelBlockList");
          if (blockList && Array.isArray(blockList)) {
            if (blockList.includes(labelToValidate)) {
              return "This URL is already taken, please try something else";
            }
          }

          // If the label includes illegal characters (Only allow letters,
          // numbers, underscores and dashes)
          if (labelToValidate.match(/[^A-Za-z0-9_-]/g)) {
            return "URLs may only contain letters, numbers, underscores (_), and dashes (-).";
          }
        } catch (e) {
          // Trigger an error event
          this.trigger("errorValidatingLabel");
          // eslint-disable-next-line no-console
          console.error(e);
        }
        return undefined;
      },
    },
  );

  return CollectionModel;
});