diff -r 000000000000 -r 6474c204b198 toolkit/components/places/UnifiedComplete.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/toolkit/components/places/UnifiedComplete.js Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,1236 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim: sw=2 ts=2 sts=2 expandtab + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +//////////////////////////////////////////////////////////////////////////////// +//// Constants + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +const TOPIC_SHUTDOWN = "places-shutdown"; +const TOPIC_PREFCHANGED = "nsPref:changed"; + +const DEFAULT_BEHAVIOR = 0; + +const PREF_BRANCH = "browser.urlbar"; + +// Prefs are defined as [pref name, default value]. +const PREF_ENABLED = [ "autocomplete.enabled", true ]; +const PREF_AUTOFILL = [ "autoFill", true ]; +const PREF_AUTOFILL_TYPED = [ "autoFill.typed", true ]; +const PREF_AUTOFILL_PRIORITY = [ "autoFill.priority", true ]; +const PREF_DELAY = [ "delay", 50 ]; +const PREF_BEHAVIOR = [ "matchBehavior", MATCH_BOUNDARY_ANYWHERE ]; +const PREF_DEFAULT_BEHAVIOR = [ "default.behavior", DEFAULT_BEHAVIOR ]; +const PREF_EMPTY_BEHAVIOR = [ "default.behavior.emptyRestriction", + Ci.mozIPlacesAutoComplete.BEHAVIOR_HISTORY | + Ci.mozIPlacesAutoComplete.BEHAVIOR_TYPED ]; +const PREF_FILTER_JS = [ "filter.javascript", true ]; +const PREF_MAXRESULTS = [ "maxRichResults", 25 ]; +const PREF_RESTRICT_HISTORY = [ "restrict.history", "^" ]; +const PREF_RESTRICT_BOOKMARKS = [ "restrict.bookmark", "*" ]; +const PREF_RESTRICT_TYPED = [ "restrict.typed", "~" ]; +const PREF_RESTRICT_TAG = [ "restrict.tag", "+" ]; +const PREF_RESTRICT_SWITCHTAB = [ "restrict.openpage", "%" ]; +const PREF_MATCH_TITLE = [ "match.title", "#" ]; +const PREF_MATCH_URL = [ "match.url", "@" ]; + +// Match type constants. +// These indicate what type of search function we should be using. +const MATCH_ANYWHERE = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE; +const MATCH_BOUNDARY_ANYWHERE = Ci.mozIPlacesAutoComplete.MATCH_BOUNDARY_ANYWHERE; +const MATCH_BOUNDARY = Ci.mozIPlacesAutoComplete.MATCH_BOUNDARY; +const MATCH_BEGINNING = Ci.mozIPlacesAutoComplete.MATCH_BEGINNING; +const MATCH_BEGINNING_CASE_SENSITIVE = Ci.mozIPlacesAutoComplete.MATCH_BEGINNING_CASE_SENSITIVE; + +// AutoComplete query type constants. +// Describes the various types of queries that we can process rows for. +const QUERYTYPE_KEYWORD = 0; +const QUERYTYPE_FILTERED = 1; +const QUERYTYPE_AUTOFILL_HOST = 2; +const QUERYTYPE_AUTOFILL_URL = 3; + +// This separator is used as an RTL-friendly way to split the title and tags. +// It can also be used by an nsIAutoCompleteResult consumer to re-split the +// "comment" back into the title and the tag. +const TITLE_TAGS_SEPARATOR = " \u2013 "; + +// Telemetry probes. +const TELEMETRY_1ST_RESULT = "PLACES_AUTOCOMPLETE_1ST_RESULT_TIME_MS"; + +// The default frecency value used when inserting priority results. +const FRECENCY_PRIORITY_DEFAULT = 1000; + +// Sqlite result row index constants. +const QUERYINDEX_QUERYTYPE = 0; +const QUERYINDEX_URL = 1; +const QUERYINDEX_TITLE = 2; +const QUERYINDEX_ICONURL = 3; +const QUERYINDEX_BOOKMARKED = 4; +const QUERYINDEX_BOOKMARKTITLE = 5; +const QUERYINDEX_TAGS = 6; +const QUERYINDEX_VISITCOUNT = 7; +const QUERYINDEX_TYPED = 8; +const QUERYINDEX_PLACEID = 9; +const QUERYINDEX_SWITCHTAB = 10; +const QUERYINDEX_FRECENCY = 11; + +// This SQL query fragment provides the following: +// - whether the entry is bookmarked (QUERYINDEX_BOOKMARKED) +// - the bookmark title, if it is a bookmark (QUERYINDEX_BOOKMARKTITLE) +// - the tags associated with a bookmarked entry (QUERYINDEX_TAGS) +const SQL_BOOKMARK_TAGS_FRAGMENT = sql( + "EXISTS(SELECT 1 FROM moz_bookmarks WHERE fk = h.id) AS bookmarked,", + "( SELECT title FROM moz_bookmarks WHERE fk = h.id AND title NOTNULL", + "ORDER BY lastModified DESC LIMIT 1", + ") AS btitle,", + "( SELECT GROUP_CONCAT(t.title, ',')", + "FROM moz_bookmarks b", + "JOIN moz_bookmarks t ON t.id = +b.parent AND t.parent = :parent", + "WHERE b.fk = h.id", + ") AS tags"); + +// TODO bug 412736: in case of a frecency tie, we might break it with h.typed +// and h.visit_count. That is slower though, so not doing it yet... +const SQL_DEFAULT_QUERY = sql( + "SELECT :query_type, h.url, h.title, f.url,", SQL_BOOKMARK_TAGS_FRAGMENT, ",", + "h.visit_count, h.typed, h.id, t.open_count, h.frecency", + "FROM moz_places h", + "LEFT JOIN moz_favicons f ON f.id = h.favicon_id", + "LEFT JOIN moz_openpages_temp t ON t.url = h.url", + "WHERE h.frecency <> 0", + "AND AUTOCOMPLETE_MATCH(:searchString, h.url,", + "IFNULL(btitle, h.title), tags,", + "h.visit_count, h.typed,", + "bookmarked, t.open_count,", + ":matchBehavior, :searchBehavior)", + "/*CONDITIONS*/", + "ORDER BY h.frecency DESC, h.id DESC", + "LIMIT :maxResults"); + +// Enforce ignoring the visit_count index, since the frecency one is much +// faster in this case. ANALYZE helps the query planner to figure out the +// faster path, but it may not have up-to-date information yet. +const SQL_HISTORY_QUERY = SQL_DEFAULT_QUERY.replace("/*CONDITIONS*/", + "AND +h.visit_count > 0", "g"); + +const SQL_BOOKMARK_QUERY = SQL_DEFAULT_QUERY.replace("/*CONDITIONS*/", + "AND bookmarked", "g"); + +const SQL_TAGS_QUERY = SQL_DEFAULT_QUERY.replace("/*CONDITIONS*/", + "AND tags NOTNULL", "g"); + +const SQL_TYPED_QUERY = SQL_DEFAULT_QUERY.replace("/*CONDITIONS*/", + "AND h.typed = 1", "g"); + +const SQL_SWITCHTAB_QUERY = sql( + "SELECT :query_type, t.url, t.url, NULL, NULL, NULL, NULL, NULL, NULL, NULL,", + "t.open_count, NULL", + "FROM moz_openpages_temp t", + "LEFT JOIN moz_places h ON h.url = t.url", + "WHERE h.id IS NULL", + "AND AUTOCOMPLETE_MATCH(:searchString, t.url, t.url, NULL,", + "NULL, NULL, NULL, t.open_count,", + ":matchBehavior, :searchBehavior)", + "ORDER BY t.ROWID DESC", + "LIMIT :maxResults"); + +const SQL_ADAPTIVE_QUERY = sql( + "/* do not warn (bug 487789) */", + "SELECT :query_type, h.url, h.title, f.url,", SQL_BOOKMARK_TAGS_FRAGMENT, ",", + "h.visit_count, h.typed, h.id, t.open_count, h.frecency", + "FROM (", + "SELECT ROUND(MAX(use_count) * (1 + (input = :search_string)), 1) AS rank,", + "place_id", + "FROM moz_inputhistory", + "WHERE input BETWEEN :search_string AND :search_string || X'FFFF'", + "GROUP BY place_id", + ") AS i", + "JOIN moz_places h ON h.id = i.place_id", + "LEFT JOIN moz_favicons f ON f.id = h.favicon_id", + "LEFT JOIN moz_openpages_temp t ON t.url = h.url", + "WHERE AUTOCOMPLETE_MATCH(NULL, h.url,", + "IFNULL(btitle, h.title), tags,", + "h.visit_count, h.typed, bookmarked,", + "t.open_count,", + ":matchBehavior, :searchBehavior)", + "ORDER BY rank DESC, h.frecency DESC"); + +const SQL_KEYWORD_QUERY = sql( + "/* do not warn (bug 487787) */", + "SELECT :query_type,", + "(SELECT REPLACE(url, '%s', :query_string) FROM moz_places WHERE id = b.fk)", + "AS search_url, h.title,", + "IFNULL(f.url, (SELECT f.url", + "FROM moz_places", + "JOIN moz_favicons f ON f.id = favicon_id", + "WHERE rev_host = (SELECT rev_host FROM moz_places WHERE id = b.fk)", + "ORDER BY frecency DESC", + "LIMIT 1)", + "),", + "1, b.title, NULL, h.visit_count, h.typed, IFNULL(h.id, b.fk),", + "t.open_count, h.frecency", + "FROM moz_keywords k", + "JOIN moz_bookmarks b ON b.keyword_id = k.id", + "LEFT JOIN moz_places h ON h.url = search_url", + "LEFT JOIN moz_favicons f ON f.id = h.favicon_id", + "LEFT JOIN moz_openpages_temp t ON t.url = search_url", + "WHERE LOWER(k.keyword) = LOWER(:keyword)", + "ORDER BY h.frecency DESC"); + +const SQL_HOST_QUERY = sql( + "/* do not warn (bug NA): not worth to index on (typed, frecency) */", + "SELECT :query_type, host || '/', prefix || host || '/',", + "NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, frecency", + "FROM moz_hosts", + "WHERE host BETWEEN :searchString AND :searchString || X'FFFF'", + "AND frecency <> 0", + "/*CONDITIONS*/", + "ORDER BY frecency DESC", + "LIMIT 1"); + +const SQL_TYPED_HOST_QUERY = SQL_HOST_QUERY.replace("/*CONDITIONS*/", + "AND typed = 1"); +const SQL_URL_QUERY = sql( + "/* do not warn (bug no): cannot use an index */", + "SELECT :query_type, h.url,", + "NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, h.frecency", + "FROM moz_places h", + "WHERE h.frecency <> 0", + "/*CONDITIONS*/", + "AND AUTOCOMPLETE_MATCH(:searchString, h.url,", + "h.title, '',", + "h.visit_count, h.typed, 0, 0,", + ":matchBehavior, :searchBehavior)", + "ORDER BY h.frecency DESC, h.id DESC", + "LIMIT 1"); + +const SQL_TYPED_URL_QUERY = SQL_URL_QUERY.replace("/*CONDITIONS*/", + "AND typed = 1"); + +//////////////////////////////////////////////////////////////////////////////// +//// Getters + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch", + "resource://gre/modules/TelemetryStopwatch.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Sqlite", + "resource://gre/modules/Sqlite.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PriorityUrlProvider", + "resource://gre/modules/PriorityUrlProvider.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "textURIService", + "@mozilla.org/intl/texttosuburi;1", + "nsITextToSubURI"); + +/** + * Storage object for switch-to-tab entries. + * This takes care of caching and registering open pages, that will be reused + * by switch-to-tab queries. It has an internal cache, so that the Sqlite + * store is lazy initialized only on first use. + * It has a simple API: + * initDatabase(conn): initializes the temporary Sqlite entities to store data + * add(uri): adds a given nsIURI to the store + * delete(uri): removes a given nsIURI from the store + * shutdown(): stops storing data to Sqlite + */ +XPCOMUtils.defineLazyGetter(this, "SwitchToTabStorage", () => Object.seal({ + _conn: null, + // Temporary queue used while the database connection is not available. + _queue: new Set(), + initDatabase: Task.async(function* (conn) { + // To reduce IO use an in-memory table for switch-to-tab tracking. + // Note: this should be kept up-to-date with the definition in + // nsPlacesTables.h. + yield conn.execute(sql( + "CREATE TEMP TABLE moz_openpages_temp (", + "url TEXT PRIMARY KEY,", + "open_count INTEGER", + ")")); + + // Note: this should be kept up-to-date with the definition in + // nsPlacesTriggers.h. + yield conn.execute(sql( + "CREATE TEMPORARY TRIGGER moz_openpages_temp_afterupdate_trigger", + "AFTER UPDATE OF open_count ON moz_openpages_temp FOR EACH ROW", + "WHEN NEW.open_count = 0", + "BEGIN", + "DELETE FROM moz_openpages_temp", + "WHERE url = NEW.url;", + "END")); + + this._conn = conn; + + // Populate the table with the current cache contents... + this._queue.forEach(this.add, this); + // ...then clear it to avoid double additions. + this._queue.clear(); + }), + + add: function (uri) { + if (!this._conn) { + this._queue.add(uri); + return; + } + this._conn.executeCached(sql( + "INSERT OR REPLACE INTO moz_openpages_temp (url, open_count)", + "VALUES ( :url, IFNULL( (SELECT open_count + 1", + "FROM moz_openpages_temp", + "WHERE url = :url),", + "1", + ")", + ")" + ), { url: uri.spec }); + }, + + delete: function (uri) { + if (!this._conn) { + this._queue.delete(uri); + return; + } + this._conn.executeCached(sql( + "UPDATE moz_openpages_temp", + "SET open_count = open_count - 1", + "WHERE url = :url" + ), { url: uri.spec }); + }, + + shutdown: function () { + this._conn = null; + this._queue.clear(); + } +})); + +/** + * This helper keeps track of preferences and keeps their values up-to-date. + */ +XPCOMUtils.defineLazyGetter(this, "Prefs", () => { + let prefs = new Preferences(PREF_BRANCH); + + function loadPrefs() { + store.enabled = prefs.get(...PREF_ENABLED); + store.autofill = prefs.get(...PREF_AUTOFILL); + store.autofillTyped = prefs.get(...PREF_AUTOFILL_TYPED); + store.autofillPriority = prefs.get(...PREF_AUTOFILL_PRIORITY); + store.delay = prefs.get(...PREF_DELAY); + store.matchBehavior = prefs.get(...PREF_BEHAVIOR); + store.filterJavaScript = prefs.get(...PREF_FILTER_JS); + store.maxRichResults = prefs.get(...PREF_MAXRESULTS); + store.restrictHistoryToken = prefs.get(...PREF_RESTRICT_HISTORY); + store.restrictBookmarkToken = prefs.get(...PREF_RESTRICT_BOOKMARKS); + store.restrictTypedToken = prefs.get(...PREF_RESTRICT_TYPED); + store.restrictTagToken = prefs.get(...PREF_RESTRICT_TAG); + store.restrictOpenPageToken = prefs.get(...PREF_RESTRICT_SWITCHTAB); + store.matchTitleToken = prefs.get(...PREF_MATCH_TITLE); + store.matchURLToken = prefs.get(...PREF_MATCH_URL); + store.defaultBehavior = prefs.get(...PREF_DEFAULT_BEHAVIOR); + // Further restrictions to apply for "empty searches" (i.e. searches for ""). + store.emptySearchDefaultBehavior = store.defaultBehavior | + prefs.get(...PREF_EMPTY_BEHAVIOR); + + // Validate matchBehavior; default to MATCH_BOUNDARY_ANYWHERE. + if (store.matchBehavior != MATCH_ANYWHERE && + store.matchBehavior != MATCH_BOUNDARY && + store.matchBehavior != MATCH_BEGINNING) { + store.matchBehavior = MATCH_BOUNDARY_ANYWHERE; + } + + store.tokenToBehaviorMap = new Map([ + [ store.restrictHistoryToken, "history" ], + [ store.restrictBookmarkToken, "bookmark" ], + [ store.restrictTagToken, "tag" ], + [ store.restrictOpenPageToken, "openpage" ], + [ store.matchTitleToken, "title" ], + [ store.matchURLToken, "url" ], + [ store.restrictTypedToken, "typed" ] + ]); + } + + let store = { + observe: function (subject, topic, data) { + loadPrefs(); + }, + QueryInterface: XPCOMUtils.generateQI([ Ci.nsIObserver ]) + }; + loadPrefs(); + prefs.observe("", store); + + return Object.seal(store); +}); + +//////////////////////////////////////////////////////////////////////////////// +//// Helper functions + +/** + * Joins multiple sql tokens into a single sql query. + */ +function sql(...parts) parts.join(" "); + +/** + * Used to unescape encoded URI strings and drop information that we do not + * care about. + * + * @param spec + * The text to unescape and modify. + * @return the modified spec. + */ +function fixupSearchText(spec) + textURIService.unEscapeURIForUI("UTF-8", stripPrefix(spec)); + +/** + * Generates the tokens used in searching from a given string. + * + * @param searchString + * The string to generate tokens from. + * @return an array of tokens. + * @note Calling split on an empty string will return an array containing one + * empty string. We don't want that, as it'll break our logic, so return + * an empty array then. + */ +function getUnfilteredSearchTokens(searchString) + searchString.length ? searchString.split(" ") : []; + +/** + * Strip prefixes from the URI that we don't care about for searching. + * + * @param spec + * The text to modify. + * @return the modified spec. + */ +function stripPrefix(spec) +{ + ["http://", "https://", "ftp://"].some(scheme => { + if (spec.startsWith(scheme)) { + spec = spec.slice(scheme.length); + return true; + } + return false; + }); + + if (spec.startsWith("www.")) { + spec = spec.slice(4); + } + return spec; +} + +//////////////////////////////////////////////////////////////////////////////// +//// Search Class +//// Manages a single instance of an autocomplete search. + +function Search(searchString, searchParam, autocompleteListener, + resultListener, autocompleteSearch) { + // We want to store the original string with no leading or trailing + // whitespace for case sensitive searches. + this._originalSearchString = searchString.trim(); + this._searchString = fixupSearchText(this._originalSearchString.toLowerCase()); + this._searchTokens = + this.filterTokens(getUnfilteredSearchTokens(this._searchString)); + // The protocol and the host are lowercased by nsIURI, so it's fine to + // lowercase the typed prefix, to add it back to the results later. + this._strippedPrefix = this._originalSearchString.slice( + 0, this._originalSearchString.length - this._searchString.length + ).toLowerCase(); + // The URIs in the database are fixed-up, so we can match on a lowercased + // host, but the path must be matched in a case sensitive way. + let pathIndex = + this._originalSearchString.indexOf("/", this._strippedPrefix.length); + this._autofillUrlSearchString = fixupSearchText( + this._originalSearchString.slice(0, pathIndex).toLowerCase() + + this._originalSearchString.slice(pathIndex) + ); + + this._enableActions = searchParam.split(" ").indexOf("enable-actions") != -1; + + this._listener = autocompleteListener; + this._autocompleteSearch = autocompleteSearch; + + this._matchBehavior = Prefs.matchBehavior; + // Set the default behavior for this search. + this._behavior = this._searchString ? Prefs.defaultBehavior + : Prefs.emptySearchDefaultBehavior; + // Create a new result to add eventual matches. Note we need a result + // regardless having matches. + let result = Cc["@mozilla.org/autocomplete/simple-result;1"] + .createInstance(Ci.nsIAutoCompleteSimpleResult); + result.setSearchString(searchString); + result.setListener(resultListener); + // Will be set later, if needed. + result.setDefaultIndex(-1); + this._result = result; + + // These are used to avoid adding duplicate entries to the results. + this._usedURLs = new Set(); + this._usedPlaceIds = new Set(); +} + +Search.prototype = { + /** + * Enables the desired AutoComplete behavior. + * + * @param type + * The behavior type to set. + */ + setBehavior: function (type) { + this._behavior |= + Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type.toUpperCase()]; + }, + + /** + * Determines if the specified AutoComplete behavior is set. + * + * @param aType + * The behavior type to test for. + * @return true if the behavior is set, false otherwise. + */ + hasBehavior: function (type) { + return this._behavior & + Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type.toUpperCase()]; + }, + + /** + * Used to delay the most complex queries, to save IO while the user is + * typing. + */ + _sleepDeferred: null, + _sleep: function (aTimeMs) { + // Reuse a single instance to try shaving off some usless work before + // the first query. + if (!this._sleepTimer) + this._sleepTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this._sleepDeferred = Promise.defer(); + this._sleepTimer.initWithCallback(() => this._sleepDeferred.resolve(), + aTimeMs, Ci.nsITimer.TYPE_ONE_SHOT); + return this._sleepDeferred.promise; + }, + + /** + * Given an array of tokens, this function determines which query should be + * ran. It also removes any special search tokens. + * + * @param tokens + * An array of search tokens. + * @return the filtered list of tokens to search with. + */ + filterTokens: function (tokens) { + // Set the proper behavior while filtering tokens. + for (let i = tokens.length - 1; i >= 0; i--) { + let behavior = Prefs.tokenToBehaviorMap.get(tokens[i]); + // Don't remove the token if it didn't match, or if it's an action but + // actions are not enabled. + if (behavior && (behavior != "openpage" || this._enableActions)) { + this.setBehavior(behavior); + tokens.splice(i, 1); + } + } + + // Set the right JavaScript behavior based on our preference. Note that the + // preference is whether or not we should filter JavaScript, and the + // behavior is if we should search it or not. + if (!Prefs.filterJavaScript) { + this.setBehavior("javascript"); + } + + return tokens; + }, + + /** + * Used to cancel this search, will stop providing results. + */ + cancel: function () { + if (this._sleepTimer) + this._sleepTimer.cancel(); + if (this._sleepDeferred) { + this._sleepDeferred.resolve(); + this._sleepDeferred = null; + } + delete this._pendingQuery; + }, + + /** + * Whether this search is running. + */ + get pending() !!this._pendingQuery, + + /** + * Execute the search and populate results. + * @param conn + * The Sqlite connection. + */ + execute: Task.async(function* (conn) { + this._pendingQuery = true; + TelemetryStopwatch.start(TELEMETRY_1ST_RESULT); + + // For any given search, we run many queries: + // 1) priority domains + // 2) inline completion + // 3) keywords (this._keywordQuery) + // 4) adaptive learning (this._adaptiveQuery) + // 5) open pages not supported by history (this._switchToTabQuery) + // 6) query based on match behavior + // + // (3) only gets ran if we get any filtered tokens, since if there are no + // tokens, there is nothing to match. + + // Get the final query, based on the tokens found in the search string. + let queries = [ this._adaptiveQuery, + this._switchToTabQuery, + this._searchQuery ]; + + if (this._searchTokens.length == 1) { + yield this._matchPriorityUrl(); + } else if (this._searchTokens.length > 1) { + queries.unshift(this._keywordQuery); + } + + if (this._shouldAutofill) { + // Hosts have no "/" in them. + let lastSlashIndex = this._searchString.lastIndexOf("/"); + // Search only URLs if there's a slash in the search string... + if (lastSlashIndex != -1) { + // ...but not if it's exactly at the end of the search string. + if (lastSlashIndex < this._searchString.length - 1) { + queries.unshift(this._urlQuery); + } + } else if (this.pending) { + // The host query is executed immediately, while any other is delayed + // to avoid overloading the connection. + let [ query, params ] = this._hostQuery; + yield conn.executeCached(query, params, this._onResultRow.bind(this)); + } + } + + yield this._sleep(Prefs.delay); + if (!this.pending) + return; + + for (let [query, params] of queries) { + yield conn.executeCached(query, params, this._onResultRow.bind(this)); + if (!this.pending) + return; + } + + // If we do not have enough results, and our match type is + // MATCH_BOUNDARY_ANYWHERE, search again with MATCH_ANYWHERE to get more + // results. + if (this._matchBehavior == MATCH_BOUNDARY_ANYWHERE && + this._result.matchCount < Prefs.maxRichResults) { + this._matchBehavior = MATCH_ANYWHERE; + for (let [query, params] of [ this._adaptiveQuery, + this._searchQuery ]) { + yield conn.executeCached(query, params, this._onResultRow); + if (!this.pending) + return; + } + } + + // If we didn't find enough matches and we have some frecency-driven + // matches, add them. + if (this._frecencyMatches) { + this._frecencyMatches.forEach(this._addMatch, this); + } + }), + + _matchPriorityUrl: function* () { + if (!Prefs.autofillPriority) + return; + let priorityMatch = yield PriorityUrlProvider.getMatch(this._searchString); + if (priorityMatch) { + this._result.setDefaultIndex(0); + this._addFrecencyMatch({ + value: priorityMatch.token, + comment: priorityMatch.title, + icon: priorityMatch.iconUrl, + style: "priority-" + priorityMatch.reason, + finalCompleteValue: priorityMatch.url, + frecency: FRECENCY_PRIORITY_DEFAULT + }); + } + }, + + _onResultRow: function (row) { + TelemetryStopwatch.finish(TELEMETRY_1ST_RESULT); + let queryType = row.getResultByIndex(QUERYINDEX_QUERYTYPE); + let match; + switch (queryType) { + case QUERYTYPE_AUTOFILL_HOST: + this._result.setDefaultIndex(0); + match = this._processHostRow(row); + break; + case QUERYTYPE_AUTOFILL_URL: + this._result.setDefaultIndex(0); + match = this._processUrlRow(row); + break; + case QUERYTYPE_FILTERED: + case QUERYTYPE_KEYWORD: + match = this._processRow(row); + break; + } + this._addMatch(match); + }, + + /** + * These matches should be mixed up with other matches, based on frecency. + */ + _addFrecencyMatch: function (match) { + if (!this._frecencyMatches) + this._frecencyMatches = []; + this._frecencyMatches.push(match); + // We keep this array in reverse order, so we can walk it and remove stuff + // from it in one pass. Notice that for frecency reverse order means from + // lower to higher. + this._frecencyMatches.sort((a, b) => a.frecency - b.frecency); + }, + + _addMatch: function (match) { + let notifyResults = false; + + if (this._frecencyMatches) { + for (let i = this._frecencyMatches.length - 1; i >= 0 ; i--) { + if (this._frecencyMatches[i].frecency > match.frecency) { + this._addMatch(this._frecencyMatches.splice(i, 1)[0]); + } + } + } + + // Must check both id and url, cause keywords dinamically modify the url. + if ((!match.placeId || !this._usedPlaceIds.has(match.placeId)) && + !this._usedURLs.has(stripPrefix(match.value))) { + // Add this to our internal tracker to ensure duplicates do not end up in + // the result. + // Not all entries have a place id, thus we fallback to the url for them. + // We cannot use only the url since keywords entries are modified to + // include the search string, and would be returned multiple times. Ids + // are faster too. + if (match.placeId) + this._usedPlaceIds.add(match.placeId); + this._usedURLs.add(stripPrefix(match.value)); + + this._result.appendMatch(match.value, + match.comment, + match.icon || PlacesUtils.favicons.defaultFavicon.spec, + match.style || "favicon", + match.finalCompleteValue); + notifyResults = true; + } + + if (this._result.matchCount == Prefs.maxRichResults || !this.pending) { + // We have enough results, so stop running our search. + this.cancel(); + // This tells Sqlite.jsm to stop providing us results and cancel the + // underlying query. + throw StopIteration; + } + + if (notifyResults) { + // Notify about results if we've gotten them. + this.notifyResults(true); + } + }, + + _processHostRow: function (row) { + let match = {}; + let trimmedHost = row.getResultByIndex(QUERYINDEX_URL); + let untrimmedHost = row.getResultByIndex(QUERYINDEX_TITLE); + let frecency = row.getResultByIndex(QUERYINDEX_FRECENCY); + // If the untrimmed value doesn't preserve the user's input just + // ignore it and complete to the found host. + if (untrimmedHost && + !untrimmedHost.toLowerCase().contains(this._originalSearchString.toLowerCase())) { + // THIS CAUSES null TO BE SHOWN AS TITLE. + untrimmedHost = null; + } + + match.value = this._strippedPrefix + trimmedHost; + match.comment = trimmedHost; + match.finalCompleteValue = untrimmedHost; + match.frecency = frecency; + return match; + }, + + _processUrlRow: function (row) { + let match = {}; + let value = row.getResultByIndex(QUERYINDEX_URL); + let url = fixupSearchText(value); + let frecency = row.getResultByIndex(QUERYINDEX_FRECENCY); + + let prefix = value.slice(0, value.length - stripPrefix(value).length); + + // We must complete the URL up to the next separator (which is /, ? or #). + let separatorIndex = url.slice(this._searchString.length) + .search(/[\/\?\#]/); + if (separatorIndex != -1) { + separatorIndex += this._searchString.length; + if (url[separatorIndex] == "/") { + separatorIndex++; // Include the "/" separator + } + url = url.slice(0, separatorIndex); + } + + // If the untrimmed value doesn't preserve the user's input just + // ignore it and complete to the found url. + let untrimmedURL = prefix + url; + if (untrimmedURL && + !untrimmedURL.toLowerCase().contains(this._originalSearchString.toLowerCase())) { + // THIS CAUSES null TO BE SHOWN AS TITLE. + untrimmedURL = null; + } + + match.value = this._strippedPrefix + url; + match.comment = url; + match.finalCompleteValue = untrimmedURL; + match.frecency = frecency; + return match; + }, + + _processRow: function (row) { + let match = {}; + match.placeId = row.getResultByIndex(QUERYINDEX_PLACEID); + let queryType = row.getResultByIndex(QUERYINDEX_QUERYTYPE); + let escapedURL = row.getResultByIndex(QUERYINDEX_URL); + let openPageCount = row.getResultByIndex(QUERYINDEX_SWITCHTAB) || 0; + let historyTitle = row.getResultByIndex(QUERYINDEX_TITLE) || ""; + let iconurl = row.getResultByIndex(QUERYINDEX_ICONURL) || ""; + let bookmarked = row.getResultByIndex(QUERYINDEX_BOOKMARKED); + let bookmarkTitle = bookmarked ? + row.getResultByIndex(QUERYINDEX_BOOKMARKTITLE) : null; + let tags = row.getResultByIndex(QUERYINDEX_TAGS) || ""; + let frecency = row.getResultByIndex(QUERYINDEX_FRECENCY); + + // If actions are enabled and the page is open, add only the switch-to-tab + // result. Otherwise, add the normal result. + let [url, action] = this._enableActions && openPageCount > 0 ? + ["moz-action:switchtab," + escapedURL, "action "] : + [escapedURL, ""]; + + // Always prefer the bookmark title unless it is empty + let title = bookmarkTitle || historyTitle; + + if (queryType == QUERYTYPE_KEYWORD) { + // If we do not have a title, then we must have a keyword, so let the UI + // know it is a keyword. Otherwise, we found an exact page match, so just + // show the page like a regular result. Because the page title is likely + // going to be more specific than the bookmark title (keyword title). + if (!historyTitle) { + match.style = "keyword"; + } + else { + title = historyTitle; + } + } + + // We will always prefer to show tags if we have them. + let showTags = !!tags; + + // However, we'll act as if a page is not bookmarked or tagged if the user + // only wants only history and not bookmarks or tags. + if (this.hasBehavior("history") && + !(this.hasBehavior("bookmark") || this.hasBehavior("tag"))) { + showTags = false; + match.style = "favicon"; + } + + // If we have tags and should show them, we need to add them to the title. + if (showTags) { + title += TITLE_TAGS_SEPARATOR + tags; + } + + // We have to determine the right style to display. Tags show the tag icon, + // bookmarks get the bookmark icon, and keywords get the keyword icon. If + // the result does not fall into any of those, it just gets the favicon. + if (!match.style) { + // It is possible that we already have a style set (from a keyword + // search or because of the user's preferences), so only set it if we + // haven't already done so. + if (showTags) { + match.style = "tag"; + } + else if (bookmarked) { + match.style = "bookmark"; + } + } + + match.value = url; + match.comment = title; + if (iconurl) { + match.icon = PlacesUtils.favicons + .getFaviconLinkForIcon(NetUtil.newURI(iconurl)).spec; + } + match.frecency = frecency; + + return match; + }, + + /** + * Obtains the search query to be used based on the previously set search + * behaviors (accessed by this.hasBehavior). + * + * @return an array consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + get _searchQuery() { + // We use more optimized queries for restricted searches, so we will always + // return the most restrictive one to the least restrictive one if more than + // one token is found. + // Note: "openpages" behavior is supported by the default query. + // _switchToTabQuery instead returns only pages not supported by + // history and it is always executed. + let query = this.hasBehavior("tag") ? SQL_TAGS_QUERY : + this.hasBehavior("bookmark") ? SQL_BOOKMARK_QUERY : + this.hasBehavior("typed") ? SQL_TYPED_QUERY : + this.hasBehavior("history") ? SQL_HISTORY_QUERY : + SQL_DEFAULT_QUERY; + + return [ + query, + { + parent: PlacesUtils.tagsFolderId, + query_type: QUERYTYPE_FILTERED, + matchBehavior: this._matchBehavior, + searchBehavior: this._behavior, + // We only want to search the tokens that we are left with - not the + // original search string. + searchString: this._searchTokens.join(" "), + // Limit the query to the the maximum number of desired results. + // This way we can avoid doing more work than needed. + maxResults: Prefs.maxRichResults + } + ]; + }, + + /** + * Obtains the query to search for keywords. + * + * @return an array consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + get _keywordQuery() { + // The keyword is the first word in the search string, with the parameters + // following it. + let searchString = this._originalSearchString; + let queryString = ""; + let queryIndex = searchString.indexOf(" "); + if (queryIndex != -1) { + queryString = searchString.substring(queryIndex + 1); + } + // We need to escape the parameters as if they were the query in a URL + queryString = encodeURIComponent(queryString).replace("%20", "+", "g"); + + // The first word could be a keyword, so that's what we'll search. + let keyword = this._searchTokens[0]; + + return [ + SQL_KEYWORD_QUERY, + { + keyword: keyword, + query_string: queryString, + query_type: QUERYTYPE_KEYWORD + } + ]; + }, + + /** + * Obtains the query to search for switch-to-tab entries. + * + * @return an array consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + get _switchToTabQuery() [ + SQL_SWITCHTAB_QUERY, + { + query_type: QUERYTYPE_FILTERED, + matchBehavior: this._matchBehavior, + searchBehavior: this._behavior, + // We only want to search the tokens that we are left with - not the + // original search string. + searchString: this._searchTokens.join(" "), + maxResults: Prefs.maxRichResults + } + ], + + /** + * Obtains the query to search for adaptive results. + * + * @return an array consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + get _adaptiveQuery() [ + SQL_ADAPTIVE_QUERY, + { + parent: PlacesUtils.tagsFolderId, + search_string: this._searchString, + query_type: QUERYTYPE_FILTERED, + matchBehavior: this._matchBehavior, + searchBehavior: this._behavior + } + ], + + /** + * Whether we should try to autoFill. + */ + get _shouldAutofill() { + // First of all, check for the autoFill pref. + if (!Prefs.autofill) + return false; + + // Then, we should not try to autofill if the behavior is not the default. + // TODO (bug 751709): Ideally we should have a more fine-grained behavior + // here, but for now it's enough to just check for default behavior. + if (Prefs.defaultBehavior != DEFAULT_BEHAVIOR) + return false; + + // Don't autoFill if the search term is recognized as a keyword, otherwise + // it will override default keywords behavior. Note that keywords are + // hashed on first use, so while the first query may delay a little bit, + // next ones will just hit the memory hash. + if (this._searchString.length == 0 || + PlacesUtils.bookmarks.getURIForKeyword(this._searchString)) { + return false; + } + + // Don't try to autofill if the search term includes any whitespace. + // This may confuse completeDefaultIndex cause the AUTOCOMPLETE_MATCH + // tokenizer ends up trimming the search string and returning a value + // that doesn't match it, or is even shorter. + if (/\s/.test(this._searchString)) { + return false; + } + + return true; + }, + + /** + * Obtains the query to search for autoFill host results. + * + * @return an array consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + get _hostQuery() [ + Prefs.autofillTyped ? SQL_TYPED_HOST_QUERY : SQL_TYPED_QUERY, + { + query_type: QUERYTYPE_AUTOFILL_HOST, + searchString: this._searchString.toLowerCase() + } + ], + + /** + * Obtains the query to search for autoFill url results. + * + * @return an array consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + get _urlQuery() [ + Prefs.autofillTyped ? SQL_TYPED_HOST_QUERY : SQL_TYPED_QUERY, + { + query_type: QUERYTYPE_AUTOFILL_URL, + searchString: this._autofillUrlSearchString, + matchBehavior: MATCH_BEGINNING_CASE_SENSITIVE, + searchBehavior: Ci.mozIPlacesAutoComplete.BEHAVIOR_URL + } + ], + + /** + * Notifies the listener about results. + * + * @param searchOngoing + * Indicates whether the search is ongoing. + */ + notifyResults: function (searchOngoing) { + let result = this._result; + let resultCode = result.matchCount ? "RESULT_SUCCESS" : "RESULT_NOMATCH"; + if (searchOngoing) { + resultCode += "_ONGOING"; + } + result.setSearchResult(Ci.nsIAutoCompleteResult[resultCode]); + this._listener.onSearchResult(this._autocompleteSearch, result); + }, +} + +//////////////////////////////////////////////////////////////////////////////// +//// UnifiedComplete class +//// component @mozilla.org/autocomplete/search;1?name=unifiedcomplete + +function UnifiedComplete() { + Services.obs.addObserver(this, TOPIC_SHUTDOWN, true); +} + +UnifiedComplete.prototype = { + ////////////////////////////////////////////////////////////////////////////// + //// nsIObserver + + observe: function (subject, topic, data) { + if (topic === TOPIC_SHUTDOWN) { + this.ensureShutdown(); + } + }, + + ////////////////////////////////////////////////////////////////////////////// + //// Database handling + + /** + * Promise resolved when the database initialization has completed, or null + * if it has never been requested. + */ + _promiseDatabase: null, + + /** + * Gets a Sqlite database handle. + * + * @return {Promise} + * @resolves to the Sqlite database handle (according to Sqlite.jsm). + * @rejects javascript exception. + */ + getDatabaseHandle: function () { + if (Prefs.enabled && !this._promiseDatabase) { + this._promiseDatabase = Task.spawn(function* () { + let conn = yield Sqlite.cloneStorageConnection({ + connection: PlacesUtils.history.DBConnection, + readOnly: true + }); + + // Autocomplete often fallbacks to a table scan due to lack of text + // indices. A larger cache helps reducing IO and improving performance. + // The value used here is larger than the default Storage value defined + // as MAX_CACHE_SIZE_BYTES in storage/src/mozStorageConnection.cpp. + yield conn.execute("PRAGMA cache_size = -6144"); // 6MiB + + yield SwitchToTabStorage.initDatabase(conn); + + return conn; + }.bind(this)).then(null, Cu.reportError); + } + return this._promiseDatabase; + }, + + /** + * Used to stop running queries and close the database handle. + */ + ensureShutdown: function () { + if (this._promiseDatabase) { + Task.spawn(function* () { + let conn = yield this.getDatabaseHandle(); + SwitchToTabStorage.shutdown(); + yield conn.close() + }.bind(this)).then(null, Cu.reportError); + this._promiseDatabase = null; + } + }, + + ////////////////////////////////////////////////////////////////////////////// + //// mozIPlacesAutoComplete + + registerOpenPage: function PAC_registerOpenPage(uri) { + SwitchToTabStorage.add(uri); + }, + + unregisterOpenPage: function PAC_unregisterOpenPage(uri) { + SwitchToTabStorage.delete(uri); + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsIAutoCompleteSearch + + startSearch: function (searchString, searchParam, previousResult, listener) { + // Stop the search in case the controller has not taken care of it. + if (this._currentSearch) { + this.stopSearch(); + } + + // Note: We don't use previousResult to make sure ordering of results are + // consistent. See bug 412730 for more details. + + this._currentSearch = new Search(searchString, searchParam, listener, + this, this); + + // If we are not enabled, we need to return now. Notice we need an empty + // result regardless, so we still create the Search object. + if (!Prefs.enabled) { + this.finishSearch(true); + return; + } + + let search = this._currentSearch; + this.getDatabaseHandle().then(conn => search.execute(conn)) + .then(() => { + if (search == this._currentSearch) { + this.finishSearch(true); + } + }, Cu.reportError); + }, + + stopSearch: function () { + if (this._currentSearch) { + this._currentSearch.cancel(); + } + this.finishSearch(); + }, + + /** + * Properly cleans up when searching is completed. + * + * @param notify [optional] + * Indicates if we should notify the AutoComplete listener about our + * results or not. + */ + finishSearch: function (notify=false) { + // Notify about results if we are supposed to. + if (notify) { + this._currentSearch.notifyResults(false); + } + + // Clear our state + TelemetryStopwatch.cancel(TELEMETRY_1ST_RESULT); + delete this._currentSearch; + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsIAutoCompleteSimpleResultListener + + onValueRemoved: function (result, spec, removeFromDB) { + if (removeFromDB) { + PlacesUtils.history.removePage(NetUtil.newURI(spec)); + } + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsIAutoCompleteSearchDescriptor + + get searchType() Ci.nsIAutoCompleteSearchDescriptor.SEARCH_TYPE_IMMEDIATE, + + ////////////////////////////////////////////////////////////////////////////// + //// nsISupports + + classID: Components.ID("f964a319-397a-4d21-8be6-5cdd1ee3e3ae"), + + _xpcom_factory: XPCOMUtils.generateSingletonFactory(UnifiedComplete), + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIAutoCompleteSearch, + Ci.nsIAutoCompleteSimpleResultListener, + Ci.mozIPlacesAutoComplete, + Ci.nsIObserver, + Ci.nsISupportsWeakReference + ]) +}; + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([UnifiedComplete]);