Source: js/models/filters/Filter.js

/* global define */
define(['jquery', 'underscore', 'backbone'],
    function($, _, Backbone) {

  /**
  * @class Filter
  * @classdesc A single search filter that is used in queries sent to the DataONE search service.
  * @extends Backbone.Model
  * @constructs
  */
  var FilterModel = Backbone.Model.extend(
    /** @lends Filter.prototype */
    {

    /**
    * The name of this Model
    * @name Filter#type
    * @type {string}
    * @readonly
    */
    type: "Filter",

    /**
    * Default attributes for this model
    * @type {Object}
    * @property {Element} objectDOM - The XML DOM for this filter
    * @property {string} nodeName - The XML node name for this filter's XML DOM
    * @property {string[]} fields - The search index fields to search
    * @property {string[]} values - The values to search for in the given search fields
    * @property {string} operator - The operator to use between values set on this model. "AND" or "OR"
    * @property {string} queryGroup - The name of the group this Filter is a part of, which is
    * primarily used when creating a query string from multiple Filter models. Filters
    * in the same group will be wrapped in paranthesis in the query.
    * @property {boolean} exclude - If true, search index docs matching this filter will be excluded from the search results
    * @property {boolean} matchSubstring - If true, the search values will be wrapped in wildcard characters to match substrings
    * @property {string} label - A human-readable short label for this Filter
    * @property {string} placeholder - A short example or description of this Filter
    * @property {string} icon - A term that identifies a single icon in a supported icon library
    * @property {string} description - A longer description of this Filter's function
    * @property {boolean} isInvisible - If true, this filter will be added to the query but will
    * act in the "background", like a default filter
    * @property {boolean} inFilterGroup - If true, this filter belongs to a FilterGroup model
    */
    defaults: function(){
      return{
        objectDOM: null,
        nodeName: "filter",
        fields: [],
        values: [],
        operator: "AND",
        queryGroup: null,
        exclude: false,
        matchSubstring: true,
        label: null,
        placeholder: null,
        icon: null,
        description: null,
        isInvisible: false,
        inFilterGroup: false
      }
    },

    /**
    * Creates a new Filter model
    */
    initialize: function(){
      if( this.get("objectDOM") ){
        this.set( this.parse(this.get("objectDOM")) );
      }

      //Assign a random query group to Filters that are specifing very specific datasets,
      // such as bby id, seriesId, or the isPartOf relationship. 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( this.get("fields").includes("isPartOf") || this.get("fields").includes("id") ||
          this.get("fields").includes("seriesId") ){
        this.set("queryGroup", Math.floor(Math.random() * Math.floor(10000)).toString());
      }

      //If this is an isPartOf filter, then add a label and description
      if( this.get("fields").length == 1 && this.get("fields").includes("isPartOf") ){
        this.set({
          label: "Datasets added manually",
          description: "Datasets added to this collection manually by dataset owners"
        });
      }
    },

    /**
    * Parses the given XML node into a JSON object to be set on the model
    *
    * @param {Element} xml - The XML element that contains all the filter elements
    * @return {JSON} - The JSON object of all the filter attributes
    */
    parse: function(xml){

      //If an XML element wasn't sent as a parameter, get it from the model
      if(!xml){
        var xml = this.get("objectDOM");

        //Return an empty JSON object if there is no objectDOM saved in the model
        if(!xml)
          return {};
      }

      var modelJSON = {};

      if( $(xml).children("field").length ){
        //Parse the field(s)
        modelJSON.fields = this.parseTextNode(xml, "field", true);
      }

      if( $(xml).children("label").length ){
        //Parse the label
        modelJSON.label = this.parseTextNode(xml, "label");
      }

      //Parse the operator, if it exists
      if( $(xml).find("operator").length ){
        modelJSON.operator = this.parseTextNode(xml, "operator");
      }
      else{
        if( modelJSON.fields.includes('id') ){
          modelJSON.operator = "OR";
        }
      }

      //Parse the exclude, if it exists
      if( $(xml).find("exclude").length ){
        modelJSON.exclude = (this.parseTextNode(xml, "exclude") === "true")? true : false;
      }

      //Parse the matchSubstring
      if( $(xml).find("matchSubstring").length ){
        modelJSON.matchSubstring = (this.parseTextNode(xml, "matchSubstring") === "true")? true : false;
      }

      var filterOptionsNode = $(xml).children("filterOptions");
      if( filterOptionsNode.length ){
        //Parse the filterOptions XML node
        modelJSON = _.extend(this.parseFilterOptions(filterOptionsNode), modelJSON);
      }

      //If this Filter is in a filter group, don't parse the values
      if( !this.get("inFilterGroup") ){
        if( $(xml).children("value").length ){
          //Parse the value(s)
          modelJSON.values = this.parseTextNode(xml, "value", true);
        }
      }

      return modelJSON;

    },

    /**
    * 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
    * @return {(string|Array)} - Returns a string or array of strings of the text content
    */
    parseTextNode: function( parentNode, nodeName, isMultiple ){
      var node = $(parentNode).children(nodeName);

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

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

        var allContents = [];

         _.each(node, function(node){
           if(node.textContent || node.textContent === 0)
             allContents.push( node.textContent );
        });

        return allContents;

      }
    },

    /**
    * Parses the filterOptions XML node into a literal object
    * @param {Element} filterOptionsNode - The filterOptions XML element to parse
    * @return {Object} - The parsed filter options, in literal object form
    */
    parseFilterOptions: function(filterOptionsNode){

      if( typeof filterOptionsNode == "undefined" ){
        return {};
      }

      var modelJSON = {};

      try{
        //The list of options to parse
        var options = ["placeholder", "icon", "description"];

        //Parse the text nodes for each filter option
        _.each(options, function(option){
          if( $(filterOptionsNode).children(option).length ){
            modelJSON[option] = this.parseTextNode(filterOptionsNode, option, false);
          }
        }, this);

        //Parse the generic option name and value pairs and set on the model JSON
        $(filterOptionsNode).children("option").each(function(i, optionNode){
          var optName = $(optionNode).children("optionName").text();
          var optValue = $(optionNode).children("optionValue").text();

          modelJSON[optName] = optValue;
        });

        //Return the JSON to be set on this model
        return modelJSON;

      }
      catch(e){
        return {};
      }

    },

    /**
     * Builds a query string that represents this filter.
     *
     * @return {string} The query string to send to Solr
     */
    getQuery: function(){

      //Get the values of this filter in Array format
      var values = this.get("values");
      if( !Array.isArray(values) ){
        values = [values];
      }

      //Check that there are actually values to serialize
      if( !values.length ){
        return "";
      }

      //Filter out any invalid values (can't use _.compact() because we want to keep 'false' values)
      values = _.reject(values, function(value){
                return (value === null || typeof value == "undefined" ||
                        value === NaN || value === "" || (Array.isArray(value) && !value.length));
              });

      if(!values.length){
        return "";
      }

      //Start a query string for this model and get the fields
      var queryString = "",
          fields = this.get("fields");

      //If the fields are not an array, convert it to an array
      if( !Array.isArray(fields) ){
        fields = [fields];
      }

      //Iterate over each field
      _.each( fields, function(field, i){

        //Add the query string for this field to the overall model query string
        queryString += field + ":" + this.getValueQuerySubstring(values);

        //Add the OR operator between field names
        if( fields.length > i+1 && queryString.length ){
          queryString += "%20" + this.get("operator") + "%20";
        }

      }, this);

      //If there is more than one field, wrap the multiple fields in parenthesis
      if( fields.length > 1 ){
        queryString = "(" + queryString + ")"
      }

      //If this filter should be excluding matches from the results,
      // then add a hyphen in front
      if( this.get("exclude") ){
        queryString = "-" + queryString;
      }

      return queryString;

    },

    /**
    * Constructs a query substring for each of the values set on this model
    *
    * @example
    *   values: ["walker", "jones"]
    *   Returns: "(walker%20OR%20jones)"
    *
    * @param {string[]} [values] - The values to use in this query substring.
    * If not provided, the values set on the model are used.
    * @return {string} The query substring
    */
    getValueQuerySubstring: function(values){
      //Start a query string for this field and get the values
      var valuesQueryString = "",
          values = values || this.get("values");

      //If the values are not an array, convert it to an array
      if( !Array.isArray(values) ){
        values = [values];
      }

      //Iterate over each value set on the model
      _.each( values, function(value, i){

        //If the value is not a string, then convert it to a string
        if( typeof value != "string" ){
          value = value.toString();
        }

        //Trim off whitespace
        value = value.trim();

        var dateRangeRegEx = /^\[((\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d*Z)|\*)( |%20)TO( |%20)((\d{4}-[01]\d-[0-3]\dT[0-2]\d(:|\\:)[0-5]\d(:|\\:)[0-5]\d\.\d*Z)|\*)\]/,
            isDateRange = dateRangeRegEx.test(value);

        //Escape special characters
        value = this.escapeSpecialChar(value);

        //If the value is a search phrase (more than one word), and not a date range string, wrap in quotes
        if( value.indexOf(" ") > -1 && !isDateRange ){
          valuesQueryString += "\"" + value + "\"";
        }
        else if( this.get("matchSubstring") && !isDateRange ){

          //Look for existing wildcard characters at the end of the value string
          if( value.match( /^\*|\*$/ ) ){
            valuesQueryString += value;
          }
          //Wrap the value string in wildcard characters
          else{
            valuesQueryString += "*" + value + "*";
          }

        }
        else{
          //Add the value to the query string
          valuesQueryString += value;
        }

        //Add the operator between values
        if( values.length > i+1 && valuesQueryString.length ){
          valuesQueryString += "%20" + this.get("operator") + "%20";
        }

      }, this);

      if( values.length > 1 ){
        valuesQueryString = "(" + valuesQueryString + ")"
      }

      return valuesQueryString;
    },

    /**
    * Resets the values attribute on this filter
    */
    resetValue: function(){
      this.set("values", this.defaults().values);
    },

    /**
    * Checks if this Filter has values different than the default values.
    * @return {boolean} - Returns true if this Filter has values set on it, otherwise will return false
    */
    hasChangedValues: function(){

      return (this.get("values").length > 0);

    },

    /**
    * Escapes Solr query reserved characters so that search terms can include
    *  those characters without throwing an error.
    *
    * @param {string} term - The search term or phrase to escape
    * @return {string} - The search term or phrase, after special characters are escaped
    */
    escapeSpecialChar: function(term) {
        term = term.replace(/%7B/g, "\\%7B");
        term = term.replace(/%7D/g, "\\%7D");
        term = term.replace(/%3A/g, "\\%3A");
        term = term.replace(/:/g, "\\:");
        term = term.replace(/\(/g, "\\(");
        term = term.replace(/\)/g, "\\)");
        term = term.replace(/\?/g, "\\?");
        term = term.replace(/%3F/g, "\\%3F");
        term = term.replace(/\"/g, '\\"');
        term = term.replace(/\'/g, "\\'");

        return term;
    },

    /**
     * Updates XML DOM with the new values from the model
     *
     *  @param {object} [options] A literal object with options for this serialization
     *  @property {boolean} [options.forCollection] - If true, will create an XML DOM for Collection definitions, not FilterGroups
     *  @return {Element} A new XML element with the updated values
    */
    updateDOM: function(options){

      try{

        if(typeof options == "undefined"){
          var options = {};
        }

        var objectDOM = this.get("objectDOM"),
            filterOptionsNode;

        if( typeof objectDOM == "undefined" || !objectDOM || !$(objectDOM).length ){
          // Create an XML filter element from scratch
          var objectDOM = new DOMParser().parseFromString("<" + this.get("nodeName") +
                          "></" + this.get("nodeName") + ">", "text/xml");
          var $objectDOM = $(objectDOM).find(this.get("nodeName"));
        }
        else{
          objectDOM = objectDOM.cloneNode(true);
          var $objectDOM = $(objectDOM);

          //Detach the filterOptions so they are saved
          filterOptionsNode = $objectDOM.children("filterOptions");
          filterOptionsNode.detach();

          //Empty the DOM
          $objectDOM.empty();
        }

        var xmlDocument = $objectDOM[0].ownerDocument;

        // Get new values
        var filterData = {
          // The following values are common to all FilterType elements
          label: this.get("label"),
          field: this.get("fields"),
          operator: this.get("operator"),
          exclude: this.get("exclude"),
          matchSubstring: this.get("matchSubstring"),
          value: this.get("values")
        };

        // Make new sub nodes using the new model data
        _.map(filterData, function(values, nodeName){

          // Serialize the nodes with multiple occurences
          if( Array.isArray(values) ){
              _.each(values, function(value){
                // Don't serialize falsey or default values
                if((value || value === false) && value != this.defaults()[nodeName]){
                  var nodeSerialized = xmlDocument.createElement(nodeName);
                  $(nodeSerialized).text(value);
                  $objectDOM.append(nodeSerialized);
                }
              }, this);
          // Serialize the single occurence nodes
          }
          // Don't serialize falsey or default values
          else if((values || values === false) && values != this.defaults()[nodeName]) {
            var nodeSerialized = xmlDocument.createElement(nodeName);
            $(nodeSerialized).text(values);
            $objectDOM.append(nodeSerialized);
          }

        }, this);

        //If this is a UIFilterType that won't be serialized into a Collection definition,
        // then add extra XML nodes
        if( !options.forCollection ){

          //Update the filterOptions XML DOM
          filterOptionsNode = this.updateFilterOptionsDOM(filterOptionsNode);

          //Add the filterOptions to the filter DOM
          if( typeof filterOptionsNode != "undefined" && $(filterOptionsNode).children().length ){
            $objectDOM.append(filterOptionsNode);
          }

        }

        return $objectDOM[0];
      }
      catch(e){
        console.error("Unable to serialize a Filter.", e);
        return this.get("objectDOM") || "";
      }
    },

    /**
    * Serializes the filter options into an XML DOM and returns it
    * @param {Element} [filterOptionsNode] - The XML filterOptions node to update
    * @return {Element} - The updated DOM
    */
    updateFilterOptionsDOM: function(filterOptionsNode){

      try{

        //If a filterOptions XML node was not given, create one
        if( typeof filterOptionsNode == "undefined" || !filterOptionsNode.length ){
          var filterOptionsNode = new DOMParser().parseFromString("<filterOptions></filterOptions>", "text/xml");
          var $filterOptionsNode = $(filterOptionsNode).find("filterOptions");
        }
        else{
          //Convert the XML node into a jQuery object
          var $filterOptionsNode = $(filterOptionsNode);
        }

        //Get the first option element
        var firstOptionNode = $filterOptionsNode.children("option").first();

        // The following values are for UIFilterOptionsType
        var optionsData = {};
        optionsData.placeholder = this.get("placeholder");
        optionsData.icon = this.get("icon");
        optionsData.description = this.get("description");

        var xmlDocument;

        if( filterOptionsNode.length ){
          xmlDocument = filterOptionsNode[0].ownerDocument;
        }
        if(!xmlDocument){
          xmlDocument = filterOptionsNode.ownerDocument;
        }
        if(!xmlDocument){
          xmlDocument = filterOptionsNode;
        }

        //Update the text value of existing
        _.map(optionsData, function(value, nodeName){
          //Remove the existing node, if it exists
          $filterOptionsNode.children(nodeName).remove();

          //If there is a value set on the model for this attribute, then create an XML
          // node for this attribute and set the text value
          if( value ){
            var newNode = $(xmlDocument.createElement(nodeName)).text(value);

            if( firstOptionNode.length )
              firstOptionNode.before(newNode);
            else
              $filterOptionsNode.append(newNode);
          }
        });

        //If no options were serialized, then return an empty string
        if( !$filterOptionsNode.children().length ){
          return "";
        }
        else{
          return filterOptionsNode;
        }
      }
      catch(e){
        return "";
      }

    },

    /**
    * Returns true if the given value or value set on this filter is a date range query
    * @param {string} value - The string to test
    * @return {boolean}
    */
    isDateQuery: function(value){

      if( typeof value == "undefined" && this.get("values").length == 1 ){
        var value = this.get("values")[0];
      }

      if( value ){
        var dateMatch = value.match(/[\d|\-|:|T]*Z TO [\d|\-|:|T]*Z/);

        return (Array.isArray(dateMatch) && dateMatch.length);
      }
      else{
        return false;
      }
    },

    /**
    * Checks if the values set on this model are valid.
    * Some of the attributes are changed during this process if they are found to be invalid,
    * since there aren't any easy ways for users to fix these issues themselves in the UI.
    * @return {object} - Returns a literal object with the invalid attributes and their corresponding error message
    */
    validate: function(){

      try{

        var errors = {};

        //---Validate fields----
        var fields = this.get("fields");
        //All fields should be strings
        var nonStrings = _.filter(fields, function(field){
          return (typeof field != "string" || !field.trim().length);
        });

        if( nonStrings.length ){
          //Remove the nonstrings from the model, rather than returning an error
          this.set("fields", _.without(fields, nonStrings));
        }
        //If there are no fields, set an error message
        if( !this.get("fields").length ){
          errors.fields = "Filters should have at least one search field.";
        }

        //---Validate values----
        var values = this.get("values");
        //All values should be strings, booleans, numbers, or dates
        var invalidValues = _.filter(values, function(value){
          //Empty strings are invalid
          if( typeof value == "string" && !value.trim().length ){
            return true;
          }
          //Non-empty strings, booleans, numbers, or dates are valid
          else if( typeof value == "string" || typeof value == "boolean" ||
                   typeof value == "number" || Date.prototype.isPrototypeOf(value) ){
            return false;
          }
        });

        if( invalidValues.length ){
          //Remove the invalid values from the model, rather than returning an error
          this.set("values", _.without(values, invalidValues));
        }

        //If there are no values, set an error message
        if( !this.get("values").length ){
          errors.values = "Filters should include at least one search term.";
        }

        //---Validate operator----
        //The operator must be either AND or OR
        if( this.get("operator") !== "AND" && this.get("operator") !== "OR" ){
          //Reset the value to the default rather than return an error
          this.set("operator", this.defaults().operator);
        }

        //---Validate exclude and matchSubstring----
        //Exclude should always be a boolean
        if( typeof this.get("exclude") != "boolean" ){
          //Reset the value to the default rather than return an error
          this.set("exclude", this.defaults().exclude);
        }
        //matchSubstring should always be a boolean
        if( typeof this.get("matchSubstring") != "boolean" ){
          //Reset the value to the default rather than return an error
          this.set("matchSubstring", this.defaults().matchSubstring);
        }

        //---Validate label, placeholder, icon, and description----
        var textAttributes = ["label", "placeholder", "icon", "description"];
        //These fields should be strings
        _.each(textAttributes, function(attr){
          if( typeof this.get(attr) != "string" ){
            //Reset the value to the default rather than return an error
            this.set(attr, this.defaults()[attr]);
          }
        }, this);

        if( Object.keys(errors).length )
          return errors;
        else{
          return;
        }
      }
      catch(e){
        console.error(e);
      }

    }

  });

  return FilterModel;

});