diff options
Diffstat (limited to 'toolkit/components/places/UnifiedComplete.jsm')
-rw-r--r-- | toolkit/components/places/UnifiedComplete.jsm | 2125 |
1 files changed, 2125 insertions, 0 deletions
diff --git a/toolkit/components/places/UnifiedComplete.jsm b/toolkit/components/places/UnifiedComplete.jsm new file mode 100644 index 0000000000..fe3b57a4d7 --- /dev/null +++ b/toolkit/components/places/UnifiedComplete.jsm @@ -0,0 +1,2125 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 sts=2 expandtab + * 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/. */ +/* eslint complexity: ["error", 53] */ + +"use strict"; + +// Constants + +const MS_PER_DAY = 86400000; // 24 * 60 * 60 * 1000 + +// AutoComplete query type constants. +// Describes the various types of queries that we can process rows for. +const QUERYTYPE_FILTERED = 0; +const QUERYTYPE_ADAPTIVE = 3; + +// The default frecency value used when inserting matches with unknown frecency. +const FRECENCY_DEFAULT = 1000; + +// By default we add remote tabs that have been used less than this time ago. +// Any remaining remote tabs are added in queue if no other results are found. +const RECENT_REMOTE_TAB_THRESHOLD_MS = 259200000; // 72 hours. + +// Regex used to match userContextId. +const REGEXP_USER_CONTEXT_ID = /(?:^| )user-context-id:(\d+)/; + +// Regex used to match maxResults. +const REGEXP_MAX_RESULTS = /(?:^| )max-results:(\d+)/; + +// Regex used to match one or more whitespace. +const REGEXP_SPACES = /\s+/; + +// Regex used to strip prefixes from URLs. See stripAnyPrefix(). +const REGEXP_STRIP_PREFIX = /^[a-z]+:(?:\/){0,2}/i; + +// The result is notified on a delay, to avoid rebuilding the panel at every match. +const NOTIFYRESULT_DELAY_MS = 16; + +// Sqlite result row index constants. +const QUERYINDEX_QUERYTYPE = 0; +const QUERYINDEX_URL = 1; +const QUERYINDEX_TITLE = 2; +const QUERYINDEX_BOOKMARKED = 3; +const QUERYINDEX_BOOKMARKTITLE = 4; +const QUERYINDEX_TAGS = 5; +// QUERYINDEX_VISITCOUNT = 6; +// QUERYINDEX_TYPED = 7; +const QUERYINDEX_PLACEID = 8; +const QUERYINDEX_SWITCHTAB = 9; +const QUERYINDEX_FRECENCY = 10; + +// This SQL query fragment provides the following: +// - whether the entry is bookmarked (QUERYINDEX_BOOKMARKED) +// - the bookmark title, if it is a bookmark (QUERYINDEX_BOOKMARKTITLE) +// - the tags associated with a bookmarked entry (QUERYINDEX_TAGS) +const SQL_BOOKMARK_TAGS_FRAGMENT = `EXISTS(SELECT 1 FROM moz_bookmarks WHERE fk = h.id) AS bookmarked, + ( SELECT title FROM moz_bookmarks WHERE fk = h.id AND title NOTNULL + ORDER BY lastModified DESC LIMIT 1 + ) AS btitle, + ( SELECT GROUP_CONCAT(t.title, ', ') + FROM moz_bookmarks b + JOIN moz_bookmarks t ON t.id = +b.parent AND t.parent = :parent + WHERE b.fk = h.id + ) AS tags`; + +// TODO bug 412736: in case of a frecency tie, we might break it with h.typed +// and h.visit_count. That is slower though, so not doing it yet... +// NB: as a slight performance optimization, we only evaluate the "bookmarked" +// condition once, and avoid evaluating "btitle" and "tags" when it is false. +function defaultQuery(conditions = "") { + let query = `SELECT :query_type, h.url, h.title, ${SQL_BOOKMARK_TAGS_FRAGMENT}, + h.visit_count, h.typed, h.id, t.open_count, h.frecency + FROM moz_places h + LEFT JOIN moz_openpages_temp t + ON t.url = h.url + AND t.userContextId = :userContextId + WHERE h.frecency <> 0 + AND CASE WHEN bookmarked + THEN + AUTOCOMPLETE_MATCH(:searchString, h.url, + IFNULL(btitle, h.title), tags, + h.visit_count, h.typed, + 1, t.open_count, + :matchBehavior, :searchBehavior) + ELSE + AUTOCOMPLETE_MATCH(:searchString, h.url, + h.title, '', + h.visit_count, h.typed, + 0, t.open_count, + :matchBehavior, :searchBehavior) + END + ${conditions ? "AND" : ""} ${conditions} + ORDER BY h.frecency DESC, h.id DESC + LIMIT :maxResults`; + return query; +} + +const SQL_SWITCHTAB_QUERY = `SELECT :query_type, t.url, t.url, NULL, NULL, NULL, NULL, NULL, NULL, + t.open_count, NULL + FROM moz_openpages_temp t + LEFT JOIN moz_places h ON h.url_hash = hash(t.url) AND h.url = t.url + WHERE h.id IS NULL + AND t.userContextId = :userContextId + AND AUTOCOMPLETE_MATCH(:searchString, t.url, t.url, NULL, + NULL, NULL, NULL, t.open_count, + :matchBehavior, :searchBehavior) + ORDER BY t.ROWID DESC + LIMIT :maxResults`; + +const SQL_ADAPTIVE_QUERY = `/* do not warn (bug 487789) */ + SELECT :query_type, h.url, h.title, ${SQL_BOOKMARK_TAGS_FRAGMENT}, + h.visit_count, h.typed, h.id, t.open_count, h.frecency + FROM ( + SELECT ROUND(MAX(use_count) * (1 + (input = :search_string)), 1) AS rank, + place_id + FROM moz_inputhistory + WHERE input BETWEEN :search_string AND :search_string || X'FFFF' + GROUP BY place_id + ) AS i + JOIN moz_places h ON h.id = i.place_id + LEFT JOIN moz_openpages_temp t + ON t.url = h.url + AND t.userContextId = :userContextId + WHERE AUTOCOMPLETE_MATCH(NULL, h.url, + IFNULL(btitle, h.title), tags, + h.visit_count, h.typed, bookmarked, + t.open_count, + :matchBehavior, :searchBehavior) + ORDER BY rank DESC, h.frecency DESC + LIMIT :maxResults`; + +// Getters + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]); + +XPCOMUtils.defineLazyModuleGetters(this, { + AboutPagesUtils: "resource://gre/modules/AboutPagesUtils.jsm", + BrowserUtils: "resource://gre/modules/BrowserUtils.jsm", + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", + PlacesRemoteTabsAutocompleteProvider: + "resource://gre/modules/PlacesRemoteTabsAutocompleteProvider.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", + ProfileAge: "resource://gre/modules/ProfileAge.jsm", + PromiseUtils: "resource://gre/modules/PromiseUtils.jsm", + Sqlite: "resource://gre/modules/Sqlite.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "syncUsernamePref", + "services.sync.username" +); + +function setTimeout(callback, ms) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(callback, ms, timer.TYPE_ONE_SHOT); + return timer; +} + +const kProtocolsWithIcons = [ + "chrome:", + "moz-extension:", + "about:", + "http:", + "https:", + "ftp:", +]; +function iconHelper(url) { + if (typeof url == "string") { + return kProtocolsWithIcons.some(p => url.startsWith(p)) + ? "page-icon:" + url + : PlacesUtils.favicons.defaultFavicon.spec; + } + if (url && url instanceof URL && kProtocolsWithIcons.includes(url.protocol)) { + return "page-icon:" + url.href; + } + return PlacesUtils.favicons.defaultFavicon.spec; +} + +// Preloaded Sites related + +function PreloadedSite(url, title) { + this.uri = Services.io.newURI(url); + this.title = title; + this._matchTitle = title.toLowerCase(); + this._hasWWW = this.uri.host.startsWith("www."); + this._hostWithoutWWW = this._hasWWW ? this.uri.host.slice(4) : this.uri.host; +} + +/** + * Storage object for Preloaded Sites. + * add(url, title): adds a site to storage + * populate(sites) : populates the storage with array of [url,title] + * sites[]: resulting array of sites (PreloadedSite objects) + */ +XPCOMUtils.defineLazyGetter(this, "PreloadedSiteStorage", () => + Object.seal({ + sites: [], + + add(url, title) { + let site = new PreloadedSite(url, title); + this.sites.push(site); + }, + + populate(sites) { + this.sites = []; + for (let site of sites) { + this.add(site[0], site[1]); + } + }, + }) +); + +XPCOMUtils.defineLazyGetter(this, "ProfileAgeCreatedPromise", async () => { + let times = await ProfileAge(); + return times.created; +}); + +// Maps restriction character types to textual behaviors. +XPCOMUtils.defineLazyGetter(this, "typeToBehaviorMap", () => { + return new Map([ + [UrlbarTokenizer.TYPE.RESTRICT_HISTORY, "history"], + [UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, "bookmark"], + [UrlbarTokenizer.TYPE.RESTRICT_TAG, "tag"], + [UrlbarTokenizer.TYPE.RESTRICT_OPENPAGE, "openpage"], + [UrlbarTokenizer.TYPE.RESTRICT_SEARCH, "search"], + [UrlbarTokenizer.TYPE.RESTRICT_TITLE, "title"], + [UrlbarTokenizer.TYPE.RESTRICT_URL, "url"], + ]); +}); + +XPCOMUtils.defineLazyGetter(this, "sourceToBehaviorMap", () => { + return new Map([ + [UrlbarUtils.RESULT_SOURCE.HISTORY, "history"], + [UrlbarUtils.RESULT_SOURCE.BOOKMARKS, "bookmark"], + [UrlbarUtils.RESULT_SOURCE.TABS, "openpage"], + [UrlbarUtils.RESULT_SOURCE.SEARCH, "search"], + ]); +}); + +// Helper functions + +/** + * Strips the prefix from a URL and returns the prefix and the remainder of the + * URL. "Prefix" is defined to be the scheme and colon, plus, if present, two + * slashes. If the given string is not actually a URL, then an empty prefix and + * the string itself is returned. + * + * @param str + * The possible URL to strip. + * @return If `str` is a URL, then [prefix, remainder]. Otherwise, ["", str]. + */ +function stripAnyPrefix(str) { + let match = REGEXP_STRIP_PREFIX.exec(str); + if (!match) { + return ["", str]; + } + let prefix = match[0]; + if (prefix.length < str.length && str[prefix.length] == " ") { + return ["", str]; + } + return [prefix, str.substr(prefix.length)]; +} + +/** + * Strips parts of a URL defined in `options`. + * + * @param {string} spec + * The text to modify. + * @param {object} options + * @param {boolean} options.stripHttp + * Whether to strip http. + * @param {boolean} options.stripHttps + * Whether to strip https. + * @param {boolean} options.stripWww + * Whether to strip `www.`. + * @param {boolean} options.trimSlash + * Whether to trim the trailing slash. + * @param {boolean} options.trimEmptyQuery + * Whether to trim a trailing `?`. + * @param {boolean} options.trimEmptyHash + * Whether to trim a trailing `#`. + * @returns {array} [modified, prefix, suffix] + * modified: {string} The modified spec. + * prefix: {string} The parts stripped from the prefix, if any. + * suffix: {string} The parts trimmed from the suffix, if any. + */ +function stripPrefixAndTrim(spec, options = {}) { + let prefix = ""; + let suffix = ""; + if (options.stripHttp && spec.startsWith("http://")) { + spec = spec.slice(7); + prefix = "http://"; + } else if (options.stripHttps && spec.startsWith("https://")) { + spec = spec.slice(8); + prefix = "https://"; + } + if (options.stripWww && spec.startsWith("www.")) { + spec = spec.slice(4); + prefix += "www."; + } + if (options.trimEmptyHash && spec.endsWith("#")) { + spec = spec.slice(0, -1); + suffix = "#" + suffix; + } + if (options.trimEmptyQuery && spec.endsWith("?")) { + spec = spec.slice(0, -1); + suffix = "?" + suffix; + } + if (options.trimSlash && spec.endsWith("/")) { + spec = spec.slice(0, -1); + suffix = "/" + suffix; + } + return [spec, prefix, suffix]; +} + +/** + * Returns the key to be used for a match in a map for the purposes of removing + * duplicate entries - any 2 matches that should be considered the same should + * return the same key. The type of the returned key depends on the type of the + * match, so don't assume you can compare keys using ==. Instead, use + * ObjectUtils.deepEqual(). + * + * @param {object} match + * The match object. + * @returns {value} Some opaque key object. Use ObjectUtils.deepEqual() to + * compare keys. + */ +function makeKeyForMatch(match) { + // For autofill entries, we need to have a key based on the finalCompleteValue + // rather than the value field, because the latter may have been trimmed. + let key, prefix; + if (match.style && match.style.includes("autofill")) { + [key, prefix] = stripPrefixAndTrim(match.finalCompleteValue, { + stripHttp: true, + stripHttps: true, + stripWww: true, + trimEmptyQuery: true, + trimSlash: true, + }); + + return [key, prefix, null]; + } + + let action = PlacesUtils.parseActionUrl(match.value); + if (!action) { + [key, prefix] = stripPrefixAndTrim(match.value, { + stripHttp: true, + stripHttps: true, + stripWww: true, + trimSlash: true, + trimEmptyQuery: true, + trimEmptyHash: true, + }); + return [key, prefix, null]; + } + + switch (action.type) { + case "searchengine": + // We want to exclude search suggestion matches that simply echo back the + // query string in the heuristic result. For example, if the user types + // "@engine test", we want to exclude a "test" suggestion match. + key = [ + action.type, + action.params.engineName, + ( + action.params.searchSuggestion || action.params.searchQuery + ).toLocaleLowerCase(), + ]; + break; + default: + [key, prefix] = stripPrefixAndTrim(action.params.url || match.value, { + stripHttp: true, + stripHttps: true, + stripWww: true, + trimEmptyQuery: true, + trimSlash: true, + }); + break; + } + + return [key, prefix, action]; +} + +/** + * Makes a moz-action url for the given action and set of parameters. + * + * @param type + * The action type. + * @param params + * A JS object of action params. + * @returns A moz-action url as a string. + */ +function makeActionUrl(type, params) { + let encodedParams = {}; + for (let key in params) { + // Strip null or undefined. + // Regardless, don't encode them or they would be converted to a string. + if (params[key] === null || params[key] === undefined) { + continue; + } + encodedParams[key] = encodeURIComponent(params[key]); + } + return `moz-action:${type},${JSON.stringify(encodedParams)}`; +} + +/** + * Manages a single instance of an autocomplete search. + * + * The first three parameters all originate from the similarly named parameters + * of nsIAutoCompleteSearch.startSearch(). + * + * @param searchString + * The search string. + * @param searchParam + * A space-delimited string of search parameters. The following + * parameters are supported: + * * enable-actions: Include "actions", such as switch-to-tab and search + * engine aliases, in the results. + * * disable-private-actions: The search is taking place in a private + * window outside of permanent private-browsing mode. The search + * should exclude privacy-sensitive results as appropriate. + * * private-window: The search is taking place in a private window, + * possibly in permanent private-browsing mode. The search + * should exclude privacy-sensitive results as appropriate. + * * user-context-id: The userContextId of the selected tab. + * @param autocompleteListener + * An nsIAutoCompleteObserver. + * @param autocompleteSearch + * An nsIAutoCompleteSearch. + * @param {UrlbarQueryContext} [queryContext] + * The query context, undefined for legacy consumers. + */ +function Search( + searchString, + searchParam, + autocompleteListener, + autocompleteSearch, + queryContext +) { + // We want to store the original string for case sensitive searches. + this._originalSearchString = searchString; + this._trimmedOriginalSearchString = searchString.trim(); + let unescapedSearchString = Services.textToSubURI.unEscapeURIForUI( + this._trimmedOriginalSearchString + ); + let [prefix, suffix] = stripAnyPrefix(unescapedSearchString); + this._searchString = suffix; + this._strippedPrefix = prefix.toLowerCase(); + + this._matchBehavior = Ci.mozIPlacesAutoComplete.MATCH_BOUNDARY; + // Set the default behavior for this search. + this._behavior = this._searchString + ? UrlbarPrefs.get("defaultBehavior") + : this._emptySearchDefaultBehavior; + + if (queryContext) { + this._enableActions = true; + this._inPrivateWindow = queryContext.isPrivate; + this._disablePrivateActions = + this._inPrivateWindow && !PrivateBrowsingUtils.permanentPrivateBrowsing; + this._prohibitAutoFill = !queryContext.allowAutofill; + this._maxResults = queryContext.maxResults; + this._userContextId = queryContext.userContextId; + this._currentPage = queryContext.currentPage; + this._searchModeEngine = queryContext.searchMode?.engineName; + this._searchMode = queryContext.searchMode; + if (this._searchModeEngine) { + // Filter Places results on host. + let engine = Services.search.getEngineByName(this._searchModeEngine); + this._filterOnHost = engine.getResultDomain(); + } + } else { + let params = new Set(searchParam.split(" ")); + this._enableActions = params.has("enable-actions"); + this._disablePrivateActions = params.has("disable-private-actions"); + this._inPrivateWindow = params.has("private-window"); + this._prohibitAutoFill = params.has("prohibit-autofill"); + // Extract the max-results param. + let maxResults = searchParam.match(REGEXP_MAX_RESULTS); + this._maxResults = maxResults + ? parseInt(maxResults[1]) + : UrlbarPrefs.get("maxRichResults"); + // Extract the user-context-id param. + let userContextId = searchParam.match(REGEXP_USER_CONTEXT_ID); + this._userContextId = userContextId + ? parseInt(userContextId[1], 10) + : Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID; + } + + // Use the original string here, not the stripped one, so the tokenizer can + // properly recognize token types. + let { tokens } = UrlbarTokenizer.tokenize({ + searchString: unescapedSearchString, + trimmedSearchString: unescapedSearchString.trim(), + }); + + // This allows to handle leading or trailing restriction characters specially. + this._leadingRestrictionToken = null; + if (tokens.length) { + if ( + UrlbarTokenizer.isRestrictionToken(tokens[0]) && + (tokens.length > 1 || + tokens[0].type == UrlbarTokenizer.TYPE.RESTRICT_SEARCH) + ) { + this._leadingRestrictionToken = tokens[0].value; + } + + // Check if the first token has a strippable prefix and remove it, but don't + // create an empty token. + if (prefix && tokens[0].value.length > prefix.length) { + tokens[0].value = tokens[0].value.substring(prefix.length); + } + } + + // Eventually filter restriction tokens. In general it's a good idea, but if + // the consumer requested search mode, we should use the full string to avoid + // ignoring valid tokens. + this._searchTokens = + !queryContext || queryContext.restrictToken + ? this.filterTokens(tokens) + : tokens; + + // The behavior can be set through: + // 1. a specific restrictSource in the QueryContext + // 2. typed restriction tokens + if ( + queryContext && + queryContext.restrictSource && + sourceToBehaviorMap.has(queryContext.restrictSource) + ) { + this._behavior = 0; + this.setBehavior("restrict"); + let behavior = sourceToBehaviorMap.get(queryContext.restrictSource); + this.setBehavior(behavior); + + // When we are in restrict mode, all the tokens are valid for searching, so + // there is no _heuristicToken. + this._heuristicToken = null; + } else { + // The heuristic token is the first filtered search token, but only when it's + // actually the first thing in the search string. If a prefix or restriction + // character occurs first, then the heurstic token is null. We use the + // heuristic token to help determine the heuristic result. It may be a Places + // keyword, a search engine alias, or simply a URL or part of the search + // string the user has typed. We won't know until we create the heuristic + // result. + let firstToken = !!this._searchTokens.length && this._searchTokens[0].value; + this._heuristicToken = + firstToken && this._trimmedOriginalSearchString.startsWith(firstToken) + ? firstToken + : null; + } + + // Set the right JavaScript behavior based on our preference. Note that the + // preference is whether or not we should filter JavaScript, and the + // behavior is if we should search it or not. + if (!UrlbarPrefs.get("filter.javascript")) { + this.setBehavior("javascript"); + } + + this._listener = autocompleteListener; + this._autocompleteSearch = autocompleteSearch; + + // Create a new result to add eventual matches. Note we need a result + // regardless having matches. + let result = Cc["@mozilla.org/autocomplete/simple-result;1"].createInstance( + Ci.nsIAutoCompleteSimpleResult + ); + result.setSearchString(searchString); + // Will be set later, if needed. + result.setDefaultIndex(-1); + this._result = result; + + // Used to limit the number of adaptive results. + this._adaptiveCount = 0; + this._extraAdaptiveRows = []; + + // Used to limit the number of remote tab results. + this._extraRemoteTabRows = []; + + // These are used to avoid adding duplicate entries to the results. + this._usedURLs = []; + this._usedPlaceIds = new Set(); + + // Counters for the number of results per RESULT_GROUP. + this._counts = Object.values(UrlbarUtils.RESULT_GROUP).reduce((o, p) => { + o[p] = 0; + return o; + }, {}); +} + +Search.prototype = { + /** + * Enables the desired AutoComplete behavior. + * + * @param type + * The behavior type to set. + */ + setBehavior(type) { + type = type.toUpperCase(); + this._behavior |= Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type]; + }, + + /** + * Determines if the specified AutoComplete behavior is set. + * + * @param aType + * The behavior type to test for. + * @return true if the behavior is set, false otherwise. + */ + hasBehavior(type) { + let behavior = Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type.toUpperCase()]; + + if ( + this._disablePrivateActions && + behavior == Ci.mozIPlacesAutoComplete.BEHAVIOR_OPENPAGE + ) { + return false; + } + + return this._behavior & behavior; + }, + + /** + * Used to delay the most complex queries, to save IO while the user is + * typing. + */ + _sleepResolve: null, + _sleep(aTimeMs) { + // Reuse a single instance to try shaving off some usless work before + // the first query. + if (!this._sleepTimer) { + this._sleepTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + } + return new Promise(resolve => { + this._sleepResolve = resolve; + this._sleepTimer.initWithCallback( + resolve, + aTimeMs, + Ci.nsITimer.TYPE_ONE_SHOT + ); + }); + }, + + /** + * Given an array of tokens, this function determines which query should be + * ran. It also removes any special search tokens. + * + * @param tokens + * An array of search tokens. + * @return A new, filtered array of tokens. + */ + filterTokens(tokens) { + let foundToken = false; + // Set the proper behavior while filtering tokens. + let filtered = []; + for (let token of tokens) { + if (!UrlbarTokenizer.isRestrictionToken(token)) { + filtered.push(token); + continue; + } + let behavior = typeToBehaviorMap.get(token.type); + if (!behavior) { + throw new Error(`Unknown token type ${token.type}`); + } + // Don't remove the token if it didn't match, or if it's an action but + // actions are not enabled. + if (behavior != "openpage" || this._enableActions) { + // Don't use the suggest preferences if it is a token search and + // set the restrict bit to 1 (to intersect the search results). + if (!foundToken) { + foundToken = true; + // Do not take into account previous behavior (e.g.: history, bookmark) + this._behavior = 0; + this.setBehavior("restrict"); + } + this.setBehavior(behavior); + // We return tags only for bookmarks, thus when tags are enforced, we + // must also set the bookmark behavior. + if (behavior == "tag") { + this.setBehavior("bookmark"); + } + } + } + return filtered; + }, + + /** + * Stop this search. + * After invoking this method, we won't run any more searches or heuristics, + * and no new matches may be added to the current result. + */ + stop() { + // Avoid multiple calls or re-entrance. + if (!this.pending) { + return; + } + if (this._notifyTimer) { + this._notifyTimer.cancel(); + } + this._notifyDelaysCount = 0; + if (this._sleepTimer) { + this._sleepTimer.cancel(); + } + if (this._sleepResolve) { + this._sleepResolve(); + this._sleepResolve = null; + } + if (typeof this.interrupt == "function") { + this.interrupt(); + } + this.pending = false; + }, + + /** + * Whether this search is active. + */ + pending: true, + + /** + * Execute the search and populate results. + * @param conn + * The Sqlite connection. + */ + async execute(conn) { + // A search might be canceled before it starts. + if (!this.pending) { + return; + } + + // Used by stop() to interrupt an eventual running statement. + this.interrupt = () => { + // Interrupt any ongoing statement to run the search sooner. + if (!UrlbarProvidersManager.interruptLevel) { + conn.interrupt(); + } + }; + + // For any given search, we run many queries/heuristics: + // 1) by alias (as defined in SearchService) + // 2) inline completion from search engine resultDomains + // 3) submission for the current search engine + // 4) Places keywords + // 5) adaptive learning (this._adaptiveQuery) + // 6) open pages not supported by history (this._switchToTabQuery) + // 7) query based on match behavior + // + // (4) only gets run if we get any filtered tokens, since if there are no + // tokens, there is nothing to match. + // + // (1) and (5) only get run if actions are enabled. When actions are + // enabled, the first result is always a special result (resulting from one + // of the queries between (1) and (4) inclusive). As such, the UI is + // expected to auto-select the first result when actions are enabled. If the + // first result is an inline completion result, that will also be the + // default result and therefore be autofilled (this also happens if actions + // are not enabled). + + // Check for Preloaded Sites Expiry before Autofill + await this._checkPreloadedSitesExpiry(); + + // If the query is simply "@" and we have tokenAliasEngines then return + // early. UrlbarProviderTokenAliasEngines will add engine results. + let tokenAliasEngines = await UrlbarSearchUtils.tokenAliasEngines(); + if (this._trimmedOriginalSearchString == "@" && tokenAliasEngines.length) { + this._autocompleteSearch.finishSearch(true); + return; + } + + // Add the first heuristic result, if any. Set _addingHeuristicResult + // to true so that when the result is added, "heuristic" can be included in + // its style. + this._addingHeuristicResult = true; + await this._matchFirstHeuristicResult(conn); + this._addingHeuristicResult = false; + if (!this.pending) { + return; + } + + // We sleep a little between adding the heuristic result and matching + // any other searches so we aren't kicking off potentially expensive + // searches on every keystroke. We check trimmedOriginalSearchString instead + // of whether we have a heurisitic because several sources of heuristic + // results have been factored out of UnifiedComplete. See discussion in + // bug 1655034. + // If there's no string, we search immediately because the user wants + // the empty search results ASAP. + if (this._trimmedOriginalSearchString) { + await this._sleep(UrlbarPrefs.get("delay")); + if (!this.pending) { + return; + } + + // If the heuristic result is an engine from a token alias, the search + // restriction char, or we're in search-restriction mode, then we're done. + // UrlbarProviderSearchSuggestions will handle suggestions, if any. + let emptySearchRestriction = + this._trimmedOriginalSearchString.length <= 3 && + this._leadingRestrictionToken == UrlbarTokenizer.RESTRICT.SEARCH && + /\s*\S?$/.test(this._trimmedOriginalSearchString); + if ( + emptySearchRestriction || + (tokenAliasEngines && + this._trimmedOriginalSearchString.startsWith("@")) || + (this.hasBehavior("search") && this.hasBehavior("restrict")) + ) { + this._autocompleteSearch.finishSearch(true); + return; + } + } + + // Run the adaptive query first. + await conn.executeCached( + this._adaptiveQuery[0], + this._adaptiveQuery[1], + this._onResultRow.bind(this) + ); + if (!this.pending) { + return; + } + + // Then fetch remote tabs. + if (this._enableActions && this.hasBehavior("openpage")) { + await this._matchRemoteTabs(); + if (!this.pending) { + return; + } + } + + // Finally run all the remaining queries. + let queries = []; + // "openpage" behavior is supported by the default query. + // _switchToTabQuery instead returns only pages not supported by history. + if (this.hasBehavior("openpage")) { + queries.push(this._switchToTabQuery); + } + queries.push(this._searchQuery); + for (let [query, params] of queries) { + await conn.executeCached(query, params, this._onResultRow.bind(this)); + if (!this.pending) { + return; + } + } + + // If we have some unused adaptive matches, add them now. + while ( + this._extraAdaptiveRows.length && + this._result.matchCount < this._maxResults + ) { + this._addFilteredQueryMatch(this._extraAdaptiveRows.shift()); + } + + // If we have some unused remote tab matches, add them now. + while ( + this._extraRemoteTabRows.length && + this._result.matchCount < this._maxResults + ) { + this._addMatch(this._extraRemoteTabRows.shift()); + } + + this._matchAboutPages(); + + // If we do not have enough matches search again with MATCH_ANYWHERE, to + // get more matches. + let count = + this._counts[UrlbarUtils.RESULT_GROUP.GENERAL] + + this._counts[UrlbarUtils.RESULT_GROUP.HEURISTIC]; + if (count < this._maxResults) { + this._matchBehavior = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE; + let queries = [this._adaptiveQuery, this._searchQuery]; + if (this.hasBehavior("openpage")) { + queries.unshift(this._switchToTabQuery); + } + for (let [query, params] of queries) { + await conn.executeCached(query, params, this._onResultRow.bind(this)); + if (!this.pending) { + return; + } + } + } + + this._matchPreloadedSites(); + }, + + _shouldMatchAboutPages() { + // Only autocomplete input that starts with 'about:' and has at least 1 more + // character. + return this._strippedPrefix == "about:" && this._searchString; + }, + + _matchAboutPages() { + if (!this._shouldMatchAboutPages()) { + return; + } + for (const url of AboutPagesUtils.visibleAboutUrls) { + if (url.startsWith(`about:${this._searchString}`)) { + this._addMatch({ + value: url, + comment: url, + frecency: FRECENCY_DEFAULT, + }); + } + } + }, + + async _checkPreloadedSitesExpiry() { + if (!UrlbarPrefs.get("usepreloadedtopurls.enabled")) { + return; + } + let profileCreationDate = await ProfileAgeCreatedPromise; + let daysSinceProfileCreation = + (Date.now() - profileCreationDate) / MS_PER_DAY; + if ( + daysSinceProfileCreation > + UrlbarPrefs.get("usepreloadedtopurls.expire_days") + ) { + Services.prefs.setBoolPref( + "browser.urlbar.usepreloadedtopurls.enabled", + false + ); + } + }, + + _matchPreloadedSites() { + if (!UrlbarPrefs.get("usepreloadedtopurls.enabled")) { + return; + } + + if (!this._searchString) { + // The user hasn't typed anything, or they've only typed a scheme. + return; + } + + for (let site of PreloadedSiteStorage.sites) { + let url = site.uri.spec; + if ( + (!this._strippedPrefix || url.startsWith(this._strippedPrefix)) && + (site.uri.host.includes(this._searchString) || + site._matchTitle.includes(this._searchString)) + ) { + this._addMatch({ + value: url, + comment: site.title, + style: "preloaded-top-site", + frecency: FRECENCY_DEFAULT - 1, + }); + } + } + }, + + _matchPreloadedSiteForAutofill() { + if (!UrlbarPrefs.get("usepreloadedtopurls.enabled")) { + return false; + } + + let matchedSite = PreloadedSiteStorage.sites.find(site => { + return ( + (!this._strippedPrefix || + site.uri.spec.startsWith(this._strippedPrefix)) && + (site.uri.host.startsWith(this._searchString) || + site.uri.host.startsWith("www." + this._searchString)) + ); + }); + if (!matchedSite) { + return false; + } + + this._result.setDefaultIndex(0); + + let url = matchedSite.uri.spec; + let value = stripAnyPrefix(url)[1]; + value = value.substr(value.indexOf(this._searchString)); + + this._addAutofillMatch(value, url, Infinity, ["preloaded-top-site"]); + return true; + }, + + async _matchFirstHeuristicResult(conn) { + if (this._searchMode) { + // Use UrlbarProviderHeuristicFallback. + return false; + } + + // We always try to make the first result a special "heuristic" result. The + // heuristics below determine what type of result it will be, if any. + + if (this.pending && this._enableActions && this._heuristicToken) { + // It may be a search engine with an alias - which works like a keyword. + let matched = await this._matchSearchEngineAlias(this._heuristicToken); + if (matched) { + return true; + } + } + + if (this.pending && this._heuristicToken) { + // It may be a Places keyword. + let matched = await this._matchPlacesKeyword(this._heuristicToken); + if (matched) { + return true; + } + } + + let shouldAutofill = this._shouldAutofill; + + if (this.pending && shouldAutofill) { + let matched = this._matchPreloadedSiteForAutofill(); + if (matched) { + return true; + } + } + + // Fall back to UrlbarProviderHeuristicFallback. + return false; + }, + + async _matchPlacesKeyword(keyword) { + let entry = await PlacesUtils.keywords.fetch(keyword); + if (!entry) { + return false; + } + + let searchString = UrlbarUtils.substringAfter( + this._originalSearchString, + keyword + ).trim(); + + let url = null; + let postData = null; + try { + [url, postData] = await BrowserUtils.parseUrlAndPostData( + entry.url.href, + entry.postData, + searchString + ); + } catch (ex) { + // It's not possible to bind a param to this keyword. + return false; + } + + let style = "keyword"; + let value = url; + if (this._enableActions) { + style = "action " + style; + value = makeActionUrl("keyword", { + url, + keyword, + input: this._originalSearchString, + postData, + }); + } + + let match = { + value, + // Don't use the url with replaced strings, since the icon doesn't change + // but the string does, it may cause pointless icon flicker on typing. + icon: iconHelper(entry.url), + style, + frecency: Infinity, + }; + // If there is a query string, the title will be "host: queryString". + if (this._searchTokens.length > 1) { + match.comment = entry.url.host; + } + + this._firstTokenIsKeyword = true; + this._filterOnHost = entry.url.host; + this._addMatch(match); + return true; + }, + + async _matchSearchEngineAlias(alias) { + let engine = await UrlbarSearchUtils.engineForAlias(alias); + if (!engine) { + return false; + } + + let query = UrlbarUtils.substringAfter(this._originalSearchString, alias); + + // Match an alias only when it has a space after it. If there's no trailing + // space, then continue to treat it as part of the search string. + if (!UrlbarTokenizer.REGEXP_SPACES_START.test(query)) { + return false; + } + + this._searchEngineAliasMatch = { + engine, + alias, + query: query.trimStart(), + }; + this._firstTokenIsKeyword = true; + this._filterOnHost = engine.getResultDomain(); + this._addSearchEngineMatch(this._searchEngineAliasMatch); + return true; + }, + + /** + * Adds a search engine match. + * + * @param {nsISearchEngine} engine + * The search engine associated with the match. + * @param {string} [query] + * The search query string. + * @param {string} [alias] + * The search engine alias associated with the match, if any. + * @param {bool} [historical] + * True if you're adding a suggestion match and the suggestion is from + * the user's local history (and not the search engine). + */ + _addSearchEngineMatch({ + engine, + query = "", + alias = undefined, + historical = false, + }) { + let actionURLParams = { + engineName: engine.name, + searchQuery: query, + }; + + if (alias && !query) { + // `input` should have a trailing space so that when the user selects the + // result, they can start typing their query without first having to enter + // a space between the alias and query. + actionURLParams.input = `${alias} `; + } else { + actionURLParams.input = this._originalSearchString; + } + + let match = { + comment: engine.name, + icon: engine.iconURI ? engine.iconURI.spec : null, + style: "action searchengine", + frecency: FRECENCY_DEFAULT, + }; + + if (alias) { + actionURLParams.alias = alias; + match.style += " alias"; + } + + match.value = makeActionUrl("searchengine", actionURLParams); + this._addMatch(match); + }, + + async _matchRemoteTabs() { + // Bail out early for non-sync users. + if (!syncUsernamePref) { + return; + } + + let searchString = this._searchTokens.map(t => t.value).join(" "); + let matches = await PlacesRemoteTabsAutocompleteProvider.getMatches( + searchString, + this._maxResults + ); + let remoteTabsAdded = 0; + for (let { url, title, icon, deviceName, lastUsed } of matches) { + // It's rare that Sync supplies the icon for the page (but if it does, it + // is a string URL) + if (!icon) { + icon = iconHelper(url); + } else { + icon = PlacesUtils.favicons.getFaviconLinkForIcon( + Services.io.newURI(icon) + ).spec; + } + + let match = { + // We include the deviceName in the action URL so we can render it in + // the URLBar. + value: makeActionUrl("remotetab", { url, deviceName }), + comment: title || url, + style: "action remotetab", + // we want frecency > FRECENCY_DEFAULT so it doesn't get pushed out + // by "remote" matches. + frecency: FRECENCY_DEFAULT + 1, + icon, + }; + // Mobile and desktop frecency scales are not compatible so we don't + // intermix open tabs with synced tabs. Instead, we limit the number of + // initial remote tabs to the floor of _maxResults / 2 so they do not + // overrun open tabs. + if ( + remoteTabsAdded < this._maxResults / 2 && + lastUsed > Date.now() - RECENT_REMOTE_TAB_THRESHOLD_MS + ) { + this._addMatch(match); + remoteTabsAdded++; + } else { + this._extraRemoteTabRows.push(match); + } + } + }, + + _onResultRow(row, cancel) { + let queryType = row.getResultByIndex(QUERYINDEX_QUERYTYPE); + switch (queryType) { + case QUERYTYPE_ADAPTIVE: + this._addAdaptiveQueryMatch(row); + break; + case QUERYTYPE_FILTERED: + this._addFilteredQueryMatch(row); + break; + } + // If the search has been canceled by the user or by _addMatch, or we + // fetched enough results, we can stop the underlying Sqlite query. + let count = + this._counts[UrlbarUtils.RESULT_GROUP.GENERAL] + + this._counts[UrlbarUtils.RESULT_GROUP.HEURISTIC]; + if (!this.pending || count >= this._maxResults) { + cancel(); + } + }, + + /** + * Maybe restyle a SERP in history as a search-type result. To do this, + * we extract the search term from the SERP in history then generate a search + * URL with that search term. We restyle the SERP in history if its query + * parameters are a subset of those of the generated SERP. We check for a + * subset instead of exact equivalence since the generated URL may contain + * attribution parameters while a SERP in history from an organic search would + * not. We don't allow extra params in the history URL since they might + * indicate the search is not a first-page web SERP (as opposed to a image or + * other non-web SERP). + * + * @note We will mistakenly dedupe SERPs for engines that have the same + * hostname as another engine. One example is if the user installed a + * Google Image Search engine. That engine's search URLs might only be + * distinguished by query params from search URLs from the default Google + * engine. + */ + _maybeRestyleSearchMatch(match) { + // Return if the URL does not represent a search result. + let historyUrl = match.value; + let parseResult = Services.search.parseSubmissionURL(historyUrl); + if (!parseResult?.engine) { + return false; + } + + // Here we check that the user typed all or part of the search string in the + // search history result. + let terms = parseResult.terms.toLowerCase(); + if ( + this._searchTokens.length && + this._searchTokens.every(token => !terms.includes(token.value)) + ) { + return false; + } + + // The URL for the search suggestion formed by the user's typed query. + let [generatedSuggestionUrl] = UrlbarUtils.getSearchQueryUrl( + parseResult.engine, + this._searchTokens.map(t => t.value).join(" ") + ); + + // We ignore termsParameterName when checking for a subset because we + // already checked that the typed query is a subset of the search history + // query above with this._searchTokens.every(...). + if ( + !UrlbarSearchUtils.serpsAreEquivalent( + historyUrl, + generatedSuggestionUrl, + [parseResult.termsParameterName] + ) + ) { + return false; + } + + // Turn the match into a searchengine action with a favicon. + match.value = makeActionUrl("searchengine", { + engineName: parseResult.engine.name, + input: parseResult.terms, + searchSuggestion: parseResult.terms, + searchQuery: parseResult.terms, + isSearchHistory: true, + }); + match.comment = parseResult.engine.name; + match.icon = match.icon || match.iconUrl; + match.style = "action searchengine favicon suggestion"; + return true; + }, + + _addMatch(match) { + if (typeof match.frecency != "number") { + throw new Error("Frecency not provided"); + } + + if (this._addingHeuristicResult) { + match.type = UrlbarUtils.RESULT_GROUP.HEURISTIC; + } else if (typeof match.type != "string") { + match.type = UrlbarUtils.RESULT_GROUP.GENERAL; + } + + // A search could be canceled between a query start and its completion, + // in such a case ensure we won't notify any result for it. + if (!this.pending) { + return; + } + + match.style = match.style || "favicon"; + + // Restyle past searches, unless they are bookmarks or special results. + if ( + match.style == "favicon" && + (UrlbarPrefs.get("restyleSearches") || this._searchModeEngine) + ) { + let restyled = this._maybeRestyleSearchMatch(match); + if (restyled && UrlbarPrefs.get("maxHistoricalSearchSuggestions") == 0) { + // The user doesn't want search history. + return; + } + } + + if (this._addingHeuristicResult) { + match.style += " heuristic"; + } + + match.icon = match.icon || ""; + match.finalCompleteValue = match.finalCompleteValue || ""; + + let { index, replace } = this._getInsertIndexForMatch(match); + if (index == -1) { + return; + } + if (replace) { + // Replacing an existing match from the previous search. + this._result.removeMatchAt(index); + } + this._result.insertMatchAt( + index, + match.value, + match.comment, + match.icon, + match.style, + match.finalCompleteValue + ); + this._counts[match.type]++; + + this.notifyResult(true, match.type == UrlbarUtils.RESULT_GROUP.HEURISTIC); + }, + + /** + * Check for duplicates and either discard the duplicate or replace the + * original match, in case the new one is more specific. For example, + * a Remote Tab wins over History, and a Switch to Tab wins over a Remote Tab. + * We must check both id and url for duplication, because keywords may change + * the url by replacing the %s placeholder. + * @param match + * @returns {object} matchPosition + * @returns {number} matchPosition.index + * The index the match should take in the results. Return -1 if the match + * should be discarded. + * @returns {boolean} matchPosition.replace + * True if the match should replace the result already at + * matchPosition.index. + * + */ + _getInsertIndexForMatch(match) { + let [urlMapKey, prefix, action] = makeKeyForMatch(match); + if ( + (match.placeId && this._usedPlaceIds.has(match.placeId)) || + this._usedURLs.some(e => ObjectUtils.deepEqual(e.key, urlMapKey)) + ) { + let isDupe = true; + if (action && ["switchtab", "remotetab"].includes(action.type)) { + // The new entry is a switch/remote tab entry, look for the duplicate + // among current matches. + for (let i = 0; i < this._usedURLs.length; ++i) { + let { + key: matchKey, + action: matchAction, + type: matchType, + } = this._usedURLs[i]; + if (ObjectUtils.deepEqual(matchKey, urlMapKey)) { + isDupe = true; + // Don't replace the match if the existing one is heuristic and the + // new one is a switchtab, instead also add the switchtab match. + if ( + matchType == UrlbarUtils.RESULT_GROUP.HEURISTIC && + action.type == "switchtab" + ) { + isDupe = false; + // Since we allow to insert a dupe in this case, we must continue + // checking the next matches to be sure we won't insert more than + // one dupe. For this same reason we must reset isDupe = true for + // each found dupe. + continue; + } + if (!matchAction || action.type == "switchtab") { + this._usedURLs[i] = { + key: urlMapKey, + action, + type: match.type, + prefix, + comment: match.comment, + }; + return { index: i, replace: true }; + } + break; // Found the duplicate, no reason to continue. + } + } + } else { + // Dedupe with this flow: + // 1. If the two URLs are the same, dedupe whichever is not the + // heuristic result. + // 2. If they both contain www. or both do not contain it, prefer https. + // 3. If they differ by www., send both results to the Muxer and allow + // it to decide based on results from other providers. + let prefixRank = UrlbarUtils.getPrefixRank(prefix); + for (let i = 0; i < this._usedURLs.length; ++i) { + if (!this._usedURLs[i]) { + // This is true when the result at [i] is a searchengine result. + continue; + } + + let { + key: existingKey, + prefix: existingPrefix, + type: existingType, + } = this._usedURLs[i]; + + let existingPrefixRank = UrlbarUtils.getPrefixRank(existingPrefix); + if (ObjectUtils.deepEqual(existingKey, urlMapKey)) { + isDupe = true; + + if (prefix == existingPrefix) { + // The URLs are identical. Throw out the new result, unless it's + // the heuristic. + if (match.type != UrlbarUtils.RESULT_GROUP.HEURISTIC) { + break; // Replace match. + } else { + this._usedURLs[i] = { + key: urlMapKey, + action, + type: match.type, + prefix, + comment: match.comment, + }; + return { index: i, replace: true }; + } + } + + if (prefix.endsWith("www.") == existingPrefix.endsWith("www.")) { + // The results differ only by protocol. + + if (match.type == UrlbarUtils.RESULT_GROUP.HEURISTIC) { + isDupe = false; + continue; + } + + if (prefixRank <= existingPrefixRank) { + break; // Replace match. + } else if (existingType != UrlbarUtils.RESULT_GROUP.HEURISTIC) { + this._usedURLs[i] = { + key: urlMapKey, + action, + type: match.type, + prefix, + comment: match.comment, + }; + return { index: i, replace: true }; + } else { + isDupe = false; + continue; + } + } else { + // We have two identical URLs that differ only by www. We need to + // be sure what the heuristic result is before deciding how we + // should dedupe. We mark these as non-duplicates and let the + // muxer handle it. + isDupe = false; + continue; + } + } + } + } + + // Discard the duplicate. + if (isDupe) { + return { index: -1, replace: false }; + } + } + + // Add this to our internal tracker to ensure duplicates do not end up in + // the result. + // Not all entries have a place id, thus we fallback to the url for them. + // We cannot use only the url since keywords entries are modified to + // include the search string, and would be returned multiple times. Ids + // are faster too. + if (match.placeId) { + this._usedPlaceIds.add(match.placeId); + } + + let index = 0; + // The buckets change depending on the context, that is currently decided by + // the first added match (the heuristic one). + if (!this._buckets) { + // Convert the buckets to readable objects with a count property. + let buckets = + match.type == UrlbarUtils.RESULT_GROUP.HEURISTIC && + match.style.includes("searchengine") + ? UrlbarPrefs.get("matchBucketsSearch") + : UrlbarPrefs.get("matchBuckets"); + // - available is the number of available slots in the bucket + // - insertIndex is the index of the first available slot in the bucket + // - count is the number of matches in the bucket, note that it also + // account for matches from the previous search, while available and + // insertIndex don't. + this._buckets = buckets.map(([type, available]) => ({ + type, + available, + insertIndex: 0, + count: 0, + })); + } + + let replace = 0; + for (let bucket of this._buckets) { + // Move to the next bucket if the match type is incompatible, or if there + // is no available space or if the frecency is below the threshold. + if (match.type != bucket.type || !bucket.available) { + index += bucket.count; + continue; + } + + index += bucket.insertIndex; + bucket.available--; + if (bucket.insertIndex < bucket.count) { + replace = true; + } else { + bucket.count++; + } + bucket.insertIndex++; + break; + } + this._usedURLs[index] = { + key: urlMapKey, + action, + type: match.type, + prefix, + comment: match.comment || "", + }; + return { index, replace }; + }, + + _addAutofillMatch( + autofilledValue, + finalCompleteValue, + frecency = Infinity, + extraStyles = [] + ) { + // The match's comment is only for display. Set it to finalCompleteValue, + // the actual URL that will be visited when the user chooses the match, so + // that the user knows exactly where the match will take them. To make it + // look a little nicer, remove "http://", and if the user typed a host + // without a trailing slash, remove any trailing slash, too. + let [comment] = stripPrefixAndTrim(finalCompleteValue, { + stripHttp: true, + trimEmptyQuery: true, + trimSlash: !this._searchString.includes("/"), + }); + + this._addMatch({ + value: this._strippedPrefix + autofilledValue, + finalCompleteValue, + comment, + frecency, + style: ["autofill"].concat(extraStyles).join(" "), + icon: iconHelper(finalCompleteValue), + }); + }, + + // This is the same as _addFilteredQueryMatch, but it only returns a few + // results, caching the others. If at the end we don't find other results, we + // can add these. + _addAdaptiveQueryMatch(row) { + // We should only show filtered results in search mode. + if (this._searchModeEngine) { + return; + } + // Allow one quarter of the results to be adaptive results. + // Note: ideally adaptive results should have their own provider and the + // results muxer should decide what to show. But that's too complex to + // support in the current code, so that's left for a future refactoring. + if (this._adaptiveCount < Math.ceil(this._maxResults / 4)) { + this._addFilteredQueryMatch(row); + } else { + this._extraAdaptiveRows.push(row); + } + this._adaptiveCount++; + }, + + _addFilteredQueryMatch(row) { + let placeId = row.getResultByIndex(QUERYINDEX_PLACEID); + let url = row.getResultByIndex(QUERYINDEX_URL); + let openPageCount = row.getResultByIndex(QUERYINDEX_SWITCHTAB) || 0; + let historyTitle = row.getResultByIndex(QUERYINDEX_TITLE) || ""; + let bookmarked = row.getResultByIndex(QUERYINDEX_BOOKMARKED); + let bookmarkTitle = bookmarked + ? row.getResultByIndex(QUERYINDEX_BOOKMARKTITLE) + : null; + let tags = row.getResultByIndex(QUERYINDEX_TAGS) || ""; + let frecency = row.getResultByIndex(QUERYINDEX_FRECENCY); + + let match = { + placeId, + value: url, + comment: bookmarkTitle || historyTitle, + icon: iconHelper(url), + frecency: frecency || FRECENCY_DEFAULT, + }; + + if ( + this._enableActions && + openPageCount > 0 && + this.hasBehavior("openpage") + ) { + if (this._currentPage == match.value) { + // Don't suggest switching to the current tab. + return; + } + // Actions are enabled and the page is open. Add a switch-to-tab result. + match.value = makeActionUrl("switchtab", { url: match.value }); + match.style = "action switchtab"; + } else if ( + this.hasBehavior("history") && + !this.hasBehavior("bookmark") && + !tags + ) { + // The consumer wants only history and not bookmarks and there are no + // tags. We'll act as if the page is not bookmarked. + match.style = "favicon"; + } else if (tags) { + // Store the tags in the title. It's up to the consumer to extract them. + match.comment += UrlbarUtils.TITLE_TAGS_SEPARATOR + tags; + // If we're not suggesting bookmarks, then this shouldn't display as one. + match.style = this.hasBehavior("bookmark") ? "bookmark-tag" : "tag"; + } else if (bookmarked) { + match.style = "bookmark"; + } + + this._addMatch(match); + }, + + /** + * @return a string consisting of the search query to be used based on the + * previously set urlbar suggestion preferences. + */ + get _suggestionPrefQuery() { + let conditions = []; + if (this._filterOnHost) { + conditions.push("h.rev_host = get_unreversed_host(:host || '.') || '.'"); + // When filtering on a host we are in some sort of site specific search, + // thus we want a cleaner set of results, compared to a general search. + // This means removing less interesting urls, like redirects or + // non-bookmarked title-less pages. + + if (UrlbarPrefs.get("restyleSearches") || this._searchModeEngine) { + // If restyle is enabled, we want to filter out redirect targets, + // because sources are urls built using search engines definitions that + // we can reverse-parse. + // In this case we can't filter on title-less pages because redirect + // sources likely don't have a title and recognizing sources is costly. + // Bug 468710 may help with this. + conditions.push(`NOT EXISTS ( + WITH visits(type) AS ( + SELECT visit_type + FROM moz_historyvisits + WHERE place_id = h.id + ORDER BY visit_date DESC + LIMIT 10 /* limit to the last 10 visits */ + ) + SELECT 1 FROM visits + WHERE type IN (5,6) + )`); + } else { + // If instead restyle is disabled, we want to keep redirect targets, + // because sources are often unreadable title-less urls. + conditions.push(`NOT EXISTS ( + WITH visits(id) AS ( + SELECT id + FROM moz_historyvisits + WHERE place_id = h.id + ORDER BY visit_date DESC + LIMIT 10 /* limit to the last 10 visits */ + ) + SELECT 1 + FROM visits src + JOIN moz_historyvisits dest ON src.id = dest.from_visit + WHERE dest.visit_type IN (5,6) + )`); + // Filter out empty-titled pages, they could be redirect sources that + // we can't recognize anymore because their target was wrongly expired + // due to Bug 1664252. + conditions.push("(h.foreign_count > 0 OR h.title NOTNULL)"); + } + } + + if ( + this.hasBehavior("restrict") || + !this.hasBehavior("history") || + !this.hasBehavior("bookmark") + ) { + if (this.hasBehavior("history")) { + // Enforce ignoring the visit_count index, since the frecency one is much + // faster in this case. ANALYZE helps the query planner to figure out the + // faster path, but it may not have up-to-date information yet. + conditions.push("+h.visit_count > 0"); + } + if (this.hasBehavior("bookmark")) { + conditions.push("bookmarked"); + } + if (this.hasBehavior("tag")) { + conditions.push("tags NOTNULL"); + } + } + + return defaultQuery(conditions.join(" AND ")); + }, + + get _emptySearchDefaultBehavior() { + // Further restrictions to apply for "empty searches" (searching for + // ""). The empty behavior is typed history, if history is enabled. + // Otherwise, it is bookmarks, if they are enabled. If both history and + // bookmarks are disabled, it defaults to open pages. + let val = Ci.mozIPlacesAutoComplete.BEHAVIOR_RESTRICT; + if (UrlbarPrefs.get("suggest.history")) { + val |= Ci.mozIPlacesAutoComplete.BEHAVIOR_HISTORY; + } else if (UrlbarPrefs.get("suggest.bookmark")) { + val |= Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK; + } else { + val |= Ci.mozIPlacesAutoComplete.BEHAVIOR_OPENPAGE; + } + return val; + }, + + /** + * If the user-provided string starts with a keyword that gave a heuristic + * result, this will strip it. + * @returns {string} The filtered search string. + */ + get _keywordFilteredSearchString() { + let tokens = this._searchTokens.map(t => t.value); + if (this._firstTokenIsKeyword) { + tokens = tokens.slice(1); + } + return tokens.join(" "); + }, + + /** + * Obtains the search query to be used based on the previously set search + * preferences (accessed by this.hasBehavior). + * + * @return an array consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + get _searchQuery() { + let params = { + parent: PlacesUtils.tagsFolderId, + query_type: QUERYTYPE_FILTERED, + matchBehavior: this._matchBehavior, + searchBehavior: this._behavior, + // We only want to search the tokens that we are left with - not the + // original search string. + searchString: this._keywordFilteredSearchString, + userContextId: this._userContextId, + // Limit the query to the the maximum number of desired results. + // This way we can avoid doing more work than needed. + maxResults: this._maxResults, + }; + if (this._filterOnHost) { + params.host = this._filterOnHost; + } + return [this._suggestionPrefQuery, params]; + }, + + /** + * Obtains the query to search for switch-to-tab entries. + * + * @return an array consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + get _switchToTabQuery() { + return [ + SQL_SWITCHTAB_QUERY, + { + query_type: QUERYTYPE_FILTERED, + matchBehavior: this._matchBehavior, + searchBehavior: this._behavior, + // We only want to search the tokens that we are left with - not the + // original search string. + searchString: this._keywordFilteredSearchString, + userContextId: this._userContextId, + maxResults: this._maxResults, + }, + ]; + }, + + /** + * Obtains the query to search for adaptive results. + * + * @return an array consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + get _adaptiveQuery() { + return [ + SQL_ADAPTIVE_QUERY, + { + parent: PlacesUtils.tagsFolderId, + search_string: this._searchString, + query_type: QUERYTYPE_ADAPTIVE, + matchBehavior: this._matchBehavior, + searchBehavior: this._behavior, + userContextId: this._userContextId, + maxResults: this._maxResults, + }, + ]; + }, + + /** + * Whether we should try to autoFill. + */ + get _shouldAutofill() { + // First of all, check for the autoFill pref. + if (!UrlbarPrefs.get("autoFill")) { + return false; + } + + if (this._searchTokens.length != 1) { + return false; + } + + // autoFill can only cope with history, bookmarks, and about: entries. + if (!this.hasBehavior("history") && !this.hasBehavior("bookmark")) { + return false; + } + + // autoFill doesn't search titles or tags. + if (this.hasBehavior("title") || this.hasBehavior("tag")) { + return false; + } + + // Don't try to autofill if the search term includes any whitespace. + // This may confuse completeDefaultIndex cause the AUTOCOMPLETE_MATCH + // tokenizer ends up trimming the search string and returning a value + // that doesn't match it, or is even shorter. + if (REGEXP_SPACES.test(this._originalSearchString)) { + return false; + } + + if (!this._searchString.length) { + return false; + } + + if (this._prohibitAutoFill) { + return false; + } + + return true; + }, + + // The result is notified to the search listener on a timer, to chunk multiple + // match updates together and avoid rebuilding the popup at every new match. + _notifyTimer: null, + + /** + * Notifies the current result to the listener. + * + * @param searchOngoing + * Indicates whether the search result should be marked as ongoing. + * @param skipDelay + * Whether to notify immediately. + */ + _notifyDelaysCount: 0, + notifyResult(searchOngoing, skipDelay = false) { + let notify = () => { + if (!this.pending) { + return; + } + this._notifyDelaysCount = 0; + let resultCode = this._result.matchCount + ? "RESULT_SUCCESS" + : "RESULT_NOMATCH"; + if (searchOngoing) { + resultCode += "_ONGOING"; + } + let result = this._result; + result.setSearchResult(Ci.nsIAutoCompleteResult[resultCode]); + this._listener.onSearchResult(this._autocompleteSearch, result); + if (!searchOngoing) { + // Break possible cycles. + this._listener = null; + this._autocompleteSearch = null; + this.stop(); + } + }; + if (this._notifyTimer) { + this._notifyTimer.cancel(); + } + // In the worst case, we may get evenly spaced matches that would end up + // delaying the UI by N_MATCHES * NOTIFYRESULT_DELAY_MS. Thus, we clamp the + // number of times we may delay matches. + if (skipDelay || this._notifyDelaysCount > 3) { + notify(); + } else { + this._notifyDelaysCount++; + this._notifyTimer = setTimeout(notify, NOTIFYRESULT_DELAY_MS); + } + }, +}; + +// UnifiedComplete class +// component @mozilla.org/autocomplete/search;1?name=unifiedcomplete + +function UnifiedComplete() { + if (UrlbarPrefs.get("usepreloadedtopurls.enabled")) { + // force initializing the profile age check + // to ensure the off-main-thread-IO happens ASAP + // and we don't have to wait for it when doing an autocomplete lookup + ProfileAgeCreatedPromise; + + fetch("chrome://global/content/unifiedcomplete-top-urls.json") + .then(response => response.json()) + .then(sites => PreloadedSiteStorage.populate(sites)) + .catch(ex => Cu.reportError(ex)); + } +} + +UnifiedComplete.prototype = { + // Database handling + + /** + * Promise resolved when the database initialization has completed, or null + * if it has never been requested. + */ + _promiseDatabase: null, + + /** + * Gets a Sqlite database handle. + * + * @return {Promise} + * @resolves to the Sqlite database handle (according to Sqlite.jsm). + * @rejects javascript exception. + */ + getDatabaseHandle() { + if (!this._promiseDatabase) { + this._promiseDatabase = (async () => { + let conn = await PlacesUtils.promiseLargeCacheDBConnection(); + + // We don't catch exceptions here as it is too late to block shutdown. + Sqlite.shutdown.addBlocker("Places UnifiedComplete.js closing", () => { + // Break a possible cycle through the + // previous result, the controller and + // ourselves. + this._currentSearch = null; + }); + + return conn; + })().catch(ex => { + dump("Couldn't get database handle: " + ex + "\n"); + Cu.reportError(ex); + }); + } + return this._promiseDatabase; + }, + + // mozIPlacesAutoComplete + + populatePreloadedSiteStorage(json) { + PreloadedSiteStorage.populate(json); + }, + + /** + * This is a wrapper around startSearch, with a better interface towards + * Quantum Bar. Long term this provider should be migrated to new separate + * providers and this won't be necessary + * @param {UrlbarQueryContext} queryContext + * The context for the current search. + * @param {Function} onAutocompleteResult + * A callback to notify each result to. + */ + startQuery(queryContext, onAutocompleteResult) { + let deferred = PromiseUtils.defer(); + let listener = { + onSearchResult(_, result) { + let done = + [ + Ci.nsIAutoCompleteResult.RESULT_IGNORED, + Ci.nsIAutoCompleteResult.RESULT_FAILURE, + Ci.nsIAutoCompleteResult.RESULT_NOMATCH, + Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + ].includes(result.searchResult) || result.errorDescription; + onAutocompleteResult(result); + if (done) { + deferred.resolve(); + } + }, + }; + this.startSearch( + queryContext.searchString, + "", + null, + listener, + queryContext + ); + this._deferred = deferred; + return this._deferred.promise; + }, + + // nsIAutoCompleteSearch + + startSearch( + searchString, + searchParam, + acPreviousResult, + listener, + queryContext + ) { + // Stop the search in case the controller has not taken care of it. + if (this._currentSearch) { + this.stopSearch(); + } + + let search = (this._currentSearch = new Search( + searchString, + searchParam, + listener, + this, + queryContext + )); + this.getDatabaseHandle() + .then(conn => search.execute(conn)) + .catch(ex => { + dump(`Query failed: ${ex}\n`); + Cu.reportError(ex); + }) + .then(() => { + if (search == this._currentSearch) { + this.finishSearch(true); + } + }); + }, + + stopSearch() { + if (this._currentSearch) { + this._currentSearch.stop(); + } + if (this._deferred) { + this._deferred.resolve(); + } + // Don't notify since we are canceling this search. This also means we + // won't fire onSearchComplete for this search. + this.finishSearch(); + }, + + /** + * Properly cleans up when searching is completed. + * + * @param notify [optional] + * Indicates if we should notify the AutoComplete listener about our + * results or not. + */ + finishSearch(notify = false) { + // Clear state now to avoid race conditions, see below. + let search = this._currentSearch; + if (!search) { + return; + } + this._lastLowResultsSearchSuggestion = + search._lastLowResultsSearchSuggestion; + + if (!notify || !search.pending) { + return; + } + + // There is a possible race condition here. + // When a search completes it calls finishSearch that notifies results + // here. When the controller gets the last result it fires + // onSearchComplete. + // If onSearchComplete immediately starts a new search it will set a new + // _currentSearch, and on return the execution will continue here, after + // notifyResult. + // Thus, ensure that notifyResult is the last call in this method, + // otherwise you might be touching the wrong search. + search.notifyResult(false); + }, + + // nsIAutoCompleteSearchDescriptor + + get searchType() { + return Ci.nsIAutoCompleteSearchDescriptor.SEARCH_TYPE_IMMEDIATE; + }, + + get clearingAutoFillSearchesAgain() { + return true; + }, + + // nsISupports + + classID: Components.ID("f964a319-397a-4d21-8be6-5cdd1ee3e3ae"), + + QueryInterface: ChromeUtils.generateQI([ + "nsIAutoCompleteSearch", + "nsIAutoCompleteSearchDescriptor", + "mozIPlacesAutoComplete", + "nsIObserver", + "nsISupportsWeakReference", + ]), +}; + +var EXPORTED_SYMBOLS = ["UnifiedComplete"]; |