/* 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/. */ import { GenericAutocompleteItem, sendFillRequestToParent, } from "resource://gre/modules/FillHelpers.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs", }); const formFillController = Cc[ "@mozilla.org/satchel/form-fill-controller;1" ].getService(Ci.nsIFormFillController); 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 FormHistoryAutoComplete 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, FormHistoryAutoComplete 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 {string} scenarioName * Optional autocompletion scenario name. * @param {Function} callback * A callback function that will take a single * argument (the found entries). */ requestAutoCompleteResults(searchString, params, scenarioName, 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, scenarioName, }) .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, scenarioName, }); } } 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); } } } /** * This autocomplete result combines 3 arrays of entries, fixedEntries and * externalEntries. * Entries are Form History entries, they can be removed. * Fixed entries are "appended" to entries, they are used for datalist items, * search suggestions and extra items from integrations. * External entries are meant for integrations, like Firefox Relay. * Internally entries and fixed entries are kept separated so we can * reuse and filter them. * * @implements {nsIAutoCompleteResult} */ export class FormHistoryAutoCompleteResult { 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; #fixedEntries = []; externalEntries = []; set fixedEntries(value) { this.#fixedEntries = value; this.removeDuplicateHistoryEntries(); } canSearchIncrementally(searchString) { const prevSearchString = this.searchString.trim(); return ( prevSearchString.length > 1 && searchString.includes(prevSearchString.toLowerCase()) ); } incrementalSearch(searchString) { this.searchString = searchString; searchString = searchString.trim().toLowerCase(); this.#fixedEntries = this.#fixedEntries.filter(item => item.label.toLowerCase().includes(searchString) ); 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 this.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); filteredEntries.push(entry); } filteredEntries.sort((a, b) => b.totalScore - a.totalScore); this.entries = filteredEntries; this.removeDuplicateHistoryEntries(); } /* * #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)); } /** * Remove items from history list that are already present in fixed list. * We do this rather than the opposite ( i.e. remove items from fixed list) * to reflect the order that is specified in the fixed list. */ removeDuplicateHistoryEntries() { this.entries = this.entries.filter(entry => this.#fixedEntries.every( fixed => entry.text != (fixed.label || fixed.value) ) ); } getAt(index) { for (const group of [ this.entries, this.#fixedEntries, this.externalEntries, ]) { if (index < group.length) { return group[index]; } index -= group.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() { return this.matchCount ? 0 : -1; } get searchResult() { return this.matchCount ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS : Ci.nsIAutoCompleteResult.RESULT_NOMATCH; } get matchCount() { return ( this.entries.length + this.#fixedEntries.length + this.externalEntries.length ); } getValueAt(index) { const item = this.getAt(index); return item.text || item.value; } getLabelAt(index) { const item = this.getAt(index); return item.text || item.label || item.value; } getCommentAt(index) { return this.getAt(index).comment ?? ""; } getStyleAt(index) { const itemStyle = this.getAt(index).style; if (itemStyle) { return itemStyle; } if (index >= 0) { if (index < this.entries.length) { return "fromhistory"; } if (index > 0 && index == this.entries.length) { return "datalist-first"; } } return ""; } getImageAt(_index) { return ""; } getFinalCompleteValueAt(index) { return this.getValueAt(index); } isRemovableAt(index) { return this.#isFormHistoryEntry(index) || this.getAt(index).removable; } removeValueAt(index) { if (this.#isFormHistoryEntry(index)) { const [removedEntry] = this.entries.splice(index, 1); this.client.remove(removedEntry.text, removedEntry.guid); } } #isFormHistoryEntry(index) { return index >= 0 && index < this.entries.length; } } export class FormHistoryAutoComplete { 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"); Services.obs.addObserver(this, "autocomplete-will-enter-text"); } classID = Components.ID("{23530265-31d1-4ee9-864c-c081975fb7bc}"); QueryInterface = ChromeUtils.generateQI([ "nsIFormHistoryAutoComplete", "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; } Services.console.logStringMessage("FormHistoryAutoComplete: " + 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. * aAddDataList -- add results from list=datalist for aField. * aListener -- nsIFormHistoryAutoCompleteObserver that listens for the nsIAutoCompleteResult * that may be returned asynchronously. */ autoCompleteSearchAsync( aInputName, aUntrimmedSearchString, aField, aPreviousResult, aAddDataList, aListener ) { // Guard against void DOM strings filtering into this code. if (typeof aInputName === "object") { aInputName = ""; } if (typeof aUntrimmedSearchString === "object") { aUntrimmedSearchString = ""; } const client = new FormHistoryClient({ formField: aField, inputName: aInputName, }); function reportSearchResult(result) { aListener?.onSearchCompletion(result); } // If we have datalist results, they become our "empty" result. const result = new FormHistoryAutoCompleteResult( client, [], aInputName, aUntrimmedSearchString ); if (aAddDataList) { result.fixedEntries = this.getDataListSuggestions(aField); } if (!this._enabled) { reportSearchResult(result); 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`); reportSearchResult(result); return; } if (isAutocompleteDisabled(aField)) { this.log("autoCompleteSearch not allowed due to autcomplete=off"); reportSearchResult(result); return; } const searchString = aUntrimmedSearchString.trim().toLowerCase(); const prevResult = aPreviousResult?.wrappedJSObject; if (prevResult?.canSearchIncrementally(searchString)) { this.log("Using previous autocomplete result"); prevResult.incrementalSearch(aUntrimmedSearchString); reportSearchResult(prevResult); } else { this.log("Creating new autocomplete search result."); this.getAutoCompleteValues( client, aInputName, searchString, lazy.FormScenarios.detect({ input: aField }).signUpForm ? "SignUpFormScenario" : "", ({ formHistoryEntries, externalEntries }) => { formHistoryEntries ??= []; externalEntries ??= []; if (aField?.maxLength > -1) { result.entries = formHistoryEntries.filter( el => el.text.length <= aField.maxLength ); } else { result.entries = formHistoryEntries; } result.externalEntries.push( ...externalEntries.map( entry => new GenericAutocompleteItem( entry.image, entry.title, entry.subtitle, entry.fillMessageName, entry.fillMessageData ) ) ); result.removeDuplicateHistoryEntries(); reportSearchResult(result); } ); } } getDataListSuggestions(aField) { const items = []; if (!aField?.list) { return items; } const upperFieldValue = aField.value.toUpperCase(); for (const option of aField.list.options) { const label = option.label || option.text || option.value || ""; if (!label.toUpperCase().includes(upperFieldValue)) { continue; } items.push({ label, value: option.value, }); } return items; } 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 * scenarioName - Optional autocompletion scenario name. * 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, scenarioName, callback ) { this.stopAutoCompleteSearch(); client.requestAutoCompleteResults( searchString, { fieldname }, scenarioName, entries => { this.#pendingClient = null; callback(entries); } ); this.#pendingClient = client; } async observe(subject, topic, data) { switch (topic) { case "autocomplete-will-enter-text": { if (subject && subject == formFillController.controller?.input) { await sendFillRequestToParent("FormHistory", subject, data); } break; } } } }