Source: cachingobjectmatcher.js

// Copyright (C) Plan-A Software Ltd. All Rights Reserved.
//
// Written by Kiran Lakhotia <kiran@plan-a-software.co.uk>, 2014
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

'use strict';

goog.provide('plana.ui.ac.CachingObjectMatcher');

goog.require('goog.Uri');
goog.require('goog.array');
goog.require('goog.async.Throttle');
goog.require('goog.events.EventHandler');
goog.require('goog.events.EventTarget');
goog.require('goog.net.XhrIo');
goog.require('goog.net.XmlHttpFactory');
goog.require('goog.string');
goog.require('goog.ui.ac.RenderOptions');
goog.require('plana.ui.ac.RemoteObject');
goog.require('plana.ui.ac.RemoteObjectMatcher');
goog.require('plana.ui.ac.RemoteObjectMatcher.Event');
goog.require('plana.ui.ac.RemoteObjectMatcher.EventType');

/**
 * This class is based on {@link goog.ui.ac.CachingMatcher}. It differs
 * in the following ways:
 * - it disposes of cached objects (i.e. calls
 *   {@link plana.ui.ac.RemoteObject#dispose})
 * - it defines three states the cache is in:
 *    * fetching server matches
 *    * server returned matches
 *    * no matches found
 *
 * @constructor
 * @extends {goog.events.EventHandler}
 * @param {goog.Uri} uri The uri which generates the auto complete matches. The
 *     search term is passed to the server as the 'token' query param
 * @param {goog.net.XhrIo=} opt_xhrIo Optional XhrIo object to use. By default
 *     we create a new instance
 * @param {goog.net.XmlHttpFactory=} opt_xmlHttpFactory Optional factory to use
 *     when creating XMLHttpRequest objects
 * @param {boolean=} opt_multi Whether to allow multiple entries
 * @param {boolean=} opt_noSimilar If true, request that the server does not do
 *     similarity matches for the input token against the dictionary
 *     The value is sent to the server as the 'use_similar' query param which is
 *     either "1" (opt_noSimilar==false) or "0" (opt_noSimilar==true)
 */
plana.ui.ac.CachingObjectMatcher = function(
  uri, opt_xhrIo, opt_xmlHttpFactory, opt_multi, opt_noSimilar) {
  goog.events.EventHandler.call(this);

  /**
   * The client side cache
   * @type {Array.<!plana.ui.ac.RemoteObject>}}
   * @private
   */
  this.rows_ = [];

  /**
   * Set of stringified rows, for fast deduping. Each element of this.rows_
   * is stored in rowStrings_ as (' ' + row) to ensure we avoid builtin
   * properties like 'toString'
   * @type {Object.<string, boolean>}
   * @private
   */
  this.rowStrings_ = {};

  /**
   * Maximum number of rows in the cache. If the cache grows larger than this,
   * the entire cache will be emptied
   * @type {number}
   * @private
   */
  this.maxCacheSize_ = 1000;

  /**
   * The remote object matcher
   * @type {plana.ui.ac.RemoteObjectMatcher}
   * @private
   */
  this.remoteMatcher_ =
    new plana.ui.ac.RemoteObjectMatcher(uri, opt_xhrIo,
      opt_xmlHttpFactory, opt_multi, opt_noSimilar);

  /**
   * Number of matches to request from the remote
   * matcher
   * @type {number}
   * @private
   */
  this.remoteMatcherMaxMatches_ = 100;

  /**
   * The timer to control how often remote requests are
   * submitted to the server in response to key events
   * @type {goog.async.Throttle}
   * @private
   */
  this.throttledTriggerBaseMatch_ =
    new goog.async.Throttle(this.triggerBaseMatch_, 150, this);

  /**
   * The request token to use for the remote matcher
   * @type {string}
   * @private
   */
  this.mostRecentToken_ = '';

  /**
   * The complete input string, including the token.
   * This is useful for inputs that allow multiple entries
   * @type {string}
   * @private
   */
  this.mostRecentString_ = '';

  /**
   * The handler to use for handling matches returned by
   * the server
   * @type {Function}
   * @private
   */
  this.mostRecentMatchHandler_ = null;

  /**
   * The maximum number of matches to return from
   * local cache
   * @type {number}
   * @private
   */
  this.cacheMaxMatches_ = 10;

  /**
   * The set of rows which we last displayed.
   *
   * NOTE(reinerp): The need for this is subtle. When a server result comes
   * back, we don't want to suddenly change the list of results without the user
   * doing anything. So we make sure to add the new server results to the end of
   * the currently displayed list
   *
   * We need to keep track of the last rows we displayed, because the "similar
   * matcher" we use locally might otherwise reorder results
   *
   * @type {Array.<!plana.ui.ac.RemoteObject>}
   * @private
   */
  this.mostRecentMatches_ = [];

  /**
   * The state of the cache manager and its base matcher
   * @type {number}
   * @private
   */
  this.currentState_ = plana.ui.ac.CachingObjectMatcher.State.READY;

  /**
   * Flag whether we should add matches to the local cache or not
   * @type {boolean}
   * @private
   */
  this.disableLocalCache_ = false;

  /**
   * Flag whether we should send an empty token
   * to the server, e.g. to act as a 'retrieve all' flag.
   * Default: false
   * @type {boolean}
   * @private
   */
  this.sendEmptyToken_ = false;

  //listen to matcher events
  this.listen(this.remoteMatcher_, [
    plana.ui.ac.RemoteObjectMatcher.EventType.FAILED_REQUEST,
    plana.ui.ac.RemoteObjectMatcher.EventType.MATCHES,
    plana.ui.ac.RemoteObjectMatcher.EventType.INVALID_RESPONSE
  ], this.onRemoteMatcherEvent_, false);
};
goog.inherits(plana.ui.ac.CachingObjectMatcher, goog.events.EventHandler);

