diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/urlbar/UrlbarProviderAutofill.sys.mjs | 1096 |
1 files changed, 1096 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs b/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs new file mode 100644 index 0000000000..0802d71bcb --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs @@ -0,0 +1,1096 @@ +/* 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/. */ + +/** + * This module exports a provider that provides an autofill result. + */ + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutPagesUtils: "resource://gre/modules/AboutPagesUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +// AutoComplete query type constants. +// Describes the various types of queries that we can process rows for. +const QUERYTYPE = { + AUTOFILL_ORIGIN: 1, + AUTOFILL_URL: 2, + AUTOFILL_ADAPTIVE: 3, +}; + +// Constants to support an alternative frecency algorithm. +const ORIGIN_USE_ALT_FRECENCY = Services.prefs.getBoolPref( + "places.frecency.origins.alternative.featureGate", + false +); +const ORIGIN_FRECENCY_FIELD = ORIGIN_USE_ALT_FRECENCY + ? "alt_frecency" + : "frecency"; + +// `WITH` clause for the autofill queries. autofill_frecency_threshold.value is +// the mean of all moz_origins.frecency values + stddevMultiplier * one standard +// deviation. This is inlined directly in the SQL (as opposed to being a custom +// Sqlite function for example) in order to be as efficient as possible. +// For alternative frecency, a NULL frecency will be normalized to 0.0, and when +// it will graduate, it will likely become 1 (official frecency is NOT NULL). +// Thus we set a minimum threshold of 2.0, otherwise if all the visits are older +// than the cutoff, we end up checking 0.0 (frecency) >= 0.0 (threshold) and +// autofill everything instead of nothing. +const SQL_AUTOFILL_WITH = ORIGIN_USE_ALT_FRECENCY + ? ` + WITH + autofill_frecency_threshold(value) AS ( + SELECT IFNULL( + (SELECT value FROM moz_meta WHERE key = 'origin_alt_frecency_threshold'), + 2.0 + ) + ) + ` + : ` + WITH + frecency_stats(count, sum, squares) AS ( + SELECT + CAST((SELECT IFNULL(value, 0.0) FROM moz_meta WHERE key = 'origin_frecency_count') AS REAL), + CAST((SELECT IFNULL(value, 0.0) FROM moz_meta WHERE key = 'origin_frecency_sum') AS REAL), + CAST((SELECT IFNULL(value, 0.0) FROM moz_meta WHERE key = 'origin_frecency_sum_of_squares') AS REAL) + ), + autofill_frecency_threshold(value) AS ( + SELECT + CASE count + WHEN 0 THEN 0.0 + WHEN 1 THEN sum + ELSE (sum / count) + (:stddevMultiplier * sqrt((squares - ((sum * sum) / count)) / count)) + END + FROM frecency_stats + ) + `; + +const SQL_AUTOFILL_FRECENCY_THRESHOLD = `host_frecency >= ( + SELECT value FROM autofill_frecency_threshold + )`; + +function originQuery(where) { + // `frecency`, `bookmarked` and `visited` are partitioned by the fixed host, + // without `www.`. `host_prefix` instead is partitioned by full host, because + // we assume a prefix may not work regardless of `www.`. + let selectVisited = where.includes("visited") + ? `MAX(EXISTS( + SELECT 1 FROM moz_places WHERE origin_id = o.id AND visit_count > 0 + )) OVER (PARTITION BY fixup_url(host)) > 0` + : "0"; + let selectTitle; + let joinBookmarks; + if (where.includes("bookmarked")) { + selectTitle = "ifnull(b.title, iif(h.frecency <> 0, h.title, NULL))"; + joinBookmarks = "LEFT JOIN moz_bookmarks b ON b.fk = h.id"; + } else { + selectTitle = "iif(h.frecency <> 0, h.title, NULL)"; + joinBookmarks = ""; + } + return `/* do not warn (bug no): cannot use an index to sort */ + ${SQL_AUTOFILL_WITH}, + origins(id, prefix, host_prefix, host, fixed, host_frecency, frecency, bookmarked, visited) AS ( + SELECT + id, + prefix, + first_value(prefix) OVER ( + PARTITION BY host ORDER BY ${ORIGIN_FRECENCY_FIELD} DESC, prefix = "https://" DESC, id DESC + ), + host, + fixup_url(host), + IFNULL(${ + ORIGIN_USE_ALT_FRECENCY ? "avg(alt_frecency)" : "total(frecency)" + } OVER (PARTITION BY fixup_url(host)), 0.0), + ${ORIGIN_FRECENCY_FIELD}, + MAX(EXISTS( + SELECT 1 FROM moz_places WHERE origin_id = o.id AND foreign_count > 0 + )) OVER (PARTITION BY fixup_url(host)), + ${selectVisited} + FROM moz_origins o + WHERE prefix NOT IN ('about:', 'place:') + AND ((host BETWEEN :searchString AND :searchString || X'FFFF') + OR (host BETWEEN 'www.' || :searchString AND 'www.' || :searchString || X'FFFF')) + ), + matched_origin(host_fixed, url) AS ( + SELECT iif(instr(host, :searchString) = 1, host, fixed) || '/', + ifnull(:prefix, host_prefix) || host || '/' + FROM origins + ${where} + ORDER BY frecency DESC, prefix = "https://" DESC, id DESC + LIMIT 1 + ), + matched_place(host_fixed, url, id, title, frecency) AS ( + SELECT o.host_fixed, o.url, h.id, h.title, h.frecency + FROM matched_origin o + LEFT JOIN moz_places h ON h.url_hash IN ( + hash('https://' || o.host_fixed), + hash('https://www.' || o.host_fixed), + hash('http://' || o.host_fixed), + hash('http://www.' || o.host_fixed) + ) + ORDER BY + h.title IS NOT NULL DESC, + h.title || '/' <> o.host_fixed DESC, + h.url = o.url DESC, + h.frecency DESC, + h.id DESC + LIMIT 1 + ) + SELECT :query_type AS query_type, + :searchString AS search_string, + h.host_fixed AS host_fixed, + h.url AS url, + ${selectTitle} AS title + FROM matched_place h + ${joinBookmarks} + `; +} + +function urlQuery(where1, where2, isBookmarkContained) { + // We limit the search to places that are either bookmarked or have a frecency + // over some small, arbitrary threshold (20) in order to avoid scanning as few + // rows as possible. Keep in mind that we run this query every time the user + // types a key when the urlbar value looks like a URL with a path. + let selectTitle; + let joinBookmarks; + if (isBookmarkContained) { + selectTitle = "ifnull(b.title, matched_url.title)"; + joinBookmarks = "LEFT JOIN moz_bookmarks b ON b.fk = matched_url.id"; + } else { + selectTitle = "matched_url.title"; + joinBookmarks = ""; + } + return `/* do not warn (bug no): cannot use an index to sort */ + WITH matched_url(url, title, frecency, bookmarked, visited, stripped_url, is_exact_match, id) AS ( + SELECT url, + title, + frecency, + foreign_count > 0 AS bookmarked, + visit_count > 0 AS visited, + strip_prefix_and_userinfo(url) AS stripped_url, + strip_prefix_and_userinfo(url) = strip_prefix_and_userinfo(:strippedURL) AS is_exact_match, + id + FROM moz_places + WHERE rev_host = :revHost + ${where1} + UNION ALL + SELECT url, + title, + frecency, + foreign_count > 0 AS bookmarked, + visit_count > 0 AS visited, + strip_prefix_and_userinfo(url) AS stripped_url, + strip_prefix_and_userinfo(url) = 'www.' || strip_prefix_and_userinfo(:strippedURL) AS is_exact_match, + id + FROM moz_places + WHERE rev_host = :revHost || 'www.' + ${where2} + ORDER BY is_exact_match DESC, frecency DESC, id DESC + LIMIT 1 + ) + SELECT :query_type AS query_type, + :searchString AS search_string, + :strippedURL AS stripped_url, + matched_url.url AS url, + ${selectTitle} AS title + FROM matched_url + ${joinBookmarks} + `; +} + +// Queries +const QUERY_ORIGIN_HISTORY_BOOKMARK = originQuery( + `WHERE bookmarked OR ${SQL_AUTOFILL_FRECENCY_THRESHOLD}` +); + +const QUERY_ORIGIN_PREFIX_HISTORY_BOOKMARK = originQuery( + `WHERE prefix BETWEEN :prefix AND :prefix || X'FFFF' + AND (bookmarked OR ${SQL_AUTOFILL_FRECENCY_THRESHOLD})` +); + +const QUERY_ORIGIN_HISTORY = originQuery( + `WHERE visited AND ${SQL_AUTOFILL_FRECENCY_THRESHOLD}` +); + +const QUERY_ORIGIN_PREFIX_HISTORY = originQuery( + `WHERE prefix BETWEEN :prefix AND :prefix || X'FFFF' + AND visited AND ${SQL_AUTOFILL_FRECENCY_THRESHOLD}` +); + +const QUERY_ORIGIN_BOOKMARK = originQuery(`WHERE bookmarked`); + +const QUERY_ORIGIN_PREFIX_BOOKMARK = originQuery( + `WHERE prefix BETWEEN :prefix AND :prefix || X'FFFF' AND bookmarked` +); + +const QUERY_URL_HISTORY_BOOKMARK = urlQuery( + `AND (bookmarked OR frecency > 20) + AND stripped_url COLLATE NOCASE + BETWEEN :strippedURL AND :strippedURL || X'FFFF'`, + `AND (bookmarked OR frecency > 20) + AND stripped_url COLLATE NOCASE + BETWEEN 'www.' || :strippedURL AND 'www.' || :strippedURL || X'FFFF'`, + true +); + +const QUERY_URL_PREFIX_HISTORY_BOOKMARK = urlQuery( + `AND (bookmarked OR frecency > 20) + AND url COLLATE NOCASE + BETWEEN :prefix || :strippedURL AND :prefix || :strippedURL || X'FFFF'`, + `AND (bookmarked OR frecency > 20) + AND url COLLATE NOCASE + BETWEEN :prefix || 'www.' || :strippedURL AND :prefix || 'www.' || :strippedURL || X'FFFF'`, + true +); + +const QUERY_URL_HISTORY = urlQuery( + `AND (visited OR NOT bookmarked) + AND frecency > 20 + AND stripped_url COLLATE NOCASE + BETWEEN :strippedURL AND :strippedURL || X'FFFF'`, + `AND (visited OR NOT bookmarked) + AND frecency > 20 + AND stripped_url COLLATE NOCASE + BETWEEN 'www.' || :strippedURL AND 'www.' || :strippedURL || X'FFFF'`, + false +); + +const QUERY_URL_PREFIX_HISTORY = urlQuery( + `AND (visited OR NOT bookmarked) + AND frecency > 20 + AND url COLLATE NOCASE + BETWEEN :prefix || :strippedURL AND :prefix || :strippedURL || X'FFFF'`, + `AND (visited OR NOT bookmarked) + AND frecency > 20 + AND url COLLATE NOCASE + BETWEEN :prefix || 'www.' || :strippedURL AND :prefix || 'www.' || :strippedURL || X'FFFF'`, + false +); + +const QUERY_URL_BOOKMARK = urlQuery( + `AND bookmarked + AND stripped_url COLLATE NOCASE + BETWEEN :strippedURL AND :strippedURL || X'FFFF'`, + `AND bookmarked + AND stripped_url COLLATE NOCASE + BETWEEN 'www.' || :strippedURL AND 'www.' || :strippedURL || X'FFFF'`, + true +); + +const QUERY_URL_PREFIX_BOOKMARK = urlQuery( + `AND bookmarked + AND url COLLATE NOCASE + BETWEEN :prefix || :strippedURL AND :prefix || :strippedURL || X'FFFF'`, + `AND bookmarked + AND url COLLATE NOCASE + BETWEEN :prefix || 'www.' || :strippedURL AND :prefix || 'www.' || :strippedURL || X'FFFF'`, + true +); + +/** + * Class used to create the provider. + */ +class ProviderAutofill extends UrlbarProvider { + constructor() { + super(); + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "Autofill"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + async isActive(queryContext) { + let instance = this.queryInstance; + + // This is usually reset on canceling or completing the query, but since we + // query in isActive, it may not have been canceled by the previous call. + // It is an object with values { result: UrlbarResult, instance: Query }. + // See the documentation for _getAutofillData for more information. + this._autofillData = null; + + // First of all, check for the autoFill pref. + if (!lazy.UrlbarPrefs.get("autoFill")) { + return false; + } + + if (!queryContext.allowAutofill) { + return false; + } + + if (queryContext.tokens.length != 1) { + return false; + } + + // Trying to autofill an extremely long string would be expensive, and + // not particularly useful since the filled part falls out of screen anyway. + if (queryContext.searchString.length > UrlbarUtils.MAX_TEXT_LENGTH) { + return false; + } + + // autoFill can only cope with history, bookmarks, and about: entries. + if ( + !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) && + !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) + ) { + return false; + } + + // Autofill doesn't search tags or titles + if ( + queryContext.tokens.some( + t => + t.type == lazy.UrlbarTokenizer.TYPE.RESTRICT_TAG || + t.type == lazy.UrlbarTokenizer.TYPE.RESTRICT_TITLE + ) + ) { + return false; + } + + [this._strippedPrefix, this._searchString] = UrlbarUtils.stripURLPrefix( + queryContext.searchString + ); + this._strippedPrefix = this._strippedPrefix.toLowerCase(); + + // 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 (lazy.UrlbarTokenizer.REGEXP_SPACES.test(queryContext.searchString)) { + return false; + } + + // Fetch autofill result now, rather than in startQuery. We do this so the + // muxer doesn't have to wait on autofill for every query, since startQuery + // will be guaranteed to return a result very quickly using this approach. + // Bug 1651101 is filed to improve this behaviour. + let result = await this._getAutofillResult(queryContext); + if (!result || instance != this.queryInstance) { + return false; + } + this._autofillData = { result, instance }; + return true; + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + */ + getPriority(queryContext) { + // Priority search results are restricting. + if ( + this._autofillData && + this._autofillData.instance == this.queryInstance && + this._autofillData.result.type == UrlbarUtils.RESULT_TYPE.SEARCH + ) { + return 1; + } + + return 0; + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. + * @returns {Promise} resolved when the query stops. + */ + async startQuery(queryContext, addCallback) { + // Check if the query was cancelled while the autofill result was being + // fetched. We don't expect this to be true since we also check the instance + // in isActive and clear _autofillData in cancelQuery, but we sanity check it. + if ( + !this._autofillData || + this._autofillData.instance != this.queryInstance + ) { + this.logger.error("startQuery invoked with an invalid _autofillData"); + return; + } + + this._autofillData.result.heuristic = true; + addCallback(this, this._autofillData.result); + this._autofillData = null; + } + + /** + * Cancels a running query. + * + * @param {object} queryContext The query context object + */ + cancelQuery(queryContext) { + if (this._autofillData?.instance == this.queryInstance) { + this._autofillData = null; + } + } + + /** + * Filters hosts by retaining only the ones over the autofill threshold, then + * sorts them by their frecency, and extracts the one with the highest value. + * + * @param {UrlbarQueryContext} queryContext The current queryContext. + * @param {Array} hosts Array of host names to examine. + * @returns {Promise<string?>} + * Resolved when the filtering is complete. Resolves with the top matching + * host, or null if not found. + */ + async getTopHostOverThreshold(queryContext, hosts) { + let db = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); + let conditions = []; + // Pay attention to the order of params, since they are not named. + let params = [...hosts]; + if (!ORIGIN_USE_ALT_FRECENCY) { + params.unshift(lazy.UrlbarPrefs.get("autoFill.stddevMultiplier")); + } + let sources = queryContext.sources; + if ( + sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) && + sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) + ) { + conditions.push(`(bookmarked OR ${SQL_AUTOFILL_FRECENCY_THRESHOLD})`); + } else if (sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY)) { + conditions.push(`visited AND ${SQL_AUTOFILL_FRECENCY_THRESHOLD}`); + } else if (sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS)) { + conditions.push("bookmarked"); + } + + let rows = await db.executeCached( + ` + ${SQL_AUTOFILL_WITH}, + origins(id, prefix, host_prefix, host, fixed, host_frecency, frecency, bookmarked, visited) AS ( + SELECT + id, + prefix, + first_value(prefix) OVER ( + PARTITION BY host ORDER BY ${ORIGIN_FRECENCY_FIELD} DESC, prefix = "https://" DESC, id DESC + ), + host, + fixup_url(host), + IFNULL(${ + ORIGIN_USE_ALT_FRECENCY ? "avg(alt_frecency)" : "total(frecency)" + } OVER (PARTITION BY fixup_url(host)), 0.0), + ${ORIGIN_FRECENCY_FIELD}, + MAX(EXISTS( + SELECT 1 FROM moz_places WHERE origin_id = o.id AND foreign_count > 0 + )) OVER (PARTITION BY fixup_url(host)), + MAX(EXISTS( + SELECT 1 FROM moz_places WHERE origin_id = o.id AND visit_count > 0 + )) OVER (PARTITION BY fixup_url(host)) + FROM moz_origins o + WHERE o.host IN (${new Array(hosts.length).fill("?").join(",")}) + ) + SELECT host + FROM origins + ${conditions.length ? "WHERE " + conditions.join(" AND ") : ""} + ORDER BY frecency DESC, prefix = "https://" DESC, id DESC + LIMIT 1 + `, + params + ); + if (!rows.length) { + return null; + } + return rows[0].getResultByName("host"); + } + + /** + * Obtains the query to search for autofill origin results. + * + * @param {UrlbarQueryContext} queryContext + * The current queryContext. + * @returns {Array} consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + _getOriginQuery(queryContext) { + // At this point, searchString is not a URL with a path; it does not + // contain a slash, except for possibly at the very end. If there is + // trailing slash, remove it when searching here to match the rest of the + // string because it may be an origin. + let searchStr = this._searchString.endsWith("/") + ? this._searchString.slice(0, -1) + : this._searchString; + + let opts = { + query_type: QUERYTYPE.AUTOFILL_ORIGIN, + searchString: searchStr.toLowerCase(), + }; + if (!ORIGIN_USE_ALT_FRECENCY) { + opts.stddevMultiplier = lazy.UrlbarPrefs.get("autoFill.stddevMultiplier"); + } + if (this._strippedPrefix) { + opts.prefix = this._strippedPrefix; + } + + if ( + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) && + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) + ) { + return [ + this._strippedPrefix + ? QUERY_ORIGIN_PREFIX_HISTORY_BOOKMARK + : QUERY_ORIGIN_HISTORY_BOOKMARK, + opts, + ]; + } + if (queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY)) { + return [ + this._strippedPrefix + ? QUERY_ORIGIN_PREFIX_HISTORY + : QUERY_ORIGIN_HISTORY, + opts, + ]; + } + if (queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS)) { + return [ + this._strippedPrefix + ? QUERY_ORIGIN_PREFIX_BOOKMARK + : QUERY_ORIGIN_BOOKMARK, + opts, + ]; + } + throw new Error("Either history or bookmark behavior expected"); + } + + /** + * Obtains the query to search for autoFill url results. + * + * @param {UrlbarQueryContext} queryContext + * The current queryContext. + * @returns {Array} consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + _getUrlQuery(queryContext) { + // Try to get the host from the search string. The host is the part of the + // URL up to either the path slash, port colon, or query "?". If the search + // string doesn't look like it begins with a host, then return; it doesn't + // make sense to do a URL query with it. + const urlQueryHostRegexp = /^[^/:?]+/; + let hostMatch = urlQueryHostRegexp.exec(this._searchString); + if (!hostMatch) { + return [null, null]; + } + + let host = hostMatch[0].toLowerCase(); + let revHost = host.split("").reverse().join("") + "."; + + // Build a string that's the URL stripped of its prefix, i.e., the host plus + // everything after. Use queryContext.trimmedSearchString instead of + // this._searchString because this._searchString has had unEscapeURIForUI() + // called on it. It's therefore not necessarily the literal URL. + let strippedURL = queryContext.trimmedSearchString; + if (this._strippedPrefix) { + strippedURL = strippedURL.substr(this._strippedPrefix.length); + } + strippedURL = host + strippedURL.substr(host.length); + + let opts = { + query_type: QUERYTYPE.AUTOFILL_URL, + searchString: this._searchString, + revHost, + strippedURL, + }; + if (this._strippedPrefix) { + opts.prefix = this._strippedPrefix; + } + + if ( + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) && + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) + ) { + return [ + this._strippedPrefix + ? QUERY_URL_PREFIX_HISTORY_BOOKMARK + : QUERY_URL_HISTORY_BOOKMARK, + opts, + ]; + } + if (queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY)) { + return [ + this._strippedPrefix ? QUERY_URL_PREFIX_HISTORY : QUERY_URL_HISTORY, + opts, + ]; + } + if (queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS)) { + return [ + this._strippedPrefix ? QUERY_URL_PREFIX_BOOKMARK : QUERY_URL_BOOKMARK, + opts, + ]; + } + throw new Error("Either history or bookmark behavior expected"); + } + + _getAdaptiveHistoryQuery(queryContext) { + let sourceCondition; + if ( + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) && + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) + ) { + sourceCondition = "(h.foreign_count > 0 OR h.frecency > 20)"; + } else if ( + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) + ) { + sourceCondition = + "((h.visit_count > 0 OR h.foreign_count = 0) AND h.frecency > 20)"; + } else if ( + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) + ) { + sourceCondition = "h.foreign_count > 0"; + } else { + return []; + } + + let selectTitle; + let joinBookmarks; + if (UrlbarUtils.RESULT_SOURCE.BOOKMARKS) { + selectTitle = "ifnull(b.title, matched.title)"; + joinBookmarks = "LEFT JOIN moz_bookmarks b ON b.fk = matched.id"; + } else { + selectTitle = "matched.title"; + joinBookmarks = ""; + } + + const params = { + queryType: QUERYTYPE.AUTOFILL_ADAPTIVE, + // `fullSearchString` is the value the user typed including a prefix if + // they typed one. `searchString` has been stripped of the prefix. + fullSearchString: queryContext.searchString.toLowerCase(), + searchString: this._searchString, + strippedPrefix: this._strippedPrefix, + useCountThreshold: lazy.UrlbarPrefs.get( + "autoFillAdaptiveHistoryUseCountThreshold" + ), + }; + + const query = ` + WITH matched(input, url, title, stripped_url, is_exact_match, starts_with, id) AS ( + SELECT + i.input AS input, + h.url AS url, + h.title AS title, + strip_prefix_and_userinfo(h.url) AS stripped_url, + strip_prefix_and_userinfo(h.url) = :searchString AS is_exact_match, + (strip_prefix_and_userinfo(h.url) COLLATE NOCASE BETWEEN :searchString AND :searchString || X'FFFF') AS starts_with, + h.id AS id + FROM moz_places h + JOIN moz_inputhistory i ON i.place_id = h.id + WHERE LENGTH(i.input) != 0 + AND :fullSearchString BETWEEN i.input AND i.input || X'FFFF' + AND ${sourceCondition} + AND i.use_count >= :useCountThreshold + AND (:strippedPrefix = '' OR get_prefix(h.url) = :strippedPrefix) + AND ( + starts_with OR + (stripped_url COLLATE NOCASE BETWEEN 'www.' || :searchString AND 'www.' || :searchString || X'FFFF') + ) + ORDER BY is_exact_match DESC, i.use_count DESC, h.frecency DESC, h.id DESC + LIMIT 1 + ) + SELECT + :queryType AS query_type, + :searchString AS search_string, + input, + url, + iif(starts_with, stripped_url, fixup_url(stripped_url)) AS url_fixed, + ${selectTitle} AS title, + stripped_url + FROM matched + ${joinBookmarks} + `; + + return [query, params]; + } + + /** + * Processes a matched row in the Places database. + * + * @param {object} row + * The matched row. + * @param {UrlbarQueryContext} queryContext + * The query context. + * @returns {UrlbarResult} a result generated from the matches row. + */ + _processRow(row, queryContext) { + let queryType = row.getResultByName("query_type"); + let title = row.getResultByName("title"); + + // `searchString` is `this._searchString` or derived from it. It is + // stripped, meaning the prefix (the URL protocol) has been removed. + let searchString = row.getResultByName("search_string"); + + // `fixedURL` is the part of the matching stripped URL that starts with the + // stripped search string. The important point here is "www" handling. If a + // stripped URL starts with "www", we allow the user to omit the "www" and + // still match it. So if the matching stripped URL starts with "www" but the + // stripped search string does not, `fixedURL` will also omit the "www". + // Otherwise `fixedURL` will be equivalent to the matching stripped URL. + // + // Example 1: + // stripped URL: www.example.com/ + // searchString: exam + // fixedURL: example.com/ + // Example 2: + // stripped URL: www.example.com/ + // searchString: www.exam + // fixedURL: www.example.com/ + // Example 3: + // stripped URL: example.com/ + // searchString: exam + // fixedURL: example.com/ + let fixedURL; + + // `finalCompleteValue` will be the UrlbarResult's URL. If the matching + // stripped URL starts with "www" but the user omitted it, + // `finalCompleteValue` will include it to properly reflect the real URL. + let finalCompleteValue; + + let autofilledType; + let adaptiveHistoryInput; + + switch (queryType) { + case QUERYTYPE.AUTOFILL_ORIGIN: { + fixedURL = row.getResultByName("host_fixed"); + finalCompleteValue = row.getResultByName("url"); + autofilledType = "origin"; + break; + } + case QUERYTYPE.AUTOFILL_URL: { + let url = row.getResultByName("url"); + let strippedURL = row.getResultByName("stripped_url"); + + if (!UrlbarUtils.canAutofillURL(url, strippedURL, true)) { + return null; + } + + // We autofill urls to-the-next-slash. + // http://mozilla.org/foo/bar/baz will be autofilled to: + // - http://mozilla.org/f[oo/] + // - http://mozilla.org/foo/b[ar/] + // - http://mozilla.org/foo/bar/b[az] + // And, toLowerCase() is preferred over toLocaleLowerCase() here + // because "COLLATE NOCASE" in the SQL only handles ASCII characters. + let strippedURLIndex = url + .toLowerCase() + .indexOf(strippedURL.toLowerCase()); + let strippedPrefix = url.substr(0, strippedURLIndex); + let nextSlashIndex = url.indexOf( + "/", + strippedURLIndex + strippedURL.length - 1 + ); + fixedURL = + nextSlashIndex < 0 + ? url.substr(strippedURLIndex) + : url.substring(strippedURLIndex, nextSlashIndex + 1); + finalCompleteValue = strippedPrefix + fixedURL; + if (finalCompleteValue !== url) { + title = null; + } + autofilledType = "url"; + break; + } + case QUERYTYPE.AUTOFILL_ADAPTIVE: { + adaptiveHistoryInput = row.getResultByName("input"); + fixedURL = row.getResultByName("url_fixed"); + finalCompleteValue = row.getResultByName("url"); + autofilledType = "adaptive"; + break; + } + } + + // Compute `autofilledValue`, the full value that will be placed in the + // input. It includes two parts: the part the user already typed in the + // character case they typed it (`queryContext.searchString`), and the + // autofilled part, which is the portion of the fixed URL starting after the + // stripped search string. + let autofilledValue = + queryContext.searchString + fixedURL.substring(searchString.length); + + // If more than an origin was autofilled and the user typed the full + // autofilled value, override the final URL by using the exact value the + // user typed. This allows the user to visit a URL that differs from the + // autofilled URL only in character case (for example "wikipedia.org/RAID" + // vs. "wikipedia.org/Raid") by typing the full desired URL. + if ( + queryType != QUERYTYPE.AUTOFILL_ORIGIN && + queryContext.searchString.length == autofilledValue.length + ) { + // Use `new URL().href` to lowercase the domain in the final completed + // URL. This isn't necessary since domains are case insensitive, but it + // looks nicer because it means the domain will remain lowercased in the + // input, and it also reflects the fact that Firefox will visit the + // lowercased name. + const originalCompleteValue = new URL(finalCompleteValue).href; + let strippedAutofilledValue = autofilledValue.substring( + this._strippedPrefix.length + ); + finalCompleteValue = new URL( + finalCompleteValue.substring( + 0, + finalCompleteValue.length - strippedAutofilledValue.length + ) + strippedAutofilledValue + ).href; + + // If the character case of except origin part of the original + // finalCompleteValue differs from finalCompleteValue that includes user's + // input, we set title null because it expresses different web page. + if (finalCompleteValue !== originalCompleteValue) { + title = null; + } + } + + let payload = { + url: [finalCompleteValue, UrlbarUtils.HIGHLIGHT.TYPED], + icon: UrlbarUtils.getIconForUrl(finalCompleteValue), + }; + + if (title) { + payload.title = [title, UrlbarUtils.HIGHLIGHT.TYPED]; + } else { + let [autofilled] = UrlbarUtils.stripPrefixAndTrim(finalCompleteValue, { + stripHttp: true, + trimEmptyQuery: true, + trimSlash: !this._searchString.includes("/"), + }); + payload.fallbackTitle = [autofilled, UrlbarUtils.HIGHLIGHT.TYPED]; + } + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + payload + ) + ); + + result.autofill = { + adaptiveHistoryInput, + value: autofilledValue, + selectionStart: queryContext.searchString.length, + selectionEnd: autofilledValue.length, + type: autofilledType, + }; + return result; + } + + async _getAutofillResult(queryContext) { + // We may be autofilling an about: link. + let result = this._matchAboutPageForAutofill(queryContext); + if (result) { + return result; + } + + // It may also look like a URL we know from the database. + result = await this._matchKnownUrl(queryContext); + if (result) { + return result; + } + + // Or we may want to fill a search engine domain regardless of the threshold. + result = await this._matchSearchEngineDomain(queryContext); + if (result) { + return result; + } + + return null; + } + + _matchAboutPageForAutofill(queryContext) { + // Check that the typed query is at least one character longer than the + // about: prefix. + if (this._strippedPrefix != "about:" || !this._searchString) { + return null; + } + + for (const aboutUrl of lazy.AboutPagesUtils.visibleAboutUrls) { + if (aboutUrl.startsWith(`about:${this._searchString.toLowerCase()}`)) { + let [trimmedUrl] = UrlbarUtils.stripPrefixAndTrim(aboutUrl, { + stripHttp: true, + trimEmptyQuery: true, + trimSlash: !this._searchString.includes("/"), + }); + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [trimmedUrl, UrlbarUtils.HIGHLIGHT.TYPED], + url: [aboutUrl, UrlbarUtils.HIGHLIGHT.TYPED], + icon: UrlbarUtils.getIconForUrl(aboutUrl), + }) + ); + let autofilledValue = + queryContext.searchString + + aboutUrl.substring(queryContext.searchString.length); + result.autofill = { + type: "about", + value: autofilledValue, + selectionStart: queryContext.searchString.length, + selectionEnd: autofilledValue.length, + }; + return result; + } + } + return null; + } + + async _matchKnownUrl(queryContext) { + let conn = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); + if (!conn) { + return null; + } + + // We try to autofill with adaptive history first. + if ( + lazy.UrlbarPrefs.get("autoFillAdaptiveHistoryEnabled") && + lazy.UrlbarPrefs.get("autoFillAdaptiveHistoryMinCharsThreshold") <= + queryContext.searchString.length + ) { + const [query, params] = this._getAdaptiveHistoryQuery(queryContext); + if (query) { + const resultSet = await conn.executeCached(query, params); + if (resultSet.length) { + return this._processRow(resultSet[0], queryContext); + } + } + } + + // The adaptive history query is passed queryContext.searchString (the full + // search string), but the origin and URL queries are passed the prefix + // (this._strippedPrefix) and the rest of the search string + // (this._searchString) separately. The user must specify a non-prefix part + // to trigger origin and URL autofill. + if (!this._searchString.length) { + return null; + } + + // If search string looks like an origin, try to autofill against origins. + // Otherwise treat it as a possible URL. When the string has only one slash + // at the end, we still treat it as an URL. + let query, params; + if ( + lazy.UrlbarTokenizer.looksLikeOrigin(this._searchString, { + ignoreKnownDomains: true, + }) + ) { + [query, params] = this._getOriginQuery(queryContext); + } else { + [query, params] = this._getUrlQuery(queryContext); + } + + // _getUrlQuery doesn't always return a query. + if (query) { + let rows = await conn.executeCached(query, params); + if (rows.length) { + return this._processRow(rows[0], queryContext); + } + } + return null; + } + + async _matchSearchEngineDomain(queryContext) { + if ( + !lazy.UrlbarPrefs.get("autoFill.searchEngines") || + !this._searchString.length + ) { + return null; + } + + // enginesForDomainPrefix only matches against engine domains. + // Remove an eventual trailing slash from the search string (without the + // prefix) and check if the resulting string is worth matching. + // Later, we'll verify that the found result matches the original + // searchString and eventually discard it. + let searchStr = this._searchString; + if (searchStr.indexOf("/") == searchStr.length - 1) { + searchStr = searchStr.slice(0, -1); + } + // If the search string looks more like a url than a domain, bail out. + if ( + !lazy.UrlbarTokenizer.looksLikeOrigin(searchStr, { + ignoreKnownDomains: true, + }) + ) { + return null; + } + + // Since we are autofilling, we can only pick one matching engine. Use the + // first. + let engine = ( + await lazy.UrlbarSearchUtils.enginesForDomainPrefix(searchStr) + )[0]; + if (!engine) { + return null; + } + let url = engine.searchForm; + let domain = engine.searchUrlDomain; + // Verify that the match we got is acceptable. Autofilling "example/" to + // "example.com/" would not be good. + if ( + (this._strippedPrefix && !url.startsWith(this._strippedPrefix)) || + !(domain + "/").includes(this._searchString) + ) { + return null; + } + + // The value that's autofilled in the input is the prefix the user typed, if + // any, plus the portion of the engine domain that the user typed. Append a + // trailing slash too, as is usual with autofill. + let value = + this._strippedPrefix + domain.substr(domain.indexOf(searchStr)) + "/"; + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED], + icon: engine.iconURI?.spec, + }) + ); + let autofilledValue = + queryContext.searchString + + value.substring(queryContext.searchString.length); + result.autofill = { + value: autofilledValue, + selectionStart: queryContext.searchString.length, + selectionEnd: autofilledValue.length, + }; + return result; + } +} + +export var UrlbarProviderAutofill = new ProviderAutofill(); |