/* 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/. */
/* globals AdjustableTitle */
// This is the dialog that is displayed when adding or editing a search engine
// in about:preferences, or when adding a search engine via the context menu of
// an HTML form. Depending on the scenario where it is used, different arguments
// must be supplied in an object in `window.arguments[0]`:
// - `mode` [required] - The type of dialog: NEW, EDIT or FORM.
// - `title` [optional] - Whether to display a title in the window element.
// - all arguments required by the constructor of the dialog class
/**
* @import {UserSearchEngine} from "../../../../toolkit/components/search/UserSearchEngine.sys.mjs"
*/
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
SearchService:
"moz-src:///toolkit/components/search/SearchService.sys.mjs",
SearchUtils:
"moz-src:///toolkit/components/search/SearchUtils.sys.mjs",
UserSearchEngine:
"moz-src:///toolkit/components/search/UserSearchEngine.sys.mjs",
});
// Set the appropriate l10n id before the dialog's connectedCallback.
if (window.arguments[
0].mode ==
"EDIT") {
document.l10n.setAttributes(
document.querySelector(
"dialog"),
"edit-engine-dialog"
);
document.l10n.setAttributes(
document.querySelector(
"window"),
"edit-engine-window"
);
}
else {
document.l10n.setAttributes(
document.querySelector(
"dialog"),
"add-engine-dialog2"
);
document.l10n.setAttributes(
document.querySelector(
"window"),
"add-engine-window"
);
}
let loadedResolvers = Promise.withResolvers();
document.mozSubdialogReady = loadedResolvers.promise;
/** @type {?EngineDialog} */
let gAddEngineDialog =
null;
/** @type {?Map<string, string>} */
let l10nCache =
null;
/**
* The abstract base class for all types of user search engine dialogs.
* All subclasses must implement the abstract method `onAddEngine`.
*/
class EngineDialog {
constructor() {
this._dialog = document.querySelector(
"dialog");
this._form = document.getElementById(
"addEngineForm");
this._name = document.getElementById(
"engineName");
this._alias = document.getElementById(
"engineAlias");
this._url = document.getElementById(
"engineUrl");
this._postData = document.getElementById(
"enginePostData");
this._suggestUrl = document.getElementById(
"suggestUrl");
this._form.addEventListener(
"input", e =>
this.validateInput(e.target));
document.addEventListener(
"dialogaccept",
this.onAccept.bind(
this));
document.addEventListener(
"dialogextra1", () =>
this.showAdvanced());
}
/**
* Shows the advanced section and hides the advanced button.
*
* @param {boolean} [resize]
* Whether the resizeDialog should be called. Before `mozSubdialogReady`
* is resolved, this should be false to avoid flickering.
*/
showAdvanced(resize =
true) {
this._dialog.getButton(
"extra1").hidden =
true;
document.getElementById(
"advanced-section").hidden =
false;
if (resize) {
window.resizeDialog();
}
}
onAccept() {
throw new Error(
"abstract");
}
validateName() {
let name =
this._name.value.trim();
if (!name) {
this.setValidity(
this._name,
"add-engine-no-name");
return;
}
let existingEngine = lazy.SearchService.getEngineByName(name);
if (existingEngine && !
this.allowedNames.includes(name)) {
this.setValidity(
this._name,
"add-engine-name-exists");
return;
}
this.setValidity(
this._name,
null);
}
async validateAlias() {
let alias =
this._alias.value.trim();
if (!alias) {
this.setValidity(
this._alias,
null);
return;
}
let existingEngine = await lazy.SearchService.getEngineByAlias(alias);
if (existingEngine && !
this.allowedAliases.includes(alias)) {
this.setValidity(
this._alias,
"add-engine-keyword-exists");
return;
}
this.setValidity(
this._alias,
null);
}
validateUrlInput() {
let urlString =
this._url.value.trim();
if (!urlString) {
this.setValidity(
this._url,
"add-engine-no-url");
return;
}
let url = URL.parse(urlString);
if (!url) {
this.setValidity(
this._url,
"add-engine-invalid-url");
return;
}
if (url.protocol !=
"http:" && url.protocol !=
"https:") {
this.setValidity(
this._url,
"add-engine-invalid-protocol");
return;
}
let postData =
this._postData?.value.trim();
if (!urlString.includes(
"%s") && !postData) {
this.setValidity(
this._url,
"add-engine-missing-terms-url");
return;
}
this.setValidity(
this._url,
null);
}
validatePostDataInput() {
let postData =
this._postData.value.trim();
if (postData && !postData.includes(
"%s")) {
this.setValidity(
this._postData,
"add-engine-missing-terms-post-data");
return;
}
this.setValidity(
this._postData,
null);
}
validateSuggestUrlInput() {
let urlString =
this._suggestUrl.value.trim();
if (!urlString) {
this.setValidity(
this._suggestUrl,
null);
return;
}
let url = URL.parse(urlString);
if (!url) {
this.setValidity(
this._suggestUrl,
"add-engine-invalid-url");
return;
}
if (url.protocol !=
"http:" && url.protocol !=
"https:") {
this.setValidity(
this._suggestUrl,
"add-engine-invalid-protocol");
return;
}
if (!urlString.includes(
"%s")) {
this.setValidity(
this._suggestUrl,
"add-engine-missing-terms-url");
return;
}
this.setValidity(
this._suggestUrl,
null);
}
/**
* Validates the passed input element and updates error messages.
*
* @param {HTMLInputElement} input
* The input element to validate.
*/
async validateInput(input) {
switch (input.id) {
case this._name.id:
this.validateName();
break;
case this._alias.id:
await
this.validateAlias();
break;
case this._postData.id:
case this._url.id:
// Since either the url or the post data input could
// contain %s, we need to update both inputs here.
this.validateUrlInput();
this.validatePostDataInput();
break;
case this._suggestUrl.id:
this.validateSuggestUrlInput();
break;
}
}
async validateAll() {
for (let input of
this._form.elements) {
await
this.validateInput(input);
}
}
/**
* Sets the validity of the passed input element to the string belonging
* to the passed l10n id. Also updates the input's error label and
* the accept button.
*
* @param {HTMLInputElement} inputElement
* @param {string} l10nId
* The l10n id of the string to use as validity.
* Must be a key of `l10nCache`.
*/
setValidity(inputElement, l10nId) {
if (l10nId) {
inputElement.setCustomValidity(l10nCache.get(l10nId));
}
else {
inputElement.setCustomValidity(
"");
}
let errorLabel = inputElement.parentElement.querySelector(
".error-label");
let validationMessage = inputElement.validationMessage;
// If valid, set the error label to "valid" to ensure the layout doesn't shift.
// The CSS already hides the error label based on the validity of `inputElement`.
errorLabel.textContent = validationMessage ||
"valid";
this._dialog.getButton(
"accept").disabled = !
this._form.checkValidity();
}
/**
* Engine names that always are allowed, even if they are already in use.
* This is needed for the edit engine dialog.
*
* @type {string[]}
*/
get allowedNames() {
return [];
}
/**
* Engine aliases that always are allowed, even if they are already in use.
* This is needed for the edit engine dialog.
*
* @type {string[]}
*/
get allowedAliases() {
return [];
}
}
/**
* This dialog is opened when adding a new search engine in preferences.
*/
class NewEngineDialog
extends EngineDialog {
constructor() {
super();
document.l10n.setAttributes(
this._name,
"add-engine-name-placeholder");
document.l10n.setAttributes(
this._url,
"add-engine-url-placeholder");
document.l10n.setAttributes(
this._alias,
"add-engine-keyword-placeholder");
this.validateAll();
}
onAccept() {
let params =
new URLSearchParams(
this._postData.value.trim().replace(/%s/,
"{searchTerms}")
);
let url =
this._url.value.trim().replace(/%s/,
"{searchTerms}");
lazy.SearchService.addUserEngine({
name:
this._name.value.trim(),
url,
method: params.size ?
"POST" :
"GET",
params,
suggestUrl:
this._suggestUrl.value.trim().replace(/%s/,
"{searchTerms}"),
alias:
this._alias.value.trim(),
});
}
}
/**
* This dialog is opened when editing a search engine in preferences.
*
* For non-user search engines, it will only allow editing the alias. For user
* engines it will allow editing any supported field.
*/
class EditEngineDialog
extends EngineDialog {
#engine;
/**
* Initializes the dialog with information from a user search engine.
*
* @param {object} args
* The arguments.
* @param {SearchEngine} args.engine
* The search engine to edit.
*/
constructor({ engine }) {
super();
this.#engine = engine;
this._name.value = engine.name;
this._alias.value = engine.alias ??
"";
let [url, postData] =
this.getSubmissionTemplate(
lazy.SearchUtils.URL_TYPE.SEARCH
);
this._url.value = url;
this._postData.value = postData;
if (!(
this.#engine
instanceof lazy.UserSearchEngine)) {
this._name.closest(
".dialogRow").hidden =
true;
this._url.closest(
".dialogRow").hidden =
true;
this._dialog.getButton(
"extra1").hidden =
true;
// For the fields we aren't using, pretend they are all valid so that the
// form validation can pass.
for (let input of
this._form.elements) {
if (input ===
this._alias) {
continue;
}
this.setValidity(input,
"");
}
this.validateAll();
return;
}
let [suggestUrl] =
this.getSubmissionTemplate(
lazy.SearchUtils.URL_TYPE.SUGGEST_JSON
);
if (suggestUrl) {
this._suggestUrl.value = suggestUrl;
}
if (postData || suggestUrl) {
this.showAdvanced(
false);
}
this.validateAll();
}
async validateAll() {
// For non-user engines, we only allow the alias to be changed, thus we
// should only validate the alias field.
if (
this.#engine
instanceof lazy.UserSearchEngine) {
super.validateAll();
}
else {
await
this.validateAlias();
}
}
onAccept() {
if (
this.#engine
instanceof lazy.UserSearchEngine) {
this.#engine.rename(
this._name.value.trim());
this.#engine.alias =
this._alias.value.trim();
let newURL =
this._url.value.trim();
let newPostData =
this._postData.value.trim() ||
null;
// UserSearchEngine.changeUrl() does not check whether the URL has actually changed.
let [prevURL, prevPostData] =
this.getSubmissionTemplate(
lazy.SearchUtils.URL_TYPE.SEARCH
);
if (newURL != prevURL || prevPostData != newPostData) {
this.#engine.changeUrl(
lazy.SearchUtils.URL_TYPE.SEARCH,
newURL.replace(/%s/,
"{searchTerms}"),
newPostData?.replace(/%s/,
"{searchTerms}")
);
}
let newSuggestURL =
this._suggestUrl.value.trim() ||
null;
let [prevSuggestUrl] =
this.getSubmissionTemplate(
lazy.SearchUtils.URL_TYPE.SUGGEST_JSON
);
if (newSuggestURL != prevSuggestUrl) {
this.#engine.changeUrl(
lazy.SearchUtils.URL_TYPE.SUGGEST_JSON,
newSuggestURL?.replace(/%s/,
"{searchTerms}"),
null
);
}
this.#engine.updateFavicon();
}
else {
let newAlias =
this._alias.value;
this.#engine.alias = newAlias.trim().toLowerCase();
}
}
get allowedAliases() {
return [
this.#engine.alias];
}
get allowedNames() {
return [
this.#engine.name];
}
/**
* Returns url and post data templates of the requested type.
* Both contain %s in place of the search terms.
*
* If no url of the requested type exists, both are null.
* If the url is a GET url, the post data is null.
*
* @param {string} urlType
* The `SearchUtils.URL_TYPE`.
* @returns {[?string, ?string]}
* Array of the url and post data.
*/
getSubmissionTemplate(urlType) {
let submission =
this.#engine.getSubmission(
"searchTerms", urlType);
if (!submission) {
return [
null,
null];
}
let postData =
null;
if (submission.postData) {
let binaryStream = Cc[
"@mozilla.org/binaryinputstream;1"].createInstance(
Ci.nsIBinaryInputStream
);
binaryStream.setInputStream(submission.postData.data);
postData = binaryStream
.readBytes(binaryStream.available())
.replace(
"searchTerms",
"%s");
}
let url = submission.uri.spec.replace(
"searchTerms",
"%s");
return [url, postData];
}
}
/**
* This dialog is opened via the context menu of an input and lets the
* user choose a name and an alias for an engine. Unlike the other two
* dialogs, it does not add or change an engine in the search service,
* and instead returns the user input to the caller.
*
* The chosen name and alias are returned via `window.arguments[0].engineInfo`.
* If the user chooses to not save the engine, it's undefined.
*/
class NewEngineFromFormDialog
extends EngineDialog {
/**
* Initializes the dialog.
*
* @param {object} args
* The arguments.
* @param {string} args.nameTemplate
* The initial value of the name input.
*/
constructor({ nameTemplate }) {
super();
document.getElementById(
"engineUrlRow").remove();
this._url =
null;
document.getElementById(
"suggestUrlRow").remove();
this._suggestUrl =
null;
document.getElementById(
"enginePostDataRow").remove();
this._postData =
null;
this._dialog.getButton(
"extra1").hidden =
true;
this._name.value = nameTemplate;
this.validateAll();
}
onAccept() {
// Return the input to the caller.
window.arguments[
0].engineInfo = {
name:
this._name.value.trim(),
// Empty string means no alias.
alias:
this._alias.value.trim(),
};
}
}
async
function initL10nCache() {
const errorIds = [
"add-engine-name-exists",
"add-engine-keyword-exists",
"add-engine-no-name",
"add-engine-no-url",
"add-engine-invalid-protocol",
"add-engine-invalid-url",
"add-engine-missing-terms-url",
"add-engine-missing-terms-post-data",
];
let msgs = await document.l10n.formatValues(errorIds.map(id => ({ id })));
l10nCache =
new Map();
for (let i =
0; i < errorIds.length; i++) {
l10nCache.set(errorIds[i], msgs[i]);
}
}
window.addEventListener(
"DOMContentLoaded", async () => {
try {
if (window.arguments[
0].title) {
document.documentElement.setAttribute(
"headertitle",
JSON.stringify({ raw: document.title })
);
}
else {
AdjustableTitle.hide();
}
await initL10nCache();
switch (window.arguments[
0].mode) {
case "NEW":
gAddEngineDialog =
new NewEngineDialog();
break;
case "EDIT":
gAddEngineDialog =
new EditEngineDialog(window.arguments[
0]);
break;
case "FORM":
gAddEngineDialog =
new NewEngineFromFormDialog(window.arguments[
0]);
break;
default:
throw new Error(
"Mode not supported for addEngine dialog.");
}
}
finally {
loadedResolvers.resolve();
}
});