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