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