/* -*- 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] */ /** * This module exports a provider that provides results from the Places * database, including history, bookmarks, and open tabs. */ // Constants // AutoComplete query type constants. // Describes the various types of queries that we can process rows for. const QUERYTYPE_FILTERED = 0; // The default frecency value used when inserting matches with unknown frecency. const FRECENCY_DEFAULT = 1000; // 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, NULL) ELSE AUTOCOMPLETE_MATCH(:searchString, h.url, h.title, '', h.visit_count, h.typed, 0, t.open_count, :matchBehavior, :searchBehavior, NULL) 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, NULL) ORDER BY t.ROWID DESC LIMIT :maxResults`; // Getters import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { UrlbarProvider, UrlbarUtils, } from "resource:///modules/UrlbarUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { KeywordUtils: "resource://gre/modules/KeywordUtils.sys.mjs", PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", Sqlite: "resource://gre/modules/Sqlite.sys.mjs", UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.sys.mjs", UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", }); XPCOMUtils.defineLazyModuleGetters(lazy, { ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", }); function setTimeout(callback, ms) { let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); timer.initWithCallback(callback, ms, timer.TYPE_ONE_SHOT); return timer; } // Maps restriction character types to textual behaviors. XPCOMUtils.defineLazyGetter(lazy, "typeToBehaviorMap", () => { return new Map([ [lazy.UrlbarTokenizer.TYPE.RESTRICT_HISTORY, "history"], [lazy.UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, "bookmark"], [lazy.UrlbarTokenizer.TYPE.RESTRICT_TAG, "tag"], [lazy.UrlbarTokenizer.TYPE.RESTRICT_OPENPAGE, "openpage"], [lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH, "search"], [lazy.UrlbarTokenizer.TYPE.RESTRICT_TITLE, "title"], [lazy.UrlbarTokenizer.TYPE.RESTRICT_URL, "url"], ]); }); XPCOMUtils.defineLazyGetter(lazy, "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 /** * 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) { let key, prefix; let action = lazy.PlacesUtils.parseActionUrl(match.value); if (!action) { [key, prefix] = UrlbarUtils.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] = UrlbarUtils.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 {string} type * The action type. * @param {object} params * A JS object of action params. * @returns {string} 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)}`; } /** * Converts an array of legacy match objects into UrlbarResults. * Note that at every call we get the full set of results, included the * previously returned ones, and new results may be inserted in the middle. * This means we could sort these wrongly, the muxer should take care of it. * * @param {UrlbarQueryContext} context the query context. * @param {Array} matches The match objects. * @param {set} urls a Set containing all the found urls, used to discard * already added results. * @returns {Array} converted results */ function convertLegacyMatches(context, matches, urls) { let results = []; for (let match of matches) { // First, let's check if we already added this result. // `matches` always contains all of the results, includes ones // we may have added already. This means we'll end up adding things in the // wrong order here, but that's a task for the UrlbarMuxer. let url = match.finalCompleteValue || match.value; if (urls.has(url)) { continue; } urls.add(url); let result = makeUrlbarResult(context.tokens, { url, // `match.icon` is an empty string if there is no icon. Use undefined // instead so that tests can be simplified by not including `icon: ""` in // all their payloads. icon: match.icon || undefined, style: match.style, comment: match.comment, firstToken: context.tokens[0], }); // Should not happen, but better safe than sorry. if (!result) { continue; } results.push(result); } return results; } /** * Creates a new UrlbarResult from the provided data. * * @param {Array} tokens the search tokens. * @param {object} info includes properties from the legacy result. * @returns {object} an UrlbarResult */ function makeUrlbarResult(tokens, info) { let action = lazy.PlacesUtils.parseActionUrl(info.url); if (action) { switch (action.type) { case "searchengine": // Return a form history result. return new lazy.UrlbarResult( UrlbarUtils.RESULT_TYPE.SEARCH, UrlbarUtils.RESULT_SOURCE.HISTORY, ...lazy.UrlbarResult.payloadAndSimpleHighlights(tokens, { engine: action.params.engineName, suggestion: [ action.params.searchSuggestion, UrlbarUtils.HIGHLIGHT.SUGGESTED, ], lowerCaseSuggestion: action.params.searchSuggestion.toLocaleLowerCase(), }) ); case "switchtab": return new lazy.UrlbarResult( UrlbarUtils.RESULT_TYPE.TAB_SWITCH, UrlbarUtils.RESULT_SOURCE.TABS, ...lazy.UrlbarResult.payloadAndSimpleHighlights(tokens, { url: [action.params.url, UrlbarUtils.HIGHLIGHT.TYPED], title: [info.comment, UrlbarUtils.HIGHLIGHT.TYPED], icon: info.icon, }) ); case "visiturl": return new lazy.UrlbarResult( UrlbarUtils.RESULT_TYPE.URL, UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, ...lazy.UrlbarResult.payloadAndSimpleHighlights(tokens, { title: [info.comment, UrlbarUtils.HIGHLIGHT.TYPED], url: [action.params.url, UrlbarUtils.HIGHLIGHT.TYPED], icon: info.icon, }) ); default: console.error(`Unexpected action type: ${action.type}`); return null; } } // This is a normal url/title tuple. let source; let tags = []; let comment = info.comment; // The legacy autocomplete result may return "bookmark", "bookmark-tag" or // "tag". In the last case it should not be considered a bookmark, but an // history item with tags. We don't show tags for non bookmarked items though. if (info.style.includes("bookmark")) { source = UrlbarUtils.RESULT_SOURCE.BOOKMARKS; } else { source = UrlbarUtils.RESULT_SOURCE.HISTORY; } // If the style indicates that the result is tagged, then the tags are // included in the title, and we must extract them. if (info.style.includes("tag")) { [comment, tags] = info.comment.split(UrlbarUtils.TITLE_TAGS_SEPARATOR); // However, as mentioned above, we don't want to show tags for non- // bookmarked items, so we include tags in the final result only if it's // bookmarked, and we drop the tags otherwise. if (source != UrlbarUtils.RESULT_SOURCE.BOOKMARKS) { tags = ""; } // Tags are separated by a comma and in a random order. // We should also just include tags that match the searchString. tags = tags .split(",") .map(t => t.trim()) .filter(tag => { let lowerCaseTag = tag.toLocaleLowerCase(); return tokens.some(token => lowerCaseTag.includes(token.lowerCaseValue) ); }) .sort(); } return new lazy.UrlbarResult( UrlbarUtils.RESULT_TYPE.URL, source, ...lazy.UrlbarResult.payloadAndSimpleHighlights(tokens, { url: [info.url, UrlbarUtils.HIGHLIGHT.TYPED], icon: info.icon, title: [comment, UrlbarUtils.HIGHLIGHT.TYPED], tags: [tags, UrlbarUtils.HIGHLIGHT.TYPED], }) ); } const MATCH_TYPE = { HEURISTIC: "heuristic", GENERAL: "general", SUGGESTION: "suggestion", EXTENSION: "extension", }; /** * Manages a single instance of a Places search. * * @param {UrlbarQueryContext} queryContext * The query context. * @param {Function} listener * Called as: `listener(matches, searchOngoing)` * @param {PlacesProvider} provider * The singleton that contains Places information */ function Search(queryContext, listener, provider) { // We want to store the original string for case sensitive searches. this._originalSearchString = queryContext.searchString; this._trimmedOriginalSearchString = queryContext.trimmedSearchString; let unescapedSearchString = UrlbarUtils.unEscapeURIForUI( this._trimmedOriginalSearchString ); // We want to make sure "about:" is not stripped as a prefix so that the // about pages provider will run and ultimately only suggest about pages when // a user types "about:" into the address bar. let prefix, suffix; if (unescapedSearchString.startsWith("about:")) { prefix = ""; suffix = unescapedSearchString; } else { [prefix, suffix] = UrlbarUtils.stripURLPrefix(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 ? lazy.UrlbarPrefs.get("defaultBehavior") : this._emptySearchDefaultBehavior; this._inPrivateWindow = queryContext.isPrivate; 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.searchUrlDomain; } this._userContextId = lazy.UrlbarProviderOpenTabs.getUserContextIdForOpenPagesTable( this._userContextId, this._inPrivateWindow ); // Use the original string here, not the stripped one, so the tokenizer can // properly recognize token types. let { tokens } = lazy.UrlbarTokenizer.tokenize({ searchString: unescapedSearchString, trimmedSearchString: unescapedSearchString.trim(), }); // This allows to handle leading or trailing restriction characters specially. this._leadingRestrictionToken = null; if (tokens.length) { if ( lazy.UrlbarTokenizer.isRestrictionToken(tokens[0]) && (tokens.length > 1 || tokens[0].type == lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH) ) { this._leadingRestrictionToken = tokens[0].value; } // Check if the first token has a strippable prefix other than "about:" // and remove it, but don't create an empty token. We preserve "about:" // so that the about pages provider will run and ultimately only suggest // about pages when a user types "about:" into the address bar. if ( prefix && prefix != "about:" && 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 && lazy.sourceToBehaviorMap.has(queryContext.restrictSource) ) { this._behavior = 0; this.setBehavior("restrict"); let behavior = lazy.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. 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 (!lazy.UrlbarPrefs.get("filter.javascript")) { this.setBehavior("javascript"); } this._listener = listener; this._provider = provider; this._matches = []; // These are used to avoid adding duplicate entries to the results. this._usedURLs = []; this._usedPlaceIds = new Set(); // Counters for the number of results per MATCH_TYPE. this._counts = Object.values(MATCH_TYPE).reduce((o, p) => { o[p] = 0; return o; }, {}); } Search.prototype = { /** * Enables the desired AutoComplete behavior. * * @param {string} 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 {string} type * The behavior type to test for. * @returns {boolean} true if the behavior is set, false otherwise. */ hasBehavior(type) { let behavior = Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type.toUpperCase()]; return this._behavior & behavior; }, /** * Given an array of tokens, this function determines which query should be * ran. It also removes any special search tokens. * * @param {Array} tokens * An array of search tokens. * @returns {Array} 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 (!lazy.UrlbarTokenizer.isRestrictionToken(token)) { filtered.push(token); continue; } let behavior = lazy.typeToBehaviorMap.get(token.type); if (!behavior) { throw new Error(`Unknown token type ${token.type}`); } // 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 (typeof this.interrupt == "function") { this.interrupt(); } this.pending = false; }, /** * Whether this search is active. */ pending: true, /** * Execute the search and populate results. * * @param {mozIStorageAsyncConnection} 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 (!lazy.UrlbarProvidersManager.interruptLevel) { conn.interrupt(); } }; // For any given search, we run these queries: // 1) open pages not supported by history (this._switchToTabQuery) // 2) query based on match behavior // If the query is simply "@" and we have tokenAliasEngines then return // early. UrlbarProviderTokenAliasEngines will add engine results. let tokenAliasEngines = await lazy.UrlbarSearchUtils.tokenAliasEngines(); if (this._trimmedOriginalSearchString == "@" && tokenAliasEngines.length) { this._provider.finishSearch(true); return; } // Check if the first token is an action. If it is, we should set a flag // so we don't include it in our searches. this._firstTokenIsKeyword = this._firstTokenIsKeyword || (await this._checkIfFirstTokenIsKeyword()); if (!this.pending) { return; } if (this._trimmedOriginalSearchString) { // If the user typed 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 == lazy.UrlbarTokenizer.RESTRICT.SEARCH && /\s*\S?$/.test(this._trimmedOriginalSearchString); if ( emptySearchRestriction || (tokenAliasEngines.length && this._trimmedOriginalSearchString.startsWith("@")) || (this.hasBehavior("search") && this.hasBehavior("restrict")) ) { this._provider.finishSearch(true); return; } } // Run our standard Places query. 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 do not have enough matches search again with MATCH_ANYWHERE, to // get more matches. let count = this._counts[MATCH_TYPE.GENERAL]; if (count < this._maxResults) { this._matchBehavior = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE; queries = [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; } } } }, async _checkIfFirstTokenIsKeyword() { if (!this._heuristicToken) { return false; } let aliasEngine = await lazy.UrlbarSearchUtils.engineForAlias( this._heuristicToken, this._originalSearchString ); if (aliasEngine) { return true; } let { entry } = await lazy.KeywordUtils.getBindableKeyword( this._heuristicToken, this._originalSearchString ); if (entry) { this._filterOnHost = entry.url.host; return true; } return false; }, _onResultRow(row, cancel) { let queryType = row.getResultByIndex(QUERYINDEX_QUERYTYPE); switch (queryType) { 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[MATCH_TYPE.GENERAL]; 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. * * @param {object} match * The match to maybe restyle. * @returns {boolean} True if the match can be restyled, false otherwise. */ _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 ( !lazy.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 (typeof match.type != "string") { match.type = MATCH_TYPE.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" && (lazy.UrlbarPrefs.get("restyleSearches") || this._searchModeEngine) ) { let restyled = this._maybeRestyleSearchMatch(match); if ( restyled && lazy.UrlbarPrefs.get("maxHistoricalSearchSuggestions") == 0 ) { // The user doesn't want search history. return; } } 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._matches.splice(index, 1); } this._matches.splice(index, 0, match); this._counts[match.type]++; this.notifyResult(true); }, /** * @typedef {object} MatchPositionInformation * @property {number} index * The index the match should take in the results. Return -1 if the match * should be discarded. * @property {boolean} replace * True if the match should replace the result already at * matchPosition.index. */ /** * 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 {object} match * The match to insert. * @returns {MatchPositionInformation} */ _getInsertIndexForMatch(match) { let [urlMapKey, prefix, action] = makeKeyForMatch(match); if ( (match.placeId && this._usedPlaceIds.has(match.placeId)) || this._usedURLs.some(e => lazy.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 } = this._usedURLs[i]; if (lazy.ObjectUtils.deepEqual(matchKey, urlMapKey)) { isDupe = true; 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 the newer one. // 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 } = this._usedURLs[i]; let existingPrefixRank = UrlbarUtils.getPrefixRank(existingPrefix); if (lazy.ObjectUtils.deepEqual(existingKey, urlMapKey)) { isDupe = true; if (prefix == existingPrefix) { // The URLs are identical. Throw out the new result. break; } if (prefix.endsWith("www.") == existingPrefix.endsWith("www.")) { // The results differ only by protocol. if (prefixRank <= existingPrefixRank) { break; // Replace match. } else { this._usedURLs[i] = { key: urlMapKey, action, type: match.type, prefix, comment: match.comment, }; return { index: i, replace: true }; } } 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; if (!this._groups) { this._groups = []; this._makeGroups(lazy.UrlbarPrefs.resultGroups, this._maxResults); } let replace = 0; for (let group of this._groups) { // Move to the next group if the match type is incompatible, or if there // is no available space or if the frecency is below the threshold. if (match.type != group.type || !group.available) { index += group.count; continue; } index += group.insertIndex; group.available--; if (group.insertIndex < group.count) { replace = true; } else { group.count++; } group.insertIndex++; break; } this._usedURLs[index] = { key: urlMapKey, action, type: match.type, prefix, comment: match.comment || "", }; return { index, replace }; }, _makeGroups(resultGroup, maxResultCount) { if (!resultGroup.children) { let type; switch (resultGroup.group) { case UrlbarUtils.RESULT_GROUP.FORM_HISTORY: case UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION: case UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION: type = MATCH_TYPE.SUGGESTION; break; case UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL: case UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION: case UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK: case UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX: case UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP: case UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST: case UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE: type = MATCH_TYPE.HEURISTIC; break; case UrlbarUtils.RESULT_GROUP.OMNIBOX: type = MATCH_TYPE.EXTENSION; break; default: type = MATCH_TYPE.GENERAL; break; } if (this._groups.length) { let last = this._groups[this._groups.length - 1]; if (last.type == type) { return; } } // - `available` is the number of available slots in the group // - `insertIndex` is the index of the first available slot in the group // - `count` is the number of matches in the group, note that it also // accounts for matches from the previous search, while `available` and // `insertIndex` don't. this._groups.push({ type, available: maxResultCount, insertIndex: 0, count: 0, }); return; } let initialMaxResultCount; if (typeof resultGroup.maxResultCount == "number") { initialMaxResultCount = resultGroup.maxResultCount; } else if (typeof resultGroup.availableSpan == "number") { initialMaxResultCount = resultGroup.availableSpan; } else { initialMaxResultCount = this._maxResults; } let childMaxResultCount = Math.min(initialMaxResultCount, maxResultCount); for (let child of resultGroup.children) { this._makeGroups(child, childMaxResultCount); } }, _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: UrlbarUtils.getIconForUrl(url), frecency: frecency || FRECENCY_DEFAULT, }; if (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); }, /** * @returns {string} * 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 (lazy.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("openpage") && (!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 (lazy.UrlbarPrefs.get("suggest.history")) { val |= Ci.mozIPlacesAutoComplete.BEHAVIOR_HISTORY; } else if (lazy.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). * * @returns {Array} * 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: lazy.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. * * @returns {Array} * 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, }, ]; }, // 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. */ _notifyDelaysCount: 0, notifyResult(searchOngoing) { let notify = () => { if (!this.pending) { return; } this._notifyDelaysCount = 0; this._listener(this._matches, searchOngoing); if (!searchOngoing) { // Break possible cycles. this._listener = null; this._provider = 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 (this._notifyDelaysCount > 3) { notify(); } else { this._notifyDelaysCount++; this._notifyTimer = setTimeout(notify, NOTIFYRESULT_DELAY_MS); } }, }; /** * Class used to create the provider. */ class ProviderPlaces extends UrlbarProvider { // Promise resolved when the database initialization has completed, or null // if it has never been requested. _promiseDatabase = null; /** * Returns the name of this provider. * * @returns {string} the name of this provider. */ get name() { return "Places"; } /** * Returns the type of this provider. * * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* */ get type() { return UrlbarUtils.PROVIDER_TYPE.PROFILE; } /** * Gets a Sqlite database handle. * * @returns {Promise} * A connection to the Sqlite database handle (according to {@link Sqlite.sys.mjs}). * @throws A javascript exception */ getDatabaseHandle() { if (!this._promiseDatabase) { this._promiseDatabase = (async () => { let conn = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); // We don't catch exceptions here as it is too late to block shutdown. lazy.Sqlite.shutdown.addBlocker("UrlbarProviderPlaces 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"); this.logger.error(ex); }); } return this._promiseDatabase; } /** * Whether this provider should be invoked for the given context. * If this method returns false, the providers manager won't start a query * with this provider, to save on resources. * * @param {UrlbarQueryContext} queryContext The query context object * @returns {boolean} Whether this provider should be invoked for the search. */ isActive(queryContext) { if ( !queryContext.trimmedSearchString && queryContext.searchMode?.engineName && lazy.UrlbarPrefs.get("update2.emptySearchBehavior") < 2 ) { return false; } return true; } /** * Starts querying. * * @param {object} queryContext The query context object * @param {Function} addCallback Callback invoked by the provider to add a new * result. * @returns {Promise} resolved when the query stops. */ startQuery(queryContext, addCallback) { let instance = this.queryInstance; let urls = new Set(); this._startLegacyQuery(queryContext, matches => { if (instance != this.queryInstance) { return; } let results = convertLegacyMatches(queryContext, matches, urls); for (let result of results) { addCallback(this, result); } }); return this._deferred.promise; } /** * Cancels a running query. * * @param {object} queryContext The query context object */ cancelQuery(queryContext) { 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 {boolean} [notify] * Indicates if we should notify the AutoComplete listener about our * results or not. Default false. */ 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); } onEngagement(isPrivate, state, queryContext, details) { let { result } = details; if (result?.providerName != this.name) { return; } if (details.selType == "dismiss") { switch (result.type) { case UrlbarUtils.RESULT_TYPE.SEARCH: // URL restyled as a search suggestion. Generate the URL and remove it // from browsing history. let { url } = UrlbarUtils.getUrlFromResult(result); lazy.PlacesUtils.history.remove(url).catch(console.error); queryContext.view.controller.removeResult(result); break; case UrlbarUtils.RESULT_TYPE.URL: // Remove browsing history entries from Places. lazy.PlacesUtils.history .remove(result.payload.url) .catch(console.error); queryContext.view.controller.removeResult(result); break; } } } _startLegacyQuery(queryContext, callback) { let deferred = lazy.PromiseUtils.defer(); let listener = (matches, searchOngoing) => { callback(matches); if (!searchOngoing) { deferred.resolve(); } }; this._startSearch(queryContext.searchString, listener, queryContext); this._deferred = deferred; } _startSearch(searchString, listener, queryContext) { // Stop the search in case the controller has not taken care of it. if (this._currentSearch) { this.cancelQuery(); } let search = (this._currentSearch = new Search( queryContext, listener, this )); this.getDatabaseHandle() .then(conn => search.execute(conn)) .catch(ex => { dump(`Query failed: ${ex}\n`); this.logger.error(ex); }) .then(() => { if (search == this._currentSearch) { this.finishSearch(true); } }); } } export var UrlbarProviderPlaces = new ProviderPlaces();