tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

UrlbarProviderPlaces.sys.mjs (55671B)


      1 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
      2 * vim: sw=2 ts=2 sts=2 expandtab
      3 * This Source Code Form is subject to the terms of the Mozilla Public
      4 * License, v. 2.0. If a copy of the MPL was not distributed with this
      5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      6 /* eslint complexity: ["error", 53] */
      7 
      8 /**
      9 * @import {OpenedConnection} from "resource://gre/modules/Sqlite.sys.mjs"
     10 * @import {UrlbarSearchStringTokenData} from "UrlbarTokenizer.sys.mjs"
     11 */
     12 
     13 /**
     14 * This module exports a provider that provides results from the Places
     15 * database, including history, bookmarks, and open tabs.
     16 */
     17 // Constants
     18 
     19 // The default frecency value used when inserting matches with unknown frecency.
     20 const FRECENCY_DEFAULT = 1000;
     21 
     22 // The result is notified on a delay, to avoid rebuilding the panel at every match.
     23 const NOTIFYRESULT_DELAY_MS = 16;
     24 
     25 // This SQL query fragment provides the following:
     26 //   - whether the entry is bookmarked (QUERYINDEX_BOOKMARKED)
     27 //   - the bookmark title, if it is a bookmark (QUERYINDEX_BOOKMARKTITLE)
     28 //   - the tags associated with a bookmarked entry (QUERYINDEX_TAGS)
     29 const SQL_BOOKMARK_TAGS_FRAGMENT = `
     30   EXISTS(SELECT 1 FROM moz_bookmarks WHERE fk = h.id) AS bookmarked,
     31   ( SELECT title FROM moz_bookmarks WHERE fk = h.id AND title NOTNULL
     32     ORDER BY lastModified DESC LIMIT 1
     33   ) AS btitle,
     34   ( SELECT GROUP_CONCAT(t.title ORDER BY t.title)
     35     FROM moz_bookmarks b
     36     JOIN moz_bookmarks t ON t.id = +b.parent AND t.parent = :parent
     37     WHERE b.fk = h.id
     38   ) AS tags`;
     39 
     40 // TODO bug 412736: in case of a frecency tie, we might break it with h.typed
     41 // and h.visit_count.  That is slower though, so not doing it yet...
     42 // NB: as a slight performance optimization, we only evaluate the "bookmarked"
     43 // condition once, and avoid evaluating "btitle" and "tags" when it is false.
     44 function defaultQuery(conditions = "") {
     45  let query = `
     46     SELECT h.url, h.title, ${SQL_BOOKMARK_TAGS_FRAGMENT}, h.id, t.open_count,
     47            ${lazy.PAGES_FRECENCY_FIELD} AS frecency, t.userContextId,
     48            h.last_visit_date, NULLIF(t.groupId, '') groupId
     49     FROM moz_places h
     50     LEFT JOIN moz_openpages_temp t
     51            ON t.url = h.url
     52            AND (t.userContextId = :userContextId OR (t.userContextId <> -1 AND :userContextId IS NULL))
     53     WHERE (
     54        (:switchTabsEnabled AND t.open_count > 0) OR
     55        ${lazy.PAGES_FRECENCY_FIELD} <> 0
     56       )
     57       AND CASE WHEN bookmarked
     58         THEN
     59           AUTOCOMPLETE_MATCH(:searchString, h.url,
     60                              IFNULL(btitle, h.title), tags,
     61                              h.visit_count, h.typed,
     62                              1, t.open_count,
     63                              :matchBehavior, :searchBehavior, NULL)
     64         ELSE
     65           AUTOCOMPLETE_MATCH(:searchString, h.url,
     66                              h.title, '',
     67                              h.visit_count, h.typed,
     68                              0, t.open_count,
     69                              :matchBehavior, :searchBehavior, NULL)
     70         END
     71       ${conditions ? "AND" : ""} ${conditions}
     72     ORDER BY ${lazy.PAGES_FRECENCY_FIELD} DESC, h.id DESC
     73     LIMIT :maxResults`;
     74  return query;
     75 }
     76 
     77 const SQL_SWITCHTAB_QUERY = `
     78    SELECT t.url, t.url AS title, 0 AS bookmarked, NULL AS btitle,
     79           NULL AS tags, NULL AS id, t.open_count, NULL AS frecency,
     80           t.userContextId, NULL AS last_visit_date, NULLIF(t.groupId, '') groupId
     81   FROM moz_openpages_temp t
     82   LEFT JOIN moz_places h ON h.url_hash = hash(t.url) AND h.url = t.url
     83   WHERE h.id IS NULL
     84     AND (t.userContextId = :userContextId OR (t.userContextId <> -1 AND :userContextId IS NULL))
     85     AND AUTOCOMPLETE_MATCH(:searchString, t.url, t.url, NULL,
     86                            NULL, NULL, NULL, t.open_count,
     87                            :matchBehavior, :searchBehavior, NULL)
     88   ORDER BY t.ROWID DESC
     89   LIMIT :maxResults`;
     90 
     91 // Getters
     92 
     93 import {
     94  UrlbarProvider,
     95  UrlbarUtils,
     96 } from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs";
     97 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
     98 
     99 const lazy = XPCOMUtils.declareLazy({
    100  KeywordUtils: "resource://gre/modules/KeywordUtils.sys.mjs",
    101  ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
    102  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
    103  Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
    104  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
    105  UrlbarProviderOpenTabs:
    106    "moz-src:///browser/components/urlbar/UrlbarProviderOpenTabs.sys.mjs",
    107  ProvidersManager:
    108    "moz-src:///browser/components/urlbar/UrlbarProvidersManager.sys.mjs",
    109  UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs",
    110  UrlbarSearchUtils:
    111    "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs",
    112  UrlbarTokenizer:
    113    "moz-src:///browser/components/urlbar/UrlbarTokenizer.sys.mjs",
    114  PAGES_FRECENCY_FIELD: () => {
    115    return lazy.PlacesUtils.history.isAlternativeFrecencyEnabled
    116      ? "alt_frecency"
    117      : "frecency";
    118  },
    119  // Maps restriction character types to textual behaviors.
    120  typeToBehaviorMap: () => {
    121    return /** @type {Map<Values<typeof lazy.UrlbarTokenizer.TYPE>, string>} */ (
    122      new Map([
    123        [lazy.UrlbarTokenizer.TYPE.RESTRICT_HISTORY, "history"],
    124        [lazy.UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, "bookmark"],
    125        [lazy.UrlbarTokenizer.TYPE.RESTRICT_TAG, "tag"],
    126        [lazy.UrlbarTokenizer.TYPE.RESTRICT_OPENPAGE, "openpage"],
    127        [lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH, "search"],
    128        [lazy.UrlbarTokenizer.TYPE.RESTRICT_TITLE, "title"],
    129        [lazy.UrlbarTokenizer.TYPE.RESTRICT_URL, "url"],
    130      ])
    131    );
    132  },
    133  sourceToBehaviorMap: () => {
    134    return /** @type {Map<Values<typeof UrlbarUtils.RESULT_SOURCE>, string>} */ (
    135      new Map([
    136        [UrlbarUtils.RESULT_SOURCE.HISTORY, "history"],
    137        [UrlbarUtils.RESULT_SOURCE.BOOKMARKS, "bookmark"],
    138        [UrlbarUtils.RESULT_SOURCE.TABS, "openpage"],
    139        [UrlbarUtils.RESULT_SOURCE.SEARCH, "search"],
    140      ])
    141    );
    142  },
    143 });
    144 
    145 function setTimeout(callback, ms) {
    146  let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    147  timer.initWithCallback(callback, ms, timer.TYPE_ONE_SHOT);
    148  return timer;
    149 }
    150 
    151 // Helper functions
    152 
    153 /**
    154 * Constructs the map key by joining the url with the userContextId if the pref is
    155 * set. Otherwise, just the url is used
    156 *
    157 * @param   {string} url
    158 *          The url to use
    159 * @param   {object} match
    160 *          The match object with the (optional) userContextId
    161 * @returns {string} map key
    162 */
    163 function makeMapKeyForResult(url, match) {
    164  let action = lazy.PlacesUtils.parseActionUrl(match.value);
    165  return UrlbarUtils.tupleString(
    166    url,
    167    action?.type == "switchtab" &&
    168      lazy.UrlbarPrefs.get("switchTabs.searchAllContainers") &&
    169      lazy.UrlbarProviderOpenTabs.isNonPrivateUserContextId(match.userContextId)
    170      ? match.userContextId
    171      : undefined
    172  );
    173 }
    174 
    175 /**
    176 * Returns the key to be used for a match in a map for the purposes of removing
    177 * duplicate entries - any 2 matches that should be considered the same should
    178 * return the same key.  The type of the returned key depends on the type of the
    179 * match.
    180 *
    181 * @param   {object} match
    182 *          The match object.
    183 * @returns {object} Some opaque key object.  Use ObjectUtils.deepEqual() to
    184 *          compare keys.
    185 */
    186 function makeKeyForMatch(match) {
    187  let key, prefix;
    188  let action = lazy.PlacesUtils.parseActionUrl(match.value);
    189  if (!action) {
    190    [key, prefix] = UrlbarUtils.stripPrefixAndTrim(match.value, {
    191      stripHttp: true,
    192      stripHttps: true,
    193      stripWww: true,
    194      trimSlash: true,
    195      trimEmptyQuery: true,
    196      trimEmptyHash: true,
    197    });
    198    return [makeMapKeyForResult(key, match), prefix, null];
    199  }
    200 
    201  switch (action.type) {
    202    case "searchengine":
    203      // We want to exclude search suggestion matches that simply echo back the
    204      // query string in the heuristic result.  For example, if the user types
    205      // "@engine test", we want to exclude a "test" suggestion match.
    206      key = [
    207        action.type,
    208        action.params.engineName,
    209        (
    210          action.params.searchSuggestion || action.params.searchQuery
    211        ).toLocaleLowerCase(),
    212      ].join(",");
    213      break;
    214    default:
    215      [key, prefix] = UrlbarUtils.stripPrefixAndTrim(
    216        action.params.url || match.value,
    217        {
    218          stripHttp: true,
    219          stripHttps: true,
    220          stripWww: true,
    221          trimEmptyQuery: true,
    222          trimSlash: true,
    223        }
    224      );
    225      break;
    226  }
    227  let resKey = makeMapKeyForResult(key, match);
    228  return [resKey, prefix, action];
    229 }
    230 
    231 /**
    232 * Makes a moz-action url for the given action and set of parameters.
    233 *
    234 * @param   {string} type
    235 *          The action type.
    236 * @param   {object} params
    237 *          A JS object of action params.
    238 * @returns {string} A moz-action url as a string.
    239 */
    240 function makeActionUrl(type, params) {
    241  let encodedParams = {};
    242  for (let key in params) {
    243    // Strip null or undefined.
    244    // Regardless, don't encode them or they would be converted to a string.
    245    if (params[key] === null || params[key] === undefined) {
    246      continue;
    247    }
    248    encodedParams[key] = encodeURIComponent(params[key]);
    249  }
    250  return `moz-action:${type},${JSON.stringify(encodedParams)}`;
    251 }
    252 
    253 /**
    254 * Converts an array of legacy match objects into UrlbarResults.
    255 * Note that at every call we get the full set of results, included the
    256 * previously returned ones, and new results may be inserted in the middle.
    257 * This means we could sort these wrongly, the muxer should take care of it.
    258 *
    259 * @param {UrlbarQueryContext} context the query context.
    260 * @param {Array} matches The match objects.
    261 * @param {Set<string>} urls a Set containing all the found urls, userContextId tuple
    262 *        strings used to discard already added results.
    263 */
    264 function convertLegacyMatches(context, matches, urls) {
    265  /** @type {UrlbarResult[]} */
    266  let results = [];
    267  for (let match of matches) {
    268    // First, let's check if we already added this result.
    269    // `matches` always contains all of the results, includes ones
    270    // we may have added already. This means we'll end up adding things in the
    271    // wrong order here, but that's a task for the UrlbarMuxer.
    272    let url = match.finalCompleteValue || match.value;
    273    if (urls.has(makeMapKeyForResult(url, match))) {
    274      continue;
    275    }
    276    urls.add(makeMapKeyForResult(url, match));
    277    let result = makeUrlbarResult(context, {
    278      url,
    279      // `match.icon` is an empty string if there is no icon. Use undefined
    280      // instead so that tests can be simplified by not including `icon: ""` in
    281      // all their payloads.
    282      icon: match.icon || undefined,
    283      style: match.style,
    284      title: match.comment,
    285      userContextId: match.userContextId,
    286      lastVisit: match.lastVisit,
    287      tabGroup: match.tabGroup,
    288      frecency: match.frecency,
    289    });
    290    // Should not happen, but better safe than sorry.
    291    if (!result) {
    292      continue;
    293    }
    294 
    295    results.push(result);
    296  }
    297  return results;
    298 }
    299 
    300 /**
    301 * Creates a new UrlbarResult from the provided data.
    302 *
    303 * @param {UrlbarQueryContext} queryContext
    304 * @param {object} info
    305 * @param {string} info.url
    306 * @param {string} info.title
    307 * @param {string} info.icon
    308 * @param {number} info.userContextId
    309 * @param {number} info.lastVisit
    310 * @param {number} info.tabGroup
    311 * @param {number} info.frecency
    312 * @param {string} info.style
    313 */
    314 function makeUrlbarResult(queryContext, info) {
    315  let action = lazy.PlacesUtils.parseActionUrl(info.url);
    316  if (action) {
    317    switch (action.type) {
    318      case "searchengine":
    319        // Return a form history result.
    320        return new lazy.UrlbarResult({
    321          type: UrlbarUtils.RESULT_TYPE.SEARCH,
    322          source: UrlbarUtils.RESULT_SOURCE.HISTORY,
    323          payload: {
    324            engine: action.params.engineName,
    325            isBlockable: true,
    326            blockL10n: { id: "urlbar-result-menu-remove-from-history" },
    327            helpUrl:
    328              Services.urlFormatter.formatURLPref("app.support.baseURL") +
    329              "awesome-bar-result-menu",
    330            suggestion: action.params.searchSuggestion,
    331            title: action.params.searchSuggestion,
    332            lowerCaseSuggestion:
    333              action.params.searchSuggestion.toLocaleLowerCase(),
    334          },
    335          highlights: {
    336            suggestion: UrlbarUtils.HIGHLIGHT.SUGGESTED,
    337          },
    338        });
    339      case "switchtab": {
    340        return new lazy.UrlbarResult({
    341          type: UrlbarUtils.RESULT_TYPE.TAB_SWITCH,
    342          source: UrlbarUtils.RESULT_SOURCE.TABS,
    343          payload: {
    344            url: action.params.url,
    345            title: info.title,
    346            icon: info.icon,
    347            userContextId: info.userContextId,
    348            lastVisit: info.lastVisit,
    349            tabGroup: info.tabGroup,
    350            frecency: info.frecency,
    351            action: lazy.UrlbarPrefs.get("secondaryActions.switchToTab")
    352              ? UrlbarUtils.createTabSwitchSecondaryAction(info.userContextId)
    353              : undefined,
    354          },
    355          highlights: {
    356            url: UrlbarUtils.HIGHLIGHT.TYPED,
    357            title: UrlbarUtils.HIGHLIGHT.TYPED,
    358          },
    359        });
    360      }
    361      default:
    362        console.error(`Unexpected action type: ${action.type}`);
    363        return null;
    364    }
    365  }
    366 
    367  // This is a normal url/title tuple.
    368  let source;
    369  let tags = [];
    370  let title = info.title;
    371  let isBlockable;
    372  let blockL10n;
    373  let helpUrl;
    374 
    375  // The legacy autocomplete result may return "bookmark", "bookmark-tag" or
    376  // "tag". In the last case it should not be considered a bookmark, but an
    377  // history item with tags. We don't show tags for non bookmarked items though.
    378  if (info.style.includes("bookmark")) {
    379    source = UrlbarUtils.RESULT_SOURCE.BOOKMARKS;
    380  } else {
    381    source = UrlbarUtils.RESULT_SOURCE.HISTORY;
    382    isBlockable = true;
    383    blockL10n = { id: "urlbar-result-menu-remove-from-history" };
    384    helpUrl =
    385      Services.urlFormatter.formatURLPref("app.support.baseURL") +
    386      "awesome-bar-result-menu";
    387  }
    388 
    389  // If the style indicates that the result is tagged, then the tags are
    390  // included in the title, and we must extract them.
    391  if (info.style.includes("tag")) {
    392    let titleTags;
    393    [title, titleTags] = info.title.split(UrlbarUtils.TITLE_TAGS_SEPARATOR);
    394 
    395    // However, as mentioned above, we don't want to show tags for non-
    396    // bookmarked items, so we include tags in the final result only if it's
    397    // bookmarked, and we drop the tags otherwise.
    398    if (source != UrlbarUtils.RESULT_SOURCE.BOOKMARKS) {
    399      titleTags = "";
    400    }
    401 
    402    // Tags are separated by a comma.
    403    // We should also just include tags that match the searchString.
    404    tags = titleTags.split(",").filter(tag => {
    405      let lowerCaseTag = tag.toLocaleLowerCase();
    406      return queryContext.tokens.some(token =>
    407        lowerCaseTag.includes(token.lowerCaseValue)
    408      );
    409    });
    410  }
    411 
    412  if (!title && info.url) {
    413    try {
    414      // If there's no title, show the domain as the title. Not all valid URLs
    415      // have a domain.
    416      title = new URL(info.url).URI.displayHostPort;
    417    } catch (e) {}
    418  }
    419 
    420  return new lazy.UrlbarResult({
    421    type: UrlbarUtils.RESULT_TYPE.URL,
    422    source,
    423    payload: {
    424      url: info.url,
    425      icon: info.icon,
    426      title,
    427      tags,
    428      isBlockable,
    429      blockL10n,
    430      helpUrl,
    431      lastVisit: info.lastVisit,
    432      frecency: info.frecency,
    433    },
    434    highlights: {
    435      url: UrlbarUtils.HIGHLIGHT.TYPED,
    436      title: UrlbarUtils.HIGHLIGHT.TYPED,
    437      tags: UrlbarUtils.HIGHLIGHT.TYPED,
    438    },
    439  });
    440 }
    441 
    442 const MATCH_TYPE = Object.freeze({
    443  HEURISTIC: "heuristic",
    444  GENERAL: "general",
    445  SUGGESTION: "suggestion",
    446  EXTENSION: "extension",
    447 });
    448 
    449 /**
    450 * Manages a single instance of a Places search.
    451 */
    452 class Search {
    453  /**
    454   *
    455   * @param {UrlbarQueryContext} queryContext
    456   *   The query context.
    457   * @param {Function} listener
    458   *   Called as: `listener(matches, searchOngoing)`
    459   * @param {UrlbarProviderPlaces} provider
    460   *   The UrlbarProviderPlaces instance that started this search.
    461   */
    462  constructor(queryContext, listener, provider) {
    463    // We want to store the original string for case sensitive searches.
    464    this.#originalSearchString = queryContext.searchString;
    465    this.#trimmedOriginalSearchString = queryContext.trimmedSearchString;
    466    let unescapedSearchString = UrlbarUtils.unEscapeURIForUI(
    467      this.#trimmedOriginalSearchString
    468    );
    469    // We want to make sure "about:" is not stripped as a prefix so that the
    470    // about pages provider will run and ultimately only suggest about pages when
    471    // a user types "about:" into the address bar.
    472    let prefix, suffix;
    473    if (unescapedSearchString.startsWith("about:")) {
    474      prefix = "";
    475      suffix = unescapedSearchString;
    476    } else {
    477      [prefix, suffix] = UrlbarUtils.stripURLPrefix(unescapedSearchString);
    478    }
    479    this.#searchString = suffix;
    480 
    481    // Set the default behavior for this search.
    482    this.#behavior = this.#searchString
    483      ? lazy.UrlbarPrefs.get("defaultBehavior")
    484      : this.#emptySearchDefaultBehavior;
    485 
    486    this.#inPrivateWindow = queryContext.isPrivate;
    487    // Increase the limit for the query because some results might
    488    // get deduplicated if their URLs only differ by their refs.
    489    this.#maxResults = Math.round(queryContext.maxResults * 1.5);
    490    this.#userContextId = queryContext.userContextId;
    491    this.#currentPage = queryContext.currentPage;
    492    this.#searchModeEngine = queryContext.searchMode?.engineName;
    493    if (this.#searchModeEngine) {
    494      // Filter Places results on host.
    495      let engine = Services.search.getEngineByName(this.#searchModeEngine);
    496      this.#filterOnHost = engine.searchUrlDomain;
    497    }
    498 
    499    // Use the original string here, not the stripped one, so the tokenizer can
    500    // properly recognize token types.
    501    let tokens = lazy.UrlbarTokenizer.tokenize({
    502      searchString: unescapedSearchString,
    503      trimmedSearchString: unescapedSearchString.trim(),
    504    });
    505 
    506    // This allows to handle leading or trailing restriction characters specially.
    507    this.#leadingRestrictionToken = null;
    508    if (tokens.length) {
    509      if (
    510        lazy.UrlbarTokenizer.isRestrictionToken(tokens[0]) &&
    511        (tokens.length > 1 ||
    512          tokens[0].type == lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH)
    513      ) {
    514        this.#leadingRestrictionToken = tokens[0].value;
    515      }
    516 
    517      // Check if the first token has a strippable prefix other than "about:"
    518      // and remove it, but don't create an empty token. We preserve "about:"
    519      // so that the about pages provider will run and ultimately only suggest
    520      // about pages when a user types "about:" into the address bar.
    521      if (
    522        prefix &&
    523        prefix != "about:" &&
    524        tokens[0].value.length > prefix.length
    525      ) {
    526        tokens[0].value = tokens[0].value.substring(prefix.length);
    527      }
    528    }
    529 
    530    // Eventually filter restriction tokens. In general it's a good idea, but if
    531    // the consumer requested search mode, we should use the full string to avoid
    532    // ignoring valid tokens.
    533    this.#searchTokens =
    534      !queryContext || queryContext.restrictToken
    535        ? this.filterTokens(tokens)
    536        : tokens;
    537 
    538    // The behavior can be set through:
    539    // 1. a specific restrictSource in the QueryContext
    540    // 2. typed restriction tokens
    541    if (
    542      queryContext &&
    543      queryContext.restrictSource &&
    544      lazy.sourceToBehaviorMap.has(queryContext.restrictSource)
    545    ) {
    546      this.#behavior = 0;
    547      this.setBehavior("restrict");
    548      let behavior = lazy.sourceToBehaviorMap.get(queryContext.restrictSource);
    549      this.setBehavior(behavior);
    550 
    551      // When we are in restrict mode, all the tokens are valid for searching, so
    552      // there is no #heuristicToken.
    553      this.#heuristicToken = null;
    554    } else {
    555      // The heuristic token is the first filtered search token, but only when it's
    556      // actually the first thing in the search string.  If a prefix or restriction
    557      // character occurs first, then the heurstic token is null.  We use the
    558      // heuristic token to help determine the heuristic result.
    559      let firstToken =
    560        !!this.#searchTokens.length && this.#searchTokens[0].value;
    561      this.#heuristicToken =
    562        firstToken && this.#trimmedOriginalSearchString.startsWith(firstToken)
    563          ? firstToken
    564          : null;
    565    }
    566 
    567    // Set the right JavaScript behavior based on our preference.  Note that the
    568    // preference is whether or not we should filter JavaScript, and the
    569    // behavior is if we should search it or not.
    570    if (!lazy.UrlbarPrefs.get("filter.javascript")) {
    571      this.setBehavior("javascript");
    572    }
    573 
    574    this.#listener = listener;
    575    this.#provider = provider;
    576    this.#queryContext = queryContext;
    577  }
    578 
    579  /**
    580   * Enables the desired AutoComplete behavior.
    581   *
    582   * @param {string} type
    583   *        The behavior type to set.
    584   */
    585  setBehavior(type) {
    586    type = type.toUpperCase();
    587    this.#behavior |= Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type];
    588  }
    589 
    590  /**
    591   * Determines if the specified AutoComplete behavior is set.
    592   *
    593   * @param {string} type
    594   *        The behavior type to test for.
    595   * @returns {boolean} true if the behavior is set, false otherwise.
    596   */
    597  hasBehavior(type) {
    598    let behavior = Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type.toUpperCase()];
    599    return !!(this.#behavior & behavior);
    600  }
    601 
    602  /**
    603   * Given an array of tokens, this function determines which query should be
    604   * ran.  It also removes any special search tokens.
    605   *
    606   * @param {Array} tokens
    607   *        An array of search tokens.
    608   * @returns {Array} A new, filtered array of tokens.
    609   */
    610  filterTokens(tokens) {
    611    let foundToken = false;
    612    // Set the proper behavior while filtering tokens.
    613    let filtered = [];
    614    for (let token of tokens) {
    615      if (!lazy.UrlbarTokenizer.isRestrictionToken(token)) {
    616        filtered.push(token);
    617        continue;
    618      }
    619      let behavior = lazy.typeToBehaviorMap.get(token.type);
    620      if (!behavior) {
    621        throw new Error(`Unknown token type ${token.type}`);
    622      }
    623      // Don't use the suggest preferences if it is a token search and
    624      // set the restrict bit to 1 (to intersect the search results).
    625      if (!foundToken) {
    626        foundToken = true;
    627        // Do not take into account previous behavior (e.g.: history, bookmark)
    628        this.#behavior = 0;
    629        this.setBehavior("restrict");
    630      }
    631      this.setBehavior(behavior);
    632      // We return tags only for bookmarks, thus when tags are enforced, we
    633      // must also set the bookmark behavior.
    634      if (behavior == "tag") {
    635        this.setBehavior("bookmark");
    636      }
    637    }
    638    return filtered;
    639  }
    640 
    641  /**
    642   * Stop this search.
    643   * After invoking this method, we won't run any more searches or heuristics,
    644   * and no new matches may be added to the current result.
    645   */
    646  stop() {
    647    // Avoid multiple calls or re-entrance.
    648    if (!this.pending) {
    649      return;
    650    }
    651    if (this.#notifyTimer) {
    652      this.#notifyTimer.cancel();
    653    }
    654    this.#notifyDelaysCount = 0;
    655    if (typeof this.#interrupt == "function") {
    656      this.#interrupt();
    657    }
    658    this.pending = false;
    659  }
    660 
    661  /**
    662   * Whether this search is active.
    663   */
    664  pending = true;
    665 
    666  /**
    667   * Execute the search and populate results.
    668   *
    669   * @param {OpenedConnection} conn
    670   *        The Sqlite connection.
    671   */
    672  async execute(conn) {
    673    // A search might be canceled before it starts.
    674    if (!this.pending) {
    675      return;
    676    }
    677 
    678    // Used by stop() to interrupt an eventual running statement.
    679    this.#interrupt = () => {
    680      // Interrupt any ongoing statement to run the search sooner.
    681      if (!lazy.ProvidersManager.interruptLevel) {
    682        conn.interrupt();
    683      }
    684    };
    685 
    686    // For any given search, we run these queries:
    687    // 1) open pages not supported by history (this.#switchToTabQuery)
    688    // 2) query based on match behavior
    689 
    690    // If the query is simply "@" and we have tokenAliasEngines then return
    691    // early. UrlbarProviderTokenAliasEngines will add engine results.
    692    let tokenAliasEngines = await lazy.UrlbarSearchUtils.tokenAliasEngines();
    693    if (this.#trimmedOriginalSearchString == "@" && tokenAliasEngines.length) {
    694      this.#provider.finishSearch(true);
    695      return;
    696    }
    697 
    698    // Check if the first token is an action. If it is, we should set a flag
    699    // so we don't include it in our searches.
    700    this.#firstTokenIsKeyword =
    701      this.#firstTokenIsKeyword || (await this.#checkIfFirstTokenIsKeyword());
    702    if (!this.pending) {
    703      return;
    704    }
    705 
    706    if (this.#trimmedOriginalSearchString) {
    707      // If the user typed the search restriction char or we're in
    708      // search-restriction mode, then we're done.
    709      // UrlbarProviderSearchSuggestions will handle suggestions, if any.
    710      let emptySearchRestriction =
    711        this.#trimmedOriginalSearchString.length <= 3 &&
    712        this.#leadingRestrictionToken == lazy.UrlbarTokenizer.RESTRICT.SEARCH &&
    713        /\s*\S?$/.test(this.#trimmedOriginalSearchString);
    714      if (
    715        emptySearchRestriction ||
    716        (tokenAliasEngines.length &&
    717          this.#trimmedOriginalSearchString.startsWith("@")) ||
    718        (this.hasBehavior("search") && this.hasBehavior("restrict"))
    719      ) {
    720        this.#provider.finishSearch(true);
    721        return;
    722      }
    723    }
    724 
    725    // Run our standard Places query.
    726    let queries = [];
    727    // "openpage" behavior is supported by the default query.
    728    // #switchToTabQuery instead returns only pages not supported by history.
    729    if (this.hasBehavior("openpage")) {
    730      queries.push(this.#switchToTabQuery);
    731    }
    732    queries.push(this.#searchQuery);
    733    for (let [query, params] of queries) {
    734      await conn.executeCached(query, params, this.#onResultRow.bind(this));
    735      if (!this.pending) {
    736        return;
    737      }
    738    }
    739 
    740    // If we do not have enough matches search again with MATCH_ANYWHERE, to
    741    // get more matches.
    742    let count = this.#counts[MATCH_TYPE.GENERAL];
    743    if (count < this.#maxResults) {
    744      this.#matchBehavior = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE;
    745      queries = [this.#searchQuery];
    746      if (this.hasBehavior("openpage")) {
    747        queries.unshift(this.#switchToTabQuery);
    748      }
    749      for (let [query, params] of queries) {
    750        await conn.executeCached(query, params, this.#onResultRow.bind(this));
    751        if (!this.pending) {
    752          return;
    753        }
    754      }
    755    }
    756  }
    757 
    758  /**
    759   * Counters for the number of results per MATCH_TYPE.
    760   */
    761  #counts = Object.values(MATCH_TYPE).reduce((o, p) => {
    762    o[p] = 0;
    763    return o;
    764  }, /** @type {Record<Values<typeof MATCH_TYPE>, number>} */ ({}));
    765 
    766  /**
    767   * @type {number}
    768   *   The default behaviour for this search. This may be a mixture of behaviors.
    769   */
    770  #behavior;
    771  #matchBehavior = Ci.mozIPlacesAutoComplete.MATCH_BOUNDARY;
    772 
    773  #maxResults;
    774 
    775  /**
    776   * The original search string, used for case sensitive searches.
    777   */
    778  #originalSearchString;
    779  #searchString;
    780  #trimmedOriginalSearchString;
    781 
    782  #currentPage;
    783  #filterOnHost;
    784  /** @type {boolean} */
    785  #firstTokenIsKeyword;
    786  #groups;
    787  #heuristicToken;
    788  #inPrivateWindow;
    789  /**
    790   * @type {?() => void}
    791   *   Used to interrupt running queries.
    792   */
    793  #interrupt;
    794  #leadingRestrictionToken;
    795  #listener;
    796  #matches = [];
    797  #provider;
    798  #searchModeEngine;
    799  #searchTokens;
    800  #userContextId;
    801  #queryContext;
    802 
    803  /**
    804   * Used to avoid adding duplicate entries to the results.
    805   */
    806  #usedURLs = [];
    807 
    808  /**
    809   * Used to avoid adding duplicate entries to the results.
    810   */
    811  #usedPlaceIds = new Set();
    812 
    813  async #checkIfFirstTokenIsKeyword() {
    814    if (!this.#heuristicToken) {
    815      return false;
    816    }
    817 
    818    let aliasEngine = await lazy.UrlbarSearchUtils.engineForAlias(
    819      this.#heuristicToken,
    820      this.#originalSearchString
    821    );
    822 
    823    if (aliasEngine) {
    824      return true;
    825    }
    826 
    827    let { entry } = await lazy.KeywordUtils.getBindableKeyword(
    828      this.#heuristicToken,
    829      this.#originalSearchString
    830    );
    831    if (entry) {
    832      this.#filterOnHost = entry.url.host;
    833      return true;
    834    }
    835 
    836    return false;
    837  }
    838 
    839  #onResultRow(row, cancel) {
    840    this.#addFilteredQueryMatch(row);
    841 
    842    // If the search has been canceled by the user or by #addMatch, or we
    843    // fetched enough results, we can stop the underlying Sqlite query.
    844    let count = this.#counts[MATCH_TYPE.GENERAL];
    845    if (!this.pending || count >= this.#maxResults) {
    846      cancel();
    847    }
    848  }
    849 
    850  /**
    851   * Maybe restyle a SERP in history as a search-type result. To do this,
    852   * we extract the search term from the SERP in history then generate a search
    853   * URL with that search term. We restyle the SERP in history if its query
    854   * parameters are a subset of those of the generated SERP. We check for a
    855   * subset instead of exact equivalence since the generated URL may contain
    856   * attribution parameters while a SERP in history from an organic search would
    857   * not. We don't allow extra params in the history URL since they might
    858   * indicate the search is not a first-page web SERP (as opposed to a image or
    859   * other non-web SERP).
    860   *
    861   * Note: We will mistakenly dedupe SERPs for engines that have the same
    862   *   hostname as another engine. One example is if the user installed a
    863   *   Google Image Search engine. That engine's search URLs might only be
    864   *   distinguished by query params from search URLs from the default Google
    865   *   engine.
    866   *
    867   * @param {object} match
    868   *   The match to maybe restyle.
    869   * @returns {boolean} True if the match can be restyled, false otherwise.
    870   */
    871  #maybeRestyleSearchMatch(match) {
    872    // Return if the URL does not represent a search result.
    873    let historyUrl = match.value;
    874    let parseResult = Services.search.parseSubmissionURL(historyUrl);
    875    if (!parseResult?.engine) {
    876      return false;
    877    }
    878 
    879    // Here we check that the user typed all or part of the search string in the
    880    // search history result.
    881    let terms = parseResult.terms.toLowerCase();
    882    if (
    883      this.#searchTokens.length &&
    884      this.#searchTokens.every(token => !terms.includes(token.value))
    885    ) {
    886      return false;
    887    }
    888 
    889    // The URL for the search suggestion formed by the user's typed query.
    890    let [generatedSuggestionUrl] = UrlbarUtils.getSearchQueryUrl(
    891      parseResult.engine,
    892      this.#searchTokens.map(t => t.value).join(" ")
    893    );
    894 
    895    // We ignore termsParameterName when checking for a subset because we
    896    // already checked that the typed query is a subset of the search history
    897    // query above with this.#searchTokens.every(...).
    898    if (
    899      !lazy.UrlbarSearchUtils.serpsAreEquivalent(
    900        historyUrl,
    901        generatedSuggestionUrl,
    902        [parseResult.termsParameterName]
    903      )
    904    ) {
    905      return false;
    906    }
    907 
    908    // Turn the match into a searchengine action with a favicon.
    909    match.value = makeActionUrl("searchengine", {
    910      engineName: parseResult.engine.name,
    911      input: parseResult.terms,
    912      searchSuggestion: parseResult.terms,
    913      searchQuery: parseResult.terms,
    914      isSearchHistory: true,
    915    });
    916    match.comment = parseResult.engine.name;
    917    match.icon = match.icon || match.iconUrl;
    918    match.style = "action searchengine favicon suggestion";
    919    return true;
    920  }
    921 
    922  #addMatch(match) {
    923    if (typeof match.frecency != "number") {
    924      throw new Error("Frecency not provided");
    925    }
    926 
    927    if (typeof match.type != "string") {
    928      match.type = MATCH_TYPE.GENERAL;
    929    }
    930 
    931    // A search could be canceled between a query start and its completion,
    932    // in such a case ensure we won't notify any result for it.
    933    if (!this.pending) {
    934      return;
    935    }
    936 
    937    match.style = match.style || "favicon";
    938 
    939    // Restyle past searches, unless they are bookmarks or special results.
    940    if (
    941      match.style == "favicon" &&
    942      (lazy.UrlbarPrefs.get("restyleSearches") || this.#searchModeEngine)
    943    ) {
    944      let restyled = this.#maybeRestyleSearchMatch(match);
    945      if (
    946        restyled &&
    947        lazy.UrlbarPrefs.get("maxHistoricalSearchSuggestions") == 0
    948      ) {
    949        // The user doesn't want search history.
    950        return;
    951      }
    952    }
    953 
    954    match.icon = match.icon || "";
    955    match.finalCompleteValue = match.finalCompleteValue || "";
    956 
    957    let { index, replace } = this.#getInsertIndexForMatch(match);
    958    if (index == -1) {
    959      return;
    960    }
    961    if (replace) {
    962      // Replacing an existing match from the previous search.
    963      this.#matches.splice(index, 1);
    964    }
    965    this.#matches.splice(index, 0, match);
    966    this.#counts[match.type]++;
    967 
    968    this.notifyResult(true);
    969  }
    970 
    971  /**
    972   * @typedef {object} MatchPositionInformation
    973   * @property {number} index
    974   *   The index the match should take in the results. Return -1 if the match
    975   *   should be discarded.
    976   * @property {boolean} replace
    977   *   True if the match should replace the result already at
    978   *   matchPosition.index.
    979   */
    980 
    981  /**
    982   * Check for duplicates and either discard the duplicate or replace the
    983   * original match, in case the new one is more specific. For example,
    984   * a Remote Tab wins over History, and a Switch to Tab wins over a Remote Tab.
    985   * We must check both id and url for duplication, because keywords may change
    986   * the url by replacing the %s placeholder.
    987   *
    988   * @param {object} match
    989   *   The match to insert.
    990   * @returns {MatchPositionInformation}
    991   */
    992  #getInsertIndexForMatch(match) {
    993    let [urlMapKey, prefix, action] = makeKeyForMatch(match);
    994    if (
    995      (match.placeId &&
    996        this.#usedPlaceIds.has(makeMapKeyForResult(match.placeId, match))) ||
    997      this.#usedURLs.some(e => lazy.ObjectUtils.deepEqual(e.key, urlMapKey))
    998    ) {
    999      let isDupe = true;
   1000      if (action && ["switchtab", "remotetab"].includes(action.type)) {
   1001        // The new entry is a switch/remote tab entry, look for the duplicate
   1002        // among current matches.
   1003        for (let i = 0; i < this.#usedURLs.length; ++i) {
   1004          let { key: matchKey, action: matchAction } = this.#usedURLs[i];
   1005          if (lazy.ObjectUtils.deepEqual(matchKey, urlMapKey)) {
   1006            isDupe = true;
   1007            if (!matchAction || action.type == "switchtab") {
   1008              this.#usedURLs[i] = {
   1009                key: urlMapKey,
   1010                action,
   1011                type: match.type,
   1012                prefix,
   1013                comment: match.comment,
   1014              };
   1015              return { index: i, replace: true };
   1016            }
   1017            break; // Found the duplicate, no reason to continue.
   1018          }
   1019        }
   1020      } else {
   1021        // Dedupe with this flow:
   1022        // 1. If the two URLs are the same, dedupe the newer one.
   1023        // 2. If they both contain www. or both do not contain it, prefer https.
   1024        // 3. If they differ by www., send both results to the Muxer and allow
   1025        //    it to decide based on results from other providers.
   1026        let prefixRank = UrlbarUtils.getPrefixRank(prefix);
   1027        for (let i = 0; i < this.#usedURLs.length; ++i) {
   1028          if (!this.#usedURLs[i]) {
   1029            // This is true when the result at [i] is a searchengine result.
   1030            continue;
   1031          }
   1032 
   1033          let { key: existingKey, prefix: existingPrefix } = this.#usedURLs[i];
   1034 
   1035          let existingPrefixRank = UrlbarUtils.getPrefixRank(existingPrefix);
   1036          if (lazy.ObjectUtils.deepEqual(existingKey, urlMapKey)) {
   1037            isDupe = true;
   1038 
   1039            if (prefix == existingPrefix) {
   1040              // The URLs are identical. Throw out the new result.
   1041              break;
   1042            }
   1043 
   1044            if (prefix.endsWith("www.") == existingPrefix.endsWith("www.")) {
   1045              // The results differ only by protocol.
   1046              if (prefixRank <= existingPrefixRank) {
   1047                break; // Replace match.
   1048              } else {
   1049                this.#usedURLs[i] = {
   1050                  key: urlMapKey,
   1051                  action,
   1052                  type: match.type,
   1053                  prefix,
   1054                  comment: match.comment,
   1055                };
   1056                return { index: i, replace: true };
   1057              }
   1058            } else {
   1059              // We have two identical URLs that differ only by www. We need to
   1060              // be sure what the heuristic result is before deciding how we
   1061              // should dedupe. We mark these as non-duplicates and let the
   1062              // muxer handle it.
   1063              isDupe = false;
   1064              continue;
   1065            }
   1066          }
   1067        }
   1068      }
   1069 
   1070      // Discard the duplicate.
   1071      if (isDupe) {
   1072        return { index: -1, replace: false };
   1073      }
   1074    }
   1075 
   1076    // Add this to our internal tracker to ensure duplicates do not end up in
   1077    // the result.
   1078    // Not all entries have a place id, thus we fallback to the url for them.
   1079    // We cannot use only the url since keywords entries are modified to
   1080    // include the search string, and would be returned multiple times.  Ids
   1081    // are faster too.
   1082    if (match.placeId) {
   1083      this.#usedPlaceIds.add(makeMapKeyForResult(match.placeId, match));
   1084    }
   1085 
   1086    let index = 0;
   1087    if (!this.#groups) {
   1088      this.#groups = [];
   1089      this.#makeGroups(
   1090        lazy.UrlbarPrefs.getResultGroups({ context: this.#queryContext }),
   1091        this.#maxResults
   1092      );
   1093    }
   1094 
   1095    let replace = false;
   1096    for (let group of this.#groups) {
   1097      // Move to the next group if the match type is incompatible, or if there
   1098      // is no available space or if the frecency is below the threshold.
   1099      if (match.type != group.type || !group.available) {
   1100        index += group.count;
   1101        continue;
   1102      }
   1103 
   1104      index += group.insertIndex;
   1105      group.available--;
   1106      if (group.insertIndex < group.count) {
   1107        replace = true;
   1108      } else {
   1109        group.count++;
   1110      }
   1111      group.insertIndex++;
   1112      break;
   1113    }
   1114    this.#usedURLs[index] = {
   1115      key: urlMapKey,
   1116      action,
   1117      type: match.type,
   1118      prefix,
   1119      comment: match.comment || "",
   1120    };
   1121    return { index, replace };
   1122  }
   1123 
   1124  #makeGroups(resultGroup, maxResultCount) {
   1125    if (!resultGroup.children) {
   1126      let type;
   1127      switch (resultGroup.group) {
   1128        case UrlbarUtils.RESULT_GROUP.FORM_HISTORY:
   1129        case UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION:
   1130        case UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION:
   1131          type = MATCH_TYPE.SUGGESTION;
   1132          break;
   1133        case UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL:
   1134        case UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION:
   1135        case UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK:
   1136        case UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX:
   1137        case UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP:
   1138        case UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST:
   1139        case UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE:
   1140          type = MATCH_TYPE.HEURISTIC;
   1141          break;
   1142        case UrlbarUtils.RESULT_GROUP.OMNIBOX:
   1143          type = MATCH_TYPE.EXTENSION;
   1144          break;
   1145        default:
   1146          type = MATCH_TYPE.GENERAL;
   1147          break;
   1148      }
   1149      if (this.#groups.length) {
   1150        let last = this.#groups[this.#groups.length - 1];
   1151        if (last.type == type) {
   1152          return;
   1153        }
   1154      }
   1155      // - `available` is the number of available slots in the group
   1156      // - `insertIndex` is the index of the first available slot in the group
   1157      // - `count` is the number of matches in the group, note that it also
   1158      //   accounts for matches from the previous search, while `available` and
   1159      //   `insertIndex` don't.
   1160      this.#groups.push({
   1161        type,
   1162        available: maxResultCount,
   1163        insertIndex: 0,
   1164        count: 0,
   1165      });
   1166      return;
   1167    }
   1168 
   1169    let initialMaxResultCount;
   1170    if (typeof resultGroup.maxResultCount == "number") {
   1171      initialMaxResultCount = resultGroup.maxResultCount;
   1172    } else if (typeof resultGroup.availableSpan == "number") {
   1173      initialMaxResultCount = resultGroup.availableSpan;
   1174    } else {
   1175      initialMaxResultCount = this.#maxResults;
   1176    }
   1177    let childMaxResultCount = Math.min(initialMaxResultCount, maxResultCount);
   1178    for (let child of resultGroup.children) {
   1179      this.#makeGroups(child, childMaxResultCount);
   1180    }
   1181  }
   1182 
   1183  #addFilteredQueryMatch(row) {
   1184    let placeId = row.getResultByName("id");
   1185    let url = row.getResultByName("url");
   1186    let openPageCount = row.getResultByName("open_count") || 0;
   1187    let historyTitle = row.getResultByName("title") || "";
   1188    let bookmarked = row.getResultByName("bookmarked");
   1189    let bookmarkTitle = bookmarked ? row.getResultByName("btitle") : null;
   1190    let tags = row.getResultByName("tags") || "";
   1191    let frecency = row.getResultByName("frecency");
   1192    let userContextId = row.getResultByName("userContextId");
   1193    let lastVisitPRTime = row.getResultByName("last_visit_date");
   1194    let lastVisit = lastVisitPRTime
   1195      ? lazy.PlacesUtils.toDate(lastVisitPRTime).getTime()
   1196      : undefined;
   1197    let tabGroup = row.getResultByName("groupId");
   1198 
   1199    let match = {
   1200      placeId,
   1201      value: url,
   1202      comment: bookmarkTitle || historyTitle,
   1203      icon: UrlbarUtils.getIconForUrl(url),
   1204      frecency: frecency || FRECENCY_DEFAULT,
   1205      userContextId,
   1206      lastVisit,
   1207      tabGroup,
   1208    };
   1209    if (openPageCount > 0 && this.hasBehavior("openpage")) {
   1210      if (
   1211        this.#currentPage == match.value &&
   1212        (!lazy.UrlbarPrefs.get("switchTabs.searchAllContainers") ||
   1213          this.#userContextId == match.userContextId)
   1214      ) {
   1215        // Don't suggest switching to the current tab.
   1216        return;
   1217      }
   1218      // Actions are enabled and the page is open.  Add a switch-to-tab result.
   1219      match.value = makeActionUrl("switchtab", { url: match.value });
   1220      match.style = "action switchtab";
   1221    } else if (
   1222      this.hasBehavior("history") &&
   1223      !this.hasBehavior("bookmark") &&
   1224      !tags
   1225    ) {
   1226      // The consumer wants only history and not bookmarks and there are no
   1227      // tags.  We'll act as if the page is not bookmarked.
   1228      match.style = "favicon";
   1229    } else if (tags) {
   1230      // Store the tags in the title.  It's up to the consumer to extract them.
   1231      match.comment += UrlbarUtils.TITLE_TAGS_SEPARATOR + tags;
   1232      // If we're not suggesting bookmarks, then this shouldn't display as one.
   1233      match.style = this.hasBehavior("bookmark") ? "bookmark-tag" : "tag";
   1234    } else if (bookmarked) {
   1235      match.style = "bookmark";
   1236    }
   1237 
   1238    this.#addMatch(match);
   1239  }
   1240 
   1241  /**
   1242   * @returns {string}
   1243   * A string consisting of the search query to be used based on the previously
   1244   * set urlbar suggestion preferences.
   1245   */
   1246  get #suggestionPrefQuery() {
   1247    let conditions = [];
   1248    if (this.#filterOnHost) {
   1249      conditions.push("h.rev_host = get_unreversed_host(:host || '.') || '.'");
   1250      // When filtering on a host we are in some sort of site specific search,
   1251      // thus we want a cleaner set of results, compared to a general search.
   1252      // This means removing less interesting urls, like redirects or
   1253      // non-bookmarked title-less pages.
   1254 
   1255      if (lazy.UrlbarPrefs.get("restyleSearches") || this.#searchModeEngine) {
   1256        // If restyle is enabled, we want to filter out redirect targets,
   1257        // because sources are urls built using search engines definitions that
   1258        // we can reverse-parse.
   1259        // In this case we can't filter on title-less pages because redirect
   1260        // sources likely don't have a title and recognizing sources is costly.
   1261        // Bug 468710 may help with this.
   1262        conditions.push(`NOT EXISTS (
   1263          WITH visits(type) AS (
   1264            SELECT visit_type
   1265            FROM moz_historyvisits
   1266            WHERE place_id = h.id
   1267            ORDER BY visit_date DESC
   1268            LIMIT 10 /* limit to the last 10 visits */
   1269          )
   1270          SELECT 1 FROM visits
   1271          WHERE type IN (5,6)
   1272        )`);
   1273      } else {
   1274        // If instead restyle is disabled, we want to keep redirect targets,
   1275        // because sources are often unreadable title-less urls.
   1276        conditions.push(`NOT EXISTS (
   1277          WITH visits(id) AS (
   1278            SELECT id
   1279            FROM moz_historyvisits
   1280            WHERE place_id = h.id
   1281            ORDER BY visit_date DESC
   1282            LIMIT 10 /* limit to the last 10 visits */
   1283            )
   1284           SELECT 1
   1285           FROM visits src
   1286           JOIN moz_historyvisits dest ON src.id = dest.from_visit
   1287           WHERE dest.visit_type IN (5,6)
   1288        )`);
   1289        // Filter out empty-titled pages, they could be redirect sources that
   1290        // we can't recognize anymore because their target was wrongly expired
   1291        // due to Bug 1664252.
   1292        conditions.push("(h.foreign_count > 0 OR h.title NOTNULL)");
   1293      }
   1294    }
   1295 
   1296    if (
   1297      this.hasBehavior("restrict") ||
   1298      (!this.hasBehavior("openpage") &&
   1299        (!this.hasBehavior("history") || !this.hasBehavior("bookmark")))
   1300    ) {
   1301      if (this.hasBehavior("history")) {
   1302        // Enforce ignoring the visit_count index, since the frecency one is much
   1303        // faster in this case.  ANALYZE helps the query planner to figure out the
   1304        // faster path, but it may not have up-to-date information yet.
   1305        conditions.push("+h.visit_count > 0");
   1306      }
   1307      if (this.hasBehavior("bookmark")) {
   1308        conditions.push("bookmarked");
   1309      }
   1310      if (this.hasBehavior("tag")) {
   1311        conditions.push("tags NOTNULL");
   1312      }
   1313    }
   1314 
   1315    return defaultQuery(conditions.join(" AND "));
   1316  }
   1317 
   1318  get #emptySearchDefaultBehavior() {
   1319    // Further restrictions to apply for "empty searches" (searching for
   1320    // "").  The empty behavior is typed history, if history is enabled.
   1321    // Otherwise, it is bookmarks, if they are enabled. If both history and
   1322    // bookmarks are disabled, it defaults to open pages.
   1323    let val = Ci.mozIPlacesAutoComplete.BEHAVIOR_RESTRICT;
   1324    if (lazy.UrlbarPrefs.get("suggest.history")) {
   1325      val |= Ci.mozIPlacesAutoComplete.BEHAVIOR_HISTORY;
   1326    } else if (lazy.UrlbarPrefs.get("suggest.bookmark")) {
   1327      val |= Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK;
   1328    } else {
   1329      val |= Ci.mozIPlacesAutoComplete.BEHAVIOR_OPENPAGE;
   1330    }
   1331    return val;
   1332  }
   1333 
   1334  /**
   1335   * If the user-provided string starts with a keyword that gave a heuristic
   1336   * result, this will strip it.
   1337   *
   1338   * @returns {string} The filtered search string.
   1339   */
   1340  get #keywordFilteredSearchString() {
   1341    let tokens = this.#searchTokens.map(t => t.value);
   1342    if (this.#firstTokenIsKeyword) {
   1343      tokens = tokens.slice(1);
   1344    }
   1345    return tokens.join(" ");
   1346  }
   1347 
   1348  /**
   1349   * Obtains the search query to be used based on the previously set search
   1350   * preferences (accessed by this.hasBehavior).
   1351   *
   1352   * @returns {Array}
   1353   *   An array consisting of the correctly optimized query to search the
   1354   *   database with and an object containing the params to bound.
   1355   */
   1356  get #searchQuery() {
   1357    let params = {
   1358      parent: lazy.PlacesUtils.tagsFolderId,
   1359      matchBehavior: this.#matchBehavior,
   1360      searchBehavior: this.#behavior,
   1361      // We only want to search the tokens that we are left with - not the
   1362      // original search string.
   1363      searchString: this.#keywordFilteredSearchString,
   1364      // Limit the query to the the maximum number of desired results.
   1365      // This way we can avoid doing more work than needed.
   1366      maxResults: this.#maxResults,
   1367      switchTabsEnabled: this.hasBehavior("openpage"),
   1368    };
   1369    params.userContextId = lazy.UrlbarPrefs.get(
   1370      "switchTabs.searchAllContainers"
   1371    )
   1372      ? lazy.UrlbarProviderOpenTabs.getUserContextIdForOpenPagesTable(
   1373          null,
   1374          this.#inPrivateWindow
   1375        )
   1376      : this.#userContextId;
   1377 
   1378    if (this.#filterOnHost) {
   1379      params.host = this.#filterOnHost;
   1380    }
   1381    return [this.#suggestionPrefQuery, params];
   1382  }
   1383 
   1384  /**
   1385   * Obtains the query to search for switch-to-tab entries.
   1386   *
   1387   * @returns {Array}
   1388   *   An array consisting of the correctly optimized query to search the
   1389   *   database with and an object containing the params to bound.
   1390   */
   1391  get #switchToTabQuery() {
   1392    return [
   1393      SQL_SWITCHTAB_QUERY,
   1394      {
   1395        matchBehavior: this.#matchBehavior,
   1396        searchBehavior: this.#behavior,
   1397        // We only want to search the tokens that we are left with - not the
   1398        // original search string.
   1399        searchString: this.#keywordFilteredSearchString,
   1400        userContextId: lazy.UrlbarPrefs.get("switchTabs.searchAllContainers")
   1401          ? lazy.UrlbarProviderOpenTabs.getUserContextIdForOpenPagesTable(
   1402              null,
   1403              this.#inPrivateWindow
   1404            )
   1405          : this.#userContextId,
   1406        maxResults: this.#maxResults,
   1407      },
   1408    ];
   1409  }
   1410 
   1411  /**
   1412   * The result is notified to the search listener on a timer, to chunk multiple
   1413   * match updates together and avoid rebuilding the popup at every new match.
   1414   *
   1415   * @type {?nsITimer}
   1416   */
   1417  #notifyTimer = null;
   1418 
   1419  #notifyDelaysCount = 0;
   1420 
   1421  /**
   1422   * Notifies the current result to the listener.
   1423   *
   1424   * @param {boolean} searchOngoing
   1425   *   Indicates whether the search result should be marked as ongoing.
   1426   */
   1427  notifyResult(searchOngoing) {
   1428    let notify = () => {
   1429      if (!this.pending) {
   1430        return;
   1431      }
   1432      this.#notifyDelaysCount = 0;
   1433      this.#listener(this.#matches, searchOngoing);
   1434      if (!searchOngoing) {
   1435        // Break possible cycles.
   1436        this.#listener = null;
   1437        this.#provider = null;
   1438        this.stop();
   1439      }
   1440    };
   1441    if (this.#notifyTimer) {
   1442      this.#notifyTimer.cancel();
   1443    }
   1444    // In the worst case, we may get evenly spaced matches that would end up
   1445    // delaying the UI by N#MATCHES * NOTIFYRESULT_DELAY_MS. Thus, we clamp the
   1446    // number of times we may delay matches.
   1447    if (this.#notifyDelaysCount > 3) {
   1448      notify();
   1449    } else {
   1450      this.#notifyDelaysCount++;
   1451      this.#notifyTimer = setTimeout(notify, NOTIFYRESULT_DELAY_MS);
   1452    }
   1453  }
   1454 }
   1455 
   1456 /**
   1457 * Promise resolved when the database initialization has completed, or null
   1458 * if it has never been requested. This is shared between all instances.
   1459 *
   1460 * @type {?Promise<OpenedConnection>}
   1461 */
   1462 let _promiseDatabase = null;
   1463 
   1464 /**
   1465 * Class used to create the provider.
   1466 */
   1467 export class UrlbarProviderPlaces extends UrlbarProvider {
   1468  /** @type {?PromiseWithResolvers<void>} */
   1469  #deferred = null;
   1470  /** @type {?Search} */
   1471  #currentSearch = null;
   1472 
   1473  /**
   1474   * @returns {Values<typeof UrlbarUtils.PROVIDER_TYPE>}
   1475   */
   1476  get type() {
   1477    return UrlbarUtils.PROVIDER_TYPE.PROFILE;
   1478  }
   1479 
   1480  /**
   1481   * Gets a Sqlite database handle.
   1482   *
   1483   * @returns {Promise<OpenedConnection>}
   1484   *   A connection to the Sqlite database handle (according to {@link Sqlite.sys.mjs}).
   1485   * @throws A javascript exception
   1486   */
   1487  getDatabaseHandle() {
   1488    if (!_promiseDatabase) {
   1489      _promiseDatabase = (async () => {
   1490        let conn = await lazy.PlacesUtils.promiseLargeCacheDBConnection();
   1491 
   1492        // We don't catch exceptions here as it is too late to block shutdown.
   1493        lazy.Sqlite.shutdown.addBlocker("UrlbarProviderPlaces closing", () => {
   1494          // Break a possible cycle through the
   1495          // previous result, the controller and
   1496          // ourselves.
   1497          this.#currentSearch = null;
   1498        });
   1499 
   1500        return conn;
   1501      })().catch(ex => {
   1502        dump("Couldn't get database handle: " + ex + "\n");
   1503        this.logger.error(ex);
   1504      });
   1505    }
   1506    return _promiseDatabase;
   1507  }
   1508 
   1509  /**
   1510   * Whether this provider should be invoked for the given context.
   1511   * If this method returns false, the providers manager won't start a query
   1512   * with this provider, to save on resources.
   1513   *
   1514   * @param {UrlbarQueryContext} queryContext The query context object
   1515   */
   1516  async isActive(queryContext) {
   1517    if (
   1518      !queryContext.trimmedSearchString &&
   1519      queryContext.searchMode?.engineName
   1520    ) {
   1521      return false;
   1522    }
   1523    return true;
   1524  }
   1525 
   1526  /**
   1527   * Starts querying.
   1528   *
   1529   * @param {UrlbarQueryContext} queryContext
   1530   * @param {(provider: UrlbarProvider, result: UrlbarResult) => void} addCallback
   1531   *   Callback invoked by the provider to add a new result.
   1532   */
   1533  startQuery(queryContext, addCallback) {
   1534    let instance = this.queryInstance;
   1535    let urls = new Set();
   1536    this.#startLegacyQuery(queryContext, matches => {
   1537      if (instance != this.queryInstance) {
   1538        return;
   1539      }
   1540      let results = convertLegacyMatches(queryContext, matches, urls);
   1541      for (let result of results) {
   1542        addCallback(this, result);
   1543      }
   1544    });
   1545    return this.#deferred.promise;
   1546  }
   1547 
   1548  /**
   1549   * Cancels a running query.
   1550   */
   1551  cancelQuery() {
   1552    if (this.#currentSearch) {
   1553      this.#currentSearch.stop();
   1554    }
   1555    if (this.#deferred) {
   1556      this.#deferred.resolve();
   1557    }
   1558    // Don't notify since we are canceling this search.  This also means we
   1559    // won't fire onSearchComplete for this search.
   1560    this.finishSearch();
   1561  }
   1562 
   1563  /**
   1564   * Properly cleans up when searching is completed.
   1565   *
   1566   * @param {boolean} [notify]
   1567   *        Indicates if we should notify the AutoComplete listener about our
   1568   *        results or not. Default false.
   1569   */
   1570  finishSearch(notify = false) {
   1571    // Clear state now to avoid race conditions, see below.
   1572    let search = this.#currentSearch;
   1573    if (!search) {
   1574      return;
   1575    }
   1576 
   1577    if (!notify || !search.pending) {
   1578      return;
   1579    }
   1580 
   1581    // There is a possible race condition here.
   1582    // When a search completes it calls finishSearch that notifies results
   1583    // here.  When the controller gets the last result it fires
   1584    // onSearchComplete.
   1585    // If onSearchComplete immediately starts a new search it will set a new
   1586    // _currentSearch, and on return the execution will continue here, after
   1587    // notifyResult.
   1588    // Thus, ensure that notifyResult is the last call in this method,
   1589    // otherwise you might be touching the wrong search.
   1590    search.notifyResult(false);
   1591  }
   1592 
   1593  onEngagement(queryContext, controller, details) {
   1594    let { result } = details;
   1595    if (details.selType == "dismiss") {
   1596      switch (result.type) {
   1597        case UrlbarUtils.RESULT_TYPE.SEARCH: {
   1598          // URL restyled as a search suggestion. Generate the URL and remove it
   1599          // from browsing history.
   1600          let { url } = UrlbarUtils.getUrlFromResult(result);
   1601          lazy.PlacesUtils.history.remove(url).catch(console.error);
   1602          controller.removeResult(result);
   1603          break;
   1604        }
   1605        case UrlbarUtils.RESULT_TYPE.URL:
   1606          // Remove browsing history entries from Places.
   1607          lazy.PlacesUtils.history
   1608            .remove(result.payload.url)
   1609            .catch(console.error);
   1610          controller.removeResult(result);
   1611          break;
   1612      }
   1613    }
   1614  }
   1615 
   1616  #startLegacyQuery(queryContext, callback) {
   1617    let deferred = Promise.withResolvers();
   1618    let listener = (matches, searchOngoing) => {
   1619      callback(matches);
   1620      if (!searchOngoing) {
   1621        deferred.resolve();
   1622      }
   1623    };
   1624    this.#startSearch(queryContext.searchString, listener, queryContext);
   1625    this.#deferred = deferred;
   1626  }
   1627 
   1628  #startSearch(searchString, listener, queryContext) {
   1629    // Stop the search in case the controller has not taken care of it.
   1630    if (this.#currentSearch) {
   1631      this.cancelQuery();
   1632    }
   1633 
   1634    let search = (this.#currentSearch = new Search(
   1635      queryContext,
   1636      listener,
   1637      this
   1638    ));
   1639    this.getDatabaseHandle()
   1640      .then(conn => search.execute(conn))
   1641      .catch(ex => {
   1642        dump(`Query failed: ${ex}\n`);
   1643        this.logger.error(ex);
   1644      })
   1645      .then(() => {
   1646        if (search == this.#currentSearch) {
   1647          this.finishSearch(true);
   1648        }
   1649      });
   1650  }
   1651 }