/* 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/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { FormHistory: "resource://gre/modules/FormHistory.sys.mjs", SearchSuggestionController: "moz-src:///toolkit/components/search/SearchSuggestionController.sys.mjs", }); /** * A search history autocomplete result that implements nsIAutoCompleteResult. * Based on FormHistoryAutoCompleteResult. * * @implements {nsIAutoCompleteResult} */ class SearchHistoryResult { /** * The name of the associated field in form history. * * @type {string} */ #formFieldName = null; /** * An array of entries from form history. * * @type {object[]|null} */ #formHistoryEntries = null; // // An array of entries that have come from a remote source and cannot // be deleted. These are listed after the form history entries. // // @type {object[]} // (using proper JSDoc comment here causes sphinx-js failures: // https://github.com/mozilla/sphinx-js/issues/242). // #remoteEntries = []; QueryInterface = ChromeUtils.generateQI([ "nsIAutoCompleteResult", "nsISupportsWeakReference", ]); /** * Constructor * * @param {string} formFieldName * The name of the associated field in form history. * @param {string} searchString * The search string used for the search. * @param {object[]} formHistoryEntries * The entries received from form history. */ constructor(formFieldName, searchString, formHistoryEntries) { this.#formFieldName = formFieldName; this.searchString = searchString; this.#formHistoryEntries = formHistoryEntries; } /** * Sets the remote entries and de-dupes them against the form history entries. * * @param {object[]} remoteEntries * The fixed entries to save. */ set remoteEntries(remoteEntries) { this.#remoteEntries = remoteEntries; this.#removeDuplicateHistoryEntries(); } /** * The search string associated with this result. * * @type {string} */ searchString = ""; /** * An error description, always blank for these results. * * @type {string} */ errorDescription = ""; /** * Index of the default item that should be entered if none is selected. * * @returns {number} */ get defaultIndex() { return this.matchCount ? 0 : -1; } /** * The result of the search. * * @returns {number} */ get searchResult() { return this.matchCount ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS : Ci.nsIAutoCompleteResult.RESULT_NOMATCH; } /** * The number of matches. * * @returns {number} */ get matchCount() { return this.#formHistoryEntries.length + this.#remoteEntries.length; } /** * Gets the value of the result at the given index. This is the value that * will be filled into the text field. * * @param {number} index * The index of the result. * @returns {string} */ getValueAt(index) { const item = this.#getAt(index); return item.text || item.value; } /** * Gets the label at the given index. This is the string that is displayed * in the autocomplete dropdown row. If there is additional text to be * displayed, it should be stored within a field in the comment. * * @param {number} index * The index of the result. * @returns {string} */ getLabelAt(index) { const item = this.#getAt(index); return item.text || item.label || item.value; } /** * Get the comment of the result at the given index. This is a serialized * JSON object containing additional properties related to the index. * * @param {number} index * The index of the result. * @returns {string} */ getCommentAt(index) { return this.#getAt(index).comment ?? ""; } /** * Gets the style hint for the result at the given index. * * @param {number} index * The index of the result. * @returns {string} */ getStyleAt(index) { const itemStyle = this.#getAt(index).style; if (itemStyle) { return itemStyle; } if (index >= 0) { if (index < this.#formHistoryEntries.length) { return "fromhistory"; } if (index > 0 && index == this.#formHistoryEntries.length) { return "datalist-first"; } } return ""; } /** * Gets the image of the result at the given index. * * @param {number} _index * The index of the result. * @returns {string} */ getImageAt(_index) { return ""; } /** * Gets the final value that should be completed when the user confirms * the match at the given index. * * @param {number} index * The index of the result. * @returns {string} */ getFinalCompleteValueAt(index) { return this.getValueAt(index); } /** * True if the value at the given index is removable. * * @param {number} index * The index of the result. * @returns {boolean} */ isRemovableAt(index) { return this.#isFormHistoryEntry(index); } /** * Remove the value at the given index from the autocomplete results. * * @param {number} index * The index of the result. */ removeValueAt(index) { if (this.isRemovableAt(index)) { const [removedEntry] = this.#formHistoryEntries.splice(index, 1); lazy.FormHistory.update({ op: "remove", fieldname: this.#formFieldName, value: removedEntry.text, guid: removedEntry.guid, }); } } /** * Returns the entry at the given index taking into account both the * form history entries and the remote entries. * * @param {number} index * The index of the entry to find. * @returns {object} * The object at the given index. * @throws {Components.Exception} * Throws if the index is out of range. */ #getAt(index) { for (const group of [this.#formHistoryEntries, this.#remoteEntries]) { if (index < group.length) { return group[index]; } index -= group.length; } throw Components.Exception( "Index out of range.", Cr.NS_ERROR_ILLEGAL_VALUE ); } /** * Returns true if the value at the given index is one of the form history * entries. * * @param {number} index * The index of the result. * @returns {boolean} */ #isFormHistoryEntry(index) { return index >= 0 && index < this.#formHistoryEntries.length; } /** * 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.#formHistoryEntries = this.#formHistoryEntries.filter(entry => this.#remoteEntries.every( fixed => entry.text != (fixed.label || fixed.value) ) ); } } /** * SuggestAutoComplete is a base class that implements nsIAutoCompleteSearch * and can collect results for a given search by using this.#suggestionController. * We do it this way since the AutoCompleteController in Mozilla requires a * unique XPCOM Service for every search provider, even if the logic for two * providers is identical. * * @implements {nsIAutoCompleteSearch} */ class SuggestAutoComplete { constructor() { this.#suggestionController = new lazy.SearchSuggestionController(); this.#suggestionController.maxLocalResults = this.#historyLimit; } /** * Notifies the front end of new results. * * @param {nsIAutoCompleteResult} result * Any previous form history result. * @private */ onResultsReady(result) { if (this.#listener) { this.#listener.onSearchResult(this, result); // Null out listener to make sure we don't notify it twice this.#listener = null; } } /** * Initiates the search result gathering process. Part of * nsIAutoCompleteSearch implementation. * * @param {string} searchString * The user's query string. * @param {string} searchParam * unused, "an extra parameter"; even though this parameter and the * next are unused, pass them through in case the form history * service wants them * @param {object} previousResult * unused, a client-cached store of the previous generated resultset * for faster searching. * @param {nsIAutoCompleteObserver} listener * object implementing nsIAutoCompleteObserver which we notify when * results are ready. */ startSearch(searchString, searchParam, previousResult, listener) { var formHistorySearchParam = searchParam.split("|")[0]; // Receive the information about the privacy mode of the window to which // this search box belongs. The front-end's search.xml bindings passes this // information in the searchParam parameter. The alternative would have // been to modify nsIAutoCompleteSearch to add an argument to startSearch // and patch all of autocomplete to be aware of this, but the searchParam // argument is already an opaque argument, so this solution is hopefully // less hackish (although still gross.) var privacyMode = searchParam.split("|")[1] == "private"; // Start search immediately if possible, otherwise once the search // service is initialized if (Services.search.isInitialized) { this.#triggerSearch( searchString, formHistorySearchParam, listener, privacyMode ).catch(console.error); return; } Services.search .init() .then(() => { this.#triggerSearch( searchString, formHistorySearchParam, listener, privacyMode ).catch(console.error); }) .catch(result => console.error( "Could not initialize search service, bailing out:", result ) ); } /** * Ends the search result gathering process. Part of nsIAutoCompleteSearch * implementation. */ stopSearch() { // Prevent reporting results of stopped search this.#listener = null; this.#suggestionController.stop(); } #suggestionController; /** * Maximum number of history items displayed. This is capped at 7 * because the primary consumer (Firefox search bar) displays 10 rows * by default, and so we want to leave some space for suggestions * to be visible. * * @type {number} */ #historyLimit = 7; /** * The object implementing nsIAutoCompleteObserver that we notify when * we have found results. * * @type {nsIAutoCompleteObserver|null} */ #listener = null; /** * Actual implementation of search. * * @param {string} searchString * The user's query string. * @param {string} searchParam * unused * @param {nsIAutoCompleteObserver} listener * object implementing nsIAutoCompleteObserver which we notify when * results are ready. * @param {boolean} privacyMode * True if the search was made from a private browsing mode context. */ async #triggerSearch(searchString, searchParam, listener, privacyMode) { this.#listener = listener; let results = await this.#suggestionController.fetch( searchString, privacyMode, Services.search.defaultEngine ); let formHistoryEntries = (results?.formHistoryResults ?? []).map( historyEntry => ({ // We supply the comments field so that autocomplete does not kick // in the unescaping of the results for display which it uses for // urls. comment: historyEntry.text, ...historyEntry, }) ); let autoCompleteResult = new SearchHistoryResult( this.#suggestionController.formHistoryParam, searchString, formHistoryEntries ); if (results?.remote?.length) { // We shouldn't show tail suggestions in their full-text form. // Suggestions are shown after form history results. autoCompleteResult.remoteEntries = results.remote.reduce((acc, item) => { if (!item.matchPrefix && !item.tail) { acc.push({ value: item.value, label: item.value, // We supply the comments field so that autocomplete does not kick // in the unescaping of the results for display which it uses for // urls. comment: item.value, }); } return acc; }, []); } // Notify the FE of our new results this.onResultsReady(autoCompleteResult); } QueryInterface = ChromeUtils.generateQI([ "nsIAutoCompleteSearch", "nsIAutoCompleteObserver", ]); } /** * SearchSuggestAutoComplete is a service implementation that handles suggest * results specific to web searches. * * @class */ export class SearchSuggestAutoComplete extends SuggestAutoComplete { classID = Components.ID("{aa892eb4-ffbf-477d-9f9a-06c995ae9f27}"); serviceURL = ""; }