summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/UrlbarResult.sys.mjs
blob: f7756ba3110f7f8ed28c3e354e6c3eba888d73f1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
/* 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 urlbar result class, each representing a single result
 * found by a provider that can be passed from the model to the view through
 * the controller. It is mainly defined by a result type, and a payload,
 * containing the data. A few getters allow to retrieve information common to all
 * the result types.
 */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
  UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
});

XPCOMUtils.defineLazyModuleGetters(lazy, {
  BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm",
  JsonSchemaValidator:
    "resource://gre/modules/components-utils/JsonSchemaValidator.jsm",
});

/**
 * Class used to create a single result.
 */
export class UrlbarResult {
  /**
   * Creates a result.
   *
   * @param {integer} resultType one of UrlbarUtils.RESULT_TYPE.* values
   * @param {integer} resultSource one of UrlbarUtils.RESULT_SOURCE.* values
   * @param {object} payload data for this result. A payload should always
   *        contain a way to extract a final url to visit. The url getter
   *        should have a case for each of the types.
   * @param {object} [payloadHighlights] payload highlights, if any. Each
   *        property in the payload may have a corresponding property in this
   *        object. The value of each property should be an array of [index,
   *        length] tuples. Each tuple indicates a substring in the correspoding
   *        payload property.
   */
  constructor(resultType, resultSource, payload, payloadHighlights = {}) {
    // Type describes the payload and visualization that should be used for
    // this result.
    if (!Object.values(lazy.UrlbarUtils.RESULT_TYPE).includes(resultType)) {
      throw new Error("Invalid result type");
    }
    this.type = resultType;

    // Source describes which data has been used to derive this result. In case
    // multiple sources are involved, use the more privacy restricted.
    if (!Object.values(lazy.UrlbarUtils.RESULT_SOURCE).includes(resultSource)) {
      throw new Error("Invalid result source");
    }
    this.source = resultSource;

    // UrlbarView is responsible for updating this.
    this.rowIndex = -1;

    // May be used to indicate an heuristic result. Heuristic results can bypass
    // source filters in the ProvidersManager, that otherwise may skip them.
    this.heuristic = false;

    // The payload contains result data. Some of the data is common across
    // multiple types, but most of it will vary.
    if (!payload || typeof payload != "object") {
      throw new Error("Invalid result payload");
    }
    this.payload = this.validatePayload(payload);

    if (!payloadHighlights || typeof payloadHighlights != "object") {
      throw new Error("Invalid result payload highlights");
    }
    this.payloadHighlights = payloadHighlights;

    // Make sure every property in the payload has an array of highlights.  If a
    // payload property does not have a highlights array, then give it one now.
    // That way the consumer doesn't need to check whether it exists.
    for (let name in payload) {
      if (!(name in this.payloadHighlights)) {
        this.payloadHighlights[name] = [];
      }
    }
  }

  /**
   * Returns a title that could be used as a label for this result.
   *
   * @returns {string} The label to show in a simplified title / url view.
   */
  get title() {
    return this._titleAndHighlights[0];
  }

  /**
   * Returns an array of highlights for the title.
   *
   * @returns {Array} The array of highlights.
   */
  get titleHighlights() {
    return this._titleAndHighlights[1];
  }

  /**
   * Returns an array [title, highlights].
   *
   * @returns {Array} The title and array of highlights.
   */
  get _titleAndHighlights() {
    switch (this.type) {
      case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD:
      case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH:
      case lazy.UrlbarUtils.RESULT_TYPE.URL:
      case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX:
      case lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB:
        if (this.payload.qsSuggestion) {
          return [
            // We will initially only be targetting en-US users with this experiment
            // but will need to change this to work properly with l10n.
            this.payload.qsSuggestion + " — " + this.payload.title,
            this.payloadHighlights.qsSuggestion,
          ];
        }
        return this.payload.title
          ? [this.payload.title, this.payloadHighlights.title]
          : [this.payload.url || "", this.payloadHighlights.url || []];
      case lazy.UrlbarUtils.RESULT_TYPE.SEARCH:
        if (this.payload.providesSearchMode) {
          return ["", []];
        }
        if (this.payload.tail && this.payload.tailOffsetIndex >= 0) {
          return [this.payload.tail, this.payloadHighlights.tail];
        } else if (this.payload.suggestion) {
          return [this.payload.suggestion, this.payloadHighlights.suggestion];
        }
        return [this.payload.query, this.payloadHighlights.query];
      default:
        return ["", []];
    }
  }