/**
 * @override
 * @suppress {checkTypes}
 */
plana.ui.ac.CachingObjectMatcher.prototype.disposeInternal = function() {
  this.unlisten(this.remoteMatcher_, [
    plana.ui.ac.RemoteObjectMatcher.EventType.FAILED_REQUEST,
    plana.ui.ac.RemoteObjectMatcher.EventType.MATCHES,
    plana.ui.ac.RemoteObjectMatcher.EventType.INVALID_RESPONSE
  ], this.onRemoteMatcherEvent_, false);

  for (var i = 0, match; match = this.rows_[i]; ++i) {
    match.dispose();
  }
  this.rows_.length = 0;
  this.rows_ = null;
  this.rowStrings_ = null;
  this.maxCacheSize_ = null;
  this.remoteMatcher_.dispose();
  this.remoteMatcher_ = null;
  this.remoteMatcherMaxMatches_ = null;
  this.throttledTriggerBaseMatch_.dispose();
  this.throttledTriggerBaseMatch_ = null;
  this.mostRecentToken_ = null;
  this.mostRecentString_ = null;
  this.mostRecentMatchHandler_ = null;
  this.cacheMaxMatches_ = null;
  this.mostRecentMatches_.length = 0;
  this.mostRecentMatches_ = null;
  this.currentState_ = null;
  this.disableLocalCache_ = null;
  this.sendEmptyToken_ = null;
  plana.ui.ac.CachingObjectMatcher.superClass_.disposeInternal.call(this);
};

/**
 * This function returns true on non-empty strings and if fullstring isn't
 * empty, but token might be. Otherwise returns false
 * @param {string} token Current token in autocomplete
 * @param {string} fullstring The complete value of the autocomplete
 *     input
 * @return {boolean} Whether new matches be requested
 */
plana.ui.ac.CachingObjectMatcher.prototype.shouldRequestMatches = function(
  token, fullstring) {
  return (!goog.string.isEmptySafe(token) || this.sendEmptyToken_);
};

/**
 * Sets the number of milliseconds with which to throttle the match requests
 * on the underlying matcher
 *
 * Default value: 150
 *
 * @param {number} throttleTime The value to set
 */
