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