Source: src/js/collections/Filters.js

define([
  "jquery", "underscore", "backbone",
  "models/filters/Filter", "models/filters/BooleanFilter", "models/filters/ChoiceFilter",
  "models/filters/DateFilter", "models/filters/NumericFilter", "models/filters/ToggleFilter",
],
  function (
    $, _, Backbone,
    Filter, BooleanFilter, ChoiceFilter,
    DateFilter, NumericFilter, ToggleFilter,
  ) {
    "use strict";

    /**
     * @class Filters
     * @classdesc A collection of Filter models that represents a full search
     * @classcategory Collections
     * @name Filters
     * @extends Backbone.Collection
    * @constructor
     */
    var Filters = Backbone.Collection.extend(
          /** @lends Filters.prototype */{

        /**
        * If the search results must always match one of the ids in the id filters,
        * then the id filters will be added to the query with an AND operator.
        * @type {boolean}
        */
        mustMatchIds: false,

        /**
        * Function executed whenever a new Filters collection is created.
        * @param {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]} models -
        * Array of filter or filter group models to add to this creation
        * @param {Object} [options] -
        * @property {boolean} isUIFilterType - Set to true to indicate that these filters
        * or filterGroups are part of a UIFilterGroup (aka custom Portal search filter).
        * Otherwise, it's assumed that this model is in a Collection model definition.
        * @property {XMLElement} objectDOM -  A FilterGroupType or UIFilterGroupType XML
        * element from a portal or collection document. If provided, the XML will be
        * parsed and the Filters models extracted
        * @property {boolean} catalogSearch  - If set to true, a catalog search phrase
        * will be appended to the search query that limits the results to un-obsoleted
        * metadata.
        */
        initialize: function (models, options) {
          try {
            if (typeof options === "undefined") {
              var options = {};
            }
            if (options && options.objectDOM) {
              // Models are automatically added to the collection by the parse function.
              var isUIFilterType = options.isUIFilterType == true ? true : false
              this.parse(options.objectDOM, isUIFilterType);
            }
            if (options.catalogSearch) {
              this.catalogSearchQuery = this.createCatalogSearchQuery();
            }
          } catch (error) {
            console.log("Error initializing a Filters collection. Error details: " + error);
          }
        },

        /**
        * Creates the type of Filter Model based on the given filter type. This
        * function is typically not called directly. It is used by Backbone.js when adding
        * a new model to the collection.
        * @param {object} attrs - A literal object that contains the attributes to pass to the model
        * @property {string} attrs.filterType - The type of Filter to create
        * @property {XMLElement} attrs.objectDOM - The Filter XML
        * @param {object} options - A literal object of additional options to pass to the model
        * @returns {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup}
        */
        model: function (attrs, options) {

          // Get the model type
          var type = ""
          // If no filterType was specified, but an objectDOM exists (from parsing a
          // Collection or Portal document), get the filter type from the objectDOM
          // node name
          if (!attrs.filterType && attrs.objectDOM) {
            type = attrs.objectDOM.nodeName;
          } else if (attrs.filterType) {
            type = attrs.filterType;
          } else if (attrs.nodeName){
            type = attrs.nodeName
          }
          // Ignoring the case of the type allows using either the
          // filter type (e.g. BooleanFilter) or the nodeName value
          // (e.g. "booleanFilter")
          type = type.toLowerCase();

          switch (type) {
            case "booleanfilter":
              return new BooleanFilter(attrs, options);

            case "datefilter":
              return new DateFilter(attrs, options);

            case "numericfilter":
              return new NumericFilter(attrs, options);

            case "filtergroup":
              // We must initialize a Filter Group using the inline require syntax to
              // avoid the problem of circular dependencies. Filters requires Filter
              // Groups, and Filter Groups require Filters. For more info, see
              // https://requirejs.org/docs/api.html#circular
              var FilterGroup = require('models/filters/FilterGroup');
              var newFilterGroup = new FilterGroup(attrs, options)
              return newFilterGroup;

            case "choicefilter":
              return new ChoiceFilter(attrs, options);

            case "togglefilter":
              return new ToggleFilter(attrs, options);

            default:
              return new Filter(attrs, options);
          }

        },

        /**
         * Parses a <filterGroup> or <definition> element from a collection or portal
         * document and sets the resulting models on this collection.
         *
         *  @param {XMLElement} objectDOM - A FilterGroupType or UIFilterGroupType XML
         *  element from a portal or collection document
         *  @param {boolean} isUIFilterType - Set to true to indicate that these filters
         *  or filterGroups are part of a UIFilterGroup (aka custom Portal search filter).
         *  Otherwise, it's assumed that the filters are part of a Collection model
         *  definition.
         *  @return {JSON} The result of the parsed XML, in JSON.
        */
        parse: function (objectDOM, isUIFilterType) {

          var filters = this;

          $(objectDOM).children().each(function (i, filterNode) {
            filters.add({
              objectDOM: filterNode,
              isUIFilterType: isUIFilterType == true ? true : false
            })
          });

          return filters.toJSON();
        },

        /**
         * Builds the query string to send to the query engine. Iterates over each filter
         * in the collection and adds to the query string.
         *
         * @param {string} [operator=AND] The operator to use to combine multiple filters in this filter group. Must be AND or OR.
         * @return {string} The query string to send to Solr
         */
        getQuery: function (operator = "AND") {

          // The complete query string that eventually gets returned
          var completeQuery = ""

          // Ensure that the operator is AND or OR so that the query string will be valid.
          // Default to AND.
          if (typeof operator !== "string") {
            var operator = "AND";
          }
          operator = operator.toUpperCase();
          if(!["AND", "OR"].includes(operator)){
            operator = "AND"
          }

          // Adds URI encoded spaces to either side of a string
          var padString = function(string){ return "%20" + string + "%20" }

          // Get the list of filters that use id fields since these are used differently.
          var idFilters = this.getIdFilters();
          // Get the remaining filters that don't contain any ID fields
          var mainFilters = this.getNonIdFilters();

          // Create the grouped query for the id filters
          var idFilterQuery = this.getGroupQuery(idFilters, "OR");
          // Make a query for all of the filters that do not contain ID fields
          var mainQuery = this.getGroupQuery(mainFilters, operator);

          // First add the query string built from the non-ID filters
          completeQuery += mainQuery;

          // Then add the Data Catalog filters if Filters was initialized with the
          // catalogSearch = true option. Filters that are used in the data catalog are
          // treated specially
          if(this.catalogSearchQuery && this.catalogSearchQuery.length){
            // If there are other filters besides the catalog filters, AND the catalog
            // filters to the end of the query for the other filters, regardless of which
            // operator this function uses to combine other filters.
            if (completeQuery && completeQuery.trim().length) {
              completeQuery += padString("AND");
            }
            completeQuery += this.catalogSearchQuery
          }

          // Finally, add the ID filters to the very end of the query. This is done so
          // that the query string is constructed with these filters "OR"ed into the
          // query. For example, a query might be to look for datasets by a certain
          // scientist OR with the given id. If those filters were ANDed together, the
          // search would essentially ignore the creator filter and only return the
          // dataset with the matching id.
          if(idFilterQuery && idFilterQuery.trim().length){
            if (completeQuery && completeQuery.trim().length) {
              // If the search results must always match one of the ids in the id filters,
              // then add the id filters to the query with the AND operator. This flag
              // is set on this Collection. Otherwise, use the OR operator
              var idOperator = this.mustMatchIds ? padString("AND") : padString("OR");
              completeQuery = "(" + completeQuery + ")" + idOperator + idFilterQuery;
            } else {
              // If the query is ONLY made of id filters, then the id filter query is the
              // complete query
              completeQuery += idFilterQuery
            }
          }

          // Return the completed query
          return completeQuery;

        },

        /**
         * Searches the Filter models in this collection and returns any that have at
         * least one field that matches any of the ID query fields, such as by id, seriesId, or the isPartOf relationship.
         * @returns {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]}
         * Returns an array of filter models that include at least one ID field
         */
        getIdFilters: function(){
          try {
            return this.filter(function (filterModel) {
              if(typeof filterModel.isIdFilter == "undefined"){
                return false
              }
              return filterModel.isIdFilter()
            });
          } catch (error) {
            console.log("Error trying to find ID Filters, error details: " + error);
          }
        },

        /**
         * Searches the Filter models in this collection and returns all have no fields
         * matching any of the ID query fields.
         * @returns {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]}
         * Returns an array of filter models that do not include any ID fields
         */
        getNonIdFilters: function(){
          try {
            return this.difference(this.getIdFilters());
          } catch (error) {
            console.log("Error trying to find non-ID Filters, error details: " + error);
          }
        },

        /**
        * Get a query string for a group of Filters.
        * The Filters will be ANDed together, unless a different operator is given.
        * @param {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]} filterModels - The Filters to turn into a query string
        * @param {string} [operator="AND"] - The operator to use between filter models
        * @return {string} The query string
        */
        getGroupQuery: function (filterModels, operator="AND") {

          try {
            if(!filterModels || !filterModels.length || !this.getNonEmptyFilters(filterModels)){
              return ""
            }
            //Start an array to contain the query fragments
            var groupQueryFragments = [];

            //For each Filter in this group, get the query string
            _.each(filterModels, function (filterModel) {
              // Get the Solr query string from this model. Pass on the group operator so
              // that we can detect whether this filter query needs a positive clause in
              // case it has exclude set to true.
              var filterQuery = filterModel.getQuery(operator);
              //Add the filter query string to the overall array
              if (filterQuery && filterQuery.length > 0) {
                groupQueryFragments.push(filterQuery);
              }
            }, this);

            //Join this group's query fragments with an OR operator
            if (groupQueryFragments.length) {
              var queryString = groupQueryFragments.join("%20" + operator + "%20");
              if(groupQueryFragments.length > 1){
                queryString = "(" + queryString + ")"
              }
              return queryString
            }
            //Otherwise, return an empty string
            else {
              return "";
            }
          } catch (error) {
            console.log("Error creating a group query, returning a blank string. " +
              " Error details: " + error);
            return ""
          }

        },

        /**
         * Given a Solr field name, determines if that field is set as a filter option
         */
        filterIsAvailable: function (field) {

          var matchingFilter = this.find(function (filterModel) {
            return _.contains(filterModel.fields, field);
          });

          if (matchingFilter) {
            return true;
          } else {
            return false;
          }
        },

        /*
         * Returns an array of filter models in this collection that have a value set
         *
         * @return {Array} - an array of filter models in this collection that have a value set
         */
        getCurrentFilters: function () {
          var currentFilters = new Array();

          this.each(function (filterModel) {
            //If the filter model has values set differently than the default AND it is
            // not an invisible filter, then add it to the current filters array
            if (!filterModel.get("isInvisible") &&
              ((Array.isArray(filterModel.get("values")) && filterModel.get("values").length &&
                _.difference(filterModel.get("values"), filterModel.defaults().values).length) ||
                (!Array.isArray(filterModel.get("values")) && filterModel.get("values") !== filterModel.defaults().values))
            ) {
              currentFilters.push(filterModel);
            }
          });

          return currentFilters;
        },

        /*
         * Clear the values of all geohash-related models in the collection
         */
        resetGeohash: function () {
          //Find all the filters in this collection that are related to geohashes
          this.each(function (filterModel) {
            if (!filterModel.get("isInvisible") &&
              (filterModel.type == "SpatialFilter" ||
                _.intersection(filterModel.fields, ["geohashes", "geohashLevel", "geohashGroups"]).length)) {
              filterModel.resetValue();
            }
          });
        },

        /**
         * Create a partial query string that's required for catalog searches
         * @returns {string} - Returns the query string fragment for a catalog search
         */
        createCatalogSearchQuery: function(){
          var catalogFilters = new Filters([
            {
              fields: ["obsoletedBy"],
              values: ["*"],
              exclude: true
            },
            {
              fields: ["formatType"],
              values: ["METADATA"],
              matchSubstring: false
            }]);
          var query = catalogFilters.getGroupQuery(catalogFilters.models, "AND");
          return query
        },

        /**
        * Creates and adds a Filter to this collection that filters datasets
        * to only those that the logged-in user has permission to change permission of.
        */
        addOwnershipFilter: function () {

          if (MetacatUI.appUserModel.get("loggedIn")) {
            //Filter datasets by their ownership
            this.add({
              fields: ["rightsHolder", "changePermission"],
              values: MetacatUI.appUserModel.get("allIdentitiesAndGroups"),
              operator: "OR",
              fieldsOperator: "OR",
              matchSubstring: false,
              exclude: false
            });
          }

        },

        /**
        * Creates and adds a Filter to this collection that filters datasets
        * to only those that the logged-in user has permission to write to.
        */
        addWritePermissionFilter: function () {

          if (MetacatUI.appUserModel.get("loggedIn")) {
            //Filter datasets by their ownership
            this.add({
              fields: ["rightsHolder", "writePermission", "changePermission"],
              values: MetacatUI.appUserModel.get("allIdentitiesAndGroups"),
              operator: "OR",
              fieldsOperator: "OR",
              matchSubstring: false,
              exclude: false
            });
          }

        },

        /**
        * Removes Filter models from this collection if they match the given field
        * @param {string} field - The field whose matching filters that should be removed from this collection
        */
        removeFiltersByField: function (field) {

          var toRemove = [];

          this.each(function (filter) {
            if (filter.get && filter.get("fields").includes(field)) {
              toRemove.push(filter);
            }
          });

          this.remove(toRemove);

        },

        /**
         * Remove filters from the collection that are
         * lacking fields, values, and in the case of a numeric filter,
         * a min and max value.
         * @param {boolean} [recursive=false] - Set to true to also remove empty filters
         * from within any and all nested filterGroups.
         */
        removeEmptyFilters: function(recursive = false){

          try {
            var toRemove = this.difference(this.getNonEmptyFilters());
            this.remove(toRemove);

            if (recursive){
              var nestedGroups = this.filter(function (filterModel) {
                return filterModel.type == "FilterGroup" });

              if(nestedGroups){
                nestedGroups.forEach(function(filterGroupModel){
                  filterGroupModel.get("filters").removeEmptyFilters(true)
                });
              }
            }

          } catch (e) {
            console.log("Failed to remove empty Filter models from the Filters collection, error message: " + e);
          }

        },


        /**
         * getNonEmptyFilters - Returns the array of filters that are not empty
         * @return {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]}
         * returns an array of Filter or FilterGroup models that are not empty
         */
        getNonEmptyFilters: function(){
          try {
            return this.filter(function(filterModel){
              return !filterModel.isEmpty();
            });
          } catch (e) {
            console.log("Failed to remove empty Filter models from the Filters collection, error message: " + e);
          }
        },

        /**
         * Remove a Filter from the Filters collection silently, and
         * replace it with a new model.
         *
         * @param  {Filter} model    The model to replace
         * @param  {object} newAttrs Attributes for the replacement model. Use the filterType attribute to replace with a different type of Filter.
         * @return {Filter}          Returns the replacement Filter model, which is already part of the Filters collection.
         */
        replaceModel: function (model, newAttrs) {
          try {
            var index = this.indexOf(model),
              oldModelId = model.cid;

            this.remove(oldModelId, { silent: true });
            
            var newModel = this.add(
              newAttrs,
              { at: index }
            );

            return newModel;
          } catch (e) {
            console.log("Failed to replace a Filter model in a Filters collection, " + e);
          }
        },

        /**
         * visibleIndexOf - Get the index of a given model, excluding any
         * filters that are marked as invisible.
         *
         * @param  {Filter|BooleanFilter|NumericFilter|DateFilter} model The filter model for which to get the visible index
         * @return {number} An integer representing the filter model's position in the list of visible filters.
         */
        visibleIndexOf: function(model){
          try {
            // Don't count invisible filters in the index we display to the user
            var visibleFilters = this.filter(function(filterModel){
              var isInvisible = filterModel.get("isInvisible");
              return typeof isInvisible == "undefined" || isInvisible === false
            });
            return _.indexOf(visibleFilters, model);
          } catch (e) {
            console.log("Failed to get the index of a Filter within the collection of visible Filters, error message: " + e);
          }
        },

        /*
        hasGeohashFilter: function() {

            var currentFilters = this.getCurrentFilters();
            var geohashFilter = _.find(currentFilters, function(filterModel){
                return (_.intersection(filterModel.get("fields"),
                    ["geohashes", "geohash"]).length > 0);
            });

            if(geohashFilter) {
                return true;
            } else {
                return false;
            }
        }
        */
      });
    return Filters;
  });