Bug 1824490 - Use the end page value rather than the start page value of the previous...
[gecko.git] / browser / components / urlbar / UrlbarResult.sys.mjs
blob729240a111261dda32c384ffbaaacb7863411f89
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /**
6  * This module exports a urlbar result class, each representing a single result
7  * found by a provider that can be passed from the model to the view through
8  * the controller. It is mainly defined by a result type, and a payload,
9  * containing the data. A few getters allow to retrieve information common to all
10  * the result types.
11  */
13 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
15 const lazy = {};
17 ChromeUtils.defineESModuleGetters(lazy, {
18   JsonSchemaValidator:
19     "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs",
20   UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
21   UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
22 });
24 XPCOMUtils.defineLazyModuleGetters(lazy, {
25   BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm",
26 });
28 /**
29  * Class used to create a single result.
30  */
31 export class UrlbarResult {
32   /**
33    * Creates a result.
34    *
35    * @param {integer} resultType one of UrlbarUtils.RESULT_TYPE.* values
36    * @param {integer} resultSource one of UrlbarUtils.RESULT_SOURCE.* values
37    * @param {object} payload data for this result. A payload should always
38    *        contain a way to extract a final url to visit. The url getter
39    *        should have a case for each of the types.
40    * @param {object} [payloadHighlights] payload highlights, if any. Each
41    *        property in the payload may have a corresponding property in this
42    *        object. The value of each property should be an array of [index,
43    *        length] tuples. Each tuple indicates a substring in the correspoding
44    *        payload property.
45    */
46   constructor(resultType, resultSource, payload, payloadHighlights = {}) {
47     // Type describes the payload and visualization that should be used for
48     // this result.
49     if (!Object.values(lazy.UrlbarUtils.RESULT_TYPE).includes(resultType)) {
50       throw new Error("Invalid result type");
51     }
52     this.type = resultType;
54     // Source describes which data has been used to derive this result. In case
55     // multiple sources are involved, use the more privacy restricted.
56     if (!Object.values(lazy.UrlbarUtils.RESULT_SOURCE).includes(resultSource)) {
57       throw new Error("Invalid result source");
58     }
59     this.source = resultSource;
61     // UrlbarView is responsible for updating this.
62     this.rowIndex = -1;
64     // May be used to indicate an heuristic result. Heuristic results can bypass
65     // source filters in the ProvidersManager, that otherwise may skip them.
66     this.heuristic = false;
68     // The payload contains result data. Some of the data is common across
69     // multiple types, but most of it will vary.
70     if (!payload || typeof payload != "object") {
71       throw new Error("Invalid result payload");
72     }
73     this.payload = this.validatePayload(payload);
75     if (!payloadHighlights || typeof payloadHighlights != "object") {
76       throw new Error("Invalid result payload highlights");
77     }
78     this.payloadHighlights = payloadHighlights;
80     // Make sure every property in the payload has an array of highlights.  If a
81     // payload property does not have a highlights array, then give it one now.
82     // That way the consumer doesn't need to check whether it exists.
83     for (let name in payload) {
84       if (!(name in this.payloadHighlights)) {
85         this.payloadHighlights[name] = [];
86       }
87     }
88   }
90   /**
91    * Returns a title that could be used as a label for this result.
92    *
93    * @returns {string} The label to show in a simplified title / url view.
94    */
95   get title() {
96     return this._titleAndHighlights[0];
97   }
99   /**
100    * Returns an array of highlights for the title.
101    *
102    * @returns {Array} The array of highlights.
103    */
104   get titleHighlights() {
105     return this._titleAndHighlights[1];
106   }
108   /**
109    * Returns an array [title, highlights].
110    *
111    * @returns {Array} The title and array of highlights.
112    */
113   get _titleAndHighlights() {
114     switch (this.type) {
115       case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD:
116       case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH:
117       case lazy.UrlbarUtils.RESULT_TYPE.URL:
118       case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX:
119       case lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB:
120         if (this.payload.qsSuggestion) {
121           return [
122             // We will initially only be targetting en-US users with this experiment
123             // but will need to change this to work properly with l10n.
124             this.payload.qsSuggestion + " — " + this.payload.title,
125             this.payloadHighlights.qsSuggestion,
126           ];
127         }
129         if (this.payload.fallbackTitle) {
130           return [
131             this.payload.fallbackTitle,
132             this.payloadHighlights.fallbackTitle,
133           ];
134         }
136         if (this.payload.title) {
137           return [this.payload.title, this.payloadHighlights.title];
138         }
140         return [this.payload.url ?? "", this.payloadHighlights.url ?? []];
141       case lazy.UrlbarUtils.RESULT_TYPE.SEARCH:
142         if (this.payload.providesSearchMode) {
143           return ["", []];
144         }
145         if (this.payload.tail && this.payload.tailOffsetIndex >= 0) {
146           return [this.payload.tail, this.payloadHighlights.tail];
147         } else if (this.payload.suggestion) {
148           return [this.payload.suggestion, this.payloadHighlights.suggestion];
149         }
150         return [this.payload.query, this.payloadHighlights.query];
151       default:
152         return ["", []];
153     }
154   }
156   /**
157    * Returns an icon url.
158    *
159    * @returns {string} url of the icon.
160    */
161   get icon() {
162     return this.payload.icon;
163   }
165   /**
166    * Returns whether the result's `suggestedIndex` property is defined.
167    * `suggestedIndex` is an optional hint to the muxer that can be set to
168    * suggest a specific position among the results.
169    *
170    * @returns {boolean} Whether `suggestedIndex` is defined.
171    */
172   get hasSuggestedIndex() {
173     return typeof this.suggestedIndex == "number";
174   }
176   /**
177    * Returns the given payload if it's valid or throws an error if it's not.
178    * The schemas in UrlbarUtils.RESULT_PAYLOAD_SCHEMA are used for validation.
179    *
180    * @param {object} payload The payload object.
181    * @returns {object} `payload` if it's valid.
182    */
183   validatePayload(payload) {
184     let schema = lazy.UrlbarUtils.getPayloadSchema(this.type);
185     if (!schema) {
186       throw new Error(`Unrecognized result type: ${this.type}`);
187     }
188     let result = lazy.JsonSchemaValidator.validate(payload, schema, {
189       allowExplicitUndefinedProperties: true,
190       allowNullAsUndefinedProperties: true,
191       allowExtraProperties: this.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC,
192     });
193     if (!result.valid) {
194       throw result.error;
195     }
196     return payload;
197   }
199   /**
200    * A convenience function that takes a payload annotated with
201    * UrlbarUtils.HIGHLIGHT enums and returns the payload and the payload's
202    * highlights. Use this function when the highlighting required by your
203    * payload is based on simple substring matching, as done by
204    * UrlbarUtils.getTokenMatches(). Pass the return values as the `payload` and
205    * `payloadHighlights` params of the UrlbarResult constructor.
206    * `payloadHighlights` is optional. If omitted, payload will not be
207    * highlighted.
208    *
209    * If the payload doesn't have a title or has an empty title, and it also has
210    * a URL, then this function also sets the title to the URL's domain.
211    *
212    * @param {Array} tokens The tokens that should be highlighted in each of the
213    *        payload properties.
214    * @param {object} payloadInfo An object that looks like this:
215    *        { payloadPropertyName: payloadPropertyInfo }
216    *
217    *        Each payloadPropertyInfo may be either a string or an array.  If
218    *        it's a string, then the property value will be that string, and no
219    *        highlighting will be applied to it.  If it's an array, then it
220    *        should look like this: [payloadPropertyValue, highlightType].
221    *        payloadPropertyValue may be a string or an array of strings.  If
222    *        it's a string, then the payloadHighlights in the return value will
223    *        be an array of match highlights as described in
224    *        UrlbarUtils.getTokenMatches().  If it's an array, then
225    *        payloadHighlights will be an array of arrays of match highlights,
226    *        one element per element in payloadPropertyValue.
227    * @returns {Array} An array [payload, payloadHighlights].
228    */
229   static payloadAndSimpleHighlights(tokens, payloadInfo) {
230     // Convert scalar values in payloadInfo to [value] arrays.
231     for (let [name, info] of Object.entries(payloadInfo)) {
232       if (!Array.isArray(info)) {
233         payloadInfo[name] = [info];
234       }
235     }
237     if (
238       (!payloadInfo.title || !payloadInfo.title[0]) &&
239       !payloadInfo.fallbackTitle &&
240       payloadInfo.url &&
241       typeof payloadInfo.url[0] == "string"
242     ) {
243       // If there's no title, show the domain as the title.  Not all valid URLs
244       // have a domain.
245       payloadInfo.title = payloadInfo.title || [
246         "",
247         lazy.UrlbarUtils.HIGHLIGHT.TYPED,
248       ];
249       try {
250         payloadInfo.title[0] = new URL(payloadInfo.url[0]).host;
251       } catch (e) {}
252     }
254     if (payloadInfo.url) {
255       // For display purposes we need to unescape the url.
256       payloadInfo.displayUrl = [...payloadInfo.url];
257       let url = payloadInfo.displayUrl[0];
258       if (url && lazy.UrlbarPrefs.get("trimURLs")) {
259         url = lazy.BrowserUIUtils.removeSingleTrailingSlashFromURL(url);
260         if (url.startsWith("https://")) {
261           url = url.substring(8);
262           if (url.startsWith("www.")) {
263             url = url.substring(4);
264           }
265         }
266       }
267       payloadInfo.displayUrl[0] = lazy.UrlbarUtils.unEscapeURIForUI(url);
268     }
270     // For performance reasons limit excessive string lengths, to reduce the
271     // amount of string matching we do here, and avoid wasting resources to
272     // handle long textruns that the user would never see anyway.
273     for (let prop of ["displayUrl", "title", "suggestion"]) {
274       let val = payloadInfo[prop]?.[0];
275       if (typeof val == "string") {
276         payloadInfo[prop][0] = val.substring(
277           0,
278           lazy.UrlbarUtils.MAX_TEXT_LENGTH
279         );
280       }
281     }
283     let entries = Object.entries(payloadInfo);
284     return [
285       entries.reduce((payload, [name, [val, _]]) => {
286         payload[name] = val;
287         return payload;
288       }, {}),
289       entries.reduce((highlights, [name, [val, highlightType]]) => {
290         if (highlightType) {
291           highlights[name] = !Array.isArray(val)
292             ? lazy.UrlbarUtils.getTokenMatches(tokens, val || "", highlightType)
293             : val.map(subval =>
294                 lazy.UrlbarUtils.getTokenMatches(tokens, subval, highlightType)
295               );
296         }
297         return highlights;
298       }, {}),
299     ];
300   }
302   static _dynamicResultTypesByName = new Map();
304   /**
305    * Registers a dynamic result type.  Dynamic result types are types that are
306    * created at runtime, for example by an extension.  A particular type should
307    * be added only once; if this method is called for a type more than once, the
308    * `type` in the last call overrides those in previous calls.
309    *
310    * @param {string} name
311    *   The name of the type.  This is used in CSS selectors, so it shouldn't
312    *   contain any spaces or punctuation except for -, _, etc.
313    * @param {object} type
314    *   An object that describes the type.  Currently types do not have any
315    *   associated metadata, so this object should be empty.
316    */
317   static addDynamicResultType(name, type = {}) {
318     if (/[^a-z0-9_-]/i.test(name)) {
319       this.logger.error(`Illegal dynamic type name: ${name}`);
320       return;
321     }
322     this._dynamicResultTypesByName.set(name, type);
323   }
325   /**
326    * Unregisters a dynamic result type.
327    *
328    * @param {string} name
329    *   The name of the type.
330    */
331   static removeDynamicResultType(name) {
332     let type = this._dynamicResultTypesByName.get(name);
333     if (type) {
334       this._dynamicResultTypesByName.delete(name);
335     }
336   }
338   /**
339    * Returns an object describing a registered dynamic result type.
340    *
341    * @param {string} name
342    *   The name of the type.
343    * @returns {object}
344    *   Currently types do not have any associated metadata, so the return value
345    *   is an empty object if the type exists.  If the type doesn't exist,
346    *   undefined is returned.
347    */
348   static getDynamicResultType(name) {
349     return this._dynamicResultTypesByName.get(name);
350   }
352   /**
353    * This is useful for logging results. If you need the full payload, then it's
354    * better to JSON.stringify the result object itself.
355    *
356    * @returns {string} string representation of the result.
357    */
358   toString() {
359     if (this.payload.url) {
360       return this.payload.title + " - " + this.payload.url.substr(0, 100);
361     }
362     if (this.payload.keyword) {
363       return this.payload.keyword + " - " + this.payload.query;
364     }
365     if (this.payload.suggestion) {
366       return this.payload.engine + " - " + this.payload.suggestion;
367     }
368     if (this.payload.engine) {
369       return this.payload.engine + " - " + this.payload.query;
370     }
371     return JSON.stringify(this);
372   }