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/. */
4
5"use strict";
6
7/**
8 * This module exports a urlbar result class, each representing a single result
9 * found by a provider that can be passed from the model to the view through
10 * the controller. It is mainly defined by a result type, and a payload,
11 * containing the data. A few getters allow to retrieve information common to all
12 * the result types.
13 */
14
15var EXPORTED_SYMBOLS = ["UrlbarResult"];
16
17const { XPCOMUtils } = ChromeUtils.import(
18  "resource://gre/modules/XPCOMUtils.jsm"
19);
20XPCOMUtils.defineLazyModuleGetters(this, {
21  BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm",
22  JsonSchemaValidator:
23    "resource://gre/modules/components-utils/JsonSchemaValidator.jsm",
24  Services: "resource://gre/modules/Services.jsm",
25  UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm",
26  UrlbarUtils: "resource:///modules/UrlbarUtils.jsm",
27});
28
29/**
30 * Class used to create a single result.
31 */
32class UrlbarResult {
33  /**
34   * Creates a result.
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(UrlbarUtils.RESULT_TYPE).includes(resultType)) {
50      throw new Error("Invalid result type");
51    }
52    this.type = resultType;
53
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(UrlbarUtils.RESULT_SOURCE).includes(resultSource)) {
57      throw new Error("Invalid result source");
58    }
59    this.source = resultSource;
60
61    // UrlbarView is responsible for updating this.
62    this.rowIndex = -1;
63
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;
67
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);
74
75    if (!payloadHighlights || typeof payloadHighlights != "object") {
76      throw new Error("Invalid result payload highlights");
77    }
78    this.payloadHighlights = payloadHighlights;
79
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  }
89
90  /**
91   * Returns a title that could be used as a label for this result.
92   * @returns {string} The label to show in a simplified title / url view.
93   */
94  get title() {
95    return this._titleAndHighlights[0];
96  }
97
98  /**
99   * Returns an array of highlights for the title.
100   * @returns {array} The array of highlights.
101   */
102  get titleHighlights() {
103    return this._titleAndHighlights[1];
104  }
105
106  /**
107   * Returns an array [title, highlights].
108   * @returns {array} The title and array of highlights.
109   */
110  get _titleAndHighlights() {
111    switch (this.type) {
112      case UrlbarUtils.RESULT_TYPE.KEYWORD:
113      case UrlbarUtils.RESULT_TYPE.TAB_SWITCH:
114      case UrlbarUtils.RESULT_TYPE.URL:
115      case UrlbarUtils.RESULT_TYPE.OMNIBOX:
116      case UrlbarUtils.RESULT_TYPE.REMOTE_TAB:
117        if (this.payload.qsSuggestion) {
118          return [
119            // We will initially only be targetting en-US users with this experiment
120            // but will need to change this to work properly with l10n.
121            this.payload.qsSuggestion + " — " + this.payload.title,
122            this.payloadHighlights.qsSuggestion,
123          ];
124        }
125        return this.payload.title
126          ? [this.payload.title, this.payloadHighlights.title]
127          : [this.payload.url || "", this.payloadHighlights.url || []];
128      case UrlbarUtils.RESULT_TYPE.SEARCH:
129        if (this.payload.providesSearchMode) {
130          return ["", []];
131        }
132        if (this.payload.tail && this.payload.tailOffsetIndex >= 0) {
133          return [this.payload.tail, this.payloadHighlights.tail];
134        } else if (this.payload.suggestion) {
135          return [this.payload.suggestion, this.payloadHighlights.suggestion];
136        }
137        return [this.payload.query, this.payloadHighlights.query];
138      default:
139        return ["", []];
140    }
141  }
142
143  /**
144   * Returns an icon url.
145   * @returns {string} url of the icon.
146   */
147  get icon() {
148    return this.payload.icon;
149  }
150
151  /**
152   * Returns whether the result's `suggestedIndex` property is defined.
153   * `suggestedIndex` is an optional hint to the muxer that can be set to
154   * suggest a specific position among the results.
155   * @returns {boolean} Whether `suggestedIndex` is defined.
156   */
157  get hasSuggestedIndex() {
158    return typeof this.suggestedIndex == "number";
159  }
160
161  /**
162   * Returns the given payload if it's valid or throws an error if it's not.
163   * The schemas in UrlbarUtils.RESULT_PAYLOAD_SCHEMA are used for validation.
164   *
165   * @param {object} payload The payload object.
166   * @returns {object} `payload` if it's valid.
167   */
168  validatePayload(payload) {
169    let schema = UrlbarUtils.getPayloadSchema(this.type);
170    if (!schema) {
171      throw new Error(`Unrecognized result type: ${this.type}`);
172    }
173    let result = JsonSchemaValidator.validate(payload, schema, {
174      allowExplicitUndefinedProperties: true,
175      allowNullAsUndefinedProperties: true,
176      allowExtraProperties: this.type == UrlbarUtils.RESULT_TYPE.DYNAMIC,
177    });
178    if (!result.valid) {
179      throw result.error;
180    }
181    return payload;
182  }
183
184  /**
185   * A convenience function that takes a payload annotated with
186   * UrlbarUtils.HIGHLIGHT enums and returns the payload and the payload's
187   * highlights. Use this function when the highlighting required by your
188   * payload is based on simple substring matching, as done by
189   * UrlbarUtils.getTokenMatches(). Pass the return values as the `payload` and
190   * `payloadHighlights` params of the UrlbarResult constructor.
191   * `payloadHighlights` is optional. If omitted, payload will not be
192   * highlighted.
193   *
194   * If the payload doesn't have a title or has an empty title, and it also has
195   * a URL, then this function also sets the title to the URL's domain.
196   *
197   * @param {array} tokens The tokens that should be highlighted in each of the
198   *        payload properties.
199   * @param {object} payloadInfo An object that looks like this:
200   *        { payloadPropertyName: payloadPropertyInfo }
201   *
202   *        Each payloadPropertyInfo may be either a string or an array.  If
203   *        it's a string, then the property value will be that string, and no
204   *        highlighting will be applied to it.  If it's an array, then it
205   *        should look like this: [payloadPropertyValue, highlightType].
206   *        payloadPropertyValue may be a string or an array of strings.  If
207   *        it's a string, then the payloadHighlights in the return value will
208   *        be an array of match highlights as described in
209   *        UrlbarUtils.getTokenMatches().  If it's an array, then
210   *        payloadHighlights will be an array of arrays of match highlights,
211   *        one element per element in payloadPropertyValue.
212   * @returns {array} An array [payload, payloadHighlights].
213   */
214  static payloadAndSimpleHighlights(tokens, payloadInfo) {
215    // Convert scalar values in payloadInfo to [value] arrays.
216    for (let [name, info] of Object.entries(payloadInfo)) {
217      if (!Array.isArray(info)) {
218        payloadInfo[name] = [info];
219      }
220    }
221
222    if (
223      (!payloadInfo.title || !payloadInfo.title[0]) &&
224      payloadInfo.url &&
225      typeof payloadInfo.url[0] == "string"
226    ) {
227      // If there's no title, show the domain as the title.  Not all valid URLs
228      // have a domain.
229      payloadInfo.title = payloadInfo.title || [
230        "",
231        UrlbarUtils.HIGHLIGHT.TYPED,
232      ];
233      try {
234        payloadInfo.title[0] = new URL(payloadInfo.url[0]).host;
235      } catch (e) {}
236    }
237
238    if (payloadInfo.url) {
239      // For display purposes we need to unescape the url.
240      payloadInfo.displayUrl = [...payloadInfo.url];
241      let url = payloadInfo.displayUrl[0];
242      if (url && UrlbarPrefs.get("trimURLs")) {
243        url = BrowserUIUtils.removeSingleTrailingSlashFromURL(url);
244        if (url.startsWith("https://")) {
245          url = url.substring(8);
246          if (url.startsWith("www.")) {
247            url = url.substring(4);
248          }
249        }
250      }
251      payloadInfo.displayUrl[0] = Services.textToSubURI.unEscapeURIForUI(url);
252    }
253
254    // For performance reasons limit excessive string lengths, to reduce the
255    // amount of string matching we do here, and avoid wasting resources to
256    // handle long textruns that the user would never see anyway.
257    for (let prop of ["displayUrl", "title", "suggestion"]) {
258      let val = payloadInfo[prop]?.[0];
259      if (typeof val == "string") {
260        payloadInfo[prop][0] = val.substring(0, UrlbarUtils.MAX_TEXT_LENGTH);
261      }
262    }
263
264    let entries = Object.entries(payloadInfo);
265    return [
266      entries.reduce((payload, [name, [val, _]]) => {
267        payload[name] = val;
268        return payload;
269      }, {}),
270      entries.reduce((highlights, [name, [val, highlightType]]) => {
271        if (highlightType) {
272          highlights[name] = !Array.isArray(val)
273            ? UrlbarUtils.getTokenMatches(tokens, val || "", highlightType)
274            : val.map(subval =>
275                UrlbarUtils.getTokenMatches(tokens, subval, highlightType)
276              );
277        }
278        return highlights;
279      }, {}),
280    ];
281  }
282
283  static _dynamicResultTypesByName = new Map();
284
285  /**
286   * Registers a dynamic result type.  Dynamic result types are types that are
287   * created at runtime, for example by an extension.  A particular type should
288   * be added only once; if this method is called for a type more than once, the
289   * `type` in the last call overrides those in previous calls.
290   *
291   * @param {string} name
292   *   The name of the type.  This is used in CSS selectors, so it shouldn't
293   *   contain any spaces or punctuation except for -, _, etc.
294   * @param {object} type
295   *   An object that describes the type.  Currently types do not have any
296   *   associated metadata, so this object should be empty.
297   */
298  static addDynamicResultType(name, type = {}) {
299    if (/[^a-z0-9_-]/i.test(name)) {
300      Cu.reportError(`Illegal dynamic type name: ${name}`);
301      return;
302    }
303    this._dynamicResultTypesByName.set(name, type);
304  }
305
306  /**
307   * Unregisters a dynamic result type.
308   *
309   * @param {string} name
310   *   The name of the type.
311   */
312  static removeDynamicResultType(name) {
313    let type = this._dynamicResultTypesByName.get(name);
314    if (type) {
315      this._dynamicResultTypesByName.delete(name);
316    }
317  }
318
319  /**
320   * Returns an object describing a registered dynamic result type.
321   *
322   * @param {string} name
323   *   The name of the type.
324   * @returns {object}
325   *   Currently types do not have any associated metadata, so the return value
326   *   is an empty object if the type exists.  If the type doesn't exist,
327   *   undefined is returned.
328   */
329  static getDynamicResultType(name) {
330    return this._dynamicResultTypesByName.get(name);
331  }
332
333  /**
334   * This is useful for logging results. If you need the full payload, then it's
335   * better to JSON.stringify the result object itself.
336   * @returns {string} string representation of the result.
337   */
338  toString() {
339    if (this.payload.url) {
340      return this.payload.title + " - " + this.payload.url.substr(0, 100);
341    }
342    if (this.payload.keyword) {
343      return this.payload.keyword + " - " + this.payload.query;
344    }
345    if (this.payload.suggestion) {
346      return this.payload.engine + " - " + this.payload.suggestion;
347    }
348    if (this.payload.engine) {
349      return this.payload.engine + " - " + this.payload.query;
350    }
351    return JSON.stringify(this);
352  }
353}
354