addEngine.js (14391B)
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 /* globals AdjustableTitle */ 6 7 // This is the dialog that is displayed when adding or editing a search engine 8 // in about:preferences, or when adding a search engine via the context menu of 9 // an HTML form. Depending on the scenario where it is used, different arguments 10 // must be supplied in an object in `window.arguments[0]`: 11 // - `mode` [required] - The type of dialog: NEW, EDIT or FORM. 12 // - `title` [optional] - Whether to display a title in the window element. 13 // - all arguments required by the constructor of the dialog class 14 15 /** 16 * @import {UserSearchEngine} from "../../../../toolkit/components/search/UserSearchEngine.sys.mjs" 17 */ 18 19 const lazy = {}; 20 21 ChromeUtils.defineESModuleGetters(lazy, { 22 SearchUtils: "moz-src:///toolkit/components/search/SearchUtils.sys.mjs", 23 }); 24 25 // Set the appropriate l10n id before the dialog's connectedCallback. 26 if (window.arguments[0].mode == "EDIT") { 27 document.l10n.setAttributes( 28 document.querySelector("dialog"), 29 "edit-engine-dialog" 30 ); 31 document.l10n.setAttributes( 32 document.querySelector("window"), 33 "edit-engine-window" 34 ); 35 } else { 36 document.l10n.setAttributes( 37 document.querySelector("dialog"), 38 "add-engine-dialog2" 39 ); 40 document.l10n.setAttributes( 41 document.querySelector("window"), 42 "add-engine-window" 43 ); 44 } 45 46 let loadedResolvers = Promise.withResolvers(); 47 document.mozSubdialogReady = loadedResolvers.promise; 48 49 /** @type {?EngineDialog} */ 50 let gAddEngineDialog = null; 51 /** @type {?Map<string, string>} */ 52 let l10nCache = null; 53 54 /** 55 * The abstract base class for all types of user search engine dialogs. 56 * All subclasses must implement the abstract method `onAddEngine`. 57 */ 58 class EngineDialog { 59 constructor() { 60 this._dialog = document.querySelector("dialog"); 61 62 this._form = document.getElementById("addEngineForm"); 63 this._name = document.getElementById("engineName"); 64 this._alias = document.getElementById("engineAlias"); 65 this._url = document.getElementById("engineUrl"); 66 this._postData = document.getElementById("enginePostData"); 67 this._suggestUrl = document.getElementById("suggestUrl"); 68 69 this._form.addEventListener("input", e => this.validateInput(e.target)); 70 document.addEventListener("dialogaccept", this.onAccept.bind(this)); 71 document.addEventListener("dialogextra1", () => this.showAdvanced()); 72 } 73 74 /** 75 * Shows the advanced section and hides the advanced button. 76 * 77 * @param {boolean} [resize] 78 * Whether the resizeDialog should be called. Before `mozSubdialogReady` 79 * is resolved, this should be false to avoid flickering. 80 */ 81 showAdvanced(resize = true) { 82 this._dialog.getButton("extra1").hidden = true; 83 document.getElementById("advanced-section").hidden = false; 84 if (resize) { 85 window.resizeDialog(); 86 } 87 } 88 89 onAccept() { 90 throw new Error("abstract"); 91 } 92 93 validateName() { 94 let name = this._name.value.trim(); 95 if (!name) { 96 this.setValidity(this._name, "add-engine-no-name"); 97 return; 98 } 99 100 let existingEngine = Services.search.getEngineByName(name); 101 if (existingEngine && !this.allowedNames.includes(name)) { 102 this.setValidity(this._name, "add-engine-name-exists"); 103 return; 104 } 105 106 this.setValidity(this._name, null); 107 } 108 109 async validateAlias() { 110 let alias = this._alias.value.trim(); 111 if (!alias) { 112 this.setValidity(this._alias, null); 113 return; 114 } 115 116 let existingEngine = await Services.search.getEngineByAlias(alias); 117 if (existingEngine && !this.allowedAliases.includes(alias)) { 118 this.setValidity(this._alias, "add-engine-keyword-exists"); 119 return; 120 } 121 122 this.setValidity(this._alias, null); 123 } 124 125 validateUrlInput() { 126 let urlString = this._url.value.trim(); 127 if (!urlString) { 128 this.setValidity(this._url, "add-engine-no-url"); 129 return; 130 } 131 132 let url = URL.parse(urlString); 133 if (!url) { 134 this.setValidity(this._url, "add-engine-invalid-url"); 135 return; 136 } 137 138 if (url.protocol != "http:" && url.protocol != "https:") { 139 this.setValidity(this._url, "add-engine-invalid-protocol"); 140 return; 141 } 142 143 let postData = this._postData?.value.trim(); 144 if (!urlString.includes("%s") && !postData) { 145 this.setValidity(this._url, "add-engine-missing-terms-url"); 146 return; 147 } 148 this.setValidity(this._url, null); 149 } 150 151 validatePostDataInput() { 152 let postData = this._postData.value.trim(); 153 if (postData && !postData.includes("%s")) { 154 this.setValidity(this._postData, "add-engine-missing-terms-post-data"); 155 return; 156 } 157 this.setValidity(this._postData, null); 158 } 159 160 validateSuggestUrlInput() { 161 let urlString = this._suggestUrl.value.trim(); 162 if (!urlString) { 163 this.setValidity(this._suggestUrl, null); 164 return; 165 } 166 167 let url = URL.parse(urlString); 168 if (!url) { 169 this.setValidity(this._suggestUrl, "add-engine-invalid-url"); 170 return; 171 } 172 173 if (url.protocol != "http:" && url.protocol != "https:") { 174 this.setValidity(this._suggestUrl, "add-engine-invalid-protocol"); 175 return; 176 } 177 178 if (!urlString.includes("%s")) { 179 this.setValidity(this._suggestUrl, "add-engine-missing-terms-url"); 180 return; 181 } 182 183 this.setValidity(this._suggestUrl, null); 184 } 185 186 /** 187 * Validates the passed input element and updates error messages. 188 * 189 * @param {HTMLInputElement} input 190 * The input element to validate. 191 */ 192 async validateInput(input) { 193 switch (input.id) { 194 case this._name.id: 195 this.validateName(); 196 break; 197 case this._alias.id: 198 await this.validateAlias(); 199 break; 200 case this._postData.id: 201 case this._url.id: 202 // Since either the url or the post data input could 203 // contain %s, we need to update both inputs here. 204 this.validateUrlInput(); 205 this.validatePostDataInput(); 206 break; 207 case this._suggestUrl.id: 208 this.validateSuggestUrlInput(); 209 break; 210 } 211 } 212 213 async validateAll() { 214 for (let input of this._form.elements) { 215 await this.validateInput(input); 216 } 217 } 218 219 /** 220 * Sets the validity of the passed input element to the string belonging 221 * to the passed l10n id. Also updates the input's error label and 222 * the accept button. 223 * 224 * @param {HTMLInputElement} inputElement 225 * @param {string} l10nId 226 * The l10n id of the string to use as validity. 227 * Must be a key of `l10nCache`. 228 */ 229 setValidity(inputElement, l10nId) { 230 if (l10nId) { 231 inputElement.setCustomValidity(l10nCache.get(l10nId)); 232 } else { 233 inputElement.setCustomValidity(""); 234 } 235 236 let errorLabel = inputElement.parentElement.querySelector(".error-label"); 237 let validationMessage = inputElement.validationMessage; 238 239 // If valid, set the error label to "valid" to ensure the layout doesn't shift. 240 // The CSS already hides the error label based on the validity of `inputElement`. 241 errorLabel.textContent = validationMessage || "valid"; 242 243 this._dialog.getButton("accept").disabled = !this._form.checkValidity(); 244 } 245 246 /** 247 * Engine names that always are allowed, even if they are already in use. 248 * This is needed for the edit engine dialog. 249 * 250 * @type {string[]} 251 */ 252 get allowedNames() { 253 return []; 254 } 255 256 /** 257 * Engine aliases that always are allowed, even if they are already in use. 258 * This is needed for the edit engine dialog. 259 * 260 * @type {string[]} 261 */ 262 get allowedAliases() { 263 return []; 264 } 265 } 266 267 /** 268 * This dialog is opened when adding a new search engine in preferences. 269 */ 270 class NewEngineDialog extends EngineDialog { 271 constructor() { 272 super(); 273 document.l10n.setAttributes(this._name, "add-engine-name-placeholder"); 274 document.l10n.setAttributes(this._url, "add-engine-url-placeholder"); 275 document.l10n.setAttributes(this._alias, "add-engine-keyword-placeholder"); 276 277 this.validateAll(); 278 } 279 280 onAccept() { 281 let params = new URLSearchParams( 282 this._postData.value.trim().replace(/%s/, "{searchTerms}") 283 ); 284 let url = this._url.value.trim().replace(/%s/, "{searchTerms}"); 285 286 Services.search.addUserEngine({ 287 name: this._name.value.trim(), 288 url, 289 method: params.size ? "POST" : "GET", 290 params, 291 suggestUrl: this._suggestUrl.value.trim().replace(/%s/, "{searchTerms}"), 292 alias: this._alias.value.trim(), 293 }); 294 } 295 } 296 297 /** 298 * This dialog is opened when editing a user search engine in preferences. 299 */ 300 class EditEngineDialog extends EngineDialog { 301 #engine; 302 /** 303 * Initializes the dialog with information from a user search engine. 304 * 305 * @param {object} args 306 * The arguments. 307 * @param {UserSearchEngine} args.engine 308 * The search engine to edit. Must be a UserSearchEngine. 309 */ 310 constructor({ engine }) { 311 super(); 312 this.#engine = engine; 313 this._name.value = engine.name; 314 this._alias.value = engine.alias ?? ""; 315 316 let [url, postData] = this.getSubmissionTemplate( 317 lazy.SearchUtils.URL_TYPE.SEARCH 318 ); 319 this._url.value = url; 320 this._postData.value = postData; 321 322 let [suggestUrl] = this.getSubmissionTemplate( 323 lazy.SearchUtils.URL_TYPE.SUGGEST_JSON 324 ); 325 if (suggestUrl) { 326 this._suggestUrl.value = suggestUrl; 327 } 328 329 if (postData || suggestUrl) { 330 this.showAdvanced(false); 331 } 332 333 this.validateAll(); 334 } 335 336 onAccept() { 337 this.#engine.rename(this._name.value.trim()); 338 this.#engine.alias = this._alias.value.trim(); 339 340 let newURL = this._url.value.trim(); 341 let newPostData = this._postData.value.trim() || null; 342 343 // UserSearchEngine.changeUrl() does not check whether the URL has actually changed. 344 let [prevURL, prevPostData] = this.getSubmissionTemplate( 345 lazy.SearchUtils.URL_TYPE.SEARCH 346 ); 347 if (newURL != prevURL || prevPostData != newPostData) { 348 this.#engine.changeUrl( 349 lazy.SearchUtils.URL_TYPE.SEARCH, 350 newURL.replace(/%s/, "{searchTerms}"), 351 newPostData?.replace(/%s/, "{searchTerms}") 352 ); 353 } 354 355 let newSuggestURL = this._suggestUrl.value.trim() || null; 356 let [prevSuggestUrl] = this.getSubmissionTemplate( 357 lazy.SearchUtils.URL_TYPE.SUGGEST_JSON 358 ); 359 if (newSuggestURL != prevSuggestUrl) { 360 this.#engine.changeUrl( 361 lazy.SearchUtils.URL_TYPE.SUGGEST_JSON, 362 newSuggestURL?.replace(/%s/, "{searchTerms}"), 363 null 364 ); 365 } 366 367 this.#engine.updateFavicon(); 368 } 369 370 get allowedAliases() { 371 return [this.#engine.alias]; 372 } 373 374 get allowedNames() { 375 return [this.#engine.name]; 376 } 377 378 /** 379 * Returns url and post data templates of the requested type. 380 * Both contain %s in place of the search terms. 381 * 382 * If no url of the requested type exists, both are null. 383 * If the url is a GET url, the post data is null. 384 * 385 * @param {string} urlType 386 * The `SearchUtils.URL_TYPE`. 387 * @returns {[?string, ?string]} 388 * Array of the url and post data. 389 */ 390 getSubmissionTemplate(urlType) { 391 let submission = this.#engine.getSubmission("searchTerms", urlType); 392 if (!submission) { 393 return [null, null]; 394 } 395 let postData = null; 396 if (submission.postData) { 397 let binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( 398 Ci.nsIBinaryInputStream 399 ); 400 binaryStream.setInputStream(submission.postData.data); 401 402 postData = binaryStream 403 .readBytes(binaryStream.available()) 404 .replace("searchTerms", "%s"); 405 } 406 let url = submission.uri.spec.replace("searchTerms", "%s"); 407 return [url, postData]; 408 } 409 } 410 411 /** 412 * This dialog is opened via the context menu of an input and lets the 413 * user choose a name and an alias for an engine. Unlike the other two 414 * dialogs, it does not add or change an engine in the search service, 415 * and instead returns the user input to the caller. 416 * 417 * The chosen name and alias are returned via `window.arguments[0].engineInfo`. 418 * If the user chooses to not save the engine, it's undefined. 419 */ 420 class NewEngineFromFormDialog extends EngineDialog { 421 /** 422 * Initializes the dialog. 423 * 424 * @param {object} args 425 * The arguments. 426 * @param {string} args.nameTemplate 427 * The initial value of the name input. 428 */ 429 constructor({ nameTemplate }) { 430 super(); 431 document.getElementById("engineUrlRow").remove(); 432 this._url = null; 433 document.getElementById("suggestUrlRow").remove(); 434 this._suggestUrl = null; 435 document.getElementById("enginePostDataRow").remove(); 436 this._postData = null; 437 this._dialog.getButton("extra1").hidden = true; 438 439 this._name.value = nameTemplate; 440 this.validateAll(); 441 } 442 443 onAccept() { 444 // Return the input to the caller. 445 window.arguments[0].engineInfo = { 446 name: this._name.value.trim(), 447 // Empty string means no alias. 448 alias: this._alias.value.trim(), 449 }; 450 } 451 } 452 453 async function initL10nCache() { 454 const errorIds = [ 455 "add-engine-name-exists", 456 "add-engine-keyword-exists", 457 "add-engine-no-name", 458 "add-engine-no-url", 459 "add-engine-invalid-protocol", 460 "add-engine-invalid-url", 461 "add-engine-missing-terms-url", 462 "add-engine-missing-terms-post-data", 463 ]; 464 465 let msgs = await document.l10n.formatValues(errorIds.map(id => ({ id }))); 466 l10nCache = new Map(); 467 468 for (let i = 0; i < errorIds.length; i++) { 469 l10nCache.set(errorIds[i], msgs[i]); 470 } 471 } 472 473 window.addEventListener("DOMContentLoaded", async () => { 474 try { 475 if (window.arguments[0].title) { 476 document.documentElement.setAttribute( 477 "headertitle", 478 JSON.stringify({ raw: document.title }) 479 ); 480 } else { 481 AdjustableTitle.hide(); 482 } 483 484 await initL10nCache(); 485 486 switch (window.arguments[0].mode) { 487 case "NEW": 488 gAddEngineDialog = new NewEngineDialog(); 489 break; 490 case "EDIT": 491 gAddEngineDialog = new EditEngineDialog(window.arguments[0]); 492 break; 493 case "FORM": 494 gAddEngineDialog = new NewEngineFromFormDialog(window.arguments[0]); 495 break; 496 default: 497 throw new Error("Mode not supported for addEngine dialog."); 498 } 499 } finally { 500 loadedResolvers.resolve(); 501 } 502 });