plana.ui.ac.CachingObjectMatcher.prototype.setThrottleTime = function(
  throttleTime) {
  //dispose of old throttle
  this.throttledTriggerBaseMatch_.dispose();
  this.throttledTriggerBaseMatch_ =
    new goog.async.Throttle(this.triggerBaseMatch_, throttleTime, this);
};


/**
 * Sets the maxMatches to use for the remote matcher
 *
 * Default value: 100
 *
 * @param {number} maxMatches The value to set
 */
plana.ui.ac.CachingObjectMatcher.prototype.setRemoteMatcherMaxMatches =
  function(maxMatches) {
    this.remoteMatcherMaxMatches_ = maxMatches;
};


/**
 * Sets the maximum size of the local cache. If the local cache grows larger
 * than this size, it will be emptied
 *
 * Default value: 1000
 *
 * @param {number} maxCacheSize The value to set
 */
plana.ui.ac.CachingObjectMatcher.prototype.setMaxCacheSize = function(
  maxCacheSize) {
  this.maxCacheSize_ = maxCacheSize;
};

/**
 * This function is taken from {@link goog.ui.ac.ArrayMatcher}. It
 * matches the token against the specified rows, first looking for prefix
 * matches and if that fails, then looking for similar matches
 *
 * @param {string} token Token to match
 * @param {number} maxMatches Max number of matches to return
 * @return {!Array.<plana.ui.ac.RemoteObject>} Rows that match
 */
plana.ui.ac.CachingObjectMatcher.prototype.getCachedMatches = function(
  token, maxMatches) {
  var matches =
    this.getPrefixMatchesForRows(token, maxMatches);

  if (matches.length == 0) {
    matches = this.getSimilarMatchesForRows(token, maxMatches);
  }
  return matches;
};

/**
 * This function is taken from {@link goog.ui.ac.ArrayMatcher}. It
 * matches the token against the start of words in the row
 * @param {string} token Token to match
 * @param {number} maxMatches Max number of matches to return
 * @return {!Array.<plana.ui.ac.RemoteObject>} Rows that match
 */
plana.ui.ac.CachingObjectMatcher.prototype.getPrefixMatchesForRows = function(
  token, maxMatches) {
  var matches = [];

  if (!goog.string.isEmptySafe(token)) {
    var escapedToken = goog.string.regExpEscape(token);
    var matcher = new RegExp('(^|\\W+)' + escapedToken, 'i');

    var matchCount = 0;
    for (var i = 0, row;
      (row = this.rows_[i]) && matchCount < maxMatches; ++i) {
      if (row.toString().match(matcher)) {
        matchCount++;
        matches.push(row);
      }
    }
  }
  return matches;
};

/**
 * This function is taken from {@link goog.ui.ac.ArrayMatcher}. It
 * matches the token against similar rows, by calculating "distance" between the
 * terms
 * @param {string} token Token to match
 * @param {number} maxMatches Max number of matches to return
 * @return {!Array.<plana.ui.ac.RemoteObject>} The best maxMatches rows
 */
plana.ui.ac.CachingObjectMatcher.prototype.getSimilarMatchesForRows = function(
  token, maxMatches) {
  /**
   * @type {Array.<{obj: plana.ui.ac.RemoteObject,
   *           score: number,
   *           index: number}>}
   */
  var results = [];

  for (var index = 0, row; row = this.rows_[index]; ++index) {
    var str = token.toLowerCase();
    var txt = row.toString().toLowerCase();
    var score = 0;

    if (txt.indexOf(str) != -1) {
      score = parseInt((txt.indexOf(str) / 4).toString(), 10);

    } else {
      var arr = str.split('');

      var lastPos = -1;
      var penalty = 10;

      for (var i = 0, c; c = arr[i]; ++i) {
        var pos = txt.indexOf(c);

        if (pos > lastPos) {
          var diff = pos - lastPos - 1;

          if (diff > penalty - 5) {
            diff = penalty - 5;
          }

          score += diff;

          lastPos = pos;
        } else {
          score += penalty;
          penalty += 5;
        }
      }
    }

    if (score < str.length * 6) {
      results.push({
        obj: row,
        score: score,
        index: index
      });
    }
  }

  results.sort(
    /**
     * @param  {{obj: plana.ui.ac.RemoteObject,
     *           score: number,
     *           index: number}} a Score a
     * @param  {{obj: plana.ui.ac.RemoteObject,
     *           score: number,
     *           index: number}} b Score b
     * @return {number}
     */

    function(a, b) {
      var diff = a.score - b.score;
      if (diff != 0) {
        return diff;
      }
      return a.index - b.index;
    });

  var matches = [];
  for (var i = 0; i < maxMatches && i < results.length; ++i) {
    matches.push(results[i].obj);
  }

  return matches;
};

