Source: src/js/models/Search.js

define([
  "jquery",
  "underscore",
  "backbone",
  "models/SolrResult",
  "collections/Filters",
], function ($, _, Backbone, SolrResult, Filters) {
  "use strict";

  /**
   * @class Search
   * @classdesc Search filters can be either plain text or a filter object with the following options:
   * filterLabel - text that will be displayed in the filter element in the UI
   * label - text that will be displayed in the autocomplete  list
   * value - the value that will be included in the query
   * description - a longer text description of the filter value
   * Example: {filterLabel: "Creator", label: "Jared Kibele (16)", value: "Kibele", description: "Search for data creators"}
   * @classcategory Models
   * @extends Backbone.Model
   * @constructor
   */
  var Search = Backbone.Model.extend(
    /** @lends Search.prototype */ {
      /**
       * @type {object}
       * @property {Filters} filters - The collection of filters used to build a query, an instance of Filters
       */
      defaults: function () {
        return {
          all: [],
          projectText: [],
          creator: [],
          taxon: [],
          isPrivate: null,
          documents: false,
          resourceMap: false,
          yearMin: 1900, //The user-selected minimum year
          yearMax: new Date().getUTCFullYear(), //The user-selected maximum year
          pubYear: false,
          dataYear: false,
          sortOrder: "dateUploaded+desc",
          sortByReads: false, // True if we can sort by reads/popularity
          east: null,
          west: null,
          north: null,
          south: null,
          useGeohash: true,
          geohashes: [],
          geohashLevel: 9,
          geohashGroups: {},
          dataSource: [],
          username: [],
          rightsHolder: [],
          submitter: [],
          spatial: [],
          attribute: [],
          sem_annotation: [],
          annotation: [],
          additionalCriteria: [],
          id: [],
          seriesId: [],
          idOnly: [],
          provFields: [],
          formatType: [
            {
              value: "METADATA",
              label: "science metadata",
              description: null,
            },
          ],
          exclude: [
            {
              field: "obsoletedBy",
              value: "*",
            },
            {
              field: "formatId",
              value: "*dataone.org/collections*",
            },
            {
              field: "formatId",
              value: "*dataone.org/portals*",
            },
          ],
          filters: null,
        };
      },

      //A list of all the filter names that are related to the spatial/map filter
      spatialFilters: [
        "useGeohash",
        "geohashes",
        "geohashLevel",
        "geohashGroups",
        "east",
        "west",
        "north",
        "south",
      ],

      initialize: function () {
        this.listenTo(this, "change:geohashes", this.groupGeohashes);
      },

      fieldLabels: {
        attribute: "Data attribute",
        documents: "Only results with data",
        annotation: "Annotation",
        dataSource: "Data source",
        creator: "Creator",
        dataYear: "Data coverage",
        pubYear: "Publish year",
        id: "Identifier",
        seriesId: "seriesId",
        taxon: "Taxon",
        spatial: "Location",
        isPrivate: "Private datasets",
        all: "",
        projectText: "Project",
      },

      //Map the filter names to their index field names
      fieldNameMap: {
        attribute: "attribute",
        annotation: "sem_annotation",
        dataSource: "datasource",
        documents: "documents",
        formatType: "formatType",
        all: "",
        creator: "originText",
        spatial: "siteText",
        resourceMap: "resourceMap",
        pubYear: ["datePublished", "dateUploaded"],
        id: ["id", "identifier", "documents", "resourceMap", "seriesId"],
        idOnly: ["id", "seriesId"],
        rightsHolder: "rightsHolder",
        submitter: "submitter",
        username: ["rightsHolder", "writePermission", "changePermission"],
        taxon: [
          "kingdom",
          "phylum",
          "class",
          "order",
          "family",
          "genus",
          "species",
        ],
        isPrivate: "isPublic",
        projectText: "projectText",
      },

      facetNameMap: {
        creator: "origin",
        attribute: "attribute",
        annotation: "sem_annotation",
        spatial: "site",
        taxon: [
          "kingdom",
          "phylum",
          "class",
          "order",
          "family",
          "genus",
          "species",
        ],
        isPublic: "isPublic",
        all: "keywords",
        projectText: "project",
      },

      getCurrentFilters: function () {
        var changedAttr = this.changedAttributes(_.clone(this.defaults())),
          currentFilters = Object.keys(changedAttr),
          ignoreAttr = ["sortOrder", "provFields"];

        if (!changedAttr) return new Array();

        //Check for changed attributes that should be ignored
        _.each(
          Object.keys(changedAttr),
          function (attr) {
            //If the value is an empty array, but the default value is an empty array too,
            // then it's not a changed filter attribute
            if (
              Array.isArray(this.get(attr)) &&
              this.get(attr).length == 0 &&
              Array.isArray(this.defaults()[attr]) &&
              this.defaults()[attr].length == 0
            ) {
              currentFilters = _.without(currentFilters, attr);
            } else if (ignoreAttr.includes(attr)) {
              currentFilters = _.without(currentFilters, attr);
            }
          },
          this,
        );

        //Don't count the geohashes or directions as a filter if the geohash filter is turned off
        if (!this.get("useGeohash")) {
          currentFilters = _.difference(currentFilters, this.spatialFilters);
        }

        return currentFilters;
      },

      filterCount: function () {
        var currentFilters = this.getCurrentFilters();

        return currentFilters.length;
      },

      //Function filterIsAvailable will check if a filter is available in this search index -
      //if the filter name if included in the defaults of this model, it is marked as available.
      //Comment out or remove defaults that are not in the index or should not be included in queries
      filterIsAvailable: function (name) {
        //Get the keys for this model as a way to list the filters that are available
        var defaults = _.keys(this.defaults());
        if (_.indexOf(defaults, name) >= 0) {
          return true;
        } else {
          return false;
        }
      },

      /*
       * Removes a specified filter from the search model
       */
      removeFromModel: function (category, filterValueToRemove) {
        //Remove this filter term from the model
        if (category) {
          //Get the current filter terms array
          var currentFilterValues = this.get(category);

          //The year filters have special rules
          //If both year types will be reset/default, then also reset the year min and max values
          if (category == "pubYear" || category == "dataYear") {
            var otherType = category == "pubYear" ? "dataYear" : "pubYear";

            if (_.contains(this.getCurrentFilters(), otherType))
              var newFilterValues = this.defaults()[category];
            else {
              this.set(category, this.defaults()[category]);
              this.set("yearMin", this.defaults()["yearMin"]);
              this.set("yearMax", this.defaults()["yearMax"]);
              return;
            }
          } else if (Array.isArray(currentFilterValues)) {
            //Remove this filter term from the array
            var newFilterValues = _.without(
              currentFilterValues,
              filterValueToRemove,
            );
            _.each(currentFilterValues, function (currentFilterValue, key) {
              var valueString =
                typeof currentFilterValue == "object"
                  ? currentFilterValue.value
                  : currentFilterValue;
              if (valueString == filterValueToRemove) {
                newFilterValues = _.without(
                  newFilterValues,
                  currentFilterValue,
                );
              }
            });
          } else {
            //Get the default value
            var newFilterValues = this.defaults()[category];
          }

          //Set the new value
          this.set(category, newFilterValues);
        }
      },

      /**
       *
       * @param {Filters|Filter[]} filters The collection of filters to add to this model OR an array of Filter models
       */
      addFilters: function (filters) {
        try {
          let currentFilters = this.get("filters");

          //If the passed collection is the same as the one set already, return
          if (currentFilters == filters) return;
          //If the given Filters collec is different than the one set on the model now, combine them
          else if (
            Filters.isPrototypeOf(currentFilters) &&
            Filters.isPrototypeOf(filters)
          ) {
            filters.models.forEach((f) => {
              currentFilters.add(f);
            });
            this.set("filters", currentFilters);
          } else if (
            Filters.isPrototypeOf(currentFilters) &&
            Array.isArray(filters)
          ) {
            filters.forEach((f) => {
              currentFilters.add(f);
            });
            this.set("filters", currentFilters);
          } else if (!currentFilters) this.set("filters", new Filters(filters));
        } catch (e) {
          console.error("Couldn't add Filters to the Search model: ", e);
        }
      },

      /*
       * Resets the geoashes and geohashLevel filters to default
       */
      resetGeohash: function () {
        this.set("geohashes", this.defaults().geohashes);
        this.set("geohashLevel", this.defaults().geohashLevel);
        this.set("geohashGroups", this.defaults().geohashGroups);
      },

      groupGeohashes: function () {
        //Find out if there are any geohashes that can be combined together, by looking for all 32 geohashes within the same precision level
        var sortedGeohashes = this.get("geohashes");
        sortedGeohashes.sort();

        var groupedGeohashes = _.groupBy(sortedGeohashes, function (n) {
          return n.substring(0, n.length - 1);
        });

        //Find groups of geohashes that makeup a complete geohash tile (32) so we can shorten the query
        var completeGroups = _.filter(
          Object.keys(groupedGeohashes),
          function (n) {
            return groupedGeohashes[n].length == 32;
          },
        );
        //Find the remaining incomplete geohash groupss
        var incompleteGroups = [];
        _.each(
          _.filter(Object.keys(groupedGeohashes), function (n) {
            return groupedGeohashes[n].length < 32;
          }),
          function (n) {
            incompleteGroups.push(groupedGeohashes[n]);
          },
        );
        incompleteGroups = _.flatten(incompleteGroups);

        //Start a geohash group object
        var geohashGroups = {};
        if (
          typeof incompleteGroups !== "undefined" &&
          incompleteGroups.length > 0
        ) {
          geohashGroups[incompleteGroups[0].length.toString()] =
            incompleteGroups;
        }
        if (
          typeof completeGroups !== "undefined" &&
          completeGroups.length > 0
        ) {
          geohashGroups[completeGroups[0].length.toString()] = completeGroups;
        }
        //Save it
        this.set("geohashGroups", geohashGroups);
        this.trigger("change", "geohashGroups");
      },

      hasGeohashFilter: function () {
        var currentGeohashFilter = this.get("geohashGroups");
        return (
          typeof currentGeohashFilter == "object" &&
          Object.keys(currentGeohashFilter).length > 0
        );
      },

      /**
       * Builds the query string to send to the query engine. Goes over each filter specified in this model and adds to the query string.
       * Some filters have special rules on how to format the query, which are built first, then the remaining filters are tacked on to the
       * query string as a basic name:value pair. These "other filters" are specified in the otherFilters variable.
       * @param {string} filter - A single filter to get a query fragment for
       * @param {object} options - Additional options for this function
       * @property {boolean} options.forPOST - If true, the query will not be url-encoded, for POST requests
       */
      getQuery: function (filter, options) {
        //----All other filters with a basic name:value pair pattern----
        var otherFilters = [
          "attribute",
          "formatType",
          "rightsHolder",
          "submitter",
        ];

        //Start the query string
        var query = "",
          forPOST = false;

        //See if we are looking for a sub-query or a query for all filters
        if (typeof filter == "undefined") {
          var filter = null;
          var getAll = true;
        } else {
          var getAll = false;
        }

        //Get the options sent to this function via the options object
        if (typeof options == "object" && options) {
          forPOST = options.forPOST;
        }

        var model = this;

        //-----Annotation-----
        if (
          this.filterIsAvailable("annotation") &&
          (filter == "annotation" || getAll)
        ) {
          var annotations = this.get("annotation");
          _.each(annotations, function (annotationFilter, key, list) {
            var filterValue = "";

            //Get the filter value
            if (typeof annotationFilter == "object") {
              filterValue = annotationFilter.value || "";
            } else {
              filterValue = annotationFilter;
            }

            // Trim leading and trailing whitespace just in case
            filterValue = filterValue.trim();

            if (forPOST) {
              // Encode and wrap URI in urlencoded double quote chars
              filterValue = '"' + filterValue.trim() + '"';
            } else {
              // Encode and wrap URI in urlencoded double quote chars
              filterValue =
                "%22" + encodeURIComponent(filterValue.trim()) + "%22";
            }

            query += model.fieldNameMap["annotation"] + ":" + filterValue;
          });
        }

        //---Identifier---
        if (
          this.filterIsAvailable("id") &&
          (filter == "id" || getAll) &&
          this.get("id").length
        ) {
          var identifiers = this.get("id");

          if (Array.isArray(identifiers)) {
            if (query.length) {
              query += " AND ";
            }

            query += this.getGroupedQuery(
              this.fieldNameMap["id"],
              identifiers,
              {
                operator: "OR",
                subtext: true,
              },
            );
          } else if (identifiers) {
            if (query.length) {
              query += " AND ";
            }

            if (forPOST) {
              query +=
                this.fieldNameMap["id"] +
                ":*" +
                this.escapeSpecialChar(identifiers) +
                "*";
            } else {
              query +=
                this.fieldNameMap["id"] +
                ":*" +
                this.escapeSpecialChar(encodeURIComponent(identifiers)) +
                "*";
            }
          }
        }

        //---resourceMap---
        if (
          this.filterIsAvailable("resourceMap") &&
          (filter == "resourceMap" || getAll)
        ) {
          var resourceMap = this.get("resourceMap");

          //If the resource map search setting is a list of resource map IDs
          if (Array.isArray(resourceMap)) {
            if (query.length) {
              query += " AND ";
            }

            query += this.getGroupedQuery(
              this.fieldNameMap["resourceMap"],
              resourceMap,
              {
                operator: "OR",
                forPOST: forPOST,
              },
            );
          } else if (resourceMap) {
            if (query.length) {
              query += " AND ";
            }
            //Otherwise, treat it as a binary setting
            query += this.fieldNameMap["resourceMap"] + ":*";
          }
        }

        //---documents---
        if (
          this.filterIsAvailable("documents") &&
          (filter == "documents" || getAll)
        ) {
          var documents = this.get("documents");

          //If the documents search setting is a list ofdocuments IDs
          if (Array.isArray(documents)) {
            if (query.length) {
              query += " AND ";
            }

            query += this.getGroupedQuery(
              this.fieldNameMap["documents"],
              documents,
              {
                operator: "OR",
                forPOST: forPOST,
              },
            );
          } else if (documents) {
            if (query.length) {
              query += " AND ";
            }
            //Otherwise, treat it as a binary setting
            query += this.fieldNameMap["documents"] + ":*";
          }
        }

        //---Username: search for this username in rightsHolder and submitter ---
        if (
          this.filterIsAvailable("username") &&
          (filter == "username" || getAll) &&
          this.get("username").length
        ) {
          var username = this.get("username");
          if (username) {
            if (query.length) {
              query += " AND ";
            }

            query += this.getGroupedQuery(
              this.fieldNameMap["username"],
              username,
              {
                operator: "OR",
                forPOST: forPOST,
              },
            );
          }
        }

        //--- ID Only - searches only the id and seriesId fields ---
        if (
          this.filterIsAvailable("idOnly") &&
          (filter == "idOnly" || getAll) &&
          this.get("idOnly").length
        ) {
          var idOnly = this.get("idOnly");
          if (idOnly) {
            if (query.length) {
              query += " AND ";
            }

            query += this.getGroupedQuery(this.fieldNameMap["idOnly"], idOnly, {
              operator: "OR",
              forPOST: forPOST,
            });
          }
        }

        //---Taxon---
        if (
          this.filterIsAvailable("taxon") &&
          (filter == "taxon" || getAll) &&
          this.get("taxon").length
        ) {
          var taxon = this.get("taxon");

          for (var i = 0; i < taxon.length; i++) {
            var value =
              typeof taxon == "object" ? taxon[i].value : taxon[i].trim();

            query += this.getMultiFieldQuery(
              this.fieldNameMap["taxon"],
              value,
              {
                subtext: true,
                forPOST: forPOST,
              },
            );
          }
        }

        //------Pub Year-----
        if (
          this.filterIsAvailable("pubYear") &&
          (filter == "pubYear" || getAll)
        ) {
          //Get the types of year to be searched first
          var pubYear = this.get("pubYear");
          if (pubYear) {
            //Get the minimum and maximum years chosen
            var yearMin = this.get("yearMin");
            var yearMax = this.get("yearMax");

            if (query.length) {
              query += " AND ";
            }

            var value =
              "[" +
              yearMin +
              "-01-01T00:00:00Z TO " +
              yearMax +
              "-12-31T00:00:00Z]";
            var opts = {
              forPOST: forPOST,
              escapeSquareBrackets: false,
            };

            //Add to the query if we are searching publication year
            query += this.getMultiFieldQuery(
              this.fieldNameMap["pubYear"],
              value,
              opts,
            );
          }
        }

        //-----Data year------
        if (
          this.filterIsAvailable("dataYear") &&
          (filter == "dataYear" || getAll)
        ) {
          var dataYear = this.get("dataYear");

          if (dataYear) {
            //Get the minimum and maximum years chosen
            var yearMin = this.get("yearMin");
            var yearMax = this.get("yearMax");

            if (query.length) {
              query += " AND ";
            }

            query +=
              "beginDate:[" +
              yearMin +
              "-01-01T00:00:00Z TO *]" +
              " AND endDate:[* TO " +
              yearMax +
              "-12-31T00:00:00Z]";
          }
        }

        //----- public/private ------
        if (
          this.filterIsAvailable("isPrivate") &&
          (filter == "isPrivate" || getAll)
        ) {
          var isPrivate = this.get("isPrivate");
          if (isPrivate !== null && isPrivate !== "undefined") {
            if (query.length) {
              query += " AND ";
            }

            // Currently, the Solr field 'isPublic' can be set to true or false, or not set.
            // isPrivate is equivalent to "isPublic:false" or isPublic not set
            if (isPrivate) {
              query += "-isPublic:true";
            }
          }
        }

        //-----Data Source--------
        if (
          this.filterIsAvailable("dataSource") &&
          (filter == "dataSource" || getAll)
        ) {
          var filterValue = null;
          var filterValues = [];

          if (this.get("dataSource").length > 0) {
            var objectValues = _.filter(this.get("dataSource"), function (v) {
              return typeof v == "object";
            });
            if (objectValues && objectValues.length) {
              filterValues.push(_.pluck(objectValues, "value"));
            }
          }

          var stringValues = _.filter(this.get("dataSource"), function (v) {
            return typeof v == "string";
          });
          if (stringValues && stringValues.length) {
            filterValues.push(stringValues);
          }

          filterValues = _.flatten(filterValues);

          if (filterValues.length) {
            if (query.length) {
              query += " AND ";
            }

            query += this.getGroupedQuery(
              this.fieldNameMap["dataSource"],
              filterValues,
              {
                operator: "OR",
                forPOST: forPOST,
              },
            );
          }
        }

        //-----Excluded fields-----
        if (
          this.filterIsAvailable("exclude") &&
          (filter == "exclude" || getAll)
        ) {
          var exclude = this.get("exclude");
          _.each(exclude, function (excludeField, key, list) {
            if (model.needsQuotes(excludeField.value)) {
              if (forPOST) {
                var filterValue = '"' + excludeField.value + '"';
              } else {
                var filterValue =
                  "%22" + encodeURIComponent(excludeField.value) + "%22";
              }
            } else {
              if (forPOST) {
                var filterValue = excludeField.value;
              } else {
                var filterValue = encodeURIComponent(excludeField.value);
              }
            }

            filterValue = model.escapeSpecialChar(filterValue);

            if (query.length) {
              query += " AND ";
            }

            query += " -" + excludeField.field + ":" + filterValue;
          });
        }

        //-----Additional criteria - both field and value are provided-----
        if (
          this.filterIsAvailable("additionalCriteria") &&
          (filter == "additionalCriteria" || getAll)
        ) {
          var additionalCriteria = this.get("additionalCriteria");
          for (var i = 0; i < additionalCriteria.length; i++) {
            var value;

            if (forPOST) {
              value = additionalCriteria[i];
            } else {
              //if(this.needsQuotes(additionalCriteria[i])) value = "%22" + encodeURIComponent(additionalCriteria[i]) + "%22";
              value = encodeURIComponent(additionalCriteria[i]);
            }

            if (query.length) {
              query += " AND ";
            }

            query += model.escapeSpecialChar(value);
          }
        }

        //-----All (full text search) -----
        if (this.filterIsAvailable("all") && (filter == "all" || getAll)) {
          var all = this.get("all");
          for (var i = 0; i < all.length; i++) {
            var filterValue = all[i];

            if (typeof filterValue == "object") {
              filterValue = filterValue.value;
            } else if (
              (typeof filterValue == "string" && !filterValue.length) ||
              typeof filterValue == "undefined" ||
              filterValue === null
            ) {
              continue;
            }

            if (this.needsQuotes(filterValue)) {
              if (forPOST) {
                filterValue = '"' + filterValue + '"';
              } else {
                filterValue = "%22" + encodeURIComponent(filterValue) + "%22";
              }
            } else {
              if (forPOST) {
                filterValue = filterValue;
              } else {
                filterValue = encodeURIComponent(filterValue);
              }
            }

            if (query.length) {
              query += " AND ";
            }

            query += model.escapeSpecialChar(filterValue);
          }
        }

        //-----Other Filters/Basic Filters-----
        _.each(otherFilters, function (filterName, key, list) {
          if (
            model.filterIsAvailable(filterName) &&
            (filter == filterName || getAll)
          ) {
            var filterValue = null;
            var filterValues = model.get(filterName);

            for (var i = 0; i < filterValues.length; i++) {
              //Trim the spaces off
              var filterValue = filterValues[i];
              if (typeof filterValue == "object") {
                filterValue = filterValue.value;
              }
              filterValue = filterValue.trim();

              // Does this need to be wrapped in quotes?
              if (model.needsQuotes(filterValue)) {
                if (forPOST) {
                  filterValue = '"' + filterValue + '"';
                } else {
                  filterValue = "%22" + encodeURIComponent(filterValue) + "%22";
                }
              } else {
                if (forPOST) {
                  filterValue = filterValue;
                } else {
                  filterValue = encodeURIComponent(filterValue);
                }
              }

              if (query.length) {
                query += " AND ";
              }

              query +=
                model.fieldNameMap[filterName] +
                ":" +
                model.escapeSpecialChar(filterValue);
            }
          }
        });

        //-----Geohashes-----
        if (
          this.filterIsAvailable("geohashLevel") &&
          (filter == "geohash" || getAll) &&
          this.get("useGeohash")
        ) {
          var geohashes = this.get("geohashes");

          if (typeof geohashes != undefined && geohashes.length > 0) {
            var groups = this.get("geohashGroups"),
              numGroups =
                typeof groups == "object" ? Object.keys(groups).length : 0;

            if (numGroups > 0) {
              //Add the AND operator in front of the geohash filter
              if (query.length) {
                query += " AND ";
              }

              //If there is more than one geohash group/level, wrap them in paranthesis
              if (numGroups > 1) {
                query += "(";
              }

              _.each(Object.keys(groups), function (level, i, allLevels) {
                var geohashList = groups[level];

                query += "geohash_" + level + ":";

                if (geohashList.length > 1) {
                  query += "(";
                }

                _.each(geohashList, function (g, ii, allGeohashes) {
                  //Keep URI's from getting too long if we are using GET
                  if (
                    MetacatUI.appModel.get("disableQueryPOSTs") &&
                    query.length > 1900
                  ) {
                    //Remove the last " OR "
                    if (query.endsWith(" OR ")) {
                      query = query.substring(0, query.length - 4);
                    }

                    return;
                  } else {
                    //Add the geohash value to the query
                    query += g;

                    //Add an " OR " operator inbetween geohashes
                    if (ii < allGeohashes.length - 1) {
                      query += " OR ";
                    }
                  }
                });

                //Close the paranthesis
                if (geohashList.length > 1) {
                  query += ")";
                }

                //Add an " OR " operator inbetween geohash levels
                if (i < allLevels.length - 1) {
                  query += " OR ";
                }
              });

              //Close the paranthesis
              if (numGroups > 1) {
                query += ")";
              }
            }
          }
        }

        //---Spatial---
        if (
          this.filterIsAvailable("spatial") &&
          (filter == "spatial" || getAll)
        ) {
          var spatial = this.get("spatial");

          if (Array.isArray(spatial) && spatial.length) {
            if (query.length) {
              query += " AND ";
            }

            query += this.getGroupedQuery(
              this.fieldNameMap["spatial"],
              spatial,
              {
                operator: "AND",
                subtext: false,
                forPOST: forPOST,
              },
            );
          } else if (typeof spatial == "string" && spatial.length) {
            if (query.length) {
              query += " AND ";
            }

            if (forPOST) {
              query +=
                this.fieldNameMap["spatial"] +
                ":" +
                model.escapeSpecialChar(spatial);
            } else {
              query +=
                this.fieldNameMap["spatial"] +
                ":" +
                model.escapeSpecialChar(encodeURIComponent(spatial));
            }
          }
        }

        //---Creator---
        if (
          this.filterIsAvailable("creator") &&
          (filter == "creator" || getAll)
        ) {
          var creator = this.get("creator");

          if (Array.isArray(creator) && creator.length) {
            if (query.length) {
              query += " AND ";
            }

            query += this.getGroupedQuery(
              this.fieldNameMap["creator"],
              creator,
              {
                operator: "AND",
                subtext: false,
                forPOST: forPOST,
              },
            );
          } else if (typeof creator == "string" && creator.length) {
            if (query.length) {
              query += " AND ";
            }

            if (forPOST) {
              query +=
                this.fieldNameMap["creator"] +
                ":" +
                model.escapeSpecialChar(creator);
            } else {
              query +=
                this.fieldNameMap["creator"] +
                ":" +
                model.escapeSpecialChar(encodeURIComponent(creator));
            }
          }
        }

        // Add project filter
        if (
          this.filterIsAvailable("projectText") &&
          (filter == "projectText" || getAll)
        ) {
          var project = this.get("projectText");
          if (project && project.length > 0) {
            if (query.length) {
              query += " AND ";
            }
            query += 'projectText:"' + project[0].value + '"';
          }
        }

        return query;
      },

      getFacetQuery: function (fields) {
        var facetQuery =
          "&facet=true" +
          "&facet.sort=count" +
          "&facet.mincount=1" +
          "&facet.limit=-1";

        //Get the list of fields
        if (!fields) {
          var fields =
            "keywords,origin,family,species,genus,kingdom,phylum,order,class,site";
          if (this.filterIsAvailable("annotation")) {
            fields += "," + this.facetNameMap["annotation"];
          }
          if (this.filterIsAvailable("attribute")) {
            fields += ",attributeName,attributeLabel";
          }
        }

        var model = this;
        //Add the fields to the query string
        _.each(fields.split(","), function (f) {
          var fieldNames = model.facetNameMap[f] || f;

          if (typeof fieldNames == "string") {
            fieldNames = [fieldNames];
          }

          _.each(fieldNames, function (fName) {
            facetQuery += "&facet.field=" + fName;
          });
        });

        return facetQuery;
      },

      //Check for spaces in a string - we'll use this to url encode the query
      needsQuotes: function (entry) {
        //Check for spaces
        var value = "";

        if (typeof entry == "object") {
          value = entry.value;
        } else if (typeof entry == "string") {
          value = entry;
        } else {
          return false;
        }

        //Is this a date range search? If so, we don't use quote marks
        var ISODateRegEx =
          /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z)/;
        if (ISODateRegEx.exec(value)) {
          return false;
        }

        //Check for a space character
        if (value.indexOf(" ") > -1) {
          return true;
        }

        //Check if this is an account subject string
        var LDAPSubjectRegEx =
            /(uid=|UID=|cn=|CN=).+([a-zA-Z]=).+([a-zA-Z]=).*/,
          ORCIDRegEx =
            /^http\:\/\/orcid\.org\/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9X]{4}/;

        if (LDAPSubjectRegEx.exec(value) || ORCIDRegEx.exec(value)) {
          return true;
        }

        return false;
      },

      escapeSpecialChar: function (term, escapeSquareBrackets = true) {
        term = term.replace(/%7B/g, "%5C%7B");
        term = term.replace(/%7D/g, "%5C%7D");
        term = term.replace(/%3A/g, "%5C%3A");
        term = term.replace(/:/g, "%5C:");
        term = term.replace(/\(/g, "%5C(");
        term = term.replace(/\)/g, "%5C)");
        term = term.replace(/\?/g, "%5C?");
        term = term.replace(/%3F/g, "%5C%3F");
        term = term.replace(/%2B/g, "%5C%2B");
        //Remove ampersands (&) for now since they are reserved Solr characters and the Metacat Solr can't seem to handle them even when they are escaped properly for some reason
        term = term.replace(/%26/g, "");
        term = term.replace(/%7C%7C/g, "%5C%7C%5C%7C");
        term = term.replace(/%21/g, "%5C%21");
        term = term.replace(/%28/g, "%5C%28");
        term = term.replace(/%29/g, "%5C%29");
        term = term.replace(/%5E/g, "%5C%5E");
        term = term.replace(/%22/g, "%5C%22");
        term = term.replace(/~/g, "%5C~");
        term = term.replace(/-/g, "%5C-");
        term = term.replace(/%2F/g, "%5C%2F");

        if (escapeSquareBrackets) {
          term = term.replace(/%5B/g, "%5C%5B");
          term = term.replace(/%5D/g, "%5C%5D");
        }

        return term;
      },
      /*
       * Makes a Solr syntax grouped query using the field name, the field values to search for, and the operator.
       * Example:  title:(resistance OR salmon OR "pink salmon")
       */
      getGroupedQuery: function (fieldName, values, options) {
        if (!values) return "";
        values = _.compact(values);
        if (!values.length) return "";

        var query = "",
          numValues = values.length,
          model = this;

        if (Array.isArray(fieldName) && fieldName.length > 1) {
          return this.getMultiFieldQuery(fieldName, values, options);
        }

        if (options && typeof options == "object") {
          var operator = options.operator,
            subtext = options.subtext,
            forPOST = options.forPOST;
        }

        if (
          typeof operator === "undefined" ||
          !operator ||
          (operator != "OR" && operator != "AND")
        ) {
          var operator = "OR";
        }

        if (numValues == 1) {
          var value = values[0],
            queryAddition;

          if (
            !Array.isArray(value) &&
            typeof value === "object" &&
            value.value
          ) {
            value = value.value.trim();
          }

          if (this.needsQuotes(values[0])) {
            if (forPOST) {
              queryAddition = '"' + this.escapeSpecialChar(value) + '"';
            } else {
              queryAddition =
                "%22" +
                this.escapeSpecialChar(encodeURIComponent(value)) +
                "%22";
            }
          } else if (subtext) {
            if (forPOST) {
              queryAddition = "*" + this.escapeSpecialChar(value) + "*";
            } else {
              queryAddition =
                "*" + this.escapeSpecialChar(encodeURIComponent(value)) + "*";
            }
          } else {
            if (forPOST) {
              queryAddition = this.escapeSpecialChar(value);
            } else {
              queryAddition = this.escapeSpecialChar(encodeURIComponent(value));
            }
          }
          query = fieldName + ":" + queryAddition;
        } else {
          _.each(
            values,
            function (value, i) {
              //Check for filter objects
              if (
                !Array.isArray(value) &&
                typeof value === "object" &&
                value.value
              ) {
                value = value.value.trim();
              }

              if (model.needsQuotes(value)) {
                if (forPOST) {
                  value = '"' + value + '"';
                } else {
                  value = "%22" + encodeURIComponent(value) + "%22";
                }
              } else if (subtext) {
                if (forPOST) {
                  value = "*" + this.escapeSpecialChar(value) + "*";
                } else {
                  value =
                    "*" +
                    this.escapeSpecialChar(encodeURIComponent(value)) +
                    "*";
                }
              } else {
                if (forPOST) {
                  value = this.escapeSpecialChar(value);
                } else {
                  value = this.escapeSpecialChar(encodeURIComponent(value));
                }
              }

              if (i == 0 && numValues > 1) {
                query += fieldName + ":(" + value;
              } else if (i > 0 && i < numValues - 1 && query.length) {
                query += " " + operator + " " + value;
              } else if (i > 0 && i < numValues - 1) {
                query += value;
              } else if (i == numValues - 1) {
                query += " " + operator + " " + value + ")";
              }
            },
            this,
          );
        }

        return query;
      },

      /*
       * Makes a Solr syntax query using multiple field names, one field value to search for, and some options.
       * Example: (family:*Pin* OR class:*Pin* OR order:*Pin* OR phlyum:*Pin*)
       * Options:
       * 		- operator (OR or AND)
       * 		- subtext (binary) - will surround search value with wildcards to search for partial matches
       * 		- Example:
       * 			var options = { operator: "OR", subtext: true }
       */
      getMultiFieldQuery: function (fieldNames, value, options) {
        var query = "",
          numFields = fieldNames.length,
          model = this;

        //Catch errors
        if (numFields < 1 || !value) return "";

        //If only one field was given, then use the grouped query function
        if (numFields == 1) {
          return this.getGroupedQuery(fieldNames, value, options);
        }

        //Get the options
        if (options && typeof options == "object") {
          var operator = options.operator,
            subtext = options.subtext,
            forPOST = options.forPOST;
        }
        var esb =
          typeof options.escapeSquareBrackets == "boolean"
            ? options.escapeSquareBrackets
            : true;

        //Default to the OR operator
        if (
          typeof operator === "undefined" ||
          !operator ||
          (operator != "OR" && operator != "AND")
        ) {
          var operator = "OR";
        }
        if (typeof subtext === "undefined") {
          var subtext = false;
        }

        //Create the value string
        //Trim the spaces off
        if (!Array.isArray(value) && typeof value === "object" && value.value) {
          value = [value.value.trim()];
        } else if (typeof value == "string") {
          value = [value.trim()];
        }

        var valueString = "";
        if (Array.isArray(value)) {
          var model = this;

          _.each(
            value,
            function (v, i) {
              if (typeof v == "object" && v.value) {
                v = v.value;
              }

              if (value.length > 1 && i == 0) {
                valueString += "(";
              }

              if (model.needsQuotes(v) || _.contains(fieldNames, "id")) {
                if (forPOST) {
                  valueString +=
                    '"' + this.escapeSpecialChar(v.trim(), esb) + '"';
                } else {
                  valueString +=
                    '"' +
                    this.escapeSpecialChar(encodeURIComponent(v.trim()), esb) +
                    '"';
                }
              } else if (subtext) {
                if (forPOST) {
                  valueString +=
                    "*" + this.escapeSpecialChar(v.trim(), esb) + "*";
                } else {
                  valueString +=
                    "*" +
                    this.escapeSpecialChar(encodeURIComponent(v.trim()), esb) +
                    "*";
                }
              } else {
                if (forPOST) {
                  valueString += this.escapeSpecialChar(v.trim(), esb);
                } else {
                  valueString += this.escapeSpecialChar(
                    encodeURIComponent(v.trim()),
                    esb,
                  );
                }
              }

              if (i < value.length - 1) {
                valueString += " OR ";
              } else if (i == value.length - 1 && value.length > 1) {
                valueString += ")";
              }
            },
            this,
          );
        } else valueString = value;

        query = "(";

        //Create the query string
        var last = numFields - 1;
        _.each(fieldNames, function (field, i) {
          query += field + ":" + valueString;
          if (i < last) {
            query += " " + operator + " ";
          }
        });

        query += ")";

        return query;
      },

      /**** Provenance-related functions ****/
      // Returns which fields are provenance-related in this model
      // Useful for querying the index and such
      getProvFields: function () {
        var provFields = this.get("provFields");

        if (!provFields.length) {
          var defaultFields = Object.keys(SolrResult.prototype.defaults);
          provFields = _.filter(defaultFields, function (fieldName) {
            if (fieldName.indexOf("prov_") == 0) return true;
          });

          this.set("provFields", provFields);
        }

        return provFields;
      },

      getProvFlList: function () {
        var provFields = this.getProvFields(),
          provFl = "";
        _.each(provFields, function (provField, i) {
          provFl += provField;
          if (i < provFields.length - 1) provFl += ",";
        });

        return provFl;
      },

      clear: function () {
        return this.set(_.clone(this.defaults()));
      },
    },
  );
  return Search;
});