  /**
   * Returns an icon url.
   *
   * @returns {string} url of the icon.
   */
  get icon() {
    return this.payload.icon;
  }

  /**
   * Returns whether the result's `suggestedIndex` property is defined.
   * `suggestedIndex` is an optional hint to the muxer that can be set to
   * suggest a specific position among the results.
   *
   * @returns {boolean} Whether `suggestedIndex` is defined.
   */
  get hasSuggestedIndex() {
    return typeof this.suggestedIndex == "number";
  }

  /**
   * Returns the given payload if it's valid or throws an error if it's not.
   * The schemas in UrlbarUtils.RESULT_PAYLOAD_SCHEMA are used for validation.
   *
   * @param {object} payload The payload object.
   * @returns {object} `payload` if it's valid.
   */
  validatePayload(payload) {
    let schema = lazy.UrlbarUtils.getPayloadSchema(this.type);
    if (!schema) {
      throw new Error(`Unrecognized result type: ${this.type}`);
    }
    let result = lazy.JsonSchemaValidator.validate(payload, schema, {
      allowExplicitUndefinedProperties: true,
      allowNullAsUndefinedProperties: true,
      allowExtraProperties: this.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC,
    });
    if (!result.valid) {
      throw result.error;
    }
    return payload;
  }

  /**
   * A convenience function that takes a payload annotated with
   * UrlbarUtils.HIGHLIGHT enums and returns the payload and the payload's
   * highlights. Use this function when the highlighting required by your
   * payload is based on simple substring matching, as done by
   * UrlbarUtils.getTokenMatches(). Pass the return values as the `payload` and
   * `payloadHighlights` params of the UrlbarResult constructor.
   * `payloadHighlights` is optional. If omitted, payload will not be
   * highlighted.
   *
   * If the payload doesn't have a title or has an empty title, and it also has
   * a URL, then this function also sets the title to the URL's domain.
   *
   * @param {Array} tokens The tokens that should be highlighted in each of the
   *        payload properties.
   * @param {object} payloadInfo An object that looks like this:
   *        { payloadPropertyName: payloadPropertyInfo }
   *
   *        Each payloadPropertyInfo may be either a string or an array.  If
   *        it's a string, then the property value will be that string, and no
   *        highlighting will be applied to it.  If it's an array, then it
   *        should look like this: [payloadPropertyValue, highlightType].
   *        payloadPropertyValue may be a string or an array of strings.  If
   *        it's a string, then the payloadHighlights in the return value will
   *        be an array of match highlights as described in
   *        UrlbarUtils.getTokenMatches().  If it's an array, then
   *        payloadHighlights will be an array of arrays of match highlights,
   *        one element per element in payloadPropertyValue.
   * @returns {Array} An array [payload, payloadHighlights].
   */
  static payloadAndSimpleHighlights(tokens, payloadInfo) {
    // Convert scalar values in payloadInfo to [value] arrays.
    for (let [name, info] of Object.entries(payloadInfo)) {
      if (!Array.isArray(info)) {
        payloadInfo[name] = [info];
      }
    }

    if (
      (!payloadInfo.title || !payloadInfo.title[0]) &&
      payloadInfo.url &&
      typeof payloadInfo.url[0] == "string"
    ) {
      // If there's no title, show the domain as the title.  Not all valid URLs
      // have a domain.
      payloadInfo.title = payloadInfo.title || [
        "",
        lazy.UrlbarUtils.HIGHLIGHT.TYPED,
      ];
      try {
        payloadInfo.title[0] = new URL(payloadInfo.url[0]).host;
      } catch (e) {}
    }

    if (payloadInfo.url) {
      // For display purposes we need to unescape the url.
      payloadInfo.displayUrl = [...payloadInfo.url];
      let url = payloadInfo.displayUrl[0];
      if (url && lazy.UrlbarPrefs.get("trimURLs")) {
        url = lazy.BrowserUIUtils.removeSingleTrailingSlashFromURL(url);
        if (url.startsWith("https://")) {
          url = url.substring(8);
          if (url.startsWith("www.")) {
            url = url.substring(4);
          }
        }
      }
      payloadInfo.displayUrl[0] = lazy.UrlbarUtils.unEscapeURIForUI(url);
    }

    // For performance reasons limit excessive string lengths, to reduce the
    // amount of string matching we do here, and avoid wasting resources to
    // handle long textruns that the user would never see anyway.
    for (let prop of ["displayUrl", "title", "suggestion"]) {
      let val = payloadInfo[prop]?.[0];
      if (typeof val == "string") {
        payloadInfo[prop][0] = val.substring(
          0,
          lazy.UrlbarUtils.MAX_TEXT_LENGTH
        );
      }
    }

    let entries = Object.entries(payloadInfo);
    return [
      entries.reduce((payload, [name, [val, _]]) => {
        payload[name] = val;
        return payload;
      }, {}),
      entries.reduce((highlights, [name, [val, highlightType]]) => {
        if (highlightType) {
          highlights[name] = !Array.isArray(val)
            ? lazy.UrlbarUtils.getTokenMatches(tokens, val || "", highlightType)
            : val.map(subval =>
                lazy.UrlbarUtils.getTokenMatches(tokens, subval, highlightType)
              );
        }
        return highlights;
      }, {}),
    ];
  }