/**
 * This function is taken from {@link goog.ui.ac.CachingMatcher}.
 * It passes matches to the autocomplete
 * @param {string} token Token to match
 * @param {number} maxMatches Max number of matches to return
 * @param {?Function} matchHandler callback to execute after matching
 * @param {string=} opt_fullstring The complete string in the input
 *     textbox
 */
plana.ui.ac.CachingObjectMatcher.prototype.requestMatchingRows =
  function(token, maxMatches, matchHandler, opt_fullstring) {

    this.cacheMaxMatches_ = maxMatches;
    this.mostRecentToken_ = token;
    this.mostRecentMatchHandler_ = matchHandler;
    this.mostRecentString_ = opt_fullstring || '';

    var fetching = this.shouldRequestMatches(token, this.mostRecentString_);
    if (fetching) {
      this.currentState_ = plana.ui.ac.CachingObjectMatcher.State.FETCHING;
      this.throttledTriggerBaseMatch_.fire();
    } else {
      this.currentState_ = plana.ui.ac.CachingObjectMatcher.State.READY;
    }

    var matches = this.getCachedMatches(token, maxMatches);

    if (matchHandler)
      matchHandler(token, matches);
    this.mostRecentMatches_ = matches;
};


/**
 * This function is taken from {@link goog.ui.ac.CachingMatcher}.
 * Adds the specified rows to the cache
 * @param {Array.<plana.ui.ac.RemoteObject>} rows
 * @private
 */
plana.ui.ac.CachingObjectMatcher.prototype.addRowsToCache_ = function(rows) {
  if (!this.disableLocalCache_) {
    goog.array.forEach(rows, function(row) {
      var str = row.toString();
      // The ' ' prefix is to avoid colliding with builtins like toString.
      if (!this.rowStrings_[' ' + str]) {
        this.rows_.push(row);
        this.rowStrings_[' ' + str] = true;
      }
    }, this);
  }
};


/**
 * Checks if the cache is larger than the maximum cache size. If so clears it
 * @private
 */
plana.ui.ac.CachingObjectMatcher.prototype.clearCacheIfTooLarge_ = function() {
  if (this.rows_.length > this.maxCacheSize_) {
    this.clearCache();
  }
};


/**
 * This function is adapted from {@link goog.ui.ac.CachingMatcher}.
 * Triggers a match request against the base matcher. This function is
 * unthrottled, so don't call it directly; instead use
 * this.throttledTriggerBaseMatch_
 * @private
 */
plana.ui.ac.CachingObjectMatcher.prototype.triggerBaseMatch_ = function() {
  this.remoteMatcher_.requestMatches(this.mostRecentToken_,
    this.remoteMatcherMaxMatches_, this.mostRecentString_);
};

/**
 * Callback for events dispatched by the remote matcher. This function adds
 * results to cached results and calls the match handler to update the list
 * of matches
 * @param {plana.ui.ac.RemoteObjectMatcher.Event} e The event object dispatched
 *     by the remote matcher object
 * @private
 */
