/* ***** BEGIN LICENSE BLOCK ***** * Version: MPL 1.1/GPL 2.0/LGPL 2.1 * * The contents of this file are subject to the Mozilla Public License Version * 1.1 (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.mozilla.org/MPL/ * * Software distributed under the License is distributed on an "AS IS" basis, * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License * for the specific language governing rights and limitations under the * License. * * The Original Code is Google Suggest Autocomplete Implementation for Firefox. * * The Initial Developer of the Original Code is Google Inc. * Portions created by the Initial Developer are Copyright (C) 2006 * the Initial Developer. All Rights Reserved. * * Contributor(s): * Ben Goodger * Mike Connor * Joe Hughes * Pamela Greene * * Alternatively, the contents of this file may be used under the terms of * either the GNU General Public License Version 2 or later (the "GPL"), or * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), * in which case the provisions of the GPL or the LGPL are applicable instead * of those above. If you wish to allow use of your version of this file only * under the terms of either the GPL or the LGPL, and not to allow others to * use your version of this file under the terms of the MPL, indicate your * decision by deleting the provisions above and replace them with the notice * and other provisions required by the GPL or the LGPL. If you do not delete * the provisions above, a recipient may use your version of this file under * the terms of any one of the MPL, the GPL or the LGPL. * * ***** END LICENSE BLOCK ***** */ 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"; /** * Metadata describing the Web Search suggest mode */ const SEARCH_SUGGEST_CONTRACTID = "@mozilla.org/autocomplete/search;1?name=search-autocomplete"; const SEARCH_SUGGEST_CLASSNAME = "Remote Search Suggestions"; const SEARCH_SUGGEST_CLASSID = Components.ID("{aa892eb4-ffbf-477d-9f9a-06c995ae9f27}"); const SEARCH_BUNDLE = "chrome://browser/locale/search.properties"; const Cc = Components.classes; const Ci = Components.interfaces; const Cr = Components.results; const HTTP_OK = 200; const HTTP_INTERNAL_SERVER_ERROR = 500; const HTTP_BAD_GATEWAY = 502; const HTTP_SERVICE_UNAVAILABLE = 503; /** * SuggestAutoCompleteResult contains the results returned by the Suggest * service - it implements nsIAutoCompleteResult and is used by the auto- * complete controller to populate the front end. * @constructor */ function SuggestAutoCompleteResult(searchString, searchResult, defaultIndex, errorDescription, results, comments, formHistoryResult) { this._searchString = searchString; this._searchResult = searchResult; this._defaultIndex = defaultIndex; this._errorDescription = errorDescription; this._results = results; this._comments = comments; this._formHistoryResult = formHistoryResult; } SuggestAutoCompleteResult.prototype = { /** * The user's query string * @private */ _searchString: "", /** * The result code of this result object, see |get searchResult| for possible * values. * @private */ _searchResult: 0, /** * The default item that should be entered if none is selected * @private */ _defaultIndex: 0, /** * The reason the search failed * @private */ _errorDescription: "", /** * The list of words returned by the Suggest Service * @private */ _results: [], /** * The list of Comments (number of results - or page titles) returned by the * Suggest Service. * @private */ _comments: [], /** * A reference to the form history nsIAutocompleteResult that we're wrapping. * We use this to forward removeEntryAt calls as needed. */ _formHistoryResult: null, /** * @return the user's query string */ get searchString() { return this._searchString; }, /** * @return the result code of this result object, either: * RESULT_IGNORED (invalid searchString) * RESULT_FAILURE (failure) * RESULT_NOMATCH (no matches found) * RESULT_SUCCESS (matches found) */ get searchResult() { return this._searchResult; }, /** * @return the default item that should be entered if none is selected */ get defaultIndex() { return this._defaultIndex; }, /** * @return the reason the search failed */ get errorDescription() { return this._errorDescription; }, /** * @return the number of results */ get matchCount() { return this._results.length; }, /** * Retrieves a result * @param index the index of the result requested * @return the result at the specified index */ getValueAt: function(index) { return this._results[index]; }, /** * Retrieves a comment (metadata instance) * @param index the index of the comment requested * @return the comment at the specified index */ getCommentAt: function(index) { return this._comments[index]; }, /** * Retrieves a style hint specific to a particular index. * @param index the index of the style hint requested * @return the style hint at the specified index */ getStyleAt: function(index) { if (!this._comments[index]) return null; // not a category label, so no special styling if (index == 0) return "suggestfirst"; // category label on first line of results return "suggesthint"; // category label on any other line of results }, /** * Removes a result from the resultset * @param index the index of the result to remove */ removeValueAt: function(index, removeFromDatabase) { // Forward the removeValueAt call to the underlying result if we have one // Note: this assumes that the form history results were added to the top // of our arrays. if (removeFromDatabase && this._formHistoryResult && index < this._formHistoryResult.matchCount) { // Delete the history result from the DB this._formHistoryResult.removeValueAt(index, true); } this._results.splice(index, 1); this._comments.splice(index, 1); }, /** * Part of nsISupports implementation. * @param iid requested interface identifier * @return this object (XPConnect handles the magic of telling the caller that * we're the type it requested) */ QueryInterface: function(iid) { if (!iid.equals(Ci.nsIAutoCompleteResult) && !iid.equals(Ci.nsISupports)) throw Cr.NS_ERROR_NO_INTERFACE; return this; } }; /** * 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._loadSuggestPref(); }, /** * this._strings is the string bundle for message internationalization. */ get _strings() { if (!this.__strings) { var sbs = Cc["@mozilla.org/intl/stringbundle;1"]. getService(Ci.nsIStringBundleService); this.__strings = sbs.createBundle(SEARCH_BUNDLE); } return this.__strings; }, __strings: null, /** * Search suggestions will be shown if this._suggestEnabled is true. */ _loadSuggestPref: function SAC_loadSuggestPref() { var prefService = Cc["@mozilla.org/preferences-service;1"]. getService(Ci.nsIPrefBranch); this._suggestEnabled = prefService.getBoolPref(BROWSER_SUGGEST_PREF); }, _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. If this gets * called, it means that we've given up on receiving a reply from the * search engine's suggestion server in a timely manner. */ notify: function SAC_notify(timer) { // make sure we're still waiting for this response before sending if ((timer != this._formHistoryTimer) || !this._listener) return; this._listener.onSearchResult(this, this._formHistoryResult); this._formHistoryTimer = null; 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, previousResult) { var formHistory = Cc["@mozilla.org/autocomplete/search;1?name=form-history"]. createInstance(Ci.nsIAutoCompleteSearch); formHistory.startSearch(searchString, searchParam, previousResult, this); }, /** * Makes a note of the fact that we've recieved 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(); // This is a modified version of Crockford's JSON sanitizer, obtained // from http://www.json.org/js.html. // This should use built-in functions once bug 340987 is fixed. const JSON_STRING = /^("(\\.|[^"\\\n\r])*?"|[,:{}\[\]0-9.\-+Eaeflnr-u \n\r\t])+?$/; var sandbox = new Components.utils.Sandbox(this._suggestURI.prePath); function parseJSON(aString) { try { if (JSON_STRING.test(aString)) return Components.utils.evalInSandbox("(" + aString + ")", sandbox); } catch (e) {} return []; }; var serverResults = parseJSON(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._strings.GetStringFromName("suggestion_label"); // 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 SuggestAutoCompleteResult( searchString, Ci.nsIAutoCompleteResult.RESULT_SUCCESS, 0, "", 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) { var searchService = Cc["@mozilla.org/browser/search-service;1"]. getService(Ci.nsIBrowserSearchService); // 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 = searchService.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, previousResult); 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); 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, previousResult); } }, /** * 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._loadSuggestPref(); break; case XPCOM_SHUTDOWN_TOPIC: this._removeObservers(); break; } }, _addObservers: function SAC_addObservers() { var prefService2 = Cc["@mozilla.org/preferences-service;1"]. getService(Ci.nsIPrefBranch2); prefService2.addObserver(BROWSER_SUGGEST_PREF, this, false); var os = Cc["@mozilla.org/observer-service;1"]. getService(Ci.nsIObserverService); os.addObserver(this, XPCOM_SHUTDOWN_TOPIC, false); }, _removeObservers: function SAC_removeObservers() { var prefService2 = Cc["@mozilla.org/preferences-service;1"]. getService(Ci.nsIPrefBranch2); prefService2.removeObserver(BROWSER_SUGGEST_PREF, this); var os = Cc["@mozilla.org/observer-service;1"]. getService(Ci.nsIObserverService); os.removeObserver(this, XPCOM_SHUTDOWN_TOPIC); }, /** * Part of nsISupports implementation. * @param iid requested interface identifier * @return this object (XPConnect handles the magic of telling the caller that * we're the type it requested) */ QueryInterface: function(iid) { if (!iid.equals(Ci.nsIAutoCompleteSearch) && !iid.equals(Ci.nsIAutoCompleteObserver) && !iid.equals(Ci.nsISupports)) throw Cr.NS_ERROR_NO_INTERFACE; return this; } }; /** * 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 = { __proto__: SuggestAutoComplete.prototype, serviceURL: "" }; var gModule = { /** * Registers all the components supplied by this module. Part of nsIModule * implementation. * @param componentManager the XPCOM component manager * @param location the location of the module on disk * @param loaderString opaque loader specific string * @param type loader type being used to load this module */ registerSelf: function(componentManager, location, loaderString, type) { if (this._firstTime) { this._firstTime = false; throw Cr.NS_ERROR_FACTORY_REGISTER_AGAIN; } componentManager = componentManager.QueryInterface(Ci.nsIComponentRegistrar); for (var key in this.objects) { var obj = this.objects[key]; componentManager.registerFactoryLocation(obj.CID, obj.className, obj.contractID, location, loaderString, type); } }, /** * Retrieves a Factory for the given ClassID. Part of nsIModule * implementation. * @param componentManager the XPCOM component manager * @param cid the ClassID of the object for which a factory * has been requested * @param iid the IID of the interface requested */ getClassObject: function(componentManager, cid, iid) { if (!iid.equals(Ci.nsIFactory)) throw Cr.NS_ERROR_NOT_IMPLEMENTED; for (var key in this.objects) { if (cid.equals(this.objects[key].CID)) return this.objects[key].factory; } throw Cr.NS_ERROR_NO_INTERFACE; }, /** * Create a Factory object that can construct an instance of an object. * @param constructor the constructor used to create the object * @private */ _makeFactory: function(constructor) { function createInstance(outer, iid) { if (outer != null) throw Cr.NS_ERROR_NO_AGGREGATION; return (new constructor()).QueryInterface(iid); } return { createInstance: createInstance }; }, /** * Determines whether or not this module can be unloaded. * @return returning true indicates that this module can be unloaded. */ canUnload: function(componentManager) { return true; } }; /** * Entry point for registering the components supplied by this JavaScript * module. * @param componentManager the XPCOM component manager * @param location the location of this module on disk */ function NSGetModule(componentManager, location) { // Metadata about the objects this module can construct gModule.objects = { search: { CID: SEARCH_SUGGEST_CLASSID, contractID: SEARCH_SUGGEST_CONTRACTID, className: SEARCH_SUGGEST_CLASSNAME, factory: gModule._makeFactory(SearchSuggestAutoComplete) }, }; return gModule; }