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 }