/* 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 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--| // 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)); } }