diff options
Diffstat (limited to 'toolkit/components/satchel/FormAutoComplete.sys.mjs')
-rw-r--r-- | toolkit/components/satchel/FormAutoComplete.sys.mjs | 648 |
1 files changed, 648 insertions, 0 deletions
diff --git a/toolkit/components/satchel/FormAutoComplete.sys.mjs b/toolkit/components/satchel/FormAutoComplete.sys.mjs new file mode 100644 index 0000000000..de4a093573 --- /dev/null +++ b/toolkit/components/satchel/FormAutoComplete.sys.mjs @@ -0,0 +1,648 @@ +/* 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/. */ + +function isAutocompleteDisabled(aField) { + if (!aField) { + return false; + } + + if (aField.autocomplete !== "") { + return aField.autocomplete === "off"; + } + + return 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} clientInfo + * Info required to build the FormHistoryClient + * @param {Node} clientInfo.formField + * A DOM node that we're requesting form history for. + * @param {string} clientInfo.inputName + * 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. + */ +export class FormHistoryClient { + constructor({ formField, inputName }) { + if (formField) { + if (inputName == this.SEARCHBAR_ID) { + throw new Error( + "FormHistoryClient constructed with both a formField and an inputName. " + + "This is not supported, and only empty results will be returned." + ); + } + const window = formField.ownerGlobal; + this.windowGlobal = window.windowGlobalChild; + } + + this.inputName = inputName; + this.id = FormHistoryClient.nextRequestID++; + } + + static nextRequestID = 1; + SEARCHBAR_ID = "searchbar-history"; + cancelled = false; + inputName = ""; + + getActor() { + return this.windowGlobal?.getActor("FormHistory"); + } + + /** + * Query FormHistory for some results. + * + * @param {string} searchString + * The string to search FormHistory for. See + * FormHistory.getAutoCompleteResults. + * @param {object} params + * An Object with search properties. See + * FormHistory.getAutoCompleteResults. + * @param {Function} callback + * A callback function that will take a single + * argument (the found entries). + */ + requestAutoCompleteResults(searchString, params, callback) { + this.cancelled = false; + + // Use the actor if possible, otherwise for the searchbar, + // use the more roundabout per-process message manager which has + // no sendQuery method. + const actor = this.getActor(); + if (actor) { + actor + .sendQuery("FormHistory:AutoCompleteSearchAsync", { + searchString, + params, + }) + .then( + results => this.handleAutoCompleteResults(results, callback), + () => this.cancel() + ); + } else { + this.callback = callback; + Services.cpmm.addMessageListener( + "FormHistory:AutoCompleteSearchResults", + this + ); + Services.cpmm.sendAsyncMessage("FormHistory:AutoCompleteSearchAsync", { + id: this.id, + searchString, + params, + }); + } + } + + handleAutoCompleteResults(results, callback) { + if (this.cancelled) { + return; + } + + if (!callback) { + console.error("FormHistoryClient received response with no callback"); + return; + } + + callback(results); + this.cancel(); + } + + /** + * Cancel an in-flight results request. This ensures that the + * callback that requestAutoCompleteResults was passed is never + * called from this FormHistoryClient. + */ + cancel() { + if (this.callback) { + Services.cpmm.removeMessageListener( + "FormHistory:AutoCompleteSearchResults", + this + ); + this.callback = null; + } + this.cancelled = true; + } + + /** + * Remove an item from FormHistory. + * + * @param {string} value + * + * The value to remove for this particular + * field. + * + * @param {string} guid + * + * The guid for the item being removed. + */ + remove(value, guid) { + const actor = this.getActor() || Services.cpmm; + actor.sendAsyncMessage("FormHistory:RemoveEntry", { + inputName: this.inputName, + value, + guid, + }); + } + + receiveMessage(msg) { + const { id, results } = msg.data; + if (id == this.id) { + this.handleAutoCompleteResults(results, this.callback); + } + } +} + +// nsIAutoCompleteResult implementation +export class FormAutoCompleteResult { + constructor(client, entries, fieldName, searchString) { + this.client = client; + this.entries = entries; + this.fieldName = fieldName; + this.searchString = searchString; + } + + QueryInterface = ChromeUtils.generateQI([ + "nsIAutoCompleteResult", + "nsISupportsWeakReference", + ]); + + // private + client = null; + entries = null; + fieldName = null; + + _checkIndexBounds(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) { + return -1; + } + return 0; + } + + get searchResult() { + if (!this.entries.length) { + return Ci.nsIAutoCompleteResult.RESULT_NOMATCH; + } + return Ci.nsIAutoCompleteResult.RESULT_SUCCESS; + } + + get matchCount() { + return this.entries.length; + } + + getValueAt(index) { + this._checkIndexBounds(index); + return this.entries[index].text; + } + + getLabelAt(index) { + return this.getValueAt(index); + } + + getCommentAt(index) { + this._checkIndexBounds(index); + return ""; + } + + getStyleAt(index) { + this._checkIndexBounds(index); + return ""; + } + + getImageAt(index) { + this._checkIndexBounds(index); + return ""; + } + + getFinalCompleteValueAt(index) { + return this.getValueAt(index); + } + + isRemovableAt(index) { + this._checkIndexBounds(index); + return true; + } + + removeValueAt(index) { + this._checkIndexBounds(index); + + const [removedEntry] = this.entries.splice(index, 1); + this.client.remove(removedEntry.text, removedEntry.guid); + } +} + +export class FormAutoComplete { + constructor() { + // 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"); + } + + classID = Components.ID("{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}"); + QueryInterface = ChromeUtils.generateQI([ + "nsIFormAutoComplete", + "nsISupportsWeakReference", + ]); + + // 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; + + observer = { + _self: null, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + observe(_subject, topic, data) { + const self = this._self; + + if (topic == "nsPref:changed") { + const prefName = data; + self.log(`got change to ${prefName} preference`); + + switch (prefName) { + case "debug": + self._debug = self._prefBranch.getBoolPref(prefName); + break; + case "enable": + self._enabled = self._prefBranch.getBoolPref(prefName); + break; + } + } + }, + }; + + // 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(message) { + if (!this._debug) { + return; + } + dump("FormAutoComplete: " + message + "\n"); + Services.console.logStringMessage("FormAutoComplete: " + message); + } + + /* + * autoCompleteSearchAsync + * + * aInputName -- |name| or |id| attribute value from the form input being + * autocompleted + * aUntrimmedSearchString -- current value of the input + * aField -- HTMLInputElement 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. + * options -- an optional nsIPropertyBag2 containing additional search + * parameters. + */ + autoCompleteSearchAsync( + aInputName, + aUntrimmedSearchString, + aField, + aPreviousResult, + aDatalistResult, + aListener, + aOptions + ) { + // Guard against void DOM strings filtering into this code. + if (typeof aInputName === "object") { + aInputName = ""; + } + if (typeof aUntrimmedSearchString === "object") { + aUntrimmedSearchString = ""; + } + const params = {}; + if (aOptions) { + try { + aOptions.QueryInterface(Ci.nsIPropertyBag2); + for (const { name, value } of aOptions.enumerator) { + params[name] = value; + } + } catch (ex) { + console.error("Invalid options object: " + ex); + } + } + + const client = new FormHistoryClient({ + formField: aField, + inputName: aInputName, + }); + + function maybeNotifyListener(result) { + aListener?.onSearchCompletion(result); + } + + // If we have datalist results, they become our "empty" result. + const emptyResult = + aDatalistResult || + new FormAutoCompleteResult( + client, + [], + aInputName, + aUntrimmedSearchString + ); + + if (!this._enabled) { + maybeNotifyListener(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`); + maybeNotifyListener(emptyResult); + return; + } + + if (isAutocompleteDisabled(aField)) { + this.log("autoCompleteSearch not allowed due to autcomplete=off"); + maybeNotifyListener(emptyResult); + return; + } + + this.log( + "AutoCompleteSearch invoked. Search is: " + aUntrimmedSearchString + ); + const 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 + const prevSearchString = aPreviousResult?.searchString.trim(); + if ( + prevSearchString?.length > 1 && + searchString.includes(prevSearchString.toLowerCase()) + ) { + this.log("Using previous autocomplete result"); + const result = aPreviousResult; + const 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._items and only the results from + // form history in wrappedResult.entries. + // First, grab the entire list of old results. + const allResults = wrappedResult._items; + const datalistItems = []; + 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> + for (const oldItem of allResults.slice(wrappedResult.entries.length)) { + if (oldItem.label.toLowerCase().includes(searchString)) { + datalistItems.push({ + value: oldItem.value, + label: oldItem.label, + comment: "", + removable: oldItem.removable, + }); + } + } + } + + const 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 filteredEntries = []; + for (const entry of wrappedResult.entries) { + // 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.includes(tok))) { + continue; + } + this._calculateScore(entry, searchString, searchTokens); + this.log( + `Reusing autocomplete entry '${entry.text}' (${entry.frecency} / ${entry.totalScore})` + ); + filteredEntries.push(entry); + } + filteredEntries.sort((a, b) => b.totalScore - a.totalScore); + wrappedResult.entries = filteredEntries; + + // If we had datalistResults, re-merge them back into the filtered + // entries. + if (datalistItems.length) { + filteredEntries = filteredEntries.map(elt => ({ + value: elt.text, + // History entries don't have labels (their labels would be read + // from their values). + label: "", + comment: "", + removable: true, + })); + + datalistItems[0].comment = "separator"; + + wrappedResult._items = filteredEntries.concat(datalistItems); + } + + maybeNotifyListener(result); + } else { + this.log("Creating new autocomplete search result."); + + // Start with an empty list. + let result = aDatalistResult + ? new FormAutoCompleteResult( + client, + [], + aInputName, + aUntrimmedSearchString + ) + : emptyResult; + + const processEntry = aEntries => { + if (aField?.maxLength > -1) { + result.entries = aEntries.filter( + el => el.text.length <= aField.maxLength + ); + } else { + result.entries = aEntries; + } + + if (aDatalistResult?.matchCount > 0) { + result = this.mergeResults(result, aDatalistResult); + } + + maybeNotifyListener(result); + }; + + this.getAutoCompleteValues( + client, + aInputName, + searchString, + params, + processEntry + ); + } + } + + mergeResults(historyResult, datalistResult) { + const items = datalistResult.wrappedJSObject._items; + + // historyResult will be null if form autocomplete is disabled. We + // still want the list values to display. + const entries = historyResult.wrappedJSObject.entries; + const historyResults = entries.map(entry => ({ + value: entry.text, + label: entry.text, + comment: "", + removable: true, + })); + + const isInArray = (value, arr, key) => + arr.find(item => item[key].toUpperCase() === value.toUpperCase()); + + // Remove items from history list that are already present in data list. + // We do this rather than the opposite ( i.e. remove items from data list) + // to reflect the order that is specified in the data list. + const dedupedHistoryResults = historyResults.filter( + historyRes => !isInArray(historyRes.value, items, "value") + ); + + // Now put the history results above the datalist suggestions. + // Note that we don't need to worry about deduplication of elements inside + // the datalist suggestions because the datalist is user-provided. + const finalItems = dedupedHistoryResults.concat(items); + + historyResult.wrappedJSObject.entries = + historyResult.wrappedJSObject.entries.filter( + entry => !isInArray(entry.text, items, "value") + ); + + // 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. + const { FormAutoCompleteResult } = ChromeUtils.importESModule( + "resource://gre/modules/nsFormAutoCompleteResult.sys.mjs" + ); + return new FormAutoCompleteResult( + datalistResult.searchString, + Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + 0, + "", + finalItems, + historyResult + ); + } + + stopAutoCompleteSearch() { + 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 + * params - object containing additional properties to query autocomplete. + * 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(client, fieldName, searchString, params, callback) { + params = Object.assign({ fieldname: fieldName }, params); + + 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(entry, aSearchString, searchTokens) { + let boundaryCalc = 0; + // for each word, calculate word boundary weights + for (const token of searchTokens) { + if (entry.textLowerCase.startsWith(token)) { + boundaryCalc++; + } + if (entry.textLowerCase.includes(" " + token)) { + boundaryCalc++; + } + } + boundaryCalc = boundaryCalc * this._boundaryWeight; + // now add more weight if we have a traditional prefix match and + // multiply boundary bonuses by boundary weight + if (entry.textLowerCase.startsWith(aSearchString)) { + boundaryCalc += this._prefixWeight; + } + entry.totalScore = Math.round(entry.frecency * Math.max(1, boundaryCalc)); + } +} |