diff options
Diffstat (limited to 'toolkit/components/satchel/nsFormAutoComplete.js')
-rw-r--r-- | toolkit/components/satchel/nsFormAutoComplete.js | 624 |
1 files changed, 624 insertions, 0 deletions
diff --git a/toolkit/components/satchel/nsFormAutoComplete.js b/toolkit/components/satchel/nsFormAutoComplete.js new file mode 100644 index 000000000..aa090479a --- /dev/null +++ b/toolkit/components/satchel/nsFormAutoComplete.js @@ -0,0 +1,624 @@ +/* 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 <datalist> 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--| + // <history entries><datalist 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]); |