gecko/toolkit/components/search/nsSearchSuggestions.js

570 lines
18 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const SEARCH_RESPONSE_SUGGESTION_JSON = "application/x-suggestions+json";
const BROWSER_SUGGEST_PREF = "browser.search.suggest.enabled";
const XPCOM_SHUTDOWN_TOPIC = "xpcom-shutdown";
const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed";
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cr = Components.results;
const Cu = Components.utils;
const HTTP_OK = 200;
const HTTP_INTERNAL_SERVER_ERROR = 500;
const HTTP_BAD_GATEWAY = 502;
const HTTP_SERVICE_UNAVAILABLE = 503;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/nsFormAutoCompleteResult.jsm");
Cu.import("resource://gre/modules/Services.jsm");
/**
* SuggestAutoComplete is a base class that implements nsIAutoCompleteSearch
* and can collect results for a given search by using the search URL supplied
* by the subclass. We do it this way since the AutoCompleteController in
* Mozilla requires a unique XPCOM Service for every search provider, even if
* the logic for two providers is identical.
* @constructor
*/
function SuggestAutoComplete() {
this._init();
}
SuggestAutoComplete.prototype = {
_init: function() {
this._addObservers();
this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF);
},
get _suggestionLabel() {
delete this._suggestionLabel;
let bundle = Services.strings.createBundle("chrome://global/locale/search/search.properties");
return this._suggestionLabel = bundle.GetStringFromName("suggestion_label");
},
/**
* Search suggestions will be shown if this._suggestEnabled is true.
*/
_suggestEnabled: null,
/*************************************************************************
* Server request backoff implementation fields below
* These allow us to throttle requests if the server is getting hammered.
**************************************************************************/
/**
* This is an array that contains the timestamps (in unixtime) of
* the last few backoff-triggering errors.
*/
_serverErrorLog: [],
/**
* If we receive this number of backoff errors within the amount of time
* specified by _serverErrorPeriod, then we initiate backoff.
*/
_maxErrorsBeforeBackoff: 3,
/**
* If we receive enough consecutive errors (where "enough" is defined by
* _maxErrorsBeforeBackoff above) within this time period,
* we trigger the backoff behavior.
*/
_serverErrorPeriod: 600000, // 10 minutes in milliseconds
/**
* If we get another backoff error immediately after timeout, we increase the
* backoff to (2 x old period) + this value.
*/
_serverErrorTimeoutIncrement: 600000, // 10 minutes in milliseconds
/**
* The current amount of time to wait before trying a server request
* after receiving a backoff error.
*/
_serverErrorTimeout: 0,
/**
* Time (in unixtime) after which we're allowed to try requesting again.
*/
_nextRequestTime: 0,
/**
* The last engine we requested against (so that we can tell if the
* user switched engines).
*/
_serverErrorEngine: null,
/**
* The XMLHttpRequest object.
* @private
*/
_request: null,
/**
* The object implementing nsIAutoCompleteObserver that we notify when
* we have found results
* @private
*/
_listener: null,
/**
* If this is true, we'll integrate form history results with the
* suggest results.
*/
_includeFormHistory: true,
/**
* True if a request for remote suggestions was sent. This is used to
* differentiate between the "_request is null because the request has
* already returned a result" and "_request is null because no request was
* sent" cases.
*/
_sentSuggestRequest: false,
/**
* This is the callback for the suggest timeout timer.
*/
notify: function SAC_notify(timer) {
// FIXME: bug 387341
// Need to break the cycle between us and the timer.
this._formHistoryTimer = null;
// If this._listener is null, we've already sent out suggest results, so
// nothing left to do here.
if (!this._listener)
return;
// Otherwise, the XMLHTTPRequest for suggest results is taking too long,
// so send out the form history results and cancel the request.
this._listener.onSearchResult(this, this._formHistoryResult);
this._reset();
},
/**
* This determines how long (in ms) we should wait before giving up on
* the suggestions and just showing local form history results.
*/
_suggestionTimeout: 500,
/**
* This is the callback for that the form history service uses to
* send us results.
*/
onSearchResult: function SAC_onSearchResult(search, result) {
this._formHistoryResult = result;
if (this._request) {
// We still have a pending request, wait a bit to give it a chance to
// finish.
this._formHistoryTimer = Cc["@mozilla.org/timer;1"].
createInstance(Ci.nsITimer);
this._formHistoryTimer.initWithCallback(this, this._suggestionTimeout,
Ci.nsITimer.TYPE_ONE_SHOT);
} else if (!this._sentSuggestRequest) {
// We didn't send a request, so just send back the form history results.
this._listener.onSearchResult(this, this._formHistoryResult);
this._reset();
}
},
/**
* This is the URI that the last suggest request was sent to.
*/
_suggestURI: null,
/**
* Autocomplete results from the form history service get stored here.
*/
_formHistoryResult: null,
/**
* This holds the suggest server timeout timer, if applicable.
*/
_formHistoryTimer: null,
/**
* This clears all the per-request state.
*/
_reset: function SAC_reset() {
// Don't let go of our listener and form history result if the timer is
// still pending, the timer will call _reset() when it fires.
if (!this._formHistoryTimer) {
this._listener = null;
this._formHistoryResult = null;
}
this._request = null;
},
/**
* This sends an autocompletion request to the form history service,
* which will call onSearchResults with the results of the query.
*/
_startHistorySearch: function SAC_SHSearch(searchString, searchParam) {
var formHistory =
Cc["@mozilla.org/autocomplete/search;1?name=form-history"].
createInstance(Ci.nsIAutoCompleteSearch);
formHistory.startSearch(searchString, searchParam, this._formHistoryResult, this);
},
/**
* Makes a note of the fact that we've received a backoff-triggering
* response, so that we can adjust the backoff behavior appropriately.
*/
_noteServerError: function SAC__noteServeError() {
var currentTime = Date.now();
this._serverErrorLog.push(currentTime);
if (this._serverErrorLog.length > this._maxErrorsBeforeBackoff)
this._serverErrorLog.shift();
if ((this._serverErrorLog.length == this._maxErrorsBeforeBackoff) &&
((currentTime - this._serverErrorLog[0]) < this._serverErrorPeriod)) {
// increase timeout, and then don't request until timeout is over
this._serverErrorTimeout = (this._serverErrorTimeout * 2) +
this._serverErrorTimeoutIncrement;
this._nextRequestTime = currentTime + this._serverErrorTimeout;
}
},
/**
* Resets the backoff behavior; called when we get a successful response.
*/
_clearServerErrors: function SAC__clearServerErrors() {
this._serverErrorLog = [];
this._serverErrorTimeout = 0;
this._nextRequestTime = 0;
},
/**
* This checks whether we should send a server request (i.e. we're not
* in a error-triggered backoff period.
*
* @private
*/
_okToRequest: function SAC__okToRequest() {
return Date.now() > this._nextRequestTime;
},
/**
* This checks to see if the new search engine is different
* from the previous one, and if so clears any error state that might
* have accumulated for the old engine.
*
* @param engine The engine that the suggestion request would be sent to.
* @private
*/
_checkForEngineSwitch: function SAC__checkForEngineSwitch(engine) {
if (engine == this._serverErrorEngine)
return;
// must've switched search providers, clear old errors
this._serverErrorEngine = engine;
this._clearServerErrors();
},
/**
* This returns true if the status code of the HTTP response
* represents a backoff-triggering error.
*
* @param status The status code from the HTTP response
* @private
*/
_isBackoffError: function SAC__isBackoffError(status) {
return ((status == HTTP_INTERNAL_SERVER_ERROR) ||
(status == HTTP_BAD_GATEWAY) ||
(status == HTTP_SERVICE_UNAVAILABLE));
},
/**
* Called when the 'readyState' of the XMLHttpRequest changes. We only care
* about state 4 (COMPLETED) - handle the response data.
* @private
*/
onReadyStateChange: function() {
// xxx use the real const here
if (!this._request || this._request.readyState != 4)
return;
try {
var status = this._request.status;
} catch (e) {
// The XML HttpRequest can throw NS_ERROR_NOT_AVAILABLE.
return;
}
if (this._isBackoffError(status)) {
this._noteServerError();
return;
}
var responseText = this._request.responseText;
if (status != HTTP_OK || responseText == "")
return;
this._clearServerErrors();
var serverResults = JSON.parse(responseText);
var searchString = serverResults[0] || "";
var results = serverResults[1] || [];
var comments = []; // "comments" column values for suggestions
var historyResults = [];
var historyComments = [];
// If form history is enabled and has results, add them to the list.
if (this._includeFormHistory && this._formHistoryResult &&
(this._formHistoryResult.searchResult ==
Ci.nsIAutoCompleteResult.RESULT_SUCCESS)) {
for (var i = 0; i < this._formHistoryResult.matchCount; ++i) {
var term = this._formHistoryResult.getValueAt(i);
// we don't want things to appear in both history and suggestions
var dupIndex = results.indexOf(term);
if (dupIndex != -1)
results.splice(dupIndex, 1);
historyResults.push(term);
historyComments.push("");
}
}
// fill out the comment column for the suggestions
for (var i = 0; i < results.length; ++i)
comments.push("");
// if we have any suggestions, put a label at the top
if (comments.length > 0)
comments[0] = this._suggestionLabel;
// now put the history results above the suggestions
var finalResults = historyResults.concat(results);
var finalComments = historyComments.concat(comments);
// Notify the FE of our new results
this.onResultsReady(searchString, finalResults, finalComments,
this._formHistoryResult);
// Reset our state for next time.
this._reset();
},
/**
* Notifies the front end of new results.
* @param searchString the user's query string
* @param results an array of results to the search
* @param comments an array of metadata corresponding to the results
* @private
*/
onResultsReady: function(searchString, results, comments,
formHistoryResult) {
if (this._listener) {
var result = new FormAutoCompleteResult(
searchString,
Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
0,
"",
results,
results,
comments,
formHistoryResult);
this._listener.onSearchResult(this, result);
// Null out listener to make sure we don't notify it twice, in case our
// timer callback still hasn't run.
this._listener = null;
}
},
/**
* Initiates the search result gathering process. Part of
* nsIAutoCompleteSearch implementation.
*
* @param searchString the user's query string
* @param searchParam unused, "an extra parameter"; even though
* this parameter and the next are unused, pass
* them through in case the form history
* service wants them
* @param previousResult unused, a client-cached store of the previous
* generated resultset for faster searching.
* @param listener object implementing nsIAutoCompleteObserver which
* we notify when results are ready.
*/
startSearch: function(searchString, searchParam, previousResult, listener) {
// Don't reuse a previous form history result when it no longer applies.
if (!previousResult)
this._formHistoryResult = null;
var formHistorySearchParam = searchParam.split("|")[0];
// Receive the information about the privacy mode of the window to which
// this search box belongs. The front-end's search.xml bindings passes this
// information in the searchParam parameter. The alternative would have
// been to modify nsIAutoCompleteSearch to add an argument to startSearch
// and patch all of autocomplete to be aware of this, but the searchParam
// argument is already an opaque argument, so this solution is hopefully
// less hackish (although still gross.)
var privacyMode = (searchParam.split("|")[1] == "private");
// Start search immediately if possible, otherwise once the search
// service is initialized
if (Services.search.isInitialized) {
this._triggerSearch(searchString, formHistorySearchParam, listener, privacyMode);
return;
}
Services.search.init((function startSearch_cb(aResult) {
if (!Components.isSuccessCode(aResult)) {
Cu.reportError("Could not initialize search service, bailing out: " + aResult);
return;
}
this._triggerSearch(searchString, formHistorySearchParam, listener, privacyMode);
}).bind(this));
},
/**
* Actual implementation of search.
*/
_triggerSearch: function(searchString, searchParam, listener, privacyMode) {
// If there's an existing request, stop it. There is no smart filtering
// here as there is when looking through history/form data because the
// result set returned by the server is different for every typed value -
// "ocean breathes" does not return a subset of the results returned for
// "ocean", for example. This does nothing if there is no current request.
this.stopSearch();
this._listener = listener;
var engine = Services.search.currentEngine;
this._checkForEngineSwitch(engine);
if (!searchString ||
!this._suggestEnabled ||
!engine.supportsResponseType(SEARCH_RESPONSE_SUGGESTION_JSON) ||
!this._okToRequest()) {
// We have an empty search string (user pressed down arrow to see
// history), or search suggestions are disabled, or the current engine
// has no suggest functionality, or we're in backoff mode; so just use
// local history.
this._sentSuggestRequest = false;
this._startHistorySearch(searchString, searchParam);
return;
}
// Actually do the search
this._request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
createInstance(Ci.nsIXMLHttpRequest);
var submission = engine.getSubmission(searchString,
SEARCH_RESPONSE_SUGGESTION_JSON);
this._suggestURI = submission.uri;
var method = (submission.postData ? "POST" : "GET");
this._request.open(method, this._suggestURI.spec, true);
this._request.channel.notificationCallbacks = new AuthPromptOverride();
if (this._request.channel instanceof Ci.nsIPrivateBrowsingChannel) {
this._request.channel.setPrivate(privacyMode);
}
var self = this;
function onReadyStateChange() {
self.onReadyStateChange();
}
this._request.onreadystatechange = onReadyStateChange;
this._request.send(submission.postData);
if (this._includeFormHistory) {
this._sentSuggestRequest = true;
this._startHistorySearch(searchString, searchParam);
}
},
/**
* Ends the search result gathering process. Part of nsIAutoCompleteSearch
* implementation.
*/
stopSearch: function() {
if (this._request) {
this._request.abort();
this._reset();
}
},
/**
* nsIObserver
*/
observe: function SAC_observe(aSubject, aTopic, aData) {
switch (aTopic) {
case NS_PREFBRANCH_PREFCHANGE_TOPIC_ID:
this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF);
break;
case XPCOM_SHUTDOWN_TOPIC:
this._removeObservers();
break;
}
},
_addObservers: function SAC_addObservers() {
Services.prefs.addObserver(BROWSER_SUGGEST_PREF, this, false);
Services.obs.addObserver(this, XPCOM_SHUTDOWN_TOPIC, false);
},
_removeObservers: function SAC_removeObservers() {
Services.prefs.removeObserver(BROWSER_SUGGEST_PREF, this);
Services.obs.removeObserver(this, XPCOM_SHUTDOWN_TOPIC);
},
// nsISupports
QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteSearch,
Ci.nsIAutoCompleteObserver])
};
function AuthPromptOverride() {
}
AuthPromptOverride.prototype = {
// nsIAuthPromptProvider
getAuthPrompt: function (reason, iid) {
// Return a no-op nsIAuthPrompt2 implementation.
return {
promptAuth: function () {
throw Cr.NS_ERROR_NOT_IMPLEMENTED;
},
asyncPromptAuth: function () {
throw Cr.NS_ERROR_NOT_IMPLEMENTED;
}
};
},
// nsIInterfaceRequestor
getInterface: function SSLL_getInterface(iid) {
return this.QueryInterface(iid);
},
// nsISupports
QueryInterface: XPCOMUtils.generateQI([Ci.nsIAuthPromptProvider,
Ci.nsIInterfaceRequestor])
};
/**
* SearchSuggestAutoComplete is a service implementation that handles suggest
* results specific to web searches.
* @constructor
*/
function SearchSuggestAutoComplete() {
// This calls _init() in the parent class (SuggestAutoComplete) via the
// prototype, below.
this._init();
}
SearchSuggestAutoComplete.prototype = {
classID: Components.ID("{aa892eb4-ffbf-477d-9f9a-06c995ae9f27}"),
__proto__: SuggestAutoComplete.prototype,
serviceURL: ""
};
var component = [SearchSuggestAutoComplete];
this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component);