/* vim: set ts=4 sts=4 sw=4 et tw=80: */ /* 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/. */ "use strict"; const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils", "resource://gre/modules/BrowserUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Deprecated", "resource://gre/modules/Deprecated.jsm"); function isAutocompleteDisabled(aField) { if (aField.autocomplete !== "") { return aField.autocomplete === "off"; } return aField.form && aField.form.autocomplete === "off"; } /** * An abstraction to talk with the FormHistory database over * the message layer. FormHistoryClient will take care of * figuring out the most appropriate message manager to use, * and what things to send. * * It is assumed that nsFormAutoComplete will only ever use * one instance at a time, and will not attempt to perform more * than one search request with the same instance at a time. * However, nsFormAutoComplete might call remove() any number of * times with the same instance of the client. * * @param Object with the following properties: * * formField (DOM node): * A DOM node that we're requesting form history for. * * inputName (string): * The name of the input to do the FormHistory look-up * with. If this is searchbar-history, then formField * needs to be null, otherwise constructing will throw. */ function FormHistoryClient({ formField, inputName }) { if (formField && inputName != this.SEARCHBAR_ID) { let window = formField.ownerDocument.defaultView; let topDocShell = window.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIDocShell) .sameTypeRootTreeItem .QueryInterface(Ci.nsIDocShell); this.mm = topDocShell.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIContentFrameMessageManager); } else { if (inputName == this.SEARCHBAR_ID) { if (formField) { throw new Error("FormHistoryClient constructed with both a " + "formField and an inputName. This is not " + "supported, and only empty results will be " + "returned."); } } this.mm = Services.cpmm; } this.inputName = inputName; this.id = FormHistoryClient.nextRequestID++; } FormHistoryClient.prototype = { SEARCHBAR_ID: "searchbar-history", // It is assumed that nsFormAutoComplete only uses / cares about // one FormHistoryClient at a time, and won't attempt to have // multiple in-flight searches occurring with the same FormHistoryClient. // We use an ID number per instantiated FormHistoryClient to make // sure we only respond to messages that were meant for us. id: 0, callback: null, inputName: "", mm: null, /** * Query FormHistory for some results. * * @param searchString (string) * The string to search FormHistory for. See * FormHistory.getAutoCompleteResults. * @param params (object) * An Object with search properties. See * FormHistory.getAutoCompleteResults. * @param callback * A callback function that will take a single * argument (the found entries). */ requestAutoCompleteResults(searchString, params, callback) { this.mm.sendAsyncMessage("FormHistory:AutoCompleteSearchAsync", { id: this.id, searchString, params, }); this.mm.addMessageListener("FormHistory:AutoCompleteSearchResults", this); this.callback = callback; }, /** * Cancel an in-flight results request. This ensures that the * callback that requestAutoCompleteResults was passed is never * called from this FormHistoryClient. */ cancel() { this.clearListeners(); }, /** * Remove an item from FormHistory. * * @param value (string) * * The value to remove for this particular * field. */ remove(value) { this.mm.sendAsyncMessage("FormHistory:RemoveEntry", { inputName: this.inputName, value, }); }, // Private methods receiveMessage(msg) { let { id, results } = msg.data; if (id != this.id) { return; } if (!this.callback) { Cu.reportError("FormHistoryClient received message with no " + "callback"); return; } this.callback(results); this.clearListeners(); }, clearListeners() { this.mm.removeMessageListener("FormHistory:AutoCompleteSearchResults", this); this.callback = null; }, }; FormHistoryClient.nextRequestID = 1; function FormAutoComplete() { this.init(); } /** * FormAutoComplete * * Implements the nsIFormAutoComplete interface in the main process. */ FormAutoComplete.prototype = { classID : Components.ID("{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}"), QueryInterface : XPCOMUtils.generateQI([Ci.nsIFormAutoComplete, Ci.nsISupportsWeakReference]), _prefBranch : null, _debug : true, // mirrors browser.formfill.debug _enabled : true, // mirrors browser.formfill.enable preference _agedWeight : 2, _bucketSize : 1, _maxTimeGroupings : 25, _timeGroupingSize : 7 * 24 * 60 * 60 * 1000 * 1000, _expireDays : null, _boundaryWeight : 25, _prefixWeight : 5, // Only one query via FormHistoryClient is performed at a time, and the // most recent FormHistoryClient which will be stored in _pendingClient // while the query is being performed. It will be cleared when the query // finishes, is cancelled, or an error occurs. If a new query occurs while // one is already pending, the existing one is cancelled. _pendingClient : null, init : function() { // Preferences. Add observer so we get notified of changes. this._prefBranch = Services.prefs.getBranch("browser.formfill."); this._prefBranch.addObserver("", this.observer, true); this.observer._self = this; this._debug = this._prefBranch.getBoolPref("debug"); this._enabled = this._prefBranch.getBoolPref("enable"); this._agedWeight = this._prefBranch.getIntPref("agedWeight"); this._bucketSize = this._prefBranch.getIntPref("bucketSize"); this._maxTimeGroupings = this._prefBranch.getIntPref("maxTimeGroupings"); this._timeGroupingSize = this._prefBranch.getIntPref("timeGroupingSize") * 1000 * 1000; this._expireDays = this._prefBranch.getIntPref("expire_days"); }, observer : { _self : null, QueryInterface : XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]), observe : function (subject, topic, data) { let self = this._self; if (topic == "nsPref:changed") { let prefName = data; self.log("got change to " + prefName + " preference"); switch (prefName) { case "agedWeight": self._agedWeight = self._prefBranch.getIntPref(prefName); break; case "debug": self._debug = self._prefBranch.getBoolPref(prefName); break; case "enable": self._enabled = self._prefBranch.getBoolPref(prefName); break; case "maxTimeGroupings": self._maxTimeGroupings = self._prefBranch.getIntPref(prefName); break; case "timeGroupingSize": self._timeGroupingSize = self._prefBranch.getIntPref(prefName) * 1000 * 1000; break; case "bucketSize": self._bucketSize = self._prefBranch.getIntPref(prefName); break; case "boundaryWeight": self._boundaryWeight = self._prefBranch.getIntPref(prefName); break; case "prefixWeight": self._prefixWeight = self._prefBranch.getIntPref(prefName); break; default: self.log("Oops! Pref not handled, change ignored."); } } } }, // AutoCompleteE10S needs to be able to call autoCompleteSearchAsync without // going through IDL in order to pass a mock DOM object field. get wrappedJSObject() { return this; }, /* * log * * Internal function for logging debug messages to the Error Console * window */ log : function (message) { if (!this._debug) return; dump("FormAutoComplete: " + message + "\n"); Services.console.logStringMessage("FormAutoComplete: " + message); }, /* * autoCompleteSearchAsync * * aInputName -- |name| attribute from the form input being autocompleted. * aUntrimmedSearchString -- current value of the input * aField -- nsIDOMHTMLInputElement being autocompleted (may be null if from chrome) * aPreviousResult -- previous search result, if any. * aDatalistResult -- results from list=datalist for aField. * aListener -- nsIFormAutoCompleteObserver that listens for the nsIAutoCompleteResult * that may be returned asynchronously. */ autoCompleteSearchAsync : function (aInputName, aUntrimmedSearchString, aField, aPreviousResult, aDatalistResult, aListener) { function sortBytotalScore (a, b) { return b.totalScore - a.totalScore; } // Guard against void DOM strings filtering into this code. if (typeof aInputName === "object") { aInputName = ""; } if (typeof aUntrimmedSearchString === "object") { aUntrimmedSearchString = ""; } let client = new FormHistoryClient({ formField: aField, inputName: aInputName }); // If we have datalist results, they become our "empty" result. let emptyResult = aDatalistResult || new FormAutoCompleteResult(client, [], aInputName, aUntrimmedSearchString, null); if (!this._enabled) { if (aListener) { aListener.onSearchCompletion(emptyResult); } return; } // don't allow form inputs (aField != null) to get results from search bar history if (aInputName == 'searchbar-history' && aField) { this.log('autoCompleteSearch for input name "' + aInputName + '" is denied'); if (aListener) { aListener.onSearchCompletion(emptyResult); } return; } if (aField && isAutocompleteDisabled(aField)) { this.log('autoCompleteSearch not allowed due to autcomplete=off'); if (aListener) { aListener.onSearchCompletion(emptyResult); } return; } this.log("AutoCompleteSearch invoked. Search is: " + aUntrimmedSearchString); let searchString = aUntrimmedSearchString.trim().toLowerCase(); // reuse previous results if: // a) length greater than one character (others searches are special cases) AND // b) the the new results will be a subset of the previous results if (aPreviousResult && aPreviousResult.searchString.trim().length > 1 && searchString.indexOf(aPreviousResult.searchString.trim().toLowerCase()) >= 0) { this.log("Using previous autocomplete result"); let result = aPreviousResult; let wrappedResult = result.wrappedJSObject; wrappedResult.searchString = aUntrimmedSearchString; // Leaky abstraction alert: it would be great to be able to split // this code between nsInputListAutoComplete and here but because of // the way we abuse the formfill autocomplete API in e10s, we have // to deal with the results here as well (and down below // in mergeResults). // If there were datalist results result is a FormAutoCompleteResult // as defined in nsFormAutoCompleteResult.jsm with the entire list // of results in wrappedResult._values and only the results from // form history in wrappedResult.entries. // First, grab the entire list of old results. let allResults = wrappedResult._labels; let datalistResults, datalistLabels; if (allResults) { // We have datalist results, extract them from the values array. // Both allResults and values arrays are in the form of: // |--wR.entries--| // let oldLabels = allResults.slice(wrappedResult.entries.length); let oldValues = wrappedResult._values.slice(wrappedResult.entries.length); datalistLabels = []; datalistResults = []; for (let i = 0; i < oldLabels.length; ++i) { if (oldLabels[i].toLowerCase().includes(searchString)) { datalistLabels.push(oldLabels[i]); datalistResults.push(oldValues[i]); } } } let searchTokens = searchString.split(/\s+/); // We have a list of results for a shorter search string, so just // filter them further based on the new search string and add to a new array. let entries = wrappedResult.entries; let filteredEntries = []; for (let i = 0; i < entries.length; i++) { let entry = entries[i]; // Remove results that do not contain the token // XXX bug 394604 -- .toLowerCase can be wrong for some intl chars if (searchTokens.some(tok => entry.textLowerCase.indexOf(tok) < 0)) continue; this._calculateScore(entry, searchString, searchTokens); this.log("Reusing autocomplete entry '" + entry.text + "' (" + entry.frecency +" / " + entry.totalScore + ")"); filteredEntries.push(entry); } filteredEntries.sort(sortBytotalScore); wrappedResult.entries = filteredEntries; // If we had datalistResults, re-merge them back into the filtered // entries. if (datalistResults) { filteredEntries = filteredEntries.map(elt => elt.text); let comments = new Array(filteredEntries.length + datalistResults.length).fill(""); comments[filteredEntries.length] = "separator"; // History entries don't have labels (their labels would be read // from their values). Pad out the labels array so the datalist // results (which do have separate values and labels) line up. datalistLabels = new Array(filteredEntries.length).fill("").concat(datalistLabels); wrappedResult._values = filteredEntries.concat(datalistResults); wrappedResult._labels = datalistLabels; wrappedResult._comments = comments; } if (aListener) { aListener.onSearchCompletion(result); } } else { this.log("Creating new autocomplete search result."); // Start with an empty list. let result = aDatalistResult ? new FormAutoCompleteResult(client, [], aInputName, aUntrimmedSearchString, null) : emptyResult; let processEntry = (aEntries) => { if (aField && aField.maxLength > -1) { result.entries = aEntries.filter(function (el) { return el.text.length <= aField.maxLength; }); } else { result.entries = aEntries; } if (aDatalistResult && aDatalistResult.matchCount > 0) { result = this.mergeResults(result, aDatalistResult); } if (aListener) { aListener.onSearchCompletion(result); } } this.getAutoCompleteValues(client, aInputName, searchString, processEntry); } }, mergeResults(historyResult, datalistResult) { let values = datalistResult.wrappedJSObject._values; let labels = datalistResult.wrappedJSObject._labels; let comments = new Array(values.length).fill(""); // historyResult will be null if form autocomplete is disabled. We // still want the list values to display. let entries = historyResult.wrappedJSObject.entries; let historyResults = entries.map(entry => entry.text); let historyComments = new Array(entries.length).fill(""); // now put the history results above the datalist suggestions let finalValues = historyResults.concat(values); let finalLabels = historyResults.concat(labels); let finalComments = historyComments.concat(comments); // This is ugly: there are two FormAutoCompleteResult classes in the // tree, one in a module and one in this file. Datalist results need to // use the one defined in the module but the rest of this file assumes // that we use the one defined here. To get around that, we explicitly // import the module here, out of the way of the other uses of // FormAutoCompleteResult. let {FormAutoCompleteResult} = Cu.import("resource://gre/modules/nsFormAutoCompleteResult.jsm", {}); return new FormAutoCompleteResult(datalistResult.searchString, Ci.nsIAutoCompleteResult.RESULT_SUCCESS, 0, "", finalValues, finalLabels, finalComments, historyResult); }, stopAutoCompleteSearch : function () { if (this._pendingClient) { this._pendingClient.cancel(); this._pendingClient = null; } }, /* * Get the values for an autocomplete list given a search string. * * client - a FormHistoryClient instance to perform the search with * fieldName - fieldname field within form history (the form input name) * searchString - string to search for * callback - called when the values are available. Passed an array of objects, * containing properties for each result. The callback is only called * when successful. */ getAutoCompleteValues : function (client, fieldName, searchString, callback) { let params = { agedWeight: this._agedWeight, bucketSize: this._bucketSize, expiryDate: 1000 * (Date.now() - this._expireDays * 24 * 60 * 60 * 1000), fieldname: fieldName, maxTimeGroupings: this._maxTimeGroupings, timeGroupingSize: this._timeGroupingSize, prefixWeight: this._prefixWeight, boundaryWeight: this._boundaryWeight } this.stopAutoCompleteSearch(); client.requestAutoCompleteResults(searchString, params, (entries) => { this._pendingClient = null; callback(entries); }); this._pendingClient = client; }, /* * _calculateScore * * entry -- an nsIAutoCompleteResult entry * aSearchString -- current value of the input (lowercase) * searchTokens -- array of tokens of the search string * * Returns: an int */ _calculateScore : function (entry, aSearchString, searchTokens) { let boundaryCalc = 0; // for each word, calculate word boundary weights for (let token of searchTokens) { boundaryCalc += (entry.textLowerCase.indexOf(token) == 0); boundaryCalc += (entry.textLowerCase.indexOf(" " + token) >= 0); } boundaryCalc = boundaryCalc * this._boundaryWeight; // now add more weight if we have a traditional prefix match and // multiply boundary bonuses by boundary weight boundaryCalc += this._prefixWeight * (entry.textLowerCase. indexOf(aSearchString) == 0); entry.totalScore = Math.round(entry.frecency * Math.max(1, boundaryCalc)); } }; // end of FormAutoComplete implementation // nsIAutoCompleteResult implementation function FormAutoCompleteResult(client, entries, fieldName, searchString, messageManager) { this.client = client; this.entries = entries; this.fieldName = fieldName; this.searchString = searchString; this.messageManager = messageManager; } FormAutoCompleteResult.prototype = { QueryInterface : XPCOMUtils.generateQI([Ci.nsIAutoCompleteResult, Ci.nsISupportsWeakReference]), // private client : null, entries : null, fieldName : null, _checkIndexBounds : function (index) { if (index < 0 || index >= this.entries.length) throw Components.Exception("Index out of range.", Cr.NS_ERROR_ILLEGAL_VALUE); }, // Allow autoCompleteSearch to get at the JS object so it can // modify some readonly properties for internal use. get wrappedJSObject() { return this; }, // Interfaces from idl... searchString : "", errorDescription : "", get defaultIndex() { if (this.entries.length == 0) return -1; return 0; }, get searchResult() { if (this.entries.length == 0) return Ci.nsIAutoCompleteResult.RESULT_NOMATCH; return Ci.nsIAutoCompleteResult.RESULT_SUCCESS; }, get matchCount() { return this.entries.length; }, getValueAt : function (index) { this._checkIndexBounds(index); return this.entries[index].text; }, getLabelAt: function(index) { return this.getValueAt(index); }, getCommentAt : function (index) { this._checkIndexBounds(index); return ""; }, getStyleAt : function (index) { this._checkIndexBounds(index); return ""; }, getImageAt : function (index) { this._checkIndexBounds(index); return ""; }, getFinalCompleteValueAt : function (index) { return this.getValueAt(index); }, removeValueAt : function (index, removeFromDB) { this._checkIndexBounds(index); let [removedEntry] = this.entries.splice(index, 1); if (removeFromDB) { this.client.remove(removedEntry.text); } } }; this.NSGetFactory = XPCOMUtils.generateNSGetFactory([FormAutoComplete]);