plana.ui.ac.CachingObjectMatcher.prototype.onRemoteMatcherEvent_ = function(e) {
  // make sure to keep existing rows highlighted.
  var options = new goog.ui.ac.RenderOptions();
  options.setPreserveHilited(true);

  switch (e.type) {
    case plana.ui.ac.RemoteObjectMatcher.EventType.MATCHES:
      var matches = e.matches;
      this.addRowsToCache_(matches);

      //mostRecentMatches_ contains the matches shown from cache
      var oldMatchesSet = {};
      for (var i = 0, match; match = this.mostRecentMatches_[i]; ++i) {
        // The ' ' prefix is to avoid colliding with builtins like toString.
        oldMatchesSet[' ' + match.toString()] = true;
      }

      var newMatches = goog.array.filter(matches, function(match) {
        return !(oldMatchesSet[' ' + match.toString()]);
      });

      /*combine cached matches and server matches and cut the server
       *matches to the cacheMaxMatches */
      this.mostRecentMatches_ =
        this.mostRecentMatches_.concat(newMatches).
      slice(0, this.cacheMaxMatches_);

      var fetched = this.shouldRequestMatches(this.mostRecentToken_,
        this.mostRecentString_);
      if (fetched &&
        this.mostRecentMatches_.length == 0)
        this.currentState_ = plana.ui.ac.CachingObjectMatcher.State.NO_MATCH;
      else
        this.currentState_ = plana.ui.ac.CachingObjectMatcher.State.READY;

      if (this.mostRecentMatchHandler_)
        this.mostRecentMatchHandler_(this.mostRecentToken_,
          this.mostRecentMatches_, options);

      // We clear the cache *after* running the local match, so we don't
      // suddenly remove results just because the remote match came back.
      this.clearCacheIfTooLarge_();
      break;
    case plana.ui.ac.RemoteObjectMatcher.EventType.FAILED_REQUEST:
    case plana.ui.ac.RemoteObjectMatcher.EventType.INVALID_RESPONSE:
      this.currentState_ = plana.ui.ac.CachingObjectMatcher.State.ERROR;
      //show cached items if we have any...
      //maybe notify listeners somehow of error to show an error
      if (this.mostRecentMatchHandler_)
        this.mostRecentMatchHandler_(this.mostRecentToken_,
          this.mostRecentMatches_, options);
      break;
    default:
      throw 'Invalid remote event type:' + e.type;
  }
};

/**
 * This function returns the current state of the cache manager
 * and its base matcher
 * @return {number}
 */
plana.ui.ac.CachingObjectMatcher.prototype.getState = function() {
  return this.currentState_;
};

/**
 * This function returns the matcher used to fetch matches via
 * ajax
 * @return {plana.ui.ac.RemoteObjectMatcher}
 */
plana.ui.ac.CachingObjectMatcher.prototype.getRemoteMatcher = function() {
  return this.remoteMatcher_;
};

/**
 * This function clears the cache
 */
plana.ui.ac.CachingObjectMatcher.prototype.clearCache = function() {
  for (var i = 0, match; match = this.rows_[i]; ++i) {
    match.dispose();
  }
  this.rows_.length = 0;
  this.rowStrings_ = {};
};

/**
 * Setter for flag if we should send an empty token to the server to
 * request matches.
 * @param {boolean} sendEmpty
 */
plana.ui.ac.CachingObjectMatcher.prototype.sendEmptyToken = function(sendEmpty) {
  this.sendEmptyToken_ = sendEmpty;
};

/**
 * Setter to disable the local cache. If disabled, matches returned by the
 * server will not be stored locally
 * @param {boolean} disable The value to set
 */
plana.ui.ac.CachingObjectMatcher.prototype.disableLocalCache = function(
  disable) {
  this.disableLocalCache_ = disable;
};

/**
 * List of events dispatched by the cache manager
 * @enum {!number}
 */
plana.ui.ac.CachingObjectMatcher.State = {
  /**
   * @desc This state indicates that the remote
   * matcher is fetching results from the server
   */
  FETCHING: 0,
  /**
   * @desc This state indicates that the server
   * could not find matches for a token
   */
  NO_MATCH: 1,
  /**
   * @desc This state indicates that matches
   * have been returned by the server
   */
  READY: 2,
  /**
   * @desc This state indicates that the remote
   * matcher encountered an error trying to
   * get matches
   */
  ERROR: 3
};