  static _dynamicResultTypesByName = new Map();

  /**
   * Registers a dynamic result type.  Dynamic result types are types that are
   * created at runtime, for example by an extension.  A particular type should
   * be added only once; if this method is called for a type more than once, the
   * `type` in the last call overrides those in previous calls.
   *
   * @param {string} name
   *   The name of the type.  This is used in CSS selectors, so it shouldn't
   *   contain any spaces or punctuation except for -, _, etc.
   * @param {object} type
   *   An object that describes the type.  Currently types do not have any
   *   associated metadata, so this object should be empty.
   */
  static addDynamicResultType(name, type = {}) {
    if (/[^a-z0-9_-]/i.test(name)) {
      this.logger.error(`Illegal dynamic type name: ${name}`);
      return;
    }
    this._dynamicResultTypesByName.set(name, type);
  }

  /**
   * Unregisters a dynamic result type.
   *
   * @param {string} name
   *   The name of the type.
   */
  static removeDynamicResultType(name) {
    let type = this._dynamicResultTypesByName.get(name);
    if (type) {
      this._dynamicResultTypesByName.delete(name);
    }
  }

  /**
   * Returns an object describing a registered dynamic result type.
   *
   * @param {string} name
   *   The name of the type.
   * @returns {object}
   *   Currently types do not have any associated metadata, so the return value
   *   is an empty object if the type exists.  If the type doesn't exist,
   *   undefined is returned.
   */
  static getDynamicResultType(name) {
    return this._dynamicResultTypesByName.get(name);
  }

  /**
   * This is useful for logging results. If you need the full payload, then it's
   * better to JSON.stringify the result object itself.
   *
   * @returns {string} string representation of the result.
   */
  toString() {
    if (this.payload.url) {
      return this.payload.title + " - " + this.payload.url.substr(0, 100);
    }
    if (this.payload.keyword) {
      return this.payload.keyword + " - " + this.payload.query;
    }
    if (this.payload.suggestion) {
      return this.payload.engine + " - " + this.payload.suggestion;
    }
    if (this.payload.engine) {
      return this.payload.engine + " - " + this.payload.query;
    }
    return JSON.stringify(this);
  }
}