summaryrefslogtreecommitdiffstats
path: root/toolkit/components/formautofill
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/formautofill
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/formautofill')
-rw-r--r--toolkit/components/formautofill/AutofillProfileAutoComplete.sys.mjs482
-rw-r--r--toolkit/components/formautofill/AutofillTelemetry.sys.mjs629
-rw-r--r--toolkit/components/formautofill/Constants.ios.mjs102
-rw-r--r--toolkit/components/formautofill/FormAutofill.ios.sys.mjs17
-rw-r--r--toolkit/components/formautofill/FormAutofill.sys.mjs294
-rw-r--r--toolkit/components/formautofill/FormAutofillChild.ios.sys.mjs107
-rw-r--r--toolkit/components/formautofill/FormAutofillChild.sys.mjs472
-rw-r--r--toolkit/components/formautofill/FormAutofillContent.sys.mjs442
-rw-r--r--toolkit/components/formautofill/FormAutofillNative.cpp1489
-rw-r--r--toolkit/components/formautofill/FormAutofillNative.h24
-rw-r--r--toolkit/components/formautofill/FormAutofillParent.sys.mjs716
-rw-r--r--toolkit/components/formautofill/FormAutofillPreferences.sys.mjs396
-rw-r--r--toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs2134
-rw-r--r--toolkit/components/formautofill/FormAutofillSync.sys.mjs400
-rw-r--r--toolkit/components/formautofill/Helpers.ios.mjs177
-rw-r--r--toolkit/components/formautofill/Overrides.ios.js22
-rw-r--r--toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs452
-rw-r--r--toolkit/components/formautofill/android/FormAutofillPrompter.sys.mjs69
-rw-r--r--toolkit/components/formautofill/android/FormAutofillStorage.sys.mjs265
-rw-r--r--toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs1410
-rw-r--r--toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs106
-rw-r--r--toolkit/components/formautofill/jar.mn17
-rw-r--r--toolkit/components/formautofill/metrics.yaml279
-rw-r--r--toolkit/components/formautofill/moz.build37
-rw-r--r--toolkit/components/formautofill/phonenumberutils/PhoneNumber.sys.mjs474
-rw-r--r--toolkit/components/formautofill/phonenumberutils/PhoneNumberMetaData.sys.mjs291
-rw-r--r--toolkit/components/formautofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs67
-rw-r--r--toolkit/components/formautofill/shared/AddressComponent.sys.mjs1120
-rw-r--r--toolkit/components/formautofill/shared/AddressMetaData.sys.mjs2451
-rw-r--r--toolkit/components/formautofill/shared/AddressMetaDataExtension.sys.mjs765
-rw-r--r--toolkit/components/formautofill/shared/AddressMetaDataLoader.sys.mjs168
-rw-r--r--toolkit/components/formautofill/shared/AddressParser.sys.mjs285
-rw-r--r--toolkit/components/formautofill/shared/CreditCardRecord.sys.mjs66
-rw-r--r--toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs1221
-rw-r--r--toolkit/components/formautofill/shared/FieldScanner.sys.mjs224
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs411
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs1213
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs406
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs1292
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs1129
-rw-r--r--toolkit/components/formautofill/shared/FormStateManager.sys.mjs157
-rw-r--r--toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs687
-rw-r--r--toolkit/components/formautofill/shared/LabelUtils.sys.mjs120
43 files changed, 23085 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/AutofillProfileAutoComplete.sys.mjs b/toolkit/components/formautofill/AutofillProfileAutoComplete.sys.mjs
new file mode 100644
index 0000000000..2d87f7931d
--- /dev/null
+++ b/toolkit/components/formautofill/AutofillProfileAutoComplete.sys.mjs
@@ -0,0 +1,482 @@
+/* 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/. */
+
+/**
+ * Form Autofill content process module.
+ */
+
+import { GenericAutocompleteItem } from "resource://gre/modules/FillHelpers.sys.mjs";
+
+/* eslint-disable no-use-before-define */
+
+const Cm = Components.manager;
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddressResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs",
+ ComponentUtils: "resource://gre/modules/ComponentUtils.sys.mjs",
+ CreditCardResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs",
+ FormAutofill: "resource://autofill/FormAutofill.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ FormAutofillContent: "resource://autofill/FormAutofillContent.sys.mjs",
+ FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs",
+ InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs",
+});
+
+const autocompleteController = Cc[
+ "@mozilla.org/autocomplete/controller;1"
+].getService(Ci.nsIAutoCompleteController);
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "ADDRESSES_COLLECTION_NAME",
+ () => lazy.FormAutofillUtils.ADDRESSES_COLLECTION_NAME
+);
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "CREDITCARDS_COLLECTION_NAME",
+ () => lazy.FormAutofillUtils.CREDITCARDS_COLLECTION_NAME
+);
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "FIELD_STATES",
+ () => lazy.FormAutofillUtils.FIELD_STATES
+);
+
+function getActorFromWindow(contentWindow, name = "FormAutofill") {
+ // In unit tests, contentWindow isn't a real window.
+ if (!contentWindow) {
+ return null;
+ }
+
+ return contentWindow.windowGlobalChild
+ ? contentWindow.windowGlobalChild.getActor(name)
+ : null;
+}
+
+// Register/unregister a constructor as a factory.
+function AutocompleteFactory() {}
+AutocompleteFactory.prototype = {
+ register(targetConstructor) {
+ let proto = targetConstructor.prototype;
+ this._classID = proto.classID;
+
+ let factory =
+ lazy.ComponentUtils.generateSingletonFactory(targetConstructor);
+ this._factory = factory;
+
+ let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+ registrar.registerFactory(
+ proto.classID,
+ proto.classDescription,
+ proto.contractID,
+ factory
+ );
+
+ if (proto.classID2) {
+ this._classID2 = proto.classID2;
+ registrar.registerFactory(
+ proto.classID2,
+ proto.classDescription,
+ proto.contractID2,
+ factory
+ );
+ }
+ },
+
+ unregister() {
+ let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+ registrar.unregisterFactory(this._classID, this._factory);
+ if (this._classID2) {
+ registrar.unregisterFactory(this._classID2, this._factory);
+ }
+ this._factory = null;
+ },
+};
+
+/**
+ * @class
+ *
+ * @implements {nsIAutoCompleteSearch}
+ */
+function AutofillProfileAutoCompleteSearch() {
+ this.log = lazy.FormAutofill.defineLogGetter(
+ this,
+ "AutofillProfileAutoCompleteSearch"
+ );
+}
+AutofillProfileAutoCompleteSearch.prototype = {
+ classID: Components.ID("4f9f1e4c-7f2c-439e-9c9e-566b68bc187d"),
+ contractID: "@mozilla.org/autocomplete/search;1?name=autofill-profiles",
+ classDescription: "AutofillProfileAutoCompleteSearch",
+ QueryInterface: ChromeUtils.generateQI(["nsIAutoCompleteSearch"]),
+
+ // Begin nsIAutoCompleteSearch implementation
+
+ /**
+ * Searches for a given string and notifies a listener (either synchronously
+ * or asynchronously) of the result
+ *
+ * @param {string} searchString the string to search for
+ * @param {string} searchParam
+ * @param {object} previousResult a previous result to use for faster searchinig
+ * @param {object} listener the listener to notify when the search is complete
+ */
+ startSearch(searchString, searchParam, previousResult, listener) {
+ let {
+ activeInput,
+ activeSection,
+ activeFieldDetail,
+ activeHandler,
+ savedFieldNames,
+ } = lazy.FormAutofillContent;
+ this.forceStop = false;
+
+ let isAddressField = lazy.FormAutofillUtils.isAddressField(
+ activeFieldDetail.fieldName
+ );
+ const isCreditCardField = lazy.FormAutofillUtils.isCreditCardField(
+ activeFieldDetail.fieldName
+ );
+ let isInputAutofilled =
+ activeHandler.getFilledStateByElement(activeInput) ==
+ lazy.FIELD_STATES.AUTO_FILLED;
+ let allFieldNames = activeSection.allFieldNames;
+ let filledRecordGUID = activeSection.filledRecordGUID;
+
+ let searchPermitted = isAddressField
+ ? lazy.FormAutofill.isAutofillAddressesEnabled
+ : lazy.FormAutofill.isAutofillCreditCardsEnabled;
+ let AutocompleteResult = isAddressField
+ ? lazy.AddressResult
+ : lazy.CreditCardResult;
+ let isFormAutofillSearch = true;
+ let pendingSearchResult = null;
+
+ ProfileAutocomplete.lastProfileAutoCompleteFocusedInput = activeInput;
+ // Fallback to form-history if ...
+ // - specified autofill feature is pref off.
+ // - no profile can fill the currently-focused input.
+ // - the current form has already been populated and the field is not
+ // an empty credit card field.
+ // - (address only) less than 3 inputs are covered by all saved fields in the storage.
+ if (
+ !searchPermitted ||
+ !savedFieldNames.has(activeFieldDetail.fieldName) ||
+ (!isInputAutofilled &&
+ filledRecordGUID &&
+ !(isCreditCardField && activeInput.value === "")) ||
+ (isAddressField &&
+ allFieldNames.filter(field => savedFieldNames.has(field)).length <
+ lazy.FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD)
+ ) {
+ isFormAutofillSearch = false;
+ if (activeInput.autocomplete == "off") {
+ // Create a dummy result as an empty search result.
+ pendingSearchResult = new AutocompleteResult("", "", [], [], {});
+ } else {
+ pendingSearchResult = new Promise(resolve => {
+ let formHistory = Cc[
+ "@mozilla.org/autocomplete/search;1?name=form-history"
+ ].createInstance(Ci.nsIAutoCompleteSearch);
+ formHistory.startSearch(searchString, searchParam, previousResult, {
+ onSearchResult: (_, result) => resolve(result),
+ });
+ });
+ }
+ } else if (isInputAutofilled) {
+ pendingSearchResult = new AutocompleteResult(searchString, "", [], [], {
+ isInputAutofilled,
+ });
+ } else {
+ let infoWithoutElement = { ...activeFieldDetail };
+ delete infoWithoutElement.elementWeakRef;
+
+ let data = {
+ collectionName: isAddressField
+ ? lazy.ADDRESSES_COLLECTION_NAME
+ : lazy.CREDITCARDS_COLLECTION_NAME,
+ info: infoWithoutElement,
+ searchString,
+ };
+
+ pendingSearchResult = this._getRecords(activeInput, data).then(
+ ({ records, externalEntries }) => {
+ if (this.forceStop) {
+ return null;
+ }
+ // Sort addresses by timeLastUsed for showing the lastest used address at top.
+ records.sort((a, b) => b.timeLastUsed - a.timeLastUsed);
+
+ let adaptedRecords = activeSection.getAdaptedProfiles(records);
+ let handler = lazy.FormAutofillContent.activeHandler;
+ let isSecure = lazy.InsecurePasswordUtils.isFormSecure(handler.form);
+
+ const result = new AutocompleteResult(
+ searchString,
+ activeFieldDetail.fieldName,
+ allFieldNames,
+ adaptedRecords,
+ { isSecure, isInputAutofilled }
+ );
+
+ result.externalEntries.push(
+ ...externalEntries.map(
+ entry =>
+ new GenericAutocompleteItem(
+ entry.image,
+ entry.title,
+ entry.subtitle,
+ entry.fillMessageName,
+ entry.fillMessageData
+ )
+ )
+ );
+
+ return result;
+ }
+ );
+ }
+
+ Promise.resolve(pendingSearchResult).then(result => {
+ if (this.forceStop) {
+ // If we notify the listener the search result when the search is already
+ // cancelled, it corrupts the internal state of the listener. So we only
+ // reset the controller's state in this case.
+ if (isFormAutofillSearch) {
+ autocompleteController.resetInternalState();
+ }
+ return;
+ }
+
+ listener.onSearchResult(this, result);
+ // Don't save cache results or reset state when returning non-autofill results such as the
+ // form history fallback above.
+ if (isFormAutofillSearch) {
+ ProfileAutocomplete.lastProfileAutoCompleteResult = result;
+ // Reset AutoCompleteController's state at the end of startSearch to ensure that
+ // none of form autofill result will be cached in other places and make the
+ // result out of sync.
+ autocompleteController.resetInternalState();
+ } else {
+ // Clear the cache so that we don't try to autofill from it after falling
+ // back to form history.
+ ProfileAutocomplete.lastProfileAutoCompleteResult = null;
+ }
+ });
+ },
+
+ /**
+ * Stops an asynchronous search that is in progress
+ */
+ stopSearch() {
+ ProfileAutocomplete.lastProfileAutoCompleteResult = null;
+ this.forceStop = true;
+ },
+
+ /**
+ * Get the records from parent process for AutoComplete result.
+ *
+ * @private
+ * @param {object} input
+ * Input element for autocomplete.
+ * @param {object} data
+ * Parameters for querying the corresponding result.
+ * @param {string} data.collectionName
+ * The name used to specify which collection to retrieve records.
+ * @param {string} data.searchString
+ * The typed string for filtering out the matched records.
+ * @param {string} data.info
+ * The input autocomplete property's information.
+ * @returns {Promise}
+ * Promise that resolves when addresses returned from parent process.
+ */
+ _getRecords(input, data) {
+ if (!input) {
+ return [];
+ }
+
+ let actor = getActorFromWindow(input.ownerGlobal);
+ return actor.sendQuery("FormAutofill:GetRecords", {
+ scenarioName: lazy.FormScenarios.detect({ input }).signUpForm
+ ? "SignUpFormScenario"
+ : "",
+ ...data,
+ });
+ },
+};
+
+export const ProfileAutocomplete = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ lastProfileAutoCompleteResult: null,
+ lastProfileAutoCompleteFocusedInput: null,
+ _registered: false,
+ _factory: null,
+
+ ensureRegistered() {
+ if (this._registered) {
+ return;
+ }
+
+ this.log = lazy.FormAutofill.defineLogGetter(this, "ProfileAutocomplete");
+ this.debug("ensureRegistered");
+ this._factory = new AutocompleteFactory();
+ this._factory.register(AutofillProfileAutoCompleteSearch);
+ this._registered = true;
+
+ Services.obs.addObserver(this, "autocomplete-will-enter-text");
+
+ this.debug(
+ "ensureRegistered. Finished with _registered:",
+ this._registered
+ );
+ },
+
+ ensureUnregistered() {
+ if (!this._registered) {
+ return;
+ }
+
+ this.debug("ensureUnregistered");
+ this._factory.unregister();
+ this._factory = null;
+ this._registered = false;
+ this._lastAutoCompleteResult = null;
+
+ Services.obs.removeObserver(this, "autocomplete-will-enter-text");
+ },
+
+ async observe(subject, topic, data) {
+ switch (topic) {
+ case "autocomplete-will-enter-text": {
+ if (!lazy.FormAutofillContent.activeInput) {
+ // The observer notification is for autocomplete in a different process.
+ break;
+ }
+ lazy.FormAutofillContent.autofillPending = true;
+ Services.obs.notifyObservers(null, "autofill-fill-starting");
+ await this._fillFromAutocompleteRow(
+ lazy.FormAutofillContent.activeInput
+ );
+ Services.obs.notifyObservers(null, "autofill-fill-complete");
+ lazy.FormAutofillContent.autofillPending = false;
+ break;
+ }
+ }
+ },
+
+ fillRequestId: 0,
+
+ async sendFillRequestToFormAutofillParent(input, comment) {
+ if (!comment) {
+ return false;
+ }
+
+ if (!input || input != autocompleteController?.input.focusedInput) {
+ return false;
+ }
+
+ const { fillMessageName, fillMessageData } = JSON.parse(comment ?? "{}");
+ if (!fillMessageName) {
+ return false;
+ }
+
+ this.fillRequestId++;
+ const fillRequestId = this.fillRequestId;
+ const actor = getActorFromWindow(input.ownerGlobal, "FormAutofill");
+ const value = await actor.sendQuery(fillMessageName, fillMessageData ?? {});
+
+ // skip fill if another fill operation started during await
+ if (fillRequestId != this.fillRequestId) {
+ return false;
+ }
+
+ if (typeof value !== "string") {
+ return false;
+ }
+
+ // If AutoFillParent returned a string to fill, we must do it here because
+ // nsAutoCompleteController.cpp already finished it's work before we finished await.
+ input.setUserInput(value);
+ input.select(value.length, value.length);
+
+ return true;
+ },
+
+ _getSelectedIndex(contentWindow) {
+ let actor = getActorFromWindow(contentWindow, "AutoComplete");
+ if (!actor) {
+ throw new Error("Invalid autocomplete selectedIndex");
+ }
+
+ return actor.selectedIndex;
+ },
+
+ async _fillFromAutocompleteRow(focusedInput) {
+ this.debug("_fillFromAutocompleteRow:", focusedInput);
+ let formDetails = lazy.FormAutofillContent.activeFormDetails;
+ if (!formDetails) {
+ // The observer notification is for a different frame.
+ return;
+ }
+
+ let selectedIndex = this._getSelectedIndex(focusedInput.ownerGlobal);
+ const validIndex =
+ selectedIndex >= 0 &&
+ selectedIndex < this.lastProfileAutoCompleteResult?.matchCount;
+ const comment = validIndex
+ ? this.lastProfileAutoCompleteResult.getCommentAt(selectedIndex)
+ : null;
+
+ if (
+ selectedIndex == -1 ||
+ !this.lastProfileAutoCompleteResult ||
+ this.lastProfileAutoCompleteResult.getStyleAt(selectedIndex) !=
+ "autofill-profile"
+ ) {
+ await this.sendFillRequestToFormAutofillParent(focusedInput, comment);
+ return;
+ }
+
+ let profile = JSON.parse(comment);
+
+ await lazy.FormAutofillContent.activeHandler.autofillFormFields(profile);
+ },
+
+ _clearProfilePreview() {
+ if (
+ !this.lastProfileAutoCompleteFocusedInput ||
+ !lazy.FormAutofillContent.activeSection
+ ) {
+ return;
+ }
+
+ lazy.FormAutofillContent.activeSection.clearPreviewedFormFields();
+ },
+
+ _previewSelectedProfile(selectedIndex) {
+ if (
+ !lazy.FormAutofillContent.activeInput ||
+ !lazy.FormAutofillContent.activeFormDetails
+ ) {
+ // The observer notification is for a different process/frame.
+ return;
+ }
+
+ if (
+ !this.lastProfileAutoCompleteResult ||
+ this.lastProfileAutoCompleteResult.getStyleAt(selectedIndex) !=
+ "autofill-profile"
+ ) {
+ return;
+ }
+
+ let profile = JSON.parse(
+ this.lastProfileAutoCompleteResult.getCommentAt(selectedIndex)
+ );
+ lazy.FormAutofillContent.activeSection.previewFormFields(profile);
+ },
+};
diff --git a/toolkit/components/formautofill/AutofillTelemetry.sys.mjs b/toolkit/components/formautofill/AutofillTelemetry.sys.mjs
new file mode 100644
index 0000000000..93aa99a4b8
--- /dev/null
+++ b/toolkit/components/formautofill/AutofillTelemetry.sys.mjs
@@ -0,0 +1,629 @@
+/* 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/. */
+
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import { FormAutofillCreditCardSection } from "resource://gre/modules/shared/FormAutofillSection.sys.mjs";
+
+const { FIELD_STATES } = FormAutofillUtils;
+
+class AutofillTelemetryBase {
+ SUPPORTED_FIELDS = {};
+
+ EVENT_CATEGORY = null;
+ EVENT_OBJECT_FORM_INTERACTION = null;
+
+ SCALAR_DETECTED_SECTION_COUNT = null;
+ SCALAR_SUBMITTED_SECTION_COUNT = null;
+
+ HISTOGRAM_NUM_USES = null;
+ HISTOGRAM_PROFILE_NUM_USES = null;
+ HISTOGRAM_PROFILE_NUM_USES_KEY = null;
+
+ #initFormEventExtra(value) {
+ let extra = {};
+ for (const field of Object.values(this.SUPPORTED_FIELDS)) {
+ extra[field] = value;
+ }
+ return extra;
+ }
+
+ #setFormEventExtra(extra, key, value) {
+ if (!this.SUPPORTED_FIELDS[key]) {
+ return;
+ }
+
+ extra[this.SUPPORTED_FIELDS[key]] = value;
+ }
+
+ /**
+ * Building the extra keys object that is included in the Legacy Telemetry event `cc_form_v2`
+ * or `address_form` event and the Glean event `cc_form`, and `address_form`.
+ * It indicates the detected credit card or address fields and which method (autocomplete property, regular expression heuristics or fathom) identified them.
+ *
+ * @param {object} section Using section.fieldDetails to extract which fields were identified and how
+ * @param {string} undetected Default value when a field is not detected: 'undetected' (Glean) and 'false' in (Legacy)
+ * @param {string} autocomplete Value when a field is identified with autocomplete property: 'autocomplete' (Glean), 'true' (Legacy)
+ * @param {string} regexp Value when a field is identified with regex expression heuristics: 'regexp' (Glean), '0' (Legacy)
+ * @param {boolean} includeMultiPart Include multi part data or not
+ * @returns {object} Extra keys to include in the form event
+ */
+ #buildFormDetectedEventExtra(
+ section,
+ undetected,
+ autocomplete,
+ regexp,
+ includeMultiPart
+ ) {
+ let extra = this.#initFormEventExtra(undetected);
+
+ let identified = new Set();
+ section.fieldDetails.forEach(detail => {
+ identified.add(detail.fieldName);
+
+ if (detail.reason == "autocomplete") {
+ this.#setFormEventExtra(extra, detail.fieldName, autocomplete);
+ } else {
+ // confidence exists only when a field is identified by fathom.
+ let confidence =
+ detail.confidence > 0 ? Math.floor(100 * detail.confidence) / 100 : 0;
+
+ this.#setFormEventExtra(
+ extra,
+ detail.fieldName,
+ confidence ? confidence.toString() : regexp
+ );
+ }
+
+ if (
+ detail.fieldName === "cc-number" &&
+ this.SUPPORTED_FIELDS[detail.fieldName] &&
+ includeMultiPart
+ ) {
+ extra.cc_number_multi_parts = detail.part ?? 1;
+ }
+ });
+ return extra;
+ }
+
+ recordFormDetected(section) {
+ this.recordFormEvent(
+ "detected",
+ section.flowId,
+ this.#buildFormDetectedEventExtra(section, "false", "true", "0", false)
+ );
+
+ this.recordGleanFormEvent(
+ "formDetected",
+ section.flowId,
+ this.#buildFormDetectedEventExtra(
+ section,
+ "undetected",
+ "autocomplete",
+ "regexp",
+ true
+ )
+ );
+ }
+
+ recordPopupShown(section, fieldName) {
+ const extra = { field_name: fieldName };
+ this.recordFormEvent("popup_shown", section.flowId, extra);
+ this.recordGleanFormEvent("formPopupShown", section.flowId, extra);
+ }
+
+ recordFormFilled(section, profile) {
+ // Calculate values for telemetry
+ let extra = this.#initFormEventExtra("unavailable");
+
+ for (let fieldDetail of section.fieldDetails) {
+ let element = fieldDetail.element;
+ let state = profile[fieldDetail.fieldName] ? "filled" : "not_filled";
+ if (
+ section.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.NORMAL &&
+ (HTMLSelectElement.isInstance(element) ||
+ (HTMLInputElement.isInstance(element) && element.value.length))
+ ) {
+ state = "user_filled";
+ }
+ this.#setFormEventExtra(extra, fieldDetail.fieldName, state);
+ }
+
+ this.recordFormEvent("filled", section.flowId, extra);
+ this.recordGleanFormEvent("formFilled", section.flowId, extra);
+ }
+
+ recordFilledModified(section, fieldName) {
+ const extra = { field_name: fieldName };
+ this.recordFormEvent("filled_modified", section.flowId, extra);
+ this.recordGleanFormEvent("formFilledModified", section.flowId, extra);
+ }
+
+ recordFormSubmitted(section, record, form) {
+ let extra = this.#initFormEventExtra("unavailable");
+
+ if (record.guid !== null) {
+ // If the `guid` is not null, it means we're editing an existing record.
+ // In that case, all fields in the record are autofilled, and fields in
+ // `untouchedFields` are unmodified.
+ for (const [fieldName, value] of Object.entries(record.record)) {
+ if (record.untouchedFields?.includes(fieldName)) {
+ this.#setFormEventExtra(extra, fieldName, "autofilled");
+ } else if (value) {
+ this.#setFormEventExtra(extra, fieldName, "user_filled");
+ } else {
+ this.#setFormEventExtra(extra, fieldName, "not_filled");
+ }
+ }
+ } else {
+ Object.keys(record.record).forEach(fieldName =>
+ this.#setFormEventExtra(extra, fieldName, "user_filled")
+ );
+ }
+
+ this.recordFormEvent("submitted", section.flowId, extra);
+ this.recordGleanFormEvent("formSubmitted", section.flowId, extra);
+ }
+
+ recordFormCleared(section, fieldName) {
+ const extra = { field_name: fieldName };
+
+ // Note that when a form is cleared, we also record `filled_modified` events
+ // for all the fields that have been cleared.
+ this.recordFormEvent("cleared", section.flowId, extra);
+ this.recordGleanFormEvent("formCleared", section.flowId, extra);
+ }
+
+ recordFormEvent(method, flowId, extra) {
+ Services.telemetry.recordEvent(
+ this.EVENT_CATEGORY,
+ method,
+ this.EVENT_OBJECT_FORM_INTERACTION,
+ flowId,
+ extra
+ );
+ }
+
+ recordGleanFormEvent(eventName, flowId, extra) {
+ throw new Error("Not implemented.");
+ }
+
+ recordFormInteractionEvent(
+ method,
+ section,
+ { fieldName, profile, record, form } = {}
+ ) {
+ if (!this.EVENT_OBJECT_FORM_INTERACTION) {
+ return undefined;
+ }
+ switch (method) {
+ case "detected":
+ return this.recordFormDetected(section);
+ case "popup_shown":
+ return this.recordPopupShown(section, fieldName);
+ case "filled":
+ return this.recordFormFilled(section, profile);
+ case "filled_modified":
+ return this.recordFilledModified(section, fieldName);
+ case "submitted":
+ return this.recordFormSubmitted(section, record, form);
+ case "cleared":
+ return this.recordFormCleared(section, fieldName);
+ }
+ return undefined;
+ }
+
+ recordDoorhangerEvent(method, object, flowId) {
+ Services.telemetry.recordEvent(this.EVENT_CATEGORY, method, object, flowId);
+ }
+
+ recordManageEvent(method) {
+ Services.telemetry.recordEvent(this.EVENT_CATEGORY, method, "manage");
+ }
+
+ recordAutofillProfileCount(count) {
+ throw new Error("Not implemented.");
+ }
+
+ recordDetectedSectionCount() {
+ if (!this.SCALAR_DETECTED_SECTION_COUNT) {
+ return;
+ }
+
+ Services.telemetry.scalarAdd(this.SCALAR_DETECTED_SECTION_COUNT, 1);
+ }
+
+ recordSubmittedSectionCount(count) {
+ if (!this.SCALAR_SUBMITTED_SECTION_COUNT || !count) {
+ return;
+ }
+
+ Services.telemetry.scalarAdd(this.SCALAR_SUBMITTED_SECTION_COUNT, count);
+ }
+
+ recordNumberOfUse(records) {
+ let histogram = Services.telemetry.getKeyedHistogramById(
+ this.HISTOGRAM_PROFILE_NUM_USES
+ );
+ histogram.clear();
+
+ for (let record of records) {
+ histogram.add(this.HISTOGRAM_PROFILE_NUM_USES_KEY, record.timesUsed);
+ }
+ }
+}
+
+export class AddressTelemetry extends AutofillTelemetryBase {
+ EVENT_CATEGORY = "address";
+ EVENT_OBJECT_FORM_INTERACTION = "address_form";
+ EVENT_OBJECT_FORM_INTERACTION_EXT = "address_form_ext";
+
+ SCALAR_DETECTED_SECTION_COUNT =
+ "formautofill.addresses.detected_sections_count";
+ SCALAR_SUBMITTED_SECTION_COUNT =
+ "formautofill.addresses.submitted_sections_count";
+ SCALAR_AUTOFILL_PROFILE_COUNT =
+ "formautofill.addresses.autofill_profiles_count";
+
+ HISTOGRAM_PROFILE_NUM_USES = "AUTOFILL_PROFILE_NUM_USES";
+ HISTOGRAM_PROFILE_NUM_USES_KEY = "address";
+
+ // Fields that are record in `address_form` and `address_form_ext` telemetry
+ SUPPORTED_FIELDS = {
+ "street-address": "street_address",
+ "address-line1": "address_line1",
+ "address-line2": "address_line2",
+ "address-line3": "address_line3",
+ "address-level1": "address_level1",
+ "address-level2": "address_level2",
+ "postal-code": "postal_code",
+ country: "country",
+ name: "name",
+ "given-name": "given_name",
+ "additional-name": "additional_name",
+ "family-name": "family_name",
+ email: "email",
+ organization: "organization",
+ tel: "tel",
+ };
+
+ // Fields that are record in `address_form` event telemetry extra_keys
+ static SUPPORTED_FIELDS_IN_FORM = [
+ "street_address",
+ "address_line1",
+ "address_line2",
+ "address_line3",
+ "address_level2",
+ "address_level1",
+ "postal_code",
+ "country",
+ ];
+
+ // Fields that are record in `address_form_ext` event telemetry extra_keys
+ static SUPPORTED_FIELDS_IN_FORM_EXT = [
+ "name",
+ "given_name",
+ "additional_name",
+ "family_name",
+ "email",
+ "organization",
+ "tel",
+ ];
+
+ recordGleanFormEvent(eventName, flowId, extra) {
+ // To be implemented when migrating the legacy event address.address_form to Glean
+ }
+
+ recordFormEvent(method, flowId, extra) {
+ let extExtra = {};
+ if (["detected", "filled", "submitted"].includes(method)) {
+ for (const [key, value] of Object.entries(extra)) {
+ if (AddressTelemetry.SUPPORTED_FIELDS_IN_FORM_EXT.includes(key)) {
+ extExtra[key] = value;
+ delete extra[key];
+ }
+ }
+ }
+
+ Services.telemetry.recordEvent(
+ this.EVENT_CATEGORY,
+ method,
+ this.EVENT_OBJECT_FORM_INTERACTION,
+ flowId,
+ extra
+ );
+
+ if (Object.keys(extExtra).length) {
+ Services.telemetry.recordEvent(
+ this.EVENT_CATEGORY,
+ method,
+ this.EVENT_OBJECT_FORM_INTERACTION_EXT,
+ flowId,
+ extExtra
+ );
+ }
+ }
+
+ recordAutofillProfileCount(count) {
+ Services.telemetry.scalarSet(this.SCALAR_AUTOFILL_PROFILE_COUNT, count);
+ }
+}
+
+class CreditCardTelemetry extends AutofillTelemetryBase {
+ EVENT_CATEGORY = "creditcard";
+ EVENT_OBJECT_FORM_INTERACTION = "cc_form_v2";
+
+ SCALAR_DETECTED_SECTION_COUNT =
+ "formautofill.creditCards.detected_sections_count";
+ SCALAR_SUBMITTED_SECTION_COUNT =
+ "formautofill.creditCards.submitted_sections_count";
+
+ HISTOGRAM_NUM_USES = "CREDITCARD_NUM_USES";
+ HISTOGRAM_PROFILE_NUM_USES = "AUTOFILL_PROFILE_NUM_USES";
+ HISTOGRAM_PROFILE_NUM_USES_KEY = "credit_card";
+
+ // Mapping of field name used in formautofill code to the field name
+ // used in the telemetry.
+ SUPPORTED_FIELDS = {
+ "cc-name": "cc_name",
+ "cc-number": "cc_number",
+ "cc-type": "cc_type",
+ "cc-exp": "cc_exp",
+ "cc-exp-month": "cc_exp_month",
+ "cc-exp-year": "cc_exp_year",
+ };
+
+ recordLegacyFormEvent(method, flowId, extra = null) {
+ Services.telemetry.recordEvent(
+ this.EVENT_CATEGORY,
+ method,
+ "cc_form",
+ flowId,
+ extra
+ );
+ }
+
+ recordGleanFormEvent(eventName, flowId, extra) {
+ extra.flow_id = flowId;
+ Glean.formautofillCreditcards[eventName].record(extra);
+ }
+
+ recordFormDetected(section) {
+ super.recordFormDetected(section);
+
+ let identified = new Set();
+ section.fieldDetails.forEach(detail => {
+ identified.add(detail.fieldName);
+ });
+ let extra = {
+ cc_name_found: identified.has("cc-name") ? "true" : "false",
+ cc_number_found: identified.has("cc-number") ? "true" : "false",
+ cc_exp_found:
+ identified.has("cc-exp") ||
+ (identified.has("cc-exp-month") && identified.has("cc-exp-year"))
+ ? "true"
+ : "false",
+ };
+
+ this.recordLegacyFormEvent("detected", section.flowId, extra);
+ }
+
+ recordPopupShown(section, fieldName) {
+ super.recordPopupShown(section, fieldName);
+
+ this.recordLegacyFormEvent("popup_shown", section.flowId);
+ }
+
+ recordFormFilled(section, profile) {
+ super.recordFormFilled(section, profile);
+ // Calculate values for telemetry
+ let extra = {
+ cc_name: "unavailable",
+ cc_number: "unavailable",
+ cc_exp: "unavailable",
+ };
+
+ for (let fieldDetail of section.fieldDetails) {
+ let element = fieldDetail.element;
+ let state = profile[fieldDetail.fieldName] ? "filled" : "not_filled";
+ if (
+ section.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.NORMAL &&
+ (HTMLSelectElement.isInstance(element) ||
+ (HTMLInputElement.isInstance(element) && element.value.length))
+ ) {
+ state = "user_filled";
+ }
+ switch (fieldDetail.fieldName) {
+ case "cc-name":
+ extra.cc_name = state;
+ break;
+ case "cc-number":
+ extra.cc_number = state;
+ break;
+ case "cc-exp":
+ case "cc-exp-month":
+ case "cc-exp-year":
+ extra.cc_exp = state;
+ break;
+ }
+ }
+
+ this.recordLegacyFormEvent("filled", section.flowId, extra);
+ }
+
+ recordFilledModified(section, fieldName) {
+ super.recordFilledModified(section, fieldName);
+
+ let extra = { field_name: fieldName };
+ this.recordLegacyFormEvent("filled_modified", section.flowId, extra);
+ }
+
+ /**
+ * Called when a credit card form is submitted
+ *
+ * @param {object} section Section that produces this record
+ * @param {object} record Credit card record filled in the form.
+ * @param {Array<HTMLForm>} form Form that contains the section
+ */
+ recordFormSubmitted(section, record, form) {
+ super.recordFormSubmitted(section, record, form);
+
+ // For legacy cc_form event telemetry
+ let extra = {
+ fields_not_auto: "0",
+ fields_auto: "0",
+ fields_modified: "0",
+ };
+
+ if (record.guid !== null) {
+ let totalCount = form.elements.length;
+ let autofilledCount = Object.keys(record.record).length;
+ let unmodifiedCount = record.untouchedFields.length;
+
+ extra.fields_not_auto = (totalCount - autofilledCount).toString();
+ extra.fields_auto = autofilledCount.toString();
+ extra.fields_modified = (autofilledCount - unmodifiedCount).toString();
+ } else {
+ // If the `guid` is null, we're filling a new form.
+ // In that case, all not-null fields are manually filled.
+ extra.fields_not_auto = Array.from(form.elements)
+ .filter(element => !!element.value?.trim().length)
+ .length.toString();
+ }
+
+ this.recordLegacyFormEvent("submitted", section.flowId, extra);
+ }
+
+ recordNumberOfUse(records) {
+ super.recordNumberOfUse(records);
+
+ if (!this.HISTOGRAM_NUM_USES) {
+ return;
+ }
+
+ let histogram = Services.telemetry.getHistogramById(
+ this.HISTOGRAM_NUM_USES
+ );
+ histogram.clear();
+
+ for (let record of records) {
+ histogram.add(record.timesUsed);
+ }
+ }
+
+ recordAutofillProfileCount(count) {
+ Glean.formautofillCreditcards.autofillProfilesCount.set(count);
+ }
+}
+
+export class AutofillTelemetry {
+ static #creditCardTelemetry = new CreditCardTelemetry();
+ static #addressTelemetry = new AddressTelemetry();
+
+ // const for `type` parameter used in the utility functions
+ static ADDRESS = "address";
+ static CREDIT_CARD = "creditcard";
+
+ static #getTelemetryBySection(section) {
+ return section instanceof FormAutofillCreditCardSection
+ ? this.#creditCardTelemetry
+ : this.#addressTelemetry;
+ }
+
+ static #getTelemetryByType(type) {
+ return type == AutofillTelemetry.CREDIT_CARD
+ ? this.#creditCardTelemetry
+ : this.#addressTelemetry;
+ }
+
+ /**
+ * Utility functions for `doorhanger` event (defined in Events.yaml)
+ *
+ * Category: address or creditcard
+ * Event name: doorhanger
+ */
+ static recordDoorhangerShown(type, object, flowId) {
+ const telemetry = this.#getTelemetryByType(type);
+ telemetry.recordDoorhangerEvent("show", object, flowId);
+ }
+
+ static recordDoorhangerClicked(type, method, object, flowId) {
+ const telemetry = this.#getTelemetryByType(type);
+
+ // We don't have `create` method in telemetry, we treat `create` as `save`
+ switch (method) {
+ case "create":
+ method = "save";
+ break;
+ case "open-pref":
+ method = "pref";
+ break;
+ case "learn-more":
+ method = "learn_more";
+ break;
+ }
+
+ telemetry.recordDoorhangerEvent(method, object, flowId);
+ }
+
+ /**
+ * Utility functions for form event (defined in Events.yaml)
+ *
+ * Category: address or creditcard
+ * Event name: cc_form, cc_form_v2, or address_form
+ */
+
+ static recordFormInteractionEvent(
+ method,
+ section,
+ { fieldName, profile, record, form } = {}
+ ) {
+ const telemetry = this.#getTelemetryBySection(section);
+ telemetry.recordFormInteractionEvent(method, section, {
+ fieldName,
+ profile,
+ record,
+ form,
+ });
+ }
+
+ /**
+ * Utility functions for submitted section count scalar (defined in Scalars.yaml)
+ *
+ * Category: formautofill.creditCards or formautofill.addresses
+ * Scalar name: submitted_sections_count
+ */
+ static recordDetectedSectionCount(section) {
+ const telemetry = this.#getTelemetryBySection(section);
+ telemetry.recordDetectedSectionCount();
+ }
+
+ static recordSubmittedSectionCount(type, count) {
+ const telemetry = this.#getTelemetryByType(type);
+ telemetry.recordSubmittedSectionCount(count);
+ }
+
+ static recordManageEvent(type, method) {
+ const telemetry = this.#getTelemetryByType(type);
+ telemetry.recordManageEvent(method);
+ }
+
+ static recordAutofillProfileCount(type, count) {
+ const telemetry = this.#getTelemetryByType(type);
+ telemetry.recordAutofillProfileCount(count);
+ }
+
+ /**
+ * Utility functions for address/credit card number of use
+ */
+ static recordNumberOfUse(type, records) {
+ const telemetry = this.#getTelemetryByType(type);
+ telemetry.recordNumberOfUse(records);
+ }
+
+ static recordFormSubmissionHeuristicCount(label) {
+ Glean.formautofill.formSubmissionHeuristic[label].add(1);
+ }
+}
diff --git a/toolkit/components/formautofill/Constants.ios.mjs b/toolkit/components/formautofill/Constants.ios.mjs
new file mode 100644
index 0000000000..b78e47198d
--- /dev/null
+++ b/toolkit/components/formautofill/Constants.ios.mjs
@@ -0,0 +1,102 @@
+/* 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/. */
+
+const IOS_DEFAULT_PREFERENCES = {
+ "extensions.formautofill.creditCards.heuristics.mode": 1,
+ "extensions.formautofill.creditCards.heuristics.fathom.confidenceThreshold": 0.5,
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold": 0.95,
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence": 0,
+ "extensions.formautofill.creditCards.heuristics.fathom.types":
+ "cc-number,cc-name",
+ "extensions.formautofill.addresses.capture.requiredFields":
+ "street-address,postal-code,address-level1,address-level2",
+ "extensions.formautofill.loglevel": "Warn",
+ "extensions.formautofill.addresses.supported": "off",
+ "extensions.formautofill.creditCards.supported": "detect",
+ "browser.search.region": "US",
+ "extensions.formautofill.creditCards.supportedCountries": "US,CA,GB,FR,DE",
+ "extensions.formautofill.addresses.enabled": true,
+ "extensions.formautofill.addresses.experiments.enabled": false, // TODO(FXCM-765): fetch this value from swift
+ "extensions.formautofill.addresses.capture.enabled": false,
+ "extensions.formautofill.addresses.supportedCountries": "",
+ "extensions.formautofill.creditCards.enabled": true,
+ "extensions.formautofill.reauth.enabled": true,
+ "extensions.formautofill.creditCards.hideui": false,
+ "extensions.formautofill.supportRTL": false,
+ "extensions.formautofill.creditCards.ignoreAutocompleteOff": true,
+ "extensions.formautofill.addresses.ignoreAutocompleteOff": true,
+ "extensions.formautofill.heuristics.enabled": true,
+ "extensions.formautofill.section.enabled": true,
+ "extensions.formautofill.heuristics.captureOnFormRemoval": false,
+ "extensions.formautofill.heuristics.captureOnPageNavigation": false,
+ "extensions.formautofill.focusOnAutofill": false,
+};
+
+// Used Mimic the behavior of .getAutocompleteInfo()
+// List from: https://searchfox.org/mozilla-central/source/dom/base/AutocompleteFieldList.h#89-149
+// Also found here: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete
+const VALID_AUTOCOMPLETE_FIELDS = [
+ "off",
+ "on",
+ "name",
+ "honorific-prefix",
+ "given-name",
+ "additional-name",
+ "family-name",
+ "honorific-suffix",
+ "nickname",
+ "email",
+ "username",
+ "new-password",
+ "current-password",
+ "one-time-code",
+ "organization-title",
+ "organization",
+ "street-address",
+ "address-line1",
+ "address-line2",
+ "address-line3",
+ "address-level4",
+ "address-level3",
+ "address-level2",
+ "address-level1",
+ "country",
+ "country-name",
+ "postal-code",
+ "cc-name",
+ "cc-given-name",
+ "cc-additional-name",
+ "cc-family-name",
+ "cc-number",
+ "cc-exp",
+ "cc-exp-month",
+ "cc-exp-year",
+ "cc-csc",
+ "cc-type",
+ "transaction-currency",
+ "transaction-amount",
+ "language",
+ "bday",
+ "bday-day",
+ "bday-month",
+ "bday-year",
+ "sex",
+ "tel",
+ "tel-country-code",
+ "tel-national",
+ "tel-area-code",
+ "tel-local",
+ "tel-extension",
+ "impp",
+ "url",
+ "photo",
+];
+
+export const IOSAppConstants = Object.freeze({
+ platform: "ios",
+ prefs: IOS_DEFAULT_PREFERENCES,
+ validAutocompleteFields: VALID_AUTOCOMPLETE_FIELDS,
+});
+
+export default IOSAppConstants;
diff --git a/toolkit/components/formautofill/FormAutofill.ios.sys.mjs b/toolkit/components/formautofill/FormAutofill.ios.sys.mjs
new file mode 100644
index 0000000000..8e205c16c6
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofill.ios.sys.mjs
@@ -0,0 +1,17 @@
+/* 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/. */
+
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+
+FormAutofill.defineLogGetter = (scope, logPrefix) => ({
+ // TODO: Bug 1828405. Explore how logging should be handled.
+ // Maybe it makes more sense to do it on swift side and have JS just send messages.
+ info: () => {},
+ error: () => {},
+ warn: () => {},
+ debug: () => {},
+});
+
+export { FormAutofill };
+export default FormAutofill;
diff --git a/toolkit/components/formautofill/FormAutofill.sys.mjs b/toolkit/components/formautofill/FormAutofill.sys.mjs
new file mode 100644
index 0000000000..77502afbbe
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofill.sys.mjs
@@ -0,0 +1,294 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { Region } from "resource://gre/modules/Region.sys.mjs";
+import { AddressMetaDataLoader } from "resource://gre/modules/shared/AddressMetaDataLoader.sys.mjs";
+
+const AUTOFILL_ADDRESSES_AVAILABLE_PREF =
+ "extensions.formautofill.addresses.supported";
+// This pref should be refactored after the migration of the old bool pref
+const AUTOFILL_CREDITCARDS_AVAILABLE_PREF =
+ "extensions.formautofill.creditCards.supported";
+const BROWSER_SEARCH_REGION_PREF = "browser.search.region";
+const CREDITCARDS_AUTOFILL_SUPPORTED_COUNTRIES_PREF =
+ "extensions.formautofill.creditCards.supportedCountries";
+const ENABLED_AUTOFILL_ADDRESSES_PREF =
+ "extensions.formautofill.addresses.enabled";
+const ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF =
+ "extensions.formautofill.addresses.capture.enabled";
+const ENABLED_AUTOFILL_ADDRESSES_CAPTURE_REQUIRED_FIELDS_PREF =
+ "extensions.formautofill.addresses.capture.requiredFields";
+const ENABLED_AUTOFILL_ADDRESSES_SUPPORTED_COUNTRIES_PREF =
+ "extensions.formautofill.addresses.supportedCountries";
+const ENABLED_AUTOFILL_CREDITCARDS_PREF =
+ "extensions.formautofill.creditCards.enabled";
+const ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF =
+ "extensions.formautofill.reauth.enabled";
+const AUTOFILL_CREDITCARDS_HIDE_UI_PREF =
+ "extensions.formautofill.creditCards.hideui";
+const FORM_AUTOFILL_SUPPORT_RTL_PREF = "extensions.formautofill.supportRTL";
+const AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF =
+ "extensions.formautofill.creditCards.ignoreAutocompleteOff";
+const AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF =
+ "extensions.formautofill.addresses.ignoreAutocompleteOff";
+const ENABLED_AUTOFILL_CAPTURE_ON_FORM_REMOVAL_PREF =
+ "extensions.formautofill.heuristics.captureOnFormRemoval";
+const ENABLED_AUTOFILL_CAPTURE_ON_PAGE_NAVIGATION_PREF =
+ "extensions.formautofill.heuristics.captureOnPageNavigation";
+
+export const FormAutofill = {
+ ENABLED_AUTOFILL_ADDRESSES_PREF,
+ ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF,
+ ENABLED_AUTOFILL_CAPTURE_ON_FORM_REMOVAL_PREF,
+ ENABLED_AUTOFILL_CAPTURE_ON_PAGE_NAVIGATION_PREF,
+ ENABLED_AUTOFILL_CREDITCARDS_PREF,
+ ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF,
+ AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF,
+ AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF,
+
+ _region: null,
+
+ get DEFAULT_REGION() {
+ return this._region || Region.home || "US";
+ },
+
+ set DEFAULT_REGION(region) {
+ this._region = region;
+ },
+
+ /**
+ * Determines if an autofill feature should be enabled based on the "available"
+ * and "supportedCountries" parameters.
+ *
+ * @param {string} available Available can be one of the following: "on", "detect", "off".
+ * "on" forces the particular Form Autofill feature on, while "detect" utilizes the supported countries
+ * to see if the feature should be available.
+ * @param {string[]} supportedCountries
+ * @returns {boolean} `true` if autofill feature is supported in the current browser search region
+ */
+ _isSupportedRegion(available, supportedCountries) {
+ if (available == "on") {
+ return true;
+ } else if (available == "detect") {
+ if (!FormAutofill.supportRTL && Services.locale.isAppLocaleRTL) {
+ return false;
+ }
+
+ return supportedCountries.includes(FormAutofill.browserSearchRegion);
+ }
+ return false;
+ },
+ isAutofillAddressesAvailableInCountry(country) {
+ return FormAutofill._addressAutofillSupportedCountries.includes(country);
+ },
+ get isAutofillEnabled() {
+ return this.isAutofillAddressesEnabled || this.isAutofillCreditCardsEnabled;
+ },
+ /**
+ * Determines if the credit card autofill feature is available to use in the browser.
+ * If the feature is not available, then there are no user facing ways to enable it.
+ *
+ * @returns {boolean} `true` if credit card autofill is available
+ */
+ get isAutofillCreditCardsAvailable() {
+ return this._isSupportedRegion(
+ FormAutofill._isAutofillCreditCardsAvailable,
+ FormAutofill._creditCardAutofillSupportedCountries
+ );
+ },
+ /**
+ * Determines if the address autofill feature is available to use in the browser.
+ * If the feature is not available, then there are no user facing ways to enable it.
+ * Two conditions must be met for the autofill feature to be considered available:
+ * 1. Address autofill support is confirmed when:
+ * - `extensions.formautofill.addresses.supported` is set to `on`.
+ * - The user is located in a region supported by the feature
+ * (`extensions.formautofill.creditCards.supportedCountries`).
+ * 2. Address autofill is enabled through a Nimbus experiment:
+ * - The experiment pref `extensions.formautofill.addresses.experiments.enabled` is set to true.
+ *
+ * @returns {boolean} `true` if address autofill is available
+ */
+ get isAutofillAddressesAvailable() {
+ const isUserInSupportedRegion = this._isSupportedRegion(
+ FormAutofill._isAutofillAddressesAvailable,
+ FormAutofill._addressAutofillSupportedCountries
+ );
+ return (
+ isUserInSupportedRegion ||
+ FormAutofill._isAutofillAddressesAvailableInExperiment
+ );
+ },
+ /**
+ * Determines if the user has enabled or disabled credit card autofill.
+ *
+ * @returns {boolean} `true` if credit card autofill is enabled
+ */
+ get isAutofillCreditCardsEnabled() {
+ return (
+ this.isAutofillCreditCardsAvailable &&
+ FormAutofill._isAutofillCreditCardsEnabled
+ );
+ },
+ /**
+ * Determines if credit card autofill is locked by policy.
+ *
+ * @returns {boolean} `true` if credit card autofill is locked
+ */
+ get isAutofillCreditCardsLocked() {
+ return Services.prefs.prefIsLocked(ENABLED_AUTOFILL_CREDITCARDS_PREF);
+ },
+ /**
+ * Determines if the user has enabled or disabled address autofill.
+ *
+ * @returns {boolean} `true` if address autofill is enabled
+ */
+ get isAutofillAddressesEnabled() {
+ return (
+ this.isAutofillAddressesAvailable &&
+ FormAutofill._isAutofillAddressesEnabled
+ );
+ },
+ /**
+ * Determines if address autofill is locked by policy.
+ *
+ * @returns {boolean} `true` if address autofill is locked
+ */
+ get isAutofillAddressesLocked() {
+ return Services.prefs.prefIsLocked(ENABLED_AUTOFILL_ADDRESSES_PREF);
+ },
+
+ defineLogGetter(scope, logPrefix) {
+ // A logging helper for debug logging to avoid creating Console objects
+ // or triggering expensive JS -> C++ calls when debug logging is not
+ // enabled.
+ //
+ // Console objects, even natively-implemented ones, can consume a lot of
+ // memory, and since this code may run in every content process, that
+ // memory can add up quickly. And, even when debug-level messages are
+ // being ignored, console.debug() calls can be expensive.
+ //
+ // This helper avoids both of those problems by never touching the
+ // console object unless debug logging is enabled.
+ scope.debug = function debug() {
+ if (FormAutofill.logLevel.toLowerCase() == "debug") {
+ this.log.debug(...arguments);
+ }
+ };
+
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ return new ConsoleAPI({
+ maxLogLevelPref: "extensions.formautofill.loglevel",
+ prefix: logPrefix,
+ });
+ },
+};
+
+// TODO: Bug 1747284. Use Region.home instead of reading "browser.serach.region"
+// by default. However, Region.home doesn't observe preference change at this point,
+// we should also fix that issue.
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "browserSearchRegion",
+ BROWSER_SEARCH_REGION_PREF,
+ FormAutofill.DEFAULT_REGION
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "logLevel",
+ "extensions.formautofill.loglevel",
+ "Warn"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_isAutofillAddressesAvailable",
+ AUTOFILL_ADDRESSES_AVAILABLE_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_isAutofillAddressesEnabled",
+ ENABLED_AUTOFILL_ADDRESSES_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "isAutofillAddressesCaptureEnabled",
+ ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_isAutofillCreditCardsAvailable",
+ AUTOFILL_CREDITCARDS_AVAILABLE_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_isAutofillCreditCardsEnabled",
+ ENABLED_AUTOFILL_CREDITCARDS_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "isAutofillCreditCardsHideUI",
+ AUTOFILL_CREDITCARDS_HIDE_UI_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_addressAutofillSupportedCountries",
+ ENABLED_AUTOFILL_ADDRESSES_SUPPORTED_COUNTRIES_PREF,
+ null,
+ val => val.split(",")
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_creditCardAutofillSupportedCountries",
+ CREDITCARDS_AUTOFILL_SUPPORTED_COUNTRIES_PREF,
+ null,
+ null,
+ val => val.split(",")
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "supportRTL",
+ FORM_AUTOFILL_SUPPORT_RTL_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "creditCardsAutocompleteOff",
+ AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "addressesAutocompleteOff",
+ AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "captureOnFormRemoval",
+ ENABLED_AUTOFILL_CAPTURE_ON_FORM_REMOVAL_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "captureOnPageNavigation",
+ ENABLED_AUTOFILL_CAPTURE_ON_PAGE_NAVIGATION_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "addressCaptureRequiredFields",
+ ENABLED_AUTOFILL_ADDRESSES_CAPTURE_REQUIRED_FIELDS_PREF,
+ null,
+ null,
+ val => val?.split(",").filter(v => !!v)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_isAutofillAddressesAvailableInExperiment",
+ "extensions.formautofill.addresses.experiments.enabled"
+);
+
+ChromeUtils.defineLazyGetter(FormAutofill, "countries", () =>
+ AddressMetaDataLoader.getCountries()
+);
diff --git a/toolkit/components/formautofill/FormAutofillChild.ios.sys.mjs b/toolkit/components/formautofill/FormAutofillChild.ios.sys.mjs
new file mode 100644
index 0000000000..1aa713b5b7
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillChild.ios.sys.mjs
@@ -0,0 +1,107 @@
+/* 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/. */
+
+/* eslint-disable no-undef,mozilla/balanced-listeners */
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import { FormStateManager } from "resource://gre/modules/shared/FormStateManager.sys.mjs";
+import { CreditCardRecord } from "resource://gre/modules/shared/CreditCardRecord.sys.mjs";
+
+export class FormAutofillChild {
+ /**
+ * Creates an instance of FormAutofillChild.
+ *
+ * @param {object} callbacks - An object containing callback functions.
+ * @param {object} callbacks.address - Callbacks related to addresses.
+ * @param {Function} callbacks.address.autofill - Function called to autofill address fields.
+ * @param {Function} callbacks.address.submit - Function called on address form submission.
+ * @param {object} callbacks.creditCard - Callbacks related to credit cards.
+ * @param {Function} callbacks.creditCard.autofill - Function called to autofill credit card fields.
+ * @param {Function} callbacks.creditCard.submit - Function called on credit card form submission.
+ */
+ constructor(callbacks) {
+ this.onFocusIn = this.onFocusIn.bind(this);
+ this.onSubmit = this.onSubmit.bind(this);
+
+ this.callbacks = callbacks;
+
+ this.fieldDetailsManager = new FormStateManager();
+
+ document.addEventListener("focusin", this.onFocusIn);
+ document.addEventListener("submit", this.onSubmit);
+ }
+
+ _doIdentifyAutofillFields(element) {
+ this.fieldDetailsManager.updateActiveInput(element);
+ this.fieldDetailsManager.identifyAutofillFields(element);
+
+ const activeFieldName =
+ this.fieldDetailsManager.activeFieldDetail?.fieldName;
+
+ const activeFieldDetails =
+ this.fieldDetailsManager.activeSection?.fieldDetails;
+
+ // Only ping swift if current field is either a cc or address field
+ if (!activeFieldDetails?.find(field => field.element === element)) {
+ return;
+ }
+
+ const fieldNamesWithValues =
+ this.transformToFieldNamesWithValues(activeFieldDetails);
+ if (FormAutofillUtils.isAddressField(activeFieldName)) {
+ this.callbacks.address.autofill(fieldNamesWithValues);
+ } else if (FormAutofillUtils.isCreditCardField(activeFieldName)) {
+ // Normalize record format so we always get a consistent
+ // credit card record format: {cc-number, cc-name, cc-exp-month, cc-exp-year}
+ CreditCardRecord.normalizeFields(fieldNamesWithValues);
+ this.callbacks.creditCard.autofill(fieldNamesWithValues);
+ }
+ }
+
+ transformToFieldNamesWithValues(details) {
+ return details?.reduce(
+ (acc, field) => ({
+ ...acc,
+ [field.fieldName]: field.element.value,
+ }),
+ {}
+ );
+ }
+
+ onFocusIn(evt) {
+ const element = evt.target;
+ this.fieldDetailsManager.updateActiveInput(element);
+ if (!FormAutofillUtils.isCreditCardOrAddressFieldType(element)) {
+ return;
+ }
+ this._doIdentifyAutofillFields(element);
+ }
+
+ onSubmit(evt) {
+ if (!this.fieldDetailsManager.activeHandler) {
+ return;
+ }
+
+ this.fieldDetailsManager.activeHandler.onFormSubmitted();
+ const records = this.fieldDetailsManager.activeHandler.createRecords();
+
+ if (records.creditCard.length) {
+ // Normalize record format so we always get a consistent
+ // credit card record format: {cc-number, cc-name, cc-exp-month, cc-exp-year}
+ const creditCardRecords = records.creditCard.map(entry => {
+ CreditCardRecord.normalizeFields(entry.record);
+ return entry.record;
+ });
+ this.callbacks.creditCard.submit(creditCardRecords);
+ }
+
+ // TODO(FXSP-133 Phase 3): Support address capture
+ // this.callbacks.address.submit();
+ }
+
+ fillFormFields(payload) {
+ this.fieldDetailsManager.activeHandler.autofillFormFields(payload);
+ }
+}
+
+export default FormAutofillChild;
diff --git a/toolkit/components/formautofill/FormAutofillChild.sys.mjs b/toolkit/components/formautofill/FormAutofillChild.sys.mjs
new file mode 100644
index 0000000000..c40bfddbce
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillChild.sys.mjs
@@ -0,0 +1,472 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AutoCompleteChild: "resource://gre/actors/AutoCompleteChild.sys.mjs",
+ FormAutofill: "resource://autofill/FormAutofill.sys.mjs",
+ FormAutofillContent: "resource://autofill/FormAutofillContent.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+const observer = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ // Only handle pushState/replaceState here.
+ if (
+ !(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) ||
+ !(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE)
+ ) {
+ return;
+ }
+ const window = aWebProgress.DOMWindow;
+ const formAutofillChild = window.windowGlobalChild.getActor("FormAutofill");
+ formAutofillChild.onPageNavigation();
+ },
+
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ if (
+ // if restoring a previously-rendered presentation (bfcache)
+ aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_STOP
+ ) {
+ return;
+ }
+
+ if (!(aStateFlags & Ci.nsIWebProgressListener.STATE_START)) {
+ return;
+ }
+
+ // We only care about when a page triggered a load, not the user. For example:
+ // clicking refresh/back/forward, typing a URL and hitting enter, and loading a bookmark aren't
+ // likely to be when a user wants to save a formautofill data.
+ let channel = aRequest.QueryInterface(Ci.nsIChannel);
+ let triggeringPrincipal = channel.loadInfo.triggeringPrincipal;
+ if (
+ triggeringPrincipal.isNullPrincipal ||
+ triggeringPrincipal.equals(
+ Services.scriptSecurityManager.getSystemPrincipal()
+ )
+ ) {
+ return;
+ }
+
+ // Don't handle history navigation, reload, or pushState not triggered via chrome UI.
+ // e.g. history.go(-1), location.reload(), history.replaceState()
+ if (!(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_NORMAL)) {
+ return;
+ }
+
+ const window = aWebProgress.DOMWindow;
+ const formAutofillChild = window.windowGlobalChild.getActor("FormAutofill");
+ formAutofillChild.onPageNavigation();
+ },
+};
+
+/**
+ * Handles content's interactions for the frame.
+ */
+export class FormAutofillChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ this._nextHandleElement = null;
+ this._alreadyDOMContentLoaded = false;
+ this._hasDOMContentLoadedHandler = false;
+ this._hasPendingTask = false;
+ this.testListener = null;
+
+ lazy.AutoCompleteChild.addPopupStateListener(this);
+ }
+
+ didDestroy() {
+ lazy.AutoCompleteChild.removePopupStateListener(this);
+ lazy.FormAutofillContent.didDestroy();
+ }
+
+ popupStateChanged(messageName, data, target) {
+ let docShell;
+ try {
+ docShell = this.docShell;
+ } catch (ex) {
+ lazy.AutoCompleteChild.removePopupStateListener(this);
+ return;
+ }
+
+ if (!lazy.FormAutofill.isAutofillEnabled) {
+ return;
+ }
+
+ const { chromeEventHandler } = docShell;
+
+ switch (messageName) {
+ case "FormAutoComplete:PopupClosed": {
+ lazy.FormAutofillContent.onPopupClosed(data.selectedRowStyle);
+ Services.tm.dispatchToMainThread(() => {
+ chromeEventHandler.removeEventListener(
+ "keydown",
+ lazy.FormAutofillContent._onKeyDown,
+ true
+ );
+ });
+
+ break;
+ }
+ case "FormAutoComplete:PopupOpened": {
+ lazy.FormAutofillContent.onPopupOpened();
+ chromeEventHandler.addEventListener(
+ "keydown",
+ lazy.FormAutofillContent._onKeyDown,
+ true
+ );
+ break;
+ }
+ }
+ }
+
+ /**
+ * Invokes the FormAutofillContent to identify the autofill fields
+ * and consider opening the dropdown menu for the focused field
+ *
+ */
+ _doIdentifyAutofillFields() {
+ if (this._hasPendingTask) {
+ return;
+ }
+ this._hasPendingTask = true;
+
+ lazy.setTimeout(() => {
+ const isAnyFieldIdentified =
+ lazy.FormAutofillContent.identifyAutofillFields(
+ this._nextHandleElement
+ );
+ if (isAnyFieldIdentified) {
+ if (lazy.FormAutofill.captureOnFormRemoval) {
+ this.registerDOMDocFetchSuccessEventListener(
+ this._nextHandleElement.ownerDocument
+ );
+ }
+ if (lazy.FormAutofill.captureOnPageNavigation) {
+ this.registerProgressListener();
+ }
+ }
+
+ this._hasPendingTask = false;
+ this._nextHandleElement = null;
+ // This is for testing purpose only which sends a notification to indicate that the
+ // form has been identified, and ready to open popup.
+ this.sendAsyncMessage("FormAutofill:FieldsIdentified");
+ lazy.FormAutofillContent.updateActiveInput();
+ });
+ }
+
+ /**
+ * Gets the highest accessible docShell
+ *
+ * @returns {DocShell} highest accessible docShell
+ */
+ getHighestDocShell() {
+ const window = this.document.defaultView;
+
+ let docShell;
+ for (
+ let browsingContext = BrowsingContext.getFromWindow(window);
+ browsingContext?.docShell;
+ browsingContext = browsingContext.parent
+ ) {
+ docShell = browsingContext.docShell;
+ }
+
+ return docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ }
+
+ /**
+ * After being notified of a page navigation, we check whether
+ * the navigated window is the active window or one of its parents
+ * (active window = FormAutofillContent.activeHandler.window)
+ *
+ * @returns {boolean} whether the navigation affects the active window
+ */
+ isActiveWindowNavigation() {
+ const activeWindow = lazy.FormAutofillContent.activeHandler.window;
+ const navigatedWindow = this.document.defaultView;
+ const navigatedBrowsingContext =
+ BrowsingContext.getFromWindow(navigatedWindow);
+
+ for (
+ let browsingContext = BrowsingContext.getFromWindow(activeWindow);
+ browsingContext?.docShell;
+ browsingContext = browsingContext.parent
+ ) {
+ if (navigatedBrowsingContext === browsingContext) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Infer a form submission after document is navigated
+ */
+ onPageNavigation() {
+ const activeElement =
+ lazy.FormAutofillContent.activeFieldDetail?.elementWeakRef.deref();
+
+ if (!this.isActiveWindowNavigation()) {
+ return;
+ }
+
+ const formSubmissionReason =
+ lazy.FormAutofillUtils.FORM_SUBMISSION_REASON.PAGE_NAVIGATION;
+
+ // We only capture the form of the active field right now,
+ // this means that we might miss some fields (see bug 1871356)
+ lazy.FormAutofillContent.formSubmitted(activeElement, formSubmissionReason);
+ }
+
+ /**
+ * After a form submission we unregister the
+ * nsIWebProgressListener from the top level doc shell
+ */
+ unregisterProgressListener() {
+ const docShell = this.getHighestDocShell();
+ try {
+ docShell.removeProgressListener(observer);
+ } catch (ex) {
+ // Ignore NS_ERROR_FAILURE if the progress listener was not registered
+ }
+ }
+
+ /**
+ * After a focusin event and after we identified formautofill fields,
+ * we set up a nsIWebProgressListener that notifies of a request state
+ * change or window location change in the top level doc shell
+ */
+ registerProgressListener() {
+ const docShell = this.getHighestDocShell();
+
+ const flags =
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT |
+ Ci.nsIWebProgress.NOTIFY_LOCATION;
+ try {
+ docShell.addProgressListener(observer, flags);
+ } catch (ex) {
+ // Ignore NS_ERROR_FAILURE if the progress listener was already added
+ }
+ }
+
+ /**
+ * After a focusin event and after we identify formautofill fields,
+ * we set up an event listener for the DOMDocFetchSuccess event
+ *
+ * @param {Document} document The document we want to be notified by of a DOMDocFetchSuccess event
+ */
+ registerDOMDocFetchSuccessEventListener(document) {
+ document.setNotifyFetchSuccess(true);
+
+ // Is removed after a DOMDocFetchSuccess event (bug 1864855)
+ /* eslint-disable mozilla/balanced-listeners */
+ this.docShell.chromeEventHandler.addEventListener(
+ "DOMDocFetchSuccess",
+ this,
+ true
+ );
+ }
+
+ /**
+ * After a DOMDocFetchSuccess event, we register an event listener for the DOMFormRemoved event
+ *
+ * @param {Document} document The document we want to be notified by of a DOMFormRemoved event
+ */
+ registerDOMFormRemovedEventListener(document) {
+ document.setNotifyFormOrPasswordRemoved(true);
+
+ // Is removed after a DOMFormRemoved event (bug 1864855)
+ /* eslint-disable mozilla/balanced-listeners */
+ this.docShell.chromeEventHandler.addEventListener(
+ "DOMFormRemoved",
+ this,
+ true
+ );
+ }
+
+ /**
+ * After a DOMDocFetchSuccess event we remove the DOMDocFetchSuccess event listener
+ *
+ * @param {Document} document The document we are notified by of a DOMDocFetchSuccess event
+ */
+ unregisterDOMDocFetchSuccessEventListener(document) {
+ document.setNotifyFetchSuccess(false);
+ this.docShell.chromeEventHandler.removeEventListener(
+ "DOMDocFetchSuccess",
+ this
+ );
+ }
+
+ /**
+ * After a DOMFormRemoved event we remove the DOMFormRemoved event listener
+ *
+ * @param {Document} document The document we are notified by of a DOMFormRemoved event
+ */
+ unregisterDOMFormRemovedEventListener(document) {
+ document.setNotifyFormOrPasswordRemoved(false);
+ this.docShell.chromeEventHandler.removeEventListener(
+ "DOMFormRemoved",
+ this
+ );
+ }
+
+ shouldIgnoreFormAutofillEvent(event) {
+ let nodePrincipal = event.target.nodePrincipal;
+ return (
+ nodePrincipal.isSystemPrincipal ||
+ nodePrincipal.isNullPrincipal ||
+ nodePrincipal.schemeIs("about")
+ );
+ }
+
+ handleEvent(evt) {
+ if (!evt.isTrusted) {
+ return;
+ }
+ if (this.shouldIgnoreFormAutofillEvent(evt)) {
+ return;
+ }
+
+ switch (evt.type) {
+ case "focusin": {
+ if (lazy.FormAutofill.isAutofillEnabled) {
+ this.onFocusIn(evt);
+ }
+ break;
+ }
+ case "DOMFormBeforeSubmit": {
+ if (lazy.FormAutofill.isAutofillEnabled) {
+ this.onDOMFormBeforeSubmit(evt);
+ }
+ break;
+ }
+ case "DOMFormRemoved": {
+ this.onDOMFormRemoved(evt);
+ break;
+ }
+ case "DOMDocFetchSuccess": {
+ this.onDOMDocFetchSuccess(evt);
+ break;
+ }
+
+ default: {
+ throw new Error("Unexpected event type");
+ }
+ }
+ }
+
+ onFocusIn(evt) {
+ lazy.FormAutofillContent.updateActiveInput();
+
+ let element = evt.target;
+ if (!lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element)) {
+ return;
+ }
+ this._nextHandleElement = element;
+
+ if (!this._alreadyDOMContentLoaded) {
+ let doc = element.ownerDocument;
+ if (doc.readyState === "loading") {
+ if (!this._hasDOMContentLoadedHandler) {
+ this._hasDOMContentLoadedHandler = true;
+ doc.addEventListener(
+ "DOMContentLoaded",
+ () => this._doIdentifyAutofillFields(),
+ { once: true }
+ );
+ }
+ return;
+ }
+ this._alreadyDOMContentLoaded = true;
+ }
+
+ this._doIdentifyAutofillFields();
+ }
+
+ /**
+ * Handle the DOMFormBeforeSubmit event.
+ *
+ * @param {Event} evt
+ */
+ onDOMFormBeforeSubmit(evt) {
+ const formElement = evt.target;
+
+ const formSubmissionReason =
+ lazy.FormAutofillUtils.FORM_SUBMISSION_REASON.FORM_SUBMIT_EVENT;
+
+ lazy.FormAutofillContent.formSubmitted(formElement, formSubmissionReason);
+ }
+
+ /**
+ * Handle the DOMFormRemoved event.
+ *
+ * Infers a form submission when the form is removed
+ * after a successful fetch or XHR request.
+ *
+ * @param {Event} evt DOMFormRemoved
+ */
+ onDOMFormRemoved(evt) {
+ const document = evt.composedTarget.ownerDocument;
+
+ const formSubmissionReason =
+ lazy.FormAutofillUtils.FORM_SUBMISSION_REASON.FORM_REMOVAL_AFTER_FETCH;
+
+ lazy.FormAutofillContent.formSubmitted(evt.target, formSubmissionReason);
+
+ this.unregisterDOMFormRemovedEventListener(document);
+ }
+
+ /**
+ * Handle the DOMDocFetchSuccess event.
+ *
+ * Sets up an event listener for the DOMFormRemoved event
+ * and unregisters the event listener for DOMDocFetchSuccess event.
+ *
+ * @param {Event} evt DOMDocFetchSuccess
+ */
+ onDOMDocFetchSuccess(evt) {
+ const document = evt.target;
+
+ this.registerDOMFormRemovedEventListener(document);
+
+ this.unregisterDOMDocFetchSuccessEventListener(document);
+ }
+
+ receiveMessage(message) {
+ if (!lazy.FormAutofill.isAutofillEnabled) {
+ return;
+ }
+
+ const doc = this.document;
+
+ switch (message.name) {
+ case "FormAutofill:PreviewProfile": {
+ lazy.FormAutofillContent.previewProfile(doc);
+ break;
+ }
+ case "FormAutofill:ClearForm": {
+ lazy.FormAutofillContent.clearForm();
+ break;
+ }
+ case "FormAutofill:FillForm": {
+ lazy.FormAutofillContent.activeHandler.autofillFormFields(message.data);
+ break;
+ }
+ }
+ }
+}
diff --git a/toolkit/components/formautofill/FormAutofillContent.sys.mjs b/toolkit/components/formautofill/FormAutofillContent.sys.mjs
new file mode 100644
index 0000000000..133e5e1d0a
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillContent.sys.mjs
@@ -0,0 +1,442 @@
+/* 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/. */
+
+/**
+ * Form Autofill content process module.
+ */
+
+/* eslint-disable no-use-before-define */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormAutofill: "resource://autofill/FormAutofill.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ FormStateManager: "resource://gre/modules/shared/FormStateManager.sys.mjs",
+ ProfileAutocomplete:
+ "resource://autofill/AutofillProfileAutoComplete.sys.mjs",
+ AutofillTelemetry: "resource://autofill/AutofillTelemetry.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "DELEGATE_AUTOCOMPLETE",
+ "toolkit.autocomplete.delegate",
+ false
+);
+
+const formFillController = Cc[
+ "@mozilla.org/satchel/form-fill-controller;1"
+].getService(Ci.nsIFormFillController);
+
+function getActorFromWindow(contentWindow, name = "FormAutofill") {
+ // In unit tests, contentWindow isn't a real window.
+ if (!contentWindow) {
+ return null;
+ }
+
+ return contentWindow.windowGlobalChild
+ ? contentWindow.windowGlobalChild.getActor(name)
+ : null;
+}
+
+/**
+ * Handles content's interactions for the process.
+ *
+ */
+export var FormAutofillContent = {
+ /**
+ * @type {Set} Set of the fields with usable values in any saved profile.
+ */
+ get savedFieldNames() {
+ return Services.cpmm.sharedData.get("FormAutofill:savedFieldNames");
+ },
+
+ /**
+ * @type {boolean} Flag indicating whether a focus action requiring
+ * the popup to be active is pending.
+ */
+ _popupPending: false,
+
+ /**
+ * @type {boolean} Flag indicating whether the form is waiting to be
+ * filled by Autofill.
+ */
+ _autofillPending: false,
+
+ init() {
+ this.log = lazy.FormAutofill.defineLogGetter(this, "FormAutofillContent");
+ this.debug("init");
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ Services.cpmm.sharedData.addEventListener("change", this);
+
+ let autofillEnabled = Services.cpmm.sharedData.get("FormAutofill:enabled");
+ // If storage hasn't be initialized yet autofillEnabled is undefined but we need to ensure
+ // autocomplete is registered before the focusin so register it in this case as long as the
+ // pref is true.
+ let shouldEnableAutofill =
+ autofillEnabled === undefined &&
+ (lazy.FormAutofill.isAutofillAddressesEnabled ||
+ lazy.FormAutofill.isAutofillCreditCardsEnabled);
+ if (autofillEnabled || shouldEnableAutofill) {
+ lazy.ProfileAutocomplete.ensureRegistered();
+ }
+
+ /**
+ * @type {FormAutofillFieldDetailsManager} handling state management of current forms and handlers.
+ */
+ this._fieldDetailsManager = new lazy.FormStateManager(
+ this.formSubmitted.bind(this),
+ this._showPopup.bind(this)
+ );
+ },
+
+ get activeFieldDetail() {
+ return this._fieldDetailsManager.activeFieldDetail;
+ },
+
+ get activeFormDetails() {
+ return this._fieldDetailsManager.activeFormDetails;
+ },
+
+ get activeInput() {
+ return this._fieldDetailsManager.activeInput;
+ },
+
+ get activeHandler() {
+ return this._fieldDetailsManager.activeHandler;
+ },
+
+ get activeSection() {
+ return this._fieldDetailsManager.activeSection;
+ },
+
+ /**
+ * Send the profile to parent for doorhanger and storage saving/updating.
+ *
+ * @param {object} profile Submitted form's address/creditcard guid and record.
+ * @param {object} domWin Current content window.
+ */
+ _onFormSubmit(profile, domWin) {
+ let actor = getActorFromWindow(domWin);
+ actor.sendAsyncMessage("FormAutofill:OnFormSubmit", profile);
+ },
+
+ /**
+ * Handle a form submission and early return when:
+ * 1. In private browsing mode.
+ * 2. Could not map any autofill handler by form element.
+ * 3. Number of filled fields is less than autofill threshold
+ *
+ * @param {HTMLElement} formElement Root element which receives submit event.
+ * @param {string} formSubmissionReason Reason for invoking the form submission
+ * (see options for FORM_SUBMISSION_REASON in FormAutofillUtils))
+ * @param {Window} domWin Content window; passed for unit tests and when
+ * invoked by the FormAutofillSection
+ * @param {object} handler FormAutofillHander, if known by caller
+ */
+ formSubmitted(
+ formElement,
+ formSubmissionReason,
+ domWin = formElement.ownerGlobal,
+ handler = undefined
+ ) {
+ this.debug(`Handling form submission - infered by ${formSubmissionReason}`);
+
+ // Unregister the progress listener since we detected a form submission
+ // (domWin is null in unit tests)
+ getActorFromWindow(domWin)?.unregisterProgressListener();
+
+ lazy.AutofillTelemetry.recordFormSubmissionHeuristicCount(
+ formSubmissionReason
+ );
+
+ if (!lazy.FormAutofill.isAutofillEnabled) {
+ this.debug("Form Autofill is disabled");
+ return;
+ }
+
+ // The `domWin` truthiness test is used by unit tests to bypass this check.
+ if (domWin && lazy.PrivateBrowsingUtils.isContentWindowPrivate(domWin)) {
+ this.debug("Ignoring submission in a private window");
+ return;
+ }
+
+ handler = handler || this._fieldDetailsManager._getFormHandler(formElement);
+ const records = this._fieldDetailsManager.getRecords(formElement, handler);
+
+ if (!records || !handler) {
+ this.debug("Form element could not map to an existing handler");
+ return;
+ }
+
+ [records.address, records.creditCard].forEach((rs, idx) => {
+ lazy.AutofillTelemetry.recordSubmittedSectionCount(
+ idx == 0
+ ? lazy.AutofillTelemetry.ADDRESS
+ : lazy.AutofillTelemetry.CREDIT_CARD,
+ rs?.length
+ );
+
+ rs?.forEach(r => {
+ lazy.AutofillTelemetry.recordFormInteractionEvent(
+ "submitted",
+ r.section,
+ {
+ record: r,
+ form: handler.form,
+ }
+ );
+ delete r.section;
+ });
+ });
+
+ this._onFormSubmit(records, domWin);
+ },
+
+ _showPopup() {
+ formFillController.showPopup();
+ },
+
+ handleEvent(evt) {
+ switch (evt.type) {
+ case "change": {
+ if (!evt.changedKeys.includes("FormAutofill:enabled")) {
+ return;
+ }
+ if (Services.cpmm.sharedData.get("FormAutofill:enabled")) {
+ lazy.ProfileAutocomplete.ensureRegistered();
+ if (this._popupPending) {
+ this._popupPending = false;
+ this.debug("handleEvent: Opening deferred popup");
+ this._showPopup();
+ }
+ } else {
+ lazy.ProfileAutocomplete.ensureUnregistered();
+ }
+ break;
+ }
+ }
+ },
+
+ /**
+ * All active items should be updated according the active element of
+ * `formFillController.focusedInput`. All of them including element,
+ * handler, section, and field detail, can be retrieved by their own getters.
+ *
+ * @param {HTMLElement|null} element The active item should be updated based
+ * on this or `formFillController.focusedInput` will be taken.
+ */
+ updateActiveInput(element) {
+ element = element || formFillController.focusedInput;
+ if (!element) {
+ this.debug("updateActiveElement: no element selected");
+ return;
+ }
+ this._fieldDetailsManager.updateActiveInput(element);
+ this.debug("updateActiveElement: checking for popup-on-focus");
+ // We know this element just received focus. If it's a credit card field,
+ // open its popup.
+ if (this._autofillPending) {
+ this.debug("updateActiveElement: skipping check; autofill is imminent");
+ } else if (element.value?.length !== 0) {
+ this.debug(
+ `updateActiveElement: Not opening popup because field is not empty.`
+ );
+ } else {
+ this.debug(
+ "updateActiveElement: checking if empty field is cc-*: ",
+ this.activeFieldDetail?.fieldName
+ );
+
+ if (
+ this.activeFieldDetail?.fieldName?.startsWith("cc-") ||
+ AppConstants.platform === "android"
+ ) {
+ if (Services.cpmm.sharedData.get("FormAutofill:enabled")) {
+ this.debug("updateActiveElement: opening pop up");
+ this._showPopup();
+ } else {
+ this.debug(
+ "updateActiveElement: Deferring pop-up until Autofill is ready"
+ );
+ this._popupPending = true;
+ }
+ }
+ }
+ },
+
+ set autofillPending(flag) {
+ this.debug("Setting autofillPending to", flag);
+ this._autofillPending = flag;
+ },
+
+ /**
+ * Identifies and marks each autofill field
+ *
+ * @param {HTMLElement} element
+ * Element that serves as an anchor for the formautofill heuristics to retrieve
+ * the root form and run the formautofill heuristics on the form elements
+ * @returns {boolean}
+ * whether any autofill fields were identified
+ */
+ identifyAutofillFields(element) {
+ this.debug(
+ `identifyAutofillFields: ${element.ownerDocument.location?.hostname}`
+ );
+
+ if (lazy.DELEGATE_AUTOCOMPLETE || !this.savedFieldNames) {
+ this.debug("identifyAutofillFields: savedFieldNames are not known yet");
+ let actor = getActorFromWindow(element.ownerGlobal);
+ if (actor) {
+ actor.sendAsyncMessage("FormAutofill:InitStorage");
+ }
+ }
+
+ const validDetails =
+ this._fieldDetailsManager.identifyAutofillFields(element);
+
+ validDetails?.forEach(detail => this._markAsAutofillField(detail.element));
+
+ return !!validDetails.length;
+ },
+
+ clearForm() {
+ let focusedInput =
+ this.activeInput ||
+ lazy.ProfileAutocomplete._lastAutoCompleteFocusedInput;
+ if (!focusedInput) {
+ return;
+ }
+
+ this.activeSection.clearPopulatedForm();
+
+ let fieldName = FormAutofillContent.activeFieldDetail?.fieldName;
+ if (lazy.FormAutofillUtils.isCreditCardField(fieldName)) {
+ lazy.AutofillTelemetry.recordFormInteractionEvent(
+ "cleared",
+ this.activeSection,
+ { fieldName }
+ );
+ }
+ },
+
+ previewProfile(doc) {
+ let docWin = doc.ownerGlobal;
+ let selectedIndex = lazy.ProfileAutocomplete._getSelectedIndex(docWin);
+ let lastAutoCompleteResult =
+ lazy.ProfileAutocomplete.lastProfileAutoCompleteResult;
+ let focusedInput = this.activeInput;
+ let actor = getActorFromWindow(docWin);
+
+ if (
+ selectedIndex === -1 ||
+ !focusedInput ||
+ !lastAutoCompleteResult ||
+ lastAutoCompleteResult.getStyleAt(selectedIndex) != "autofill-profile"
+ ) {
+ actor.sendAsyncMessage("FormAutofill:UpdateWarningMessage", {});
+
+ lazy.ProfileAutocomplete._clearProfilePreview();
+ } else {
+ let focusedInputDetails = this.activeFieldDetail;
+ let profile = JSON.parse(
+ lastAutoCompleteResult.getCommentAt(selectedIndex)
+ );
+ let allFieldNames = FormAutofillContent.activeSection.allFieldNames;
+ let profileFields = allFieldNames.filter(
+ fieldName => !!profile[fieldName]
+ );
+
+ let focusedCategory = lazy.FormAutofillUtils.getCategoryFromFieldName(
+ focusedInputDetails.fieldName
+ );
+ let categories =
+ lazy.FormAutofillUtils.getCategoriesFromFieldNames(profileFields);
+ actor.sendAsyncMessage("FormAutofill:UpdateWarningMessage", {
+ focusedCategory,
+ categories,
+ });
+
+ lazy.ProfileAutocomplete._previewSelectedProfile(selectedIndex);
+ }
+ },
+
+ onPopupClosed(selectedRowStyle) {
+ this.debug("Popup has closed.");
+ lazy.ProfileAutocomplete._clearProfilePreview();
+
+ let lastAutoCompleteResult =
+ lazy.ProfileAutocomplete.lastProfileAutoCompleteResult;
+ let focusedInput = FormAutofillContent.activeInput;
+ if (
+ lastAutoCompleteResult &&
+ FormAutofillContent._keyDownEnterForInput &&
+ focusedInput === FormAutofillContent._keyDownEnterForInput &&
+ focusedInput ===
+ lazy.ProfileAutocomplete.lastProfileAutoCompleteFocusedInput
+ ) {
+ if (selectedRowStyle == "autofill-footer") {
+ let actor = getActorFromWindow(focusedInput.ownerGlobal);
+ actor.sendAsyncMessage("FormAutofill:OpenPreferences");
+ } else if (selectedRowStyle == "autofill-clear-button") {
+ FormAutofillContent.clearForm();
+ }
+ }
+ },
+
+ onPopupOpened() {
+ this.debug(
+ "Popup has opened, automatic =",
+ formFillController.passwordPopupAutomaticallyOpened
+ );
+
+ let fieldName = FormAutofillContent.activeFieldDetail?.fieldName;
+ if (fieldName && this.activeSection) {
+ lazy.AutofillTelemetry.recordFormInteractionEvent(
+ "popup_shown",
+ this.activeSection,
+ { fieldName }
+ );
+ }
+ },
+
+ _markAsAutofillField(field) {
+ // Since Form Autofill popup is only for input element, any non-Input
+ // element should be excluded here.
+ if (!HTMLInputElement.isInstance(field)) {
+ return;
+ }
+
+ formFillController.markAsAutofillField(field);
+ },
+
+ _onKeyDown(e) {
+ delete FormAutofillContent._keyDownEnterForInput;
+ let lastAutoCompleteResult =
+ lazy.ProfileAutocomplete.lastProfileAutoCompleteResult;
+ let focusedInput = FormAutofillContent.activeInput;
+ if (
+ e.keyCode != e.DOM_VK_RETURN ||
+ !lastAutoCompleteResult ||
+ !focusedInput ||
+ focusedInput !=
+ lazy.ProfileAutocomplete.lastProfileAutoCompleteFocusedInput
+ ) {
+ return;
+ }
+ FormAutofillContent._keyDownEnterForInput = focusedInput;
+ },
+
+ didDestroy() {
+ this._fieldDetailsManager.didDestroy();
+ },
+};
+
+FormAutofillContent.init();
diff --git a/toolkit/components/formautofill/FormAutofillNative.cpp b/toolkit/components/formautofill/FormAutofillNative.cpp
new file mode 100644
index 0000000000..57af789861
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillNative.cpp
@@ -0,0 +1,1489 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */
+/* 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/. */
+
+#include "FormAutofillNative.h"
+
+#include <math.h>
+
+#include "mozilla/ClearOnShutdown.h"
+#include "mozilla/ComputedStyle.h"
+#include "mozilla/dom/AutocompleteInfoBinding.h"
+#include "mozilla/dom/Document.h"
+#include "mozilla/dom/Element.h"
+#include "mozilla/dom/HTMLInputElement.h"
+#include "mozilla/dom/HTMLLabelElement.h"
+#include "mozilla/dom/HTMLOptionElement.h"
+#include "mozilla/dom/HTMLSelectElement.h"
+#include "mozilla/HashTable.h"
+#include "mozilla/RustRegex.h"
+#include "nsContentUtils.h"
+#include "nsIFrame.h"
+#include "nsIFrameInlines.h"
+#include "nsLayoutUtils.h"
+#include "nsTStringHasher.h"
+#include "mozilla/StaticPtr.h"
+
+namespace mozilla::dom {
+
+static const char kWhitespace[] = "\b\t\r\n ";
+
+enum class RegexKey : uint8_t {
+ CC_NAME,
+ CC_NUMBER,
+ CC_EXP,
+ CC_EXP_MONTH,
+ CC_EXP_YEAR,
+ CC_TYPE,
+ MM_MONTH,
+ YY_OR_YYYY,
+ MONTH,
+ YEAR,
+ MMYY,
+ VISA_CHECKOUT,
+ CREDIT_CARD_NETWORK,
+ CREDIT_CARD_NETWORK_EXACT_MATCH,
+ CREDIT_CARD_NETWORK_LONG,
+ TWO_OR_FOUR_DIGIT_YEAR,
+ DWFRM,
+ BML,
+ TEMPLATED_VALUE,
+ FIRST,
+ LAST,
+ GIFT,
+ SUBSCRIPTION,
+ VALIDATION,
+
+ Count
+};
+
+// We don't follow the coding style (naming start with capital letter) here and
+// the following CCXXX enum class because we want to sync the rule naming with
+// the JS implementation.
+enum class CCNumberParams : uint8_t {
+ idOrNameMatchNumberRegExp,
+ labelsMatchNumberRegExp,
+ closestLabelMatchesNumberRegExp,
+ placeholderMatchesNumberRegExp,
+ ariaLabelMatchesNumberRegExp,
+ idOrNameMatchGift,
+ labelsMatchGift,
+ placeholderMatchesGift,
+ ariaLabelMatchesGift,
+ idOrNameMatchSubscription,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ inputTypeNotNumbery,
+
+ Count,
+};
+
+enum class CCNameParams : uint8_t {
+ idOrNameMatchNameRegExp,
+ labelsMatchNameRegExp,
+ closestLabelMatchesNameRegExp,
+ placeholderMatchesNameRegExp,
+ ariaLabelMatchesNameRegExp,
+ idOrNameMatchFirst,
+ labelsMatchFirst,
+ placeholderMatchesFirst,
+ ariaLabelMatchesFirst,
+ idOrNameMatchLast,
+ labelsMatchLast,
+ placeholderMatchesLast,
+ ariaLabelMatchesLast,
+ idOrNameMatchFirstAndLast,
+ idOrNameMatchSubscription,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+
+ Count,
+};
+
+enum class CCTypeParams : uint8_t {
+ idOrNameMatchTypeRegExp,
+ labelsMatchTypeRegExp,
+ closestLabelMatchesTypeRegExp,
+ idOrNameMatchVisaCheckout,
+ ariaLabelMatchesVisaCheckout,
+ isSelectWithCreditCardOptions,
+ isRadioWithCreditCardText,
+ idOrNameMatchSubscription,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+
+ Count,
+};
+
+enum class CCExpParams : uint8_t {
+ labelsMatchExpRegExp,
+ closestLabelMatchesExpRegExp,
+ placeholderMatchesExpRegExp,
+ labelsMatchExpWith2Or4DigitYear,
+ placeholderMatchesExpWith2Or4DigitYear,
+ labelsMatchMMYY,
+ placeholderMatchesMMYY,
+ maxLengthIs7,
+ idOrNameMatchSubscription,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ isExpirationMonthLikely,
+ isExpirationYearLikely,
+ idOrNameMatchMonth,
+ idOrNameMatchYear,
+ idOrNameMatchExpMonthRegExp,
+ idOrNameMatchExpYearRegExp,
+ idOrNameMatchValidation,
+ Count,
+};
+
+enum class CCExpMonthParams : uint8_t {
+ idOrNameMatchExpMonthRegExp,
+ labelsMatchExpMonthRegExp,
+ closestLabelMatchesExpMonthRegExp,
+ placeholderMatchesExpMonthRegExp,
+ ariaLabelMatchesExpMonthRegExp,
+ idOrNameMatchMonth,
+ labelsMatchMonth,
+ placeholderMatchesMonth,
+ ariaLabelMatchesMonth,
+ nextFieldIdOrNameMatchExpYearRegExp,
+ nextFieldLabelsMatchExpYearRegExp,
+ nextFieldPlaceholderMatchExpYearRegExp,
+ nextFieldAriaLabelMatchExpYearRegExp,
+ nextFieldIdOrNameMatchYear,
+ nextFieldLabelsMatchYear,
+ nextFieldPlaceholderMatchesYear,
+ nextFieldAriaLabelMatchesYear,
+ nextFieldMatchesExpYearAutocomplete,
+ isExpirationMonthLikely,
+ nextFieldIsExpirationYearLikely,
+ maxLengthIs2,
+ placeholderMatchesMM,
+ roleIsMenu,
+ idOrNameMatchSubscription,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+
+ Count,
+};
+
+enum class CCExpYearParams : uint8_t {
+ idOrNameMatchExpYearRegExp,
+ labelsMatchExpYearRegExp,
+ closestLabelMatchesExpYearRegExp,
+ placeholderMatchesExpYearRegExp,
+ ariaLabelMatchesExpYearRegExp,
+ idOrNameMatchYear,
+ labelsMatchYear,
+ placeholderMatchesYear,
+ ariaLabelMatchesYear,
+ previousFieldIdOrNameMatchExpMonthRegExp,
+ previousFieldLabelsMatchExpMonthRegExp,
+ previousFieldPlaceholderMatchExpMonthRegExp,
+ previousFieldAriaLabelMatchExpMonthRegExp,
+ previousFieldIdOrNameMatchMonth,
+ previousFieldLabelsMatchMonth,
+ previousFieldPlaceholderMatchesMonth,
+ previousFieldAriaLabelMatchesMonth,
+ previousFieldMatchesExpMonthAutocomplete,
+ isExpirationYearLikely,
+ previousFieldIsExpirationMonthLikely,
+ placeholderMatchesYYOrYYYY,
+ roleIsMenu,
+ idOrNameMatchSubscription,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+
+ Count,
+};
+
+struct AutofillParams {
+ EnumeratedArray<CCNumberParams, CCNumberParams::Count, double>
+ mCCNumberParams;
+ EnumeratedArray<CCNameParams, CCNameParams::Count, double> mCCNameParams;
+ EnumeratedArray<CCTypeParams, CCTypeParams::Count, double> mCCTypeParams;
+ EnumeratedArray<CCExpParams, CCExpParams::Count, double> mCCExpParams;
+ EnumeratedArray<CCExpMonthParams, CCExpMonthParams::Count, double>
+ mCCExpMonthParams;
+ EnumeratedArray<CCExpYearParams, CCExpYearParams::Count, double>
+ mCCExpYearParams;
+};
+
+// clang-format off
+constexpr AutofillParams kCoefficents{
+ .mCCNumberParams = {
+ /* idOrNameMatchNumberRegExp */ 7.679469585418701,
+ /* labelsMatchNumberRegExp */ 5.122580051422119,
+ /* closestLabelMatchesNumberRegExp */ 2.1256935596466064,
+ /* placeholderMatchesNumberRegExp */ 9.471800804138184,
+ /* ariaLabelMatchesNumberRegExp */ 6.067715644836426,
+ /* idOrNameMatchGift */ -22.946273803710938,
+ /* labelsMatchGift */ -7.852959632873535,
+ /* placeholderMatchesGift */ -2.355496406555176,
+ /* ariaLabelMatchesGift */ -2.940307855606079,
+ /* idOrNameMatchSubscription */ 0.11255314946174622,
+ /* idOrNameMatchDwfrmAndBml */ -0.0006645023822784424,
+ /* hasTemplatedValue */ -0.11370040476322174,
+ /* inputTypeNotNumbery */ -3.750155210494995
+ },
+ .mCCNameParams = {
+ /* idOrNameMatchNameRegExp */ 7.496212959289551,
+ /* labelsMatchNameRegExp */ 6.081472873687744,
+ /* closestLabelMatchesNameRegExp */ 2.600574254989624,
+ /* placeholderMatchesNameRegExp */ 5.750874042510986,
+ /* ariaLabelMatchesNameRegExp */ 5.162227153778076,
+ /* idOrNameMatchFirst */ -6.742659091949463,
+ /* labelsMatchFirst */ -0.5234538912773132,
+ /* placeholderMatchesFirst */ -3.4615235328674316,
+ /* ariaLabelMatchesFirst */ -1.3145145177841187,
+ /* idOrNameMatchLast */ -12.561869621276855,
+ /* labelsMatchLast */ -0.27417105436325073,
+ /* placeholderMatchesLast */ -1.434966802597046,
+ /* ariaLabelMatchesLast */ -2.9319725036621094,
+ /* idOrNameMatchFirstAndLast */ 24.123435974121094,
+ /* idOrNameMatchSubscription */ 0.08349418640136719,
+ /* idOrNameMatchDwfrmAndBml */ 0.01882520318031311,
+ /* hasTemplatedValue */ 0.182317852973938
+ },
+ .mCCTypeParams = {
+ /* idOrNameMatchTypeRegExp */ 2.0581533908843994,
+ /* labelsMatchTypeRegExp */ 1.0784518718719482,
+ /* closestLabelMatchesTypeRegExp */ 0.6995877623558044,
+ /* idOrNameMatchVisaCheckout */ -3.320356845855713,
+ /* ariaLabelMatchesVisaCheckout */ -3.4196767807006836,
+ /* isSelectWithCreditCardOptions */ 10.337477684020996,
+ /* isRadioWithCreditCardText */ 4.530318737030029,
+ /* idOrNameMatchSubscription */ -3.7206356525421143,
+ /* idOrNameMatchDwfrmAndBml */ -0.08782318234443665,
+ /* hasTemplatedValue */ 0.1772511601448059
+ },
+ .mCCExpParams = {
+ /* labelsMatchExpRegExp */ 7.588159561157227,
+ /* closestLabelMatchesExpRegExp */ 1.41484534740448,
+ /* placeholderMatchesExpRegExp */ 8.759064674377441,
+ /* labelsMatchExpWith2Or4DigitYear */ -3.876218795776367,
+ /* placeholderMatchesExpWith2Or4DigitYear */ 2.8364884853363037,
+ /* labelsMatchMMYY */ 8.836017608642578,
+ /* placeholderMatchesMMYY */ -0.5231751799583435,
+ /* maxLengthIs7 */ 1.3565447330474854,
+ /* idOrNameMatchSubscription */ 0.1779913753271103,
+ /* idOrNameMatchDwfrmAndBml */ 0.21037884056568146,
+ /* hasTemplatedValue */ 0.14900512993335724,
+ /* isExpirationMonthLikely */ -3.223409652709961,
+ /* isExpirationYearLikely */ -2.536919593811035,
+ /* idOrNameMatchMonth */ -3.6893014907836914,
+ /* idOrNameMatchYear */ -3.108184337615967,
+ /* idOrNameMatchExpMonthRegExp */ -2.264357089996338,
+ /* idOrNameMatchExpYearRegExp */ -2.7957723140716553,
+ /* idOrNameMatchValidation */ -2.29402756690979
+ },
+ .mCCExpMonthParams = {
+ /* idOrNameMatchExpMonthRegExp */ 0.2787344455718994,
+ /* labelsMatchExpMonthRegExp */ 1.298413634300232,
+ /* closestLabelMatchesExpMonthRegExp */ -11.206244468688965,
+ /* placeholderMatchesExpMonthRegExp */ 1.2605619430541992,
+ /* ariaLabelMatchesExpMonthRegExp */ 1.1330018043518066,
+ /* idOrNameMatchMonth */ 6.1464314460754395,
+ /* labelsMatchMonth */ 0.7051732540130615,
+ /* placeholderMatchesMonth */ 0.7463492751121521,
+ /* ariaLabelMatchesMonth */ 1.8244760036468506,
+ /* nextFieldIdOrNameMatchExpYearRegExp */ 0.06347066164016724,
+ /* nextFieldLabelsMatchExpYearRegExp */ -0.1692247837781906,
+ /* nextFieldPlaceholderMatchExpYearRegExp */ 1.0434566736221313,
+ /* nextFieldAriaLabelMatchExpYearRegExp */ 1.751156210899353,
+ /* nextFieldIdOrNameMatchYear */ -0.532447338104248,
+ /* nextFieldLabelsMatchYear */ 1.3248541355133057,
+ /* nextFieldPlaceholderMatchesYear */ 0.604235827922821,
+ /* nextFieldAriaLabelMatchesYear */ 1.5364223718643188,
+ /* nextFieldMatchesExpYearAutocomplete */ 6.285938262939453,
+ /* isExpirationMonthLikely */ 13.117807388305664,
+ /* nextFieldIsExpirationYearLikely */ 7.182341575622559,
+ /* maxLengthIs2 */ 4.477289199829102,
+ /* placeholderMatchesMM */ 14.403288841247559,
+ /* roleIsMenu */ 5.770959854125977,
+ /* idOrNameMatchSubscription */ -0.043085768818855286,
+ /* idOrNameMatchDwfrmAndBml */ 0.02823038399219513,
+ /* hasTemplatedValue */ 0.07234494388103485
+ },
+ .mCCExpYearParams = {
+ /* idOrNameMatchExpYearRegExp */ 5.426016807556152,
+ /* labelsMatchExpYearRegExp */ 1.3240209817886353,
+ /* closestLabelMatchesExpYearRegExp */ -8.702284812927246,
+ /* placeholderMatchesExpYearRegExp */ 0.9059725999832153,
+ /* ariaLabelMatchesExpYearRegExp */ 0.5550334453582764,
+ /* idOrNameMatchYear */ 5.362994194030762,
+ /* labelsMatchYear */ 2.7185044288635254,
+ /* placeholderMatchesYear */ 0.7883157134056091,
+ /* ariaLabelMatchesYear */ 0.311492383480072,
+ /* previousFieldIdOrNameMatchExpMonthRegExp */ 1.8155208826065063,
+ /* previousFieldLabelsMatchExpMonthRegExp */ -0.46133187413215637,
+ /* previousFieldPlaceholderMatchExpMonthRegExp */ 1.0374903678894043,
+ /* previousFieldAriaLabelMatchExpMonthRegExp */ -0.5901495814323425,
+ /* previousFieldIdOrNameMatchMonth */ -5.960310935974121,
+ /* previousFieldLabelsMatchMonth */ 0.6495584845542908,
+ /* previousFieldPlaceholderMatchesMonth */ 0.7198042273521423,
+ /* previousFieldAriaLabelMatchesMonth */ 3.4590985774993896,
+ /* previousFieldMatchesExpMonthAutocomplete */ 2.986003875732422,
+ /* isExpirationYearLikely */ 4.021566390991211,
+ /* previousFieldIsExpirationMonthLikely */ 9.298635482788086,
+ /* placeholderMatchesYYOrYYYY */ 10.457176208496094,
+ /* roleIsMenu */ 1.1051956415176392,
+ /* idOrNameMatchSubscription */ 0.000688597559928894,
+ /* idOrNameMatchDwfrmAndBml */ 0.15687309205532074,
+ /* hasTemplatedValue */ -0.19141331315040588
+ }
+};
+// clang-format off
+
+constexpr float kCCNumberBias = -4.948795795440674;
+constexpr float kCCNameBias = -5.3578081130981445;
+// Comment out code that are not used right now
+/*
+constexpr float kCCTypeBias = -5.979659557342529;
+constexpr float kCCExpBias = -5.849575996398926;
+constexpr float kCCExpMonthBias = -8.844199180603027;
+constexpr float kCCExpYearBias = -6.499860763549805;
+*/
+
+struct Rule {
+ RegexKey key;
+ const char* pattern;
+};
+
+const Rule kFirefoxRules[] = {
+ {RegexKey::MM_MONTH, "^mm$|\\(mm\\)"},
+ {RegexKey::YY_OR_YYYY, "^(yy|yyyy)$|\\(yy\\)|\\(yyyy\\)"},
+ {RegexKey::MONTH, "month"},
+ {RegexKey::YEAR, "year"},
+ {RegexKey::MMYY, "mm\\s*(/|\\\\)\\s*yy"},
+ {RegexKey::VISA_CHECKOUT, "visa(-|\\s)checkout"},
+ // This should be a union of NETWORK_NAMES in CreditCard.sys.mjs
+ {RegexKey::CREDIT_CARD_NETWORK_LONG,
+ "american express|master card|union pay"},
+ // Please also update CREDIT_CARD_NETWORK_EXACT_MATCH while updating
+ // CREDIT_CARD_NETWORK
+ {RegexKey::CREDIT_CARD_NETWORK,
+ "amex|cartebancaire|diners|discover|jcb|mastercard|mir|unionpay|visa"},
+ {RegexKey::CREDIT_CARD_NETWORK_EXACT_MATCH,
+ "^\\s*(?:amex|cartebancaire|diners|discover|jcb|mastercard|mir|unionpay|"
+ "visa)\\s*$"},
+ {RegexKey::TWO_OR_FOUR_DIGIT_YEAR,
+ "(?:exp.*date[^y\\\\n\\\\r]*|mm\\\\s*[-/]?\\\\s*)yy(?:yy)?(?:[^y]|$)"},
+ {RegexKey::DWFRM, "^dwfrm"},
+ {RegexKey::BML, "BML"},
+ {RegexKey::TEMPLATED_VALUE, "^\\{\\{.*\\}\\}$"},
+ {RegexKey::FIRST, "first"},
+ {RegexKey::LAST, "last"},
+ {RegexKey::GIFT, "gift"},
+ {RegexKey::SUBSCRIPTION, "subscription"},
+ {RegexKey::VALIDATION, "validate|validation"},
+};
+
+// These are the rules used by Bitwarden [0], converted into RegExp form.
+// [0]
+// https://github.com/bitwarden/browser/blob/c2b8802201fac5e292d55d5caf3f1f78088d823c/src/services/autofill.service.ts#L436
+const Rule kCreditCardRules[] = {
+ /* eslint-disable */
+ // Let us keep our consistent wrapping.
+ {RegexKey::CC_NAME,
+ // Firefox-specific rules
+ "account.*holder.*name"
+ "|^(credit[-\\s]?card|card).*name"
+ // de-DE
+ "|^(kredit)?(karten|konto)inhaber"
+ "|^(name).*karte"
+ // fr-FR
+ "|nom.*(titulaire|détenteur)"
+ "|(titulaire|détenteur).*(carte)"
+ // it-IT
+ "|titolare.*carta"
+ // pl-PL
+ "|posiadacz.*karty"
+ // es-ES
+ "|nombre.*(titular|tarjeta)"
+ // nl-NL
+ "|naam.*op.*kaart"
+ // Rules from Bitwarden
+ "|cc-?name"
+ "|card-?name"
+ "|cardholder-?name"
+ "|(^nom$)"
+ // Rules are from Chromium source codes
+ "|card.?(?:holder|owner)|name.*(\\b)?on(\\b)?.*card"
+ "|(?:card|cc).?name|cc.?full.?name"
+ "|(?:card|cc).?owner"
+ "|nom.*carte" // fr-FR
+ "|nome.*cart" // it-IT
+ "|名前" // ja-JP
+ "|Имя.*карты" // ru
+ "|信用卡开户名|开户名|持卡人姓名" // zh-CN
+ "|持卡人姓名"}, // zh-TW
+ /* eslint-enable */
+
+ {RegexKey::CC_NUMBER,
+ // Firefox-specific rules
+ // de-DE
+ "(cc|kk)nr"
+ "|(kredit)?(karten)(nummer|nr)"
+ // it-IT
+ "|numero.*carta"
+ // fr-FR
+ "|(numero|número|numéro).*(carte)"
+ // pl-PL
+ "|numer.*karty"
+ // es-ES
+ "|(número|numero).*tarjeta"
+ // nl-NL
+ "|kaartnummer"
+ // Rules from Bitwarden
+ "|cc-?number"
+ "|cc-?num"
+ "|card-?number"
+ "|card-?num"
+ "|cc-?no"
+ "|card-?no"
+ "|numero-?carte"
+ "|num-?carte"
+ "|cb-?num"
+ // Rules are from Chromium source codes
+ "|(add)?(?:card|cc|acct).?(?:number|#|no|num)"
+ "|カード番号" // ja-JP
+ "|Номер.*карты" // ru
+ "|信用卡号|信用卡号码" // zh-CN
+ "|信用卡卡號" // zh-TW
+ "|카드"}, // ko-KR
+
+ {RegexKey::CC_EXP,
+ // Firefox-specific rules
+ "mm\\s*(/|\\|-)\\s*(yy|jj|aa)"
+ "|(month|mois)\\s*(/|\\|-|et)\\s*(year|année)"
+ // de-DE
+ // fr-FR
+ // Rules from Bitwarden
+ "|(^cc-?exp$)"
+ "|(^card-?exp$)"
+ "|(^cc-?expiration$)"
+ "|(^card-?expiration$)"
+ "|(^cc-?ex$)"
+ "|(^card-?ex$)"
+ "|(^card-?expire$)"
+ "|(^card-?expiry$)"
+ "|(^validite$)"
+ "|(^expiration$)"
+ "|(^expiry$)"
+ "|mm-?yy"
+ "|mm-?yyyy"
+ "|yy-?mm"
+ "|yyyy-?mm"
+ "|expiration-?date"
+ "|payment-?card-?expiration"
+ "|(^payment-?cc-?date$)"
+ // Rules are from Chromium source codes
+ "|expir|exp.*date|^expfield$"
+ "|ablaufdatum|gueltig|gültig" // de-DE
+ "|fecha" // es
+ "|date.*exp" // fr-FR
+ "|scadenza" // it-IT
+ "|有効期限" // ja-JP
+ "|validade" // pt-BR, pt-PT
+ "|Срок действия карты"}, // ru
+
+ {RegexKey::CC_EXP_MONTH,
+ // Firefox-specific rules
+ "(cc|kk)month" // de-DE
+ // Rules from Bitwarden
+ "|(^exp-?month$)"
+ "|(^cc-?exp-?month$)"
+ "|(^cc-?month$)"
+ "|(^card-?month$)"
+ "|(^cc-?mo$)"
+ "|(^card-?mo$)"
+ "|(^exp-?mo$)"
+ "|(^card-?exp-?mo$)"
+ "|(^cc-?exp-?mo$)"
+ "|(^card-?expiration-?month$)"
+ "|(^expiration-?month$)"
+ "|(^cc-?mm$)"
+ "|(^cc-?m$)"
+ "|(^card-?mm$)"
+ "|(^card-?m$)"
+ "|(^card-?exp-?mm$)"
+ "|(^cc-?exp-?mm$)"
+ "|(^exp-?mm$)"
+ "|(^exp-?m$)"
+ "|(^expire-?month$)"
+ "|(^expire-?mo$)"
+ "|(^expiry-?month$)"
+ "|(^expiry-?mo$)"
+ "|(^card-?expire-?month$)"
+ "|(^card-?expire-?mo$)"
+ "|(^card-?expiry-?month$)"
+ "|(^card-?expiry-?mo$)"
+ "|(^mois-?validite$)"
+ "|(^mois-?expiration$)"
+ "|(^m-?validite$)"
+ "|(^m-?expiration$)"
+ "|(^expiry-?date-?field-?month$)"
+ "|(^expiration-?date-?month$)"
+ "|(^expiration-?date-?mm$)"
+ "|(^exp-?mon$)"
+ "|(^validity-?mo$)"
+ "|(^exp-?date-?mo$)"
+ "|(^cb-?date-?mois$)"
+ "|(^date-?m$)"
+ // Rules are from Chromium source codes
+ "|exp.*mo|ccmonth|cardmonth|addmonth"
+ "|monat" // de-DE
+ // "|fecha" // es
+ // "|date.*exp" // fr-FR
+ // "|scadenza" // it-IT
+ // "|有効期限" // ja-JP
+ // "|validade" // pt-BR, pt-PT
+ // "|Срок действия карты" // ru
+ "|月"}, // zh-CN
+
+ {RegexKey::CC_EXP_YEAR,
+ // Firefox-specific rules
+ "(cc|kk)year" // de-DE
+ // Rules from Bitwarden
+ "|(^exp-?year$)"
+ "|(^cc-?exp-?year$)"
+ "|(^cc-?year$)"
+ "|(^card-?year$)"
+ "|(^cc-?yr$)"
+ "|(^card-?yr$)"
+ "|(^exp-?yr$)"
+ "|(^card-?exp-?yr$)"
+ "|(^cc-?exp-?yr$)"
+ "|(^card-?expiration-?year$)"
+ "|(^expiration-?year$)"
+ "|(^cc-?yy$)"
+ "|(^cc-?y$)"
+ "|(^card-?yy$)"
+ "|(^card-?y$)"
+ "|(^card-?exp-?yy$)"
+ "|(^cc-?exp-?yy$)"
+ "|(^exp-?yy$)"
+ "|(^exp-?y$)"
+ "|(^cc-?yyyy$)"
+ "|(^card-?yyyy$)"
+ "|(^card-?exp-?yyyy$)"
+ "|(^cc-?exp-?yyyy$)"
+ "|(^expire-?year$)"
+ "|(^expire-?yr$)"
+ "|(^expiry-?year$)"
+ "|(^expiry-?yr$)"
+ "|(^card-?expire-?year$)"
+ "|(^card-?expire-?yr$)"
+ "|(^card-?expiry-?year$)"
+ "|(^card-?expiry-?yr$)"
+ "|(^an-?validite$)"
+ "|(^an-?expiration$)"
+ "|(^annee-?validite$)"
+ "|(^annee-?expiration$)"
+ "|(^expiry-?date-?field-?year$)"
+ "|(^expiration-?date-?year$)"
+ "|(^cb-?date-?ann$)"
+ "|(^expiration-?date-?yy$)"
+ "|(^expiration-?date-?yyyy$)"
+ "|(^validity-?year$)"
+ "|(^exp-?date-?year$)"
+ "|(^date-?y$)"
+ // Rules are from Chromium source codes
+ "|(add)?year"
+ "|jahr" // de-DE
+ // "|fecha" // es
+ // "|scadenza" // it-IT
+ // "|有効期限" // ja-JP
+ // "|validade" // pt-BR, pt-PT
+ // "|Срок действия карты" // ru
+ "|年|有效期"}, // zh-CN
+
+ {RegexKey::CC_TYPE,
+ // Firefox-specific rules
+ "type"
+ // de-DE
+ "|Kartenmarke"
+ // Rules from Bitwarden
+ "|(^cc-?type$)"
+ "|(^card-?type$)"
+ "|(^card-?brand$)"
+ "|(^cc-?brand$)"
+ "|(^cb-?type$)"},
+ // Rules are from Chromium source codes
+};
+
+static double Sigmoid(double x) { return 1.0 / (1.0 + exp(-x)); }
+
+class FormAutofillImpl {
+ public:
+ FormAutofillImpl();
+
+ void GetFormAutofillConfidences(
+ GlobalObject& aGlobal, const Sequence<OwningNonNull<Element>>& aElements,
+ nsTArray<FormAutofillConfidences>& aResults, ErrorResult& aRv);
+
+ private:
+ const RustRegex& GetRegex(RegexKey key);
+
+ bool StringMatchesRegExp(const nsACString& str, RegexKey key);
+ bool StringMatchesRegExp(const nsAString& str, RegexKey key);
+ bool TextContentMatchesRegExp(Element& element, RegexKey key);
+ size_t CountRegExpMatches(const nsACString& str, RegexKey key);
+ size_t CountRegExpMatches(const nsAString& str, RegexKey key);
+ bool IdOrNameMatchRegExp(Element& element, RegexKey key);
+ bool NextFieldMatchesExpYearAutocomplete(Element* aNextField);
+ bool PreviousFieldMatchesExpMonthAutocomplete(Element* aPrevField);
+ bool LabelMatchesRegExp(Element& element, const nsTArray<nsCString>* labels,
+ RegexKey key);
+ bool ClosestLabelMatchesRegExp(Element& aElement, RegexKey aKey);
+ bool PlaceholderMatchesRegExp(Element& element, RegexKey key);
+ bool AriaLabelMatchesRegExp(Element& element, RegexKey key);
+ bool AutocompleteStringMatches(Element& aElement, const nsAString& aKey);
+
+ bool HasTemplatedValue(Element& element);
+ bool MaxLengthIs(Element& aElement, int32_t aValue);
+ bool IsExpirationMonthLikely(Element& element);
+ bool IsExpirationYearLikely(Element& element);
+ bool InputTypeNotNumbery(Element& element);
+ bool IsSelectWithCreditCardOptions(Element& element);
+ bool IsRadioWithCreditCardText(Element& element,
+ const nsTArray<nsCString>* labels,
+ ErrorResult& aRv);
+ bool MatchesExpYearAutocomplete(Element& element);
+ bool RoleIsMenu(Element& element);
+
+ Element* FindRootForField(Element* aElement);
+
+ Element* FindField(const Sequence<OwningNonNull<Element>>& aElements,
+ uint32_t aStartIndex, int8_t aDirection);
+ Element* NextField(const Sequence<OwningNonNull<Element>>& aElements,
+ uint32_t aStartIndex);
+ Element* PrevField(const Sequence<OwningNonNull<Element>>& aElements,
+ uint32_t aStartIndex);
+
+ // Array contains regular expressions to match the corresponding
+ // field. Ex, CC number, CC type, etc.
+ using RegexStringArray =
+ EnumeratedArray<RegexKey, RegexKey::Count, nsCString>;
+ RegexStringArray mRuleMap;
+
+ // Array that holds RegexWrapper that created by regex::ffi::regex_new
+ using RegexWrapperArray =
+ EnumeratedArray<RegexKey, RegexKey::Count,
+ RustRegex>;
+ RegexWrapperArray mRegexes;
+};
+
+FormAutofillImpl::FormAutofillImpl() {
+ const Rule* rulesets[] = {&kFirefoxRules[0], &kCreditCardRules[0]};
+ size_t rulesetLengths[] = {ArrayLength(kFirefoxRules),
+ ArrayLength(kCreditCardRules)};
+
+ for (uint32_t i = 0; i < ArrayLength(rulesetLengths); ++i) {
+ for (uint32_t j = 0; j < rulesetLengths[i]; ++j) {
+ nsCString& rule = mRuleMap[rulesets[i][j].key];
+ if (!rule.IsEmpty()) {
+ rule.Append("|");
+ }
+ rule.Append(rulesets[i][j].pattern);
+ }
+ }
+}
+
+const RustRegex& FormAutofillImpl::GetRegex(RegexKey aKey) {
+ if (!mRegexes[aKey]) {
+ RustRegex regex(mRuleMap[aKey], RustRegexOptions().CaseInsensitive(true));
+ MOZ_DIAGNOSTIC_ASSERT(regex);
+ mRegexes[aKey] = std::move(regex);
+ }
+ return mRegexes[aKey];
+}
+
+bool FormAutofillImpl::StringMatchesRegExp(const nsACString& aStr,
+ RegexKey aKey) {
+ return GetRegex(aKey).IsMatch(aStr);
+}
+
+bool FormAutofillImpl::StringMatchesRegExp(const nsAString& aStr,
+ RegexKey aKey) {
+ return StringMatchesRegExp(NS_ConvertUTF16toUTF8(aStr), aKey);
+}
+
+bool FormAutofillImpl::TextContentMatchesRegExp(Element& element,
+ RegexKey key) {
+ ErrorResult rv;
+ nsAutoString text;
+ element.GetTextContent(text, rv);
+ if (rv.Failed()) {
+ return false;
+ }
+
+ return StringMatchesRegExp(text, key);
+}
+
+size_t FormAutofillImpl::CountRegExpMatches(const nsACString& aStr,
+ RegexKey aKey) {
+ return GetRegex(aKey).CountMatches(aStr);
+}
+
+size_t FormAutofillImpl::CountRegExpMatches(const nsAString& aStr,
+ RegexKey aKey) {
+ return CountRegExpMatches(NS_ConvertUTF16toUTF8(aStr), aKey);
+}
+
+bool FormAutofillImpl::NextFieldMatchesExpYearAutocomplete(
+ Element* aNextField) {
+ return AutocompleteStringMatches(*aNextField, u"cc-exp-year"_ns);
+}
+
+bool FormAutofillImpl::PreviousFieldMatchesExpMonthAutocomplete(
+ Element* aPrevField) {
+ return AutocompleteStringMatches(*aPrevField, u"cc-exp-month"_ns);
+}
+
+bool FormAutofillImpl::IdOrNameMatchRegExp(Element& aElement, RegexKey key) {
+ nsAutoString str;
+ aElement.GetId(str);
+ if (StringMatchesRegExp(str, key)) {
+ return true;
+ }
+ aElement.GetAttr(nsGkAtoms::name, str);
+ return StringMatchesRegExp(str, key);
+}
+
+bool FormAutofillImpl::LabelMatchesRegExp(
+ Element& aElement, const nsTArray<nsCString>* labelStrings, RegexKey key) {
+ if (labelStrings) {
+ for (const auto& str : *labelStrings) {
+ if (StringMatchesRegExp(str, key)) {
+ return true;
+ }
+ }
+ }
+
+ Element* parent = aElement.GetParentElement();
+ if (!parent) {
+ return false;
+ }
+
+ ErrorResult aRv;
+ if (parent->IsHTMLElement(nsGkAtoms::td)) {
+ Element* pp = parent->GetParentElement();
+ if (pp) {
+ return TextContentMatchesRegExp(*pp, key);
+ }
+ }
+ if (parent->IsHTMLElement(nsGkAtoms::td)) {
+ Element* pes = aElement.GetPreviousElementSibling();
+ if (pes) {
+ return TextContentMatchesRegExp(*pes, key);
+ }
+ }
+ return false;
+}
+
+bool FormAutofillImpl::ClosestLabelMatchesRegExp(Element& aElement,
+ RegexKey aKey) {
+ ErrorResult aRv;
+ Element* pes = aElement.GetPreviousElementSibling();
+ if (pes && pes->IsHTMLElement(nsGkAtoms::label)) {
+ return TextContentMatchesRegExp(*pes, aKey);
+ }
+
+ Element* nes = aElement.GetNextElementSibling();
+ if (nes && nes->IsHTMLElement(nsGkAtoms::label)) {
+ return TextContentMatchesRegExp(*nes, aKey);
+ }
+
+ return false;
+}
+
+bool FormAutofillImpl::PlaceholderMatchesRegExp(Element& aElement,
+ RegexKey aKey) {
+ nsAutoString str;
+ if (!aElement.GetAttr(nsGkAtoms::placeholder, str)) {
+ return false;
+ }
+ return StringMatchesRegExp(str, aKey);
+}
+
+bool FormAutofillImpl::AriaLabelMatchesRegExp(Element& aElement,
+ RegexKey aKey) {
+ nsAutoString str;
+ if (!aElement.GetAttr(nsGkAtoms::aria_label, str)) {
+ return false;
+ }
+ return StringMatchesRegExp(str, aKey);
+}
+
+bool FormAutofillImpl::AutocompleteStringMatches(Element& aElement,
+ const nsAString& aKey) {
+ Nullable<AutocompleteInfo> info;
+ if (auto* input = HTMLInputElement::FromNode(aElement)) {
+ input->GetAutocompleteInfo(info);
+ } else {
+ AutocompleteInfo autoInfo;
+ if (auto* select = HTMLSelectElement::FromNode(aElement)) {
+ select->GetAutocompleteInfo(autoInfo);
+ info.SetValue(autoInfo);
+ }
+ }
+
+ if (info.IsNull()) {
+ return false;
+ }
+
+ return info.Value().mFieldName.Equals(aKey);
+}
+
+bool FormAutofillImpl::HasTemplatedValue(Element& aElement) {
+ nsAutoString str;
+ if (!aElement.GetAttr(nsGkAtoms::value, str)) {
+ return false;
+ }
+ return StringMatchesRegExp(str, RegexKey::TEMPLATED_VALUE);
+}
+
+bool FormAutofillImpl::RoleIsMenu(Element& aElement) {
+ return aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::role,
+ nsGkAtoms::menu, eCaseMatters);
+}
+
+bool FormAutofillImpl::InputTypeNotNumbery(Element& aElement) {
+ auto* input = HTMLInputElement::FromNode(aElement);
+ if (!input) {
+ return true;
+ }
+
+ auto type = input->ControlType();
+ return type != FormControlType::InputText &&
+ type != FormControlType::InputTel &&
+ type != FormControlType::InputNumber;
+}
+
+bool FormAutofillImpl::IsSelectWithCreditCardOptions(Element& aElement) {
+ auto* select = HTMLSelectElement::FromNode(aElement);
+ if (!select) {
+ return false;
+ }
+
+ nsCOMPtr<nsIHTMLCollection> options = select->Options();
+ for (uint32_t i = 0; i < options->Length(); ++i) {
+ auto* item = options->Item(i);
+ auto* option = HTMLOptionElement::FromNode(item);
+ if (!option) {
+ continue;
+ }
+ // Bug 1756799, consider using getAttribute("value") instead of .value
+ nsAutoString str;
+ option->GetValue(str);
+ if (StringMatchesRegExp(str, RegexKey::CREDIT_CARD_NETWORK_EXACT_MATCH) ||
+ StringMatchesRegExp(str, RegexKey::CREDIT_CARD_NETWORK_LONG)) {
+ return true;
+ }
+
+ option->GetText(str);
+ if (StringMatchesRegExp(str, RegexKey::CREDIT_CARD_NETWORK_EXACT_MATCH) ||
+ StringMatchesRegExp(str, RegexKey::CREDIT_CARD_NETWORK_LONG)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool FormAutofillImpl::IsRadioWithCreditCardText(
+ Element& aElement, const nsTArray<nsCString>* aLabels, ErrorResult& aRv) {
+ auto* input = HTMLInputElement::FromNode(aElement);
+ if (!input) {
+ return false;
+ }
+ auto type = input->ControlType();
+ if (type != FormControlType::InputRadio) {
+ return false;
+ }
+
+ nsAutoString str;
+ input->GetValue(str, CallerType::System);
+ if (CountRegExpMatches(str, RegexKey::CREDIT_CARD_NETWORK) == 1) {
+ return true;
+ }
+
+ if (aLabels) {
+ size_t labelsMatched = 0;
+ for (const auto& label : *aLabels) {
+ size_t labelMatches =
+ CountRegExpMatches(label, RegexKey::CREDIT_CARD_NETWORK);
+ if (labelMatches > 1) {
+ return false;
+ }
+ if (labelMatches > 0) {
+ labelsMatched++;
+ }
+ }
+
+ if (labelsMatched) {
+ return labelsMatched == 1;
+ }
+ }
+
+ // Bug 1756798 : Remove reading text content in a <input>
+ nsAutoString text;
+ aElement.GetTextContent(text, aRv);
+ if (aRv.Failed()) {
+ return false;
+ }
+ return CountRegExpMatches(text, RegexKey::CREDIT_CARD_NETWORK) == 1;
+}
+
+bool FormAutofillImpl::MaxLengthIs(Element& aElement, int32_t aValue) {
+ auto* input = HTMLInputElement::FromNode(aElement);
+ if (!input) {
+ return false;
+ }
+ return input->MaxLength() == aValue;
+}
+
+static bool TestOptionElementForInteger(Element* aElement, int32_t aTestValue) {
+ auto* option = HTMLOptionElement::FromNodeOrNull(aElement);
+ if (!option) {
+ return false;
+ }
+ nsAutoString str;
+ option->GetValue(str);
+ nsContentUtils::ParseHTMLIntegerResultFlags parseFlags;
+ int32_t val = nsContentUtils::ParseHTMLInteger(str, &parseFlags);
+ if (val == aTestValue) {
+ return true;
+ }
+ option->GetRenderedLabel(str);
+ val = nsContentUtils::ParseHTMLInteger(str, &parseFlags);
+ return val == aTestValue;
+}
+
+static bool MatchOptionContiguousInteger(HTMLOptionsCollection* aOptions,
+ uint32_t aNumContiguous,
+ int32_t aInteger) {
+ uint32_t len = aOptions->Length();
+ if (aNumContiguous > len) {
+ return false;
+ }
+
+ for (uint32_t i = 0; i <= aOptions->Length() - aNumContiguous; i++) {
+ bool match = true;
+ for (uint32_t j = 0; j < aNumContiguous; j++) {
+ if (!TestOptionElementForInteger(aOptions->GetElementAt(i + j),
+ aInteger + j)) {
+ match = false;
+ break;
+ }
+ }
+ if (match) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool FormAutofillImpl::IsExpirationYearLikely(Element& aElement) {
+ auto* select = HTMLSelectElement::FromNode(aElement);
+ if (!select) {
+ return false;
+ }
+
+ auto* options = select->Options();
+ if (!options) {
+ return false;
+ }
+
+ PRExplodedTime tm;
+ PR_ExplodeTime(PR_Now(), PR_LocalTimeParameters, &tm);
+ uint16_t currentYear = tm.tm_year;
+
+ return MatchOptionContiguousInteger(options, 3, currentYear);
+}
+
+bool FormAutofillImpl::IsExpirationMonthLikely(Element& aElement) {
+ auto* select = HTMLSelectElement::FromNode(aElement);
+ if (!select) {
+ return false;
+ }
+
+ auto* options = select->Options();
+ if (!options) {
+ return false;
+ }
+
+ if (options->Length() != 12 && options->Length() != 13) {
+ return false;
+ }
+
+ return MatchOptionContiguousInteger(options, 12, 1);
+}
+
+Element* FormAutofillImpl::FindRootForField(Element* aElement) {
+ if (const auto* control =
+ nsGenericHTMLFormControlElement::FromNode(aElement)) {
+ if (Element* form = control->GetForm()) {
+ return form;
+ }
+ }
+
+ return aElement->OwnerDoc()->GetDocumentElement();
+}
+
+Element* FormAutofillImpl::FindField(
+ const Sequence<OwningNonNull<Element>>& aElements, uint32_t aStartIndex,
+ int8_t aDirection) {
+ MOZ_ASSERT(aDirection == 1 || aDirection == -1);
+ MOZ_ASSERT(aStartIndex < aElements.Length());
+
+ Element* curFieldRoot = FindRootForField(aElements[aStartIndex]);
+ bool isRootForm = curFieldRoot->IsHTMLElement(nsGkAtoms::form);
+
+ uint32_t num =
+ aDirection == 1 ? aElements.Length() - aStartIndex - 1 : aStartIndex;
+ for (uint32_t i = 0, searchIndex = aStartIndex; i < num; i++) {
+ searchIndex += aDirection;
+ const auto& element = aElements[searchIndex];
+ Element* root = FindRootForField(element);
+
+ if (isRootForm) {
+ // Only search fields that are within the same root element.
+ if (curFieldRoot != root) {
+ return nullptr;
+ }
+ } else {
+ // Exclude elements inside the rootElement that are already in a <form>.
+ if (root->IsHTMLElement(nsGkAtoms::form)) {
+ continue;
+ }
+ }
+
+ if (element->IsAnyOfHTMLElements(nsGkAtoms::input, nsGkAtoms::select)) {
+ return element.get();
+ }
+ }
+
+ return nullptr;
+}
+
+Element* FormAutofillImpl::NextField(
+ const Sequence<OwningNonNull<Element>>& aElements, uint32_t aStartIndex) {
+ return FindField(aElements, aStartIndex, 1);
+}
+
+Element* FormAutofillImpl::PrevField(
+ const Sequence<OwningNonNull<Element>>& aElements, uint32_t aStartIndex) {
+ return FindField(aElements, aStartIndex, -1);
+}
+
+static void ExtractLabelStrings(nsINode* aNode, nsTArray<nsCString>& aStrings,
+ ErrorResult& aRv) {
+ if (aNode->IsAnyOfHTMLElements(nsGkAtoms::script, nsGkAtoms::noscript,
+ nsGkAtoms::option, nsGkAtoms::style)) {
+ return;
+ }
+
+ if (aNode->IsText() || !aNode->HasChildren()) {
+ nsAutoString text;
+ aNode->GetTextContent(text, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+
+ text.Trim(kWhitespace);
+ CopyUTF16toUTF8(text, *aStrings.AppendElement());
+ return;
+ }
+
+ for (nsINode* child = aNode->GetFirstChild(); child;
+ child = child->GetNextSibling()) {
+ if (child->IsElement() || child->IsText()) {
+ ExtractLabelStrings(child, aStrings, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+ }
+ }
+}
+
+nsTArray<nsCString>* GetLabelStrings(
+ Element* aElement,
+ const nsTHashMap<void*, nsTArray<nsCString>>& aElementMap,
+ const nsTHashMap<nsAtom*, nsTArray<nsCString>>& aIdMap) {
+ if (!aElement) {
+ return nullptr;
+ }
+
+ if (nsAtom* idAtom = aElement->GetID()) {
+ return aIdMap.Lookup(idAtom).DataPtrOrNull();
+ }
+
+ return aElementMap.Lookup(aElement).DataPtrOrNull();
+}
+
+void FormAutofillImpl::GetFormAutofillConfidences(
+ GlobalObject& aGlobal, const Sequence<OwningNonNull<Element>>& aElements,
+ nsTArray<FormAutofillConfidences>& aResults, ErrorResult& aRv) {
+ if (aElements.IsEmpty()) {
+ return;
+ }
+
+ // Create Labels
+ auto* document = aElements[0]->OwnerDoc();
+#ifdef DEBUG
+ for (uint32_t i = 1; i < aElements.Length(); ++i) {
+ MOZ_ASSERT(document == aElements[i]->OwnerDoc());
+ }
+#endif
+
+ RefPtr<nsContentList> labels = document->GetElementsByTagName(u"label"_ns);
+ nsTHashMap<void*, nsTArray<nsCString>> elementsToLabelStrings;
+ nsTHashMap<nsAtom*, nsTArray<nsCString>> elementsIdToLabelStrings;
+ if (labels) {
+ for (uint32_t i = 0; i < labels->Length(); ++i) {
+ auto* item = labels->Item(i);
+ auto* label = HTMLLabelElement::FromNode(item);
+ if (NS_WARN_IF(!label)) {
+ continue;
+ }
+ auto* control = label->GetControl();
+ if (!control) {
+ continue;
+ }
+ nsTArray<nsCString> labelStrings;
+ ExtractLabelStrings(label, labelStrings, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+
+ // We need two maps here to keep track controls with id and without id.
+ // We can't just use map without id to cover all cases because there
+ // might be multiple elements with the same id.
+ if (control->GetID()) {
+ elementsIdToLabelStrings.LookupOrInsert(control->GetID())
+ .AppendElements(std::move(labelStrings));
+ } else {
+ elementsToLabelStrings.LookupOrInsert(control).AppendElements(
+ std::move(labelStrings));
+ }
+ }
+ }
+
+ nsTArray<AutofillParams> paramSet;
+ paramSet.SetLength(aElements.Length());
+
+ for (uint32_t i = 0; i < aElements.Length(); ++i) {
+ auto& params = paramSet[i];
+ const auto& element = aElements[i];
+
+ const nsTArray<nsCString>* labelStrings = GetLabelStrings(
+ element, elementsToLabelStrings, elementsIdToLabelStrings);
+
+ bool idOrNameMatchDwfrmAndBml =
+ IdOrNameMatchRegExp(element, RegexKey::DWFRM) &&
+ IdOrNameMatchRegExp(element, RegexKey::BML);
+ bool hasTemplatedValue = HasTemplatedValue(element);
+ bool inputTypeNotNumbery = InputTypeNotNumbery(element);
+ bool idOrNameMatchSubscription =
+ IdOrNameMatchRegExp(element, RegexKey::SUBSCRIPTION);
+ bool idOrNameMatchFirstAndLast =
+ IdOrNameMatchRegExp(element, RegexKey::FIRST) &&
+ IdOrNameMatchRegExp(element, RegexKey::LAST);
+
+#define RULE_IMPL2(rule, type) params.m##type##Params[type##Params::rule]
+#define RULE_IMPL(rule, type) RULE_IMPL2(rule, type)
+#define RULE(rule) RULE_IMPL(rule, RULE_TYPE)
+
+ // cc-number
+#define RULE_TYPE CCNumber
+ RULE(idOrNameMatchNumberRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_NUMBER);
+ RULE(labelsMatchNumberRegExp) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::CC_NUMBER);
+ RULE(closestLabelMatchesNumberRegExp) =
+ ClosestLabelMatchesRegExp(element, RegexKey::CC_NUMBER);
+ RULE(placeholderMatchesNumberRegExp) =
+ PlaceholderMatchesRegExp(element, RegexKey::CC_NUMBER);
+ RULE(ariaLabelMatchesNumberRegExp) =
+ AriaLabelMatchesRegExp(element, RegexKey::CC_NUMBER);
+ RULE(idOrNameMatchGift) = IdOrNameMatchRegExp(element, RegexKey::GIFT);
+ RULE(labelsMatchGift) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::GIFT);
+ RULE(placeholderMatchesGift) =
+ PlaceholderMatchesRegExp(element, RegexKey::GIFT);
+ RULE(ariaLabelMatchesGift) =
+ AriaLabelMatchesRegExp(element, RegexKey::GIFT);
+ RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription;
+ RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml;
+ RULE(hasTemplatedValue) = hasTemplatedValue;
+ RULE(inputTypeNotNumbery) = inputTypeNotNumbery;
+#undef RULE_TYPE
+
+ // cc-name
+#define RULE_TYPE CCName
+ RULE(idOrNameMatchNameRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_NAME);
+ RULE(labelsMatchNameRegExp) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::CC_NAME);
+ RULE(closestLabelMatchesNameRegExp) =
+ ClosestLabelMatchesRegExp(element, RegexKey::CC_NAME);
+ RULE(placeholderMatchesNameRegExp) =
+ PlaceholderMatchesRegExp(element, RegexKey::CC_NAME);
+ RULE(ariaLabelMatchesNameRegExp) =
+ AriaLabelMatchesRegExp(element, RegexKey::CC_NAME);
+ RULE(idOrNameMatchFirst) = IdOrNameMatchRegExp(element, RegexKey::FIRST);
+ RULE(labelsMatchFirst) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::FIRST);
+ RULE(placeholderMatchesFirst) =
+ PlaceholderMatchesRegExp(element, RegexKey::FIRST);
+ RULE(ariaLabelMatchesFirst) =
+ AriaLabelMatchesRegExp(element, RegexKey::FIRST);
+ RULE(idOrNameMatchLast) = IdOrNameMatchRegExp(element, RegexKey::LAST);
+ RULE(labelsMatchLast) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::LAST);
+ RULE(placeholderMatchesLast) =
+ PlaceholderMatchesRegExp(element, RegexKey::LAST);
+ RULE(ariaLabelMatchesLast) =
+ AriaLabelMatchesRegExp(element, RegexKey::LAST);
+ RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription;
+ RULE(idOrNameMatchFirstAndLast) = idOrNameMatchFirstAndLast;
+ RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml;
+ RULE(hasTemplatedValue) = hasTemplatedValue;
+#undef RULE_TYPE
+
+ // We only use Fathom to detect cc-number & cc-name fields for now.
+ // Comment out code below instead of removing them to make it clear that
+ // the current design is to support multiple rules.
+/*
+ Element* nextFillableField = NextField(aElements, i);
+ Element* prevFillableField = PrevField(aElements, i);
+
+ const nsTArray<nsCString>* nextLabelStrings = GetLabelStrings(
+ nextFillableField, elementsToLabelStrings, elementsIdToLabelStrings);
+ const nsTArray<nsCString>* prevLabelStrings = GetLabelStrings(
+ prevFillableField, elementsToLabelStrings, elementsIdToLabelStrings);
+ bool roleIsMenu = RoleIsMenu(element);
+
+ // cc-type
+#define RULE_TYPE CCType
+ RULE(idOrNameMatchTypeRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_TYPE);
+ RULE(labelsMatchTypeRegExp) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::CC_TYPE);
+ RULE(closestLabelMatchesTypeRegExp) =
+ ClosestLabelMatchesRegExp(element, RegexKey::CC_TYPE);
+ RULE(idOrNameMatchVisaCheckout) =
+ IdOrNameMatchRegExp(element, RegexKey::VISA_CHECKOUT);
+ RULE(ariaLabelMatchesVisaCheckout) =
+ AriaLabelMatchesRegExp(element, RegexKey::VISA_CHECKOUT);
+ RULE(isSelectWithCreditCardOptions) =
+ IsSelectWithCreditCardOptions(element);
+ RULE(isRadioWithCreditCardText) =
+ IsRadioWithCreditCardText(element, labelStrings, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+
+ RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription;
+ RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml;
+ RULE(hasTemplatedValue) = hasTemplatedValue;
+#undef RULE_TYPE
+
+ // cc-exp
+#define RULE_TYPE CCExp
+ RULE(labelsMatchExpRegExp) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::CC_EXP);
+ RULE(closestLabelMatchesExpRegExp) =
+ ClosestLabelMatchesRegExp(element, RegexKey::CC_EXP);
+ RULE(placeholderMatchesExpRegExp) =
+ PlaceholderMatchesRegExp(element, RegexKey::CC_EXP);
+ RULE(labelsMatchExpWith2Or4DigitYear) = LabelMatchesRegExp(
+ element, labelStrings, RegexKey::TWO_OR_FOUR_DIGIT_YEAR);
+ RULE(placeholderMatchesExpWith2Or4DigitYear) =
+ PlaceholderMatchesRegExp(element, RegexKey::TWO_OR_FOUR_DIGIT_YEAR);
+ RULE(labelsMatchMMYY) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::MMYY);
+ RULE(placeholderMatchesMMYY) =
+ PlaceholderMatchesRegExp(element, RegexKey::MMYY);
+ RULE(maxLengthIs7) = MaxLengthIs(element, 7);
+ RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription;
+ RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml;
+ RULE(hasTemplatedValue) = hasTemplatedValue;
+ RULE(isExpirationMonthLikely) = IsExpirationMonthLikely(element);
+ RULE(isExpirationYearLikely) = IsExpirationYearLikely(element);
+ RULE(idOrNameMatchMonth) = IdOrNameMatchRegExp(element, RegexKey::MONTH);
+ RULE(idOrNameMatchYear) = IdOrNameMatchRegExp(element, RegexKey::YEAR);
+ RULE(idOrNameMatchExpMonthRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_EXP_MONTH);
+ RULE(idOrNameMatchExpYearRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_EXP_YEAR);
+ RULE(idOrNameMatchValidation) =
+ IdOrNameMatchRegExp(element, RegexKey::VALIDATION);
+#undef RULE_TYPE
+
+ // cc-exp-month
+#define RULE_TYPE CCExpMonth
+ RULE(idOrNameMatchExpMonthRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_EXP_MONTH);
+ RULE(labelsMatchExpMonthRegExp) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::CC_EXP_MONTH);
+ RULE(closestLabelMatchesExpMonthRegExp) =
+ ClosestLabelMatchesRegExp(element, RegexKey::CC_EXP_MONTH);
+ RULE(placeholderMatchesExpMonthRegExp) =
+ PlaceholderMatchesRegExp(element, RegexKey::CC_EXP_MONTH);
+ RULE(ariaLabelMatchesExpMonthRegExp) =
+ AriaLabelMatchesRegExp(element, RegexKey::CC_EXP_MONTH);
+ RULE(idOrNameMatchMonth) = IdOrNameMatchRegExp(element, RegexKey::MONTH);
+ RULE(labelsMatchMonth) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::MONTH);
+ RULE(placeholderMatchesMonth) =
+ PlaceholderMatchesRegExp(element, RegexKey::MONTH);
+ RULE(ariaLabelMatchesMonth) =
+ AriaLabelMatchesRegExp(element, RegexKey::MONTH);
+ RULE(nextFieldIdOrNameMatchExpYearRegExp) =
+ nextFillableField &&
+ IdOrNameMatchRegExp(*nextFillableField, RegexKey::CC_EXP_YEAR);
+ RULE(nextFieldLabelsMatchExpYearRegExp) =
+ nextFillableField &&
+ LabelMatchesRegExp(element, nextLabelStrings, RegexKey::CC_EXP_YEAR);
+ RULE(nextFieldPlaceholderMatchExpYearRegExp) =
+ nextFillableField &&
+ PlaceholderMatchesRegExp(*nextFillableField, RegexKey::CC_EXP_YEAR);
+ RULE(nextFieldAriaLabelMatchExpYearRegExp) =
+ nextFillableField &&
+ AriaLabelMatchesRegExp(*nextFillableField, RegexKey::CC_EXP_YEAR);
+ RULE(nextFieldIdOrNameMatchYear) =
+ nextFillableField &&
+ IdOrNameMatchRegExp(*nextFillableField, RegexKey::YEAR);
+ RULE(nextFieldLabelsMatchYear) =
+ nextFillableField &&
+ LabelMatchesRegExp(element, nextLabelStrings, RegexKey::YEAR);
+ RULE(nextFieldPlaceholderMatchesYear) =
+ nextFillableField &&
+ PlaceholderMatchesRegExp(*nextFillableField, RegexKey::YEAR);
+ RULE(nextFieldAriaLabelMatchesYear) =
+ nextFillableField &&
+ AriaLabelMatchesRegExp(*nextFillableField, RegexKey::YEAR);
+ RULE(nextFieldMatchesExpYearAutocomplete) =
+ nextFillableField &&
+ NextFieldMatchesExpYearAutocomplete(nextFillableField);
+ RULE(isExpirationMonthLikely) = IsExpirationMonthLikely(element);
+ RULE(nextFieldIsExpirationYearLikely) =
+ nextFillableField && IsExpirationYearLikely(*nextFillableField);
+ RULE(maxLengthIs2) = MaxLengthIs(element, 2);
+ RULE(placeholderMatchesMM) =
+ PlaceholderMatchesRegExp(element, RegexKey::MM_MONTH);
+ RULE(roleIsMenu) = roleIsMenu;
+ RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription;
+ RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml;
+ RULE(hasTemplatedValue) = hasTemplatedValue;
+#undef RULE_TYPE
+
+ // cc-exp-year
+#define RULE_TYPE CCExpYear
+ RULE(idOrNameMatchExpYearRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_EXP_YEAR);
+ RULE(labelsMatchExpYearRegExp) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::CC_EXP_YEAR);
+ RULE(closestLabelMatchesExpYearRegExp) =
+ ClosestLabelMatchesRegExp(element, RegexKey::CC_EXP_YEAR);
+ RULE(placeholderMatchesExpYearRegExp) =
+ PlaceholderMatchesRegExp(element, RegexKey::CC_EXP_YEAR);
+ RULE(ariaLabelMatchesExpYearRegExp) =
+ AriaLabelMatchesRegExp(element, RegexKey::CC_EXP_YEAR);
+ RULE(idOrNameMatchYear) = IdOrNameMatchRegExp(element, RegexKey::YEAR);
+ RULE(labelsMatchYear) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::YEAR);
+ RULE(placeholderMatchesYear) =
+ PlaceholderMatchesRegExp(element, RegexKey::YEAR);
+ RULE(ariaLabelMatchesYear) =
+ AriaLabelMatchesRegExp(element, RegexKey::YEAR);
+ RULE(previousFieldIdOrNameMatchExpMonthRegExp) =
+ prevFillableField &&
+ IdOrNameMatchRegExp(*prevFillableField, RegexKey::CC_EXP_MONTH);
+ RULE(previousFieldLabelsMatchExpMonthRegExp) =
+ prevFillableField &&
+ LabelMatchesRegExp(element, prevLabelStrings, RegexKey::CC_EXP_MONTH);
+ RULE(previousFieldPlaceholderMatchExpMonthRegExp) =
+ prevFillableField &&
+ PlaceholderMatchesRegExp(*prevFillableField, RegexKey::CC_EXP_MONTH);
+ RULE(previousFieldAriaLabelMatchExpMonthRegExp) =
+ prevFillableField &&
+ AriaLabelMatchesRegExp(*prevFillableField, RegexKey::CC_EXP_MONTH);
+ RULE(previousFieldIdOrNameMatchMonth) =
+ prevFillableField &&
+ IdOrNameMatchRegExp(*prevFillableField, RegexKey::MONTH);
+ RULE(previousFieldLabelsMatchMonth) =
+ prevFillableField &&
+ LabelMatchesRegExp(element, prevLabelStrings, RegexKey::MONTH);
+ RULE(previousFieldPlaceholderMatchesMonth) =
+ prevFillableField &&
+ PlaceholderMatchesRegExp(*prevFillableField, RegexKey::MONTH);
+ RULE(previousFieldAriaLabelMatchesMonth) =
+ prevFillableField &&
+ AriaLabelMatchesRegExp(*prevFillableField, RegexKey::MONTH);
+ RULE(previousFieldMatchesExpMonthAutocomplete) =
+ prevFillableField &&
+ PreviousFieldMatchesExpMonthAutocomplete(prevFillableField);
+ RULE(isExpirationYearLikely) = IsExpirationYearLikely(element);
+ RULE(previousFieldIsExpirationMonthLikely) =
+ prevFillableField && IsExpirationMonthLikely(*prevFillableField);
+ RULE(placeholderMatchesYYOrYYYY) =
+ PlaceholderMatchesRegExp(element, RegexKey::YY_OR_YYYY);
+ RULE(roleIsMenu) = roleIsMenu;
+ RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription;
+ RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml;
+ RULE(hasTemplatedValue) = hasTemplatedValue;
+#undef RULE_TYPE
+*/
+
+#undef RULE_IMPL2
+#undef RULE_IMPL
+#undef RULE
+
+#define CALCULATE_SCORE(type, score) \
+ for (auto i : MakeEnumeratedRange(type##Params::Count)) { \
+ (score) += params.m##type##Params[i] * kCoefficents.m##type##Params[i]; \
+ } \
+ (score) = Sigmoid(score + k##type##Bias);
+
+ // Calculating the final score of each rule
+ FormAutofillConfidences score;
+ CALCULATE_SCORE(CCNumber, score.mCcNumber)
+ CALCULATE_SCORE(CCName, score.mCcName)
+
+ // Comment out code that are not used right now
+ // CALCULATE_SCORE(CCType, score.mCcType)
+ // CALCULATE_SCORE(CCExp, score.mCcExp)
+ // CALCULATE_SCORE(CCExpMonth, score.mCcExpMonth)
+ // CALCULATE_SCORE(CCExpYear, score.mCcExpYear)
+
+#undef CALCULATE_SCORE
+
+ aResults.AppendElement(score);
+ }
+}
+
+static StaticAutoPtr<FormAutofillImpl> sFormAutofillInstance;
+
+static FormAutofillImpl* GetFormAutofillImpl() {
+ if (!sFormAutofillInstance) {
+ sFormAutofillInstance = new FormAutofillImpl();
+ ClearOnShutdown(&sFormAutofillInstance);
+ }
+ return sFormAutofillInstance;
+}
+
+/* static */
+void FormAutofillNative::GetFormAutofillConfidences(
+ GlobalObject& aGlobal, const Sequence<OwningNonNull<Element>>& aElements,
+ nsTArray<FormAutofillConfidences>& aResults, ErrorResult& aRv) {
+ GetFormAutofillImpl()->GetFormAutofillConfidences(aGlobal, aElements,
+ aResults, aRv);
+}
+
+} // namespace mozilla::dom
diff --git a/toolkit/components/formautofill/FormAutofillNative.h b/toolkit/components/formautofill/FormAutofillNative.h
new file mode 100644
index 0000000000..bca124631a
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillNative.h
@@ -0,0 +1,24 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */
+/* 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/. */
+
+#ifndef mozilla_dom_FormAutofillNative_h
+#define mozilla_dom_FormAutofillNative_h
+
+#include "mozilla/dom/BindingDeclarations.h"
+#include "mozilla/dom/ChromeUtilsBinding.h"
+
+namespace mozilla::dom {
+class Element;
+
+class FormAutofillNative {
+ public:
+ static void GetFormAutofillConfidences(
+ GlobalObject& aGlobal, const Sequence<OwningNonNull<Element>>& aElements,
+ nsTArray<FormAutofillConfidences>& aResults, ErrorResult& aRv);
+};
+
+} // namespace mozilla::dom
+
+#endif // mozilla_dom_FormAutofillNative_h
diff --git a/toolkit/components/formautofill/FormAutofillParent.sys.mjs b/toolkit/components/formautofill/FormAutofillParent.sys.mjs
new file mode 100644
index 0000000000..ba0d769906
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillParent.sys.mjs
@@ -0,0 +1,716 @@
+/* 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/. */
+
+/*
+ * Implements a service used to access storage and communicate with content.
+ *
+ * A "fields" array is used to communicate with FormAutofillContent. Each item
+ * represents a single input field in the content page as well as its
+ * @autocomplete properties. The schema is as below. Please refer to
+ * FormAutofillContent.js for more details.
+ *
+ * [
+ * {
+ * section,
+ * addressType,
+ * contactType,
+ * fieldName,
+ * value,
+ * index
+ * },
+ * {
+ * // ...
+ * }
+ * ]
+ */
+
+// We expose a singleton from this module. Some tests may import the
+// constructor via a backstage pass.
+import { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs";
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddressComponent: "resource://gre/modules/shared/AddressComponent.sys.mjs",
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
+ FormAutofillPreferences:
+ "resource://autofill/FormAutofillPreferences.sys.mjs",
+ FormAutofillPrompter: "resource://autofill/FormAutofillPrompter.sys.mjs",
+ FirefoxRelay: "resource://gre/modules/FirefoxRelay.sys.mjs",
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "log", () =>
+ FormAutofill.defineLogGetter(lazy, "FormAutofillParent")
+);
+
+const { ENABLED_AUTOFILL_ADDRESSES_PREF, ENABLED_AUTOFILL_CREDITCARDS_PREF } =
+ FormAutofill;
+
+const { ADDRESSES_COLLECTION_NAME, CREDITCARDS_COLLECTION_NAME } =
+ FormAutofillUtils;
+
+let gMessageObservers = new Set();
+
+export let FormAutofillStatus = {
+ _initialized: false,
+
+ /**
+ * Cache of the Form Autofill status (considering preferences and storage).
+ */
+ _active: null,
+
+ /**
+ * Initializes observers and registers the message handler.
+ */
+ init() {
+ if (this._initialized) {
+ return;
+ }
+ this._initialized = true;
+
+ Services.obs.addObserver(this, "privacy-pane-loaded");
+
+ // Observing the pref and storage changes
+ Services.prefs.addObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this);
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+
+ // Only listen to credit card related preference if it is available
+ if (FormAutofill.isAutofillCreditCardsAvailable) {
+ Services.prefs.addObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this);
+ }
+
+ // We have to use empty window type to get all opened windows here because the
+ // window type parameter may not be available during startup.
+ for (let win of Services.wm.getEnumerator("")) {
+ let { documentElement } = win.document;
+ if (documentElement?.getAttribute("windowtype") == "navigator:browser") {
+ this.injectElements(win.document);
+ } else {
+ // Manually call onOpenWindow for windows that are already opened but not
+ // yet have the window type set. This ensures we inject the elements we need
+ // when its docuemnt is ready.
+ this.onOpenWindow(win);
+ }
+ }
+ Services.wm.addListener(this);
+
+ Services.telemetry.setEventRecordingEnabled("creditcard", true);
+ Services.telemetry.setEventRecordingEnabled("address", true);
+ },
+
+ /**
+ * Uninitializes FormAutofillStatus. This is for testing only.
+ *
+ * @private
+ */
+ uninit() {
+ lazy.gFormAutofillStorage._saveImmediately();
+
+ if (!this._initialized) {
+ return;
+ }
+ this._initialized = false;
+
+ this._active = null;
+
+ Services.obs.removeObserver(this, "privacy-pane-loaded");
+ Services.prefs.removeObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this);
+ Services.wm.removeListener(this);
+
+ if (FormAutofill.isAutofillCreditCardsAvailable) {
+ Services.prefs.removeObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this);
+ }
+ },
+
+ get formAutofillStorage() {
+ return lazy.gFormAutofillStorage;
+ },
+
+ /**
+ * Broadcast the status to frames when the form autofill status changes.
+ */
+ onStatusChanged() {
+ lazy.log.debug("onStatusChanged: Status changed to", this._active);
+ Services.ppmm.sharedData.set("FormAutofill:enabled", this._active);
+ // Sync autofill enabled to make sure the value is up-to-date
+ // no matter when the new content process is initialized.
+ Services.ppmm.sharedData.flush();
+ },
+
+ /**
+ * Query preference and storage status to determine the overall status of the
+ * form autofill feature.
+ *
+ * @returns {boolean} whether form autofill is active (enabled and has data)
+ */
+ computeStatus() {
+ const savedFieldNames = Services.ppmm.sharedData.get(
+ "FormAutofill:savedFieldNames"
+ );
+
+ return (
+ (Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF) ||
+ Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF)) &&
+ savedFieldNames &&
+ savedFieldNames.size > 0
+ );
+ },
+
+ /**
+ * Update the status and trigger onStatusChanged, if necessary.
+ */
+ updateStatus() {
+ lazy.log.debug("updateStatus");
+ let wasActive = this._active;
+ this._active = this.computeStatus();
+ if (this._active !== wasActive) {
+ this.onStatusChanged();
+ }
+ },
+
+ async updateSavedFieldNames() {
+ lazy.log.debug("updateSavedFieldNames");
+
+ let savedFieldNames;
+ const addressNames =
+ await lazy.gFormAutofillStorage.addresses.getSavedFieldNames();
+
+ // Don't access the credit cards store unless it is enabled.
+ if (FormAutofill.isAutofillCreditCardsAvailable) {
+ const creditCardNames =
+ await lazy.gFormAutofillStorage.creditCards.getSavedFieldNames();
+ savedFieldNames = new Set([...addressNames, ...creditCardNames]);
+ } else {
+ savedFieldNames = addressNames;
+ }
+
+ Services.ppmm.sharedData.set(
+ "FormAutofill:savedFieldNames",
+ savedFieldNames
+ );
+ Services.ppmm.sharedData.flush();
+
+ this.updateStatus();
+ },
+
+ injectElements(doc) {
+ Services.scriptloader.loadSubScript(
+ "chrome://formautofill/content/customElements.js",
+ doc.ownerGlobal
+ );
+ },
+
+ onOpenWindow(xulWindow) {
+ const win = xulWindow.docShell.domWindow;
+ win.addEventListener(
+ "load",
+ () => {
+ if (
+ win.document.documentElement.getAttribute("windowtype") ==
+ "navigator:browser"
+ ) {
+ this.injectElements(win.document);
+ }
+ },
+ { once: true }
+ );
+ },
+
+ onCloseWindow() {},
+
+ async observe(subject, topic, data) {
+ lazy.log.debug("observe:", topic, "with data:", data);
+ switch (topic) {
+ case "privacy-pane-loaded": {
+ let formAutofillPreferences = new lazy.FormAutofillPreferences();
+ let document = subject.document;
+ let prefFragment = formAutofillPreferences.init(document);
+ let formAutofillGroupBox = document.getElementById(
+ "formAutofillGroupBox"
+ );
+ formAutofillGroupBox.appendChild(prefFragment);
+ break;
+ }
+
+ case "nsPref:changed": {
+ // Observe pref changes and update _active cache if status is changed.
+ this.updateStatus();
+ break;
+ }
+
+ case "formautofill-storage-changed": {
+ // Early exit if only metadata is changed
+ if (data == "notifyUsed") {
+ break;
+ }
+
+ await this.updateSavedFieldNames();
+ break;
+ }
+
+ default: {
+ throw new Error(
+ `FormAutofillStatus: Unexpected topic observed: ${topic}`
+ );
+ }
+ }
+ },
+};
+
+// Lazily load the storage JSM to avoid disk I/O until absolutely needed.
+// Once storage is loaded we need to update saved field names and inform content processes.
+ChromeUtils.defineLazyGetter(lazy, "gFormAutofillStorage", () => {
+ let { formAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ );
+ lazy.log.debug("Loading formAutofillStorage");
+
+ formAutofillStorage.initialize().then(() => {
+ // Update the saved field names to compute the status and update child processes.
+ FormAutofillStatus.updateSavedFieldNames();
+ });
+
+ return formAutofillStorage;
+});
+
+export class FormAutofillParent extends JSWindowActorParent {
+ constructor() {
+ super();
+ FormAutofillStatus.init();
+ }
+
+ static addMessageObserver(observer) {
+ gMessageObservers.add(observer);
+ }
+
+ static removeMessageObserver(observer) {
+ gMessageObservers.delete(observer);
+ }
+
+ /**
+ * Handles the message coming from FormAutofillContent.
+ *
+ * @param {object} message
+ * @param {string} message.name The name of the message.
+ * @param {object} message.data The data of the message.
+ */
+ async receiveMessage({ name, data }) {
+ switch (name) {
+ case "FormAutofill:InitStorage": {
+ await lazy.gFormAutofillStorage.initialize();
+ await FormAutofillStatus.updateSavedFieldNames();
+ break;
+ }
+ case "FormAutofill:GetRecords": {
+ const relayPromise = lazy.FirefoxRelay.autocompleteItemsAsync({
+ formOrigin: this.formOrigin,
+ scenarioName: data.scenarioName,
+ hasInput: !!data.searchString?.length,
+ });
+ const recordsPromise = FormAutofillParent._getRecords(data);
+ const [records, externalEntries] = await Promise.all([
+ recordsPromise,
+ relayPromise,
+ ]);
+ return { records, externalEntries };
+ }
+ case "FormAutofill:OnFormSubmit": {
+ this.notifyMessageObservers("onFormSubmitted", data);
+ await this._onFormSubmit(data);
+ break;
+ }
+ case "FormAutofill:OpenPreferences": {
+ const win = lazy.BrowserWindowTracker.getTopWindow();
+ win.openPreferences("privacy-form-autofill");
+ break;
+ }
+ case "FormAutofill:GetDecryptedString": {
+ let { cipherText, reauth } = data;
+ if (!FormAutofillUtils._reauthEnabledByUser) {
+ lazy.log.debug("Reauth is disabled");
+ reauth = false;
+ }
+ let string;
+ try {
+ string = await lazy.OSKeyStore.decrypt(cipherText, reauth);
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_ABORT) {
+ throw e;
+ }
+ lazy.log.warn("User canceled encryption login");
+ }
+ return string;
+ }
+ case "FormAutofill:UpdateWarningMessage":
+ this.notifyMessageObservers("updateWarningNote", data);
+ break;
+
+ case "FormAutofill:FieldsIdentified":
+ this.notifyMessageObservers("fieldsIdentified", data);
+ break;
+
+ // The remaining Save and Remove messages are invoked only by tests.
+ case "FormAutofill:SaveAddress": {
+ if (data.guid) {
+ await lazy.gFormAutofillStorage.addresses.update(
+ data.guid,
+ data.address
+ );
+ } else {
+ await lazy.gFormAutofillStorage.addresses.add(data.address);
+ }
+ break;
+ }
+ case "FormAutofill:SaveCreditCard": {
+ if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) {
+ lazy.log.warn("User canceled encryption login");
+ return undefined;
+ }
+ await lazy.gFormAutofillStorage.creditCards.add(data.creditcard);
+ break;
+ }
+ case "FormAutofill:RemoveAddresses": {
+ data.guids.forEach(guid =>
+ lazy.gFormAutofillStorage.addresses.remove(guid)
+ );
+ break;
+ }
+ case "FormAutofill:RemoveCreditCards": {
+ data.guids.forEach(guid =>
+ lazy.gFormAutofillStorage.creditCards.remove(guid)
+ );
+ break;
+ }
+ case "PasswordManager:offerRelayIntegration": {
+ FirefoxRelayTelemetry.recordRelayOfferedEvent(
+ "clicked",
+ data.telemetry.flowId,
+ data.telemetry.scenarioName
+ );
+ return this.#offerRelayIntegration();
+ }
+ case "PasswordManager:generateRelayUsername": {
+ FirefoxRelayTelemetry.recordRelayUsernameFilledEvent(
+ "clicked",
+ data.telemetry.flowId
+ );
+ return this.#generateRelayUsername();
+ }
+ }
+
+ return undefined;
+ }
+
+ get formOrigin() {
+ return lazy.LoginHelper.getLoginOrigin(
+ this.manager.documentPrincipal?.originNoSuffix
+ );
+ }
+
+ getRootBrowser() {
+ return this.browsingContext.topFrameElement;
+ }
+
+ async #offerRelayIntegration() {
+ const browser = this.getRootBrowser();
+ return lazy.FirefoxRelay.offerRelayIntegration(browser, this.formOrigin);
+ }
+
+ async #generateRelayUsername() {
+ const browser = this.getRootBrowser();
+ return lazy.FirefoxRelay.generateUsername(browser, this.formOrigin);
+ }
+
+ notifyMessageObservers(callbackName, data) {
+ for (let observer of gMessageObservers) {
+ try {
+ if (callbackName in observer) {
+ observer[callbackName](
+ data,
+ this.manager.browsingContext.topChromeWindow
+ );
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+
+ /**
+ * Get the records from profile store and return results back to content
+ * process. It will decrypt the credit card number and append
+ * "cc-number-decrypted" to each record if OSKeyStore isn't set.
+ *
+ * This is static as a unit test calls this.
+ *
+ * @private
+ * @param {object} data
+ * @param {string} data.collectionName
+ * The name used to specify which collection to retrieve records.
+ * @param {string} data.searchString
+ * The typed string for filtering out the matched records.
+ * @param {string} data.info
+ * The input autocomplete property's information.
+ */
+ static async _getRecords({ collectionName, searchString, info }) {
+ let collection = lazy.gFormAutofillStorage[collectionName];
+ if (!collection) {
+ return [];
+ }
+
+ let recordsInCollection = await collection.getAll();
+ if (!info || !info.fieldName || !recordsInCollection.length) {
+ return recordsInCollection;
+ }
+
+ let isCC = collectionName == CREDITCARDS_COLLECTION_NAME;
+ // We don't filter "cc-number"
+ if (isCC && info.fieldName == "cc-number") {
+ recordsInCollection = recordsInCollection.filter(
+ record => !!record["cc-number"]
+ );
+ return recordsInCollection;
+ }
+
+ let records = [];
+ let lcSearchString = searchString.toLowerCase();
+
+ for (let record of recordsInCollection) {
+ let fieldValue = record[info.fieldName];
+ if (!fieldValue) {
+ continue;
+ }
+
+ if (
+ collectionName == ADDRESSES_COLLECTION_NAME &&
+ record.country &&
+ !FormAutofill.isAutofillAddressesAvailableInCountry(record.country)
+ ) {
+ // Address autofill isn't supported for the record's country so we don't
+ // want to attempt to potentially incorrectly fill the address fields.
+ continue;
+ }
+
+ if (
+ lcSearchString &&
+ !String(fieldValue).toLowerCase().startsWith(lcSearchString)
+ ) {
+ continue;
+ }
+ records.push(record);
+ }
+
+ return records;
+ }
+
+ async _onAddressSubmit(address, browser) {
+ const storage = lazy.gFormAutofillStorage.addresses;
+
+ // Make sure record is normalized before comparing with records in the storage
+ try {
+ storage._normalizeRecord(address.record);
+ } catch (_e) {
+ return false;
+ }
+
+ const newAddress = new lazy.AddressComponent(
+ address.record,
+ // Invalid address fields in the address form will not be captured.
+ { ignoreInvalid: true }
+ );
+
+ // Exams all stored record to determine whether to show the prompt or not.
+ let mergeableFields = [];
+ let preserveFields = [];
+ let oldRecord = {};
+
+ for (const record of await storage.getAll()) {
+ const savedAddress = new lazy.AddressComponent(record);
+ // filter invalid field
+ const result = newAddress.compare(savedAddress);
+
+ // If any of the fields in the new address are different from the corresponding fields
+ // in the saved address, the two addresses are considered different. For example, if
+ // the name, email, country are the same but the street address is different, the two
+ // addresses are not considered the same.
+ if (Object.values(result).includes("different")) {
+ continue;
+ }
+
+ // If none of the fields in the new address are mergeable, the new address is considered
+ // a duplicate of a local address. Therefore, we don't need to capture this address.
+ const fields = Object.entries(result)
+ .filter(v => ["superset", "similar"].includes(v[1]))
+ .map(v => v[0]);
+ if (!fields.length) {
+ lazy.log.debug(
+ "A duplicated address record is found, do not show the prompt"
+ );
+ storage.notifyUsed(record.guid);
+ return false;
+ }
+
+ // If the new address is neither a duplicate of the saved address nor a different address.
+ // There must be at least one field we can merge, show the update doorhanger
+ lazy.log.debug(
+ "A mergeable address record is found, show the update prompt"
+ );
+
+ // If one record has fewer mergeable fields compared to another, it suggests greater similarity
+ // to the merged record. In such cases, we opt for the record with the fewest mergeable fields.
+ // TODO: Bug 1830841. Add a testcase
+ if (!mergeableFields.length || mergeableFields > fields.length) {
+ mergeableFields = fields;
+ preserveFields = Object.entries(result)
+ .filter(v => ["same", "subset"].includes(v[1]))
+ .map(v => v[0]);
+ oldRecord = record;
+ }
+ }
+
+ // Find a mergeable old record, construct the new record by only copying mergeable fields
+ // from the new address.
+ let newRecord = {};
+ if (mergeableFields.length) {
+ // TODO: This is only temporarily, should be removed after Bug 1836438 is fixed
+ if (mergeableFields.includes("name")) {
+ mergeableFields.push("given-name", "additional-name", "family-name");
+ }
+ mergeableFields.forEach(f => {
+ if (f in newAddress.record) {
+ newRecord[f] = newAddress.record[f];
+ }
+ });
+
+ if (preserveFields.includes("name")) {
+ preserveFields.push("given-name", "additional-name", "family-name");
+ }
+ preserveFields.forEach(f => {
+ if (f in oldRecord) {
+ newRecord[f] = oldRecord[f];
+ }
+ });
+ } else {
+ newRecord = newAddress.record;
+ }
+
+ if (!this._shouldShowSaveAddressPrompt(newAddress.record)) {
+ return false;
+ }
+
+ return async () => {
+ await lazy.FormAutofillPrompter.promptToSaveAddress(
+ browser,
+ storage,
+ address.flowId,
+ { oldRecord, newRecord }
+ );
+ };
+ }
+
+ async _onCreditCardSubmit(creditCard, browser) {
+ const storage = lazy.gFormAutofillStorage.creditCards;
+
+ // Make sure record is normalized before comparing with records in the storage
+ try {
+ storage._normalizeRecord(creditCard.record);
+ } catch (_e) {
+ return false;
+ }
+
+ // If the record alreay exists in the storage, don't bother showing the prompt
+ const matchRecord = (
+ await storage.getMatchRecords(creditCard.record).next()
+ ).value;
+ if (matchRecord) {
+ storage.notifyUsed(matchRecord.guid);
+ return false;
+ }
+
+ // Suppress the pending doorhanger from showing up if user disabled credit card in previous doorhanger.
+ if (!FormAutofill.isAutofillCreditCardsEnabled) {
+ return false;
+ }
+
+ // Overwrite the guid if there is a duplicate
+ const duplicateRecord =
+ (await storage.getDuplicateRecords(creditCard.record).next()).value ?? {};
+
+ return async () => {
+ await lazy.FormAutofillPrompter.promptToSaveCreditCard(
+ browser,
+ storage,
+ creditCard.flowId,
+ { oldRecord: duplicateRecord, newRecord: creditCard.record }
+ );
+ };
+ }
+
+ async _onFormSubmit(data) {
+ let { address, creditCard } = data;
+
+ let browser = this.manager.browsingContext.top.embedderElement;
+
+ // Transmit the telemetry immediately in the meantime form submitted, and handle these pending
+ // doorhangers at a later.
+ await Promise.all(
+ [
+ await Promise.all(
+ address.map(addrRecord => this._onAddressSubmit(addrRecord, browser))
+ ),
+ await Promise.all(
+ creditCard.map(ccRecord =>
+ this._onCreditCardSubmit(ccRecord, browser)
+ )
+ ),
+ ]
+ .map(pendingDoorhangers => {
+ return pendingDoorhangers.filter(
+ pendingDoorhanger =>
+ !!pendingDoorhanger && typeof pendingDoorhanger == "function"
+ );
+ })
+ .map(pendingDoorhangers =>
+ (async () => {
+ for (const showDoorhanger of pendingDoorhangers) {
+ await showDoorhanger();
+ }
+ })()
+ )
+ );
+ }
+
+ _shouldShowSaveAddressPrompt(record) {
+ if (!FormAutofill.isAutofillAddressesCaptureEnabled) {
+ return false;
+ }
+
+ // Do not save address for regions that we don't support
+ if (
+ FormAutofill._isAutofillAddressesAvailable == "detect" &&
+ !FormAutofill.isAutofillAddressesAvailableInCountry(record.country)
+ ) {
+ lazy.log.debug(
+ `Do not show the address capture prompt for unsupported regions - ${record.country}`
+ );
+ return false;
+ }
+
+ // Display the address capture doorhanger only when the submitted form contains all
+ // the required fields. This approach is implemented to prevent excessive prompting.
+ const requiredFields = FormAutofill.addressCaptureRequiredFields ?? [];
+ if (!requiredFields.every(field => field in record)) {
+ lazy.log.debug(
+ "Do not show the address capture prompt when the submitted form doesn't contain all the required fields"
+ );
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/toolkit/components/formautofill/FormAutofillPreferences.sys.mjs b/toolkit/components/formautofill/FormAutofillPreferences.sys.mjs
new file mode 100644
index 0000000000..18937371b9
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillPreferences.sys.mjs
@@ -0,0 +1,396 @@
+/* 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/. */
+
+/**
+ * Injects the form autofill section into about:preferences.
+ */
+
+// Add addresses enabled flag in telemetry environment for recording the number of
+// users who disable/enable the address autofill feature.
+const BUNDLE_URI = "chrome://formautofill/locale/formautofill.properties";
+const MANAGE_ADDRESSES_URL =
+ "chrome://formautofill/content/manageAddresses.xhtml";
+const MANAGE_CREDITCARDS_URL =
+ "chrome://formautofill/content/manageCreditCards.xhtml";
+
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+});
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["browser/preferences/preferences.ftl"], true)
+);
+
+const {
+ ENABLED_AUTOFILL_ADDRESSES_PREF,
+ ENABLED_AUTOFILL_CREDITCARDS_PREF,
+ ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF,
+} = FormAutofill;
+const {
+ MANAGE_ADDRESSES_L10N_IDS,
+ EDIT_ADDRESS_L10N_IDS,
+ MANAGE_CREDITCARDS_L10N_IDS,
+ EDIT_CREDITCARD_L10N_IDS,
+} = FormAutofillUtils;
+// Add credit card enabled flag in telemetry environment for recording the number of
+// users who disable/enable the credit card autofill feature.
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+export function FormAutofillPreferences() {
+ this.bundle = Services.strings.createBundle(BUNDLE_URI);
+}
+
+FormAutofillPreferences.prototype = {
+ /**
+ * Create the Form Autofill preference group.
+ *
+ * @param {HTMLDocument} document
+ * @returns {XULElement}
+ */
+ init(document) {
+ this.createPreferenceGroup(document);
+ this.attachEventListeners();
+
+ return this.refs.formAutofillFragment;
+ },
+
+ /**
+ * Remove event listeners and the preference group.
+ */
+ uninit() {
+ this.detachEventListeners();
+ this.refs.formAutofillGroup.remove();
+ },
+
+ /**
+ * Create Form Autofill preference group
+ *
+ * @param {HTMLDocument} document
+ */
+ createPreferenceGroup(document) {
+ let formAutofillFragment = document.createDocumentFragment();
+ let formAutofillGroupBoxLabel = document.createXULElement("label");
+ let formAutofillGroupBoxLabelHeading = document.createElementNS(
+ HTML_NS,
+ "h2"
+ );
+ let formAutofillGroup = document.createXULElement("vbox");
+ // Wrappers are used to properly compute the search tooltip positions
+ // let savedAddressesBtnWrapper = document.createXULElement("hbox");
+ // let savedCreditCardsBtnWrapper = document.createXULElement("hbox");
+ this.refs = {};
+ this.refs.formAutofillGroup = formAutofillGroup;
+ this.refs.formAutofillFragment = formAutofillFragment;
+
+ formAutofillGroupBoxLabel.appendChild(formAutofillGroupBoxLabelHeading);
+ formAutofillFragment.appendChild(formAutofillGroupBoxLabel);
+ formAutofillFragment.appendChild(formAutofillGroup);
+
+ let showAddressUI = FormAutofill.isAutofillAddressesAvailable;
+ let showCreditCardUI = FormAutofill.isAutofillCreditCardsAvailable;
+
+ if (!showAddressUI && !showCreditCardUI) {
+ return;
+ }
+
+ formAutofillGroupBoxLabelHeading.textContent = lazy.l10n.formatValueSync(
+ "pane-privacy-autofill-header"
+ );
+
+ if (showAddressUI) {
+ let savedAddressesBtnWrapper = document.createXULElement("hbox");
+ let addressAutofill = document.createXULElement("hbox");
+ let addressAutofillCheckboxGroup = document.createXULElement("hbox");
+ let addressAutofillCheckbox = document.createXULElement("checkbox");
+ let addressAutofillLearnMore = document.createElement("a", {
+ is: "moz-support-link",
+ });
+ let savedAddressesBtn = document.createXULElement("button", {
+ is: "highlightable-button",
+ });
+ savedAddressesBtn.className = "accessory-button";
+ addressAutofillCheckbox.className = "tail-with-learn-more";
+
+ formAutofillGroup.id = "formAutofillGroup";
+ addressAutofill.id = "addressAutofill";
+ addressAutofillLearnMore.id = "addressAutofillLearnMore";
+
+ addressAutofill.setAttribute("data-subcategory", "address-autofill");
+ addressAutofillCheckbox.setAttribute(
+ "label",
+ lazy.l10n.formatValueSync("autofill-addresses-checkbox")
+ );
+ savedAddressesBtn.setAttribute(
+ "label",
+ lazy.l10n.formatValueSync("autofill-saved-addresses-button")
+ );
+ // Align the start to keep the savedAddressesBtn as original size
+ // when addressAutofillCheckboxGroup's height is changed by a longer l10n string
+ savedAddressesBtnWrapper.setAttribute("align", "start");
+
+ addressAutofillLearnMore.setAttribute(
+ "support-page",
+ "autofill-card-address"
+ );
+
+ // Add preferences search support
+ savedAddressesBtn.setAttribute(
+ "search-l10n-ids",
+ MANAGE_ADDRESSES_L10N_IDS.concat(EDIT_ADDRESS_L10N_IDS).join(",")
+ );
+
+ // Manually set the checked state
+ if (FormAutofill.isAutofillAddressesEnabled) {
+ addressAutofillCheckbox.setAttribute("checked", true);
+ }
+ if (FormAutofill.isAutofillAddressesLocked) {
+ addressAutofillCheckbox.disabled = true;
+ }
+
+ addressAutofillCheckboxGroup.setAttribute("align", "center");
+ addressAutofillCheckboxGroup.setAttribute("flex", "1");
+
+ formAutofillGroup.appendChild(addressAutofill);
+ addressAutofill.appendChild(addressAutofillCheckboxGroup);
+ addressAutofillCheckboxGroup.appendChild(addressAutofillCheckbox);
+ addressAutofillCheckboxGroup.appendChild(addressAutofillLearnMore);
+ addressAutofill.appendChild(savedAddressesBtnWrapper);
+ savedAddressesBtnWrapper.appendChild(savedAddressesBtn);
+
+ this.refs.formAutofillFragment = formAutofillFragment;
+ this.refs.addressAutofillCheckbox = addressAutofillCheckbox;
+ this.refs.savedAddressesBtn = savedAddressesBtn;
+ }
+
+ if (showCreditCardUI) {
+ let savedCreditCardsBtnWrapper = document.createXULElement("hbox");
+ let creditCardAutofill = document.createXULElement("hbox");
+ let creditCardAutofillCheckboxGroup = document.createXULElement("hbox");
+ let creditCardAutofillCheckbox = document.createXULElement("checkbox");
+ let creditCardAutofillLearnMore = document.createElement("a", {
+ is: "moz-support-link",
+ });
+ let savedCreditCardsBtn = document.createXULElement("button", {
+ is: "highlightable-button",
+ });
+ savedCreditCardsBtn.className = "accessory-button";
+ creditCardAutofillCheckbox.className = "tail-with-learn-more";
+
+ creditCardAutofill.id = "creditCardAutofill";
+ creditCardAutofillLearnMore.id = "creditCardAutofillLearnMore";
+
+ creditCardAutofill.setAttribute(
+ "data-subcategory",
+ "credit-card-autofill"
+ );
+ creditCardAutofillCheckbox.setAttribute(
+ "label",
+ lazy.l10n.formatValueSync("autofill-payment-methods-checkbox-message")
+ );
+
+ savedCreditCardsBtn.setAttribute(
+ "label",
+ lazy.l10n.formatValueSync("autofill-saved-payment-methods-button")
+ );
+ // Align the start to keep the savedCreditCardsBtn as original size
+ // when creditCardAutofillCheckboxGroup's height is changed by a longer l10n string
+ savedCreditCardsBtnWrapper.setAttribute("align", "start");
+
+ creditCardAutofillLearnMore.setAttribute(
+ "support-page",
+ "credit-card-autofill"
+ );
+
+ let creditCardsAutofillDescription =
+ document.createXULElement("description");
+
+ creditCardsAutofillDescription.setAttribute("flex", "1");
+ creditCardsAutofillDescription.className = "indent tip-caption";
+ creditCardsAutofillDescription.setAttribute("data-l10n-attrs", "hidden");
+ creditCardsAutofillDescription.setAttribute(
+ "data-l10n-id",
+ "autofill-payment-methods-checkbox-submessage"
+ );
+
+ // Add preferences search support
+ savedCreditCardsBtn.setAttribute(
+ "search-l10n-ids",
+ MANAGE_CREDITCARDS_L10N_IDS.concat(EDIT_CREDITCARD_L10N_IDS).join(",")
+ );
+
+ // Manually set the checked state
+ if (FormAutofill.isAutofillCreditCardsEnabled) {
+ creditCardAutofillCheckbox.setAttribute("checked", true);
+ }
+ if (FormAutofill.isAutofillCreditCardsLocked) {
+ creditCardAutofillCheckbox.disabled = true;
+ }
+
+ creditCardAutofillCheckboxGroup.setAttribute("align", "center");
+ creditCardAutofillCheckboxGroup.setAttribute("flex", "1");
+
+ formAutofillGroup.appendChild(creditCardAutofill);
+ creditCardAutofill.appendChild(creditCardAutofillCheckboxGroup);
+ creditCardAutofillCheckboxGroup.appendChild(creditCardAutofillCheckbox);
+ creditCardAutofillCheckboxGroup.appendChild(creditCardAutofillLearnMore);
+ creditCardAutofill.appendChild(savedCreditCardsBtnWrapper);
+ savedCreditCardsBtnWrapper.appendChild(savedCreditCardsBtn);
+ formAutofillGroup.appendChild(creditCardsAutofillDescription);
+
+ this.refs.creditCardAutofillCheckbox = creditCardAutofillCheckbox;
+ this.refs.savedCreditCardsBtn = savedCreditCardsBtn;
+
+ if (lazy.OSKeyStore.canReauth()) {
+ let reauth = document.createXULElement("hbox");
+ let reauthCheckboxGroup = document.createXULElement("hbox");
+ let reauthCheckbox = document.createXULElement("checkbox");
+ let reauthLearnMore = document.createElement("a", {
+ is: "moz-support-link",
+ });
+
+ reauthCheckboxGroup.classList.add("indent");
+ reauthCheckbox.classList.add("tail-with-learn-more");
+ reauthCheckbox.disabled = !FormAutofill.isAutofillCreditCardsEnabled;
+
+ reauth.id = "creditCardReauthenticate";
+ reauthLearnMore.id = "creditCardReauthenticateLearnMore";
+
+ reauth.setAttribute("data-subcategory", "reauth-credit-card-autofill");
+
+ reauthCheckbox.setAttribute(
+ "label",
+ lazy.l10n.formatValueSync("autofill-reauth-checkbox")
+ );
+
+ reauthLearnMore.setAttribute(
+ "support-page",
+ "credit-card-autofill#w_require-authentication-for-autofill"
+ );
+
+ // Manually set the checked state
+ if (FormAutofillUtils._reauthEnabledByUser) {
+ reauthCheckbox.setAttribute("checked", true);
+ }
+
+ reauthCheckboxGroup.setAttribute("align", "center");
+ reauthCheckboxGroup.setAttribute("flex", "1");
+
+ formAutofillGroup.appendChild(reauth);
+ reauth.appendChild(reauthCheckboxGroup);
+ reauthCheckboxGroup.appendChild(reauthCheckbox);
+ reauthCheckboxGroup.appendChild(reauthLearnMore);
+ this.refs.reauthCheckbox = reauthCheckbox;
+ }
+ }
+ },
+
+ /**
+ * Handle events
+ *
+ * @param {DOMEvent} event
+ */
+ async handleEvent(event) {
+ switch (event.type) {
+ case "command": {
+ let target = event.target;
+
+ if (target == this.refs.addressAutofillCheckbox) {
+ // Set preference directly instead of relying on <Preference>
+ Services.prefs.setBoolPref(
+ ENABLED_AUTOFILL_ADDRESSES_PREF,
+ target.checked
+ );
+ } else if (target == this.refs.creditCardAutofillCheckbox) {
+ Services.prefs.setBoolPref(
+ ENABLED_AUTOFILL_CREDITCARDS_PREF,
+ target.checked
+ );
+ if (this.refs.reauthCheckbox) {
+ this.refs.reauthCheckbox.disabled = !target.checked;
+ }
+ } else if (target == this.refs.reauthCheckbox) {
+ if (!lazy.OSKeyStore.canReauth()) {
+ break;
+ }
+
+ let messageTextId = "autofillReauthOSDialog";
+ // We reuse the if/else order from wizard markup to increase
+ // odds of consistent behavior.
+ if (AppConstants.platform == "macosx") {
+ messageTextId += "Mac";
+ } else if (AppConstants.platform == "linux") {
+ messageTextId += "Lin";
+ } else {
+ messageTextId += "Win";
+ }
+
+ let messageText = this.bundle.GetStringFromName(messageTextId);
+
+ const brandBundle = Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+ let win = target.ownerGlobal.docShell.chromeEventHandler.ownerGlobal;
+ let loggedIn = await lazy.OSKeyStore.ensureLoggedIn(
+ messageText,
+ brandBundle.GetStringFromName("brandFullName"),
+ win,
+ false
+ );
+ if (!loggedIn.authenticated) {
+ target.checked = !target.checked;
+ break;
+ }
+
+ Services.prefs.setBoolPref(
+ ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF,
+ target.checked
+ );
+ } else if (target == this.refs.savedAddressesBtn) {
+ target.ownerGlobal.gSubDialog.open(MANAGE_ADDRESSES_URL);
+ } else if (target == this.refs.savedCreditCardsBtn) {
+ target.ownerGlobal.gSubDialog.open(MANAGE_CREDITCARDS_URL);
+ }
+ break;
+ }
+ case "click": {
+ let target = event.target;
+
+ if (target == this.refs.addressAutofillCheckboxLabel) {
+ let pref = FormAutofill.isAutofillAddressesEnabled;
+ Services.prefs.setBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF, !pref);
+ this.refs.addressAutofillCheckbox.checked = !pref;
+ } else if (target == this.refs.creditCardAutofillCheckboxLabel) {
+ let pref = FormAutofill.isAutofillCreditCardsEnabled;
+ Services.prefs.setBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF, !pref);
+ this.refs.creditCardAutofillCheckbox.checked = !pref;
+ this.refs.reauthCheckbox.disabled = pref;
+ }
+ break;
+ }
+ }
+ },
+
+ /**
+ * Attach event listener
+ */
+ attachEventListeners() {
+ this.refs.formAutofillGroup.addEventListener("command", this);
+ this.refs.formAutofillGroup.addEventListener("click", this);
+ },
+
+ /**
+ * Remove event listener
+ */
+ detachEventListeners() {
+ this.refs.formAutofillGroup.removeEventListener("command", this);
+ this.refs.formAutofillGroup.removeEventListener("click", this);
+ },
+};
diff --git a/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs b/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs
new file mode 100644
index 0000000000..591bfc1578
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs
@@ -0,0 +1,2134 @@
+/* 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/. */
+
+/*
+ * Interface for the storage of Form Autofill.
+ *
+ * The data is stored in JSON format, without indentation and the computed
+ * fields, using UTF-8 encoding. With indentation and computed fields applied,
+ * the schema would look like this:
+ *
+ * {
+ * version: 1,
+ * addresses: [
+ * {
+ * guid, // 12 characters
+ * version, // schema version in integer
+ *
+ * // address fields
+ * given-name,
+ * additional-name,
+ * family-name,
+ * name,
+ * organization, // Company
+ * street-address, // (Multiline)
+ * address-level3, // Suburb/Sublocality
+ * address-level2, // City/Town
+ * address-level1, // Province (Standardized code if possible)
+ * postal-code,
+ * country, // ISO 3166
+ * tel, // Stored in E.164 format
+ * email,
+ *
+ * // computed fields (These fields are computed based on the above fields
+ * // and are not allowed to be modified directly.)
+ * given-name,
+ * additional-name,
+ * family-name,
+ * address-line1,
+ * address-line2,
+ * address-line3,
+ * country-name,
+ * tel-country-code,
+ * tel-national,
+ * tel-area-code,
+ * tel-local,
+ * tel-local-prefix,
+ * tel-local-suffix,
+ *
+ * // metadata
+ * timeCreated, // in ms
+ * timeLastUsed, // in ms
+ * timeLastModified, // in ms
+ * timesUsed,
+ * _sync: { ... optional sync metadata },
+ * ...unknown fields // We keep fields we don't understand/expect from other clients
+ * // to prevent data loss for other clients, we roundtrip them for sync
+ * }
+ * ],
+ * creditCards: [
+ * {
+ * guid, // 12 characters
+ * version, // schema version in integer
+ *
+ * // credit card fields
+ * billingAddressGUID, // An optional GUID of an autofill address record
+ * which may or may not exist locally.
+ *
+ * cc-name,
+ * cc-number, // will be stored in masked format (************1234)
+ * // (see details below)
+ * cc-exp-month,
+ * cc-exp-year, // 2-digit year will be converted to 4 digits
+ * // upon saving
+ * cc-type, // Optional card network id (instrument type)
+ *
+ * // computed fields (These fields are computed based on the above fields
+ * // and are not allowed to be modified directly.)
+ * cc-given-name,
+ * cc-additional-name,
+ * cc-family-name,
+ * cc-number-encrypted, // encrypted from the original unmasked "cc-number"
+ * // (see details below)
+ * cc-exp,
+ *
+ * // metadata
+ * timeCreated, // in ms
+ * timeLastUsed, // in ms
+ * timeLastModified, // in ms
+ * timesUsed,
+ * _sync: { ... optional sync metadata },
+ * ...unknown fields // We keep fields we don't understand/expect from other clients
+ * // to prevent data loss for other clients, we roundtrip them for sync
+ * }
+ * ]
+ * }
+ *
+ *
+ * Encrypt-related Credit Card Fields (cc-number & cc-number-encrypted):
+ *
+ * When saving or updating a credit-card record, the storage will encrypt the
+ * value of "cc-number", store the encrypted number in "cc-number-encrypted"
+ * field, and replace "cc-number" field with the masked number. These all happen
+ * in "computeFields". We do reverse actions in "_stripComputedFields", which
+ * decrypts "cc-number-encrypted", restores it to "cc-number", and deletes
+ * "cc-number-encrypted". Therefore, calling "_stripComputedFields" followed by
+ * "computeFields" can make sure the encrypt-related fields are up-to-date.
+ *
+ * In general, you have to decrypt the number by your own outside FormAutofillStorage
+ * when necessary. However, you will get the decrypted records when querying
+ * data with "rawData=true" to ensure they're ready to sync.
+ *
+ *
+ * Sync Metadata:
+ *
+ * Records may also have a _sync field, which consists of:
+ * {
+ * changeCounter, // integer - the number of changes made since the last
+ * // sync.
+ * lastSyncedFields, // object - hashes of the original values for fields
+ * // changed since the last sync.
+ * }
+ *
+ * Records with such a field have previously been synced. Records without such
+ * a field are yet to be synced, so are treated specially in some cases (eg,
+ * they don't need a tombstone, de-duping logic treats them as special etc).
+ * Records without the field are always considered "dirty" from Sync's POV
+ * (meaning they will be synced on the next sync), at which time they will gain
+ * this new field.
+ */
+
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AutofillTelemetry: "resource://autofill/AutofillTelemetry.sys.mjs",
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ CreditCardRecord: "resource://gre/modules/shared/CreditCardRecord.sys.mjs",
+ FormAutofillNameUtils:
+ "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+ PhoneNumber: "resource://autofill/phonenumberutils/PhoneNumber.sys.mjs",
+});
+
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+
+const STORAGE_SCHEMA_VERSION = 1;
+
+// NOTE: It's likely this number can never change.
+// Please talk to the sync team before changing this!
+// (And if it did ever change, it must never be "4" due to the reconcile hacks
+// below which repairs credit-cards with version=4)
+export const ADDRESS_SCHEMA_VERSION = 1;
+
+// Version 2: Bug 1486954 - Encrypt `cc-number`
+// Version 3: Bug 1639795 - Update keystore name
+// Version 4: (deprecated!!! See Bug 1812235): Bug 1667257 - Do not store `cc-type` field
+// Next version should be 5
+// NOTE: It's likely this number can never change.
+// Please talk to the sync team before changing this!
+export const CREDIT_CARD_SCHEMA_VERSION = 3;
+
+const NAME_COMPONENTS = ["given-name", "additional-name", "family-name"];
+
+const STREET_ADDRESS_COMPONENTS = [
+ "address-line1",
+ "address-line2",
+ "address-line3",
+];
+
+const TEL_COMPONENTS = [
+ "tel-country-code",
+ "tel-national",
+ "tel-area-code",
+ "tel-local",
+ "tel-local-prefix",
+ "tel-local-suffix",
+];
+
+const VALID_ADDRESS_FIELDS = [
+ "name",
+ "organization",
+ "street-address",
+ "address-level3",
+ "address-level2",
+ "address-level1",
+ "postal-code",
+ "country",
+ "tel",
+ "email",
+];
+
+const VALID_ADDRESS_COMPUTED_FIELDS = [
+ "country-name",
+ ...NAME_COMPONENTS,
+ ...STREET_ADDRESS_COMPONENTS,
+ ...TEL_COMPONENTS,
+];
+
+const VALID_CREDIT_CARD_FIELDS = [
+ "billingAddressGUID",
+ "cc-name",
+ "cc-number",
+ "cc-exp-month",
+ "cc-exp-year",
+ "cc-type",
+];
+
+const VALID_CREDIT_CARD_COMPUTED_FIELDS = [
+ "cc-given-name",
+ "cc-additional-name",
+ "cc-family-name",
+ "cc-number-encrypted",
+ "cc-exp",
+];
+
+const INTERNAL_FIELDS = [
+ "guid",
+ "version",
+ "timeCreated",
+ "timeLastUsed",
+ "timeLastModified",
+ "timesUsed",
+];
+
+function sha512(string) {
+ if (string == null) {
+ return null;
+ }
+ let encoder = new TextEncoder();
+ let bytes = encoder.encode(string);
+ let hash = new CryptoHash("sha512");
+ hash.update(bytes, bytes.length);
+ return hash.finish(/* base64 */ true);
+}
+
+/**
+ * Class that manipulates records in a specified collection.
+ *
+ * Note that it is responsible for converting incoming data to a consistent
+ * format in the storage. For example, computed fields will be transformed to
+ * the original fields and 2-digit years will be calculated into 4 digits.
+ */
+class AutofillRecords {
+ /**
+ * Creates an AutofillRecords.
+ *
+ * @param {JSONFile} store
+ * An instance of JSONFile.
+ * @param {string} collectionName
+ * A key of "store.data".
+ * @param {Array.<string>} validFields
+ * A list containing non-metadata field names.
+ * @param {Array.<string>} validComputedFields
+ * A list containing computed field names.
+ * @param {number} schemaVersion
+ * The schema version for the new record.
+ */
+ constructor(
+ store,
+ collectionName,
+ validFields,
+ validComputedFields,
+ schemaVersion
+ ) {
+ this.log = FormAutofill.defineLogGetter(
+ lazy,
+ "AutofillRecords:" + collectionName
+ );
+
+ this.VALID_FIELDS = validFields;
+ this.VALID_COMPUTED_FIELDS = validComputedFields;
+
+ this._store = store;
+ this._collectionName = collectionName;
+ this._schemaVersion = schemaVersion;
+
+ this._initialize();
+
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+ }
+
+ _initialize() {
+ this._initializePromise = Promise.all(
+ this._data.map(async (record, index) =>
+ this._migrateRecord(record, index)
+ )
+ ).then(hasChangesArr => {
+ let dataHasChanges = hasChangesArr.includes(true);
+ if (dataHasChanges) {
+ this._store.saveSoon();
+ }
+ });
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "formautofill-storage-changed":
+ let collectionName = subject.wrappedJSObject.collectionName;
+ if (collectionName != this._collectionName) {
+ return;
+ }
+ const telemetryType =
+ subject.wrappedJSObject.collectionName == "creditCards"
+ ? lazy.AutofillTelemetry.CREDIT_CARD
+ : lazy.AutofillTelemetry.ADDRESS;
+ const count = this._data.filter(entry => !entry.deleted).length;
+ lazy.AutofillTelemetry.recordAutofillProfileCount(telemetryType, count);
+ break;
+ }
+ }
+
+ /**
+ * Gets the schema version number.
+ *
+ * @returns {number}
+ * The current schema version number.
+ */
+ get version() {
+ return this._schemaVersion;
+ }
+
+ /**
+ * Gets the data of this collection.
+ *
+ * @returns {Array}
+ * The data object.
+ */
+ get _data() {
+ return this._getData();
+ }
+
+ _getData() {
+ return this._store.data[this._collectionName];
+ }
+
+ // Ensures that we don't try to apply synced records with newer schema
+ // versions. This is a temporary measure to ensure we don't accidentally
+ // bump the schema version without a syncing strategy in place (bug 1377204).
+ _ensureMatchingVersion(record) {
+ if (record.version != this.version) {
+ throw new Error(
+ `Got unknown record version ${record.version}; want ${this.version}`
+ );
+ }
+ }
+
+ /**
+ * Initialize the records in the collection, resolves when the migration completes.
+ *
+ * @returns {Promise}
+ */
+ initialize() {
+ return this._initializePromise;
+ }
+
+ /**
+ * Adds a new record.
+ *
+ * @param {object} record
+ * The new record for saving.
+ * @param {object} options
+ * @param {boolean} [options.sourceSync = false]
+ * Did sync generate this addition?
+ * @returns {Promise<string>}
+ * The GUID of the newly added item..
+ */
+ async add(record, { sourceSync = false } = {}) {
+ let recordToSave = this._clone(record);
+
+ if (sourceSync) {
+ // Remove tombstones for incoming items that were changed on another
+ // device. Local deletions always lose to avoid data loss.
+ let index = this._findIndexByGUID(recordToSave.guid, {
+ includeDeleted: true,
+ });
+ if (index > -1) {
+ let existing = this._data[index];
+ if (existing.deleted) {
+ this._data.splice(index, 1);
+ } else {
+ throw new Error(`Record ${recordToSave.guid} already exists`);
+ }
+ }
+ } else if (!recordToSave.deleted) {
+ this._normalizeRecord(recordToSave);
+ // _normalizeRecord shouldn't do any validation (throw) because in the
+ // `update` case it is called with partial records whereas
+ // `_validateFields` is called with a complete one.
+ this._validateFields(recordToSave);
+
+ recordToSave.guid = this._generateGUID();
+ recordToSave.version = this.version;
+
+ // Metadata
+ let now = Date.now();
+ recordToSave.timeCreated = now;
+ recordToSave.timeLastModified = now;
+ recordToSave.timeLastUsed = 0;
+ recordToSave.timesUsed = 0;
+ }
+
+ return this._saveRecord(recordToSave, { sourceSync });
+ }
+
+ async _saveRecord(record, { sourceSync = false } = {}) {
+ if (!record.guid) {
+ throw new Error("Record missing GUID");
+ }
+
+ let recordToSave;
+ if (record.deleted) {
+ if (this._findByGUID(record.guid, { includeDeleted: true })) {
+ throw new Error("a record with this GUID already exists");
+ }
+ recordToSave = {
+ guid: record.guid,
+ timeLastModified: record.timeLastModified || Date.now(),
+ deleted: true,
+ };
+ } else {
+ this._ensureMatchingVersion(record);
+ recordToSave = record;
+ await this.computeFields(recordToSave);
+ }
+
+ if (sourceSync) {
+ let sync = this._getSyncMetaData(recordToSave, true);
+ sync.changeCounter = 0;
+ }
+
+ this._data.push(recordToSave);
+
+ this.updateUseCountTelemetry();
+
+ this._store.saveSoon();
+
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ sourceSync,
+ guid: record.guid,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "add"
+ );
+ return recordToSave.guid;
+ }
+
+ _generateGUID() {
+ let guid;
+ while (!guid || this._findByGUID(guid)) {
+ guid = Services.uuid
+ .generateUUID()
+ .toString()
+ .replace(/[{}-]/g, "")
+ .substring(0, 12);
+ }
+ return guid;
+ }
+
+ /**
+ * Update the specified record.
+ *
+ * @param {string} guid
+ * Indicates which record to update.
+ * @param {object} record
+ * The new record used to overwrite the old one.
+ * @param {Promise<boolean>} [preserveOldProperties = false]
+ * Preserve old record's properties if they don't exist in new record.
+ */
+ async update(guid, record, preserveOldProperties = false) {
+ this.log.debug(`update: ${guid}`);
+
+ let recordFoundIndex = this._findIndexByGUID(guid);
+ if (recordFoundIndex == -1) {
+ throw new Error("No matching record.");
+ }
+
+ // Clone the record before modifying it to avoid exposing incomplete changes.
+ let recordFound = this._clone(this._data[recordFoundIndex]);
+ await this._stripComputedFields(recordFound);
+
+ let recordToUpdate = this._clone(record);
+ this._normalizeRecord(recordToUpdate, true);
+
+ let hasValidField = false;
+ for (let field of this.VALID_FIELDS) {
+ let oldValue = recordFound[field];
+ let newValue = recordToUpdate[field];
+
+ // Resume the old field value in the perserve case
+ if (preserveOldProperties && newValue === undefined) {
+ newValue = oldValue;
+ }
+
+ if (newValue === undefined || newValue === "") {
+ delete recordFound[field];
+ } else {
+ hasValidField = true;
+ recordFound[field] = newValue;
+ }
+
+ this._maybeStoreLastSyncedField(recordFound, field, oldValue);
+ }
+
+ if (!hasValidField) {
+ throw new Error("Record contains no valid field.");
+ }
+
+ // _normalizeRecord above is called with the `record` argument provided to
+ // `update` which may not contain all resulting fields when
+ // `preserveOldProperties` is used. This means we need to validate for
+ // missing fields after we compose the record (`recordFound`) with the stored
+ // record like we do in the loop above.
+ this._validateFields(recordFound);
+
+ recordFound.timeLastModified = Date.now();
+ let syncMetadata = this._getSyncMetaData(recordFound);
+ if (syncMetadata) {
+ syncMetadata.changeCounter += 1;
+ }
+
+ await this.computeFields(recordFound);
+ this._data[recordFoundIndex] = recordFound;
+
+ this._store.saveSoon();
+
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ guid,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "update"
+ );
+ }
+
+ /**
+ * Notifies the storage of the use of the specified record, so we can update
+ * the metadata accordingly. This does not bump the Sync change counter, since
+ * we don't sync `timesUsed` or `timeLastUsed`.
+ *
+ * @param {string} guid
+ * Indicates which record to be notified.
+ */
+ notifyUsed(guid) {
+ this.log.debug("notifyUsed:", guid);
+
+ let recordFound = this._findByGUID(guid);
+ if (!recordFound) {
+ throw new Error("No matching record.");
+ }
+
+ recordFound.timesUsed++;
+ recordFound.timeLastUsed = Date.now();
+
+ this.updateUseCountTelemetry();
+
+ this._store.saveSoon();
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ guid,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "notifyUsed"
+ );
+ }
+
+ updateUseCountTelemetry() {
+ const telemetryType =
+ this._collectionName == "creditCards"
+ ? lazy.AutofillTelemetry.CREDIT_CARD
+ : lazy.AutofillTelemetry.ADDRESS;
+ let records = this._data.filter(r => !r.deleted);
+ lazy.AutofillTelemetry.recordNumberOfUse(telemetryType, records);
+ }
+
+ /**
+ * Removes the specified record. No error occurs if the record isn't found.
+ *
+ * @param {string} guid
+ * Indicates which record to remove.
+ * @param {object} options
+ * @param {boolean} [options.sourceSync = false]
+ * Did Sync generate this removal?
+ */
+ remove(guid, { sourceSync = false } = {}) {
+ this.log.debug("remove:", guid);
+
+ if (sourceSync) {
+ this._removeSyncedRecord(guid);
+ } else {
+ let index = this._findIndexByGUID(guid, { includeDeleted: false });
+ if (index == -1) {
+ this.log.warn("attempting to remove non-existing entry", guid);
+ return;
+ }
+ let existing = this._data[index];
+ if (existing.deleted) {
+ return; // already a tombstone - don't touch it.
+ }
+ let existingSync = this._getSyncMetaData(existing);
+ if (existingSync) {
+ // existing sync metadata means it has been synced. This means we must
+ // leave a tombstone behind.
+ this._data[index] = {
+ guid,
+ timeLastModified: Date.now(),
+ deleted: true,
+ _sync: existingSync,
+ };
+ existingSync.changeCounter++;
+ } else {
+ // If there's no sync meta-data, this record has never been synced, so
+ // we can delete it.
+ this._data.splice(index, 1);
+ }
+ }
+
+ this.updateUseCountTelemetry();
+
+ this._store.saveSoon();
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ sourceSync,
+ guid,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "remove"
+ );
+ }
+
+ /**
+ * Returns the record with the specified GUID.
+ *
+ * @param {string} guid
+ * Indicates which record to retrieve.
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns a raw record without modifications and the computed fields
+ * (this includes private fields)
+ * @returns {Promise<object>}
+ * A clone of the record.
+ */
+ async get(guid, { rawData = false } = {}) {
+ this.log.debug(`get: ${guid}`);
+
+ let recordFound = this._findByGUID(guid);
+ if (!recordFound) {
+ return null;
+ }
+
+ // The record is cloned to avoid accidental modifications from outside.
+ let clonedRecord = this._cloneAndCleanUp(recordFound);
+ if (rawData) {
+ // The *-name fields, previously listed in VALID_FIELDS, have been moved to
+ // COMPUTED_FIELDS. By default, the sync payload includes only those fields in VALID_FIELDS.
+ // Excluding *-name fields from the sync payload would prevent older devices from
+ // synchronizing with newer devices. To maintain backward compatibility, keep those deprecated
+ // ields in the payload, ensuring that older devices can still sync with newer devices.
+ const fieldsToKeep = NAME_COMPONENTS;
+ await this._stripComputedFields(clonedRecord, fieldsToKeep);
+ } else {
+ this._recordReadProcessor(clonedRecord);
+ }
+ return clonedRecord;
+ }
+
+ /**
+ * Returns all records.
+ *
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns raw records without modifications and the computed fields.
+ * @param {boolean} [options.includeDeleted = false]
+ * Also return any tombstone records.
+ * @returns {Promise<Array.<object>>}
+ * An array containing clones of all records.
+ */
+ async getAll({ rawData = false, includeDeleted = false } = {}) {
+ this.log.debug(`getAll. includeDeleted = ${includeDeleted}`);
+
+ let records = this._data.filter(r => !r.deleted || includeDeleted);
+ // Records are cloned to avoid accidental modifications from outside.
+ let clonedRecords = records.map(r => this._cloneAndCleanUp(r));
+ await Promise.all(
+ clonedRecords.map(async record => {
+ if (rawData) {
+ const fieldsToKeep = NAME_COMPONENTS;
+ await this._stripComputedFields(record, fieldsToKeep);
+ } else {
+ this._recordReadProcessor(record);
+ }
+ })
+ );
+ return clonedRecords;
+ }
+
+ /**
+ * Returns true if the data set is empty. If the `includeDeleted` option is set to true,
+ * it will also consider items that are marked as deleted.
+ *
+ * @param {object} [options={}] options
+ * @param {boolean} [options.includeDeleted = false]
+ * Indicates whether to include deleted items in the check.
+ * @returns {boolean} Returns `true` if the data set is empty, otherwise `false`.
+ */
+ isEmpty({ includeDeleted = false } = {}) {
+ return !this._data.find(r => !r.deleted || includeDeleted);
+ }
+
+ /**
+ * Return all saved field names in the collection.
+ *
+ * @returns {Promise<Set>} Set containing saved field names.
+ */
+ async getSavedFieldNames() {
+ this.log.debug("getSavedFieldNames");
+
+ let records = this._data.filter(r => !r.deleted);
+ records
+ .map(record => this._cloneAndCleanUp(record))
+ .forEach(record => this._recordReadProcessor(record));
+
+ let fieldNames = new Set();
+ for (let record of records) {
+ for (let fieldName of Object.keys(record)) {
+ if (INTERNAL_FIELDS.includes(fieldName) || !record[fieldName]) {
+ continue;
+ }
+ fieldNames.add(fieldName);
+ }
+ }
+
+ return fieldNames;
+ }
+
+ /**
+ * Functions intended to be used in the support of Sync.
+ */
+
+ /**
+ * Stores a hash of the last synced value for a field in a locally updated
+ * record. We use this value to rebuild the shared parent, or base, when
+ * reconciling incoming records that may have changed on another device.
+ *
+ * Storing the hash of the values that we last wrote to the Sync server lets
+ * us determine if a remote change conflicts with a local change. If the
+ * hashes for the base, current local value, and remote value all differ, we
+ * have a conflict.
+ *
+ * These fields are not themselves synced, and will be removed locally as
+ * soon as we have successfully written the record to the Sync server - so
+ * it is expected they will not remain for long, as changes which cause a
+ * last synced field to be written will itself cause a sync.
+ *
+ * We also skip this for updates made by Sync, for internal fields, for
+ * records that haven't been uploaded yet, and for fields which have already
+ * been changed since the last sync.
+ *
+ * @param {object} record
+ * The updated local record.
+ * @param {string} field
+ * The field name.
+ * @param {string} lastSyncedValue
+ * The last synced field value.
+ */
+ _maybeStoreLastSyncedField(record, field, lastSyncedValue) {
+ let sync = this._getSyncMetaData(record);
+ if (!sync) {
+ // The record hasn't been uploaded yet, so we can't end up with merge
+ // conflicts.
+ return;
+ }
+ let alreadyChanged = field in sync.lastSyncedFields;
+ if (alreadyChanged) {
+ // This field was already changed multiple times since the last sync.
+ return;
+ }
+ let newValue = record[field];
+ if (lastSyncedValue != newValue) {
+ sync.lastSyncedFields[field] = sha512(lastSyncedValue);
+ }
+ }
+
+ /**
+ * Attempts a three-way merge between a changed local record, an incoming
+ * remote record, and the shared parent that we synthesize from the last
+ * synced fields - see _maybeStoreLastSyncedField.
+ *
+ * @param {object} strippedLocalRecord
+ * The changed local record, currently in storage. Computed fields
+ * are stripped.
+ * @param {object} remoteRecord
+ * The remote record.
+ * @returns {object | null}
+ * The merged record, or `null` if there are conflicts and the
+ * records can't be merged.
+ */
+ _mergeSyncedRecords(strippedLocalRecord, remoteRecord) {
+ let sync = this._getSyncMetaData(strippedLocalRecord, true);
+
+ // Copy all internal fields from the remote record. We'll update their
+ // values in `_replaceRecordAt`.
+ let mergedRecord = {};
+ for (let field of INTERNAL_FIELDS) {
+ if (remoteRecord[field] != null) {
+ mergedRecord[field] = remoteRecord[field];
+ }
+ }
+
+ for (let field of this.VALID_FIELDS) {
+ let isLocalSame = false;
+ let isRemoteSame = false;
+ if (field in sync.lastSyncedFields) {
+ // If the field has changed since the last sync, compare hashes to
+ // determine if the local and remote values are different. Hashing is
+ // expensive, but we don't expect this to happen frequently.
+ let lastSyncedValue = sync.lastSyncedFields[field];
+ isLocalSame = lastSyncedValue == sha512(strippedLocalRecord[field]);
+ isRemoteSame = lastSyncedValue == sha512(remoteRecord[field]);
+ } else {
+ // Otherwise, if the field hasn't changed since the last sync, we know
+ // it's the same locally.
+ isLocalSame = true;
+ isRemoteSame = strippedLocalRecord[field] == remoteRecord[field];
+ }
+
+ let value;
+ if (isLocalSame && isRemoteSame) {
+ // Local and remote are the same; doesn't matter which one we pick.
+ value = strippedLocalRecord[field];
+ } else if (isLocalSame && !isRemoteSame) {
+ value = remoteRecord[field];
+ } else if (!isLocalSame && isRemoteSame) {
+ // We don't need to bump the change counter when taking the local
+ // change, because the counter must already be > 0 if we're attempting
+ // a three-way merge.
+ value = strippedLocalRecord[field];
+ } else if (strippedLocalRecord[field] == remoteRecord[field]) {
+ // Shared parent doesn't match either local or remote, but the values
+ // are identical, so there's no conflict.
+ value = strippedLocalRecord[field];
+ } else {
+ // Both local and remote changed to different values. We'll need to fork
+ // the local record to resolve the conflict.
+ return null;
+ }
+
+ if (value != null) {
+ mergedRecord[field] = value;
+ }
+ }
+
+ // When merging records, we shouldn't persist any unknown fields on the local and instead
+ // rely on the remote for unknown fields, so we filter the fields we know and keep the rest
+ Object.keys(remoteRecord)
+ .filter(
+ key =>
+ !this.VALID_FIELDS.includes(key) && !INTERNAL_FIELDS.includes(key)
+ )
+ .forEach(key => (mergedRecord[key] = remoteRecord[key]));
+ return mergedRecord;
+ }
+
+ /**
+ * Replaces a local record with a remote or merged record, copying internal
+ * fields and Sync metadata.
+ *
+ * @param {number} index
+ * @param {object} remoteRecord
+ * @param {object} options
+ * @param {Promise<boolean>} [options.keepSyncMetadata = false]
+ * Should we copy Sync metadata? This is true if `remoteRecord` is a
+ * merged record with local changes that we need to upload. Passing
+ * `keepSyncMetadata` retains the record's change counter and
+ * last synced fields, so that we don't clobber the local change if
+ * the sync is interrupted after the record is merged, but before
+ * it's uploaded.
+ */
+ async _replaceRecordAt(
+ index,
+ remoteRecord,
+ { keepSyncMetadata = false } = {}
+ ) {
+ let localRecord = this._data[index];
+ let newRecord = this._clone(remoteRecord);
+
+ await this._stripComputedFields(newRecord);
+
+ this._data[index] = newRecord;
+
+ if (keepSyncMetadata) {
+ // It's safe to move the Sync metadata from the old record to the new
+ // record, since we always clone records when we return them, and we
+ // never hand out references to the metadata object via public methods.
+ newRecord._sync = localRecord._sync;
+ } else {
+ // As a side effect, `_getSyncMetaData` marks the record as syncing if the
+ // existing `localRecord` is a dupe of `remoteRecord`, and we're replacing
+ // local with remote.
+ let sync = this._getSyncMetaData(newRecord, true);
+ sync.changeCounter = 0;
+ }
+
+ if (
+ !newRecord.timeCreated ||
+ localRecord.timeCreated < newRecord.timeCreated
+ ) {
+ newRecord.timeCreated = localRecord.timeCreated;
+ }
+
+ if (
+ !newRecord.timeLastModified ||
+ localRecord.timeLastModified > newRecord.timeLastModified
+ ) {
+ newRecord.timeLastModified = localRecord.timeLastModified;
+ }
+
+ // Copy local-only fields from the existing local record.
+ for (let field of ["timeLastUsed", "timesUsed"]) {
+ if (localRecord[field] != null) {
+ newRecord[field] = localRecord[field];
+ }
+ }
+
+ await this.computeFields(newRecord);
+ }
+
+ /**
+ * Clones a local record, giving the clone a new GUID and Sync metadata. The
+ * original record remains unchanged in storage.
+ *
+ * @param {object} strippedLocalRecord
+ * The local record. Computed fields are stripped.
+ * @returns {string}
+ * A clone of the local record with a new GUID.
+ */
+ async _forkLocalRecord(strippedLocalRecord) {
+ let forkedLocalRecord = this._cloneAndCleanUp(strippedLocalRecord);
+ forkedLocalRecord.guid = this._generateGUID();
+
+ // Give the record fresh Sync metadata and bump its change counter as a
+ // side effect. This also excludes the forked record from de-duping on the
+ // next sync, if the current sync is interrupted before the record can be
+ // uploaded.
+ this._getSyncMetaData(forkedLocalRecord, true);
+
+ await this.computeFields(forkedLocalRecord);
+ this._data.push(forkedLocalRecord);
+
+ return forkedLocalRecord;
+ }
+
+ /**
+ * Reconciles an incoming remote record into the matching local record. This
+ * method is only used by Sync; other callers should use `merge`.
+ *
+ * @param {object} remoteRecord
+ * The incoming record. `remoteRecord` must not be a tombstone, and
+ * must have a matching local record with the same GUID. Use
+ * `add` to insert remote records that don't exist locally, and
+ * `remove` to apply remote tombstones.
+ * @returns {Promise<object>}
+ * A `{forkedGUID}` tuple. `forkedGUID` is `null` if the merge
+ * succeeded without conflicts, or a new GUID referencing the
+ * existing locally modified record if the conflicts could not be
+ * resolved.
+ */
+ async reconcile(remoteRecord) {
+ this._ensureMatchingVersion(remoteRecord);
+ if (remoteRecord.deleted) {
+ throw new Error(`Can't reconcile tombstone ${remoteRecord.guid}`);
+ }
+
+ let localIndex = this._findIndexByGUID(remoteRecord.guid);
+ if (localIndex < 0) {
+ throw new Error(`Record ${remoteRecord.guid} not found`);
+ }
+
+ let localRecord = this._data[localIndex];
+ let sync = this._getSyncMetaData(localRecord, true);
+
+ let forkedGUID = null;
+
+ // NOTE: This implies a credit-card - so it's critical ADDRESS_SCHEMA_VERSION
+ // never equals 4 while this code exists!
+ let requiresForceUpdate =
+ localRecord.version != remoteRecord.version && remoteRecord.version == 4;
+
+ if (requiresForceUpdate) {
+ // Another desktop device that is still using version=4 has created or
+ // modified a remote record. Here we downgrade it to version=3 so we can
+ // treat it normally, then cause it to be re-uploaded so other desktop
+ // or mobile devices can still see it.
+ // That device still using version=4 *will* again see it, and again
+ // upgrade it, but thankfully that 3->4 migration doesn't force a reupload
+ // of all records, or we'd be going back and forward on every sync.
+ // Once that version=4 device gets updated to roll back to version=3, it
+ // will then yet again re-upload it, this time with version=3, but the
+ // content will be the same here, so everything should work out in the end.
+ //
+ // If we just ignored this incoming record, it would remain on the server
+ // with version=4. If the device that wrote that went away (ie, never
+ // synced again) nothing would ever repair it back to 3, which would
+ // be bad because mobile would remain broken until the user edited the
+ // card somewhere.
+ remoteRecord = await this._computeMigratedRecord(remoteRecord);
+ }
+ if (sync.changeCounter === 0) {
+ // Local not modified. Replace local with remote.
+ await this._replaceRecordAt(localIndex, remoteRecord, {
+ keepSyncMetadata: false,
+ });
+ } else {
+ let strippedLocalRecord = this._clone(localRecord);
+ await this._stripComputedFields(strippedLocalRecord);
+
+ let mergedRecord = this._mergeSyncedRecords(
+ strippedLocalRecord,
+ remoteRecord
+ );
+ if (mergedRecord) {
+ // Local and remote modified, but we were able to merge. Replace the
+ // local record with the merged record.
+ await this._replaceRecordAt(localIndex, mergedRecord, {
+ keepSyncMetadata: true,
+ });
+ } else {
+ // Merge conflict. Fork the local record, then replace the original
+ // with the merged record.
+ let forkedLocalRecord = await this._forkLocalRecord(
+ strippedLocalRecord
+ );
+ forkedGUID = forkedLocalRecord.guid;
+ await this._replaceRecordAt(localIndex, remoteRecord, {
+ keepSyncMetadata: false,
+ });
+ }
+ }
+
+ if (requiresForceUpdate) {
+ // The incoming record was version=4 and we want to re-upload it as version=3.
+ // We need to reach directly into self._data[] so we can poke at the
+ // sync metadata directly.
+ let indexToUpdate = this._findIndexByGUID(remoteRecord.guid);
+ let toUpdate = this._data[indexToUpdate];
+ this._getSyncMetaData(toUpdate, true).changeCounter += 1;
+ this.log.info(
+ `Flagging record ${toUpdate.guid} for re-upload after record version downgrade`
+ );
+ }
+
+ this._store.saveSoon();
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ sourceSync: true,
+ guid: remoteRecord.guid,
+ forkedGUID,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "reconcile"
+ );
+
+ return { forkedGUID };
+ }
+
+ _removeSyncedRecord(guid) {
+ let index = this._findIndexByGUID(guid, { includeDeleted: true });
+ if (index == -1) {
+ // Removing a record we don't know about. It may have been synced and
+ // removed by another device before we saw it. Store the tombstone in
+ // case the server is later wiped and we need to reupload everything.
+ let tombstone = {
+ guid,
+ timeLastModified: Date.now(),
+ deleted: true,
+ };
+
+ let sync = this._getSyncMetaData(tombstone, true);
+ sync.changeCounter = 0;
+ this._data.push(tombstone);
+ return;
+ }
+
+ let existing = this._data[index];
+ let sync = this._getSyncMetaData(existing, true);
+ if (sync.changeCounter > 0) {
+ // Deleting a record with unsynced local changes. To avoid potential
+ // data loss, we ignore the deletion in favor of the changed record.
+ this.log.info(
+ "Ignoring deletion for record with local changes",
+ existing
+ );
+ return;
+ }
+
+ if (existing.deleted) {
+ this.log.info("Ignoring deletion for tombstone", existing);
+ return;
+ }
+
+ // Removing a record that's not changed locally, and that's not already
+ // deleted. Replace the record with a synced tombstone.
+ this._data[index] = {
+ guid,
+ timeLastModified: Date.now(),
+ deleted: true,
+ _sync: sync,
+ };
+ }
+
+ /**
+ * Provide an object that describes the changes to sync.
+ *
+ * This is called at the start of the sync process to determine what needs
+ * to be updated on the server. As the server is updated, sync will update
+ * entries in the returned object, and when sync is complete it will pass
+ * the object to pushSyncChanges, which will apply the changes to the store.
+ *
+ * @returns {object}
+ * An object describing the changes to sync.
+ */
+ pullSyncChanges() {
+ let changes = {};
+
+ let profiles = this._data;
+ for (let profile of profiles) {
+ let sync = this._getSyncMetaData(profile, true);
+ if (sync.changeCounter < 1) {
+ if (sync.changeCounter != 0) {
+ this.log.error("negative change counter", profile);
+ }
+ continue;
+ }
+ changes[profile.guid] = {
+ profile,
+ counter: sync.changeCounter,
+ modified: profile.timeLastModified,
+ synced: false,
+ };
+ }
+ this._store.saveSoon();
+
+ return changes;
+ }
+
+ /**
+ * Apply the metadata changes made by Sync.
+ *
+ * This is called with metadata about what was synced - see pullSyncChanges.
+ *
+ * @param {object} changes
+ * The possibly modified object obtained via pullSyncChanges.
+ */
+ pushSyncChanges(changes) {
+ for (let [guid, { counter, synced }] of Object.entries(changes)) {
+ if (!synced) {
+ continue;
+ }
+ let recordFound = this._findByGUID(guid, { includeDeleted: true });
+ if (!recordFound) {
+ this.log.warn("No profile found to persist changes for guid " + guid);
+ continue;
+ }
+ let sync = this._getSyncMetaData(recordFound, true);
+ sync.changeCounter = Math.max(0, sync.changeCounter - counter);
+ if (sync.changeCounter === 0) {
+ // Clear the shared parent fields once we've uploaded all pending
+ // changes, since the server now matches what we have locally.
+ sync.lastSyncedFields = {};
+ }
+ }
+ this._store.saveSoon();
+ }
+
+ /**
+ * Reset all sync metadata for all items.
+ *
+ * This is called when Sync is disconnected from this device. All sync
+ * metadata for all items is removed.
+ */
+ resetSync() {
+ for (let record of this._data) {
+ delete record._sync;
+ }
+ // XXX - we should probably also delete all tombstones?
+ this.log.info("All sync metadata was reset");
+ }
+
+ /**
+ * Changes the GUID of an item. This should be called only by Sync. There
+ * must be an existing record with oldID and it must never have been synced
+ * or an error will be thrown. There must be no existing record with newID.
+ *
+ * No tombstone will be created for the old GUID - we check it hasn't
+ * been synced, so no tombstone is necessary.
+ *
+ * @param {string} oldID
+ * GUID of the existing item to change the GUID of.
+ * @param {string} newID
+ * The new GUID for the item.
+ */
+ changeGUID(oldID, newID) {
+ this.log.debug("changeGUID: ", oldID, newID);
+ if (oldID == newID) {
+ throw new Error("changeGUID: old and new IDs are the same");
+ }
+ if (this._findIndexByGUID(newID) >= 0) {
+ throw new Error("changeGUID: record with destination id exists already");
+ }
+
+ let index = this._findIndexByGUID(oldID);
+ let profile = this._data[index];
+ if (!profile) {
+ throw new Error("changeGUID: no source record");
+ }
+ if (this._getSyncMetaData(profile)) {
+ throw new Error("changeGUID: existing record has already been synced");
+ }
+
+ profile.guid = newID;
+
+ this._store.saveSoon();
+ }
+
+ // Used to get, and optionally create, sync metadata. Brand new records will
+ // *not* have sync meta-data - it will be created when they are first
+ // synced.
+ _getSyncMetaData(record, forceCreate = false) {
+ if (!record._sync && forceCreate) {
+ // create default metadata and indicate we need to save.
+ record._sync = {
+ changeCounter: 1,
+ lastSyncedFields: {},
+ };
+ this._store.saveSoon();
+ }
+ return record._sync;
+ }
+
+ /**
+ * Finds a local record with matching common fields and a different GUID.
+ * Sync uses this method to find and update unsynced local records with
+ * fields that match incoming remote records. This avoids creating
+ * duplicate profiles with the same information.
+ *
+ * @param {object} remoteRecord
+ * The remote record.
+ * @returns {Promise<string|null>}
+ * The GUID of the matching local record, or `null` if no records
+ * match.
+ */
+ async findDuplicateGUID(remoteRecord) {
+ if (!remoteRecord.guid) {
+ throw new Error("Record missing GUID");
+ }
+ this._ensureMatchingVersion(remoteRecord);
+ if (remoteRecord.deleted) {
+ // Tombstones don't carry enough info to de-dupe, and we should have
+ // handled them separately when applying the record.
+ throw new Error("Tombstones can't have duplicates");
+ }
+ let localRecords = this._data;
+ for (let localRecord of localRecords) {
+ if (localRecord.deleted) {
+ continue;
+ }
+ if (localRecord.guid == remoteRecord.guid) {
+ throw new Error(`Record ${remoteRecord.guid} already exists`);
+ }
+ if (this._getSyncMetaData(localRecord)) {
+ // This local record has already been uploaded, so it can't be a dupe of
+ // another incoming item.
+ continue;
+ }
+
+ // Ignore computed fields when matching records as they aren't synced at all.
+ let strippedLocalRecord = this._clone(localRecord);
+ await this._stripComputedFields(strippedLocalRecord);
+
+ let keys = new Set(Object.keys(remoteRecord));
+ for (let key of Object.keys(strippedLocalRecord)) {
+ keys.add(key);
+ }
+ // Ignore internal fields when matching records. Internal fields are synced,
+ // but almost certainly have different values than the local record, and
+ // we'll update them in `reconcile`.
+ for (let field of INTERNAL_FIELDS) {
+ keys.delete(field);
+ }
+ if (!keys.size) {
+ // This shouldn't ever happen; a valid record will always have fields
+ // that aren't computed or internal. Sync can't do anything about that,
+ // so we ignore the dubious local record instead of throwing.
+ continue;
+ }
+ let same = true;
+ for (let key of keys) {
+ // For now, we ensure that both (or neither) records have the field
+ // with matching values. This doesn't account for the version yet
+ // (bug 1377204).
+ same =
+ key in strippedLocalRecord == key in remoteRecord &&
+ strippedLocalRecord[key] == remoteRecord[key];
+ if (!same) {
+ break;
+ }
+ }
+ if (same) {
+ return strippedLocalRecord.guid;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Internal helper functions.
+ */
+
+ _clone(record) {
+ return Object.assign({}, record);
+ }
+
+ _cloneAndCleanUp(record) {
+ let result = {};
+ for (let key in record) {
+ // Do not expose hidden fields and fields with empty value (mainly used
+ // as placeholders of the computed fields).
+ if (!key.startsWith("_") && record[key] !== "") {
+ result[key] = record[key];
+ }
+ }
+ return result;
+ }
+
+ _findByGUID(guid, { includeDeleted = false } = {}) {
+ let found = this._findIndexByGUID(guid, { includeDeleted });
+ return found < 0 ? undefined : this._data[found];
+ }
+
+ _findIndexByGUID(guid, { includeDeleted = false } = {}) {
+ return this._data.findIndex(record => {
+ return record.guid == guid && (!record.deleted || includeDeleted);
+ });
+ }
+
+ async _migrateRecord(record, index) {
+ let hasChanges = false;
+
+ if (record.deleted) {
+ return hasChanges;
+ }
+
+ if (!record.version || isNaN(record.version) || record.version < 1) {
+ this.log.warn("Invalid record version:", record.version);
+
+ // Force to run the migration.
+ record.version = 0;
+ }
+
+ if (this._isMigrationNeeded(record)) {
+ hasChanges = true;
+
+ record = await this._computeMigratedRecord(record);
+
+ if (record.deleted) {
+ // record is deleted by _computeMigratedRecord(),
+ // go ahead and put it in the store.
+ this._data[index] = record;
+ return hasChanges;
+ }
+
+ // Compute the computed fields before putting it to store.
+ await this.computeFields(record);
+ this._data[index] = record;
+
+ return hasChanges;
+ }
+
+ hasChanges |= await this.computeFields(record);
+ return hasChanges;
+ }
+
+ _normalizeRecord(record, preserveEmptyFields = false) {
+ this._normalizeFields(record);
+
+ for (const key in record) {
+ if (!this.VALID_FIELDS.includes(key)) {
+ // Though we allow unknown fields, certain fields are still protected
+ // from being changed
+ if (INTERNAL_FIELDS.includes(key)) {
+ throw new Error(`"${key}" is not a valid field.`);
+ } else {
+ // We shouldn't try to normalize unknown fields. We'll just roundtrip them
+ this.log.warn(`${key} is not a known field. Skipping normalization.`);
+ continue;
+ }
+ }
+ if (typeof record[key] !== "string" && typeof record[key] !== "number") {
+ throw new Error(
+ `"${key}" contains invalid data type: ${typeof record[key]}`
+ );
+ }
+ if (!preserveEmptyFields && record[key] === "") {
+ delete record[key];
+ }
+ }
+
+ const keys = Object.keys(record);
+ // By default we ensure there is always a country field, so if this record
+ // doesn't contain other fields, this is an empty record
+ if (!keys.length || (keys.length == 1 && keys[0] == "country")) {
+ throw new Error("Record contains no valid field.");
+ }
+ }
+
+ /**
+ * Unconditionally remove all data and tombstones for this collection.
+ */
+ removeAll({ sourceSync = false } = {}) {
+ this._store.data[this._collectionName] = [];
+ this._store.saveSoon();
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ sourceSync,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "removeAll"
+ );
+ }
+
+ _isMigrationNeeded(record) {
+ return record.version < this.version;
+ }
+
+ /**
+ * Strip the computed fields based on the record version.
+ *
+ * @param {object} record The record to migrate
+ * @returns {object} Migrated record.
+ * Record is always cloned, with version updated,
+ * with computed fields stripped.
+ * Could be a tombstone record, if the record
+ * should be discorded.
+ */
+ async _computeMigratedRecord(record) {
+ if (!record.deleted) {
+ record = this._clone(record);
+ await this._stripComputedFields(record);
+ record.version = this.version;
+ }
+ return record;
+ }
+
+ async _stripComputedFields(record, fieldsToKeep = []) {
+ for (const field of this.VALID_COMPUTED_FIELDS) {
+ if (fieldsToKeep.includes(field)) {
+ continue;
+ }
+ delete record[field];
+ }
+ }
+
+ // An interface to be inherited.
+ _recordReadProcessor(record) {}
+
+ // An interface to be inherited.
+ async computeFields(record) {}
+
+ /**
+ * An interface to be inherited to mutate the argument to normalize it.
+ *
+ * @param {object} partialRecord containing the record passed by the consumer of
+ * storage and in the case of `update` with
+ * `preserveOldProperties` will only include the
+ * properties that the user is changing so the
+ * lack of a field doesn't mean that the record
+ * won't have that field.
+ */
+ _normalizeFields(partialRecord) {}
+
+ /**
+ * An interface to be inherited to validate that the complete record is
+ * consistent and isn't missing required fields. Overrides should throw for
+ * invalid records.
+ *
+ * @param {object} record containing the complete record that would be stored
+ * if this doesn't throw due to an error.
+ * @throws
+ */
+ _validateFields(record) {}
+
+ // An interface to be inherited.
+ migrateRemoteRecord(remoteRecord) {}
+}
+
+export class AddressesBase extends AutofillRecords {
+ constructor(store) {
+ super(
+ store,
+ "addresses",
+ VALID_ADDRESS_FIELDS,
+ VALID_ADDRESS_COMPUTED_FIELDS,
+ ADDRESS_SCHEMA_VERSION
+ );
+ }
+
+ _recordReadProcessor(address) {
+ if (address.country && !FormAutofill.countries.has(address.country)) {
+ delete address.country;
+ delete address["country-name"];
+ }
+ }
+
+ _isMigrationNeeded(record) {
+ if (super._isMigrationNeeded(record)) {
+ return true;
+ }
+
+ if (
+ !record.name &&
+ (record["given-name"] ||
+ record["additional-name"] ||
+ record["family-name"])
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ async _computeMigratedRecord(address) {
+ // Bug 1836438 - `name` field was moved from computed fields to valid fields.
+ if (
+ !address.name &&
+ (address["given-name"] ||
+ address["additional-name"] ||
+ address["family-name"])
+ ) {
+ address.name = lazy.FormAutofillNameUtils.joinNameParts({
+ given: address["given-name"] ?? "",
+ middle: address["additional-name"] ?? "",
+ family: address["family-name"] ?? "",
+ });
+ }
+
+ return super._computeMigratedRecord(address);
+ }
+
+ async computeFields(address) {
+ // NOTE: Remember to bump the schema version number if any of the existing
+ // computing algorithm changes. (No need to bump when just adding new
+ // computed fields.)
+
+ // NOTE: Computed fields should be always present in the storage no matter
+ // it's empty or not.
+
+ let hasNewComputedFields = false;
+
+ if (address.deleted) {
+ return hasNewComputedFields;
+ }
+
+ // Compute split names
+ if (!("given-name" in address)) {
+ const nameParts = lazy.FormAutofillNameUtils.splitName(address.name);
+ address["given-name"] = nameParts.given;
+ address["additional-name"] = nameParts.middle;
+ address["family-name"] = nameParts.family;
+ hasNewComputedFields = true;
+ }
+
+ // Compute address lines
+ if (!("address-line1" in address)) {
+ let streetAddress = [];
+ if (address["street-address"]) {
+ streetAddress = address["street-address"]
+ .split("\n")
+ .map(s => s.trim());
+ }
+ for (let i = 0; i < 3; i++) {
+ address[`address-line${i + 1}`] = streetAddress[i] || "";
+ }
+ if (streetAddress.length > 3) {
+ address["address-line3"] = lazy.FormAutofillUtils.toOneLineAddress(
+ streetAddress.slice(2)
+ );
+ }
+ hasNewComputedFields = true;
+ }
+
+ // Compute country name
+ if (!("country-name" in address)) {
+ if (address.country) {
+ try {
+ address["country-name"] = Services.intl.getRegionDisplayNames(
+ undefined,
+ [address.country]
+ );
+ } catch (e) {
+ address["country-name"] = "";
+ }
+ } else {
+ address["country-name"] = "";
+ }
+ hasNewComputedFields = true;
+ }
+
+ // Compute tel
+ if (!("tel-national" in address)) {
+ if (address.tel) {
+ let tel = lazy.PhoneNumber.Parse(
+ address.tel,
+ address.country || FormAutofill.DEFAULT_REGION
+ );
+ if (tel) {
+ if (tel.countryCode) {
+ address["tel-country-code"] = tel.countryCode;
+ }
+ if (tel.nationalNumber) {
+ address["tel-national"] = tel.nationalNumber;
+ }
+
+ // PhoneNumberUtils doesn't support parsing the components of a telephone
+ // number so we hard coded the parser for US numbers only. We will need
+ // to figure out how to parse numbers from other regions when we support
+ // new countries in the future.
+ if (tel.nationalNumber && tel.countryCode == "+1") {
+ let telComponents = tel.nationalNumber.match(
+ /(\d{3})((\d{3})(\d{4}))$/
+ );
+ if (telComponents) {
+ address["tel-area-code"] = telComponents[1];
+ address["tel-local"] = telComponents[2];
+ address["tel-local-prefix"] = telComponents[3];
+ address["tel-local-suffix"] = telComponents[4];
+ }
+ }
+ } else {
+ // Treat "tel" as "tel-national" directly if it can't be parsed.
+ address["tel-national"] = address.tel;
+ }
+ }
+
+ TEL_COMPONENTS.forEach(c => {
+ address[c] = address[c] || "";
+ });
+ }
+
+ return hasNewComputedFields;
+ }
+
+ _normalizeFields(address) {
+ this._normalizeCountryFields(address);
+ this._normalizeNameFields(address);
+ this._normalizeAddressFields(address);
+ this._normalizeTelFields(address);
+ }
+
+ _normalizeNameFields(address) {
+ if (
+ !address.name &&
+ (address["given-name"] ||
+ address["additional-name"] ||
+ address["family-name"])
+ ) {
+ address.name = lazy.FormAutofillNameUtils.joinNameParts({
+ given: address["given-name"] ?? "",
+ middle: address["additional-name"] ?? "",
+ family: address["family-name"] ?? "",
+ });
+ }
+
+ delete address["given-name"];
+ delete address["additional-name"];
+ delete address["family-name"];
+ }
+
+ _normalizeAddressFields(address) {
+ if (STREET_ADDRESS_COMPONENTS.some(c => !!address[c])) {
+ // Treat "street-address" as "address-line1" if it contains only one line
+ // and "address-line1" is omitted.
+ if (
+ !address["address-line1"] &&
+ address["street-address"] &&
+ !address["street-address"].includes("\n")
+ ) {
+ address["address-line1"] = address["street-address"];
+ delete address["street-address"];
+ }
+
+ // Concatenate "address-line*" if "street-address" is omitted.
+ if (!address["street-address"]) {
+ address["street-address"] = STREET_ADDRESS_COMPONENTS.map(
+ c => address[c]
+ )
+ .join("\n")
+ .replace(/\n+$/, "");
+ }
+ }
+ STREET_ADDRESS_COMPONENTS.forEach(c => delete address[c]);
+ }
+
+ _normalizeCountryFields(address) {
+ // When we can't identify the country code, it is possible because that the region exists
+ // in regionNames.properties but not in libaddressinput.
+ const country =
+ lazy.FormAutofillUtils.identifyCountryCode(
+ address.country || address["country-name"]
+ ) || address.country;
+
+ // Only values included in the region list will be saved.
+ let hasLocalizedName = false;
+ try {
+ if (country) {
+ let localizedName = Services.intl.getRegionDisplayNames(undefined, [
+ country,
+ ]);
+ hasLocalizedName = localizedName != country;
+ }
+ } catch (e) {}
+
+ if (country && hasLocalizedName) {
+ address.country = country;
+ } else {
+ address.country = FormAutofill.DEFAULT_REGION;
+ }
+
+ delete address["country-name"];
+ }
+
+ _normalizeTelFields(address) {
+ if (address.tel || TEL_COMPONENTS.some(c => !!address[c])) {
+ lazy.FormAutofillUtils.compressTel(address);
+
+ let possibleRegion = address.country || FormAutofill.DEFAULT_REGION;
+ let tel = lazy.PhoneNumber.Parse(address.tel, possibleRegion);
+
+ if (tel && tel.internationalNumber) {
+ // Force to save numbers in E.164 format if parse success.
+ address.tel = tel.internationalNumber;
+ }
+ }
+ TEL_COMPONENTS.forEach(c => delete address[c]);
+ }
+
+ /**
+ * Migrate the remote record to the expected format.
+ *
+ * @param {object} remoteRecord The remote record.
+ */
+ migrateRemoteRecord(remoteRecord) {
+ // When a remote record lacks the `name` field but includes any `*-name` fields, we can
+ // assume that the record originates from an older device. This is because even if an older
+ // device pulls the `name` field from a newer record from the sync server, the `name` field,
+ // being a computed field for an older device, will always be stripped.
+
+ // If the remote record comes from an older device, we compare the `*-name` fields in the
+ // remote record with those in the corresponding local record. If the values of the `*-name`
+ // fields differ, it indicates that the remote record has updated these fields. If the
+ // values are the same, we replace the name field of the remote record with the local
+ // name field to ensure the completeness of the name field when reconciling.
+ //
+ // Here is an example:
+ // Assume the local record is {"name": "Mr. John Doe"}. If an updated remote record
+ // has {"given-name": "John", "family-name": "Doe"}, we will NOT join the `*-name` fields
+ // and replace the local `name` field with "John Doe". This allows us to retain the complete
+ // name - "Mr. John Doe".
+ // However, if the updated remote record has {"given-name": "Jane", "family-name": "Poe"},
+ // we will rebuild it and replace the local `name` field with "Jane Poe".
+ if (
+ !("name" in remoteRecord) &&
+ NAME_COMPONENTS.some(c => c in remoteRecord)
+ ) {
+ const localRecord = this._findByGUID(remoteRecord.guid);
+ if (
+ localRecord &&
+ NAME_COMPONENTS.every(c => remoteRecord[c] == localRecord[c])
+ ) {
+ remoteRecord.name = localRecord.name;
+ } else {
+ remoteRecord.name = lazy.FormAutofillNameUtils.joinNameParts({
+ given: remoteRecord["given-name"],
+ middle: remoteRecord["additional-name"],
+ family: remoteRecord["family-name"],
+ });
+ }
+ }
+
+ // To enable new devices to sync name field changes with older devices, we still
+ // include the computed *-name fields in the sync payload while uploading.
+ // This also means that the incoming remote record will also contain *-name fields.
+ // However, since the autofill storage does not expect remote records to contain
+ // computed fields while merging, we remove them from the remote record.
+ NAME_COMPONENTS.forEach(f => delete remoteRecord[f]);
+ }
+}
+
+export class CreditCardsBase extends AutofillRecords {
+ constructor(store) {
+ super(
+ store,
+ "creditCards",
+ VALID_CREDIT_CARD_FIELDS,
+ VALID_CREDIT_CARD_COMPUTED_FIELDS,
+ CREDIT_CARD_SCHEMA_VERSION
+ );
+ }
+
+ async computeFields(creditCard) {
+ // NOTE: Remember to bump the schema version number if any of the existing
+ // computing algorithm changes. (No need to bump when just adding new
+ // computed fields.)
+
+ // NOTE: Computed fields should be always present in the storage no matter
+ // it's empty or not.
+
+ let hasNewComputedFields = false;
+
+ if (creditCard.deleted) {
+ return hasNewComputedFields;
+ }
+
+ let type = lazy.CreditCard.getType(creditCard["cc-number"]);
+ if (type) {
+ creditCard["cc-type"] = type;
+ }
+
+ // Compute split names
+ if (!("cc-given-name" in creditCard)) {
+ const nameParts = lazy.FormAutofillNameUtils.splitName(
+ creditCard["cc-name"]
+ );
+ creditCard["cc-given-name"] = nameParts.given;
+ creditCard["cc-additional-name"] = nameParts.middle;
+ creditCard["cc-family-name"] = nameParts.family;
+ hasNewComputedFields = true;
+ }
+
+ // Compute credit card expiration date
+ if (!("cc-exp" in creditCard)) {
+ if (creditCard["cc-exp-month"] && creditCard["cc-exp-year"]) {
+ creditCard["cc-exp"] =
+ String(creditCard["cc-exp-year"]) +
+ "-" +
+ String(creditCard["cc-exp-month"]).padStart(2, "0");
+ } else {
+ creditCard["cc-exp"] = "";
+ }
+ hasNewComputedFields = true;
+ }
+
+ // Encrypt credit card number
+ await this._encryptNumber(creditCard);
+
+ return hasNewComputedFields;
+ }
+
+ async _encryptNumber(creditCard) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ _isMigrationNeeded(record) {
+ return (
+ // version 4 is deprecated and is rolled back to version 3
+ record.version == 4 || record.version < this.version
+ );
+ }
+
+ async _computeMigratedRecord(creditCard) {
+ if (creditCard.version <= 2) {
+ if (creditCard["cc-number-encrypted"]) {
+ // We cannot decrypt the data, so silently remove the record for
+ // the user.
+ if (!creditCard.deleted) {
+ this.log.warn(
+ "Removing version",
+ creditCard.version,
+ "credit card record to migrate to new encryption:",
+ creditCard.guid
+ );
+
+ // Replace the record with a tombstone record here,
+ // regardless of existence of sync metadata.
+ let existingSync = this._getSyncMetaData(creditCard);
+ creditCard = {
+ guid: creditCard.guid,
+ timeLastModified: Date.now(),
+ deleted: true,
+ };
+
+ if (existingSync) {
+ creditCard._sync = existingSync;
+ existingSync.changeCounter++;
+ }
+ }
+ }
+ }
+
+ // Do not remove the migration code until we're sure no users have version 4
+ // credit card records (created in Fx110 or Fx111)
+ if (creditCard.version == 4) {
+ // Version 4 is deprecated, so downgrade or upgrade to the current version
+ // Since the only change made in version 4 is deleting `cc-type` field, so
+ // nothing else need to be done here expect flagging sync needed
+ let existingSync = this._getSyncMetaData(creditCard);
+ if (existingSync) {
+ existingSync.changeCounter++;
+ }
+ }
+
+ return super._computeMigratedRecord(creditCard);
+ }
+
+ async _stripComputedFields(creditCard) {
+ if (creditCard["cc-number-encrypted"]) {
+ try {
+ creditCard["cc-number"] = await lazy.OSKeyStore.decrypt(
+ creditCard["cc-number-encrypted"]
+ );
+ } catch (ex) {
+ if (ex.result == Cr.NS_ERROR_ABORT) {
+ throw ex;
+ }
+ // Quietly recover from encryption error,
+ // so existing credit card entry with undecryptable number
+ // can be updated.
+ }
+ }
+ await super._stripComputedFields(creditCard);
+ }
+
+ _normalizeFields(creditCard) {
+ lazy.CreditCardRecord.normalizeFields(creditCard);
+ }
+
+ _validateFields(creditCard) {
+ if (!creditCard["cc-number"]) {
+ throw new Error("Missing/invalid cc-number");
+ }
+ }
+
+ _ensureMatchingVersion(record) {
+ if (!record.version || isNaN(record.version) || record.version < 1) {
+ throw new Error(
+ `Got invalid record version ${record.version}; want ${this.version}`
+ );
+ }
+
+ if (record.version == 4) {
+ // Version 4 is deprecated, we need to force downloading it from sync
+ // and let migration do the work to downgrade it back to the current version.
+ return true;
+ } else if (record.version < this.version) {
+ switch (record.version) {
+ case 1:
+ case 2:
+ // The difference between version 1 and 2 is only about the encryption
+ // method used for the cc-number-encrypted field.
+ // The difference between version 2 and 3 is the name of the OS
+ // key encryption record.
+ // As long as the record is already decrypted, it is safe to bump the
+ // version directly.
+ if (!record["cc-number-encrypted"]) {
+ record.version = this.version;
+ } else {
+ throw new Error(
+ "Could not migrate record version:",
+ record.version,
+ "->",
+ this.version
+ );
+ }
+ break;
+ default:
+ throw new Error(
+ "Unknown credit card version to match: " + record.version
+ );
+ }
+ }
+
+ return super._ensureMatchingVersion(record);
+ }
+
+ /**
+ * Find a match credit card record in storage that is either exactly the same
+ * as the given record or a superset of the given record.
+ *
+ * See the comments in `getDuplicateRecords` to see the difference between
+ * `getDuplicateRecords` and `getMatchRecords`
+ *
+ * @param {object} record
+ * The credit card for match checking. please make sure the
+ * record is normalized.
+ * @returns {object}
+ * Return the first matched record found in storage, null otherwise.
+ */
+ async *getMatchRecords(record) {
+ for await (const recordInStorage of this.getDuplicateRecords(record)) {
+ const fields = this.VALID_FIELDS.filter(f => f != "cc-number");
+ if (
+ fields.every(
+ field => !record[field] || record[field] == recordInStorage[field]
+ )
+ ) {
+ yield recordInStorage;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Find a duplicate credit card record in the storage.
+ *
+ * A record is considered as a duplicate of another record when two records
+ * are the "same". This might be true even when some of their fields are
+ * different. For example, one record has the same credit card number but has
+ * different expiration date as the other record are still considered as
+ * "duplicate".
+ * This is different from `getMatchRecords`, which ensures all the fields with
+ * value in the the record is equal to the returned record.
+ *
+ * @param {object} record
+ * The credit card for duplication checking. please make sure the
+ * record is normalized.
+ * @returns {object}
+ * Return the first duplicated record found in storage, null otherwise.
+ */
+ async *getDuplicateRecords(record) {
+ if (!record["cc-number"]) {
+ return null;
+ }
+
+ for (const recordInStorage of this._data) {
+ if (recordInStorage.deleted) {
+ continue;
+ }
+
+ const decrypted = await lazy.OSKeyStore.decrypt(
+ recordInStorage["cc-number-encrypted"],
+ false
+ );
+
+ if (decrypted == record["cc-number"]) {
+ yield recordInStorage;
+ }
+ }
+ return null;
+ }
+}
+
+export class FormAutofillStorageBase {
+ constructor(path) {
+ this._path = path;
+ this._initializePromise = null;
+ this.INTERNAL_FIELDS = INTERNAL_FIELDS;
+ }
+
+ get version() {
+ return STORAGE_SCHEMA_VERSION;
+ }
+
+ get addresses() {
+ return this.getAddresses();
+ }
+
+ get creditCards() {
+ return this.getCreditCards();
+ }
+
+ getAddresses() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ getCreditCards() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ /**
+ * Initialize storage to memory.
+ *
+ * @returns {Promise} When the operation finished successfully.
+ * @throws JavaScript exception.
+ */
+ initialize() {
+ if (!this._initializePromise) {
+ this._store = this._initializeStore();
+ this._initializePromise = this._store.load().then(() => {
+ let initializeAutofillRecords = [
+ this.addresses.initialize(),
+ this.creditCards.initialize(),
+ ];
+ return Promise.all(initializeAutofillRecords);
+ });
+ }
+ return this._initializePromise;
+ }
+
+ _initializeStore() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ // For test only.
+ _saveImmediately() {
+ return this._store._save();
+ }
+
+ _finalize() {
+ return this._store.finalize();
+ }
+}
diff --git a/toolkit/components/formautofill/FormAutofillSync.sys.mjs b/toolkit/components/formautofill/FormAutofillSync.sys.mjs
new file mode 100644
index 0000000000..4540737e38
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillSync.sys.mjs
@@ -0,0 +1,400 @@
+/* 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/. */
+
+import {
+ Changeset,
+ Store,
+ SyncEngine,
+ Tracker,
+} from "resource://services-sync/engines.sys.mjs";
+import { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
+import { Utils } from "resource://services-sync/util.sys.mjs";
+
+import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Log: "resource://gre/modules/Log.sys.mjs",
+ formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs",
+});
+
+// A helper to sanitize address and creditcard records suitable for logging.
+export function sanitizeStorageObject(ob) {
+ if (!ob) {
+ return null;
+ }
+ const allowList = ["timeCreated", "timeLastUsed", "timeLastModified"];
+ let result = {};
+ for (let key of Object.keys(ob)) {
+ let origVal = ob[key];
+ if (allowList.includes(key)) {
+ result[key] = origVal;
+ } else if (typeof origVal == "string") {
+ result[key] = "X".repeat(origVal.length);
+ } else {
+ result[key] = typeof origVal; // *shrug*
+ }
+ }
+ return result;
+}
+
+export function AutofillRecord(collection, id) {
+ CryptoWrapper.call(this, collection, id);
+}
+
+AutofillRecord.prototype = {
+ toEntry() {
+ return Object.assign(
+ {
+ guid: this.id,
+ },
+ this.entry
+ );
+ },
+
+ fromEntry(entry) {
+ this.id = entry.guid;
+ this.entry = entry;
+ // The GUID is already stored in record.id, so we nuke it from the entry
+ // itself to save a tiny bit of space. The formAutofillStorage clones profiles,
+ // so nuking in-place is OK.
+ delete this.entry.guid;
+ },
+
+ cleartextToString() {
+ // And a helper so logging a *Sync* record auto sanitizes.
+ let record = this.cleartext;
+ return JSON.stringify({ entry: sanitizeStorageObject(record.entry) });
+ },
+};
+Object.setPrototypeOf(AutofillRecord.prototype, CryptoWrapper.prototype);
+
+// Profile data is stored in the "entry" object of the record.
+Utils.deferGetSet(AutofillRecord, "cleartext", ["entry"]);
+
+function FormAutofillStore(name, engine) {
+ Store.call(this, name, engine);
+}
+
+FormAutofillStore.prototype = {
+ _subStorageName: null, // overridden below.
+ _storage: null,
+
+ get storage() {
+ if (!this._storage) {
+ this._storage = lazy.formAutofillStorage[this._subStorageName];
+ }
+ return this._storage;
+ },
+
+ async getAllIDs() {
+ let result = {};
+ for (let { guid } of await this.storage.getAll({ includeDeleted: true })) {
+ result[guid] = true;
+ }
+ return result;
+ },
+
+ async changeItemID(oldID, newID) {
+ this.storage.changeGUID(oldID, newID);
+ },
+
+ // Note: this function intentionally returns false in cases where we only have
+ // a (local) tombstone - and formAutofillStorage.get() filters them for us.
+ async itemExists(id) {
+ return Boolean(await this.storage.get(id));
+ },
+
+ async applyIncoming(remoteRecord) {
+ if (remoteRecord.deleted) {
+ this._log.trace("Deleting record", remoteRecord);
+ this.storage.remove(remoteRecord.id, { sourceSync: true });
+ return;
+ }
+
+ // Records from the remote might come from an older device. To ensure that
+ // remote records from older devices can still sync with the local records,
+ // we migrate the remote records. This enables the merging of older records
+ // with newer records.
+ //
+ // Currently, this migration is only used for converting `*-name` fields to `name` fields.
+ // The migration process involves:
+ // 1. Generating a `name` field so we don't assume the `name` field is empty, thereby
+ // avoiding erasing its value.
+ // 2. Removing deprecated *-name fields from the remote record because the autofill storage
+ // does not expect to see those fields.
+ this.storage.migrateRemoteRecord(remoteRecord.entry);
+
+ if (await this.itemExists(remoteRecord.id)) {
+ // We will never get a tombstone here, so we are updating a real record.
+ await this._doUpdateRecord(remoteRecord);
+ return;
+ }
+
+ // No matching local record. Try to dedupe a NEW local record.
+ let localDupeID = await this.storage.findDuplicateGUID(
+ remoteRecord.toEntry()
+ );
+ if (localDupeID) {
+ this._log.trace(
+ `Deduping local record ${localDupeID} to remote`,
+ remoteRecord
+ );
+ // Change the local GUID to match the incoming record, then apply the
+ // incoming record.
+ await this.changeItemID(localDupeID, remoteRecord.id);
+ await this._doUpdateRecord(remoteRecord);
+ return;
+ }
+
+ // We didn't find a dupe, either, so must be a new record (or possibly
+ // a non-deleted version of an item we have a tombstone for, which add()
+ // handles for us.)
+ this._log.trace("Add record", remoteRecord);
+ let entry = remoteRecord.toEntry();
+ await this.storage.add(entry, { sourceSync: true });
+ },
+
+ async createRecord(id, collection) {
+ this._log.trace("Create record", id);
+ let record = new AutofillRecord(collection, id);
+ let entry = await this.storage.get(id, {
+ rawData: true,
+ });
+ if (entry) {
+ record.fromEntry(entry);
+ } else {
+ // We should consider getting a more authortative indication it's actually deleted.
+ this._log.debug(
+ `Failed to get autofill record with id "${id}", assuming deleted`
+ );
+ record.deleted = true;
+ }
+ return record;
+ },
+
+ async _doUpdateRecord(record) {
+ this._log.trace("Updating record", record);
+
+ let entry = record.toEntry();
+ let { forkedGUID } = await this.storage.reconcile(entry);
+ if (this._log.level <= lazy.Log.Level.Debug) {
+ let forkedRecord = forkedGUID ? await this.storage.get(forkedGUID) : null;
+ let reconciledRecord = await this.storage.get(record.id);
+ this._log.debug("Updated local record", {
+ forked: sanitizeStorageObject(forkedRecord),
+ updated: sanitizeStorageObject(reconciledRecord),
+ });
+ }
+ },
+
+ // NOTE: Because we re-implement the incoming/reconcilliation logic we leave
+ // the |create|, |remove| and |update| methods undefined - the base
+ // implementation throws, which is what we want to happen so we can identify
+ // any places they are "accidentally" called.
+};
+Object.setPrototypeOf(FormAutofillStore.prototype, Store.prototype);
+
+function FormAutofillTracker(name, engine) {
+ Tracker.call(this, name, engine);
+}
+
+FormAutofillTracker.prototype = {
+ async observe(subject, topic, data) {
+ if (topic != "formautofill-storage-changed") {
+ return;
+ }
+ if (
+ subject &&
+ subject.wrappedJSObject &&
+ subject.wrappedJSObject.sourceSync
+ ) {
+ return;
+ }
+ switch (data) {
+ case "add":
+ case "update":
+ case "remove":
+ this.score += SCORE_INCREMENT_XLARGE;
+ break;
+ default:
+ this._log.debug("unrecognized autofill notification", data);
+ break;
+ }
+ },
+
+ onStart() {
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+ },
+
+ onStop() {
+ Services.obs.removeObserver(this, "formautofill-storage-changed");
+ },
+};
+Object.setPrototypeOf(FormAutofillTracker.prototype, Tracker.prototype);
+
+// This uses the same conventions as BookmarkChangeset in
+// services/sync/modules/engines/bookmarks.js. Specifically,
+// - "synced" means the item has already been synced (or we have another reason
+// to ignore it), and should be ignored in most methods.
+class AutofillChangeset extends Changeset {
+ constructor() {
+ super();
+ }
+
+ getModifiedTimestamp(id) {
+ throw new Error("Don't use timestamps to resolve autofill merge conflicts");
+ }
+
+ has(id) {
+ let change = this.changes[id];
+ if (change) {
+ return !change.synced;
+ }
+ return false;
+ }
+
+ delete(id) {
+ let change = this.changes[id];
+ if (change) {
+ // Mark the change as synced without removing it from the set. We do this
+ // so that we can update FormAutofillStorage in `trackRemainingChanges`.
+ change.synced = true;
+ }
+ }
+}
+
+function FormAutofillEngine(service, name) {
+ SyncEngine.call(this, name, service);
+}
+
+FormAutofillEngine.prototype = {
+ // the priority for this engine is == addons, so will happen after bookmarks
+ // prefs and tabs, but before forms, history, etc.
+ syncPriority: 5,
+
+ // We don't use SyncEngine.initialize() for this, as we initialize even if
+ // the engine is disabled, and we don't want to be the loader of
+ // FormAutofillStorage in this case.
+ async _syncStartup() {
+ await lazy.formAutofillStorage.initialize();
+ await SyncEngine.prototype._syncStartup.call(this);
+ },
+
+ // We handle reconciliation in the store, not the engine.
+ async _reconcile() {
+ return true;
+ },
+
+ emptyChangeset() {
+ return new AutofillChangeset();
+ },
+
+ async _uploadOutgoing() {
+ this._modified.replace(this._store.storage.pullSyncChanges());
+ await SyncEngine.prototype._uploadOutgoing.call(this);
+ },
+
+ // Typically, engines populate the changeset before downloading records.
+ // However, we handle conflict resolution in the store, so we can wait
+ // to pull changes until we're ready to upload.
+ async pullAllChanges() {
+ return {};
+ },
+
+ async pullNewChanges() {
+ return {};
+ },
+
+ async trackRemainingChanges() {
+ this._store.storage.pushSyncChanges(this._modified.changes);
+ },
+
+ _deleteId(id) {
+ this._noteDeletedId(id);
+ },
+
+ async _resetClient() {
+ await lazy.formAutofillStorage.initialize();
+ this._store.storage.resetSync();
+ await this.resetLastSync(0);
+ },
+
+ async _wipeClient() {
+ await lazy.formAutofillStorage.initialize();
+ this._store.storage.removeAll({ sourceSync: true });
+ },
+};
+Object.setPrototypeOf(FormAutofillEngine.prototype, SyncEngine.prototype);
+
+// The concrete engines
+
+function AddressesRecord(collection, id) {
+ AutofillRecord.call(this, collection, id);
+}
+
+AddressesRecord.prototype = {
+ _logName: "Sync.Record.Addresses",
+};
+Object.setPrototypeOf(AddressesRecord.prototype, AutofillRecord.prototype);
+
+function AddressesStore(name, engine) {
+ FormAutofillStore.call(this, name, engine);
+}
+
+AddressesStore.prototype = {
+ _subStorageName: "addresses",
+};
+Object.setPrototypeOf(AddressesStore.prototype, FormAutofillStore.prototype);
+
+export function AddressesEngine(service) {
+ FormAutofillEngine.call(this, service, "Addresses");
+}
+
+AddressesEngine.prototype = {
+ _trackerObj: FormAutofillTracker,
+ _storeObj: AddressesStore,
+ _recordObj: AddressesRecord,
+
+ get prefName() {
+ return "addresses";
+ },
+};
+Object.setPrototypeOf(AddressesEngine.prototype, FormAutofillEngine.prototype);
+
+function CreditCardsRecord(collection, id) {
+ AutofillRecord.call(this, collection, id);
+}
+
+CreditCardsRecord.prototype = {
+ _logName: "Sync.Record.CreditCards",
+};
+Object.setPrototypeOf(CreditCardsRecord.prototype, AutofillRecord.prototype);
+
+function CreditCardsStore(name, engine) {
+ FormAutofillStore.call(this, name, engine);
+}
+
+CreditCardsStore.prototype = {
+ _subStorageName: "creditCards",
+};
+Object.setPrototypeOf(CreditCardsStore.prototype, FormAutofillStore.prototype);
+
+export function CreditCardsEngine(service) {
+ FormAutofillEngine.call(this, service, "CreditCards");
+}
+
+CreditCardsEngine.prototype = {
+ _trackerObj: FormAutofillTracker,
+ _storeObj: CreditCardsStore,
+ _recordObj: CreditCardsRecord,
+ get prefName() {
+ return "creditcards";
+ },
+};
+Object.setPrototypeOf(
+ CreditCardsEngine.prototype,
+ FormAutofillEngine.prototype
+);
diff --git a/toolkit/components/formautofill/Helpers.ios.mjs b/toolkit/components/formautofill/Helpers.ios.mjs
new file mode 100644
index 0000000000..4144d3e98c
--- /dev/null
+++ b/toolkit/components/formautofill/Helpers.ios.mjs
@@ -0,0 +1,177 @@
+/* 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/. */
+
+import { IOSAppConstants } from "resource://gre/modules/shared/Constants.ios.mjs";
+import Overrides from "resource://gre/modules/Overrides.ios.js";
+
+/* eslint mozilla/use-isInstance: 0 */
+HTMLSelectElement.isInstance = element => element instanceof HTMLSelectElement;
+HTMLInputElement.isInstance = element => element instanceof HTMLInputElement;
+HTMLFormElement.isInstance = element => element instanceof HTMLFormElement;
+ShadowRoot.isInstance = element => element instanceof ShadowRoot;
+
+HTMLElement.prototype.ownerGlobal = window;
+
+HTMLInputElement.prototype.setUserInput = function (value) {
+ this.value = value;
+
+ // In React apps, setting .value may not always work reliably.
+ // We dispatch change, input as a workaround.
+ // There are other more "robust" solutions:
+ // - Dispatching keyboard events and comparing the value after setting it
+ // (https://github.com/fmeum/browserpass-extension/blob/5efb1f9de6078b509904a83847d370c8e92fc097/src/inject.js#L412-L440)
+ // - Using the native setter
+ // (https://github.com/facebook/react/issues/10135#issuecomment-401496776)
+ // These are a bit more bloated. We can consider using these later if we encounter any further issues.
+ ["input", "change"].forEach(eventName => {
+ this.dispatchEvent(new Event(eventName, { bubbles: true }));
+ });
+
+ this.dispatchEvent(new Event("blur", { bubbles: true }));
+};
+
+// Mimic the behavior of .getAutocompleteInfo()
+// It should return an object with a fieldName property matching the autocomplete attribute
+// only if it's a valid value from this list https://searchfox.org/mozilla-central/source/dom/base/AutocompleteFieldList.h#89-149
+// Also found here: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete
+HTMLElement.prototype.getAutocompleteInfo = function () {
+ const autocomplete = this.getAttribute("autocomplete");
+
+ return {
+ fieldName: IOSAppConstants.validAutocompleteFields.includes(autocomplete)
+ ? autocomplete
+ : "",
+ };
+};
+
+// This function helps us debug better when an error occurs because a certain mock is missing
+const withNotImplementedError = obj =>
+ new Proxy(obj, {
+ get(target, prop) {
+ if (!Object.keys(target).includes(prop)) {
+ throw new Error(
+ `Not implemented: ${prop} doesn't exist in mocked object `
+ );
+ }
+ return Reflect.get(...arguments);
+ },
+ });
+
+// Webpack needs to be able to statically analyze require statements in order to build the dependency graph
+// In order to require modules dynamically at runtime, we use require.context() to create a dynamic require
+// that is still able to be parsed by Webpack at compile time. The "./" and ".mjs" tells webpack that files
+// in the current directory ending with .mjs might be needed and should be added to the dependency graph.
+// NOTE: This can't handle circular dependencies. A static import can be used in this case.
+// https://webpack.js.org/guides/dependency-management/
+const internalModuleResolvers = {
+ resolveModule(moduleURI) {
+ // eslint-disable-next-line no-undef
+ const moduleResolver = require.context("./", false, /.mjs$/);
+ // Desktop code uses uris for importing modules of the form resource://gre/modules/<module_path>
+ // We only need the filename here
+ const moduleName = moduleURI.split("/").pop();
+ const modulePath =
+ "./" + (Overrides.ModuleOverrides[moduleName] ?? moduleName);
+ return moduleResolver(modulePath);
+ },
+
+ resolveModules(obj, modules) {
+ for (const [exportName, moduleURI] of Object.entries(modules)) {
+ const resolvedModule = this.resolveModule(moduleURI);
+ obj[exportName] = resolvedModule?.[exportName];
+ }
+ },
+};
+
+// Define mock for XPCOMUtils
+export const XPCOMUtils = withNotImplementedError({
+ defineLazyPreferenceGetter: (
+ obj,
+ prop,
+ pref,
+ defaultValue = null,
+ onUpdate = null,
+ transform = val => val
+ ) => {
+ if (!Object.keys(IOSAppConstants.prefs).includes(pref)) {
+ throw Error(`Pref ${pref} is not defined.`);
+ }
+ obj[prop] = transform(IOSAppConstants.prefs[pref] ?? defaultValue);
+ },
+ defineLazyModuleGetters(obj, modules) {
+ internalModuleResolvers.resolveModules(obj, modules);
+ },
+});
+
+// eslint-disable-next-line no-shadow
+export const ChromeUtils = withNotImplementedError({
+ defineLazyGetter: (obj, prop, getFn) => {
+ obj[prop] = getFn?.call(obj);
+ },
+ defineESModuleGetters(obj, modules) {
+ internalModuleResolvers.resolveModules(obj, modules);
+ },
+ importESModule(moduleURI) {
+ return internalModuleResolvers.resolveModule(moduleURI);
+ },
+});
+window.ChromeUtils = ChromeUtils;
+
+// Define mock for Region.sys.mjs
+export const Region = withNotImplementedError({
+ home: "US",
+});
+
+// Define mock for OSKeyStore.sys.mjs
+export const OSKeyStore = withNotImplementedError({
+ ensureLoggedIn: () => true,
+});
+
+// Checks an element's focusability and accessibility via keyboard navigation
+const checkFocusability = element => {
+ return (
+ !element.disabled &&
+ !element.hidden &&
+ element.style.display != "none" &&
+ element.tabIndex != "-1"
+ );
+};
+
+// Define mock for Services
+// NOTE: Services is a global so we need to attach it to the window
+// eslint-disable-next-line no-shadow
+export const Services = withNotImplementedError({
+ focus: withNotImplementedError({
+ elementIsFocusable: checkFocusability,
+ }),
+ locale: withNotImplementedError({ isAppLocaleRTL: false }),
+ prefs: withNotImplementedError({ prefIsLocked: () => false }),
+ strings: withNotImplementedError({
+ createBundle: () =>
+ withNotImplementedError({
+ GetStringFromName: () => "",
+ formatStringFromName: () => "",
+ }),
+ }),
+ uuid: withNotImplementedError({ generateUUID: () => "" }),
+});
+window.Services = Services;
+
+// Define mock for Localization
+window.Localization = function () {
+ return { formatValueSync: () => "" };
+};
+
+export const windowUtils = withNotImplementedError({
+ removeManuallyManagedState: () => {},
+ addManuallyManagedState: () => {},
+});
+window.windowUtils = windowUtils;
+
+export const AutofillTelemetry = withNotImplementedError({
+ recordFormInteractionEvent: () => {},
+ recordDetectedSectionCount: () => {},
+});
+
+export { IOSAppConstants as AppConstants } from "resource://gre/modules/shared/Constants.ios.mjs";
diff --git a/toolkit/components/formautofill/Overrides.ios.js b/toolkit/components/formautofill/Overrides.ios.js
new file mode 100644
index 0000000000..a0023a267c
--- /dev/null
+++ b/toolkit/components/formautofill/Overrides.ios.js
@@ -0,0 +1,22 @@
+/* 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/. */
+
+"use strict";
+
+// This array defines overrides that webpack will use when bundling the JS on iOS
+// in order to load the right modules
+const ModuleOverrides = {
+ "AutofillTelemetry.sys.mjs": "Helpers.ios.mjs",
+ "AppConstants.sys.mjs": "Helpers.ios.mjs",
+ "XPCOMUtils.sys.mjs": "Helpers.ios.mjs",
+ "Region.sys.mjs": "Helpers.ios.mjs",
+ "OSKeyStore.sys.mjs": "Helpers.ios.mjs",
+ "FormAutofill.sys.mjs": "FormAutofill.ios.sys.mjs",
+ "EntryFile.sys.mjs": "FormAutofillChild.ios.sys.mjs",
+};
+
+// We need this because not all webpack libraries used in iOS are ES Modules
+// Hence we defer to CommonJS.
+// eslint-disable-next-line no-undef
+module.exports = { ModuleOverrides };
diff --git a/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs b/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs
new file mode 100644
index 0000000000..15fc1a520c
--- /dev/null
+++ b/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs
@@ -0,0 +1,452 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["browser/preferences/formAutofill.ftl"], true)
+);
+
+class ProfileAutoCompleteResult {
+ externalEntries = [];
+
+ constructor(
+ searchString,
+ focusedFieldName,
+ allFieldNames,
+ matchingProfiles,
+ { resultCode = null, isSecure = true, isInputAutofilled = false }
+ ) {
+ // nsISupports
+ this.QueryInterface = ChromeUtils.generateQI(["nsIAutoCompleteResult"]);
+
+ // The user's query string
+ this.searchString = searchString;
+ // The field name of the focused input.
+ this._focusedFieldName = focusedFieldName;
+ // The matching profiles contains the information for filling forms.
+ this._matchingProfiles = matchingProfiles;
+ // The default item that should be entered if none is selected
+ this.defaultIndex = 0;
+ // The reason the search failed
+ this.errorDescription = "";
+ // The value used to determine whether the form is secure or not.
+ this._isSecure = isSecure;
+ // The value to indicate whether the focused input has been autofilled or not.
+ this._isInputAutofilled = isInputAutofilled;
+ // All fillable field names in the form including the field name of the currently-focused input.
+ this._allFieldNames = [
+ ...this._matchingProfiles.reduce((fieldSet, curProfile) => {
+ for (let field of Object.keys(curProfile)) {
+ fieldSet.add(field);
+ }
+
+ return fieldSet;
+ }, new Set()),
+ ].filter(field => allFieldNames.includes(field));
+
+ // Force return success code if the focused field is auto-filled in order
+ // to show clear form button popup.
+ if (isInputAutofilled) {
+ resultCode = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
+ }
+ // The result code of this result object.
+ if (resultCode) {
+ this.searchResult = resultCode;
+ } else {
+ this.searchResult = matchingProfiles.length
+ ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS
+ : Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
+ }
+
+ // An array of primary and secondary labels for each profile.
+ this._popupLabels = this._generateLabels(
+ this._focusedFieldName,
+ this._allFieldNames,
+ this._matchingProfiles
+ );
+ }
+
+ getAt(index) {
+ for (const group of [this._popupLabels, this.externalEntries]) {
+ if (index < group.length) {
+ return group[index];
+ }
+ index -= group.length;
+ }
+
+ throw Components.Exception(
+ "Index out of range.",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+
+ /**
+ * @returns {number} The number of results
+ */
+ get matchCount() {
+ return this._popupLabels.length + this.externalEntries.length;
+ }
+
+ /**
+ * Get the secondary label based on the focused field name and related field names
+ * in the same form.
+ *
+ * @param {string} focusedFieldName The field name of the focused input
+ * @param {Array<object>} allFieldNames The field names in the same section
+ * @param {object} profile The profile providing the labels to show.
+ * @returns {string} The secondary label
+ */
+ _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
+ return "";
+ }
+
+ _generateLabels(focusedFieldName, allFieldNames, profiles) {}
+
+ /**
+ * Get the value of the result at the given index.
+ *
+ * Always return empty string for form autofill feature to suppress
+ * AutoCompleteController from autofilling, as we'll populate the
+ * fields on our own.
+ *
+ * @param {number} index The index of the result requested
+ * @returns {string} The result at the specified index
+ */
+ getValueAt(index) {
+ this.getAt(index);
+ return "";
+ }
+
+ getLabelAt(index) {
+ const label = this.getAt(index);
+ if (typeof label == "string") {
+ return label;
+ }
+ return JSON.stringify(label);
+ }
+
+ /**
+ * Retrieves a comment (metadata instance)
+ *
+ * @param {number} index The index of the comment requested
+ * @returns {string} The comment at the specified index
+ */
+ getCommentAt(index) {
+ const item = this.getAt(index);
+ return item.comment ?? JSON.stringify(this._matchingProfiles[index]);
+ }
+
+ /**
+ * Retrieves a style hint specific to a particular index.
+ *
+ * @param {number} index The index of the style hint requested
+ * @returns {string} The style hint at the specified index
+ */
+ getStyleAt(index) {
+ const itemStyle = this.getAt(index).style;
+ if (itemStyle) {
+ return itemStyle;
+ }
+
+ if (index == this._popupLabels.length - 1) {
+ return "autofill-footer";
+ }
+ if (this._isInputAutofilled) {
+ return "autofill-clear-button";
+ }
+
+ return "autofill-profile";
+ }
+
+ /**
+ * Retrieves an image url.
+ *
+ * @param {number} index The index of the image url requested
+ * @returns {string} The image url at the specified index
+ */
+ getImageAt(index) {
+ return this.getAt(index).image ?? "";
+ }
+
+ /**
+ * Retrieves a result
+ *
+ * @param {number} index The index of the result requested
+ * @returns {string} The result at the specified index
+ */
+ getFinalCompleteValueAt(index) {
+ return this.getValueAt(index);
+ }
+
+ /**
+ * Returns true if the value at the given index is removable
+ *
+ * @param {number} index The index of the result to remove
+ * @returns {boolean} True if the value is removable
+ */
+ isRemovableAt(index) {
+ return false;
+ }
+
+ /**
+ * Removes a result from the resultset
+ *
+ * @param {number} index The index of the result to remove
+ */
+ removeValueAt(index) {
+ // There is no plan to support removing profiles via autocomplete.
+ }
+}
+
+export class AddressResult extends ProfileAutoCompleteResult {
+ _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
+ // We group similar fields into the same field name so we won't pick another
+ // field in the same group as the secondary label.
+ const GROUP_FIELDS = {
+ name: ["name", "given-name", "additional-name", "family-name"],
+ "street-address": [
+ "street-address",
+ "address-line1",
+ "address-line2",
+ "address-line3",
+ ],
+ "country-name": ["country", "country-name"],
+ tel: [
+ "tel",
+ "tel-country-code",
+ "tel-national",
+ "tel-area-code",
+ "tel-local",
+ "tel-local-prefix",
+ "tel-local-suffix",
+ ],
+ };
+
+ const secondaryLabelOrder = [
+ "street-address", // Street address
+ "name", // Full name
+ "address-level3", // Townland / Neighborhood / Village
+ "address-level2", // City/Town
+ "organization", // Company or organization name
+ "address-level1", // Province/State (Standardized code if possible)
+ "country-name", // Country name
+ "postal-code", // Postal code
+ "tel", // Phone number
+ "email", // Email address
+ ];
+
+ for (let field in GROUP_FIELDS) {
+ if (GROUP_FIELDS[field].includes(focusedFieldName)) {
+ focusedFieldName = field;
+ break;
+ }
+ }
+
+ for (const currentFieldName of secondaryLabelOrder) {
+ if (focusedFieldName == currentFieldName || !profile[currentFieldName]) {
+ continue;
+ }
+
+ let matching = GROUP_FIELDS[currentFieldName]
+ ? allFieldNames.some(fieldName =>
+ GROUP_FIELDS[currentFieldName].includes(fieldName)
+ )
+ : allFieldNames.includes(currentFieldName);
+
+ if (matching) {
+ if (
+ currentFieldName == "street-address" &&
+ profile["-moz-street-address-one-line"]
+ ) {
+ return profile["-moz-street-address-one-line"];
+ }
+ return profile[currentFieldName];
+ }
+ }
+
+ return ""; // Nothing matched.
+ }
+
+ _generateLabels(focusedFieldName, allFieldNames, profiles) {
+ if (this._isInputAutofilled) {
+ return [
+ { primary: "", secondary: "" }, // Clear button
+ { primary: "", secondary: "" }, // Footer
+ ];
+ }
+
+ // Skip results without a primary label.
+ let labels = profiles
+ .filter(profile => {
+ return !!profile[focusedFieldName];
+ })
+ .map(profile => {
+ let primaryLabel = profile[focusedFieldName];
+ if (
+ focusedFieldName == "street-address" &&
+ profile["-moz-street-address-one-line"]
+ ) {
+ primaryLabel = profile["-moz-street-address-one-line"];
+ }
+ return {
+ primary: primaryLabel,
+ secondary: this._getSecondaryLabel(
+ focusedFieldName,
+ allFieldNames,
+ profile
+ ),
+ };
+ });
+ // Add an empty result entry for footer. Its content will come from
+ // the footer binding, so don't assign any value to it.
+ // The additional properties: categories and focusedCategory are required of
+ // the popup to generate autofill hint on the footer.
+ labels.push({
+ primary: "",
+ secondary: "",
+ categories: lazy.FormAutofillUtils.getCategoriesFromFieldNames(
+ this._allFieldNames
+ ),
+ focusedCategory: lazy.FormAutofillUtils.getCategoryFromFieldName(
+ this._focusedFieldName
+ ),
+ });
+
+ return labels;
+ }
+}
+
+export class CreditCardResult extends ProfileAutoCompleteResult {
+ _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
+ const GROUP_FIELDS = {
+ "cc-name": [
+ "cc-name",
+ "cc-given-name",
+ "cc-additional-name",
+ "cc-family-name",
+ ],
+ "cc-exp": ["cc-exp", "cc-exp-month", "cc-exp-year"],
+ };
+
+ const secondaryLabelOrder = [
+ "cc-number", // Credit card number
+ "cc-name", // Full name
+ "cc-exp", // Expiration date
+ ];
+
+ for (let field in GROUP_FIELDS) {
+ if (GROUP_FIELDS[field].includes(focusedFieldName)) {
+ focusedFieldName = field;
+ break;
+ }
+ }
+
+ for (const currentFieldName of secondaryLabelOrder) {
+ if (focusedFieldName == currentFieldName || !profile[currentFieldName]) {
+ continue;
+ }
+
+ let matching = GROUP_FIELDS[currentFieldName]
+ ? allFieldNames.some(fieldName =>
+ GROUP_FIELDS[currentFieldName].includes(fieldName)
+ )
+ : allFieldNames.includes(currentFieldName);
+
+ if (matching) {
+ if (currentFieldName == "cc-number") {
+ return lazy.CreditCard.formatMaskedNumber(profile[currentFieldName]);
+ }
+ return profile[currentFieldName];
+ }
+ }
+
+ return ""; // Nothing matched.
+ }
+
+ _generateLabels(focusedFieldName, allFieldNames, profiles) {
+ if (!this._isSecure) {
+ let brandName =
+ lazy.FormAutofillUtils.brandBundle.GetStringFromName("brandShortName");
+
+ return [
+ lazy.FormAutofillUtils.stringBundle.formatStringFromName(
+ "insecureFieldWarningDescription",
+ [brandName]
+ ),
+ ];
+ }
+
+ if (this._isInputAutofilled) {
+ return [
+ { primary: "", secondary: "" }, // Clear button
+ { primary: "", secondary: "" }, // Footer
+ ];
+ }
+
+ // Skip results without a primary label.
+ let labels = profiles
+ .filter(profile => {
+ return !!profile[focusedFieldName];
+ })
+ .map(profile => {
+ let primary = profile[focusedFieldName];
+
+ if (focusedFieldName == "cc-number") {
+ primary = lazy.CreditCard.formatMaskedNumber(primary);
+ }
+ const secondary = this._getSecondaryLabel(
+ focusedFieldName,
+ allFieldNames,
+ profile
+ );
+ // The card type is displayed visually using an image. For a11y, we need
+ // to expose it as text. We do this using aria-label. However,
+ // aria-label overrides the text content, so we must include that also.
+ const ccType = profile["cc-type"];
+ const image = lazy.CreditCard.getCreditCardLogo(ccType);
+ const ccTypeL10nId = lazy.CreditCard.getNetworkL10nId(ccType);
+ const ccTypeName = ccTypeL10nId
+ ? lazy.l10n.formatValueSync(ccTypeL10nId)
+ : ccType ?? ""; // Unknown card type
+ const ariaLabel = [
+ ccTypeName,
+ primary.toString().replaceAll("*", ""),
+ secondary,
+ ]
+ .filter(chunk => !!chunk) // Exclude empty chunks.
+ .join(" ");
+ return {
+ primary,
+ secondary,
+ ariaLabel,
+ image,
+ };
+ });
+ // Add an empty result entry for footer.
+ labels.push({ primary: "", secondary: "" });
+
+ return labels;
+ }
+
+ getStyleAt(index) {
+ const itemStyle = this.getAt(index).style;
+ if (itemStyle) {
+ return itemStyle;
+ }
+
+ if (!this._isSecure) {
+ return "autofill-insecureWarning";
+ }
+
+ return super.getStyleAt(index);
+ }
+}
diff --git a/toolkit/components/formautofill/android/FormAutofillPrompter.sys.mjs b/toolkit/components/formautofill/android/FormAutofillPrompter.sys.mjs
new file mode 100644
index 0000000000..6bb0e991b1
--- /dev/null
+++ b/toolkit/components/formautofill/android/FormAutofillPrompter.sys.mjs
@@ -0,0 +1,69 @@
+/* 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/. */
+
+/*
+ * Implements doorhanger singleton that wraps up the PopupNotifications and handles
+ * the doorhager UI for formautofill related features.
+ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/GeckoViewAutocomplete.sys.mjs",
+ GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.sys.mjs",
+ GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs",
+});
+
+// Sync with Autocomplete.SaveOption.Hint in Autocomplete.java
+const CreditCardStorageHint = {
+ NONE: 0,
+ GENERATED: 1 << 0,
+ LOW_CONFIDENCE: 1 << 1,
+};
+
+export let FormAutofillPrompter = {
+ _createMessage(creditCards) {
+ let hint = CreditCardStorageHint.NONE;
+ return {
+ // Sync with PromptController
+ type: "Autocomplete:Save:CreditCard",
+ hint,
+ creditCards,
+ };
+ },
+
+ async promptToSaveAddress(
+ browser,
+ storage,
+ flowId,
+ { oldRecord, newRecord }
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ async promptToSaveCreditCard(
+ browser,
+ storage,
+ flowId,
+ { oldRecord, newRecord }
+ ) {
+ if (oldRecord) {
+ newRecord = { ...oldRecord, ...newRecord };
+ }
+
+ const prompt = new lazy.GeckoViewPrompter(browser.ownerGlobal);
+ prompt.asyncShowPrompt(
+ this._createMessage([lazy.CreditCard.fromGecko(newRecord)]),
+ result => {
+ const selectedCreditCard = result?.selection?.value;
+
+ if (!selectedCreditCard) {
+ return;
+ }
+
+ lazy.GeckoViewAutocomplete.onCreditCardSave(selectedCreditCard);
+ }
+ );
+ },
+};
diff --git a/toolkit/components/formautofill/android/FormAutofillStorage.sys.mjs b/toolkit/components/formautofill/android/FormAutofillStorage.sys.mjs
new file mode 100644
index 0000000000..0d11880ff5
--- /dev/null
+++ b/toolkit/components/formautofill/android/FormAutofillStorage.sys.mjs
@@ -0,0 +1,265 @@
+/* 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/. */
+
+/*
+ * Implements an interface of the storage of Form Autofill for GeckoView.
+ */
+
+import {
+ FormAutofillStorageBase,
+ CreditCardsBase,
+ AddressesBase,
+} from "resource://autofill/FormAutofillStorageBase.sys.mjs";
+import { JSONFile } from "resource://gre/modules/JSONFile.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Address: "resource://gre/modules/GeckoViewAutocomplete.sys.mjs",
+ CreditCard: "resource://gre/modules/GeckoViewAutocomplete.sys.mjs",
+ GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.sys.mjs",
+});
+
+class GeckoViewStorage extends JSONFile {
+ constructor() {
+ super({ path: null, sanitizedBasename: "GeckoViewStorage" });
+ }
+
+ async updateCreditCards() {
+ const creditCards =
+ await lazy.GeckoViewAutocomplete.fetchCreditCards().then(
+ results => results?.map(r => lazy.CreditCard.parse(r).toGecko()) ?? [],
+ _ => []
+ );
+ super.data.creditCards = creditCards;
+ }
+
+ async updateAddresses() {
+ const addresses = await lazy.GeckoViewAutocomplete.fetchAddresses().then(
+ results => results?.map(r => lazy.Address.parse(r).toGecko()) ?? [],
+ _ => []
+ );
+ super.data.addresses = addresses;
+ }
+
+ async load() {
+ super.data = { creditCards: {}, addresses: {} };
+ await this.updateCreditCards();
+ await this.updateAddresses();
+ }
+
+ ensureDataReady() {
+ if (this.dataReady) {
+ return;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ async _save() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+}
+
+class Addresses extends AddressesBase {
+ // Override AutofillRecords methods.
+
+ _initialize() {
+ this._initializePromise = Promise.resolve();
+ }
+
+ async _saveRecord(record, { sourceSync = false } = {}) {
+ lazy.GeckoViewAutocomplete.onAddressSave(lazy.Address.fromGecko(record));
+ }
+
+ /**
+ * Returns the record with the specified GUID.
+ *
+ * @param {string} guid
+ * Indicates which record to retrieve.
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns a raw record without modifications and the computed fields
+ * (this includes private fields)
+ * @returns {Promise<object>}
+ * A clone of the record.
+ */
+ async get(guid, { rawData = false } = {}) {
+ await this._store.updateAddresses();
+ return super.get(guid, { rawData });
+ }
+
+ /**
+ * Returns all records.
+ *
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns raw records without modifications and the computed fields.
+ * @param {boolean} [options.includeDeleted = false]
+ * Also return any tombstone records.
+ * @returns {Promise<Array.<object>>}
+ * An array containing clones of all records.
+ */
+ async getAll({ rawData = false, includeDeleted = false } = {}) {
+ await this._store.updateAddresses();
+ return super.getAll({ rawData, includeDeleted });
+ }
+
+ /**
+ * Return all saved field names in the collection.
+ *
+ * @returns {Set} Set containing saved field names.
+ */
+ async getSavedFieldNames() {
+ await this._store.updateAddresses();
+ return super.getSavedFieldNames();
+ }
+
+ async reconcile(remoteRecord) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ async findDuplicateGUID(remoteRecord) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+}
+
+class CreditCards extends CreditCardsBase {
+ async _encryptNumber(creditCard) {
+ // Don't encrypt or obfuscate for GV, since we don't store or show
+ // the number. The API has to always provide the original number.
+ }
+
+ // Override AutofillRecords methods.
+
+ _initialize() {
+ this._initializePromise = Promise.resolve();
+ }
+
+ async _saveRecord(record, { sourceSync = false } = {}) {
+ lazy.GeckoViewAutocomplete.onCreditCardSave(
+ lazy.CreditCard.fromGecko(record)
+ );
+ }
+
+ /**
+ * Returns the record with the specified GUID.
+ *
+ * @param {string} guid
+ * Indicates which record to retrieve.
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns a raw record without modifications and the computed fields
+ * (this includes private fields)
+ * @returns {Promise<object>}
+ * A clone of the record.
+ */
+ async get(guid, { rawData = false } = {}) {
+ await this._store.updateCreditCards();
+ return super.get(guid, { rawData });
+ }
+
+ /**
+ * Returns all records.
+ *
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns raw records without modifications and the computed fields.
+ * @param {boolean} [options.includeDeleted = false]
+ * Also return any tombstone records.
+ * @returns {Promise<Array.<object>>}
+ * An array containing clones of all records.
+ */
+ async getAll({ rawData = false, includeDeleted = false } = {}) {
+ await this._store.updateCreditCards();
+ return super.getAll({ rawData, includeDeleted });
+ }
+
+ /**
+ * Return all saved field names in the collection.
+ *
+ * @returns {Set} Set containing saved field names.
+ */
+ async getSavedFieldNames() {
+ await this._store.updateCreditCards();
+ return super.getSavedFieldNames();
+ }
+
+ /**
+ * Find a duplicate credit card record in the storage.
+ *
+ * A record is considered as a duplicate of another record when two records
+ * are the "same". This might be true even when some of their fields are
+ * different. For example, one record has the same credit card number but has
+ * different expiration date as the other record are still considered as
+ * "duplicate".
+ * This is different from `getMatchRecord`, which ensures all the fields with
+ * value in the the record is equal to the returned record.
+ *
+ * @param {object} record
+ * The credit card for duplication checking. please make sure the
+ * record is normalized.
+ * @returns {object}
+ * Return the first duplicated record found in storage, null otherwise.
+ */
+ async *getDuplicateRecords(record) {
+ if (!record["cc-number"]) {
+ return null;
+ }
+
+ await this._store.updateCreditCards();
+ for (const recordInStorage of this._data) {
+ if (recordInStorage.deleted) {
+ continue;
+ }
+
+ if (recordInStorage["cc-number"] == record["cc-number"]) {
+ yield recordInStorage;
+ }
+ }
+ return null;
+ }
+
+ async reconcile(remoteRecord) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ async findDuplicateGUID(remoteRecord) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+}
+
+export class FormAutofillStorage extends FormAutofillStorageBase {
+ constructor() {
+ super(null);
+ }
+
+ getAddresses() {
+ if (!this._addresses) {
+ this._store.ensureDataReady();
+ this._addresses = new Addresses(this._store);
+ }
+ return this._addresses;
+ }
+
+ getCreditCards() {
+ if (!this._creditCards) {
+ this._store.ensureDataReady();
+ this._creditCards = new CreditCards(this._store);
+ }
+ return this._creditCards;
+ }
+
+ /**
+ * Initializes the in-memory async store API.
+ *
+ * @returns {JSONFile}
+ * The JSONFile store.
+ */
+ _initializeStore() {
+ return new GeckoViewStorage();
+ }
+}
+
+// The singleton exposed by this module.
+export const formAutofillStorage = new FormAutofillStorage();
diff --git a/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs b/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs
new file mode 100644
index 0000000000..ecf787137e
--- /dev/null
+++ b/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs
@@ -0,0 +1,1410 @@
+/* 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/. */
+
+/*
+ * Implements doorhanger singleton that wraps up the PopupNotifications and handles
+ * the doorhager UI for formautofill related features.
+ */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+
+import { AutofillTelemetry } from "resource://autofill/AutofillTelemetry.sys.mjs";
+import { showConfirmation } from "resource://gre/modules/FillHelpers.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "log", () =>
+ FormAutofill.defineLogGetter(lazy, "FormAutofillPrompter")
+);
+
+const l10n = new Localization(
+ [
+ "browser/preferences/formAutofill.ftl",
+ "toolkit/formautofill/formAutofill.ftl",
+ "branding/brand.ftl",
+ ],
+ true
+);
+
+const { ENABLED_AUTOFILL_CREDITCARDS_PREF } = FormAutofill;
+
+let CONTENT = {};
+
+/**
+ * `AutofillDoorhanger` provides a base for both address capture and credit card
+ * capture doorhanger notifications. It handles the UI generation and logic
+ * related to displaying the doorhanger,
+ *
+ * The UI data sourced from the `CONTENT` variable is used for rendering. Derived classes
+ * should override the `render()` method to customize the layout.
+ */
+export class AutofillDoorhanger {
+ /**
+ * Constructs an instance of the `AutofillDoorhanger` class.
+ *
+ * @param {object} browser The browser where the doorhanger will be displayed.
+ * @param {object} oldRecord The old record that can be merged with the new record
+ * @param {object} newRecord The new record submitted by users
+ */
+ static headerClass = "address-capture-header";
+ static descriptionClass = "address-capture-description";
+ static contentClass = "address-capture-content";
+ static menuButtonId = "address-capture-menu-button";
+
+ static preferenceURL = null;
+ static learnMoreURL = null;
+
+ constructor(browser, oldRecord, newRecord, flowId) {
+ this.browser = browser;
+ this.oldRecord = oldRecord ?? {};
+ this.newRecord = newRecord;
+ this.flowId = flowId;
+ }
+
+ get ui() {
+ return CONTENT[this.constructor.name];
+ }
+
+ // PopupNotification appends a "-notification" suffix to the id to avoid
+ // id conflict.
+ get notificationId() {
+ return this.ui.id + "-notification";
+ }
+
+ // The popup notification element
+ get panel() {
+ return this.browser.ownerDocument.getElementById(this.notificationId);
+ }
+
+ get doc() {
+ return this.browser.ownerDocument;
+ }
+
+ get chromeWin() {
+ return this.browser.ownerGlobal;
+ }
+
+ /*
+ * An autofill doorhanger consists 3 parts - header, description, and content
+ * The content part contains customized UI layout for this doorhanger
+ */
+
+ // The container of the header part
+ static header(panel) {
+ return panel.querySelector(`.${AutofillDoorhanger.headerClass}`);
+ }
+ get header() {
+ return AutofillDoorhanger.header(this.panel);
+ }
+
+ // The container of the description part
+ static description(panel) {
+ return panel.querySelector(`.${AutofillDoorhanger.descriptionClass}`);
+ }
+ get description() {
+ return AutofillDoorhanger.description(this.panel);
+ }
+
+ // The container of the content part
+ static content(panel) {
+ return panel.querySelector(`.${AutofillDoorhanger.contentClass}`);
+ }
+ get content() {
+ return AutofillDoorhanger.content(this.panel);
+ }
+
+ static menuButton(panel) {
+ return panel.querySelector(`#${AutofillDoorhanger.menuButtonId}`);
+ }
+ get menuButton() {
+ return AutofillDoorhanger.menuButton(this.panel);
+ }
+
+ static menuPopup(panel) {
+ return AutofillDoorhanger.menuButton(panel).querySelector(
+ `.toolbar-menupopup`
+ );
+ }
+ get menuPopup() {
+ return AutofillDoorhanger.menuPopup(this.panel);
+ }
+
+ static preferenceButton(panel) {
+ return AutofillDoorhanger.menuButton(panel).querySelector(
+ `[data-l10n-id=address-capture-manage-address-button]`
+ );
+ }
+ static learnMoreButton(panel) {
+ return AutofillDoorhanger.menuButton(panel).querySelector(
+ `[data-l10n-id=address-capture-learn-more-button]`
+ );
+ }
+
+ get preferenceURL() {
+ return this.constructor.preferenceURL;
+ }
+ get learnMoreURL() {
+ return this.constructor.learnMoreURL;
+ }
+
+ onMenuItemClick(evt) {
+ AutofillTelemetry.recordDoorhangerClicked(
+ this.constructor.telemetryType,
+ evt,
+ this.constructor.telemetryObject,
+ this.flowId
+ );
+
+ if (evt == "open-pref") {
+ this.browser.ownerGlobal.openPreferences(this.preferenceURL);
+ } else if (evt == "learn-more") {
+ const url =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ this.learnMoreURL;
+ this.browser.ownerGlobal.openWebLinkIn(url, "tab", {
+ relatedToCurrent: true,
+ });
+ }
+ }
+
+ // Build the doorhanger markup
+ render() {
+ this.renderHeader();
+
+ this.renderDescription();
+
+ // doorhanger specific content
+ this.renderContent();
+ }
+
+ renderHeader() {
+ // Render the header text
+ const text = this.header.querySelector(`p`);
+ this.doc.l10n.setAttributes(text, this.ui.header.l10nId);
+
+ // Render the menu button
+ if (!this.ui.menu?.length || AutofillDoorhanger.menuButton(this.panel)) {
+ return;
+ }
+
+ const button = this.doc.createElement("button");
+ button.setAttribute("id", AutofillDoorhanger.menuButtonId);
+ button.setAttribute("class", "address-capture-icon-button");
+ this.doc.l10n.setAttributes(button, "address-capture-open-menu-button");
+
+ const menupopup = this.doc.createXULElement("menupopup");
+ menupopup.setAttribute("id", AutofillDoorhanger.menuButtonId);
+ menupopup.setAttribute("class", "toolbar-menupopup");
+
+ for (const [index, element] of this.ui.menu.entries()) {
+ const menuitem = this.doc.createXULElement("menuitem");
+ this.doc.l10n.setAttributes(menuitem, element.l10nId);
+ /* eslint-disable mozilla/balanced-listeners */
+ menuitem.addEventListener("command", event => {
+ event.stopPropagation();
+ this.onMenuItemClick(element.evt);
+ });
+ menupopup.appendChild(menuitem);
+
+ if (index != this.ui.menu.length - 1) {
+ menupopup.appendChild(this.doc.createXULElement("menuseparator"));
+ }
+ }
+
+ button.appendChild(menupopup);
+ /* eslint-disable mozilla/balanced-listeners */
+ button.addEventListener("click", event => {
+ event.stopPropagation();
+ menupopup.openPopup(button, "after_start");
+ });
+ this.header.appendChild(button);
+ }
+
+ renderDescription() {
+ if (this.ui.description?.l10nId) {
+ const text = this.description.querySelector(`p`);
+ this.doc.l10n.setAttributes(text, this.ui.description.l10nId);
+ this.description?.setAttribute("style", "");
+ } else {
+ this.description?.setAttribute("style", "display:none");
+ }
+ }
+
+ onEventCallback(state) {
+ lazy.log.debug(`Doorhanger receives event callback: ${state}`);
+
+ if (state == "showing") {
+ this.render();
+ }
+ }
+
+ async show() {
+ AutofillTelemetry.recordDoorhangerShown(
+ this.constructor.telemetryType,
+ this.constructor.telemetryObject,
+ this.flowId
+ );
+
+ let options = {
+ ...this.ui.options,
+ eventCallback: state => this.onEventCallback(state),
+ };
+
+ this.#setAnchor();
+
+ return new Promise(resolve => {
+ this.resolve = resolve;
+ this.chromeWin.PopupNotifications.show(
+ this.browser,
+ this.ui.id,
+ this.getNotificationHeader?.() ?? "",
+ this.ui.anchor.id,
+ ...this.#createActions(),
+ options
+ );
+ });
+ }
+
+ /**
+ * Closes the doorhanger with a given action.
+ * This method is specifically intended for closing the doorhanger in scenarios
+ * other than clicking the main or secondary buttons.
+ */
+ closeDoorhanger(action) {
+ this.resolve(action);
+ const notification = this.chromeWin.PopupNotifications.getNotification(
+ this.ui.id,
+ this.browser
+ );
+ if (notification) {
+ this.chromeWin.PopupNotifications.remove(notification);
+ }
+ }
+
+ /**
+ * Create an image element for notification anchor if it doesn't already exist.
+ */
+ #setAnchor() {
+ let anchor = this.doc.getElementById(this.ui.anchor.id);
+ if (!anchor) {
+ // Icon shown on URL bar
+ anchor = this.doc.createXULElement("image");
+ anchor.id = this.ui.anchor.id;
+ anchor.setAttribute("src", this.ui.anchor.URL);
+ anchor.classList.add("notification-anchor-icon");
+ anchor.setAttribute("role", "button");
+ anchor.setAttribute("tooltiptext", this.ui.anchor.tooltiptext);
+
+ const popupBox = this.doc.getElementById("notification-popup-box");
+ popupBox.appendChild(anchor);
+ }
+ }
+
+ /**
+ * Generate the main action and secondary actions from content parameters and
+ * promise resolve.
+ */
+ #createActions() {
+ function getLabelAndAccessKey(param) {
+ const msg = l10n.formatMessagesSync([{ id: param.l10nId }])[0];
+ return {
+ label: msg.attributes.find(x => x.name == "label").value,
+ accessKey: msg.attributes.find(x => x.name == "accessKey").value,
+ dismiss: param.dismiss,
+ };
+ }
+
+ const mainActionParams = this.ui.footer.mainAction;
+ const secondaryActionParams = this.ui.footer.secondaryActions;
+
+ const callback = () => {
+ AutofillTelemetry.recordDoorhangerClicked(
+ this.constructor.telemetryType,
+ mainActionParams.callbackState,
+ this.constructor.telemetryObject,
+ this.flowId
+ );
+
+ this.resolve(mainActionParams.callbackState);
+ };
+
+ const mainAction = {
+ ...getLabelAndAccessKey(mainActionParams),
+ callback,
+ };
+
+ let secondaryActions = [];
+ for (const params of secondaryActionParams) {
+ const callback = () => {
+ AutofillTelemetry.recordDoorhangerClicked(
+ this.constructor.telemetryType,
+ params.callbackState,
+ this.constructor.telemetryObject,
+ this.flowId
+ );
+
+ this.resolve(params.callbackState);
+ };
+
+ secondaryActions.push({
+ ...getLabelAndAccessKey(params),
+ callback,
+ });
+ }
+
+ return [mainAction, secondaryActions];
+ }
+}
+
+export class AddressSaveDoorhanger extends AutofillDoorhanger {
+ static preferenceURL = "privacy-address-autofill";
+ static learnMoreURL = "automatically-fill-your-address-web-forms";
+ static editButtonId = "address-capture-edit-address-button";
+
+ static telemetryType = AutofillTelemetry.ADDRESS;
+ static telemetryObject = "capture_doorhanger";
+
+ constructor(browser, oldRecord, newRecord, flowId) {
+ super(browser, oldRecord, newRecord, flowId);
+ }
+
+ static editButton(panel) {
+ return panel.querySelector(`#${AddressSaveDoorhanger.editButtonId}`);
+ }
+ get editButton() {
+ return AddressSaveDoorhanger.editButton(this.panel);
+ }
+
+ /**
+ * Formats a line by comparing the old and the new address field and returns an array of
+ * <span> elements that represents the formatted line.
+ *
+ * @param {Array<Array<string>>} datalist An array of pairs, where each pair contains old and new data.
+ * @param {boolean} showDiff True to format the text line that highlight the diff part.
+ *
+ * @returns {Array<HTMLSpanElement>} An array of formatted text elements.
+ */
+ #formatLine(datalist, showDiff) {
+ const createSpan = (text, style = null) => {
+ let s;
+
+ if (showDiff) {
+ if (style == "remove") {
+ s = this.doc.createElement("del");
+ s.setAttribute("class", "address-update-text-diff-removed");
+ } else if (style == "add") {
+ s = this.doc.createElement("mark");
+ s.setAttribute("class", "address-update-text-diff-added");
+ } else {
+ s = this.doc.createElement("span");
+ }
+ } else {
+ s = this.doc.createElement("span");
+ }
+ s.textContent = text;
+ return s;
+ };
+
+ let spans = [];
+ let previousField;
+ for (const [field, oldData, newData] of datalist) {
+ if (!oldData && !newData) {
+ continue;
+ }
+
+ // Always add a whitespace between field data that we put in the same line.
+ // Ex. first-name: John, family-name: Doe becomes
+ // "John Doe"
+ if (spans.length) {
+ if (previousField == "address-level2" && field == "address-level1") {
+ spans.push(createSpan(", "));
+ } else {
+ spans.push(createSpan(" "));
+ }
+ }
+
+ if (!oldData) {
+ spans.push(createSpan(newData, "add"));
+ } else if (!newData || oldData == newData) {
+ // The same
+ spans.push(createSpan(oldData));
+ } else if (newData.startsWith(oldData)) {
+ // Have the same prefix
+ const diff = newData.slice(oldData.length).trim();
+ spans.push(createSpan(newData.slice(0, newData.length - diff.length)));
+ spans.push(createSpan(diff, "add"));
+ } else if (newData.endsWith(oldData)) {
+ // Have the same suffix
+ const diff = newData.slice(0, newData.length - oldData.length).trim();
+ spans.push(createSpan(diff, "add"));
+ spans.push(createSpan(newData.slice(diff.length)));
+ } else {
+ spans.push(createSpan(oldData, "remove"));
+ spans.push(createSpan(" "));
+ spans.push(createSpan(newData, "add"));
+ }
+
+ previousField = field;
+ }
+
+ return spans;
+ }
+
+ #formatTextByAddressCategory(fieldName) {
+ let data = [];
+ switch (fieldName) {
+ case "street-address":
+ data = [
+ [
+ fieldName,
+ FormAutofillUtils.toOneLineAddress(
+ this.oldRecord["street-address"]
+ ),
+ FormAutofillUtils.toOneLineAddress(
+ this.newRecord["street-address"]
+ ),
+ ],
+ ];
+ break;
+ case "address":
+ data = ["address-level2", "address-level1", "postal-code"].map(
+ field => [field, this.oldRecord[field], this.newRecord[field]]
+ );
+ break;
+ case "name":
+ case "country":
+ case "tel":
+ case "email":
+ case "organization":
+ data = [
+ [fieldName, this.oldRecord[fieldName], this.newRecord[fieldName]],
+ ];
+ break;
+ }
+
+ const showDiff = !!Object.keys(this.oldRecord).length;
+ return this.#formatLine(data, showDiff);
+ }
+
+ renderDescription() {
+ if (lazy.formAutofillStorage.addresses.isEmpty()) {
+ super.renderDescription();
+ } else {
+ this.description?.setAttribute("style", "display:none");
+ }
+ }
+
+ renderContent() {
+ this.content.replaceChildren();
+
+ // Each section contains address fields that are grouped together while displaying
+ // the doorhanger.
+ for (const { imgClass, categories } of this.ui.content.sections) {
+ // Add all the address fields that are in the same category
+ let texts = [];
+ categories.forEach(category => {
+ const line = this.#formatTextByAddressCategory(category);
+ if (line.length) {
+ texts.push(line);
+ }
+ });
+
+ // If there is no data for this section, just ignore it.
+ if (!texts.length) {
+ continue;
+ }
+
+ const section = this.doc.createElement("div");
+ section.setAttribute("class", "address-save-update-row-container");
+
+ // Add image icon for this section
+ //const img = this.doc.createElement("img");
+ const img = this.doc.createXULElement("image");
+ img.setAttribute("class", imgClass);
+ section.appendChild(img);
+
+ // Each line is consisted of multiple <span> to form diff style texts
+ const lineContainer = this.doc.createElement("div");
+ for (const spans of texts) {
+ const p = this.doc.createElement("p");
+ spans.forEach(span => p.appendChild(span));
+ lineContainer.appendChild(p);
+ }
+ section.appendChild(lineContainer);
+
+ this.content.appendChild(section);
+
+ // Put the edit address button in the first section
+ if (!AddressSaveDoorhanger.editButton(this.panel)) {
+ const button = this.doc.createElement("button");
+ button.setAttribute("id", AddressSaveDoorhanger.editButtonId);
+ button.setAttribute("class", "address-capture-icon-button");
+ this.doc.l10n.setAttributes(
+ button,
+ "address-capture-edit-address-button"
+ );
+
+ // The element will be removed after the popup is closed
+ /* eslint-disable mozilla/balanced-listeners */
+ button.addEventListener("click", event => {
+ event.stopPropagation();
+ this.closeDoorhanger("edit-address");
+ });
+ section.appendChild(button);
+ }
+ }
+ }
+
+ // The record to be saved by this doorhanger
+ recordToSave() {
+ return this.newRecord;
+ }
+}
+
+/**
+ * Address Update doorhanger and Address Save doorhanger have the same implementation.
+ * The only difference is UI.
+ */
+export class AddressUpdateDoorhanger extends AddressSaveDoorhanger {
+ static telemetryObject = "update_doorhanger";
+}
+
+export class AddressEditDoorhanger extends AutofillDoorhanger {
+ static telemetryType = AutofillTelemetry.ADDRESS;
+ static telemetryObject = "edit_doorhanger";
+
+ constructor(browser, record, flowId) {
+ // Address edit dialog doesn't have "old" record
+ super(browser, null, record, flowId);
+
+ this.country = record.country || FormAutofill.DEFAULT_REGION;
+ }
+
+ // Address edit doorhanger changes layout according to the country
+ #layout = null;
+ get layout() {
+ if (this.#layout?.country != this.country) {
+ this.#layout = FormAutofillUtils.getFormFormat(this.country);
+ }
+ return this.#layout;
+ }
+
+ get country() {
+ return this.newRecord.country;
+ }
+
+ set country(c) {
+ if (this.newRecord.country == c) {
+ return;
+ }
+
+ // `recordToSave` only contains the latest data the current country support.
+ // For example, if a country doesn't have `address-level2`, `recordToSave`
+ // will not have the address field.
+ // `newRecord` is where we keep all the data regardless what the country is.
+ // Merge `recordToSave` to `newRecord` before switching country to keep
+ // `newRecord` update-to-date.
+ this.newRecord = Object.assign(this.newRecord, this.recordToSave());
+
+ // The layout of the address edit doorhanger should be changed when the
+ // country is changed.
+ this.#buildCountrySpecificAddressFields();
+ }
+
+ renderContent() {
+ this.content.replaceChildren();
+
+ this.#buildAddressFields(this.content, this.ui.content.fixedFields);
+
+ this.#buildCountrySpecificAddressFields();
+ }
+
+ // Put address fields that should be in the same line together.
+ // Determined by the `newLine` property that is defined in libaddressinput
+ #buildAddressFields(container, fields) {
+ const createRowContainer = () => {
+ const div = this.doc.createElement("div");
+ div.setAttribute("class", "address-edit-row-container");
+ container.appendChild(div);
+ return div;
+ };
+
+ let row = null;
+ let createRow = true;
+ for (const { fieldId, newLine } of fields) {
+ if (createRow) {
+ row = createRowContainer();
+ }
+ row.appendChild(this.#createInputField(fieldId));
+ createRow = newLine;
+ }
+ }
+
+ #buildCountrySpecificAddressFields() {
+ const fixedFieldIds = this.ui.content.fixedFields.map(f => f.fieldId);
+ let container = this.doc.getElementById(
+ "country-specific-fields-container"
+ );
+ if (container) {
+ // Country-specific fields might be rebuilt after users update the country
+ // field, so if the container already exists, we remove all its childern and
+ // then rebuild it.
+ container.replaceChildren();
+ } else {
+ container = this.doc.createElement("div");
+ container.setAttribute("id", "country-specific-fields-container");
+
+ // Find where to insert country-specific fields
+ const nth = fixedFieldIds.indexOf(
+ this.ui.content.countrySpecificFieldsBefore
+ );
+ this.content.insertBefore(container, this.content.children[nth]);
+ }
+
+ this.#buildAddressFields(
+ container,
+ // Filter out fields that are always displayed
+ this.layout.fieldsOrder.filter(f => !fixedFieldIds.includes(f.fieldId))
+ );
+ }
+
+ #buildCountryMenupopup() {
+ const menupopup = this.doc.createXULElement("menupopup");
+
+ let menuitem = this.doc.createXULElement("menuitem");
+ menuitem.setAttribute("value", "");
+ menupopup.appendChild(menuitem);
+
+ const countries = [...FormAutofill.countries.entries()].sort((e1, e2) =>
+ e1[1].localeCompare(e2[1])
+ );
+ for (const [country] of countries) {
+ const countryName = Services.intl.getRegionDisplayNames(undefined, [
+ country.toLowerCase(),
+ ]);
+ menuitem = this.doc.createXULElement("menuitem");
+ menuitem.setAttribute("label", countryName);
+ menuitem.setAttribute("value", country);
+ menupopup.appendChild(menuitem);
+ }
+
+ return menupopup;
+ }
+
+ #buildAddressLevel1Menupopup() {
+ const menupopup = this.doc.createXULElement("menupopup");
+
+ let menuitem = this.doc.createXULElement("menuitem");
+ menuitem.setAttribute("value", "");
+ menupopup.appendChild(menuitem);
+
+ for (const [regionCode, regionName] of this.layout.addressLevel1Options) {
+ menuitem = this.doc.createXULElement("menuitem");
+ menuitem.setAttribute("label", regionCode);
+ menuitem.setAttribute("value", regionName);
+ menupopup.appendChild(menuitem);
+ }
+
+ return menupopup;
+ }
+
+ /**
+ * Creates an input field with a label and attaches it to a container element.
+ * The type of the input field is determined by the `fieldName`.
+ *
+ * @param {string} fieldName The name of the address field
+ */
+ #createInputField(fieldName) {
+ const div = this.doc.createElement("div");
+ div.setAttribute("class", "address-edit-input-container");
+
+ const inputId = AddressEditDoorhanger.getInputId(fieldName);
+ const label = this.doc.createElement("label");
+ label.setAttribute("for", inputId);
+
+ switch (fieldName) {
+ case "address-level1":
+ this.doc.l10n.setAttributes(label, this.layout.addressLevel1L10nId);
+ break;
+ case "address-level2":
+ this.doc.l10n.setAttributes(label, this.layout.addressLevel2L10nId);
+ break;
+ case "address-level3":
+ this.doc.l10n.setAttributes(label, this.layout.addressLevel3L10nId);
+ break;
+ case "postal-code":
+ this.doc.l10n.setAttributes(label, this.layout.postalCodeL10nId);
+ break;
+ case "country":
+ // workaround because `autofill-address-country` is already defined
+ this.doc.l10n.setAttributes(
+ label,
+ `autofill-address-${fieldName}-only`
+ );
+ break;
+ default:
+ this.doc.l10n.setAttributes(label, `autofill-address-${fieldName}`);
+ break;
+ }
+ div.appendChild(label);
+
+ let input;
+ let popup;
+ if ("street-address".includes(fieldName)) {
+ input = this.doc.createElement("textarea");
+ input.setAttribute("rows", 3);
+ } else if (fieldName == "country") {
+ input = this.doc.createXULElement("menulist");
+ popup = this.#buildCountryMenupopup();
+ popup.addEventListener("popuphidden", e => e.stopPropagation());
+ input.appendChild(popup);
+
+ // The element will be removed after the popup is closed
+ /* eslint-disable mozilla/balanced-listeners */
+ input.addEventListener("command", event => {
+ event.stopPropagation();
+ this.country = input.selectedItem.value;
+ });
+ } else if (
+ fieldName == "address-level1" &&
+ this.layout.addressLevel1Options
+ ) {
+ input = this.doc.createXULElement("menulist");
+ popup = this.#buildAddressLevel1Menupopup();
+ popup.addEventListener("popuphidden", e => e.stopPropagation());
+ input.appendChild(popup);
+ } else {
+ input = this.doc.createElement("input");
+ }
+
+ input.setAttribute("id", inputId);
+
+ const value = this.newRecord[fieldName] ?? "";
+ if (popup) {
+ const menuitem = Array.from(popup.childNodes).find(
+ item =>
+ item.label.toLowerCase() === value?.toLowerCase() ||
+ item.value.toLowerCase() === value?.toLowerCase()
+ );
+ input.selectedItem = menuitem;
+ } else {
+ input.value = value;
+ }
+
+ div.appendChild(input);
+
+ return div;
+ }
+
+ /*
+ * This method generates a unique input ID using the field name of the address field.
+ *
+ * @param {string} fieldName The name of the address field
+ */
+ static getInputId(fieldName) {
+ return `address-edit-${fieldName}-input`;
+ }
+
+ /*
+ * Return a regular expression that matches the ID pattern generated by getInputId.
+ */
+ static #getInputIdMatchRegexp() {
+ const regex = /^address-edit-(.+)-input$/;
+ return regex;
+ }
+
+ /**
+ * Collects data from all visible address field inputs within the doorhanger.
+ * Since address fields may vary by country, only fields present for the
+ * current country's address format are included in the record.
+ */
+ recordToSave() {
+ let record = {};
+ const regex = AddressEditDoorhanger.#getInputIdMatchRegexp();
+ const elements = this.panel.querySelectorAll("input, textarea, menulist");
+ for (const element of elements) {
+ const match = element.id.match(regex);
+ if (match && match[1]) {
+ record[match[1]] = element.value;
+ }
+ }
+ return record;
+ }
+
+ onEventCallback(state) {
+ super.onEventCallback(state);
+
+ // Close the edit address doorhanger when it has been dismissed.
+ if (state == "dismissed") {
+ this.closeDoorhanger("cancel");
+ }
+ }
+}
+
+export class CreditCardSaveDoorhanger extends AutofillDoorhanger {
+ static contentClass = "credit-card-capture-content";
+
+ static telemetryType = AutofillTelemetry.CREDIT_CARD;
+ static telemetryObject = "capture_doorhanger";
+
+ static spotlightURL = "about:preferences#privacy-credit-card-autofill";
+
+ constructor(browser, oldRecord, newRecord, flowId) {
+ super(browser, oldRecord, newRecord, flowId);
+ }
+
+ /**
+ * We have not yet sync address and credit card design. After syncing,
+ * we should be able to use the same "class"
+ */
+ static content(panel) {
+ return panel.querySelector(`.${CreditCardSaveDoorhanger.contentClass}`);
+ }
+ get content() {
+ return CreditCardSaveDoorhanger.content(this.panel);
+ }
+
+ addCheckboxListener() {
+ if (!this.ui.options.checkbox) {
+ return;
+ }
+
+ const { checkbox } = this.panel;
+ if (checkbox && !checkbox.hidden) {
+ checkbox.addEventListener("command", event => {
+ let { secondaryButton, menubutton } =
+ event.target.closest("popupnotification");
+ let checked = event.target.checked;
+ Services.prefs.setBoolPref("services.sync.engine.creditcards", checked);
+ secondaryButton.disabled = checked;
+ menubutton.disabled = checked;
+ lazy.log.debug("Set creditCard sync to", checked);
+ });
+ }
+ }
+
+ removeCheckboxListener() {
+ if (!this.ui.options.checkbox) {
+ return;
+ }
+
+ const { checkbox } = this.panel;
+
+ if (checkbox && !checkbox.hidden) {
+ checkbox.removeEventListener(
+ "command",
+ this.ui.options.checkbox.callback
+ );
+ }
+ }
+
+ appendDescription() {
+ const docFragment = this.doc.createDocumentFragment();
+
+ const label = this.doc.createXULElement("label");
+ this.doc.l10n.setAttributes(label, this.ui.description.l10nId);
+ docFragment.appendChild(label);
+
+ const descriptionWrapper = this.doc.createXULElement("hbox");
+ descriptionWrapper.className = "desc-message-box";
+
+ const number =
+ this.newRecord["cc-number"] || this.newRecord["cc-number-decrypted"];
+ const name = this.newRecord["cc-name"];
+ const network = lazy.CreditCard.getType(number);
+
+ const descriptionIcon = lazy.CreditCard.getCreditCardLogo(network);
+ if (descriptionIcon) {
+ const icon = this.doc.createXULElement("image");
+ if (
+ typeof descriptionIcon == "string" &&
+ (descriptionIcon.includes("cc-logo") ||
+ descriptionIcon.includes("icon-credit"))
+ ) {
+ icon.setAttribute("src", descriptionIcon);
+ }
+ descriptionWrapper.appendChild(icon);
+ }
+
+ const description = this.doc.createXULElement("description");
+ description.textContent =
+ `${lazy.CreditCard.getMaskedNumber(number)}` + (name ? `, ${name}` : ``);
+
+ descriptionWrapper.appendChild(description);
+ docFragment.appendChild(descriptionWrapper);
+
+ this.content.appendChild(docFragment);
+ }
+
+ appendPrivacyPanelLink() {
+ const privacyLinkElement = this.doc.createXULElement("label", {
+ is: "text-link",
+ });
+ privacyLinkElement.setAttribute("useoriginprincipal", true);
+ privacyLinkElement.setAttribute(
+ "href",
+ CreditCardSaveDoorhanger.spotlightURL ||
+ "about:preferences#privacy-form-autofill"
+ );
+
+ const linkId = `autofill-options-link${
+ AppConstants.platform == "macosx" ? "-osx" : ""
+ }`;
+ this.doc.l10n.setAttributes(privacyLinkElement, linkId);
+
+ this.content.appendChild(privacyLinkElement);
+ }
+
+ // TODO: Currently, the header and description are unused. Align
+ // these with the address doorhanger's implementation during
+ // the credit card doorhanger redesign.
+ getNotificationHeader() {
+ return l10n.formatValueSync(this.ui.header.l10nId);
+ }
+
+ renderHeader() {
+ // Not implement
+ }
+
+ renderDescription() {
+ // Not implement
+ }
+
+ renderContent() {
+ this.content.replaceChildren();
+
+ this.appendDescription();
+
+ this.appendPrivacyPanelLink();
+ }
+
+ onEventCallback(state) {
+ super.onEventCallback(state);
+
+ if (state == "removed" || state == "dismissed") {
+ this.removeCheckboxListener();
+ } else if (state == "shown") {
+ this.addCheckboxListener();
+ }
+ }
+
+ // The record to be saved by this doorhanger
+ recordToSave() {
+ return this.newRecord;
+ }
+}
+
+export class CreditCardUpdateDoorhanger extends CreditCardSaveDoorhanger {
+ static telemetryType = AutofillTelemetry.CREDIT_CARD;
+ static telemetryObject = "update_doorhanger";
+
+ constructor(browser, oldRecord, newRecord, flowId) {
+ super(browser, oldRecord, newRecord, flowId);
+ }
+}
+
+CONTENT = {
+ [AddressSaveDoorhanger.name]: {
+ id: "address-save-update",
+ anchor: {
+ id: "autofill-address-notification-icon",
+ URL: "chrome://formautofill/content/formfill-anchor.svg",
+ tooltiptext: l10n.formatValueSync("autofill-message-tooltip"),
+ },
+ header: {
+ l10nId: "address-capture-save-doorhanger-header",
+ },
+ description: {
+ l10nId: "address-capture-save-doorhanger-description",
+ },
+ menu: [
+ {
+ l10nId: "address-capture-manage-address-button",
+ evt: "open-pref",
+ },
+ {
+ l10nId: "address-capture-learn-more-button",
+ evt: "learn-more",
+ },
+ ],
+ content: {
+ // We divide address data into two sections to display in the Address Save Doorhanger.
+ sections: [
+ {
+ imgClass: "address-capture-img-address",
+ categories: [
+ "name",
+ "organization",
+ "street-address",
+ "address",
+ "country",
+ ],
+ },
+ {
+ imgClass: "address-capture-img-email",
+ categories: ["email", "tel"],
+ },
+ ],
+ },
+ footer: {
+ mainAction: {
+ l10nId: "address-capture-save-button",
+ callbackState: "create",
+ },
+ secondaryActions: [
+ {
+ l10nId: "address-capture-not-now-button",
+ callbackState: "cancel",
+ },
+ ],
+ },
+ options: {
+ autofocus: true,
+ persistWhileVisible: true,
+ hideClose: true,
+ },
+ },
+
+ [AddressUpdateDoorhanger.name]: {
+ id: "address-save-update",
+ anchor: {
+ id: "autofill-address-notification-icon",
+ URL: "chrome://formautofill/content/formfill-anchor.svg",
+ tooltiptext: l10n.formatValueSync("autofill-message-tooltip"),
+ },
+ header: {
+ l10nId: "address-capture-update-doorhanger-header",
+ },
+ menu: [
+ {
+ l10nId: "address-capture-manage-address-button",
+ evt: "open-pref",
+ },
+ {
+ l10nId: "address-capture-learn-more-button",
+ evt: "learn-more",
+ },
+ ],
+ content: {
+ // Addresses fields are categoried into two sections, each section
+ // has its own icon
+ sections: [
+ {
+ imgClass: "address-capture-img-address",
+ categories: [
+ "name",
+ "organization",
+ "street-address",
+ "address",
+ "country",
+ ],
+ },
+ {
+ imgClass: "address-capture-img-email",
+ categories: ["email", "tel"],
+ },
+ ],
+ },
+ footer: {
+ mainAction: {
+ l10nId: "address-capture-update-button",
+ callbackState: "update",
+ },
+ secondaryActions: [
+ {
+ l10nId: "address-capture-not-now-button",
+ callbackState: "cancel",
+ },
+ ],
+ },
+ options: {
+ autofocus: true,
+ persistWhileVisible: true,
+ hideClose: true,
+ },
+ },
+
+ [AddressEditDoorhanger.name]: {
+ id: "address-edit",
+ anchor: {
+ id: "autofill-address-notification-icon",
+ URL: "chrome://formautofill/content/formfill-anchor.svg",
+ tooltiptext: l10n.formatValueSync("autofill-message-tooltip"),
+ },
+ header: {
+ l10nId: "address-capture-edit-doorhanger-header",
+ },
+ menu: null,
+ content: {
+ // We start by organizing the fields in a specific order:
+ // name, organization, and country are fixed and come first.
+ // These are followed by country-specific fields, which are
+ // laid out differently for each country (as referenced from libaddressinput).
+ // Finally, we place the telephone and email fields at the end.
+ countrySpecificFieldsBefore: "tel",
+ fixedFields: [
+ { fieldId: "name", newLine: true },
+ { fieldId: "organization", newLine: true },
+ { fieldId: "country", newLine: true },
+ { fieldId: "tel", newLine: false },
+ { fieldId: "email", newLine: true },
+ ],
+ },
+ footer: {
+ mainAction: {
+ l10nId: "address-capture-save-button",
+ callbackState: "save",
+ },
+ secondaryActions: [
+ {
+ l10nId: "address-capture-cancel-button",
+ callbackState: "cancel",
+ dismiss: true,
+ },
+ ],
+ },
+ options: {
+ autofocus: true,
+ persistWhileVisible: true,
+ hideClose: true,
+ },
+ },
+
+ [CreditCardSaveDoorhanger.name]: {
+ id: "credit-card-save-update",
+ anchor: {
+ id: "autofill-credit-card-notification-icon",
+ URL: "chrome://formautofill/content/formfill-anchor.svg",
+ tooltiptext: l10n.formatValueSync("autofill-message-tooltip"),
+ },
+ header: {
+ l10nId: "credit-card-save-doorhanger-header",
+ },
+ description: {
+ l10nId: "credit-card-save-doorhanger-description",
+ },
+ content: {},
+ footer: {
+ mainAction: {
+ l10nId: "credit-card-capture-save-button",
+ callbackState: "create",
+ },
+ secondaryActions: [
+ {
+ l10nId: "credit-card-capture-cancel-button",
+ callbackState: "cancel",
+ },
+ {
+ l10nId: "credit-card-capture-never-save-button",
+ callbackState: "disable",
+ },
+ ],
+ },
+ options: {
+ persistWhileVisible: true,
+ popupIconURL: "chrome://formautofill/content/icon-credit-card.svg",
+ hideClose: true,
+
+ checkbox: {
+ get checked() {
+ return Services.prefs.getBoolPref("services.sync.engine.creditcards");
+ },
+ get label() {
+ // Only set the label when the fallowing conditions existed:
+ // - sync account is set
+ // - credit card sync is disabled
+ // - credit card sync is available
+ // otherwise return null label to hide checkbox.
+ return Services.prefs.prefHasUserValue("services.sync.username") &&
+ !Services.prefs.getBoolPref("services.sync.engine.creditcards") &&
+ Services.prefs.getBoolPref(
+ "services.sync.engine.creditcards.available"
+ )
+ ? l10n.formatValueSync(
+ "credit-card-doorhanger-credit-cards-sync-checkbox"
+ )
+ : null;
+ },
+ },
+ },
+ },
+
+ [CreditCardUpdateDoorhanger.name]: {
+ id: "credit-card-save-update",
+ anchor: {
+ id: "autofill-credit-card-notification-icon",
+ URL: "chrome://formautofill/content/formfill-anchor.svg",
+ tooltiptext: l10n.formatValueSync("autofill-message-tooltip"),
+ },
+ header: {
+ l10nId: "credit-card-update-doorhanger-header",
+ },
+ description: {
+ l10nId: "credit-card-update-doorhanger-description",
+ },
+ content: {},
+ footer: {
+ mainAction: {
+ l10nId: "credit-card-capture-update-button",
+ callbackState: "update",
+ },
+ secondaryActions: [
+ {
+ l10nId: "credit-card-capture-save-new-button",
+ callbackState: "create",
+ },
+ ],
+ },
+ options: {
+ persistWhileVisible: true,
+ popupIconURL: "chrome://formautofill/content/icon-credit-card.svg",
+ hideClose: true,
+ },
+ },
+};
+
+export let FormAutofillPrompter = {
+ async promptToSaveCreditCard(
+ browser,
+ storage,
+ flowId,
+ { oldRecord, newRecord }
+ ) {
+ const showUpdateDoorhanger = !!Object.keys(oldRecord).length;
+
+ const { ownerGlobal: win } = browser;
+ win.MozXULElement.insertFTLIfNeeded(
+ "toolkit/formautofill/formAutofill.ftl"
+ );
+
+ let action;
+ const doorhanger = showUpdateDoorhanger
+ ? new CreditCardUpdateDoorhanger(browser, oldRecord, newRecord, flowId)
+ : new CreditCardSaveDoorhanger(browser, oldRecord, newRecord, flowId);
+ action = await doorhanger.show();
+
+ lazy.log.debug(`Doorhanger action is ${action}`);
+
+ if (action == "cancel") {
+ return;
+ } else if (action == "disable") {
+ Services.prefs.setBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF, false);
+ return;
+ }
+
+ if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) {
+ lazy.log.warn("User canceled encryption login");
+ return;
+ }
+
+ this._updateStorageAfterInteractWithPrompt(
+ browser,
+ storage,
+ "credit-card",
+ action == "update" ? oldRecord : null,
+ doorhanger.recordToSave()
+ );
+ },
+
+ /**
+ * Show save or update address doorhanger
+ *
+ * @param {Element<browser>} browser Browser to show the save/update address prompt
+ * @param {object} storage Address storage
+ * @param {string} flowId Unique GUID to record a series of the same user action
+ * @param {object} options
+ * @param {object} [options.oldRecord] Record to be merged
+ * @param {object} [options.newRecord] Record with more information
+ */
+ async promptToSaveAddress(
+ browser,
+ storage,
+ flowId,
+ { oldRecord, newRecord }
+ ) {
+ const showUpdateDoorhanger = !!Object.keys(oldRecord).length;
+
+ lazy.log.debug(
+ `Show the ${showUpdateDoorhanger ? "update" : "save"} address doorhanger`
+ );
+
+ const { ownerGlobal: win } = browser;
+ await win.ensureCustomElements("moz-support-link");
+ win.MozXULElement.insertFTLIfNeeded(
+ "toolkit/formautofill/formAutofill.ftl"
+ );
+ // address-autofill-* are defined in browser/preferences now
+ win.MozXULElement.insertFTLIfNeeded("browser/preferences/formAutofill.ftl");
+
+ let doorhanger;
+ let action;
+ while (true) {
+ doorhanger = showUpdateDoorhanger
+ ? new AddressUpdateDoorhanger(browser, oldRecord, newRecord, flowId)
+ : new AddressSaveDoorhanger(browser, oldRecord, newRecord, flowId);
+ action = await doorhanger.show();
+
+ if (action == "edit-address") {
+ doorhanger = new AddressEditDoorhanger(
+ browser,
+ { ...oldRecord, ...newRecord },
+ flowId
+ );
+ action = await doorhanger.show();
+
+ // If users cancel the edit address doorhanger, show the save/update
+ // doorhanger again.
+ if (action == "cancel") {
+ continue;
+ }
+ }
+
+ break;
+ }
+
+ lazy.log.debug(`Doorhanger action is ${action}`);
+
+ if (action == "cancel") {
+ return;
+ }
+
+ this._updateStorageAfterInteractWithPrompt(
+ browser,
+ storage,
+ "address",
+ showUpdateDoorhanger ? oldRecord : null,
+ doorhanger.recordToSave()
+ );
+ },
+
+ // TODO: Simplify the code after integrating credit card prompt to use AutofillDoorhanger
+ async _updateStorageAfterInteractWithPrompt(
+ browser,
+ storage,
+ type,
+ oldRecord,
+ newRecord
+ ) {
+ let changedGUID = null;
+ if (oldRecord) {
+ changedGUID = oldRecord.guid;
+ await storage.update(changedGUID, newRecord, true);
+ } else {
+ changedGUID = await storage.add(newRecord);
+ }
+ storage.notifyUsed(changedGUID);
+
+ const hintId = `confirmation-hint-${type}-${
+ oldRecord ? "updated" : "created"
+ }`;
+ showConfirmation(browser, hintId);
+ },
+};
diff --git a/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs b/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs
new file mode 100644
index 0000000000..1f323998c3
--- /dev/null
+++ b/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs
@@ -0,0 +1,106 @@
+/* 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/. */
+
+/*
+ * Implements an interface of the storage of Form Autofill.
+ */
+
+// We expose a singleton from this module. Some tests may import the
+// constructor via a backstage pass.
+import {
+ AddressesBase,
+ CreditCardsBase,
+ FormAutofillStorageBase,
+} from "resource://autofill/FormAutofillStorageBase.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+});
+
+const PROFILE_JSON_FILE_NAME = "autofill-profiles.json";
+
+class Addresses extends AddressesBase {}
+
+class CreditCards extends CreditCardsBase {
+ constructor(store) {
+ super(store);
+ }
+
+ async _encryptNumber(creditCard) {
+ if (!("cc-number-encrypted" in creditCard)) {
+ if ("cc-number" in creditCard) {
+ let ccNumber = creditCard["cc-number"];
+ if (lazy.CreditCard.isValidNumber(ccNumber)) {
+ creditCard["cc-number"] =
+ lazy.CreditCard.getLongMaskedNumber(ccNumber);
+ } else {
+ // Credit card numbers can be entered on versions of Firefox that don't validate
+ // the number and then synced to this version of Firefox. Therefore, mask the
+ // full number if the number is invalid on this version.
+ creditCard["cc-number"] = "*".repeat(ccNumber.length);
+ }
+ creditCard["cc-number-encrypted"] = await lazy.OSKeyStore.encrypt(
+ ccNumber
+ );
+ } else {
+ creditCard["cc-number-encrypted"] = "";
+ }
+ }
+ }
+}
+
+export class FormAutofillStorage extends FormAutofillStorageBase {
+ constructor(path) {
+ super(path);
+ }
+
+ getAddresses() {
+ if (!this._addresses) {
+ this._store.ensureDataReady();
+ this._addresses = new Addresses(this._store);
+ }
+ return this._addresses;
+ }
+
+ getCreditCards() {
+ if (!this._creditCards) {
+ this._store.ensureDataReady();
+ this._creditCards = new CreditCards(this._store);
+ }
+ return this._creditCards;
+ }
+
+ /**
+ * Loads the profile data from file to memory.
+ *
+ * @returns {JSONFile}
+ * The JSONFile store.
+ */
+ _initializeStore() {
+ return new lazy.JSONFile({
+ path: this._path,
+ dataPostProcessor: this._dataPostProcessor.bind(this),
+ });
+ }
+
+ _dataPostProcessor(data) {
+ data.version = this.version;
+ if (!data.addresses) {
+ data.addresses = [];
+ }
+ if (!data.creditCards) {
+ data.creditCards = [];
+ }
+ return data;
+ }
+}
+
+// The singleton exposed by this module.
+export const formAutofillStorage = new FormAutofillStorage(
+ PathUtils.join(PathUtils.profileDir, PROFILE_JSON_FILE_NAME)
+);
diff --git a/toolkit/components/formautofill/jar.mn b/toolkit/components/formautofill/jar.mn
new file mode 100644
index 0000000000..d840f267d3
--- /dev/null
+++ b/toolkit/components/formautofill/jar.mn
@@ -0,0 +1,17 @@
+# 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/.
+
+toolkit.jar:
+% resource autofill %res/autofill/
+ res/autofill/ (./*.sys.mjs)
+ res/autofill/phonenumberutils/ (./phonenumberutils/*.sys.mjs)
+ res/autofill/addressmetadata/ (./addressmetadata/*)
+ res/autofill/content/ (./content/*)
+#ifdef ANDROID
+ res/autofill/FormAutofillPrompter.sys.mjs (./android/FormAutofillPrompter.sys.mjs)
+ res/autofill/FormAutofillStorage.sys.mjs (./android/FormAutofillStorage.sys.mjs)
+#else
+ res/autofill/FormAutofillPrompter.sys.mjs (./default/FormAutofillPrompter.sys.mjs)
+ res/autofill/FormAutofillStorage.sys.mjs (./default/FormAutofillStorage.sys.mjs)
+#endif
diff --git a/toolkit/components/formautofill/metrics.yaml b/toolkit/components/formautofill/metrics.yaml
new file mode 100644
index 0000000000..8193fbdd00
--- /dev/null
+++ b/toolkit/components/formautofill/metrics.yaml
@@ -0,0 +1,279 @@
+# 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/.
+
+# Adding a new metric? We have docs for that!
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - 'Toolkit :: Form Autofill'
+
+formautofill.creditcards:
+ autofill_profiles_count:
+ type: quantity
+ unit: credit card autofill profiles
+ description: >
+ Count at store time how many credit card autofill profiles the user has.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=990203
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1834571
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1834571#c2
+ notification_emails:
+ - autofill@lists.mozilla.org
+ - passwords-dev@mozilla.org
+ expires: never
+ telemetry_mirror: FORMAUTOFILL_CREDITCARDS_AUTOFILL_PROFILES_COUNT
+
+ form_detected:
+ type: event
+ description: >
+ Recorded when a form is recognized as a credit card form.
+ The possible value of cc_* are "autocomplete", "undetected", "regexp" or an integer between 0-100:
+ - When the value is "autocomplete", the field is identified via autocomplete attribute
+ - When the value is "undetected", the field is not detected in the form
+ - When the value is "regexp", then the field is identified by regexp-based heuristic
+ - When the value is an integer greater than 0, the value indicates the confidence value from fathom (normalized to 0-100)
+ The flow id points to an interaction session with a credit card form and is shared across cc_form events .
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653162
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1720608
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1767907
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1757731
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1834570
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073#c5
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073#c7
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1720608#c5
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1757731#c5
+ notification_emails:
+ - autofill@lists.mozilla.org
+ - passwords-dev@mozilla.org
+ expires: never
+ extra_keys:
+ flow_id:
+ description: Flow id of an interaction session with a credit card form
+ type: string
+ cc_name:
+ description: Credit card cardholder name field result
+ type: string
+ cc_number:
+ description: Credit card number field result
+ type: string
+ cc_type:
+ description: Credit card type result
+ type: string
+ cc_exp:
+ description: Credit card expiration date result
+ type: string
+ cc_exp_month:
+ description: Credit card expiration month result
+ type: string
+ cc_exp_year:
+ description: Credit card expiration year result
+ type: string
+ cc_number_multi_parts:
+ description: The count of input fields for splitting the Credit Card Number
+ type: quantity
+
+ form_popup_shown:
+ type: event
+ description: >
+ Recorded when autofill popup is shown for a credit card form.
+ The flow id indicates an interaction session with the a form and is shared across cc_form events.
+ The field_name is used to record the field that triggers this event.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653162
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1720608
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1767907
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1757731
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1834570
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073#c5
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073#c7
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1720608#c5
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1757731#c5
+ notification_emails:
+ - autofill@lists.mozilla.org
+ - passwords-dev@mozilla.org
+ expires: never
+ extra_keys:
+ flow_id:
+ description: Flow id of an interaction session with a credit card form
+ type: string
+ field_name:
+ description: Name of the field being affected by the event
+ type: string
+
+ form_filled:
+ type: event
+ description: >
+ Recorded when a credit card form is autofilled.
+ The flow id indicates an interaction session with the a form and is shared across cc_form events.
+ The possible value of cc_* are `filled`, `not_filled`, `user_filled` or `unavailable`
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653162
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1720608
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1767907
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1757731
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1834570
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073#c5
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073#c7
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1720608#c5
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1757731#c5
+ notification_emails:
+ - autofill@lists.mozilla.org
+ - passwords-dev@mozilla.org
+ expires: never
+ extra_keys:
+ flow_id:
+ description: Flow id of an interaction session with a credit card form
+ type: string
+ cc_name:
+ description: Credit card cardholder name field result
+ type: string
+ cc_number:
+ description: Credit card number field result
+ type: string
+ cc_type:
+ description: Credit card type result
+ type: string
+ cc_exp:
+ description: Credit card expiration date result
+ type: string
+ cc_exp_month:
+ description: Credit card expiration month result
+ type: string
+ cc_exp_year:
+ description: Credit card expiration year result
+ type: string
+
+ form_filled_modified:
+ type: event
+ description: >
+ Recorded when a field in a credit card form is autofilled and then modified by the user.
+ The flow id indicates an interaction session with the a form and is shared across cc_form events.
+ The field_name is used to record the field that triggers this event.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653162
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1720608
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1767907
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1757731
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1834570
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073#c5
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073#c7
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1720608#c5
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1757731#c5
+ notification_emails:
+ - autofill@lists.mozilla.org
+ - passwords-dev@mozilla.org
+ expires: never
+ extra_keys:
+ flow_id:
+ description: Flow id of an interaction session with a credit card form
+ type: string
+ field_name:
+ description: Name of the field being affected by the event
+ type: string
+
+ form_submitted:
+ type: event
+ description: >
+ Recorded when a credit card form is submitted.
+ The flow id indicates an interaction session with the a form and is shared across cc_form events.
+ The possible value of cc_* are `autofilled`, `user_filled` or `unavailable`
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653162
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1720608
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1767907
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1757731
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1834570
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073#c5
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073#c7
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1720608#c5
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1757731#c5
+ notification_emails:
+ - autofill@lists.mozilla.org
+ - passwords-dev@mozilla.org
+ expires: never
+ extra_keys:
+ flow_id:
+ description: Flow id of an interaction session with a credit card form
+ type: string
+ cc_name:
+ description: Credit card cardholder name field result
+ type: string
+ cc_number:
+ description: Credit card number field result
+ type: string
+ cc_type:
+ description: Credit card type result
+ type: string
+ cc_exp:
+ description: Credit card expiration date result
+ type: string
+ cc_exp_month:
+ description: Credit card expiration month result
+ type: string
+ cc_exp_year:
+ description: Credit card expiration year result
+ type: string
+
+ form_cleared:
+ type: event
+ description: >
+ Recorded when a credit card form is cleared .
+ The flow id indicates an interaction session with the a form and is shared across cc_form events.
+ The field_name is used to record the field that triggers this event
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653162
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1720608
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1767907
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1757731
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1834570
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073#c5
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1653073#c7
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1720608#c5
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1757731#c5
+ notification_emails:
+ - autofill@lists.mozilla.org
+ - passwords-dev@mozilla.org
+ expires: never
+ extra_keys:
+ flow_id:
+ description: Flow id of an interaction session with a credit card form
+ type: string
+ field_name:
+ description: Name of the field being affected by the event
+ type: string
+
+formautofill:
+ form_submission_heuristic:
+ type: labeled_counter
+ description:
+ The heuristic that detected the form submission.
+ labels:
+ - form-submit-event
+ - form-removal-after-fetch
+ - page-navigation
+ - iframe-pagehide
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1874829
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1874829#c4
+ notification_emails:
+ - autofill@lists.mozilla.org
+ - passwords-dev@mozilla.org
+ expires: 130
diff --git a/toolkit/components/formautofill/moz.build b/toolkit/components/formautofill/moz.build
new file mode 100644
index 0000000000..542fc595e0
--- /dev/null
+++ b/toolkit/components/formautofill/moz.build
@@ -0,0 +1,37 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Form Autofill")
+
+EXTRA_JS_MODULES.shared += [
+ "shared/AddressComponent.sys.mjs",
+ "shared/AddressMetaData.sys.mjs",
+ "shared/AddressMetaDataExtension.sys.mjs",
+ "shared/AddressMetaDataLoader.sys.mjs",
+ "shared/AddressParser.sys.mjs",
+ "shared/CreditCardRecord.sys.mjs",
+ "shared/CreditCardRuleset.sys.mjs",
+ "shared/FieldScanner.sys.mjs",
+ "shared/FormAutofillHandler.sys.mjs",
+ "shared/FormAutofillHeuristics.sys.mjs",
+ "shared/FormAutofillNameUtils.sys.mjs",
+ "shared/FormAutofillSection.sys.mjs",
+ "shared/FormAutofillUtils.sys.mjs",
+ "shared/FormStateManager.sys.mjs",
+ "shared/HeuristicsRegExp.sys.mjs",
+ "shared/LabelUtils.sys.mjs",
+]
+
+EXPORTS.mozilla += ["FormAutofillNative.h"]
+
+UNIFIED_SOURCES += ["FormAutofillNative.cpp"]
+
+include("/ipc/chromium/chromium-config.mozbuild")
+
+FINAL_LIBRARY = "xul"
diff --git a/toolkit/components/formautofill/phonenumberutils/PhoneNumber.sys.mjs b/toolkit/components/formautofill/phonenumberutils/PhoneNumber.sys.mjs
new file mode 100644
index 0000000000..80b5e43acb
--- /dev/null
+++ b/toolkit/components/formautofill/phonenumberutils/PhoneNumber.sys.mjs
@@ -0,0 +1,474 @@
+/* This Source Code Form is subject to the terms of the Apache License, Version
+ * 2.0. If a copy of the Apache License was not distributed with this file, You
+ * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */
+
+// This library came from https://github.com/andreasgal/PhoneNumber.js but will
+// be further maintained by our own in Form Autofill codebase.
+
+import { PHONE_NUMBER_META_DATA } from "resource://autofill/phonenumberutils/PhoneNumberMetaData.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PhoneNumberNormalizer:
+ "resource://autofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs",
+});
+
+export var PhoneNumber = (function (dataBase) {
+ const MAX_PHONE_NUMBER_LENGTH = 50;
+ const NON_ALPHA_CHARS = /[^a-zA-Z]/g;
+ const NON_DIALABLE_CHARS = /[^,#+\*\d]/g;
+ const NON_DIALABLE_CHARS_ONCE = new RegExp(NON_DIALABLE_CHARS.source);
+ const SPLIT_FIRST_GROUP = /^(\d+)(.*)$/;
+ const LEADING_PLUS_CHARS_PATTERN = /^[+\uFF0B]+/g;
+
+ // Format of the string encoded meta data. If the name contains "^" or "$"
+ // we will generate a regular expression from the value, with those special
+ // characters as prefix/suffix.
+ const META_DATA_ENCODING = [
+ "region",
+ "^(?:internationalPrefix)",
+ "nationalPrefix",
+ "^(?:nationalPrefixForParsing)",
+ "nationalPrefixTransformRule",
+ "nationalPrefixFormattingRule",
+ "^possiblePattern$",
+ "^nationalPattern$",
+ "formats",
+ ];
+
+ const FORMAT_ENCODING = [
+ "^pattern$",
+ "nationalFormat",
+ "^leadingDigits",
+ "nationalPrefixFormattingRule",
+ "internationalFormat",
+ ];
+
+ let regionCache = Object.create(null);
+
+ // Parse an array of strings into a convenient object. We store meta
+ // data as arrays since thats much more compact than JSON.
+ function ParseArray(array, encoding, obj) {
+ for (let n = 0; n < encoding.length; ++n) {
+ let value = array[n];
+ if (!value) {
+ continue;
+ }
+ let field = encoding[n];
+ let fieldAlpha = field.replace(NON_ALPHA_CHARS, "");
+ if (field != fieldAlpha) {
+ value = new RegExp(field.replace(fieldAlpha, value));
+ }
+ obj[fieldAlpha] = value;
+ }
+ return obj;
+ }
+
+ // Parse string encoded meta data into a convenient object
+ // representation.
+ function ParseMetaData(countryCode, md) {
+ let array = JSON.parse(md);
+ md = ParseArray(array, META_DATA_ENCODING, { countryCode });
+ regionCache[md.region] = md;
+ return md;
+ }
+
+ // Parse string encoded format data into a convenient object
+ // representation.
+ function ParseFormat(md) {
+ let formats = md.formats;
+ if (!formats) {
+ return;
+ }
+ // Bail if we already parsed the format definitions.
+ if (!Array.isArray(formats[0])) {
+ return;
+ }
+ for (let n = 0; n < formats.length; ++n) {
+ formats[n] = ParseArray(formats[n], FORMAT_ENCODING, {});
+ }
+ }
+
+ // Search for the meta data associated with a region identifier ("US") in
+ // our database, which is indexed by country code ("1"). Since we have
+ // to walk the entire database for this, we cache the result of the lookup
+ // for future reference.
+ function FindMetaDataForRegion(region) {
+ // Check in the region cache first. This will find all entries we have
+ // already resolved (parsed from a string encoding).
+ let md = regionCache[region];
+ if (md) {
+ return md;
+ }
+ for (let countryCode in dataBase) {
+ let entry = dataBase[countryCode];
+ // Each entry is a string encoded object of the form '["US..', or
+ // an array of strings. We don't want to parse the string here
+ // to save memory, so we just substring the region identifier
+ // and compare it. For arrays, we compare against all region
+ // identifiers with that country code. We skip entries that are
+ // of type object, because they were already resolved (parsed into
+ // an object), and their country code should have been in the cache.
+ if (Array.isArray(entry)) {
+ for (let n = 0; n < entry.length; n++) {
+ if (typeof entry[n] == "string" && entry[n].substr(2, 2) == region) {
+ if (n > 0) {
+ // Only the first entry has the formats field set.
+ // Parse the main country if we haven't already and use
+ // the formats field from the main country.
+ if (typeof entry[0] == "string") {
+ entry[0] = ParseMetaData(countryCode, entry[0]);
+ }
+ let formats = entry[0].formats;
+ let current = ParseMetaData(countryCode, entry[n]);
+ current.formats = formats;
+ entry[n] = current;
+ return entry[n];
+ }
+
+ entry[n] = ParseMetaData(countryCode, entry[n]);
+ return entry[n];
+ }
+ }
+ continue;
+ }
+ if (typeof entry == "string" && entry.substr(2, 2) == region) {
+ dataBase[countryCode] = ParseMetaData(countryCode, entry);
+ return dataBase[countryCode];
+ }
+ }
+ }
+
+ // Format a national number for a given region. The boolean flag "intl"
+ // indicates whether we want the national or international format.
+ function FormatNumber(regionMetaData, number, intl) {
+ // We lazily parse the format description in the meta data for the region,
+ // so make sure to parse it now if we haven't already done so.
+ ParseFormat(regionMetaData);
+ let formats = regionMetaData.formats;
+ if (!formats) {
+ return null;
+ }
+ for (let n = 0; n < formats.length; ++n) {
+ let format = formats[n];
+ // The leading digits field is optional. If we don't have it, just
+ // use the matching pattern to qualify numbers.
+ if (format.leadingDigits && !format.leadingDigits.test(number)) {
+ continue;
+ }
+ if (!format.pattern.test(number)) {
+ continue;
+ }
+ if (intl) {
+ // If there is no international format, just fall back to the national
+ // format.
+ let internationalFormat = format.internationalFormat;
+ if (!internationalFormat) {
+ internationalFormat = format.nationalFormat;
+ }
+ // Some regions have numbers that can't be dialed from outside the
+ // country, indicated by "NA" for the international format of that
+ // number format pattern.
+ if (internationalFormat == "NA") {
+ return null;
+ }
+ // Prepend "+" and the country code.
+ number =
+ "+" +
+ regionMetaData.countryCode +
+ " " +
+ number.replace(format.pattern, internationalFormat);
+ } else {
+ number = number.replace(format.pattern, format.nationalFormat);
+ // The region has a national prefix formatting rule, and it can be overwritten
+ // by each actual number format rule.
+ let nationalPrefixFormattingRule =
+ regionMetaData.nationalPrefixFormattingRule;
+ if (format.nationalPrefixFormattingRule) {
+ nationalPrefixFormattingRule = format.nationalPrefixFormattingRule;
+ }
+ if (nationalPrefixFormattingRule) {
+ // The prefix formatting rule contains two magic markers, "$NP" and "$FG".
+ // "$NP" will be replaced by the national prefix, and "$FG" with the
+ // first group of numbers.
+ let match = number.match(SPLIT_FIRST_GROUP);
+ if (match) {
+ let firstGroup = match[1];
+ let rest = match[2];
+ let prefix = nationalPrefixFormattingRule;
+ prefix = prefix.replace("$NP", regionMetaData.nationalPrefix);
+ prefix = prefix.replace("$FG", firstGroup);
+ number = prefix + rest;
+ }
+ }
+ }
+ return number == "NA" ? null : number;
+ }
+ return null;
+ }
+
+ function NationalNumber(regionMetaData, number) {
+ this.region = regionMetaData.region;
+ this.regionMetaData = regionMetaData;
+ this.number = number;
+ }
+
+ // NationalNumber represents the result of parsing a phone number. We have
+ // three getters on the prototype that format the number in national and
+ // international format. Once called, the getters put a direct property
+ // onto the object, caching the result.
+ NationalNumber.prototype = {
+ // +1 949-726-2896
+ get internationalFormat() {
+ let value = FormatNumber(this.regionMetaData, this.number, true);
+ Object.defineProperty(this, "internationalFormat", {
+ value,
+ enumerable: true,
+ });
+ return value;
+ },
+ // (949) 726-2896
+ get nationalFormat() {
+ let value = FormatNumber(this.regionMetaData, this.number, false);
+ Object.defineProperty(this, "nationalFormat", {
+ value,
+ enumerable: true,
+ });
+ return value;
+ },
+ // +19497262896
+ get internationalNumber() {
+ let value = this.internationalFormat
+ ? this.internationalFormat.replace(NON_DIALABLE_CHARS, "")
+ : null;
+ Object.defineProperty(this, "internationalNumber", {
+ value,
+ enumerable: true,
+ });
+ return value;
+ },
+ // 9497262896
+ get nationalNumber() {
+ let value = this.nationalFormat
+ ? this.nationalFormat.replace(NON_DIALABLE_CHARS, "")
+ : null;
+ Object.defineProperty(this, "nationalNumber", {
+ value,
+ enumerable: true,
+ });
+ return value;
+ },
+ // country name 'US'
+ get countryName() {
+ let value = this.region ? this.region : null;
+ Object.defineProperty(this, "countryName", { value, enumerable: true });
+ return value;
+ },
+ // country code '+1'
+ get countryCode() {
+ let value = this.regionMetaData.countryCode
+ ? "+" + this.regionMetaData.countryCode
+ : null;
+ Object.defineProperty(this, "countryCode", { value, enumerable: true });
+ return value;
+ },
+ };
+
+ // Check whether the number is valid for the given region.
+ function IsValidNumber(number, md) {
+ return md.possiblePattern.test(number);
+ }
+
+ // Check whether the number is a valid national number for the given region.
+ /* eslint-disable no-unused-vars */
+ function IsNationalNumber(number, md) {
+ return IsValidNumber(number, md) && md.nationalPattern.test(number);
+ }
+
+ // Determine the country code a number starts with, or return null if
+ // its not a valid country code.
+ function ParseCountryCode(number) {
+ for (let n = 1; n <= 3; ++n) {
+ let cc = number.substr(0, n);
+ if (dataBase[cc]) {
+ return cc;
+ }
+ }
+ return null;
+ }
+
+ // Parse a national number for a specific region. Return null if the
+ // number is not a valid national number (it might still be a possible
+ // number for parts of that region).
+ function ParseNationalNumber(number, md) {
+ if (!md.possiblePattern.test(number) || !md.nationalPattern.test(number)) {
+ return null;
+ }
+ // Success.
+ return new NationalNumber(md, number);
+ }
+
+ function ParseNationalNumberAndCheckNationalPrefix(number, md) {
+ let ret;
+
+ // This is not an international number. See if its a national one for
+ // the current region. National numbers can start with the national
+ // prefix, or without.
+ if (md.nationalPrefixForParsing) {
+ // Some regions have specific national prefix parse rules. Apply those.
+ let withoutPrefix = number.replace(
+ md.nationalPrefixForParsing,
+ md.nationalPrefixTransformRule || ""
+ );
+ ret = ParseNationalNumber(withoutPrefix, md);
+ if (ret) {
+ return ret;
+ }
+ } else {
+ // If there is no specific national prefix rule, just strip off the
+ // national prefix from the beginning of the number (if there is one).
+ let nationalPrefix = md.nationalPrefix;
+ if (
+ nationalPrefix &&
+ number.indexOf(nationalPrefix) == 0 &&
+ (ret = ParseNationalNumber(number.substr(nationalPrefix.length), md))
+ ) {
+ return ret;
+ }
+ }
+ ret = ParseNationalNumber(number, md);
+ if (ret) {
+ return ret;
+ }
+ }
+
+ function ParseNumberByCountryCode(number, countryCode) {
+ let ret;
+
+ // Lookup the meta data for the region (or regions) and if the rest of
+ // the number parses for that region, return the parsed number.
+ let entry = dataBase[countryCode];
+ if (Array.isArray(entry)) {
+ for (let n = 0; n < entry.length; ++n) {
+ if (typeof entry[n] == "string") {
+ entry[n] = ParseMetaData(countryCode, entry[n]);
+ }
+ if (n > 0) {
+ entry[n].formats = entry[0].formats;
+ }
+ ret = ParseNationalNumberAndCheckNationalPrefix(number, entry[n]);
+ if (ret) {
+ return ret;
+ }
+ }
+ return null;
+ }
+ if (typeof entry == "string") {
+ entry = dataBase[countryCode] = ParseMetaData(countryCode, entry);
+ }
+ return ParseNationalNumberAndCheckNationalPrefix(number, entry);
+ }
+
+ // Parse an international number that starts with the country code. Return
+ // null if the number is not a valid international number.
+ function ParseInternationalNumber(number) {
+ // Parse and strip the country code.
+ let countryCode = ParseCountryCode(number);
+ if (!countryCode) {
+ return null;
+ }
+ number = number.substr(countryCode.length);
+
+ return ParseNumberByCountryCode(number, countryCode);
+ }
+
+ // Parse a number and transform it into the national format, removing any
+ // international dial prefixes and country codes.
+ function ParseNumber(number, defaultRegion) {
+ let ret;
+
+ // Remove formating characters and whitespace.
+ number = lazy.PhoneNumberNormalizer.Normalize(number);
+
+ // If there is no defaultRegion or the defaultRegion is the global region,
+ // we can't parse international access codes.
+ if ((!defaultRegion || defaultRegion === "001") && number[0] !== "+") {
+ return null;
+ }
+
+ // Detect and strip leading '+'.
+ if (number[0] === "+") {
+ return ParseInternationalNumber(
+ number.replace(LEADING_PLUS_CHARS_PATTERN, "")
+ );
+ }
+
+ // If "defaultRegion" is a country code, use it to parse the number directly.
+ let matches = String(defaultRegion).match(/^\+?(\d+)/);
+ if (matches) {
+ let countryCode = ParseCountryCode(matches[1]);
+ if (!countryCode) {
+ return null;
+ }
+ return ParseNumberByCountryCode(number, countryCode);
+ }
+
+ // Lookup the meta data for the given region.
+ let md = FindMetaDataForRegion(defaultRegion.toUpperCase());
+ if (!md) {
+ dump("Couldn't find Meta Data for region: " + defaultRegion + "\n");
+ return null;
+ }
+
+ // See if the number starts with an international prefix, and if the
+ // number resulting from stripping the code is valid, then remove the
+ // prefix and flag the number as international.
+ if (md.internationalPrefix.test(number)) {
+ let possibleNumber = number.replace(md.internationalPrefix, "");
+ ret = ParseInternationalNumber(possibleNumber);
+ if (ret) {
+ return ret;
+ }
+ }
+
+ ret = ParseNationalNumberAndCheckNationalPrefix(number, md);
+ if (ret) {
+ return ret;
+ }
+
+ // Now lets see if maybe its an international number after all, but
+ // without '+' or the international prefix.
+ ret = ParseInternationalNumber(number);
+ if (ret) {
+ return ret;
+ }
+
+ // If the number matches the possible numbers of the current region,
+ // return it as a possible number.
+ if (md.possiblePattern.test(number)) {
+ return new NationalNumber(md, number);
+ }
+
+ // We couldn't parse the number at all.
+ return null;
+ }
+
+ function IsPlainPhoneNumber(number) {
+ if (typeof number !== "string") {
+ return false;
+ }
+
+ let length = number.length;
+ let isTooLong = length > MAX_PHONE_NUMBER_LENGTH;
+ let isEmpty = length === 0;
+ return !(isTooLong || isEmpty || NON_DIALABLE_CHARS_ONCE.test(number));
+ }
+
+ return {
+ IsPlain: IsPlainPhoneNumber,
+ IsValid: IsValidNumber,
+ Parse: ParseNumber,
+ FindMetaDataForRegion,
+ };
+})(PHONE_NUMBER_META_DATA);
diff --git a/toolkit/components/formautofill/phonenumberutils/PhoneNumberMetaData.sys.mjs b/toolkit/components/formautofill/phonenumberutils/PhoneNumberMetaData.sys.mjs
new file mode 100644
index 0000000000..3338ce7c16
--- /dev/null
+++ b/toolkit/components/formautofill/phonenumberutils/PhoneNumberMetaData.sys.mjs
@@ -0,0 +1,291 @@
+/* This Source Code Form is subject to the terms of the Apache License, Version
+ * 2.0. If a copy of the Apache License was not distributed with this file, You
+ * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */
+
+/*
+ * This data was generated base on libphonenumber v8.4.1 via the script in
+ * https://github.com/andreasgal/PhoneNumber.js
+ *
+ * The XML format of libphonenumber has changed since v8.4.2 so we can only stay
+ * in this version for now.
+ */
+
+export var PHONE_NUMBER_META_DATA = {
+ 46: '["SE","00","0",null,null,"$NP$FG","\\\\d{6,12}","[1-35-9]\\\\d{5,11}|4\\\\d{6,8}",[["(8)(\\\\d{2,3})(\\\\d{2,3})(\\\\d{2})","$1-$2 $3 $4","8",null,"$1 $2 $3 $4"],["([1-69]\\\\d)(\\\\d{2,3})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4","1[013689]|2[0136]|3[1356]|4[0246]|54|6[03]|90",null,"$1 $2 $3 $4"],["([1-469]\\\\d)(\\\\d{3})(\\\\d{2})","$1-$2 $3","1[136]|2[136]|3[356]|4[0246]|6[03]|90",null,"$1 $2 $3"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4","1[2457]|2(?:[247-9]|5[0138])|3[0247-9]|4[1357-9]|5[0-35-9]|6(?:[124-689]|7[0-2])|9(?:[125-8]|3[0-5]|4[0-3])",null,"$1 $2 $3 $4"],["(\\\\d{3})(\\\\d{2,3})(\\\\d{2})","$1-$2 $3","1[2457]|2(?:[247-9]|5[0138])|3[0247-9]|4[1357-9]|5[0-35-9]|6(?:[124-689]|7[0-2])|9(?:[125-8]|3[0-5]|4[0-3])",null,"$1 $2 $3"],["(7\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4","7",null,"$1 $2 $3 $4"],["(77)(\\\\d{2})(\\\\d{2})","$1-$2$3","7",null,"$1 $2 $3"],["(20)(\\\\d{2,3})(\\\\d{2})","$1-$2 $3","20",null,"$1 $2 $3"],["(9[034]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1-$2 $3 $4","9[034]",null,"$1 $2 $3 $4"],["(9[034]\\\\d)(\\\\d{4})","$1-$2","9[034]",null,"$1 $2"],["(\\\\d{3})(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4 $5","25[245]|67[3-6]",null,"$1 $2 $3 $4 $5"]]]',
+ 299: '["GL","00",null,null,null,null,"\\\\d{6}","[1-689]\\\\d{5}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]',
+ 385: '["HR","00","0",null,null,"$NP$FG","\\\\d{6,9}","[1-7]\\\\d{5,8}|[89]\\\\d{6,8}",[["(1)(\\\\d{4})(\\\\d{3})","$1 $2 $3","1",null],["([2-5]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[2-5]",null],["(9\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","9",null],["(6[01])(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","6[01]",null],["([67]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[67]",null],["(80[01])(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","8",null],["(80[01])(\\\\d{3})(\\\\d{3})","$1 $2 $3","8",null]]]',
+ 670: '["TL","00",null,null,null,null,"\\\\d{7,8}","[2-489]\\\\d{6}|7\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[2-489]",null],["(\\\\d{4})(\\\\d{4})","$1 $2","7",null]]]',
+ 258: '["MZ","00",null,null,null,null,"\\\\d{8,9}","[28]\\\\d{7,8}",[["([28]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2|8[2-7]",null],["(80\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","80",null]]]',
+ 359: '["BG","00","0",null,null,"$NP$FG","\\\\d{5,9}","[23567]\\\\d{5,7}|[489]\\\\d{6,8}",[["(2)(\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","2",null],["(2)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2",null],["(\\\\d{3})(\\\\d{4})","$1 $2","43[124-7]|70[1-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1 $2 $3","43[124-7]|70[1-9]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","[78]00",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","999",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2,3})","$1 $2 $3","[356]|4[124-7]|7[1-9]|8[1-6]|9[1-7]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","48|8[7-9]|9[08]",null]]]',
+ 682: '["CK","00",null,null,null,null,"\\\\d{5}","[2-8]\\\\d{4}",[["(\\\\d{2})(\\\\d{3})","$1 $2",null,null]]]',
+ 852: '["HK","00(?:[126-9]|30|5[09])?",null,null,null,null,"\\\\d{5,11}","[235-7]\\\\d{7}|8\\\\d{7,8}|9\\\\d{4,10}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[235-7]|[89](?:0[1-9]|[1-9])",null],["(800)(\\\\d{3})(\\\\d{3})","$1 $2 $3","800",null],["(900)(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","900",null],["(900)(\\\\d{2,5})","$1 $2","900",null]]]',
+ 998: '["UZ","810","8",null,null,"$NP $FG","\\\\d{7,9}","[679]\\\\d{8}",[["([679]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 291: '["ER","00","0",null,null,"$NP$FG","\\\\d{6,7}","[178]\\\\d{6}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]',
+ 95: '["MM","00","0",null,null,"$NP$FG","\\\\d{5,10}","[1478]\\\\d{5,7}|[256]\\\\d{5,8}|9(?:[279]\\\\d{0,2}|[58]|[34]\\\\d{1,2}|6\\\\d?)\\\\d{6}",[["(\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","1|2[245]",null],["(2)(\\\\d{4})(\\\\d{4})","$1 $2 $3","251",null],["(\\\\d)(\\\\d{2})(\\\\d{3})","$1 $2 $3","16|2",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","67|81",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3,4})","$1 $2 $3","[4-8]",null],["(9)(\\\\d{3})(\\\\d{4,6})","$1 $2 $3","9(?:2[0-4]|[35-9]|4[137-9])",null],["(9)([34]\\\\d{4})(\\\\d{4})","$1 $2 $3","9(?:3[0-36]|4[0-57-9])",null],["(9)(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","92[56]",null],["(9)(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1 $2 $3 $4","93",null]]]',
+ 266: '["LS","00",null,null,null,null,"\\\\d{8}","[2568]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]',
+ 245: '["GW","00",null,null,null,null,"\\\\d{7,9}","(?:4(?:0\\\\d{5}|4\\\\d{7})|9\\\\d{8})",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","44|9[567]",null],["(\\\\d{3})(\\\\d{4})","$1 $2","40",null]]]',
+ 374: '["AM","00","0",null,null,"($NP$FG)","\\\\d{5,8}","[1-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{6})","$1 $2","1|47",null],["(\\\\d{2})(\\\\d{6})","$1 $2","4[1349]|[5-7]|9[1-9]","$NP$FG"],["(\\\\d{3})(\\\\d{5})","$1 $2","[23]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","8|90","$NP $FG"]]]',
+ 61: [
+ '["AU","(?:14(?:1[14]|34|4[17]|[56]6|7[47]|88))?001[14-689]","0",null,null,null,"\\\\d{6,10}","1\\\\d{4,9}|[2-578]\\\\d{8}",[["([2378])(\\\\d{4})(\\\\d{4})","$1 $2 $3","[2378]","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[45]|14","$NP$FG"],["(16)(\\\\d{3,4})","$1 $2","16","$NP$FG"],["(16)(\\\\d{3})(\\\\d{2,4})","$1 $2 $3","16","$NP$FG"],["(1[389]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1(?:[38]0|90)","$FG"],["(180)(2\\\\d{3})","$1 $2","180","$FG"],["(19\\\\d)(\\\\d{3})","$1 $2","19[13]","$FG"],["(19\\\\d{2})(\\\\d{4})","$1 $2","19[679]","$FG"],["(13)(\\\\d{2})(\\\\d{2})","$1 $2 $3","13[1-9]","$FG"]]]',
+ '["CC","(?:14(?:1[14]|34|4[17]|[56]6|7[47]|88))?001[14-689]","0",null,null,null,"\\\\d{6,10}","[1458]\\\\d{5,9}"]',
+ '["CX","(?:14(?:1[14]|34|4[17]|[56]6|7[47]|88))?001[14-689]","0",null,null,null,"\\\\d{6,10}","[1458]\\\\d{5,9}"]',
+ ],
+ 500: '["FK","00",null,null,null,null,"\\\\d{5}","[2-7]\\\\d{4}"]',
+ 261: '["MG","00","0",null,null,"$NP$FG","\\\\d{7,9}","[23]\\\\d{8}",[["([23]\\\\d)(\\\\d{2})(\\\\d{3})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 92: '["PK","00","0",null,null,"($NP$FG)","\\\\d{6,12}","1\\\\d{8}|[2-8]\\\\d{5,11}|9(?:[013-9]\\\\d{4,9}|2\\\\d(?:111\\\\d{6}|\\\\d{3,7}))",[["(\\\\d{2})(111)(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","(?:2[125]|4[0-246-9]|5[1-35-7]|6[1-8]|7[14]|8[16]|91)1",null],["(\\\\d{3})(111)(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","2[349]|45|54|60|72|8[2-5]|9[2-9]",null],["(\\\\d{2})(\\\\d{7,8})","$1 $2","(?:2[125]|4[0-246-9]|5[1-35-7]|6[1-8]|7[14]|8[16]|91)[2-9]",null],["(\\\\d{3})(\\\\d{6,7})","$1 $2","2[349]|45|54|60|72|8[2-5]|9[2-9]",null],["(3\\\\d{2})(\\\\d{7})","$1 $2","3","$NP$FG"],["([15]\\\\d{3})(\\\\d{5,6})","$1 $2","58[12]|1",null],["(586\\\\d{2})(\\\\d{5})","$1 $2","586",null],["([89]00)(\\\\d{3})(\\\\d{2})","$1 $2 $3","[89]00","$NP$FG"]]]',
+ 234: '["NG","009","0",null,null,"$NP$FG","\\\\d{5,14}","[1-6]\\\\d{5,8}|9\\\\d{5,9}|[78]\\\\d{5,13}",[["(\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[12]|9(?:0[3-9]|[1-9])",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2,3})","$1 $2 $3","[3-6]|7(?:[1-79]|0[1-9])|8[2-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","70|8[01]|90[235-9]",null],["([78]00)(\\\\d{4})(\\\\d{4,5})","$1 $2 $3","[78]00",null],["([78]00)(\\\\d{5})(\\\\d{5,6})","$1 $2 $3","[78]00",null],["(78)(\\\\d{2})(\\\\d{3})","$1 $2 $3","78",null]]]',
+ 350: '["GI","00",null,null,null,null,"\\\\d{8}","[2568]\\\\d{7}",[["(\\\\d{3})(\\\\d{5})","$1 $2","2",null]]]',
+ 45: '["DK","00",null,null,null,null,"\\\\d{8}","[2-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 963: '["SY","00","0",null,null,"$NP$FG","\\\\d{6,9}","[1-59]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[1-5]",null],["(9\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","9",null]]]',
+ 226: '["BF","00",null,null,null,null,"\\\\d{8}","[25-7]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 974: '["QA","00",null,null,null,null,"\\\\d{7,8}","[2-8]\\\\d{6,7}",[["([28]\\\\d{2})(\\\\d{4})","$1 $2","[28]",null],["([3-7]\\\\d{3})(\\\\d{4})","$1 $2","[3-7]",null]]]',
+ 218: '["LY","00","0",null,null,"$NP$FG","\\\\d{7,9}","[25679]\\\\d{8}",[["([25679]\\\\d)(\\\\d{7})","$1-$2",null,null]]]',
+ 51: '["PE","19(?:1[124]|77|90)00","0",null,null,"($NP$FG)","\\\\d{6,9}","[14-9]\\\\d{7,8}",[["(1)(\\\\d{7})","$1 $2","1",null],["([4-8]\\\\d)(\\\\d{6})","$1 $2","[4-7]|8[2-4]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","80",null],["(9\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","9","$FG"]]]',
+ 62: '["ID","0(?:0[1789]|10(?:00|1[67]))","0",null,null,"$NP$FG","\\\\d{5,12}","(?:[1-79]\\\\d{6,10}|8\\\\d{7,11})",[["(\\\\d{2})(\\\\d{5,8})","$1 $2","2[124]|[36]1","($NP$FG)"],["(\\\\d{3})(\\\\d{5,8})","$1 $2","[4579]|2[035-9]|[36][02-9]","($NP$FG)"],["(8\\\\d{2})(\\\\d{3,4})(\\\\d{3})","$1-$2-$3","8[1-35-9]",null],["(8\\\\d{2})(\\\\d{4})(\\\\d{4,5})","$1-$2-$3","8[1-35-9]",null],["(1)(500)(\\\\d{3})","$1 $2 $3","15","$FG"],["(177)(\\\\d{6,8})","$1 $2","17",null],["(800)(\\\\d{5,7})","$1 $2","800",null],["(804)(\\\\d{3})(\\\\d{4})","$1 $2 $3","804",null],["(80\\\\d)(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","80[79]",null]]]',
+ 298: '["FO","00",null,"(10(?:01|[12]0|88))",null,null,"\\\\d{6}","[2-9]\\\\d{5}",[["(\\\\d{6})","$1",null,null]]]',
+ 381: '["RS","00","0",null,null,"$NP$FG","\\\\d{5,12}","[126-9]\\\\d{4,11}|3(?:[0-79]\\\\d{3,10}|8[2-9]\\\\d{2,9})",[["([23]\\\\d{2})(\\\\d{4,9})","$1 $2","(?:2[389]|39)0",null],["([1-3]\\\\d)(\\\\d{5,10})","$1 $2","1|2(?:[0-24-7]|[389][1-9])|3(?:[0-8]|9[1-9])",null],["(6\\\\d)(\\\\d{6,8})","$1 $2","6",null],["([89]\\\\d{2})(\\\\d{3,9})","$1 $2","[89]",null],["(7[26])(\\\\d{4,9})","$1 $2","7[26]",null],["(7[08]\\\\d)(\\\\d{4,9})","$1 $2","7[08]",null]]]',
+ 975: '["BT","00",null,null,null,null,"\\\\d{6,8}","[1-8]\\\\d{6,7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","1|77",null],["([2-8])(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-68]|7[246]",null]]]',
+ 34: '["ES","00",null,null,null,null,"\\\\d{9}","[5-9]\\\\d{8}",[["([89]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[89]00",null],["([5-9]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[568]|[79][0-8]",null]]]',
+ 881: '["001",null,null,null,null,null,"\\\\d{9}","[67]\\\\d{8}",[["(\\\\d)(\\\\d{3})(\\\\d{5})","$1 $2 $3","[67]",null]]]',
+ 855: '["KH","00[14-9]","0",null,null,null,"\\\\d{6,10}","[1-9]\\\\d{7,9}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","1\\\\d[1-9]|[2-9]","$NP$FG"],["(1[89]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1[89]0",null]]]',
+ 420: '["CZ","00",null,null,null,null,"\\\\d{9,12}","[2-8]\\\\d{8}|9\\\\d{8,11}",[["([2-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-8]|9[015-7]",null],["(96\\\\d)(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","96",null],["(9\\\\d)(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","9[36]",null]]]',
+ 216: '["TN","00",null,null,null,null,"\\\\d{8}","[2-57-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]',
+ 673: '["BN","00",null,null,null,null,"\\\\d{7}","[2-578]\\\\d{6}",[["([2-578]\\\\d{2})(\\\\d{4})","$1 $2",null,null]]]',
+ 290: [
+ '["SH","00",null,null,null,null,"\\\\d{4,5}","[256]\\\\d{4}"]',
+ '["TA","00",null,null,null,null,"\\\\d{4}","8\\\\d{3}"]',
+ ],
+ 882: '["001",null,null,null,null,null,"\\\\d{7,12}","[13]\\\\d{6,11}",[["(\\\\d{2})(\\\\d{4})(\\\\d{3})","$1 $2 $3","3[23]",null],["(\\\\d{2})(\\\\d{5})","$1 $2","16|342",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","34[57]",null],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","348",null],["(\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","16",null],["(\\\\d{2})(\\\\d{4,5})(\\\\d{5})","$1 $2 $3","16|39",null]]]',
+ 267: '["BW","00",null,null,null,null,"\\\\d{7,8}","[2-79]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[2-6]",null],["(7\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","7",null],["(90)(\\\\d{5})","$1 $2","9",null]]]',
+ 94: '["LK","00","0",null,null,"$NP$FG","\\\\d{7,9}","[1-9]\\\\d{8}",[["(\\\\d{2})(\\\\d{1})(\\\\d{6})","$1 $2 $3","[1-689]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","7",null]]]',
+ 356: '["MT","00",null,null,null,null,"\\\\d{8}","[2357-9]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]',
+ 375: '["BY","810","8","8?0?",null,null,"\\\\d{5,11}","[1-4]\\\\d{8}|800\\\\d{3,7}|[89]\\\\d{9,10}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","17[0-3589]|2[4-9]|[34]","$NP 0$FG"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","1(?:5[24]|6[235]|7[467])|2(?:1[246]|2[25]|3[26])","$NP 0$FG"],["(\\\\d{4})(\\\\d{2})(\\\\d{3})","$1 $2-$3","1(?:5[169]|6[3-5]|7[179])|2(?:1[35]|2[34]|3[3-5])","$NP 0$FG"],["([89]\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","8[01]|9","$NP $FG"],["(82\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","82","$NP $FG"],["(800)(\\\\d{3})","$1 $2","800","$NP $FG"],["(800)(\\\\d{2})(\\\\d{2,4})","$1 $2 $3","800","$NP $FG"]]]',
+ 690: '["TK","00",null,null,null,null,"\\\\d{4,7}","[2-47]\\\\d{3,6}"]',
+ 507: '["PA","00",null,null,null,null,"\\\\d{7,8}","[1-9]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1-$2","[1-57-9]",null],["(\\\\d{4})(\\\\d{4})","$1-$2","6",null]]]',
+ 692: '["MH","011","1",null,null,null,"\\\\d{7}","[2-6]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1-$2",null,null]]]',
+ 250: '["RW","00","0",null,null,null,"\\\\d{8,9}","[027-9]\\\\d{7,8}",[["(2\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","2","$FG"],["([7-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[7-9]","$NP$FG"],["(0\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","0",null]]]',
+ 81: '["JP","010","0",null,null,"$NP$FG","\\\\d{8,17}","[1-9]\\\\d{8,9}|00(?:[36]\\\\d{7,14}|7\\\\d{5,7}|8\\\\d{7})",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1-$2-$3","(?:12|57|99)0",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1-$2-$3","800",null],["(\\\\d{4})(\\\\d{4})","$1-$2","0077","$FG","NA"],["(\\\\d{4})(\\\\d{2})(\\\\d{3,4})","$1-$2-$3","0077","$FG","NA"],["(\\\\d{4})(\\\\d{2})(\\\\d{4})","$1-$2-$3","0088","$FG","NA"],["(\\\\d{4})(\\\\d{3})(\\\\d{3,4})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{4})(\\\\d{4})(\\\\d{4,5})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{4})(\\\\d{5})(\\\\d{5,6})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{4})(\\\\d{6})(\\\\d{6,7})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1-$2-$3","[2579]0|80[1-9]",null],["(\\\\d{4})(\\\\d)(\\\\d{4})","$1-$2-$3","1(?:26|3[79]|4[56]|5[4-68]|6[3-5])|5(?:76|97)|499|746|8(?:3[89]|63|47|51)|9(?:49|80|9[16])",null],["(\\\\d{3})(\\\\d{2})(\\\\d{4})","$1-$2-$3","1(?:2[3-6]|3[3-9]|4[2-6]|5[2-8]|[68][2-7]|7[2-689]|9[1-578])|2(?:2[03-689]|3[3-58]|4[0-468]|5[04-8]|6[013-8]|7[06-9]|8[02-57-9]|9[13])|4(?:2[28]|3[689]|6[035-7]|7[05689]|80|9[3-5])|5(?:3[1-36-9]|4[4578]|5[013-8]|6[1-9]|7[2-8]|8[14-7]|9[4-9])|7(?:2[15]|3[5-9]|4[02-9]|6[135-8]|7[0-4689]|9[014-9])|8(?:2[49]|3[3-8]|4[5-8]|5[2-9]|6[35-9]|7[579]|8[03-579]|9[2-8])|9(?:[23]0|4[02-46-9]|5[024-79]|6[4-9]|7[2-47-9]|8[02-7]|9[3-7])",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3","1|2(?:2[37]|5[5-9]|64|78|8[39]|91)|4(?:2[2689]|64|7[347])|5(?:[2-589]|39)|60|8(?:[46-9]|3[279]|2[124589])|9(?:[235-8]|93)",null],["(\\\\d{3})(\\\\d{2})(\\\\d{4})","$1-$2-$3","2(?:9[14-79]|74|[34]7|[56]9)|82|993",null],["(\\\\d)(\\\\d{4})(\\\\d{4})","$1-$2-$3","3|4(?:2[09]|7[01])|6[1-9]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3","[2479][1-9]",null]]]',
+ 237: '["CM","00",null,null,null,null,"\\\\d{8,9}","[2368]\\\\d{7,8}",[["([26])(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","[26]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[23]|88",null],["(800)(\\\\d{2})(\\\\d{3})","$1 $2 $3","80",null]]]',
+ 351: '["PT","00",null,null,null,null,"\\\\d{9}","[2-46-9]\\\\d{8}",[["(2\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2[12]",null],["([2-46-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","2[3-9]|[346-9]",null]]]',
+ 246: '["IO","00",null,null,null,null,"\\\\d{7}","3\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 227: '["NE","00",null,null,null,null,"\\\\d{8}","[0289]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[289]|09",null],["(08)(\\\\d{3})(\\\\d{3})","$1 $2 $3","08",null]]]',
+ 27: '["ZA","00","0",null,null,"$NP$FG","\\\\d{5,9}","[1-79]\\\\d{8}|8\\\\d{4,8}",[["(860)(\\\\d{3})(\\\\d{3})","$1 $2 $3","860",null],["(\\\\d{2})(\\\\d{3,4})","$1 $2","8[1-4]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2,3})","$1 $2 $3","8[1-4]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[1-79]|8(?:[0-57]|6[1-9])",null]]]',
+ 962: '["JO","00","0",null,null,"$NP$FG","\\\\d{8,9}","[235-9]\\\\d{7,8}",[["(\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2356]|87","($NP$FG)"],["(7)(\\\\d{4})(\\\\d{4})","$1 $2 $3","7[457-9]",null],["(\\\\d{3})(\\\\d{5,6})","$1 $2","70|8[0158]|9",null]]]',
+ 387: '["BA","00","0",null,null,"$NP$FG","\\\\d{6,9}","[3-9]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2-$3","[3-5]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","6[1-356]|[7-9]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","6[047]",null]]]',
+ 33: '["FR","00","0",null,null,"$NP$FG","\\\\d{9}","[1-9]\\\\d{8}",[["([1-79])(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","[1-79]",null],["(1\\\\d{2})(\\\\d{3})","$1 $2","11","$FG","NA"],["(8\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","8","$NP $FG"]]]',
+ 972: '["IL","0(?:0|1[2-9])","0",null,null,"$FG","\\\\d{4,12}","1\\\\d{6,11}|[2-589]\\\\d{3}(?:\\\\d{3,6})?|6\\\\d{3}|7\\\\d{6,9}",[["([2-489])(\\\\d{3})(\\\\d{4})","$1-$2-$3","[2-489]","$NP$FG"],["([57]\\\\d)(\\\\d{3})(\\\\d{4})","$1-$2-$3","[57]","$NP$FG"],["(153)(\\\\d{1,2})(\\\\d{3})(\\\\d{4})","$1 $2 $3 $4","153",null],["(1)([7-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1-$2-$3-$4","1[7-9]",null],["(1255)(\\\\d{3})","$1-$2","125",null],["(1200)(\\\\d{3})(\\\\d{3})","$1-$2-$3","120",null],["(1212)(\\\\d{2})(\\\\d{2})","$1-$2-$3","121",null],["(1599)(\\\\d{6})","$1-$2","15",null],["(\\\\d{4})","*$1","[2-689]",null]]]',
+ 248: '["SC","0(?:[02]|10?)",null,null,null,null,"\\\\d{6,7}","[24689]\\\\d{5,6}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[246]",null]]]',
+ 297: '["AW","00",null,null,null,null,"\\\\d{7}","[25-9]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 421: '["SK","00","0",null,null,"$NP$FG","\\\\d{6,9}","(?:[2-68]\\\\d{5,8}|9\\\\d{6,8})",[["(2)(1[67])(\\\\d{3,4})","$1 $2 $3","21[67]",null],["([3-5]\\\\d)(1[67])(\\\\d{2,3})","$1 $2 $3","[3-5]",null],["(2)(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1/$2 $3 $4","2",null],["([3-5]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1/$2 $3 $4","[3-5]",null],["([689]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[689]",null],["(9090)(\\\\d{3})","$1 $2","9090",null]]]',
+ 672: '["NF","00",null,null,null,null,"\\\\d{5,6}","[13]\\\\d{5}",[["(\\\\d{2})(\\\\d{4})","$1 $2","1",null],["(\\\\d)(\\\\d{5})","$1 $2","3",null]]]',
+ 870: '["001",null,null,null,null,null,"\\\\d{9}","[35-7]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]',
+ 883: '["001",null,null,null,null,null,"\\\\d{9}(?:\\\\d{3})?","51\\\\d{7}(?:\\\\d{3})?",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","510",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","510",null],["(\\\\d{4})(\\\\d{4})(\\\\d{4})","$1 $2 $3","51[13]",null]]]',
+ 264: '["NA","00","0",null,null,"$NP$FG","\\\\d{8,9}","[68]\\\\d{7,8}",[["(8\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","8[1235]",null],["(6\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","6",null],["(88)(\\\\d{3})(\\\\d{3})","$1 $2 $3","88",null],["(870)(\\\\d{3})(\\\\d{3})","$1 $2 $3","870",null]]]',
+ 878: '["001",null,null,null,null,null,"\\\\d{12}","1\\\\d{11}",[["(\\\\d{2})(\\\\d{5})(\\\\d{5})","$1 $2 $3",null,null]]]',
+ 239: '["ST","00",null,null,null,null,"\\\\d{7}","[29]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 357: '["CY","00",null,null,null,null,"\\\\d{8}","[257-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{6})","$1 $2",null,null]]]',
+ 240: '["GQ","00",null,null,null,null,"\\\\d{9}","[23589]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[235]",null],["(\\\\d{3})(\\\\d{6})","$1 $2","[89]",null]]]',
+ 506: '["CR","00",null,"(19(?:0[012468]|1[09]|20|66|77|99))",null,null,"\\\\d{8,10}","[24-9]\\\\d{7,9}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[24-7]|8[3-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1-$2-$3","[89]0",null]]]',
+ 86: '["CN","(1(?:[129]\\\\d{3}|79\\\\d{2}))?00","0","(1(?:[129]\\\\d{3}|79\\\\d{2}))|0",null,null,"\\\\d{4,12}","[1-7]\\\\d{6,11}|8[0-357-9]\\\\d{6,9}|9\\\\d{7,10}",[["(80\\\\d{2})(\\\\d{4})","$1 $2","80[2678]","$NP$FG"],["([48]00)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[48]00",null],["(\\\\d{5,6})","$1","100|95",null,"NA"],["(\\\\d{2})(\\\\d{5,6})","$1 $2","(?:10|2\\\\d)[19]","$NP$FG"],["(\\\\d{3})(\\\\d{5,6})","$1 $2","[3-9]","$NP$FG"],["(\\\\d{3,4})(\\\\d{4})","$1 $2","[2-9]",null,"NA"],["(21)(\\\\d{4})(\\\\d{4,6})","$1 $2 $3","21","$NP$FG"],["([12]\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","10[1-9]|2[02-9]","$NP$FG"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","3(?:1[02-9]|35|49|5|7[02-68]|9[1-68])|4(?:1[02-9]|2[179]|[35][2-9]|6[4789]|7\\\\d|8[23])|5(?:3[03-9]|4[36]|5[02-9]|6[1-46]|7[028]|80|9[2-46-9])|6(?:3[1-5]|6[0238]|9[12])|7(?:01|[1579]|2[248]|3[04-9]|4[3-6]|6[2368])|8(?:1[236-8]|2[5-7]|3|5[1-9]|7[02-9]|8[3678]|9[1-7])|9(?:0[1-3689]|1[1-79]|[379]|4[13]|5[1-5])","$NP$FG"],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","3(?:11|7[179])|4(?:[15]1|3[1-35])|5(?:1|2[37]|3[12]|51|7[13-79]|9[15])|7(?:31|5[457]|6[09]|91)|8(?:[57]1|98)","$NP$FG"],["(\\\\d{4})(\\\\d{3})(\\\\d{4})","$1 $2 $3","807","$NP$FG"],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","1[3-578]",null],["(10800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","108",null],["(\\\\d{3})(\\\\d{7,8})","$1 $2","950",null]]]',
+ 257: '["BI","00",null,null,null,null,"\\\\d{8}","[267]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 683: '["NU","00",null,null,null,null,"\\\\d{4}","[1-5]\\\\d{3}"]',
+ 43: '["AT","00","0",null,null,"$NP$FG","\\\\d{3,13}","[1-9]\\\\d{3,12}",[["(116\\\\d{3})","$1","116","$FG"],["(1)(\\\\d{3,12})","$1 $2","1",null],["(5\\\\d)(\\\\d{3,5})","$1 $2","5[079]",null],["(5\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","5[079]",null],["(5\\\\d)(\\\\d{4})(\\\\d{4,7})","$1 $2 $3","5[079]",null],["(\\\\d{3})(\\\\d{3,10})","$1 $2","316|46|51|732|6(?:5[0-3579]|[6-9])|7(?:[28]0)|[89]",null],["(\\\\d{4})(\\\\d{3,9})","$1 $2","2|3(?:1[1-578]|[3-8])|4[2378]|5[2-6]|6(?:[12]|4[1-9]|5[468])|7(?:2[1-8]|35|4[1-8]|[5-79])",null]]]',
+ 247: '["AC","00",null,null,null,null,"\\\\d{5,6}","[46]\\\\d{4}|[01589]\\\\d{5}"]',
+ 675: '["PG","00",null,null,null,null,"\\\\d{7,8}","[1-9]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[13-689]|27",null],["(\\\\d{4})(\\\\d{4})","$1 $2","20|7",null]]]',
+ 376: '["AD","00",null,null,null,null,"\\\\d{6,9}","[16]\\\\d{5,8}|[37-9]\\\\d{5}",[["(\\\\d{3})(\\\\d{3})","$1 $2","[137-9]|6[0-8]",null],["(\\\\d{4})(\\\\d{4})","$1 $2","180",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","690",null]]]',
+ 63: '["PH","00","0",null,null,null,"\\\\d{5,13}","2\\\\d{5,7}|[3-9]\\\\d{7,9}|1800\\\\d{7,9}",[["(2)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2","($NP$FG)"],["(2)(\\\\d{5})","$1 $2","2","($NP$FG)"],["(\\\\d{4})(\\\\d{4,6})","$1 $2","3(?:23|39|46)|4(?:2[3-6]|[35]9|4[26]|76)|5(?:22|44)|642|8(?:62|8[245])","($NP$FG)"],["(\\\\d{5})(\\\\d{4})","$1 $2","346|4(?:27|9[35])|883","($NP$FG)"],["([3-8]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[3-8]","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","81|9","$NP$FG"],["(1800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["(1800)(\\\\d{1,2})(\\\\d{3})(\\\\d{4})","$1 $2 $3 $4","1",null]]]',
+ 236: '["CF","00",null,null,null,null,"\\\\d{8}","[278]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 590: [
+ '["GP","00","0",null,null,"$NP$FG","\\\\d{9}","[56]\\\\d{8}",[["([56]90)(\\\\d{2})(\\\\d{4})","$1 $2-$3",null,null]]]',
+ '["BL","00","0",null,null,null,"\\\\d{9}","[56]\\\\d{8}"]',
+ '["MF","00","0",null,null,null,"\\\\d{9}","[56]\\\\d{8}"]',
+ ],
+ 53: '["CU","119","0",null,null,"($NP$FG)","\\\\d{4,8}","[2-57]\\\\d{5,7}",[["(\\\\d)(\\\\d{6,7})","$1 $2","7",null],["(\\\\d{2})(\\\\d{4,6})","$1 $2","[2-4]",null],["(\\\\d)(\\\\d{7})","$1 $2","5","$NP$FG"]]]',
+ 64: '["NZ","0(?:0|161)","0",null,null,"$NP$FG","\\\\d{7,11}","6[235-9]\\\\d{6}|[2-57-9]\\\\d{7,10}",[["([34679])(\\\\d{3})(\\\\d{4})","$1-$2 $3","[346]|7[2-57-9]|9[1-9]",null],["(24099)(\\\\d{3})","$1 $2","240",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","21",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,5})","$1 $2 $3","2(?:1[1-9]|[69]|7[0-35-9])|70|86",null],["(2\\\\d)(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","2[028]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2(?:10|74)|5|[89]0",null]]]',
+ 965: '["KW","00",null,null,null,null,"\\\\d{7,8}","[12569]\\\\d{6,7}",[["(\\\\d{4})(\\\\d{3,4})","$1 $2","[16]|2(?:[0-35-9]|4[0-35-9])|9[024-9]|52[25]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","244|5(?:[015]|66)",null]]]',
+ 224: '["GN","00",null,null,null,null,"\\\\d{8,9}","[367]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","3",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[67]",null]]]',
+ 973: '["BH","00",null,null,null,null,"\\\\d{8}","[136-9]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]',
+ 32: '["BE","00","0",null,null,"$NP$FG","\\\\d{8,9}","[1-9]\\\\d{7,8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","4[6-9]",null],["(\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[23]|4[23]|9[2-4]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[156]|7[018]|8(?:0[1-9]|[1-79])",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","(?:80|9)0",null]]]',
+ 249: '["SD","00","0",null,null,"$NP$FG","\\\\d{9}","[19]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3",null,null]]]',
+ 678: '["VU","00",null,null,null,null,"\\\\d{5,7}","[2-57-9]\\\\d{4,6}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[579]",null]]]',
+ 52: '["MX","0[09]","01","0[12]|04[45](\\\\d{10})","1$1","$NP $FG","\\\\d{7,11}","[1-9]\\\\d{9,10}",[["([358]\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","33|55|81",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2467]|3[0-2457-9]|5[089]|8[02-9]|9[0-35-9]",null],["(1)([358]\\\\d)(\\\\d{4})(\\\\d{4})","044 $2 $3 $4","1(?:33|55|81)","$FG","$1 $2 $3 $4"],["(1)(\\\\d{3})(\\\\d{3})(\\\\d{4})","044 $2 $3 $4","1(?:[2467]|3[0-2457-9]|5[089]|8[2-9]|9[1-35-9])","$FG","$1 $2 $3 $4"]]]',
+ 968: '["OM","00",null,null,null,null,"\\\\d{7,9}","(?:5|[279]\\\\d)\\\\d{6}|800\\\\d{5,6}",[["(2\\\\d)(\\\\d{6})","$1 $2","2",null],["([79]\\\\d{3})(\\\\d{4})","$1 $2","[79]",null],["([58]00)(\\\\d{4,6})","$1 $2","[58]",null]]]',
+ 599: [
+ '["CW","00",null,null,null,null,"\\\\d{7,8}","[169]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[13-7]",null],["(9)(\\\\d{3})(\\\\d{4})","$1 $2 $3","9",null]]]',
+ '["BQ","00",null,null,null,null,"\\\\d{7}","[347]\\\\d{6}"]',
+ ],
+ 800: '["001",null,null,null,null,null,"\\\\d{8}","\\\\d{8}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]',
+ 386: '["SI","00","0",null,null,"$NP$FG","\\\\d{5,8}","[1-7]\\\\d{6,7}|[89]\\\\d{4,7}",[["(\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[12]|3[24-8]|4[24-8]|5[2-8]|7[3-8]","($NP$FG)"],["([3-7]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[37][01]|4[0139]|51|6",null],["([89][09])(\\\\d{3,6})","$1 $2","[89][09]",null],["([58]\\\\d{2})(\\\\d{5})","$1 $2","59|8[1-3]",null]]]',
+ 679: '["FJ","0(?:0|52)",null,null,null,null,"\\\\d{7}(?:\\\\d{4})?","[35-9]\\\\d{6}|0\\\\d{10}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[35-9]",null],["(\\\\d{4})(\\\\d{3})(\\\\d{4})","$1 $2 $3","0",null]]]',
+ 238: '["CV","0",null,null,null,null,"\\\\d{7}","[259]\\\\d{6}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]',
+ 691: '["FM","00",null,null,null,null,"\\\\d{7}","[39]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 262: [
+ '["RE","00","0",null,null,"$NP$FG","\\\\d{9}","[268]\\\\d{8}",[["([268]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ '["YT","00","0",null,null,"$NP$FG","\\\\d{9}","[268]\\\\d{8}"]',
+ ],
+ 241: '["GA","00",null,null,null,null,"\\\\d{7,8}","0?\\\\d{7}",[["(\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[2-7]","0$FG"],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","0",null]]]',
+ 370: '["LT","00","8","[08]",null,"($NP-$FG)","\\\\d{8}","[3-9]\\\\d{7}",[["([34]\\\\d)(\\\\d{6})","$1 $2","37|4(?:1|5[45]|6[2-4])",null],["([3-6]\\\\d{2})(\\\\d{5})","$1 $2","3[148]|4(?:[24]|6[09])|528|6",null],["([7-9]\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","[7-9]","$NP $FG"],["(5)(2\\\\d{2})(\\\\d{4})","$1 $2 $3","52[0-79]",null]]]',
+ 256: '["UG","00[057]","0",null,null,"$NP$FG","\\\\d{5,9}","\\\\d{9}",[["(\\\\d{3})(\\\\d{6})","$1 $2","[7-9]|20(?:[013-8]|2[5-9])|4(?:6[45]|[7-9])",null],["(\\\\d{2})(\\\\d{7})","$1 $2","3|4(?:[1-5]|6[0-36-9])",null],["(2024)(\\\\d{5})","$1 $2","2024",null]]]',
+ 677: '["SB","0[01]",null,null,null,null,"\\\\d{5,7}","[1-9]\\\\d{4,6}",[["(\\\\d{2})(\\\\d{5})","$1 $2","[7-9]",null]]]',
+ 377: '["MC","00","0",null,null,"$NP$FG","\\\\d{8,9}","[34689]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[39]","$FG"],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","4",null],["(6)(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","6",null],["(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1 $2 $3","8","$FG"]]]',
+ 382: '["ME","00","0",null,null,"$NP$FG","\\\\d{6,9}","[2-9]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-57-9]|6[036-9]",null]]]',
+ 231: '["LR","00","0",null,null,"$NP$FG","\\\\d{7,9}","2\\\\d{7,8}|[378]\\\\d{8}|4\\\\d{6}|5\\\\d{6,8}",[["(2\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","2",null],["([4-5])(\\\\d{3})(\\\\d{3})","$1 $2 $3","[45]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[23578]",null]]]',
+ 591: '["BO","00(1\\\\d)?","0","0(1\\\\d)?",null,null,"\\\\d{7,8}","[23467]\\\\d{7}",[["([234])(\\\\d{7})","$1 $2","[234]",null],["([67]\\\\d{7})","$1","[67]",null]]]',
+ 808: '["001",null,null,null,null,null,"\\\\d{8}","\\\\d{8}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]',
+ 964: '["IQ","00","0",null,null,"$NP$FG","\\\\d{6,10}","[1-7]\\\\d{7,9}",[["(1)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["([2-6]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[2-6]",null],["(7\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","7",null]]]',
+ 225: '["CI","00",null,null,null,null,"\\\\d{8}","[02-8]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 992: '["TJ","810","8",null,null,"$FG","\\\\d{3,9}","[3-57-9]\\\\d{8}",[["([349]\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3","[34]7|91[78]",null],["([457-9]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","4[148]|[578]|9(?:1[59]|[0235-9])",null],["(331700)(\\\\d)(\\\\d{2})","$1 $2 $3","331",null],["(\\\\d{4})(\\\\d)(\\\\d{4})","$1 $2 $3","3[1-5]",null]]]',
+ 55: '["BR","00(?:1[245]|2[1-35]|31|4[13]|[56]5|99)","0","(?:0|90)(?:(1[245]|2[135]|[34]1)(\\\\d{10,11}))?","$2",null,"\\\\d{8,11}","[1-46-9]\\\\d{7,10}|5(?:[0-4]\\\\d{7,9}|5(?:[2-8]\\\\d{7}|9\\\\d{7,8}))",[["(\\\\d{4})(\\\\d{4})","$1-$2","[2-9](?:[1-9]|0[1-9])","$FG","NA"],["(\\\\d{5})(\\\\d{4})","$1-$2","9(?:[1-9]|0[1-9])","$FG","NA"],["(\\\\d{3,5})","$1","1[125689]","$FG","NA"],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2-$3","[1-9][1-9]","($FG)"],["(\\\\d{2})(\\\\d{5})(\\\\d{4})","$1 $2-$3","(?:[14689][1-9]|2[12478]|3[1-578]|5[1-5]|7[13-579])9","($FG)"],["(\\\\d{4})(\\\\d{4})","$1-$2","(?:300|40(?:0|20))",null],["([3589]00)(\\\\d{2,3})(\\\\d{4})","$1 $2 $3","[3589]00","$NP$FG"]]]',
+ 674: '["NR","00",null,null,null,null,"\\\\d{7}","[458]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 967: '["YE","00","0",null,null,"$NP$FG","\\\\d{6,9}","[1-7]\\\\d{6,8}",[["([1-7])(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[1-6]|7[24-68]",null],["(7\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","7[0137]",null]]]',
+ 49: '["DE","00","0",null,null,"$NP$FG","\\\\d{2,15}","[1-35-9]\\\\d{3,14}|4(?:[0-8]\\\\d{3,12}|9(?:[0-37]\\\\d|4(?:[1-35-8]|4\\\\d?)|5\\\\d{1,2}|6[1-8]\\\\d?)\\\\d{2,8})",[["(1\\\\d{2})(\\\\d{7,8})","$1 $2","1[67]",null],["(15\\\\d{3})(\\\\d{6})","$1 $2","15[0568]",null],["(1\\\\d{3})(\\\\d{7})","$1 $2","15",null],["(\\\\d{2})(\\\\d{3,11})","$1 $2","3[02]|40|[68]9",null],["(\\\\d{3})(\\\\d{3,11})","$1 $2","2(?:\\\\d1|0[2389]|1[24]|28|34)|3(?:[3-9][15]|40)|[4-8][1-9]1|9(?:06|[1-9]1)",null],["(\\\\d{4})(\\\\d{2,11})","$1 $2","[24-6]|[7-9](?:\\\\d[1-9]|[1-9]\\\\d)|3(?:[3569][02-46-9]|4[2-4679]|7[2-467]|8[2-46-8])",null],["(3\\\\d{4})(\\\\d{1,10})","$1 $2","3",null],["(800)(\\\\d{7,12})","$1 $2","800",null],["(\\\\d{3})(\\\\d)(\\\\d{4,10})","$1 $2 $3","(?:18|90)0|137",null],["(1\\\\d{2})(\\\\d{5,11})","$1 $2","181",null],["(18\\\\d{3})(\\\\d{6})","$1 $2","185",null],["(18\\\\d{2})(\\\\d{7})","$1 $2","18[68]",null],["(18\\\\d)(\\\\d{8})","$1 $2","18[2-579]",null],["(700)(\\\\d{4})(\\\\d{4})","$1 $2 $3","700",null],["(138)(\\\\d{4})","$1 $2","138",null],["(15[013-68])(\\\\d{2})(\\\\d{8})","$1 $2 $3","15[013-68]",null],["(15[279]\\\\d)(\\\\d{2})(\\\\d{7})","$1 $2 $3","15[279]",null],["(1[67]\\\\d)(\\\\d{2})(\\\\d{7,8})","$1 $2 $3","1(?:6[023]|7)",null]]]',
+ 31: '["NL","00","0",null,null,"$NP$FG","\\\\d{5,10}","1\\\\d{4,8}|[2-7]\\\\d{8}|[89]\\\\d{6,9}",[["([1-578]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[035]|2[0346]|3[03568]|4[0356]|5[0358]|7|8[4578]",null],["([1-5]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1[16-8]|2[259]|3[124]|4[17-9]|5[124679]",null],["(6)(\\\\d{8})","$1 $2","6[0-57-9]",null],["(66)(\\\\d{7})","$1 $2","66",null],["(14)(\\\\d{3,4})","$1 $2","14","$FG"],["([89]0\\\\d)(\\\\d{4,7})","$1 $2","80|9",null]]]',
+ 970: '["PS","00","0",null,null,"$NP$FG","\\\\d{4,10}","[24589]\\\\d{7,8}|1(?:[78]\\\\d{8}|[49]\\\\d{2,3})",[["([2489])(2\\\\d{2})(\\\\d{4})","$1 $2 $3","[2489]",null],["(5[69]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","5",null],["(1[78]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1[78]","$FG"]]]',
+ 58: '["VE","00","0",null,null,"$NP$FG","\\\\d{7,10}","[24589]\\\\d{9}",[["(\\\\d{3})(\\\\d{7})","$1-$2",null,null]]]',
+ 856: '["LA","00","0",null,null,"$NP$FG","\\\\d{6,10}","[2-8]\\\\d{7,9}",[["(20)(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","20",null],["([2-8]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","2[13]|3[14]|[4-8]",null],["(30)(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","30",null]]]',
+ 354: '["IS","1(?:0(?:01|10|20)|100)|00",null,null,null,null,"\\\\d{7,9}","[4-9]\\\\d{6}|38\\\\d{7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[4-9]",null],["(3\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","3",null]]]',
+ 242: '["CG","00",null,null,null,null,"\\\\d{9}","[028]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[02]",null],["(\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","8",null]]]',
+ 423: '["LI","00","0","0|10(?:01|20|66)",null,null,"\\\\d{7,9}","6\\\\d{8}|[23789]\\\\d{6}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3","[23789]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","6[56]",null],["(69)(7\\\\d{2})(\\\\d{4})","$1 $2 $3","697",null]]]',
+ 213: '["DZ","00","0",null,null,"$NP$FG","\\\\d{8,9}","(?:[1-4]|[5-9]\\\\d)\\\\d{7}",[["([1-4]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[1-4]",null],["([5-8]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[5-8]",null],["(9\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","9",null]]]',
+ 371: '["LV","00",null,null,null,null,"\\\\d{8}","[2689]\\\\d{7}",[["([2689]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]',
+ 503: '["SV","00",null,null,null,null,"\\\\d{7,8}|\\\\d{11}","[267]\\\\d{7}|[89]\\\\d{6}(?:\\\\d{4})?",[["(\\\\d{4})(\\\\d{4})","$1 $2","[267]",null],["(\\\\d{3})(\\\\d{4})","$1 $2","[89]",null],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","[89]",null]]]',
+ 685: '["WS","0",null,null,null,null,"\\\\d{5,7}","[2-8]\\\\d{4,6}",[["(8\\\\d{2})(\\\\d{3,4})","$1 $2","8",null],["(7\\\\d)(\\\\d{5})","$1 $2","7",null],["(\\\\d{5})","$1","[2-6]",null]]]',
+ 880: '["BD","00","0",null,null,"$NP$FG","\\\\d{6,10}","[2-79]\\\\d{5,9}|1\\\\d{9}|8[0-7]\\\\d{4,8}",[["(2)(\\\\d{7,8})","$1-$2","2",null],["(\\\\d{2})(\\\\d{4,6})","$1-$2","[3-79]1",null],["(\\\\d{4})(\\\\d{3,6})","$1-$2","1|3(?:0|[2-58]2)|4(?:0|[25]2|3[23]|[4689][25])|5(?:[02-578]2|6[25])|6(?:[0347-9]2|[26][25])|7[02-9]2|8(?:[023][23]|[4-7]2)|9(?:[02][23]|[458]2|6[016])",null],["(\\\\d{3})(\\\\d{3,7})","$1-$2","[3-79][2-9]|8",null]]]',
+ 265: '["MW","00","0",null,null,"$NP$FG","\\\\d{7,9}","(?:1(?:\\\\d{2})?|[2789]\\\\d{2})\\\\d{6}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1",null],["(2\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","2",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[1789]",null]]]',
+ 65: '["SG","0[0-3]\\\\d",null,null,null,null,"\\\\d{8,11}","[36]\\\\d{7}|[17-9]\\\\d{7,10}",[["([3689]\\\\d{3})(\\\\d{4})","$1 $2","[369]|8[1-9]",null],["(1[89]00)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[89]",null],["(7000)(\\\\d{4})(\\\\d{3})","$1 $2 $3","70",null],["(800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80",null]]]',
+ 504: '["HN","00",null,null,null,null,"\\\\d{8}","[237-9]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1-$2",null,null]]]',
+ 688: '["TV","00",null,null,null,null,"\\\\d{5,7}","[279]\\\\d{4,6}"]',
+ 84: '["VN","00","0",null,null,"$NP$FG","\\\\d{7,10}","[167]\\\\d{6,9}|[2-59]\\\\d{7,9}|8\\\\d{6,8}",[["([17]99)(\\\\d{4})","$1 $2","[17]99",null],["([48])(\\\\d{4})(\\\\d{4})","$1 $2 $3","4|8(?:[1-57]|6[0-79]|9[0-7])",null],["([235-7]\\\\d)(\\\\d{4})(\\\\d{3})","$1 $2 $3","2[025-79]|3[0136-9]|5[2-9]|6[0-46-8]|7[02-79]",null],["(80)(\\\\d{5})","$1 $2","80",null],["(69\\\\d)(\\\\d{4,5})","$1 $2","69",null],["([235-7]\\\\d{2})(\\\\d{4})(\\\\d{3})","$1 $2 $3","2[0-489]|3[25]|5[01]|65|7[18]",null],["([89]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","8(?:68|8|9[89])|9",null],["(1[2689]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1(?:[26]|8[68]|99)",null],["(1[89]00)(\\\\d{4,6})","$1 $2","1[89]0","$FG"]]]',
+ 255: '["TZ","00[056]","0",null,null,"$NP$FG","\\\\d{7,9}","\\\\d{9}",[["([24]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[24]",null],["([67]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[67]",null],["([89]\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3","[89]",null]]]',
+ 222: '["MR","00",null,null,null,null,"\\\\d{8}","[2-48]\\\\d{7}",[["([2-48]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 230: '["MU","0(?:0|[2-7]0|33)",null,null,null,null,"\\\\d{7,8}","[2-9]\\\\d{6,7}",[["([2-46-9]\\\\d{2})(\\\\d{4})","$1 $2","[2-46-9]",null],["(5\\\\d{3})(\\\\d{4})","$1 $2","5",null]]]',
+ 592: '["GY","001",null,null,null,null,"\\\\d{7}","[2-46-9]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 41: '["CH","00","0",null,null,"$NP$FG","\\\\d{9}(?:\\\\d{3})?","[2-9]\\\\d{8}|860\\\\d{9}",[["([2-9]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[2-7]|[89]1",null],["([89]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","8[047]|90",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","860",null]]]',
+ 39: [
+ '["IT","00",null,null,null,null,"\\\\d{6,11}","[01589]\\\\d{5,10}|3(?:[12457-9]\\\\d{8}|[36]\\\\d{7,9})",[["(\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","0[26]|55",null],["(0[26])(\\\\d{4})(\\\\d{5})","$1 $2 $3","0[26]",null],["(0[26])(\\\\d{4,6})","$1 $2","0[26]",null],["(0\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","0[13-57-9][0159]",null],["(\\\\d{3})(\\\\d{3,6})","$1 $2","0[13-57-9][0159]|8(?:03|4[17]|9[245])",null],["(0\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","0[13-57-9][2-46-8]",null],["(0\\\\d{3})(\\\\d{2,6})","$1 $2","0[13-57-9][2-46-8]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[13]|8(?:00|4[08]|9[59])",null],["(\\\\d{4})(\\\\d{4})","$1 $2","894",null],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","3",null]]]',
+ '["VA","00",null,null,null,null,"\\\\d{6,11}","(?:0(?:878\\\\d{5}|6698\\\\d{5})|[1589]\\\\d{5,10}|3(?:[12457-9]\\\\d{8}|[36]\\\\d{7,9}))"]',
+ ],
+ 993: '["TM","810","8",null,null,"($NP $FG)","\\\\d{8}","[1-6]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","12",null],["(\\\\d{2})(\\\\d{6})","$1 $2","6","$NP $FG"],["(\\\\d{3})(\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","13|[2-5]",null]]]',
+ 888: '["001",null,null,null,null,null,"\\\\d{11}","\\\\d{11}",[["(\\\\d{3})(\\\\d{3})(\\\\d{5})","$1 $2 $3",null,null]]]',
+ 353: '["IE","00","0",null,null,"($NP$FG)","\\\\d{5,10}","[124-9]\\\\d{6,9}",[["(1)(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d{2})(\\\\d{5})","$1 $2","2[24-9]|47|58|6[237-9]|9[35-9]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","40[24]|50[45]",null],["(48)(\\\\d{4})(\\\\d{4})","$1 $2 $3","48",null],["(818)(\\\\d{3})(\\\\d{3})","$1 $2 $3","81",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[24-69]|7[14]",null],["([78]\\\\d)(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","76|8[35-9]","$NP$FG"],["(700)(\\\\d{3})(\\\\d{3})","$1 $2 $3","70","$NP$FG"],["(\\\\d{4})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1(?:8[059]|5)","$FG"]]]',
+ 966: '["SA","00","0",null,null,"$NP$FG","\\\\d{7,10}","1\\\\d{7,8}|(?:[2-467]|92)\\\\d{7}|5\\\\d{8}|8\\\\d{9}",[["([1-467])(\\\\d{3})(\\\\d{4})","$1 $2 $3","[1-467]",null],["(1\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[1-467]",null],["(5\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","5",null],["(92\\\\d{2})(\\\\d{5})","$1 $2","92","$FG"],["(800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80","$FG"],["(811)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","81",null]]]',
+ 380: '["UA","00","0",null,null,"$NP$FG","\\\\d{5,9}","[3-9]\\\\d{8}",[["([3-9]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[38]9|4(?:[45][0-5]|87)|5(?:0|6[37]|7[37])|6[36-8]|7|9[1-9]",null],["([3-689]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","3[1-8]2|4[13678]2|5(?:[12457]2|6[24])|6(?:[49]2|[12][29]|5[24])|8[0-8]|90",null],["([3-6]\\\\d{3})(\\\\d{5})","$1 $2","3(?:5[013-9]|[1-46-8])|4(?:[137][013-9]|6|[45][6-9]|8[4-6])|5(?:[1245][013-9]|6[0135-9]|3|7[4-6])|6(?:[49][013-9]|5[0135-9]|[12][13-8])",null]]]',
+ 98: '["IR","00","0",null,null,"$NP$FG","\\\\d{4,10}","[1-8]\\\\d{9}|9(?:[0-4]\\\\d{8}|9\\\\d{2,8})",[["(21)(\\\\d{3,5})","$1 $2","21",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","[1-8]",null],["(\\\\d{3})(\\\\d{3})","$1 $2","9",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","9",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","9",null]]]',
+ 971: '["AE","00","0",null,null,"$NP$FG","\\\\d{5,12}","[2-79]\\\\d{7,8}|800\\\\d{2,9}",[["([2-4679])(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2-4679][2-8]",null],["(5\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","5",null],["([479]00)(\\\\d)(\\\\d{5})","$1 $2 $3","[479]0","$FG"],["([68]00)(\\\\d{2,9})","$1 $2","60|8","$FG"]]]',
+ 30: '["GR","00",null,null,null,null,"\\\\d{10}","[26-9]\\\\d{9}",[["([27]\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","21|7",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","2[2-9]1|[689]",null],["(2\\\\d{3})(\\\\d{6})","$1 $2","2[2-9][02-9]",null]]]',
+ 228: '["TG","00",null,null,null,null,"\\\\d{8}","[29]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[29]",null]]]',
+ 48: '["PL","00",null,null,null,null,"\\\\d{6,9}","[12]\\\\d{6,8}|[3-57-9]\\\\d{8}|6\\\\d{5,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[14]|2[0-57-9]|3[2-4]|5[24-689]|6[1-3578]|7[14-7]|8[1-79]|9[145]",null],["(\\\\d{2})(\\\\d{1})(\\\\d{4})","$1 $2 $3","[12]2",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","26|39|5[0137]|6[0469]|7[02389]|8[08]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","64",null],["(\\\\d{3})(\\\\d{3})","$1 $2","64",null]]]',
+ 886: '["TW","0(?:0[25679]|19)","0",null,null,"$NP$FG","\\\\d{7,10}","2\\\\d{6,8}|[3-689]\\\\d{7,8}|7\\\\d{7,9}",[["(20)(\\\\d)(\\\\d{4})","$1 $2 $3","202",null],["(20)(\\\\d{3})(\\\\d{4})","$1 $2 $3","20[013-9]",null],["([2-8])(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","2[23-8]|[3-6]|[78][1-9]",null],["([89]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","80|9",null],["(70)(\\\\d{4})(\\\\d{4})","$1 $2 $3","70",null]]]',
+ 212: [
+ '["MA","00","0",null,null,"$NP$FG","\\\\d{9}","[5-9]\\\\d{8}",[["([5-7]\\\\d{2})(\\\\d{6})","$1-$2","5(?:2[015-7]|3[0-4])|[67]",null],["([58]\\\\d{3})(\\\\d{5})","$1-$2","5(?:2[2-489]|3[5-9]|92)|892",null],["(5\\\\d{4})(\\\\d{4})","$1-$2","5(?:29|38)",null],["([5]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","5(?:4[067]|5[03])",null],["(8[09])(\\\\d{7})","$1-$2","8(?:0|9[013-9])",null]]]',
+ '["EH","00","0",null,null,"$NP$FG","\\\\d{9}","[5-9]\\\\d{8}"]',
+ ],
+ 372: '["EE","00",null,null,null,null,"\\\\d{4,10}","1\\\\d{3,4}|[3-9]\\\\d{6,7}|800\\\\d{6,7}",[["([3-79]\\\\d{2})(\\\\d{4})","$1 $2","[369]|4[3-8]|5(?:[0-2]|5[0-478]|6[45])|7[1-9]",null],["(70)(\\\\d{2})(\\\\d{4})","$1 $2 $3","70",null],["(8000)(\\\\d{3})(\\\\d{3})","$1 $2 $3","800",null],["([458]\\\\d{3})(\\\\d{3,4})","$1 $2","40|5|8(?:00|[1-5])",null]]]',
+ 598: '["UY","0(?:1[3-9]\\\\d|0)","0",null,null,null,"\\\\d{7,8}","[2489]\\\\d{6,7}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[24]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","9[1-9]","$NP$FG"],["(\\\\d{3})(\\\\d{4})","$1 $2","[89]0","$NP$FG"]]]',
+ 502: '["GT","00",null,null,null,null,"\\\\d{8}(?:\\\\d{3})?","[2-7]\\\\d{7}|1[89]\\\\d{9}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[2-7]",null],["(\\\\d{4})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null]]]',
+ 82: '["KR","00(?:[124-68]|3\\\\d{2}|7(?:[0-8]\\\\d|9[0-79]))","0","0(8[1-46-8]|85\\\\d{2})?",null,"$NP$FG","\\\\d{3,14}","007\\\\d{9,11}|[1-7]\\\\d{3,9}|8\\\\d{8}",[["(\\\\d{5})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","00798","$FG","NA"],["(\\\\d{5})(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3 $4","00798","$FG","NA"],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1-$2-$3","1(?:0|1[19]|[69]9|5[458])|[57]0",null],["(\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1-$2-$3","1(?:[01]|5[1-4]|6[2-8]|[7-9])|[68]0|[3-6][1-9][1-9]",null],["(\\\\d{3})(\\\\d)(\\\\d{4})","$1-$2-$3","131",null],["(\\\\d{3})(\\\\d{2})(\\\\d{4})","$1-$2-$3","131",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1-$2-$3","13[2-9]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3-$4","30",null],["(\\\\d)(\\\\d{3,4})(\\\\d{4})","$1-$2-$3","2[1-9]",null],["(\\\\d)(\\\\d{3,4})","$1-$2","21[0-46-9]",null],["(\\\\d{2})(\\\\d{3,4})","$1-$2","[3-6][1-9]1",null],["(\\\\d{4})(\\\\d{4})","$1-$2","1(?:5[246-9]|6[04678]|8[03579])","$FG"]]]',
+ 253: '["DJ","00",null,null,null,null,"\\\\d{8}","[27]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 91: '["IN","00","0",null,null,"$NP$FG","\\\\d{6,13}","008\\\\d{9}|1\\\\d{7,12}|[2-9]\\\\d{9,10}",[["(\\\\d{5})(\\\\d{5})","$1 $2","600|7(?:[02-8]|19|9[037-9])|8(?:0[015-9]|[1-9]|20)|9",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","11|2[02]|33|4[04]|79[1-9]|80[2-46]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1(?:2[0-249]|3[0-25]|4[145]|[59][14]|7[1257]|[68][1-9])|2(?:1[257]|3[013]|4[01]|5[0137]|6[0158]|78|8[1568]|9[14])|3(?:26|4[1-3]|5[34]|6[01489]|7[02-46]|8[159])|4(?:1[36]|2[1-47]|3[15]|5[12]|6[0-26-9]|7[0-24-9]|8[013-57]|9[014-7])|5(?:1[025]|[36][25]|22|4[28]|5[12]|[78]1|9[15])|6(?:12|[2-4]1|5[17]|6[13]|7[14]|80)|7(?:12|2[14]|3[134]|4[47]|5[15]|[67]1|88)|8(?:16|2[014]|3[126]|6[136]|7[078]|8[34]|91)",null],["(\\\\d{4})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1(?:[23579]|[468][1-9])|[2-8]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})(\\\\d{3})","$1 $2 $3 $4","008",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","140","$FG"],["(\\\\d{4})(\\\\d{2})(\\\\d{4})","$1 $2 $3","160","$FG"],["(\\\\d{4})(\\\\d{4,5})","$1 $2","180","$FG"],["(\\\\d{4})(\\\\d{2,4})(\\\\d{4})","$1 $2 $3","180","$FG"],["(\\\\d{4})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","186","$FG"],["(\\\\d{4})(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","18[06]","$FG"]]]',
+ 389: '["MK","00","0",null,null,"$NP$FG","\\\\d{6,8}","[2-578]\\\\d{7}",[["(2)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2",null],["([347]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[347]",null],["([58]\\\\d{2})(\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[58]",null]]]',
+ 1: [
+ '["US","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2-9]\\\\d{9}",[["(\\\\d{3})(\\\\d{4})","$1-$2",null,null,"NA"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","($1) $2-$3",null,null,"$1-$2-$3"]]]',
+ '["AI","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]',
+ '["AS","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]',
+ '["BB","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]',
+ '["BM","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[4589]\\\\d{9}"]',
+ '["BS","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]',
+ '["CA","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2-9]\\\\d{9}|3\\\\d{6}"]',
+ '["DM","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[57-9]\\\\d{9}"]',
+ '["DO","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]',
+ '["GD","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[4589]\\\\d{9}"]',
+ '["GU","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]',
+ '["JM","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]',
+ '["KN","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]',
+ '["KY","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[3589]\\\\d{9}"]',
+ '["LC","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]',
+ '["MP","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]',
+ '["MS","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]',
+ '["PR","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]',
+ '["SX","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]',
+ '["TC","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]',
+ '["TT","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]',
+ '["AG","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]',
+ '["VC","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]',
+ '["VG","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]',
+ '["VI","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[3589]\\\\d{9}"]',
+ ],
+ 60: '["MY","00","0",null,null,null,"\\\\d{6,10}","[13-9]\\\\d{7,9}",[["([4-79])(\\\\d{3})(\\\\d{4})","$1-$2 $3","[4-79]","$NP$FG"],["(3)(\\\\d{4})(\\\\d{4})","$1-$2 $3","3","$NP$FG"],["([18]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1-$2 $3","1[02-46-9][1-9]|8","$NP$FG"],["(1)([36-8]00)(\\\\d{2})(\\\\d{4})","$1-$2-$3-$4","1[36-8]0",null],["(11)(\\\\d{4})(\\\\d{4})","$1-$2 $3","11","$NP$FG"],["(15[49])(\\\\d{3})(\\\\d{4})","$1-$2 $3","15","$NP$FG"]]]',
+ 355: '["AL","00","0",null,null,"$NP$FG","\\\\d{5,9}","[2-57]\\\\d{7}|6\\\\d{8}|8\\\\d{5,7}|9\\\\d{5}",[["(4)(\\\\d{3})(\\\\d{4})","$1 $2 $3","4[0-6]",null],["(6\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","6",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2358][2-5]|4[7-9]",null],["(\\\\d{3})(\\\\d{3,5})","$1 $2","[235][16-9]|8[016-9]|[79]",null]]]',
+ 254: '["KE","000","0","005|0",null,"$NP$FG","\\\\d{7,10}","20\\\\d{6,7}|[4-9]\\\\d{6,9}",[["(\\\\d{2})(\\\\d{5,7})","$1 $2","[24-6]",null],["(\\\\d{3})(\\\\d{6})","$1 $2","7",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[89]",null]]]',
+ 223: '["ML","00",null,null,null,null,"\\\\d{8}","[246-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[246-9]",null],["(\\\\d{4})","$1","67|74",null,"NA"]]]',
+ 686: '["KI","00",null,"0",null,null,"\\\\d{5,8}","[2458]\\\\d{4}|3\\\\d{4,7}|7\\\\d{7}"]',
+ 994: '["AZ","00","0",null,null,"($NP$FG)","\\\\d{7,9}","[1-9]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","(?:1[28]|2(?:[45]2|[0-36])|365)",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[4-8]","$NP$FG"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","9","$NP$FG"]]]',
+ 979: '["001",null,null,null,null,null,"\\\\d{9}","\\\\d{9}",[["(\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3",null,null]]]',
+ 66: '["TH","00","0",null,null,"$NP$FG","\\\\d{4}|\\\\d{8,10}","[2-9]\\\\d{7,8}|1\\\\d{3}(?:\\\\d{5,6})?",[["(2)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2",null],["([13-9]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","14|[3-9]",null],["(1[89]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1","$FG"]]]',
+ 233: '["GH","00","0",null,null,"$NP$FG","\\\\d{7,9}","[235]\\\\d{8}|8\\\\d{7}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[235]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","8",null]]]',
+ 593: '["EC","00","0",null,null,"($NP$FG)","\\\\d{7,11}","1\\\\d{9,10}|[2-8]\\\\d{7}|9\\\\d{8}",[["(\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2-$3","[247]|[356][2-8]",null,"$1-$2-$3"],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","9","$NP$FG"],["(1800)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","1","$FG"]]]',
+ 509: '["HT","00",null,null,null,null,"\\\\d{8}","[2-489]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3",null,null]]]',
+ 54: '["AR","00","0","0?(?:(11|2(?:2(?:02?|[13]|2[13-79]|4[1-6]|5[2457]|6[124-8]|7[1-4]|8[13-6]|9[1267])|3(?:02?|1[467]|2[03-6]|3[13-8]|[49][2-6]|5[2-8]|[67])|4(?:7[3-578]|9)|6(?:[0136]|2[24-6]|4[6-8]?|5[15-8])|80|9(?:0[1-3]|[19]|2\\\\d|3[1-6]|4[02568]?|5[2-4]|6[2-46]|72?|8[23]?))|3(?:3(?:2[79]|6|8[2578])|4(?:0[0-24-9]|[12]|3[5-8]?|4[24-7]|5[4-68]?|6[02-9]|7[126]|8[2379]?|9[1-36-8])|5(?:1|2[1245]|3[237]?|4[1-46-9]|6[2-4]|7[1-6]|8[2-5]?)|6[24]|7(?:[069]|1[1568]|2[15]|3[145]|4[13]|5[14-8]|7[2-57]|8[126])|8(?:[01]|2[15-7]|3[2578]?|4[13-6]|5[4-8]?|6[1-357-9]|7[36-8]?|8[5-8]?|9[124])))?15)?","9$1","$NP$FG","\\\\d{6,11}","11\\\\d{8}|[2368]\\\\d{9}|9\\\\d{10}",[["([68]\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3","[68]",null],["(\\\\d{2})(\\\\d{4})","$1-$2","[2-9]","$FG","NA"],["(\\\\d{3})(\\\\d{4})","$1-$2","[2-9]","$FG","NA"],["(\\\\d{4})(\\\\d{4})","$1-$2","[2-9]","$FG","NA"],["(9)(11)(\\\\d{4})(\\\\d{4})","$2 15-$3-$4","911",null,"$1 $2 $3-$4"],["(9)(\\\\d{3})(\\\\d{3})(\\\\d{4})","$2 15-$3-$4","9(?:2[234689]|3[3-8])",null,"$1 $2 $3-$4"],["(9)(\\\\d{4})(\\\\d{2})(\\\\d{4})","$2 15-$3-$4","9[23]",null,"$1 $2 $3-$4"],["(11)(\\\\d{4})(\\\\d{4})","$1 $2-$3","1",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2-$3","2(?:2[013]|3[067]|49|6[01346]|80|9[147-9])|3(?:36|4[1-358]|5[138]|6[24]|7[069]|8[013578])",null],["(\\\\d{4})(\\\\d{2})(\\\\d{4})","$1 $2-$3","[23]",null],["(\\\\d{3})","$1","1[012]|911","$FG","NA"]]]',
+ 57: '["CO","00(?:4(?:[14]4|56)|[579])","0","0([3579]|4(?:44|56))?",null,null,"\\\\d{7,11}","(?:[13]\\\\d{0,3}|[24-8])\\\\d{7}",[["(\\\\d)(\\\\d{7})","$1 $2","1(?:8[2-9]|9[0-3]|[2-7])|[24-8]","($FG)"],["(\\\\d{3})(\\\\d{7})","$1 $2","3",null],["(1)(\\\\d{3})(\\\\d{7})","$1-$2-$3","1(?:80|9[04])","$NP$FG","$1 $2 $3"]]]',
+ 597: '["SR","00",null,null,null,null,"\\\\d{6,7}","[2-8]\\\\d{5,6}",[["(\\\\d{3})(\\\\d{3})","$1-$2","[2-4]|5[2-58]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1-$2-$3","56",null],["(\\\\d{3})(\\\\d{4})","$1-$2","[6-8]",null]]]',
+ 676: '["TO","00",null,null,null,null,"\\\\d{5,7}","[02-8]\\\\d{4,6}",[["(\\\\d{2})(\\\\d{3})","$1-$2","[1-6]|7[0-4]|8[05]",null],["(\\\\d{3})(\\\\d{4})","$1 $2","7[5-9]|8[47-9]",null],["(\\\\d{4})(\\\\d{3})","$1 $2","0",null]]]',
+ 505: '["NI","00",null,null,null,null,"\\\\d{8}","[12578]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]',
+ 850: '["KP","00|99","0",null,null,"$NP$FG","\\\\d{6,8}|\\\\d{10}","1\\\\d{9}|[28]\\\\d{7}",[["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","8",null]]]',
+ 7: [
+ '["RU","810","8",null,null,"$NP ($FG)","\\\\d{10}","[3489]\\\\d{9}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1-$2-$3","[1-79]","$FG","NA"],["([3489]\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","[34689]",null],["(7\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","7",null]]]',
+ '["KZ","810","8",null,null,null,"\\\\d{10}","(?:33\\\\d|7\\\\d{2}|80[09])\\\\d{7}"]',
+ ],
+ 268: '["SZ","00",null,null,null,null,"\\\\d{8}","[027]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[027]",null]]]',
+ 501: '["BZ","00",null,null,null,null,"\\\\d{7}(?:\\\\d{4})?","[2-8]\\\\d{6}|0\\\\d{10}",[["(\\\\d{3})(\\\\d{4})","$1-$2","[2-8]",null],["(0)(800)(\\\\d{4})(\\\\d{3})","$1-$2-$3-$4","0",null]]]',
+ 252: '["SO","00","0",null,null,null,"\\\\d{6,9}","[1-9]\\\\d{5,8}",[["(\\\\d{6})","$1","[134]",null],["(\\\\d)(\\\\d{6})","$1 $2","2[0-79]|[13-5]",null],["(\\\\d)(\\\\d{7})","$1 $2","24|[67]",null],["(\\\\d{2})(\\\\d{4})","$1 $2","8[125]",null],["(\\\\d{2})(\\\\d{5,7})","$1 $2","15|28|6[1-35-9]|799|9[2-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","3[59]|4[89]|6[24-6]|79|8[08]|90",null]]]',
+ 229: '["BJ","00",null,null,null,null,"\\\\d{4,8}","[2689]\\\\d{7}|7\\\\d{3}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 680: '["PW","01[12]",null,null,null,null,"\\\\d{7}","[2-8]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 263: '["ZW","00","0",null,null,"$NP$FG","\\\\d{3,10}","2(?:[012457-9]\\\\d{3,8}|6(?:[14]\\\\d{7}|\\\\d{4}))|[13-79]\\\\d{4,9}|8[06]\\\\d{8}",[["([49])(\\\\d{3})(\\\\d{2,4})","$1 $2 $3","4|9[2-9]",null],["(7\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","7",null],["(86\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","86[24]",null],["([2356]\\\\d{2})(\\\\d{3,5})","$1 $2","2(?:0[45]|2[278]|[49]8|[78])|3(?:08|17|3[78]|7[1569]|8[37]|98)|5[15][78]|6(?:[29]8|[38]7|6[78]|75|[89]8)",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2(?:1[39]|2[0157]|6[14]|7[35]|84)|329",null],["([1-356]\\\\d)(\\\\d{3,5})","$1 $2","1[3-9]|2[0569]|3[0-69]|5[05689]|6[0-46-9]",null],["([235]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[23]9|54",null],["([25]\\\\d{3})(\\\\d{3,5})","$1 $2","(?:25|54)8",null],["(8\\\\d{3})(\\\\d{6})","$1 $2","86",null],["(80\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80",null]]]',
+ 90: '["TR","00","0",null,null,null,"\\\\d{7,10}","[2-589]\\\\d{9}|444\\\\d{4}",[["(\\\\d{3})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[23]|4(?:[0-35-9]|4[0-35-9])","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","5[02-69]","$NP$FG"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","51|[89]","$NP$FG"],["(444)(\\\\d{1})(\\\\d{3})","$1 $2 $3","444",null]]]',
+ 352: '["LU","00",null,"(15(?:0[06]|1[12]|35|4[04]|55|6[26]|77|88|99)\\\\d)",null,null,"\\\\d{4,11}","[24-9]\\\\d{3,10}|3(?:[0-46-9]\\\\d{2,9}|5[013-9]\\\\d{1,8})",[["(\\\\d{2})(\\\\d{3})","$1 $2","[2-5]|7[1-9]|[89](?:[1-9]|0[2-9])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3","[2-5]|7[1-9]|[89](?:[1-9]|0[2-9])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","20",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{1,2})","$1 $2 $3 $4","2(?:[0367]|4[3-8])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","20",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{1,2})","$1 $2 $3 $4 $5","2(?:[0367]|4[3-8])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{1,4})","$1 $2 $3 $4","2(?:[12589]|4[12])|[3-5]|7[1-9]|8(?:[1-9]|0[2-9])|9(?:[1-9]|0[2-46-9])",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","70|80[01]|90[015]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","6",null]]]',
+ 47: [
+ '["NO","00",null,null,null,null,"\\\\d{5}(?:\\\\d{3})?","0\\\\d{4}|[2-9]\\\\d{7}",[["([489]\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","[489]",null],["([235-7]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[235-7]",null]]]',
+ '["SJ","00",null,null,null,null,"\\\\d{5}(?:\\\\d{3})?","0\\\\d{4}|[45789]\\\\d{7}"]',
+ ],
+ 243: '["CD","00","0",null,null,"$NP$FG","\\\\d{7,9}","[2-6]\\\\d{6}|[18]\\\\d{6,8}|9\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","12",null],["([89]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","8[0-2459]|9",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","88",null],["(\\\\d{2})(\\\\d{5})","$1 $2","[1-6]",null]]]',
+ 220: '["GM","00",null,null,null,null,"\\\\d{7}","[2-9]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 687: '["NC","00",null,null,null,null,"\\\\d{6}","[2-57-9]\\\\d{5}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1.$2.$3","[2-46-9]|5[0-4]",null]]]',
+ 995: '["GE","00","0",null,null,null,"\\\\d{6,9}","[34578]\\\\d{8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[348]","$NP$FG"],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","7","$NP$FG"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","5","$FG"]]]',
+ 961: '["LB","00","0",null,null,null,"\\\\d{7,8}","[13-9]\\\\d{6,7}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[13-6]|7(?:[2-57]|62|8[0-7]|9[04-9])|8[02-9]|9","$NP$FG"],["([7-9]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[89][01]|7(?:[01]|6[013-9]|8[89]|9[1-3])",null]]]',
+ 40: '["RO","00","0",null,null,"$NP$FG","\\\\d{6,9}","[23]\\\\d{5,8}|[7-9]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[23]1",null],["(\\\\d{2})(\\\\d{4})","$1 $2","[23]1",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[23][3-7]|[7-9]",null],["(2\\\\d{2})(\\\\d{3})","$1 $2","2[3-6]",null]]]',
+ 232: '["SL","00","0",null,null,"($NP$FG)","\\\\d{6,8}","[2-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{6})","$1 $2",null,null]]]',
+ 594: '["GF","00","0",null,null,"$NP$FG","\\\\d{9}","[56]\\\\d{8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 976: '["MN","001","0",null,null,"$NP$FG","\\\\d{6,10}","[12]\\\\d{7,9}|[57-9]\\\\d{7}",[["([12]\\\\d)(\\\\d{2})(\\\\d{4})","$1 $2 $3","[12]1",null],["([12]2\\\\d)(\\\\d{5,6})","$1 $2","[12]2[1-3]",null],["([12]\\\\d{3})(\\\\d{5})","$1 $2","[12](?:27|[3-5])",null],["(\\\\d{4})(\\\\d{4})","$1 $2","[57-9]","$FG"],["([12]\\\\d{4})(\\\\d{4,5})","$1 $2","[12](?:27|[3-5])",null]]]',
+ 20: '["EG","00","0",null,null,"$NP$FG","\\\\d{5,10}","1\\\\d{4,9}|[2456]\\\\d{8}|3\\\\d{7}|[89]\\\\d{8,9}",[["(\\\\d)(\\\\d{7,8})","$1 $2","[23]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[012]|[89]00",null],["(\\\\d{2})(\\\\d{6,7})","$1 $2","1[35]|[4-6]|[89][2-9]",null]]]',
+ 689: '["PF","00",null,null,null,null,"\\\\d{6}(?:\\\\d{2})?","4\\\\d{5,7}|8\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","4[09]|8[79]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3","44",null]]]',
+ 56: '["CL","(?:0|1(?:1[0-69]|2[0-57]|5[13-58]|69|7[0167]|8[018]))0","0","0|(1(?:1[0-69]|2[0-57]|5[13-58]|69|7[0167]|8[018]))",null,"$NP$FG","\\\\d{7,11}","(?:[2-9]|600|123)\\\\d{7,8}",[["(\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","2[23]","($FG)"],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[357]|4[1-35]|6[13-57]","($FG)"],["(9)(\\\\d{4})(\\\\d{4})","$1 $2 $3","9",null],["(44)(\\\\d{3})(\\\\d{4})","$1 $2 $3","44",null],["([68]00)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","60|8","$FG"],["(600)(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","60","$FG"],["(1230)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1","$FG"],["(\\\\d{5})(\\\\d{4})","$1 $2","219","($FG)"],["(\\\\d{4,5})","$1","[1-9]","$FG","NA"]]]',
+ 596: '["MQ","00","0",null,null,"$NP$FG","\\\\d{9}","[56]\\\\d{8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 508: '["PM","00","0",null,null,"$NP$FG","\\\\d{6}","[45]\\\\d{5}",[["([45]\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]',
+ 269: '["KM","00",null,null,null,null,"\\\\d{7}","[3478]\\\\d{6}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]',
+ 358: [
+ '["FI","00|99(?:[02469]|5(?:11|33|5[59]|88|9[09]))","0",null,null,"$NP$FG","\\\\d{5,12}","1\\\\d{4,11}|[2-9]\\\\d{4,10}",[["(\\\\d{3})(\\\\d{3,7})","$1 $2","(?:[1-3]00|[6-8]0)",null],["(116\\\\d{3})","$1","116","$FG"],["(\\\\d{2})(\\\\d{4,10})","$1 $2","[14]|2[09]|50|7[135]",null],["(\\\\d)(\\\\d{4,11})","$1 $2","[25689][1-8]|3",null]]]',
+ '["AX","00|99(?:[02469]|5(?:11|33|5[59]|88|9[09]))","0",null,null,"$NP$FG","\\\\d{5,12}","1\\\\d{5,11}|[35]\\\\d{5,9}|[27]\\\\d{4,9}|4\\\\d{5,10}|6\\\\d{7,9}|8\\\\d{6,9}"]',
+ ],
+ 251: '["ET","00","0",null,null,"$NP$FG","\\\\d{7,9}","[1-59]\\\\d{8}",[["([1-59]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3",null,null]]]',
+ 681: '["WF","00",null,null,null,null,"\\\\d{6}","[4-8]\\\\d{5}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]',
+ 853: '["MO","00",null,null,null,null,"\\\\d{8}","[268]\\\\d{7}",[["([268]\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 44: [
+ '["GB","00","0",null,null,"$NP$FG","\\\\d{4,10}","\\\\d{7,10}",[["(7\\\\d{3})(\\\\d{6})","$1 $2","7(?:[1-5789]|62)",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","2|5[56]|7[06]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1(?:1|\\\\d1)|3|9[018]",null],["(\\\\d{5})(\\\\d{4,5})","$1 $2","1(?:38|5[23]|69|76|94)",null],["(1\\\\d{3})(\\\\d{5,6})","$1 $2","1",null],["(800)(\\\\d{4})","$1 $2","800",null],["(845)(46)(4\\\\d)","$1 $2 $3","845",null],["(8\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","8(?:4[2-5]|7[0-3])",null],["(80\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80",null],["([58]00)(\\\\d{6})","$1 $2","[58]00",null]]]',
+ '["GG","00","0",null,null,"$NP$FG","\\\\d{6,10}","[135789]\\\\d{6,9}"]',
+ '["IM","00","0",null,null,"$NP$FG","\\\\d{6,10}","[135789]\\\\d{6,9}"]',
+ '["JE","00","0",null,null,"$NP$FG","\\\\d{6,10}","[135789]\\\\d{6,9}"]',
+ ],
+ 244: '["AO","00",null,null,null,null,"\\\\d{9}","[29]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]',
+ 211: '["SS","00","0",null,null,null,"\\\\d{9}","[19]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,"$NP$FG"]]]',
+ 373: '["MD","00","0",null,null,"$NP$FG","\\\\d{8}","[235-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","22|3",null],["([25-7]\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","2[13-9]|[5-7]",null],["([89]\\\\d{2})(\\\\d{5})","$1 $2","[89]",null]]]',
+ 996: '["KG","00","0",null,null,"$NP$FG","\\\\d{5,10}","[235-8]\\\\d{8,9}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[25-7]|31[25]",null],["(\\\\d{4})(\\\\d{5})","$1 $2","3(?:1[36]|[2-9])",null],["(\\\\d{3})(\\\\d{3})(\\\\d)(\\\\d{3})","$1 $2 $3 $4","8",null]]]',
+ 93: '["AF","00","0",null,null,"$NP$FG","\\\\d{7,9}","[2-7]\\\\d{8}",[["([2-7]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2-7]",null]]]',
+ 260: '["ZM","00","0",null,null,"$NP$FG","\\\\d{9}","[289]\\\\d{8}",[["([29]\\\\d)(\\\\d{7})","$1 $2","[29]",null],["(800)(\\\\d{3})(\\\\d{3})","$1 $2 $3","8",null]]]',
+ 378: '["SM","00",null,"(?:0549)?([89]\\\\d{5})","0549$1",null,"\\\\d{6,10}","[05-7]\\\\d{7,9}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[5-7]",null],["(0549)(\\\\d{6})","$1 $2","0",null,"($1) $2"],["(\\\\d{6})","0549 $1","[89]",null,"(0549) $1"]]]',
+ 235: '["TD","00|16",null,null,null,null,"\\\\d{8}","[2679]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 960: '["MV","0(?:0|19)",null,null,null,null,"\\\\d{7,10}","[346-8]\\\\d{6,9}|9(?:00\\\\d{7}|\\\\d{6})",[["(\\\\d{3})(\\\\d{4})","$1-$2","[3467]|9(?:[1-9]|0[1-9])",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[89]00",null]]]',
+ 221: '["SN","00",null,null,null,null,"\\\\d{9}","[3789]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[379]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","8",null]]]',
+ 595: '["PY","00","0",null,null,null,"\\\\d{5,9}","5[0-5]\\\\d{4,7}|[2-46-9]\\\\d{5,8}",[["(\\\\d{2})(\\\\d{5})","$1 $2","(?:[26]1|3[289]|4[124678]|7[123]|8[1236])","($NP$FG)"],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","(?:[26]1|3[289]|4[124678]|7[123]|8[1236])","($NP$FG)"],["(\\\\d{3})(\\\\d{3,6})","$1 $2","[2-9]0","$NP$FG"],["(\\\\d{3})(\\\\d{6})","$1 $2","9[1-9]","$NP$FG"],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","8700",null],["(\\\\d{3})(\\\\d{4,5})","$1 $2","[2-8][1-9]","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-8][1-9]","$NP$FG"]]]',
+ 977: '["NP","00","0",null,null,"$NP$FG","\\\\d{6,10}","[1-8]\\\\d{7}|9(?:[1-69]\\\\d{6,8}|7[2-6]\\\\d{5,7}|8\\\\d{8})",[["(1)(\\\\d{7})","$1-$2","1[2-6]",null],["(\\\\d{2})(\\\\d{6})","$1-$2","1[01]|[2-8]|9(?:[1-69]|7[15-9])",null],["(9\\\\d{2})(\\\\d{7})","$1-$2","9(?:6[013]|7[245]|8)","$FG"]]]',
+ 36: '["HU","00","06",null,null,"($FG)","\\\\d{6,9}","[1-9]\\\\d{7,8}",[["(1)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[2-9]",null]]]',
+};
diff --git a/toolkit/components/formautofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs b/toolkit/components/formautofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs
new file mode 100644
index 0000000000..604eefe314
--- /dev/null
+++ b/toolkit/components/formautofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Apache License, Version
+ * 2.0. If a copy of the Apache License was not distributed with this file, You
+ * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */
+
+// This library came from https://github.com/andreasgal/PhoneNumber.js but will
+// be further maintained by our own in Form Autofill codebase.
+
+export var PhoneNumberNormalizer = (function () {
+ const UNICODE_DIGITS = /[\uFF10-\uFF19\u0660-\u0669\u06F0-\u06F9]/g;
+ const VALID_ALPHA_PATTERN = /[a-zA-Z]/g;
+ const LEADING_PLUS_CHARS_PATTERN = /^[+\uFF0B]+/g;
+ const NON_DIALABLE_CHARS = /[^,#+\*\d]/g;
+
+ // Map letters to numbers according to the ITU E.161 standard
+ let E161 = {
+ a: 2,
+ b: 2,
+ c: 2,
+ d: 3,
+ e: 3,
+ f: 3,
+ g: 4,
+ h: 4,
+ i: 4,
+ j: 5,
+ k: 5,
+ l: 5,
+ m: 6,
+ n: 6,
+ o: 6,
+ p: 7,
+ q: 7,
+ r: 7,
+ s: 7,
+ t: 8,
+ u: 8,
+ v: 8,
+ w: 9,
+ x: 9,
+ y: 9,
+ z: 9,
+ };
+
+ // Normalize a number by converting unicode numbers and symbols to their
+ // ASCII equivalents and removing all non-dialable characters.
+ function NormalizeNumber(number, numbersOnly) {
+ if (typeof number !== "string") {
+ return "";
+ }
+
+ number = number.replace(UNICODE_DIGITS, function (ch) {
+ return String.fromCharCode(48 + (ch.charCodeAt(0) & 0xf));
+ });
+ if (!numbersOnly) {
+ number = number.replace(VALID_ALPHA_PATTERN, function (ch) {
+ return String(E161[ch.toLowerCase()] || 0);
+ });
+ }
+ number = number.replace(LEADING_PLUS_CHARS_PATTERN, "+");
+ number = number.replace(NON_DIALABLE_CHARS, "");
+ return number;
+ }
+
+ return {
+ Normalize: NormalizeNumber,
+ };
+})();
diff --git a/toolkit/components/formautofill/shared/AddressComponent.sys.mjs b/toolkit/components/formautofill/shared/AddressComponent.sys.mjs
new file mode 100644
index 0000000000..a849e889b2
--- /dev/null
+++ b/toolkit/components/formautofill/shared/AddressComponent.sys.mjs
@@ -0,0 +1,1120 @@
+/* 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/. */
+
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddressParser: "resource://gre/modules/shared/AddressParser.sys.mjs",
+ FormAutofillNameUtils:
+ "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ PhoneNumber: "resource://autofill/phonenumberutils/PhoneNumber.sys.mjs",
+ PhoneNumberNormalizer:
+ "resource://autofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs",
+});
+
+/**
+ * Class representing a collection of tokens extracted from a string.
+ */
+class Tokens {
+ #tokens = null;
+
+ // By default we split passed string with whitespace.
+ constructor(value, sep = /\s+/) {
+ this.#tokens = value.split(sep);
+ }
+
+ get tokens() {
+ return this.#tokens;
+ }
+
+ /**
+ * Checks if all the tokens in the current object can be found in another
+ * token object.
+ *
+ * @param {Tokens} other The other Tokens instance to compare with.
+ * @param {Function} compare An optional custom comparison function.
+ * @returns {boolean} True if the current Token object is a subset of the
+ * other Token object, false otherwise.
+ */
+ isSubset(other, compare = (a, b) => a == b) {
+ return this.tokens.every(tokenSelf => {
+ for (const tokenOther of other.tokens) {
+ if (compare(tokenSelf, tokenOther)) {
+ return true;
+ }
+ }
+ return false;
+ });
+ }
+
+ /**
+ * Checks if all the tokens in the current object can be found in another
+ * Token object's tokens (in order).
+ * For example, ["John", "Doe"] is a subset of ["John", "Michael", "Doe"]
+ * in order but not a subset of ["Doe", "Michael", "John"] in order.
+ *
+ * @param {Tokens} other The other Tokens instance to compare with.
+ * @param {Function} compare An optional custom comparison function.
+ * @returns {boolean} True if the current Token object is a subset of the
+ * other Token object, false otherwise.
+ */
+ isSubsetInOrder(other, compare = (a, b) => a == b) {
+ if (this.tokens.length > other.tokens.length) {
+ return false;
+ }
+
+ let idx = 0;
+ return this.tokens.every(tokenSelf => {
+ for (; idx < other.tokens.length; idx++) {
+ if (compare(tokenSelf, other.tokens[idx])) {
+ return true;
+ }
+ }
+ return false;
+ });
+ }
+}
+
+/**
+ * The AddressField class is a base class representing a single address field.
+ */
+class AddressField {
+ #userValue = null;
+
+ #region = null;
+
+ /**
+ * Create a representation of a single address field.
+ *
+ * @param {string} value
+ * The unnormalized value of an address field.
+ *
+ * @param {string} region
+ * The region of a single address field. Used to determine what collator should be
+ * for string comparisons of the address's field value.
+ */
+ constructor(value, region) {
+ this.#userValue = value?.trim();
+ this.#region = region;
+ }
+
+ /**
+ * Get the unnormalized value of the address field.
+ *
+ * @returns {string} The unnormalized field value.
+ */
+ get userValue() {
+ return this.#userValue;
+ }
+
+ /**
+ * Get the collator used for string comparisons.
+ *
+ * @returns {Intl.Collator} The collator.
+ */
+ get collator() {
+ return lazy.FormAutofillUtils.getSearchCollators(this.#region, {
+ ignorePunctuation: false,
+ });
+ }
+
+ get region() {
+ return this.#region;
+ }
+
+ /**
+ * Compares two strings using the collator.
+ *
+ * @param {string} a The first string to compare.
+ * @param {string} b The second string to compare.
+ * @returns {number} A negative, zero, or positive value, depending on the comparison result.
+ */
+ localeCompare(a, b) {
+ return lazy.FormAutofillUtils.strCompare(a, b, this.collator);
+ }
+
+ /**
+ * Checks if the field value is empty.
+ *
+ * @returns {boolean} True if the field value is empty, false otherwise.
+ */
+ isEmpty() {
+ return !this.#userValue;
+ }
+
+ /**
+ * Normalizes the unnormalized field value using the provided options.
+ *
+ * @param {object} options - Options for normalization.
+ * @returns {string} The normalized field value.
+ */
+ normalizeUserValue(options) {
+ return lazy.AddressParser.normalizeString(this.#userValue, options);
+ }
+
+ /**
+ * Returns a string representation of the address field.
+ * Ex. "Country: US", "PostalCode: 55123", etc.
+ */
+ toString() {
+ return `${this.constructor.name}: ${this.#userValue}\n`;
+ }
+
+ /**
+ * Checks if the field value is valid.
+ *
+ * @returns {boolean} True if the field value is valid, false otherwise.
+ */
+ isValid() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ /**
+ * Compares the current field value with another field value for equality.
+ */
+ equals() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ /**
+ * Checks if the current field value contains another field value.
+ */
+ contains() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+}
+
+/**
+ * A street address.
+ * See autocomplete="street-address".
+ */
+class StreetAddress extends AddressField {
+ static ac = "street-address";
+
+ #structuredStreetAddress = null;
+
+ constructor(value, region) {
+ super(value, region);
+
+ this.#structuredStreetAddress = lazy.AddressParser.parseStreetAddress(
+ lazy.AddressParser.replaceControlCharacters(this.userValue, " ")
+ );
+ }
+
+ get structuredStreetAddress() {
+ return this.#structuredStreetAddress;
+ }
+ get street_number() {
+ return this.#structuredStreetAddress?.street_number;
+ }
+ get street_name() {
+ return this.#structuredStreetAddress?.street_name;
+ }
+ get floor_number() {
+ return this.#structuredStreetAddress?.floor_number;
+ }
+ get apartment_number() {
+ return this.#structuredStreetAddress?.apartment_number;
+ }
+
+ isValid() {
+ return this.userValue ? !!/[\p{Letter}]/u.exec(this.userValue) : true;
+ }
+
+ equals(other) {
+ if (this.structuredStreetAddress && other.structuredStreetAddress) {
+ return (
+ this.street_number?.toLowerCase() ==
+ other.street_number?.toLowerCase() &&
+ this.street_name?.toLowerCase() == other.street_name?.toLowerCase() &&
+ this.apartment_number?.toLowerCase() ==
+ other.apartment_number?.toLowerCase() &&
+ this.floor_number?.toLowerCase() == other.floor_number?.toLowerCase()
+ );
+ }
+
+ const options = {
+ ignore_case: true,
+ };
+
+ return (
+ this.normalizeUserValue(options) == other.normalizeUserValue(options)
+ );
+ }
+
+ contains(other) {
+ let selfStreetName = this.userValue;
+ let otherStreetName = other.userValue;
+
+ // Compare street number, apartment number and floor number if
+ // both addresses are parsed successfully.
+ if (this.structuredStreetAddress && other.structuredStreetAddress) {
+ if (
+ (other.street_number && this.street_number != other.street_number) ||
+ (other.apartment_number &&
+ this.apartment_number != other.apartment_number) ||
+ (other.floor_number && this.floor_number != other.floor_number)
+ ) {
+ return false;
+ }
+
+ // Use parsed street name to compare
+ selfStreetName = this.street_name;
+ otherStreetName = other.street_name;
+ }
+
+ // Check if one street name contains the other
+ const options = {
+ ignore_case: true,
+ replace_punctuation: " ",
+ };
+ const selfTokens = new Tokens(
+ lazy.AddressParser.normalizeString(selfStreetName, options),
+ /[\s\n\r]+/
+ );
+ const otherTokens = new Tokens(
+ lazy.AddressParser.normalizeString(otherStreetName, options),
+ /[\s\n\r]+/
+ );
+
+ return otherTokens.isSubsetInOrder(selfTokens, (a, b) =>
+ this.localeCompare(a, b)
+ );
+ }
+
+ static fromRecord(record, region) {
+ return new StreetAddress(record[StreetAddress.ac], region);
+ }
+}
+
+/**
+ * A postal code / zip code
+ * See autocomplete="postal-code"
+ */
+class PostalCode extends AddressField {
+ static ac = "postal-code";
+
+ constructor(value, region) {
+ super(value, region);
+ }
+
+ isValid() {
+ const { postalCodePattern } = lazy.FormAutofillUtils.getFormFormat(
+ this.region
+ );
+ const regexp = new RegExp(`^${postalCodePattern}$`);
+ return regexp.test(this.userValue);
+ }
+
+ equals(other) {
+ const options = {
+ ignore_case: true,
+ remove_whitespace: true,
+ remove_punctuation: true,
+ };
+
+ return (
+ this.normalizeUserValue(options) == other.normalizeUserValue(options)
+ );
+ }
+
+ contains(other) {
+ const options = {
+ ignore_case: true,
+ remove_whitespace: true,
+ remove_punctuation: true,
+ };
+
+ const self_normalized_value = this.normalizeUserValue(options);
+ const other_normalized_value = other.normalizeUserValue(options);
+
+ return (
+ self_normalized_value.endsWith(other_normalized_value) ||
+ self_normalized_value.startsWith(other_normalized_value)
+ );
+ }
+
+ static fromRecord(record, region) {
+ return new PostalCode(record[PostalCode.ac], region);
+ }
+}
+
+/**
+ * City name.
+ * See autocomplete="address-level2"
+ */
+class City extends AddressField {
+ static ac = "address-level2";
+
+ #city = null;
+
+ constructor(value, region) {
+ super(value, region);
+
+ const options = {
+ ignore_case: true,
+ };
+ this.#city = this.normalizeUserValue(options);
+ }
+
+ get city() {
+ return this.#city;
+ }
+
+ isValid() {
+ return this.userValue ? !!/[\p{Letter}]/u.exec(this.userValue) : true;
+ }
+
+ equals(other) {
+ return this.city == other.city;
+ }
+
+ contains(other) {
+ const options = {
+ ignore_case: true,
+ replace_punctuation: " ",
+ merge_whitespace: true,
+ };
+
+ const selfTokens = new Tokens(this.normalizeUserValue(options));
+ const otherTokens = new Tokens(other.normalizeUserValue(options));
+
+ return otherTokens.isSubsetInOrder(selfTokens, (a, b) =>
+ this.localeCompare(a, b)
+ );
+ }
+
+ static fromRecord(record, region) {
+ return new City(record[City.ac], region);
+ }
+}
+
+/**
+ * State.
+ * See autocomplete="address-level1"
+ */
+class State extends AddressField {
+ static ac = "address-level1";
+
+ // The abbreviated region name. For example, California is abbreviated as CA
+ #state = null;
+
+ constructor(value, region) {
+ super(value, region);
+
+ if (!this.userValue) {
+ return;
+ }
+
+ const options = {
+ merge_whitespace: true,
+ remove_punctuation: true,
+ };
+ this.#state = lazy.FormAutofillUtils.getAbbreviatedSubregionName(
+ this.normalizeUserValue(options),
+ region
+ );
+ }
+
+ get state() {
+ return this.#state;
+ }
+
+ isValid() {
+ // If we can't get the abbreviated name, assume this is an invalid state name
+ return !!this.#state;
+ }
+
+ equals(other) {
+ // If we have an abbreviated name, compare with it.
+ if (this.state) {
+ return this.state == other.state;
+ }
+
+ // If we don't have an abbreviated name, just compare the userValue
+ return this.userValue == other.userValue;
+ }
+
+ contains(other) {
+ return this.equals(other);
+ }
+
+ static fromRecord(record, region) {
+ return new State(record[State.ac], region);
+ }
+}
+
+/**
+ * A country or territory code.
+ * See autocomplete="country"
+ */
+class Country extends AddressField {
+ static ac = "country";
+
+ // iso 3166 2-alpha code
+ #country_code = null;
+
+ constructor(value, region) {
+ super(value, region);
+
+ if (this.isEmpty()) {
+ return;
+ }
+
+ const options = {
+ merge_whitespace: true,
+ remove_punctuation: true,
+ };
+
+ const country = this.normalizeUserValue(options);
+ this.#country_code = lazy.FormAutofillUtils.identifyCountryCode(country);
+
+ // When the country name is not a valid one, we use the current region instead
+ if (!this.#country_code) {
+ this.#country_code = lazy.FormAutofillUtils.identifyCountryCode(region);
+ }
+ }
+
+ get country_code() {
+ return this.#country_code;
+ }
+
+ isValid() {
+ return !!this.#country_code;
+ }
+
+ equals(other) {
+ return this.country_code == other.country_code;
+ }
+
+ contains(other) {
+ return false;
+ }
+
+ static fromRecord(record, region) {
+ return new Country(record[Country.ac], region);
+ }
+}
+
+/**
+ * The field expects the value to be a person's full name.
+ * See autocomplete="name"
+ */
+class Name extends AddressField {
+ static ac = "name";
+
+ constructor(value, region) {
+ super(value, region);
+ }
+
+ // Reference:
+ // https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/core/browser/data_model/autofill_profile_comparator.cc;drc=566369da19275cc306eeb51a3d3451885299dabb;bpv=1;bpt=1;l=935
+ static createNameVariants(name) {
+ let tokens = name.trim().split(" ");
+
+ let variants = [""];
+ if (!tokens[0]) {
+ return variants;
+ }
+
+ for (const token of tokens) {
+ let tmp = [];
+ for (const variant of variants) {
+ tmp.push(variant + " " + token);
+ tmp.push(variant + " " + token[0]);
+ }
+ variants = variants.concat(tmp);
+ }
+
+ const options = {
+ merge_whitespace: true,
+ };
+ return variants.map(v => lazy.AddressParser.normalizeString(v, options));
+ }
+
+ isValid() {
+ return this.userValue ? !!/[\p{Letter}]/u.exec(this.userValue) : true;
+ }
+
+ equals(other) {
+ const options = {
+ ignore_case: true,
+ };
+ return (
+ this.normalizeUserValue(options) == other.normalizeUserValue(options)
+ );
+ }
+
+ contains(other) {
+ // Unify puncutation while comparing so users can choose the right one
+ // if the only different part is puncutation
+ // Ex. John O'Brian is similar to John O`Brian
+ let options = {
+ ignore_case: true,
+ replace_punctuation: " ",
+ merge_whitespace: true,
+ };
+ let selfName = this.normalizeUserValue(options);
+ let otherName = other.normalizeUserValue(options);
+ let selfTokens = new Tokens(selfName);
+ let otherTokens = new Tokens(otherName);
+
+ if (
+ otherTokens.isSubsetInOrder(selfTokens, (a, b) =>
+ this.localeCompare(a, b)
+ )
+ ) {
+ return true;
+ }
+
+ // Remove puncutation from self and test whether current contains other
+ // Ex. John O'Brian is similar to John OBrian
+ selfName = this.normalizeUserValue({
+ ignore_case: true,
+ remove_punctuation: true,
+ merge_whitespace: true,
+ });
+ otherName = other.normalizeUserValue({
+ ignore_case: true,
+ remove_punctuation: true,
+ merge_whitespace: true,
+ });
+
+ selfTokens = new Tokens(selfName);
+ otherTokens = new Tokens(otherName);
+ if (
+ otherTokens.isSubsetInOrder(selfTokens, (a, b) =>
+ this.localeCompare(a, b)
+ )
+ ) {
+ return true;
+ }
+
+ // Create variants of the names by generating initials for given and middle names.
+
+ selfName = lazy.FormAutofillNameUtils.splitName(selfName);
+ otherName = lazy.FormAutofillNameUtils.splitName(otherName);
+ // In the following we compare cases when people abbreviate first name
+ // and middle name with initials. So if family name is different,
+ // we can just skip and assume the two names are different
+ if (!this.localeCompare(selfName.family, otherName.family)) {
+ return false;
+ }
+
+ const otherNameWithoutFamily = lazy.FormAutofillNameUtils.joinNameParts({
+ given: otherName.given,
+ middle: otherName.middle,
+ });
+ let givenVariants = Name.createNameVariants(selfName.given);
+ let middleVariants = Name.createNameVariants(selfName.middle);
+
+ for (const given of givenVariants) {
+ for (const middle of middleVariants) {
+ const nameVariant = lazy.FormAutofillNameUtils.joinNameParts({
+ given,
+ middle,
+ });
+
+ if (this.localeCompare(nameVariant, otherNameWithoutFamily)) {
+ return true;
+ }
+ }
+ }
+
+ // Check cases when given name and middle name are abbreviated with initial
+ // and the initials are put together. ex. John Michael Doe to JM. Doe
+ if (selfName.given && selfName.middle) {
+ const nameVariant = [
+ ...selfName.given.split(" "),
+ ...selfName.middle.split(" "),
+ ].reduce((initials, name) => {
+ initials += name[0];
+ return initials;
+ }, "");
+
+ if (this.localeCompare(nameVariant, otherNameWithoutFamily)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ static fromRecord(record, region) {
+ return new Name(record[Name.ac], region);
+ }
+}
+
+/**
+ * A full telephone number, including the country code.
+ * See autocomplete="tel"
+ */
+class Tel extends AddressField {
+ static ac = "tel";
+
+ #valid = false;
+
+ // The country code part of a telphone number, such as "1" for the United States
+ #country_code = null;
+
+ // The national part of a telphone number. For example, the phone number "+1 520-248-6621"
+ // national part is "520-248-6621".
+ #national_number = null;
+
+ constructor(value, region) {
+ super(value, region);
+
+ if (!this.userValue) {
+ return;
+ }
+
+ // TODO: Support parse telephone extension
+ // We compress all tel-related fields into a single tel field when an an form
+ // is submitted, so we need to decompress it here.
+ const parsed_tel = lazy.PhoneNumber.Parse(this.userValue, region);
+ if (parsed_tel) {
+ this.#national_number = parsed_tel?.nationalNumber;
+ this.#country_code = parsed_tel?.countryCode;
+
+ this.#valid = true;
+ } else {
+ this.#national_number = lazy.PhoneNumberNormalizer.Normalize(
+ this.userValue
+ );
+
+ const md = lazy.PhoneNumber.FindMetaDataForRegion(region);
+ this.#country_code = md ? "+" + md.nationalPrefix : null;
+
+ this.#valid = lazy.PhoneNumber.IsValid(this.#national_number, md);
+ }
+ }
+
+ get country_code() {
+ return this.#country_code;
+ }
+
+ get national_number() {
+ return this.#national_number;
+ }
+
+ isValid() {
+ return this.#valid;
+ }
+
+ equals(other) {
+ return (
+ this.national_number == other.national_number &&
+ this.country_code == other.country_code
+ );
+ }
+
+ contains(other) {
+ if (!this.country_code || this.country_code != other.country_code) {
+ return false;
+ }
+
+ return this.national_number.endsWith(other.national_number);
+ }
+
+ toString() {
+ return `${this.constructor.name}: ${this.country_code} ${this.national_number}\n`;
+ }
+
+ static fromRecord(record, region) {
+ return new Tel(record[Tel.ac], region);
+ }
+}
+
+/**
+ * A company or organization name.
+ * See autocomplete="organization".
+ */
+class Organization extends AddressField {
+ static ac = "organization";
+
+ constructor(value, region) {
+ super(value, region);
+ }
+
+ isValid() {
+ return this.userValue
+ ? !!/[\p{Letter}\p{Number}]/u.exec(this.userValue)
+ : true;
+ }
+
+ /**
+ * Two company names are considered equal only when everything is the same.
+ */
+ equals(other) {
+ return this.userValue == other.userValue;
+ }
+
+ // Mergeable use locale compare
+ contains(other) {
+ const options = {
+ replace_punctuation: " ", // mozilla org vs mozilla-org
+ merge_whitespace: true,
+ ignore_case: true, // mozilla vs Mozilla
+ };
+
+ // If every token in B can be found in A without considering order
+ // Example, 'Food & Pharmacy' contains 'Pharmacy & Food'
+ const selfTokens = new Tokens(this.normalizeUserValue(options));
+ const otherTokens = new Tokens(other.normalizeUserValue(options));
+
+ return otherTokens.isSubset(selfTokens, (a, b) => this.localeCompare(a, b));
+ }
+
+ static fromRecord(record, region) {
+ return new Organization(record[Organization.ac], region);
+ }
+}
+
+/**
+ * An email address
+ * See autocomplete="email".
+ */
+class Email extends AddressField {
+ static ac = "email";
+
+ constructor(value, region) {
+ super(value, region);
+ }
+
+ // Since we are using the valid check to determine whether we capture the email field when users submitting a forma,
+ // use a less restrict email verification method so we capture an email for most of the cases.
+ // The current algorithm is based on the regular expression defined in
+ // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
+ //
+ // We might also change this to something similar to the algorithm used in
+ // EmailInputType::IsValidEmailAddress if we want a more strict email validation algorithm.
+ isValid() {
+ const regex =
+ /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
+ const match = this.userValue.match(regex);
+ if (!match) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /*
+ // JS version of EmailInputType::IsValidEmailAddress
+ isValid() {
+ const regex = /^([a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+)@([a-zA-Z0-9-]+\.[a-zA-Z]{2,})$/;
+ const match = this.userValue.match(regex);
+ if (!match) {
+ return false;
+ }
+ const local = match[1];
+ const domain = match[2];
+
+ // The domain name can't begin with a dot or a dash.
+ if (['-', '.'].includes(domain[0])) {
+ return false;
+ }
+
+ // A dot can't follow a dot or a dash.
+ // A dash can't follow a dot.
+ const pattern = /(\.\.)|(\.-)|(-\.)/;
+ if (pattern.test(domain)) {
+ return false;
+ }
+
+ return true;
+ }
+*/
+
+ equals(other) {
+ const options = {
+ ignore_case: true,
+ };
+
+ // email is case-insenstive
+ return (
+ this.normalizeUserValue(options) == other.normalizeUserValue(options)
+ );
+ }
+
+ contains(other) {
+ return false;
+ }
+
+ static fromRecord(record, region) {
+ return new Email(record[Email.ac], region);
+ }
+}
+
+/**
+ * The AddressComparison class compares two AddressComponent instances and
+ * provides information about the differences or similarities between them.
+ *
+ * The comparison result is stored and the object and can be retrieved by calling
+ * 'result' getter.
+ */
+export class AddressComparison {
+ // Const to define the comparison result for two address fields
+ static BOTH_EMPTY = 0;
+ static A_IS_EMPTY = 1;
+ static B_IS_EMPTY = 2;
+ static A_CONTAINS_B = 3;
+ static B_CONTAINS_A = 4;
+ // When A contains B and B contains A Ex. "Pizza & Food vs Food & Pizza"
+ static SIMILAR = 5;
+ static SAME = 6;
+ static DIFFERENT = 7;
+
+ // The comparion result, keyed by field name.
+ #result = {};
+
+ /**
+ * Constructs AddressComparison by comparing two AddressComponent objects.
+ *
+ * @class
+ * @param {AddressComponent} addressA - The first address to compare.
+ * @param {AddressComponent} addressB - The second address to compare.
+ */
+ constructor(addressA, addressB) {
+ for (const fieldA of addressA.getAllFields()) {
+ const fieldName = fieldA.constructor.ac;
+ const fieldB = addressB.getField(fieldName);
+ if (fieldB) {
+ this.#result[fieldName] = AddressComparison.compare(fieldA, fieldB);
+ } else {
+ this.#result[fieldName] = AddressComparison.B_IS_EMPTY;
+ }
+ }
+
+ for (const fieldB of addressB.getAllFields()) {
+ const fieldName = fieldB.constructor.ac;
+ if (!addressB.getField(fieldName)) {
+ this.#result[fieldName] = AddressComparison.A_IS_EMPTY;
+ }
+ }
+ }
+
+ /**
+ * Retrieves the result object containing the comparison results.
+ *
+ * @returns {object} The result object with keys corresponding to field names
+ * and values being comparison constants.
+ */
+ get result() {
+ return this.#result;
+ }
+
+ /**
+ * Compares two address fields and returns the comparison result.
+ *
+ * @param {AddressField} fieldA The first field to compare.
+ * @param {AddressField} fieldB The second field to compare.
+ * @returns {number} A constant representing the comparison result.
+ */
+ static compare(fieldA, fieldB) {
+ if (fieldA.isEmpty()) {
+ return fieldB.isEmpty()
+ ? AddressComparison.BOTH_EMPTY
+ : AddressComparison.A_IS_EMPTY;
+ } else if (fieldB.isEmpty()) {
+ return AddressComparison.B_IS_EMPTY;
+ }
+
+ if (fieldA.equals(fieldB)) {
+ return AddressComparison.SAME;
+ }
+
+ if (fieldB.contains(fieldA)) {
+ if (fieldA.contains(fieldB)) {
+ return AddressComparison.SIMILAR;
+ }
+ return AddressComparison.B_CONTAINS_A;
+ } else if (fieldA.contains(fieldB)) {
+ return AddressComparison.A_CONTAINS_B;
+ }
+
+ return AddressComparison.DIFFERENT;
+ }
+
+ /**
+ * Converts a comparison result constant to a readable string.
+ *
+ * @param {number} result The comparison result constant.
+ * @returns {string} A readable string representing the comparison result.
+ */
+ static resultToString(result) {
+ switch (result) {
+ case AddressComparison.BOTH_EMPTY:
+ return "both fields are empty";
+ case AddressComparison.A_IS_EMPTY:
+ return "field A is empty";
+ case AddressComparison.B_IS_EMPTY:
+ return "field B is empty";
+ case AddressComparison.A_CONTAINS_B:
+ return "field A contains field B";
+ case AddressComparison.B_CONTAINS_B:
+ return "field B contains field A";
+ case AddressComparison.SIMILAR:
+ return "field A and field B are similar";
+ case AddressComparison.SAME:
+ return "two fields are the same";
+ case AddressComparison.DIFFERENT:
+ return "two fields are different";
+ }
+ return "";
+ }
+
+ /**
+ * Returns a formatted string representing the comparison results for each field.
+ *
+ * @returns {string} A formatted string with field names and their respective
+ * comparison results.
+ */
+ toString() {
+ let string = "Comparison Result:\n";
+ for (const [name, result] of Object.entries(this.#result)) {
+ string += `${name}: ${AddressComparison.resultToString(result)}\n`;
+ }
+ return string;
+ }
+}
+
+/**
+ * The AddressComponent class represents a structured address that is transformed
+ * from address record created in FormAutofillHandler 'createRecord' function.
+ *
+ * An AddressComponent object consisting of various fields such as state, city,
+ * country, postal code, etc. The class provides a compare methods
+ * to compare another AddressComponent against the current instance.
+ *
+ * Note. This class assumes records that pass to it have already been normalized.
+ */
+export class AddressComponent {
+ /**
+ * An object that stores individual address field instances
+ * (e.g., class State, class City, class Country, etc.), keyed by the
+ * field's clas name.
+ */
+ #fields = {};
+
+ /**
+ * Constructs an AddressComponent object by converting passed address record object.
+ *
+ * @class
+ * @param {object} record The address record object containing address data.
+ * @param {object} [options = {}] a list of options for this method
+ * @param {boolean} [options.ignoreInvalid = true] Whether to ignore invalid address
+ * fields in the AddressComponent object. If set to true,
+ * invalid fields will be ignored.
+ */
+ constructor(record, { ignoreInvalid = true } = {}) {
+ this.record = {};
+
+ // Get country code first so we can use it to parse other fields
+ const country = new Country(
+ record[Country.ac],
+ FormAutofill.DEFAULT_REGION
+ );
+ const region =
+ country.country_code ||
+ lazy.FormAutofillUtils.identifyCountryCode(FormAutofill.DEFAULT_REGION);
+
+ // Build an mapping that the key is field name and the value is the AddressField object
+ [
+ country,
+ new StreetAddress(record[StreetAddress.ac], region),
+ new PostalCode(record[PostalCode.ac], region),
+ new State(record[State.ac], region),
+ new City(record[City.ac], region),
+ new Name(record[Name.ac], region),
+ new Tel(record[Tel.ac], region),
+ new Organization(record[Organization.ac], region),
+ new Email(record[Email.ac], region),
+ ].forEach(addressField => {
+ if (
+ !addressField.isEmpty() &&
+ (!ignoreInvalid || addressField.isValid())
+ ) {
+ const fieldName = addressField.constructor.ac;
+ this.#fields[fieldName] = addressField;
+ this.record[fieldName] = record[fieldName];
+ }
+ });
+ }
+
+ /**
+ * Retrieves all the address fields.
+ *
+ * @returns {Array} An array of address field objects.
+ */
+ getAllFields() {
+ return Object.values(this.#fields);
+ }
+
+ /**
+ * Retrieves the field object with the specified name.
+ *
+ * @param {string} name The name of the field to retrieve.
+ * @returns {object} The address field object with the specified name,
+ * or undefined if the field is not found.
+ */
+ getField(name) {
+ return this.#fields[name];
+ }
+
+ /**
+ * Compares the current AddressComponent with another AddressComponent.
+ *
+ * @param {AddressComponent} address The AddressComponent object to compare
+ * against the current one.
+ * @returns {object} An object containing comparison results. The keys of the object represent
+ * individual address field, and the values are strings indicating the comparison result:
+ * - "same" if both components are either empty or the same,
+ * - "superset" if the current contains the input or the input is empty,
+ * - "subset" if the input contains the current or the current is empty,
+ * - "similar" if the two address components are similar,
+ * - "different" if the two address components are different.
+ */
+ compare(address) {
+ let result = {};
+
+ const comparison = new AddressComparison(this, address);
+ for (const [k, v] of Object.entries(comparison.result)) {
+ if ([AddressComparison.BOTH_EMPTY, AddressComparison.SAME].includes(v)) {
+ result[k] = "same";
+ } else if (
+ [AddressComparison.B_IS_EMPTY, AddressComparison.A_CONTAINS_B].includes(
+ v
+ )
+ ) {
+ result[k] = "superset";
+ } else if (
+ [AddressComparison.A_IS_EMPTY, AddressComparison.B_CONTAINS_A].includes(
+ v
+ )
+ ) {
+ result[k] = "subset";
+ } else if ([AddressComparison.SIMILAR].includes(v)) {
+ result[k] = "similar";
+ } else {
+ result[k] = "different";
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Print all the fields in this AddressComponent object.
+ */
+ toString() {
+ let string = "";
+ for (const field of Object.values(this.#fields)) {
+ string += field.toString();
+ }
+ return string;
+ }
+}
diff --git a/toolkit/components/formautofill/shared/AddressMetaData.sys.mjs b/toolkit/components/formautofill/shared/AddressMetaData.sys.mjs
new file mode 100644
index 0000000000..7f80a220af
--- /dev/null
+++ b/toolkit/components/formautofill/shared/AddressMetaData.sys.mjs
@@ -0,0 +1,2451 @@
+/* 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/. */
+
+// The data below is initially copied from
+// https://chromium-i18n.appspot.com/ssl-aggregate-address
+
+// See https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata for
+// documentation on how to use the data.
+
+// WARNING: DO NOT change any value or add additional properties in addressData.
+// We only accept the metadata of the supported countries that is copied from libaddressinput directly.
+// Please edit AddressMetaDataExtension.sys.mjs instead if you want to add new property as complement
+// or overwrite the existing properties.
+
+export const AddressMetaData = {
+ "data/AD": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/AD",
+ key: "AD",
+ lang: "ca",
+ languages: "ca",
+ name: "ANDORRA",
+ posturl:
+ "http://www.correos.es/comun/CodigosPostales/1010_s-CodPostal.asp?Provincia=",
+ sub_isoids: "07~02~03~08~04~05~06",
+ sub_keys:
+ "Parròquia d'Andorra la Vella~Canillo~Encamp~Escaldes-Engordany~La Massana~Ordino~Sant Julià de Lòria",
+ sub_names:
+ "Andorra la Vella~Canillo~Encamp~Escaldes-Engordany~La Massana~Ordino~Sant Julià de Lòria",
+ sub_zipexs: "AD500~AD100~AD200~AD700~AD400~AD300~AD600",
+ sub_zips: "AD50[01]~AD10[01]~AD20[01]~AD70[01]~AD40[01]~AD30[01]~AD60[01]",
+ zip: "AD[1-7]0\\d",
+ zipex: "AD100,AD501,AD700",
+ },
+ "data/AE": {
+ fmt: "%N%n%O%n%A%n%S",
+ id: "data/AE",
+ key: "AE",
+ lang: "ar",
+ languages: "ar",
+ lfmt: "%N%n%O%n%A%n%S",
+ name: "UNITED ARAB EMIRATES",
+ require: "AS",
+ state_name_type: "emirate",
+ sub_isoids: "AZ~SH~FU~UQ~DU~RK~AJ",
+ sub_keys:
+ "أبو ظبي~إمارة الشارقةّ~الفجيرة~ام القيوين~إمارة دبيّ~إمارة رأس الخيمة~عجمان",
+ sub_lnames:
+ "Abu Dhabi~Sharjah~Fujairah~Umm Al Quwain~Dubai~Ras al Khaimah~Ajman",
+ sub_names: "أبو ظبي~الشارقة~الفجيرة~ام القيوين~دبي~رأس الخيمة~عجمان",
+ },
+ "data/AF": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/AF",
+ key: "AF",
+ name: "AFGHANISTAN",
+ zip: "\\d{4}",
+ zipex: "1001,2601,3801",
+ },
+ "data/AG": {
+ id: "data/AG",
+ key: "AG",
+ name: "ANTIGUA AND BARBUDA",
+ require: "A",
+ },
+ "data/AI": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/AI",
+ key: "AI",
+ name: "ANGUILLA",
+ zip: "(?:AI-)?2640",
+ zipex: "2640",
+ },
+ "data/AL": {
+ fmt: "%N%n%O%n%A%n%Z%n%C",
+ id: "data/AL",
+ key: "AL",
+ name: "ALBANIA",
+ zip: "\\d{4}",
+ zipex: "1001,1017,3501",
+ },
+ "data/AM": {
+ fmt: "%N%n%O%n%A%n%Z%n%C%n%S",
+ id: "data/AM",
+ key: "AM",
+ lang: "hy",
+ languages: "hy",
+ lfmt: "%N%n%O%n%A%n%Z%n%C%n%S",
+ name: "ARMENIA",
+ sub_isoids: "AG~AR~AV~GR~ER~LO~KT~SH~SU~VD~TV",
+ sub_keys:
+ "Արագածոտն~Արարատ~Արմավիր~Գեղարքունիք~Երևան~Լոռի~Կոտայք~Շիրակ~Սյունիք~Վայոց ձոր~Տավուշ",
+ sub_lnames:
+ "Aragatsotn~Ararat~Armavir~Gegharkunik~Yerevan~Lori~Kotayk~Shirak~Syunik~Vayots Dzor~Tavush",
+ sub_zipexs:
+ "0201,0514~0601,0823~0901,1149~1201,1626~0000,0099~1701,2117~2201,2506~2601,3126~3201,3519~3601,3810~3901,4216",
+ sub_zips:
+ "0[2-5]~0[6-8]~09|1[01]~1[2-6]~00~1[7-9]|2[01]~2[2-5]~2[6-9]|3[01]~3[2-5]~3[6-8]~39|4[0-2]",
+ zip: "(?:37)?\\d{4}",
+ zipex: "375010,0002,0010",
+ },
+ "data/AO": { id: "data/AO", key: "AO", name: "ANGOLA" },
+ "data/AQ": { id: "data/AQ", key: "AQ", name: "ANTARCTICA" },
+ "data/AR": {
+ fmt: "%N%n%O%n%A%n%Z %C%n%S",
+ id: "data/AR",
+ key: "AR",
+ lang: "es",
+ languages: "es",
+ name: "ARGENTINA",
+ posturl: "http://www.correoargentino.com.ar/formularios/cpa",
+ sub_isoids: "B~K~H~U~C~X~W~E~P~Y~L~F~M~N~Q~R~A~J~D~Z~S~G~V~T",
+ sub_keys:
+ "Buenos Aires~Catamarca~Chaco~Chubut~Ciudad Autónoma de Buenos Aires~Córdoba~Corrientes~Entre Ríos~Formosa~Jujuy~La Pampa~La Rioja~Mendoza~Misiones~Neuquén~Río Negro~Salta~San Juan~San Luis~Santa Cruz~Santa Fe~Santiago del Estero~Tierra del Fuego~Tucumán",
+ sub_names:
+ "Buenos Aires~Catamarca~Chaco~Chubut~Ciudad Autónoma de Buenos Aires~Córdoba~Corrientes~Entre Ríos~Formosa~Jujuy~La Pampa~La Rioja~Mendoza~Misiones~Neuquén~Río Negro~Salta~San Juan~San Luis~Santa Cruz~Santa Fe~Santiago del Estero~Tierra del Fuego~Tucumán",
+ sub_zips:
+ "B?[1-36-8]~K?[45]~H?3~U?[89]~C?1~X?[235-8]~W?3~E?[1-3]~P?[37]~Y?4~L?[3568]~F?5~M?[56]~N?3~Q?[38]~R?[89]~A?[34]~J?5~D?[4-6]~Z?[89]~S?[2368]~G?[2-5]~V?9~T?[45]",
+ upper: "ACZ",
+ zip: "((?:[A-HJ-NP-Z])?\\d{4})([A-Z]{3})?",
+ zipex: "C1070AAM,C1000WAM,B1000TBU,X5187XAB",
+ },
+ "data/AS": {
+ fmt: "%N%n%O%n%A%n%C %S %Z",
+ id: "data/AS",
+ key: "AS",
+ name: "AMERICAN SAMOA",
+ posturl: "http://zip4.usps.com/zip4/welcome.jsp",
+ require: "ACSZ",
+ state_name_type: "state",
+ upper: "ACNOS",
+ zip: "(96799)(?:[ \\-](\\d{4}))?",
+ zip_name_type: "zip",
+ zipex: "96799",
+ },
+ "data/AT": {
+ fmt: "%O%n%N%n%A%n%Z %C",
+ id: "data/AT",
+ key: "AT",
+ name: "AUSTRIA",
+ posturl: "http://www.post.at/post_subsite_postleitzahlfinder.php",
+ require: "ACZ",
+ zip: "\\d{4}",
+ zipex: "1010,3741",
+ },
+ "data/AU": {
+ fmt: "%O%n%N%n%A%n%C %S %Z",
+ id: "data/AU",
+ key: "AU",
+ lang: "en",
+ languages: "en",
+ locality_name_type: "suburb",
+ name: "AUSTRALIA",
+ posturl: "http://www1.auspost.com.au/postcodes/",
+ require: "ACSZ",
+ state_name_type: "state",
+ sub_isoids: "ACT~NSW~NT~QLD~SA~TAS~VIC~WA",
+ sub_keys: "ACT~NSW~NT~QLD~SA~TAS~VIC~WA",
+ sub_names:
+ "Australian Capital Territory~New South Wales~Northern Territory~Queensland~South Australia~Tasmania~Victoria~Western Australia",
+ sub_zipexs:
+ "0200,2540,2618,2999~1000,2888,3585,3707~0800,0999~4000,9999~5000~7000,7999~3000,8000~6000,0872",
+ sub_zips:
+ "29|2540|260|261[0-8]|02|2620~1|2[0-57-8]|26[2-9]|261[189]|3500|358[56]|3644|3707~0[89]~[49]~5|0872~7~[38]~6|0872",
+ upper: "CS",
+ zip: "\\d{4}",
+ zipex: "2060,3171,6430,4000,4006,3001",
+ },
+ "data/AW": { id: "data/AW", key: "AW", name: "ARUBA" },
+ "data/AZ": {
+ fmt: "%N%n%O%n%A%nAZ %Z %C",
+ id: "data/AZ",
+ key: "AZ",
+ name: "AZERBAIJAN",
+ postprefix: "AZ ",
+ zip: "\\d{4}",
+ zipex: "1000",
+ },
+ "data/BA": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/BA",
+ key: "BA",
+ name: "BOSNIA AND HERZEGOVINA",
+ zip: "\\d{5}",
+ zipex: "71000",
+ },
+ "data/BB": {
+ fmt: "%N%n%O%n%A%n%C, %S %Z",
+ id: "data/BB",
+ key: "BB",
+ name: "BARBADOS",
+ state_name_type: "parish",
+ zip: "BB\\d{5}",
+ zipex: "BB23026,BB22025",
+ },
+ "data/BD": {
+ fmt: "%N%n%O%n%A%n%C - %Z",
+ id: "data/BD",
+ key: "BD",
+ name: "BANGLADESH",
+ posturl: "http://www.bangladeshpost.gov.bd/PostCode.asp",
+ zip: "\\d{4}",
+ zipex: "1340,1000",
+ },
+ "data/BE": {
+ fmt: "%O%n%N%n%A%n%Z %C",
+ id: "data/BE",
+ key: "BE",
+ name: "BELGIUM",
+ posturl:
+ "http://www.post.be/site/nl/residential/customerservice/search/postal_codes.html",
+ require: "ACZ",
+ zip: "\\d{4}",
+ zipex: "4000,1000",
+ },
+ "data/BF": {
+ fmt: "%N%n%O%n%A%n%C %X",
+ id: "data/BF",
+ key: "BF",
+ name: "BURKINA FASO",
+ },
+ "data/BG": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/BG",
+ key: "BG",
+ name: "BULGARIA (REP.)",
+ posturl: "http://www.bgpost.bg/?cid=5",
+ zip: "\\d{4}",
+ zipex: "1000,1700",
+ },
+ "data/BH": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/BH",
+ key: "BH",
+ name: "BAHRAIN",
+ zip: "(?:\\d|1[0-2])\\d{2}",
+ zipex: "317",
+ },
+ "data/BI": { id: "data/BI", key: "BI", name: "BURUNDI" },
+ "data/BJ": { id: "data/BJ", key: "BJ", name: "BENIN", upper: "AC" },
+ "data/BL": {
+ fmt: "%O%n%N%n%A%n%Z %C %X",
+ id: "data/BL",
+ key: "BL",
+ name: "SAINT BARTHELEMY",
+ posturl:
+ "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal",
+ require: "ACZ",
+ upper: "ACX",
+ zip: "9[78][01]\\d{2}",
+ zipex: "97100",
+ },
+ "data/BM": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/BM",
+ key: "BM",
+ name: "BERMUDA",
+ posturl: "http://www.landvaluation.bm/",
+ zip: "[A-Z]{2} ?[A-Z0-9]{2}",
+ zipex: "FL 07,HM GX,HM 12",
+ },
+ "data/BN": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/BN",
+ key: "BN",
+ name: "BRUNEI DARUSSALAM",
+ posturl: "http://www.post.gov.bn/SitePages/postcodes.aspx",
+ zip: "[A-Z]{2} ?\\d{4}",
+ zipex: "BT2328,KA1131,BA1511",
+ },
+ "data/BO": { id: "data/BO", key: "BO", name: "BOLIVIA", upper: "AC" },
+ "data/BQ": {
+ id: "data/BQ",
+ key: "BQ",
+ name: "BONAIRE, SINT EUSTATIUS, AND SABA",
+ },
+ "data/BR": {
+ fmt: "%O%n%N%n%A%n%D%n%C-%S%n%Z",
+ id: "data/BR",
+ key: "BR",
+ lang: "pt",
+ languages: "pt",
+ name: "BRAZIL",
+ posturl: "http://www.buscacep.correios.com.br/",
+ require: "ASCZ",
+ state_name_type: "state",
+ sub_isoids:
+ "AC~AL~AP~AM~BA~CE~DF~ES~GO~MA~MT~MS~MG~PA~PB~PR~PE~PI~RJ~RN~RS~RO~RR~SC~SP~SE~TO",
+ sub_keys:
+ "AC~AL~AP~AM~BA~CE~DF~ES~GO~MA~MT~MS~MG~PA~PB~PR~PE~PI~RJ~RN~RS~RO~RR~SC~SP~SE~TO",
+ sub_mores:
+ "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true",
+ sub_names:
+ "Acre~Alagoas~Amapá~Amazonas~Bahia~Ceará~Distrito Federal~Espírito Santo~Goiás~Maranhão~Mato Grosso~Mato Grosso do Sul~Minas Gerais~Pará~Paraíba~Paraná~Pernambuco~Piauí~Rio de Janeiro~Rio Grande do Norte~Rio Grande do Sul~Rondônia~Roraima~Santa Catarina~São Paulo~Sergipe~Tocantins",
+ sub_zipexs:
+ "69900-000,69999-999~57000-000,57999-999~68900-000,68999-999~69000-000,69400-123~40000-000,48999-999~60000-000,63999-999~70000-000,73500-123~29000-000,29999-999~72800-000,73700-123~65000-000,65999-999~78000-000,78899-999~79000-000,79999-999~30000-000,39999-999~66000-000,68899-999~58000-000,58999-999~80000-000,87999-999~50000-000,56999-999~64000-000,64999-999~20000-000,28999-999~59000-000,59999-999~90000-000,99999-999~76800-000,78900-000,78999-999~69300-000,69399-999~88000-000,89999-999~01000-000,13000-123~49000-000,49999-999~77000-000,77999-999",
+ sub_zips:
+ "699~57~689~69[0-24-8]~4[0-8]~6[0-3]~7[0-1]|72[0-7]|73[0-6]~29~72[89]|73[7-9]|7[4-6]~65~78[0-8]~79~3~6[6-7]|68[0-8]~58~8[0-7]~5[0-6]~64~2[0-8]~59~9~76[89]|789~693~8[89]~[01][1-9]~49~77",
+ sublocality_name_type: "neighborhood",
+ upper: "CS",
+ zip: "\\d{5}-?\\d{3}",
+ zipex: "40301-110,70002-900",
+ },
+ "data/BS": {
+ fmt: "%N%n%O%n%A%n%C, %S",
+ id: "data/BS",
+ key: "BS",
+ lang: "en",
+ languages: "en",
+ name: "BAHAMAS",
+ state_name_type: "island",
+ sub_isoids: "~AK~~BY~BI~CI~~~EX~~HI~IN~LI~MG~~RI~RC~SS~SW",
+ sub_keys:
+ "Abaco~Acklins~Andros~Berry Islands~Bimini~Cat Island~Crooked Island~Eleuthera~Exuma~Grand Bahama~Harbour Island~Inagua~Long Island~Mayaguana~N.P.~Ragged Island~Rum Cay~San Salvador~Spanish Wells",
+ sub_names:
+ "Abaco Islands~Acklins~Andros Island~Berry Islands~Bimini~Cat Island~Crooked Island~Eleuthera~Exuma and Cays~Grand Bahama~Harbour Island~Inagua~Long Island~Mayaguana~New Providence~Ragged Island~Rum Cay~San Salvador~Spanish Wells",
+ },
+ "data/BT": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/BT",
+ key: "BT",
+ name: "BHUTAN",
+ posturl: "http://www.bhutanpost.bt/postcodes/",
+ zip: "\\d{5}",
+ zipex: "11001,31101,35003",
+ },
+ "data/BV": { id: "data/BV", key: "BV", name: "BOUVET ISLAND" },
+ "data/BW": { id: "data/BW", key: "BW", name: "BOTSWANA" },
+ "data/BY": {
+ fmt: "%S%n%Z %C%n%A%n%O%n%N",
+ id: "data/BY",
+ key: "BY",
+ name: "BELARUS",
+ posturl: "http://ex.belpost.by/addressbook/",
+ zip: "\\d{6}",
+ zipex: "223016,225860,220050",
+ },
+ "data/BZ": { id: "data/BZ", key: "BZ", name: "BELIZE" },
+ "data/CA": {
+ fmt: "%N%n%O%n%A%n%C %S %Z",
+ id: "data/CA",
+ key: "CA",
+ lang: "en",
+ languages: "en~fr",
+ name: "CANADA",
+ posturl: "https://www.canadapost.ca/cpo/mc/personal/postalcode/fpc.jsf",
+ require: "ACSZ",
+ sub_isoids: "AB~BC~MB~NB~NL~NT~NS~NU~ON~PE~QC~SK~YT",
+ sub_keys: "AB~BC~MB~NB~NL~NT~NS~NU~ON~PE~QC~SK~YT",
+ sub_names:
+ "Alberta~British Columbia~Manitoba~New Brunswick~Newfoundland and Labrador~Northwest Territories~Nova Scotia~Nunavut~Ontario~Prince Edward Island~Quebec~Saskatchewan~Yukon",
+ sub_zips:
+ "T~V~R~E~A~X0E|X0G|X1A~B~X0A|X0B|X0C~K|L|M|N|P~C~G|H|J|K1A~S|R8A~Y",
+ upper: "ACNOSZ",
+ zip: "[ABCEGHJKLMNPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d",
+ zipex: "H3Z 2Y7,V8X 3X4,T0L 1K0,T0H 1A0,K1A 0B1",
+ },
+ "data/CA--fr": {
+ fmt: "%N%n%O%n%A%n%C %S %Z",
+ id: "data/CA--fr",
+ key: "CA",
+ lang: "fr",
+ name: "CANADA",
+ posturl: "https://www.canadapost.ca/cpo/mc/personal/postalcode/fpc.jsf",
+ require: "ACSZ",
+ sub_isoids: "AB~BC~PE~MB~NB~NS~NU~ON~QC~SK~NL~NT~YT",
+ sub_keys: "AB~BC~PE~MB~NB~NS~NU~ON~QC~SK~NL~NT~YT",
+ sub_names:
+ "Alberta~Colombie-Britannique~Île-du-Prince-Édouard~Manitoba~Nouveau-Brunswick~Nouvelle-Écosse~Nunavut~Ontario~Québec~Saskatchewan~Terre-Neuve-et-Labrador~Territoires du Nord-Ouest~Yukon",
+ sub_zips:
+ "T~V~C~R~E~B~X0A|X0B|X0C~K|L|M|N|P~G|H|J|K1A~S|R8A~A~X0E|X0G|X1A~Y",
+ upper: "ACNOSZ",
+ zip: "[ABCEGHJKLMNPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d",
+ zipex: "H3Z 2Y7,V8X 3X4,T0L 1K0,T0H 1A0,K1A 0B1",
+ },
+ "data/CC": {
+ fmt: "%O%n%N%n%A%n%C %S %Z",
+ id: "data/CC",
+ key: "CC",
+ name: "COCOS (KEELING) ISLANDS",
+ upper: "CS",
+ zip: "6799",
+ zipex: "6799",
+ },
+ "data/CD": { id: "data/CD", key: "CD", name: "CONGO (DEM. REP.)" },
+ "data/CF": { id: "data/CF", key: "CF", name: "CENTRAL AFRICAN REPUBLIC" },
+ "data/CG": { id: "data/CG", key: "CG", name: "CONGO (REP.)" },
+ "data/CH": {
+ fmt: "%O%n%N%n%A%nCH-%Z %C",
+ id: "data/CH",
+ key: "CH",
+ name: "SWITZERLAND",
+ postprefix: "CH-",
+ posturl: "http://www.post.ch/db/owa/pv_plz_pack/pr_main",
+ require: "ACZ",
+ upper: "",
+ zip: "\\d{4}",
+ zipex: "2544,1211,1556,3030",
+ },
+ "data/CI": {
+ fmt: "%N%n%O%n%X %A %C %X",
+ id: "data/CI",
+ key: "CI",
+ name: "COTE D'IVOIRE",
+ },
+ "data/CK": { id: "data/CK", key: "CK", name: "COOK ISLANDS" },
+ "data/CL": {
+ fmt: "%N%n%O%n%A%n%Z %C%n%S",
+ id: "data/CL",
+ key: "CL",
+ lang: "es",
+ languages: "es",
+ name: "CHILE",
+ posturl: "http://www.correos.cl/SitePages/home.aspx",
+ sub_isoids: "AN~AR~AP~AT~AI~BI~CO~LI~LL~LR~MA~ML~RM~TA~VS",
+ sub_keys:
+ "Antofagasta~Araucanía~Arica y Parinacota~Atacama~Aysén~Biobío~Coquimbo~O'Higgins~Los Lagos~Los Ríos~Magallanes~Maule~Región Metropolitana~Tarapacá~Valparaíso",
+ sub_mores:
+ "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true",
+ sub_names:
+ "Antofagasta~Araucanía~Arica y Parinacota~Atacama~Aysén del General Carlos Ibáñez del Campo~Biobío~Coquimbo~Libertador General Bernardo O'Higgins~Los Lagos~Los Ríos~Magallanes y de la Antártica Chilena~Maule~Metropolitana de Santiago~Tarapacá~Valparaíso",
+ zip: "\\d{7}",
+ zipex: "8340457,8720019,1230000,8329100",
+ },
+ "data/CM": { id: "data/CM", key: "CM", name: "CAMEROON" },
+ "data/CN": {
+ fmt: "%Z%n%S%C%D%n%A%n%O%n%N",
+ id: "data/CN",
+ key: "CN",
+ lang: "zh",
+ languages: "zh",
+ lfmt: "%N%n%O%n%A%n%D%n%C%n%S, %Z",
+ name: "CHINA",
+ posturl: "http://www.ems.com.cn/serviceguide/you_bian_cha_xun.html",
+ require: "ACSZ",
+ sub_isoids:
+ "34~92~11~50~35~62~44~45~52~46~13~41~23~42~43~22~32~36~21~15~64~63~37~14~61~31~51~71~12~54~91~65~53~33",
+ sub_keys:
+ "安徽省~澳门~北京市~重庆市~福建省~甘肃省~广东省~广西壮族自治区~贵州省~海南省~河北省~河南省~黑龙江省~湖北省~湖南省~吉林省~江苏省~江西省~辽宁省~内蒙古自治区~宁夏回族自治区~青海省~山东省~山西省~陕西省~上海市~四川省~台湾~天津市~西藏自治区~香港~新疆维吾尔自治区~云南省~浙江省",
+ sub_lnames:
+ "Anhui Sheng~Macau~Beijing Shi~Chongqing Shi~Fujian Sheng~Gansu Sheng~Guangdong Sheng~Guangxi Zhuangzuzizhiqu~Guizhou Sheng~Hainan Sheng~Hebei Sheng~Henan Sheng~Heilongjiang Sheng~Hubei Sheng~Hunan Sheng~Jilin Sheng~Jiangsu Sheng~Jiangxi Sheng~Liaoning Sheng~Neimenggu Zizhiqu~Ningxia Huizuzizhiqu~Qinghai Sheng~Shandong Sheng~Shanxi Sheng~Shaanxi Sheng~Shanghai Shi~Sichuan Sheng~Taiwan~Tianjin Shi~Xizang Zizhiqu~Hong Kong~Xinjiang Weiwuerzizhiqu~Yunnan Sheng~Zhejiang Sheng",
+ sub_mores:
+ "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true",
+ sub_names:
+ "安徽省~澳门~北京市~重庆市~福建省~甘肃省~广东省~广西~贵州省~海南省~河北省~河南省~黑龙江省~湖北省~湖南省~吉林省~江苏省~江西省~辽宁省~内蒙古~宁夏~青海省~山东省~山西省~陕西省~上海市~四川省~台湾~天津市~西藏~香港~新疆~云南省~浙江省",
+ sub_xrequires: "~A~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ACS~~~",
+ sub_xzips: "~999078~~~~~~~~~~~~~~~~~~~~~~~~~~\\d{3}(\\d{2})?~~~999077~~~",
+ sublocality_name_type: "district",
+ upper: "S",
+ zip: "\\d{6}",
+ zipex: "266033,317204,100096,100808",
+ },
+ "data/CO": {
+ fmt: "%N%n%O%n%A%n%C, %S, %Z",
+ id: "data/CO",
+ key: "CO",
+ name: "COLOMBIA",
+ posturl: "http://www.codigopostal.gov.co/",
+ require: "AS",
+ state_name_type: "department",
+ zip: "\\d{6}",
+ zipex: "111221,130001,760011",
+ },
+ "data/CR": {
+ fmt: "%N%n%O%n%A%n%S, %C%n%Z",
+ id: "data/CR",
+ key: "CR",
+ name: "COSTA RICA",
+ posturl: "https://www.correos.go.cr/nosotros/codigopostal/busqueda.html",
+ require: "ACS",
+ zip: "\\d{4,5}|\\d{3}-\\d{4}",
+ zipex: "1000,2010,1001",
+ },
+ "data/CU": {
+ fmt: "%N%n%O%n%A%n%C %S%n%Z",
+ id: "data/CU",
+ key: "CU",
+ lang: "es",
+ languages: "es",
+ name: "CUBA",
+ sub_isoids: "15~09~08~06~12~14~11~99~03~10~04~16~01~07~13~05",
+ sub_keys:
+ "Artemisa~Camagüey~Ciego de Ávila~Cienfuegos~Granma~Guantánamo~Holguín~Isla de la Juventud~La Habana~Las Tunas~Matanzas~Mayabeque~Pinar del Río~Sancti Spíritus~Santiago de Cuba~Villa Clara",
+ zip: "\\d{5}",
+ zipex: "10700",
+ },
+ "data/CV": {
+ fmt: "%N%n%O%n%A%n%Z %C%n%S",
+ id: "data/CV",
+ key: "CV",
+ lang: "pt",
+ languages: "pt",
+ name: "CAPE VERDE",
+ state_name_type: "island",
+ sub_isoids: "BV~BR~~MA~SL~~~~SV",
+ sub_keys:
+ "Boa Vista~Brava~Fogo~Maio~Sal~Santiago~Santo Antão~São Nicolau~São Vicente",
+ zip: "\\d{4}",
+ zipex: "7600",
+ },
+ "data/CW": { id: "data/CW", key: "CW", name: "CURACAO" },
+ "data/CX": {
+ fmt: "%O%n%N%n%A%n%C %S %Z",
+ id: "data/CX",
+ key: "CX",
+ name: "CHRISTMAS ISLAND",
+ upper: "CS",
+ zip: "6798",
+ zipex: "6798",
+ },
+ "data/CY": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/CY",
+ key: "CY",
+ name: "CYPRUS",
+ zip: "\\d{4}",
+ zipex: "2008,3304,1900",
+ },
+ "data/CZ": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/CZ",
+ key: "CZ",
+ name: "CZECH REP.",
+ posturl: "http://psc.ceskaposta.cz/CleanForm.action",
+ require: "ACZ",
+ zip: "\\d{3} ?\\d{2}",
+ zipex: "100 00,251 66,530 87,110 00,225 99",
+ },
+ "data/DE": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/DE",
+ key: "DE",
+ name: "GERMANY",
+ posturl: "http://www.postdirekt.de/plzserver/",
+ require: "ACZ",
+ zip: "\\d{5}",
+ zipex: "26133,53225",
+ },
+ "data/DJ": { id: "data/DJ", key: "DJ", name: "DJIBOUTI" },
+ "data/DK": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/DK",
+ key: "DK",
+ name: "DENMARK",
+ posturl:
+ "http://www.postdanmark.dk/da/Privat/Kundeservice/postnummerkort/Sider/Find-postnummer.aspx",
+ require: "ACZ",
+ zip: "\\d{4}",
+ zipex: "8660,1566",
+ },
+ "data/DM": { id: "data/DM", key: "DM", name: "DOMINICA" },
+ "data/DO": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/DO",
+ key: "DO",
+ name: "DOMINICAN REP.",
+ posturl: "http://inposdom.gob.do/codigo-postal/",
+ zip: "\\d{5}",
+ zipex: "11903,10101",
+ },
+ "data/DZ": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/DZ",
+ key: "DZ",
+ name: "ALGERIA",
+ zip: "\\d{5}",
+ zipex: "40304,16027",
+ },
+ "data/EC": {
+ fmt: "%N%n%O%n%A%n%Z%n%C",
+ id: "data/EC",
+ key: "EC",
+ name: "ECUADOR",
+ posturl: "http://www.codigopostal.gob.ec/",
+ upper: "CZ",
+ zip: "\\d{6}",
+ zipex: "090105,092301",
+ },
+ "data/EE": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/EE",
+ key: "EE",
+ name: "ESTONIA",
+ posturl: "https://www.omniva.ee/era/sihtnumbrite_otsing",
+ zip: "\\d{5}",
+ zipex: "69501,11212",
+ },
+ "data/EG": {
+ fmt: "%N%n%O%n%A%n%C%n%S%n%Z",
+ id: "data/EG",
+ key: "EG",
+ lang: "ar",
+ languages: "ar",
+ lfmt: "%N%n%O%n%A%n%C%n%S%n%Z",
+ name: "EGYPT",
+ sub_isoids:
+ "ASN~AST~ALX~IS~LX~BA~BH~GZ~DK~SUZ~SHR~GH~FYM~C~KB~MNF~MN~WAD~BNS~PTS~JS~DT~SHG~SIN~KN~KFS~MT",
+ sub_keys:
+ "أسوان~أسيوط~الإسكندرية~الإسماعيلية~الأقصر~البحر الأحمر~البحيرة~الجيزة~الدقهلية~السويس~الشرقية~الغربية~الفيوم~القاهرة~القليوبية~المنوفية~المنيا~الوادي الجديد~بني سويف~بورسعيد~جنوب سيناء~دمياط~سوهاج~شمال سيناء~قنا~كفر الشيخ~مطروح",
+ sub_lnames:
+ "Aswan Governorate~Asyut Governorate~Alexandria Governorate~Ismailia Governorate~Luxor Governorate~Red Sea Governorate~El Beheira Governorate~Giza Governorate~Dakahlia Governorate~Suez Governorate~Ash Sharqia Governorate~Gharbia Governorate~Faiyum Governorate~Cairo Governorate~Qalyubia Governorate~Menofia Governorate~Menia Governorate~New Valley Governorate~Beni Suef Governorate~Port Said Governorate~South Sinai Governorate~Damietta Governorate~Sohag Governorate~North Sinai Governorate~Qena Governorate~Kafr El Sheikh Governorate~Matrouh Governorate",
+ sub_zipexs:
+ "81000~71000~21000,23000~41000~85000~84000~22000~12000~35000~43000~44000~31000~63000~11000~13000~32000~61000~72000~62000~42000~46000~34000~82000~45000~83000~33000~51000",
+ sub_zips:
+ "81~71~2[13]~41~85~84~22~12~35~43~44~31~63~11~13~32~61~72~62~42~46~34~82~45~83~33~51",
+ zip: "\\d{5}",
+ zipex: "12411,11599",
+ },
+ "data/EH": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/EH",
+ key: "EH",
+ name: "WESTERN SAHARA",
+ zip: "\\d{5}",
+ zipex: "70000,72000",
+ },
+ "data/ER": { id: "data/ER", key: "ER", name: "ERITREA" },
+ "data/ES": {
+ fmt: "%N%n%O%n%A%n%Z %C %S",
+ id: "data/ES",
+ key: "ES",
+ lang: "es",
+ languages: "es~ca~gl~eu",
+ name: "SPAIN",
+ posturl:
+ "http://www.correos.es/contenido/13-MenuRec2/04-MenuRec24/1010_s-CodPostal.asp",
+ require: "ACSZ",
+ sub_keys:
+ "VI~AB~A~AL~O~AV~BA~B~BU~CC~CA~S~CS~CE~CR~CO~CU~GI~GR~GU~SS~H~HU~PM~J~C~LO~GC~LE~L~LU~M~MA~ML~MU~NA~OR~P~PO~SA~TF~SG~SE~SO~T~TE~TO~V~VA~BI~ZA~Z",
+ sub_names:
+ "Álava~Albacete~Alicante~Almería~Asturias~Ávila~Badajoz~Barcelona~Burgos~Cáceres~Cádiz~Cantabria~Castellón~Ceuta~Ciudad Real~Córdoba~Cuenca~Girona~Granada~Guadalajara~Guipúzcoa~Huelva~Huesca~Islas Baleares~Jaén~La Coruña~La Rioja~Las Palmas~León~Lérida~Lugo~Madrid~Málaga~Melilla~Murcia~Navarra~Ourense~Palencia~Pontevedra~Salamanca~Santa Cruz de Tenerife~Segovia~Sevilla~Soria~Tarragona~Teruel~Toledo~Valencia~Valladolid~Vizcaya~Zamora~Zaragoza",
+ sub_zips:
+ "01~02~03~04~33~05~06~08~09~10~11~39~12~51~13~14~16~17~18~19~20~21~22~07~23~15~26~35~24~25~27~28~29~52~30~31~32~34~36~37~38~40~41~42~43~44~45~46~47~48~49~50",
+ upper: "CS",
+ zip: "\\d{5}",
+ zipex: "28039,28300,28070",
+ },
+ "data/ES--ca": {
+ fmt: "%N%n%O%n%A%n%Z %C %S",
+ id: "data/ES--ca",
+ key: "ES",
+ lang: "ca",
+ name: "SPAIN",
+ posturl:
+ "http://www.correos.es/contenido/13-MenuRec2/04-MenuRec24/1010_s-CodPostal.asp",
+ require: "ACSZ",
+ sub_keys:
+ "A~AB~AL~VI~O~AV~BA~B~BI~BU~CC~CA~S~CS~CE~CR~CO~CU~GI~GR~GU~SS~H~HU~PM~J~C~LO~GC~LE~L~LU~M~MA~ML~MU~NA~OR~P~PO~SA~TF~SG~SE~SO~T~TE~TO~V~VA~ZA~Z",
+ sub_names:
+ "Alacant~Albacete~Almeria~Araba~Asturias~Àvila~Badajoz~Barcelona~Bizkaia~Burgos~Cáceres~Cadis~Cantabria~Castelló~Ceuta~Ciudad Real~Córdoba~Cuenca~Girona~Granada~Guadalajara~Guipúscoa~Huelva~Huesca~Illes Balears~Jaén~La Corunya~La Rioja~Las Palmas~León~Lleida~Lugo~Madrid~Málaga~Melilla~Murcia~Navarra~Ourense~Palencia~Pontevedra~Salamanca~Santa Cruz de Tenerife~Segovia~Sevilla~Soria~Tarragona~Teruel~Toledo~València~Valladolid~Zamora~Zaragoza",
+ sub_zips:
+ "03~02~04~01~33~05~06~08~48~09~10~11~39~12~51~13~14~16~17~18~19~20~21~22~07~23~15~26~35~24~25~27~28~29~52~30~31~32~34~36~37~38~40~41~42~43~44~45~46~47~49~50",
+ upper: "CS",
+ zip: "\\d{5}",
+ zipex: "28039,28300,28070",
+ },
+ "data/ES--eu": {
+ fmt: "%N%n%O%n%A%n%Z %C %S",
+ id: "data/ES--eu",
+ key: "ES",
+ lang: "eu",
+ name: "SPAIN",
+ posturl:
+ "http://www.correos.es/contenido/13-MenuRec2/04-MenuRec24/1010_s-CodPostal.asp",
+ require: "ACSZ",
+ sub_keys:
+ "A~AB~AL~VI~O~AV~BA~B~BI~BU~CC~CA~S~CS~CE~CR~C~CU~SS~GI~GR~GU~H~HU~PM~J~CO~LO~GC~LE~L~LU~M~MA~ML~MU~NA~OR~P~PO~SA~TF~SG~SE~SO~T~TE~TO~V~VA~ZA~Z",
+ sub_names:
+ "Alacant~Albacete~Almería~Araba~Asturias~Ávila~Badajoz~Barcelona~Bizkaia~Burgos~Cáceres~Cádiz~Cantabria~Castelló~Ceuta~Ciudad Real~Coruña~Cuenca~Gipuzkoa~Girona~Granada~Guadalajara~Huelva~Huesca~Illes Balears~Jaén~Kordoba~La Rioja~Las Palmas~León~Lleida~Lugo~Madrid~Málaga~Melilla~Murtzia~Nafarroa~Ourense~Palentzia~Pontevedra~Salamanca~Santa Cruz Tenerifekoa~Segovia~Sevilla~Soria~Tarragona~Teruel~Toledo~Valentzia~Valladolid~Zamora~Zaragoza",
+ sub_zips:
+ "03~02~04~01~33~05~06~08~48~09~10~11~39~12~51~13~15~16~20~17~18~19~21~22~07~23~14~26~35~24~25~27~28~29~52~30~31~32~34~36~37~38~40~41~42~43~44~45~46~47~49~50",
+ upper: "CS",
+ zip: "\\d{5}",
+ zipex: "28039,28300,28070",
+ },
+ "data/ES--gl": {
+ fmt: "%N%n%O%n%A%n%Z %C %S",
+ id: "data/ES--gl",
+ key: "ES",
+ lang: "gl",
+ name: "SPAIN",
+ posturl:
+ "http://www.correos.es/contenido/13-MenuRec2/04-MenuRec24/1010_s-CodPostal.asp",
+ require: "ACSZ",
+ sub_keys:
+ "C~A~VI~AB~AL~GC~O~AV~BA~B~BI~BU~CC~CA~S~CS~CE~CR~CO~CU~GR~GU~SS~H~HU~PM~LO~LE~L~LU~M~MA~ML~MU~NA~OR~P~PO~SA~TF~SG~SE~SO~T~TE~TO~V~VA~J~GI~ZA~Z",
+ sub_names:
+ "A Coruña~Alacant~Álava~Albacete~Almería~As Palmas~Asturias~Ávila~Badaxoz~Barcelona~Biscaia~Burgos~Cáceres~Cádiz~Cantabria~Castelló~Ceuta~Cidade Real~Córdoba~Cuenca~Granada~Guadalajara~Guipúscoa~Huelva~Huesca~Illas Baleares~La Rioja~León~Lleida~Lugo~Madrid~Málaga~Melilla~Murcia~Navarra~Ourense~Palencia~Pontevedra~Salamanca~Santa Cruz de Tenerife~Segovia~Sevilla~Soria~Tarragona~Teruel~Toledo~Valencia~Valladolid~Xaén~Xirona~Zamora~Zaragoza",
+ sub_zips:
+ "15~03~01~02~04~35~33~05~06~08~48~09~10~11~39~12~51~13~14~16~18~19~20~21~22~07~26~24~25~27~28~29~52~30~31~32~34~36~37~38~40~41~42~43~44~45~46~47~23~17~49~50",
+ upper: "CS",
+ zip: "\\d{5}",
+ zipex: "28039,28300,28070",
+ },
+ "data/ET": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/ET",
+ key: "ET",
+ name: "ETHIOPIA",
+ zip: "\\d{4}",
+ zipex: "1000",
+ },
+ "data/FI": {
+ fmt: "%O%n%N%n%A%nFI-%Z %C",
+ id: "data/FI",
+ key: "FI",
+ name: "FINLAND",
+ postprefix: "FI-",
+ posturl: "http://www.verkkoposti.com/e3/postinumeroluettelo",
+ require: "ACZ",
+ zip: "\\d{5}",
+ zipex: "00550,00011",
+ },
+ "data/FJ": { id: "data/FJ", key: "FJ", name: "FIJI" },
+ "data/FK": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/FK",
+ key: "FK",
+ name: "FALKLAND ISLANDS (MALVINAS)",
+ require: "ACZ",
+ upper: "CZ",
+ zip: "FIQQ 1ZZ",
+ zipex: "FIQQ 1ZZ",
+ },
+ "data/FM": {
+ fmt: "%N%n%O%n%A%n%C %S %Z",
+ id: "data/FM",
+ key: "FM",
+ name: "MICRONESIA (Federated State of)",
+ posturl: "http://zip4.usps.com/zip4/welcome.jsp",
+ require: "ACSZ",
+ state_name_type: "state",
+ upper: "ACNOS",
+ zip: "(9694[1-4])(?:[ \\-](\\d{4}))?",
+ zip_name_type: "zip",
+ zipex: "96941,96944",
+ },
+ "data/FO": {
+ fmt: "%N%n%O%n%A%nFO%Z %C",
+ id: "data/FO",
+ key: "FO",
+ name: "FAROE ISLANDS",
+ postprefix: "FO",
+ posturl: "http://www.postur.fo/",
+ zip: "\\d{3}",
+ zipex: "100",
+ },
+ "data/FR": {
+ fmt: "%O%n%N%n%A%n%Z %C %X",
+ id: "data/FR",
+ key: "FR",
+ name: "FRANCE",
+ posturl:
+ "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal",
+ require: "ACZ",
+ upper: "CX",
+ zip: "\\d{2} ?\\d{3}",
+ zipex: "33380,34092,33506",
+ },
+ "data/GA": { id: "data/GA", key: "GA", name: "GABON" },
+ "data/GB": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/GB",
+ key: "GB",
+ locality_name_type: "post_town",
+ name: "UNITED KINGDOM",
+ posturl: "http://www.royalmail.com/postcode-finder",
+ require: "ACZ",
+ upper: "CZ",
+ zip: "GIR ?0AA|(?:(?:AB|AL|B|BA|BB|BD|BF|BH|BL|BN|BR|BS|BT|BX|CA|CB|CF|CH|CM|CO|CR|CT|CV|CW|DA|DD|DE|DG|DH|DL|DN|DT|DY|E|EC|EH|EN|EX|FK|FY|G|GL|GY|GU|HA|HD|HG|HP|HR|HS|HU|HX|IG|IM|IP|IV|JE|KA|KT|KW|KY|L|LA|LD|LE|LL|LN|LS|LU|M|ME|MK|ML|N|NE|NG|NN|NP|NR|NW|OL|OX|PA|PE|PH|PL|PO|PR|RG|RH|RM|S|SA|SE|SG|SK|SL|SM|SN|SO|SP|SR|SS|ST|SW|SY|TA|TD|TF|TN|TQ|TR|TS|TW|UB|W|WA|WC|WD|WF|WN|WR|WS|WV|YO|ZE)(?:\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}))|BFPO ?\\d{1,4}",
+ zipex:
+ "EC1Y 8SY,GIR 0AA,M2 5BQ,M34 4AB,CR0 2YR,DN16 9AA,W1A 4ZZ,EC1A 1HQ,OX14 4PG,BS18 8HF,NR25 7HG,RH6 0NP,BH23 6AA,B6 5BA,SO23 9AP,PO1 3AX,BFPO 61",
+ },
+ "data/GD": { id: "data/GD", key: "GD", name: "GRENADA (WEST INDIES)" },
+ "data/GE": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/GE",
+ key: "GE",
+ name: "GEORGIA",
+ posturl: "http://www.georgianpost.ge/index.php?page=10",
+ zip: "\\d{4}",
+ zipex: "0101",
+ },
+ "data/GF": {
+ fmt: "%O%n%N%n%A%n%Z %C %X",
+ id: "data/GF",
+ key: "GF",
+ name: "FRENCH GUIANA",
+ posturl:
+ "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal",
+ require: "ACZ",
+ upper: "ACX",
+ zip: "9[78]3\\d{2}",
+ zipex: "97300",
+ },
+ "data/GG": {
+ fmt: "%N%n%O%n%A%n%C%nGUERNSEY%n%Z",
+ id: "data/GG",
+ key: "GG",
+ name: "CHANNEL ISLANDS",
+ posturl: "http://www.guernseypost.com/postcode_finder/",
+ require: "ACZ",
+ upper: "CZ",
+ zip: "GY\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}",
+ zipex: "GY1 1AA,GY2 2BT",
+ },
+ "data/GH": { id: "data/GH", key: "GH", name: "GHANA" },
+ "data/GI": {
+ fmt: "%N%n%O%n%A%nGIBRALTAR%n%Z",
+ id: "data/GI",
+ key: "GI",
+ name: "GIBRALTAR",
+ require: "A",
+ zip: "GX11 1AA",
+ zipex: "GX11 1AA",
+ },
+ "data/GL": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/GL",
+ key: "GL",
+ name: "GREENLAND",
+ require: "ACZ",
+ zip: "39\\d{2}",
+ zipex: "3900,3950,3911",
+ },
+ "data/GM": { id: "data/GM", key: "GM", name: "GAMBIA" },
+ "data/GN": {
+ fmt: "%N%n%O%n%Z %A %C",
+ id: "data/GN",
+ key: "GN",
+ name: "GUINEA",
+ zip: "\\d{3}",
+ zipex: "001,200,100",
+ },
+ "data/GP": {
+ fmt: "%O%n%N%n%A%n%Z %C %X",
+ id: "data/GP",
+ key: "GP",
+ name: "GUADELOUPE",
+ posturl:
+ "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal",
+ require: "ACZ",
+ upper: "ACX",
+ zip: "9[78][01]\\d{2}",
+ zipex: "97100",
+ },
+ "data/GQ": { id: "data/GQ", key: "GQ", name: "EQUATORIAL GUINEA" },
+ "data/GR": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/GR",
+ key: "GR",
+ name: "GREECE",
+ posturl: "http://www.elta.gr/findapostcode.aspx",
+ require: "ACZ",
+ zip: "\\d{3} ?\\d{2}",
+ zipex: "151 24,151 10,101 88",
+ },
+ "data/GS": {
+ fmt: "%N%n%O%n%A%n%n%C%n%Z",
+ id: "data/GS",
+ key: "GS",
+ name: "SOUTH GEORGIA",
+ require: "ACZ",
+ upper: "CZ",
+ zip: "SIQQ 1ZZ",
+ zipex: "SIQQ 1ZZ",
+ },
+ "data/GT": {
+ fmt: "%N%n%O%n%A%n%Z- %C",
+ id: "data/GT",
+ key: "GT",
+ name: "GUATEMALA",
+ zip: "\\d{5}",
+ zipex: "09001,01501",
+ },
+ "data/GU": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/GU",
+ key: "GU",
+ name: "GUAM",
+ posturl: "http://zip4.usps.com/zip4/welcome.jsp",
+ require: "ACZ",
+ upper: "ACNO",
+ zip: "(969(?:[12]\\d|3[12]))(?:[ \\-](\\d{4}))?",
+ zip_name_type: "zip",
+ zipex: "96910,96931",
+ },
+ "data/GW": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/GW",
+ key: "GW",
+ name: "GUINEA-BISSAU",
+ zip: "\\d{4}",
+ zipex: "1000,1011",
+ },
+ "data/GY": { id: "data/GY", key: "GY", name: "GUYANA" },
+ "data/HK": {
+ fmt: "%S%n%C%n%A%n%O%n%N",
+ id: "data/HK",
+ key: "HK",
+ lang: "zh-Hant",
+ languages: "zh-Hant~en",
+ lfmt: "%N%n%O%n%A%n%C%n%S",
+ locality_name_type: "district",
+ name: "HONG KONG",
+ require: "AS",
+ state_name_type: "area",
+ sub_keys: "Kowloon~Hong Kong Island~New Territories",
+ sub_mores: "true~true~true",
+ sub_names: "九龍~香港島~新界",
+ upper: "S",
+ },
+ "data/HK--en": {
+ fmt: "%S%n%C%n%A%n%O%n%N",
+ id: "data/HK--en",
+ key: "HK",
+ lang: "en",
+ lfmt: "%N%n%O%n%A%n%C%n%S",
+ locality_name_type: "district",
+ name: "HONG KONG",
+ require: "AS",
+ state_name_type: "area",
+ sub_keys: "Hong Kong Island~Kowloon~New Territories",
+ sub_lnames: "Hong Kong Island~Kowloon~New Territories",
+ sub_mores: "true~true~true",
+ upper: "S",
+ },
+ "data/HM": {
+ fmt: "%O%n%N%n%A%n%C %S %Z",
+ id: "data/HM",
+ key: "HM",
+ name: "HEARD AND MCDONALD ISLANDS",
+ upper: "CS",
+ zip: "\\d{4}",
+ zipex: "7050",
+ },
+ "data/HN": {
+ fmt: "%N%n%O%n%A%n%C, %S%n%Z",
+ id: "data/HN",
+ key: "HN",
+ name: "HONDURAS",
+ require: "ACS",
+ zip: "\\d{5}",
+ zipex: "31301",
+ },
+ "data/HR": {
+ fmt: "%N%n%O%n%A%nHR-%Z %C",
+ id: "data/HR",
+ key: "HR",
+ name: "CROATIA",
+ postprefix: "HR-",
+ posturl: "http://www.posta.hr/default.aspx?pretpum",
+ zip: "\\d{5}",
+ zipex: "10000,21001,10002",
+ },
+ "data/HT": {
+ fmt: "%N%n%O%n%A%nHT%Z %C",
+ id: "data/HT",
+ key: "HT",
+ name: "HAITI",
+ postprefix: "HT",
+ zip: "\\d{4}",
+ zipex: "6120,5310,6110,8510",
+ },
+ "data/HU": {
+ fmt: "%N%n%O%n%C%n%A%n%Z",
+ id: "data/HU",
+ key: "HU",
+ name: "HUNGARY (Rep.)",
+ posturl: "http://posta.hu/ugyfelszolgalat/iranyitoszam_kereso",
+ require: "ACZ",
+ upper: "ACNO",
+ zip: "\\d{4}",
+ zipex: "1037,2380,1540",
+ },
+ "data/ID": {
+ fmt: "%N%n%O%n%A%n%C%n%S %Z",
+ id: "data/ID",
+ key: "ID",
+ lang: "id",
+ languages: "id",
+ name: "INDONESIA",
+ require: "AS",
+ sub_isoids:
+ "AC~BA~BT~BE~YO~JK~GO~JA~JB~JT~JI~KB~KS~KT~KI~KU~BB~KR~LA~MA~MU~NB~NT~PA~PB~RI~SR~SN~ST~SG~SA~SB~SS~SU",
+ sub_keys:
+ "Aceh~Bali~Banten~Bengkulu~Daerah Istimewa Yogyakarta~DKI Jakarta~Gorontalo~Jambi~Jawa Barat~Jawa Tengah~Jawa Timur~Kalimantan Barat~Kalimantan Selatan~Kalimantan Tengah~Kalimantan Timur~Kalimantan Utara~Kepulauan Bangka Belitung~Kepulauan Riau~Lampung~Maluku~Maluku Utara~Nusa Tenggara Barat~Nusa Tenggara Timur~Papua~Papua Barat~Riau~Sulawesi Barat~Sulawesi Selatan~Sulawesi Tengah~Sulawesi Tenggara~Sulawesi Utara~Sumatera Barat~Sumatera Selatan~Sumatera Utara",
+ zip: "\\d{5}",
+ zipex: "40115",
+ },
+ "data/IE": {
+ fmt: "%N%n%O%n%A%n%D%n%C%n%S %Z",
+ id: "data/IE",
+ key: "IE",
+ lang: "en",
+ languages: "en",
+ name: "IRELAND",
+ posturl: "https://finder.eircode.ie",
+ state_name_type: "county",
+ sub_isoids:
+ "CW~CN~CE~C~DL~D~G~KY~KE~KK~LS~LM~LK~LD~LH~MO~MH~MN~OY~RN~SO~TA~WD~WH~WX~WW",
+ sub_keys:
+ "Co. Carlow~Co. Cavan~Co. Clare~Co. Cork~Co. Donegal~Co. Dublin~Co. Galway~Co. Kerry~Co. Kildare~Co. Kilkenny~Co. Laois~Co. Leitrim~Co. Limerick~Co. Longford~Co. Louth~Co. Mayo~Co. Meath~Co. Monaghan~Co. Offaly~Co. Roscommon~Co. Sligo~Co. Tipperary~Co. Waterford~Co. Westmeath~Co. Wexford~Co. Wicklow",
+ sublocality_name_type: "townland",
+ zip: "[\\dA-Z]{3} ?[\\dA-Z]{4}",
+ zip_name_type: "eircode",
+ zipex: "A65 F4E2",
+ },
+ "data/IL": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/IL",
+ key: "IL",
+ name: "ISRAEL",
+ posturl: "http://www.israelpost.co.il/zipcode.nsf/demozip?openform",
+ zip: "\\d{5}(?:\\d{2})?",
+ zipex: "9614303",
+ },
+ "data/IM": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/IM",
+ key: "IM",
+ name: "ISLE OF MAN",
+ posturl: "https://www.iompost.com/tools-forms/postcode-finder/",
+ require: "ACZ",
+ upper: "CZ",
+ zip: "IM\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}",
+ zipex: "IM2 1AA,IM99 1PS",
+ },
+ "data/IN": {
+ fmt: "%N%n%O%n%A%n%C %Z%n%S",
+ id: "data/IN",
+ key: "IN",
+ lang: "en",
+ languages: "en~hi",
+ name: "INDIA",
+ posturl: "https://www.indiapost.gov.in/vas/pages/FindPinCode.aspx",
+ require: "ACSZ",
+ state_name_type: "state",
+ sub_isoids:
+ "AN~AP~AR~AS~BR~CH~CT~DN~DD~DL~GA~GJ~HR~HP~JK~JH~KA~KL~LD~MP~MH~MN~ML~MZ~NL~OR~PY~PB~RJ~SK~TN~TG~TR~UP~UT~WB",
+ sub_keys:
+ "Andaman and Nicobar Islands~Andhra Pradesh~Arunachal Pradesh~Assam~Bihar~Chandigarh~Chhattisgarh~Dadra and Nagar Haveli~Daman and Diu~Delhi~Goa~Gujarat~Haryana~Himachal Pradesh~Jammu and Kashmir~Jharkhand~Karnataka~Kerala~Lakshadweep~Madhya Pradesh~Maharashtra~Manipur~Meghalaya~Mizoram~Nagaland~Odisha~Puducherry~Punjab~Rajasthan~Sikkim~Tamil Nadu~Telangana~Tripura~Uttar Pradesh~Uttarakhand~West Bengal",
+ sub_names:
+ "Andaman & Nicobar~Andhra Pradesh~Arunachal Pradesh~Assam~Bihar~Chandigarh~Chhattisgarh~Dadra & Nagar Haveli~Daman & Diu~Delhi~Goa~Gujarat~Haryana~Himachal Pradesh~Jammu & Kashmir~Jharkhand~Karnataka~Kerala~Lakshadweep~Madhya Pradesh~Maharashtra~Manipur~Meghalaya~Mizoram~Nagaland~Odisha~Puducherry~Punjab~Rajasthan~Sikkim~Tamil Nadu~Telangana~Tripura~Uttar Pradesh~Uttarakhand~West Bengal",
+ sub_zips:
+ "744~5[0-3]~79[0-2]~78~8[0-5]~16|1440[3-9]~49~396~396~11~403~3[6-9]~1[23]~17~1[89]~81[4-9]|82|83[0-5]~5[4-9]|53[7-9]~6[7-9]|6010|607008|777~682~4[5-8]|490~4[0-4]~79[56]~79[34]~796~79[78]~7[5-7]~60[579]~1[456]~3[0-4]~737|750~6[0-6]|536~5[0-3]~799~2[0-35-8]|24[0-7]|26[12]~24[46-9]|254|26[23]~7[0-4]",
+ zip: "\\d{6}",
+ zip_name_type: "pin",
+ zipex: "110034,110001",
+ },
+ "data/IN--hi": {
+ fmt: "%N%n%O%n%A%n%C %Z%n%S",
+ id: "data/IN--hi",
+ key: "IN",
+ lang: "hi",
+ name: "INDIA",
+ posturl: "https://www.indiapost.gov.in/vas/pages/FindPinCode.aspx",
+ require: "ACSZ",
+ state_name_type: "state",
+ sub_isoids:
+ "AN~AR~AS~AP~UP~UT~OR~KA~KL~GJ~GA~CH~CT~JK~JH~TN~TG~TR~DD~DN~DL~NL~PB~WB~PY~BR~MN~MP~MH~MZ~ML~RJ~LD~SK~HR~HP",
+ sub_keys:
+ "Andaman & Nicobar~Arunachal Pradesh~Assam~Andhra Pradesh~Uttar Pradesh~Uttarakhand~Odisha~Karnataka~Kerala~Gujarat~Goa~Chandigarh~Chhattisgarh~Jammu & Kashmir~Jharkhand~Tamil Nadu~Telangana~Tripura~Daman & Diu~Dadra & Nagar Haveli~Delhi~Nagaland~Punjab~West Bengal~Puducherry~Bihar~Manipur~Madhya Pradesh~Maharashtra~Mizoram~Meghalaya~Rajasthan~Lakshadweep~Sikkim~Haryana~Himachal Pradesh",
+ sub_names:
+ "अंडमान और निकोबार द्वीपसमूह~अरुणाचल प्रदेश~असम~आंध्र प्रदेश~उत्तर प्रदेश~उत्तराखण्ड~ओड़िशा~कर्नाटक~केरल~गुजरात~गोआ~चंडीगढ़~छत्तीसगढ़~जम्मू और कश्मीर~झारखण्ड~तमिल नाडु~तेलंगाना~त्रिपुरा~दमन और दीव~दादरा और नगर हवेली~दिल्ली~नागालैंड~पंजाब~पश्चिम बंगाल~पांडिचेरी~बिहार~मणिपुर~मध्य प्रदेश~महाराष्ट्र~मिजोरम~मेघालय~राजस्थान~लक्षद्वीप~सिक्किम~हरियाणा~हिमाचल प्रदेश",
+ sub_zips:
+ "744~79[0-2]~78~5[0-3]~2[0-35-8]|24[0-7]|26[12]~24[46-9]|254|26[23]~7[5-7]~5[4-9]|53[7-9]~6[7-9]|6010|607008|777~3[6-9]~403~16|1440[3-9]~49~1[89]~81[4-9]|82|83[0-5]~6[0-6]|536~5[0-3]~799~396~396~11~79[78]~1[456]~7[0-4]~60[579]~8[0-5]~79[56]~4[5-8]|490~4[0-4]~796~79[34]~3[0-4]~682~737|750~1[23]~17",
+ zip: "\\d{6}",
+ zip_name_type: "pin",
+ zipex: "110034,110001",
+ },
+ "data/IO": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/IO",
+ key: "IO",
+ name: "BRITISH INDIAN OCEAN TERRITORY",
+ require: "ACZ",
+ upper: "CZ",
+ zip: "BBND 1ZZ",
+ zipex: "BBND 1ZZ",
+ },
+ "data/IQ": {
+ fmt: "%O%n%N%n%A%n%C, %S%n%Z",
+ id: "data/IQ",
+ key: "IQ",
+ name: "IRAQ",
+ require: "ACS",
+ upper: "CS",
+ zip: "\\d{5}",
+ zipex: "31001",
+ },
+ "data/IR": {
+ fmt: "%O%n%N%n%S%n%C, %D%n%A%n%Z",
+ id: "data/IR",
+ key: "IR",
+ lang: "fa",
+ languages: "fa",
+ name: "IRAN",
+ sub_isoids:
+ "01~02~03~04~32~05~06~07~08~29~30~31~10~11~12~13~14~28~26~16~15~17~18~27~19~20~21~22~23~24~25",
+ sub_keys:
+ "استان آذربایجان شرقی~استان آذربایجان غربی~استان اردبیل~استان اصفهان~استان البرز~استان ایلام~استان بوشهر~استان تهران~استان چهارمحال و بختیاری~استان خراسان جنوبی~استان خراسان رضوی~استان خراسان شمالی~استان خوزستان~استان زنجان~استان سمنان~استان سیستان و بلوچستان~استان فارس~استان قزوین~استان قم~استان کردستان~استان کرمان~استان کرمانشاه~استان کهگیلویه و بویراحمد~استان گلستان~استان گیلان~استان لرستان~استان مازندران~استان مرکزی~استان هرمزگان~استان همدان~استان یزد",
+ sub_lnames:
+ "East Azerbaijan Province~West Azerbaijan Province~Ardabil Province~Isfahan Province~Alborz Province~Ilam Province~Bushehr Province~Tehran Province~Chaharmahal and Bakhtiari Province~South Khorasan Province~Razavi Khorasan Province~North Khorasan Province~Khuzestan Province~Zanjan Province~Semnan Province~Sistan and Baluchestan Province~Fars Province~Qazvin Province~Qom Province~Kurdistan Province~Kerman Province~Kermanshah Province~Kohgiluyeh and Boyer-Ahmad Province~Golestan Province~Gilan Province~Lorestan Province~Mazandaran Province~Markazi Province~Hormozgan Province~Hamadan Province~Yazd Province",
+ sublocality_name_type: "neighborhood",
+ zip: "\\d{5}-?\\d{5}",
+ zipex: "11936-12345",
+ },
+ "data/IS": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/IS",
+ key: "IS",
+ name: "ICELAND",
+ posturl: "http://www.postur.is/einstaklingar/posthus/postnumer/",
+ zip: "\\d{3}",
+ zipex: "320,121,220,110",
+ },
+ "data/IT": {
+ fmt: "%N%n%O%n%A%n%Z %C %S",
+ id: "data/IT",
+ key: "IT",
+ lang: "it",
+ languages: "it",
+ name: "ITALY",
+ posturl: "http://www.poste.it/online/cercacap/",
+ require: "ACSZ",
+ sub_isoids:
+ "AG~AL~AN~AO~AR~AP~AT~AV~BA~BT~BL~BN~BG~BI~BO~BZ~BS~BR~CA~CL~CB~CI~CE~CT~CZ~CH~CO~CS~CR~KR~CN~EN~FM~FE~FI~FG~FC~FR~GE~GO~GR~IM~IS~AQ~SP~LT~LE~LC~LI~LO~LU~MC~MN~MS~MT~VS~ME~MI~MO~MB~NA~NO~NU~OG~OT~OR~PD~PA~PR~PV~PG~PU~PE~PC~PI~PT~PN~PZ~PO~RG~RA~RC~RE~RI~RN~RM~RO~SA~SS~SV~SI~SR~SO~TA~TE~TR~TO~TP~TN~TV~TS~UD~VA~VE~VB~VC~VR~VV~VI~VT",
+ sub_keys:
+ "AG~AL~AN~AO~AR~AP~AT~AV~BA~BT~BL~BN~BG~BI~BO~BZ~BS~BR~CA~CL~CB~CI~CE~CT~CZ~CH~CO~CS~CR~KR~CN~EN~FM~FE~FI~FG~FC~FR~GE~GO~GR~IM~IS~AQ~SP~LT~LE~LC~LI~LO~LU~MC~MN~MS~MT~VS~ME~MI~MO~MB~NA~NO~NU~OG~OT~OR~PD~PA~PR~PV~PG~PU~PE~PC~PI~PT~PN~PZ~PO~RG~RA~RC~RE~RI~RN~RM~RO~SA~SS~SV~SI~SR~SO~TA~TE~TR~TO~TP~TN~TV~TS~UD~VA~VE~VB~VC~VR~VV~VI~VT",
+ sub_names:
+ "Agrigento~Alessandria~Ancona~Aosta~Arezzo~Ascoli Piceno~Asti~Avellino~Bari~Barletta-Andria-Trani~Belluno~Benevento~Bergamo~Biella~Bologna~Bolzano~Brescia~Brindisi~Cagliari~Caltanissetta~Campobasso~Carbonia-Iglesias~Caserta~Catania~Catanzaro~Chieti~Como~Cosenza~Cremona~Crotone~Cuneo~Enna~Fermo~Ferrara~Firenze~Foggia~Forlì-Cesena~Frosinone~Genova~Gorizia~Grosseto~Imperia~Isernia~L'Aquila~La Spezia~Latina~Lecce~Lecco~Livorno~Lodi~Lucca~Macerata~Mantova~Massa-Carrara~Matera~Medio Campidano~Messina~Milano~Modena~Monza e Brianza~Napoli~Novara~Nuoro~Ogliastra~Olbia-Tempio~Oristano~Padova~Palermo~Parma~Pavia~Perugia~Pesaro e Urbino~Pescara~Piacenza~Pisa~Pistoia~Pordenone~Potenza~Prato~Ragusa~Ravenna~Reggio Calabria~Reggio Emilia~Rieti~Rimini~Roma~Rovigo~Salerno~Sassari~Savona~Siena~Siracusa~Sondrio~Taranto~Teramo~Terni~Torino~Trapani~Trento~Treviso~Trieste~Udine~Varese~Venezia~Verbano-Cusio-Ossola~Vercelli~Verona~Vibo Valentia~Vicenza~Viterbo",
+ sub_zips:
+ "92~15~60~11~52~63~14~83~70~76[01]~32~82~24~13[89]~40~39~25~72~0912[1-9]|0913[0-4]|0901[0289]|0902[03468]|0903[0234]|0904|0803[035]|08043~93~860[1-4]|86100~0901[013-7]~81~95~88[01]~66~22~87~26[01]~88[89]~12|18025~94~638|63900~44~50~71~47[015]~03~16~34[01]7~58~18~860[7-9]|86170~67~19~04~73~23[89]~57~26[89]~55~62~46~54~75~0902[012579]|0903[015-9]|09040~98~20~41~208|20900~80~28[01]~080[1-3]|08100~08037|0804[024-9]~08020|0702|0703[08]~090[7-9]|09170|0801[039]|0803[04]~35~90~43~27~06~61~65~29~56~51~330[7-9]|33170~85~59~97~48~89[01]~42~02~47[89]~00~45~84~070[14]|0703[0-79]|07100~17|12071~53~96~23[01]~74~64~05~10~91~38~31~3401|341[0-689]|34062~330[1-5]|33100~21~30~28[89]~13[01]~37~89[89]~36~01",
+ upper: "CS",
+ zip: "\\d{5}",
+ zipex: "00144,47037,39049",
+ },
+ "data/JE": {
+ fmt: "%N%n%O%n%A%n%C%nJERSEY%n%Z",
+ id: "data/JE",
+ key: "JE",
+ name: "CHANNEL ISLANDS",
+ posturl: "http://www.jerseypost.com/tools/postcode-address-finder/",
+ require: "ACZ",
+ upper: "CZ",
+ zip: "JE\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}",
+ zipex: "JE1 1AA,JE2 2BT",
+ },
+ "data/JM": {
+ fmt: "%N%n%O%n%A%n%C%n%S %X",
+ id: "data/JM",
+ key: "JM",
+ lang: "en",
+ languages: "en",
+ name: "JAMAICA",
+ require: "ACS",
+ state_name_type: "parish",
+ sub_isoids: "13~09~01~12~04~02~06~14~11~08~05~03~07~10",
+ sub_keys:
+ "Clarendon~Hanover~Kingston~Manchester~Portland~St. Andrew~St. Ann~St. Catherine~St. Elizabeth~St. James~St. Mary~St. Thomas~Trelawny~Westmoreland",
+ },
+ "data/JO": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/JO",
+ key: "JO",
+ name: "JORDAN",
+ zip: "\\d{5}",
+ zipex: "11937,11190",
+ },
+ "data/JP": {
+ fmt: "〒%Z%n%S%n%A%n%O%n%N",
+ id: "data/JP",
+ key: "JP",
+ lang: "ja",
+ languages: "ja",
+ lfmt: "%N%n%O%n%A, %S%n%Z",
+ name: "JAPAN",
+ posturl: "http://www.post.japanpost.jp/zipcode/",
+ require: "ASZ",
+ state_name_type: "prefecture",
+ sub_isoids:
+ "01~02~03~04~05~06~07~08~09~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",
+ sub_keys:
+ "北海道~青森県~岩手県~宮城県~秋田県~山形県~福島県~茨城県~栃木県~群馬県~埼玉県~千葉県~東京都~神奈川県~新潟県~富山県~石川県~福井県~山梨県~長野県~岐阜県~静岡県~愛知県~三重県~滋賀県~京都府~大阪府~兵庫県~奈良県~和歌山県~鳥取県~島根県~岡山県~広島県~山口県~徳島県~香川県~愛媛県~高知県~福岡県~佐賀県~長崎県~熊本県~大分県~宮崎県~鹿児島県~沖縄県",
+ sub_lnames:
+ "Hokkaido~Aomori~Iwate~Miyagi~Akita~Yamagata~Fukushima~Ibaraki~Tochigi~Gunma~Saitama~Chiba~Tokyo~Kanagawa~Niigata~Toyama~Ishikawa~Fukui~Yamanashi~Nagano~Gifu~Shizuoka~Aichi~Mie~Shiga~Kyoto~Osaka~Hyogo~Nara~Wakayama~Tottori~Shimane~Okayama~Hiroshima~Yamaguchi~Tokushima~Kagawa~Ehime~Kochi~Fukuoka~Saga~Nagasaki~Kumamoto~Oita~Miyazaki~Kagoshima~Okinawa",
+ sub_zips:
+ "0[4-9]|00[1-7]~03|018~02~98~01~99~9[67]~3[01]~32|311|349~37|38[49]~3[3-6]~2[6-9]~1[0-8]|19[0-8]|20~2[1-5]|199~9[45]|389~93~92|939~91|922~40~3[89]|949~50~4[1-9]~4[4-9]|431~51|498|647~52~6[0-2]|520~5[3-9]|618|630~6[5-7]|563~63|64[78]~64|519~68~69|68[45]~7[01]~7[23]~7[45]~77~76~79~78~8[0-3]|871~84~85|81[17]|848~86~87|839~88~89~90",
+ upper: "S",
+ zip: "\\d{3}-?\\d{4}",
+ zipex: "154-0023,350-1106,951-8073,112-0001,208-0032,231-0012",
+ },
+ "data/KE": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/KE",
+ key: "KE",
+ name: "KENYA",
+ zip: "\\d{5}",
+ zipex: "20100,00100",
+ },
+ "data/KG": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/KG",
+ key: "KG",
+ name: "KYRGYZSTAN",
+ zip: "\\d{6}",
+ zipex: "720001",
+ },
+ "data/KH": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/KH",
+ key: "KH",
+ name: "CAMBODIA",
+ zip: "\\d{5}",
+ zipex: "12203,14206,12000",
+ },
+ "data/KI": {
+ fmt: "%N%n%O%n%A%n%S%n%C",
+ id: "data/KI",
+ key: "KI",
+ name: "KIRIBATI",
+ state_name_type: "island",
+ upper: "ACNOS",
+ },
+ "data/KM": { id: "data/KM", key: "KM", name: "COMOROS", upper: "AC" },
+ "data/KN": {
+ fmt: "%N%n%O%n%A%n%C, %S",
+ id: "data/KN",
+ key: "KN",
+ lang: "en",
+ languages: "en",
+ name: "SAINT KITTS AND NEVIS",
+ require: "ACS",
+ state_name_type: "island",
+ sub_isoids: "N~K",
+ sub_keys: "Nevis~St. Kitts",
+ },
+ "data/KP": {
+ fmt: "%Z%n%S%n%C%n%A%n%O%n%N",
+ id: "data/KP",
+ key: "KP",
+ lang: "ko",
+ languages: "ko",
+ lfmt: "%N%n%O%n%A%n%C%n%S, %Z",
+ name: "NORTH KOREA",
+ sub_isoids: "07~13~10~04~02~03~01~08~09~05~06",
+ sub_keys:
+ "강원도~라선 특별시~량강도~자강도~평안 남도~평안 북도~평양 직할시~함경 남도~함경 북도~황해남도~황해북도",
+ sub_lnames:
+ "Kangwon~Rason~Ryanggang~Chagang~South Pyongan~North Pyongan~Pyongyang~South Hamgyong~North Hamgyong~South Hwanghae~North Hwanghae",
+ },
+ "data/KR": {
+ fmt: "%S %C%D%n%A%n%O%n%N%n%Z",
+ id: "data/KR",
+ key: "KR",
+ lang: "ko",
+ languages: "ko",
+ lfmt: "%N%n%O%n%A%n%D%n%C%n%S%n%Z",
+ name: "SOUTH KOREA",
+ posturl: "http://www.epost.go.kr/search/zipcode/search5.jsp",
+ require: "ACSZ",
+ state_name_type: "do_si",
+ sub_isoids: "42~41~48~47~29~27~30~26~11~50~31~28~46~45~49~44~43",
+ sub_keys:
+ "강원도~경기도~경상남도~경상북도~광주광역시~대구광역시~대전광역시~부산광역시~서울특별시~세종특별자치시~울산광역시~인천광역시~전라남도~전라북도~제주특별자치도~충청남도~충청북도",
+ sub_lnames:
+ "Gangwon-do~Gyeonggi-do~Gyeongsangnam-do~Gyeongsangbuk-do~Gwangju~Daegu~Daejeon~Busan~Seoul~Sejong~Ulsan~Incheon~Jeollanam-do~Jeollabuk-do~Jeju-do~Chungcheongnam-do~Chungcheongbuk-do",
+ sub_mores:
+ "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true",
+ sub_names:
+ "강원~경기~경남~경북~광주~대구~대전~부산~서울~세종~울산~인천~전남~전북~제주~충남~충북",
+ sub_zipexs:
+ "25627~12410~53286~38540~62394~42456~34316~46706~06321~30065~44782~23024~59222~56445~63563~32832~28006",
+ sub_zips:
+ "2[456]\\d{2}~1[0-8]\\d{2}~5[0-3]\\d{2}~(?:3[6-9]|40)\\d{2}~6[12]\\d{2}~4[12]\\d{2}~3[45]\\d{2}~4[6-9]\\d{2}~0[1-8]\\d{2}~30[01]\\d~4[45]\\d{2}~2[1-3]\\d{2}~5[7-9]\\d{2}~5[4-6]\\d{2}~63[0-356]\\d~3[1-3]\\d{2}~2[789]\\d{2}",
+ sublocality_name_type: "district",
+ upper: "Z",
+ zip: "\\d{5}",
+ zipex: "03051",
+ },
+ "data/KW": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/KW",
+ key: "KW",
+ name: "KUWAIT",
+ zip: "\\d{5}",
+ zipex: "54541,54551,54404,13009",
+ },
+ "data/KY": {
+ fmt: "%N%n%O%n%A%n%S %Z",
+ id: "data/KY",
+ key: "KY",
+ lang: "en",
+ languages: "en",
+ name: "CAYMAN ISLANDS",
+ posturl: "http://www.caymanpost.gov.ky/",
+ require: "AS",
+ state_name_type: "island",
+ sub_keys: "Cayman Brac~Grand Cayman~Little Cayman",
+ zip: "KY\\d-\\d{4}",
+ zipex: "KY1-1100,KY1-1702,KY2-2101",
+ },
+ "data/KZ": {
+ fmt: "%Z%n%S%n%C%n%A%n%O%n%N",
+ id: "data/KZ",
+ key: "KZ",
+ name: "KAZAKHSTAN",
+ zip: "\\d{6}",
+ zipex: "040900,050012",
+ },
+ "data/LA": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/LA",
+ key: "LA",
+ name: "LAO (PEOPLE'S DEM. REP.)",
+ zip: "\\d{5}",
+ zipex: "01160,01000",
+ },
+ "data/LB": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/LB",
+ key: "LB",
+ name: "LEBANON",
+ zip: "(?:\\d{4})(?: ?(?:\\d{4}))?",
+ zipex: "2038 3054,1107 2810,1000",
+ },
+ "data/LC": { id: "data/LC", key: "LC", name: "SAINT LUCIA" },
+ "data/LI": {
+ fmt: "%O%n%N%n%A%nFL-%Z %C",
+ id: "data/LI",
+ key: "LI",
+ name: "LIECHTENSTEIN",
+ postprefix: "FL-",
+ posturl: "http://www.post.ch/db/owa/pv_plz_pack/pr_main",
+ require: "ACZ",
+ zip: "948[5-9]|949[0-8]",
+ zipex: "9496,9491,9490,9485",
+ },
+ "data/LK": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/LK",
+ key: "LK",
+ name: "SRI LANKA",
+ posturl: "http://www.slpost.gov.lk/",
+ zip: "\\d{5}",
+ zipex: "20000,00100",
+ },
+ "data/LR": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/LR",
+ key: "LR",
+ name: "LIBERIA",
+ zip: "\\d{4}",
+ zipex: "1000",
+ },
+ "data/LS": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/LS",
+ key: "LS",
+ name: "LESOTHO",
+ zip: "\\d{3}",
+ zipex: "100",
+ },
+ "data/LT": {
+ fmt: "%O%n%N%n%A%nLT-%Z %C",
+ id: "data/LT",
+ key: "LT",
+ name: "LITHUANIA",
+ postprefix: "LT-",
+ posturl: "http://www.post.lt/lt/?id=316",
+ zip: "\\d{5}",
+ zipex: "04340,03500",
+ },
+ "data/LU": {
+ fmt: "%O%n%N%n%A%nL-%Z %C",
+ id: "data/LU",
+ key: "LU",
+ name: "LUXEMBOURG",
+ postprefix: "L-",
+ posturl:
+ "https://www.post.lu/fr/grandes-entreprises/solutions-postales/rechercher-un-code-postal",
+ require: "ACZ",
+ zip: "\\d{4}",
+ zipex: "4750,2998",
+ },
+ "data/LV": {
+ fmt: "%N%n%O%n%A%n%C, %Z",
+ id: "data/LV",
+ key: "LV",
+ name: "LATVIA",
+ posturl: "http://www.pasts.lv/lv/uzzinas/nodalas/",
+ zip: "LV-\\d{4}",
+ zipex: "LV-1073,LV-1000",
+ },
+ "data/LY": { id: "data/LY", key: "LY", name: "LIBYA" },
+ "data/MA": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/MA",
+ key: "MA",
+ name: "MOROCCO",
+ zip: "\\d{5}",
+ zipex: "53000,10000,20050,16052",
+ },
+ "data/MC": {
+ fmt: "%N%n%O%n%A%nMC-%Z %C %X",
+ id: "data/MC",
+ key: "MC",
+ name: "MONACO",
+ postprefix: "MC-",
+ zip: "980\\d{2}",
+ zipex: "98000,98020,98011,98001",
+ },
+ "data/MD": {
+ fmt: "%N%n%O%n%A%nMD-%Z %C",
+ id: "data/MD",
+ key: "MD",
+ name: "Rep. MOLDOVA",
+ postprefix: "MD-",
+ zip: "\\d{4}",
+ zipex: "2012,2019",
+ },
+ "data/ME": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/ME",
+ key: "ME",
+ name: "MONTENEGRO",
+ zip: "8\\d{4}",
+ zipex: "81257,81258,81217,84314,85366",
+ },
+ "data/MF": {
+ fmt: "%O%n%N%n%A%n%Z %C %X",
+ id: "data/MF",
+ key: "MF",
+ name: "SAINT MARTIN",
+ posturl:
+ "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal",
+ require: "ACZ",
+ upper: "ACX",
+ zip: "9[78][01]\\d{2}",
+ zipex: "97100",
+ },
+ "data/MG": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/MG",
+ key: "MG",
+ name: "MADAGASCAR",
+ zip: "\\d{3}",
+ zipex: "501,101",
+ },
+ "data/MH": {
+ fmt: "%N%n%O%n%A%n%C %S %Z",
+ id: "data/MH",
+ key: "MH",
+ name: "MARSHALL ISLANDS",
+ posturl: "http://zip4.usps.com/zip4/welcome.jsp",
+ require: "ACSZ",
+ state_name_type: "state",
+ upper: "ACNOS",
+ zip: "(969[67]\\d)(?:[ \\-](\\d{4}))?",
+ zip_name_type: "zip",
+ zipex: "96960,96970",
+ },
+ "data/MK": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/MK",
+ key: "MK",
+ name: "MACEDONIA",
+ zip: "\\d{4}",
+ zipex: "1314,1321,1443,1062",
+ },
+ "data/ML": { id: "data/ML", key: "ML", name: "MALI" },
+ "data/MM": {
+ fmt: "%N%n%O%n%A%n%C, %Z",
+ id: "data/MM",
+ key: "MM",
+ name: "MYANMAR",
+ zip: "\\d{5}",
+ zipex: "11181",
+ },
+ "data/MN": {
+ fmt: "%N%n%O%n%A%n%C%n%S %Z",
+ id: "data/MN",
+ key: "MN",
+ name: "MONGOLIA",
+ posturl: "http://www.zipcode.mn/",
+ zip: "\\d{5}",
+ zipex: "65030,65270",
+ },
+ "data/MO": {
+ fmt: "%A%n%O%n%N",
+ id: "data/MO",
+ key: "MO",
+ lfmt: "%N%n%O%n%A",
+ name: "MACAO",
+ require: "A",
+ },
+ "data/MP": {
+ fmt: "%N%n%O%n%A%n%C %S %Z",
+ id: "data/MP",
+ key: "MP",
+ name: "NORTHERN MARIANA ISLANDS",
+ posturl: "http://zip4.usps.com/zip4/welcome.jsp",
+ require: "ACSZ",
+ state_name_type: "state",
+ upper: "ACNOS",
+ zip: "(9695[012])(?:[ \\-](\\d{4}))?",
+ zip_name_type: "zip",
+ zipex: "96950,96951,96952",
+ },
+ "data/MQ": {
+ fmt: "%O%n%N%n%A%n%Z %C %X",
+ id: "data/MQ",
+ key: "MQ",
+ name: "MARTINIQUE",
+ posturl:
+ "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal",
+ require: "ACZ",
+ upper: "ACX",
+ zip: "9[78]2\\d{2}",
+ zipex: "97220",
+ },
+ "data/MR": { id: "data/MR", key: "MR", name: "MAURITANIA", upper: "AC" },
+ "data/MS": { id: "data/MS", key: "MS", name: "MONTSERRAT" },
+ "data/MT": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/MT",
+ key: "MT",
+ name: "MALTA",
+ posturl: "http://postcodes.maltapost.com/",
+ upper: "CZ",
+ zip: "[A-Z]{3} ?\\d{2,4}",
+ zipex: "NXR 01,ZTN 05,GPO 01,BZN 1130,SPB 6031,VCT 1753",
+ },
+ "data/MU": {
+ fmt: "%N%n%O%n%A%n%Z%n%C",
+ id: "data/MU",
+ key: "MU",
+ name: "MAURITIUS",
+ upper: "CZ",
+ zip: "\\d{3}(?:\\d{2}|[A-Z]{2}\\d{3})",
+ zipex: "42602",
+ },
+ "data/MV": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/MV",
+ key: "MV",
+ name: "MALDIVES",
+ posturl: "http://www.maldivespost.com/?lid=10",
+ zip: "\\d{5}",
+ zipex: "20026",
+ },
+ "data/MW": {
+ fmt: "%N%n%O%n%A%n%C %X",
+ id: "data/MW",
+ key: "MW",
+ name: "MALAWI",
+ },
+ "data/MX": {
+ fmt: "%N%n%O%n%A%n%D%n%Z %C, %S",
+ id: "data/MX",
+ key: "MX",
+ lang: "es",
+ languages: "es",
+ name: "MEXICO",
+ posturl:
+ "http://www.correosdemexico.gob.mx/ServiciosLinea/Paginas/ccpostales.aspx",
+ require: "ACZ",
+ state_name_type: "state",
+ sub_isoids:
+ "AGU~BCN~BCS~CAM~CHP~CHH~CMX~COA~COL~DUR~MEX~GUA~GRO~HID~JAL~MIC~MOR~NAY~NLE~OAX~PUE~QUE~ROO~SLP~SIN~SON~TAB~TAM~TLA~VER~YUC~ZAC",
+ sub_keys:
+ "Ags.~B.C.~B.C.S.~Camp.~Chis.~Chih.~CDMX~Coah.~Col.~Dgo.~Méx.~Gto.~Gro.~Hgo.~Jal.~Mich.~Mor.~Nay.~N.L.~Oax.~Pue.~Qro.~Q.R.~S.L.P.~Sin.~Son.~Tab.~Tamps.~Tlax.~Ver.~Yuc.~Zac.",
+ sub_names:
+ "Aguascalientes~Baja California~Baja California Sur~Campeche~Chiapas~Chihuahua~Ciudad de México~Coahuila de Zaragoza~Colima~Durango~Estado de México~Guanajuato~Guerrero~Hidalgo~Jalisco~Michoacán~Morelos~Nayarit~Nuevo León~Oaxaca~Puebla~Querétaro~Quintana Roo~San Luis Potosí~Sinaloa~Sonora~Tabasco~Tamaulipas~Tlaxcala~Veracruz~Yucatán~Zacatecas",
+ sub_zipexs:
+ "20000,20999~21000,22999~23000,23999~24000,24999~29000,30999~31000,33999~00000,16999~25000,27999~28000,28999~34000,35999~50000,57999~36000,38999~39000,41999~42000,43999~44000,49999~58000,61999~62000,62999~63000,63999~64000,67999~68000,71999~72000,75999~76000,76999~77000,77999~78000,79999~80000,82999~83000,85999~86000,86999~87000,89999~90000,90999~91000,96999~97000,97999~98000,99999",
+ sub_zips:
+ "20~2[12]~23~24~29|30~3[1-3]~0|1[0-6]~2[5-7]~28~3[45]~5[0-7]~3[6-8]~39|4[01]~4[23]~4[4-9]~5[89]|6[01]~62~63~6[4-7]~6[89]|7[01]~7[2-5]~76~77~7[89]~8[0-2]~8[3-5]~86~8[7-9]~90~9[1-6]~97~9[89]",
+ sublocality_name_type: "neighborhood",
+ upper: "CSZ",
+ zip: "\\d{5}",
+ zipex: "02860,77520,06082",
+ },
+ "data/MY": {
+ fmt: "%N%n%O%n%A%n%D%n%Z %C%n%S",
+ id: "data/MY",
+ key: "MY",
+ lang: "ms",
+ languages: "ms",
+ name: "MALAYSIA",
+ posturl: "http://www.pos.com.my",
+ require: "ACZ",
+ state_name_type: "state",
+ sub_isoids: "01~02~03~14~15~04~05~06~08~09~07~16~12~13~10~11",
+ sub_keys:
+ "Johor~Kedah~Kelantan~Kuala Lumpur~Labuan~Melaka~Negeri Sembilan~Pahang~Perak~Perlis~Pulau Pinang~Putrajaya~Sabah~Sarawak~Selangor~Terengganu",
+ sub_zipexs:
+ "79000,86999~05000,09999,34950~15000,18599~50000,60000~87000,87999~75000,78399~70000,73599~25000,28999,39000,49000,69000~30000,36899,39000~01000,02799~10000,14999~62000,62999~88000,91999~93000,98999~40000,48999,63000,68199~20000,24999",
+ sub_zips:
+ "79|8[0-6]~0[5-9]|34950~1[5-9]~5|60~87~7[5-8]~7[0-4]~2[5-8]|[346]9~3[0-6]|39000~0[12]~1[0-4]~62~8[89]|9[01]~9[3-8]~4[0-8]|6[3-8]~2[0-4]",
+ sublocality_name_type: "village_township",
+ upper: "CS",
+ zip: "\\d{5}",
+ zipex: "43000,50754,88990,50670",
+ },
+ "data/MZ": {
+ fmt: "%N%n%O%n%A%n%Z %C%S",
+ id: "data/MZ",
+ key: "MZ",
+ lang: "pt",
+ languages: "pt",
+ name: "MOZAMBIQUE",
+ sub_isoids: "P~MPM~G~I~B~L~N~A~S~T~Q",
+ sub_keys:
+ "Cabo Delgado~Cidade de Maputo~Gaza~Inhambane~Manica~Maputo~Nampula~Niassa~Sofala~Tete~Zambezia",
+ zip: "\\d{4}",
+ zipex: "1102,1119,3212",
+ },
+ "data/NA": { id: "data/NA", key: "NA", name: "NAMIBIA" },
+ "data/NC": {
+ fmt: "%O%n%N%n%A%n%Z %C %X",
+ id: "data/NC",
+ key: "NC",
+ name: "NEW CALEDONIA",
+ posturl:
+ "http://poste.opt.nc/index.php?option=com_content&view=article&id=80&Itemid=131",
+ require: "ACZ",
+ upper: "ACX",
+ zip: "988\\d{2}",
+ zipex: "98814,98800,98810",
+ },
+ "data/NE": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/NE",
+ key: "NE",
+ name: "NIGER",
+ zip: "\\d{4}",
+ zipex: "8001",
+ },
+ "data/NF": {
+ fmt: "%O%n%N%n%A%n%C %S %Z",
+ id: "data/NF",
+ key: "NF",
+ name: "NORFOLK ISLAND",
+ upper: "CS",
+ zip: "2899",
+ zipex: "2899",
+ },
+ "data/NG": {
+ fmt: "%N%n%O%n%A%n%D%n%C %Z%n%S",
+ id: "data/NG",
+ key: "NG",
+ lang: "en",
+ languages: "en",
+ name: "NIGERIA",
+ posturl: "http://www.nigeriapostcodes.com/",
+ state_name_type: "state",
+ sub_isoids:
+ "AB~AD~AK~AN~BA~BY~BE~BO~CR~DE~EB~ED~EK~EN~FC~GO~IM~JI~KD~KN~KT~KE~KO~KW~LA~NA~NI~OG~ON~OS~OY~PL~RI~SO~TA~YO~ZA",
+ sub_keys:
+ "Abia~Adamawa~Akwa Ibom~Anambra~Bauchi~Bayelsa~Benue~Borno~Cross River~Delta~Ebonyi~Edo~Ekiti~Enugu~Federal Capital Territory~Gombe~Imo~Jigawa~Kaduna~Kano~Katsina~Kebbi~Kogi~Kwara~Lagos~Nasarawa~Niger~Ogun State~Ondo~Osun~Oyo~Plateau~Rivers~Sokoto~Taraba~Yobe~Zamfara",
+ upper: "CS",
+ zip: "\\d{6}",
+ zipex: "930283,300001,931104",
+ },
+ "data/NI": {
+ fmt: "%N%n%O%n%A%n%Z%n%C, %S",
+ id: "data/NI",
+ key: "NI",
+ lang: "es",
+ languages: "es",
+ name: "NICARAGUA",
+ posturl: "http://www.correos.gob.ni/index.php/codigo-postal-2",
+ state_name_type: "department",
+ sub_isoids: "BO~CA~CI~CO~ES~GR~JI~LE~MD~MN~MS~MT~NS~AN~AS~SJ~RI",
+ sub_keys:
+ "Boaco~Carazo~Chinandega~Chontales~Esteli~Granada~Jinotega~Leon~Madriz~Managua~Masaya~Matagalpa~Nueva Segovia~Raan~Raas~Rio San Juan~Rivas",
+ sub_zips:
+ "5[12]~4[56]~2[5-7]~5[56]~3[12]~4[34]~6[56]~2[12]~3[45]~1[0-6]~4[12]~6[1-3]~3[7-9]~7[12]~8[1-3]~9[12]~4[78]",
+ upper: "CS",
+ zip: "\\d{5}",
+ zipex: "52000",
+ },
+ "data/NL": {
+ fmt: "%O%n%N%n%A%n%Z %C",
+ id: "data/NL",
+ key: "NL",
+ name: "NETHERLANDS",
+ posturl: "http://www.postnl.nl/voorthuis/",
+ require: "ACZ",
+ zip: "\\d{4} ?[A-Z]{2}",
+ zipex: "1234 AB,2490 AA",
+ },
+ "data/NO": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/NO",
+ key: "NO",
+ locality_name_type: "post_town",
+ name: "NORWAY",
+ posturl: "http://adressesok.posten.no/nb/postal_codes/search",
+ require: "ACZ",
+ zip: "\\d{4}",
+ zipex: "0025,0107,6631",
+ },
+ "data/NP": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/NP",
+ key: "NP",
+ name: "NEPAL",
+ posturl: "http://www.gpo.gov.np/Home/Postalcode",
+ zip: "\\d{5}",
+ zipex: "44601",
+ },
+ "data/NR": {
+ fmt: "%N%n%O%n%A%n%S",
+ id: "data/NR",
+ key: "NR",
+ lang: "en",
+ languages: "en",
+ name: "NAURU CENTRAL PACIFIC",
+ require: "AS",
+ state_name_type: "district",
+ sub_isoids: "01~02~03~04~05~06~07~08~09~10~11~12~13~14",
+ sub_keys:
+ "Aiwo District~Anabar District~Anetan District~Anibare District~Baiti District~Boe District~Buada District~Denigomodu District~Ewa District~Ijuw District~Meneng District~Nibok District~Uaboe District~Yaren District",
+ },
+ "data/NU": { id: "data/NU", key: "NU", name: "NIUE" },
+ "data/NZ": {
+ fmt: "%N%n%O%n%A%n%D%n%C %Z",
+ id: "data/NZ",
+ key: "NZ",
+ name: "NEW ZEALAND",
+ posturl:
+ "http://www.nzpost.co.nz/Cultures/en-NZ/OnlineTools/PostCodeFinder/",
+ require: "ACZ",
+ zip: "\\d{4}",
+ zipex: "6001,6015,6332,8252,1030",
+ },
+ "data/OM": {
+ fmt: "%N%n%O%n%A%n%Z%n%C",
+ id: "data/OM",
+ key: "OM",
+ name: "OMAN",
+ zip: "(?:PC )?\\d{3}",
+ zipex: "133,112,111",
+ },
+ "data/PA": {
+ fmt: "%N%n%O%n%A%n%C%n%S",
+ id: "data/PA",
+ key: "PA",
+ name: "PANAMA (REP.)",
+ upper: "CS",
+ },
+ "data/PE": {
+ fmt: "%N%n%O%n%A%n%C %Z%n%S",
+ id: "data/PE",
+ key: "PE",
+ lang: "es",
+ languages: "es",
+ locality_name_type: "district",
+ name: "PERU",
+ posturl: "http://www.serpost.com.pe/cpostal/codigo",
+ sub_isoids:
+ "AMA~ANC~APU~ARE~AYA~CAJ~CAL~CUS~LIM~HUV~HUC~ICA~JUN~LAL~LAM~LOR~MDD~MOQ~LMA~PAS~PIU~PUN~SAM~TAC~TUM~UCA",
+ sub_keys:
+ "Amazonas~Áncash~Apurímac~Arequipa~Ayacucho~Cajamarca~Callao~Cuzco~Gobierno Regional de Lima~Huancavelica~Huánuco~Ica~Junín~La Libertad~Lambayeque~Loreto~Madre de Dios~Moquegua~Municipalidad Metropolitana de Lima~Pasco~Piura~Puno~San Martín~Tacna~Tumbes~Ucayali",
+ zip: "(?:LIMA \\d{1,2}|CALLAO 0?\\d)|[0-2]\\d{4}",
+ zipex: "LIMA 23,LIMA 42,CALLAO 2,02001",
+ },
+ "data/PF": {
+ fmt: "%N%n%O%n%A%n%Z %C %S",
+ id: "data/PF",
+ key: "PF",
+ name: "FRENCH POLYNESIA",
+ require: "ACSZ",
+ state_name_type: "island",
+ upper: "CS",
+ zip: "987\\d{2}",
+ zipex: "98709",
+ },
+ "data/PG": {
+ fmt: "%N%n%O%n%A%n%C %Z %S",
+ id: "data/PG",
+ key: "PG",
+ name: "PAPUA NEW GUINEA",
+ require: "ACS",
+ zip: "\\d{3}",
+ zipex: "111",
+ },
+ "data/PH": {
+ fmt: "%N%n%O%n%A%n%D, %C%n%Z %S",
+ id: "data/PH",
+ key: "PH",
+ lang: "en",
+ languages: "en",
+ name: "PHILIPPINES",
+ posturl: "http://www.philpost.gov.ph/",
+ sub_isoids:
+ "ABR~AGN~AGS~AKL~ALB~ANT~APA~AUR~BAS~BAN~BTN~BTG~BEN~BIL~BOH~BUK~BUL~CAG~CAN~CAS~CAM~CAP~CAT~CAV~CEB~COM~NCO~DAV~DAS~DVO~DAO~DIN~EAS~GUI~IFU~ILN~ILS~ILI~ISA~KAL~LUN~LAG~LAN~LAS~LEY~MAG~MAD~MAS~00~MDC~MDR~MSC~MSR~MOU~NEC~NER~NSA~NUE~NUV~PLW~PAM~PAN~QUE~QUI~RIZ~ROM~WSA~SAR~SIG~SOR~SCO~SLE~SUK~SLU~SUN~SUR~TAR~TAW~ZMB~ZAN~ZAS~ZSI",
+ sub_keys:
+ "Abra~Agusan del Norte~Agusan del Sur~Aklan~Albay~Antique~Apayao~Aurora~Basilan~Bataan~Batanes~Batangas~Benguet~Biliran~Bohol~Bukidnon~Bulacan~Cagayan~Camarines Norte~Camarines Sur~Camiguin~Capiz~Catanduanes~Cavite~Cebu~Compostela Valley~Cotabato~Davao del Norte~Davao del Sur~Davao Occidental~Davao Oriental~Dinagat Islands~Eastern Samar~Guimaras~Ifugao~Ilocos Norte~Ilocos Sur~Iloilo~Isabela~Kalinga~La Union~Laguna~Lanao del Norte~Lanao del Sur~Leyte~Maguindanao~Marinduque~Masbate~Metro Manila~Mindoro Occidental~Mindoro Oriental~Misamis Occidental~Misamis Oriental~Mountain Province~Negros Occidental~Negros Oriental~Northern Samar~Nueva Ecija~Nueva Vizcaya~Palawan~Pampanga~Pangasinan~Quezon Province~Quirino~Rizal~Romblon~Samar~Sarangani~Siquijor~Sorsogon~South Cotabato~Southern Leyte~Sultan Kudarat~Sulu~Surigao del Norte~Surigao del Sur~Tarlac~Tawi-Tawi~Zambales~Zamboanga del Norte~Zamboanga del Sur~Zamboanga Sibuguey",
+ sub_zipexs:
+ "2800,2826~8600,8611~8500,8513~5600,5616~4500,4517~5700,5717~3800,3806,3808~3200,3207~7300,7306~2100,2114~3900,3905~4200,4234~2600,2615~6543,6550~6300,6337~8700,8723~3000,3024~3500,3528~4600,4612~4400,4436~9100,9104~5800,5816~4800,4810~4100,4126~6000,6053~8800,8810~9400,9417~8100,8120~8000,8010~8015,8013~8200,8210~8426,8412~6800,6822~5044,5046~3600,3610~2900,2922~2700,2733~5000,5043~3300,3336~3807,3809,3814~2500,2520~4000,4033~9200,9223~9300,9321,9700,9716~6500,6542~9600,9619~4900,4905~5400,5421~~5100,5111~5200,5214~7200,7215~9000,9025~2616,2625~6100,6132~6200,6224~6400,6423~3100,3133~3700,3714~5300,5322~2000,2022~2400,2447~4300,4342~3400,3405~1850,1990~5500,5516~6700,6725~8015~6225,6230~4700,4715~9500,9513~6600,6613~9800,9811~7400,7416~8400,8425~8300,8319~2300,2318~7500,7509~2200,2213~7100,7124~7000,7043~7000,7043",
+ sub_zips:
+ "28[0-2]~86[01]~85[01]~56[01]~45[01]~57[01]~380[0-68]~320~730~21[01]~390~42[0-3]~26(0|1[0-5])~65(4[3-9]|5)~63[0-3]~87[0-2]~30[0-2]~35[0-2]~46[01]~44[0-3]~910~58[01]~48[01]~41[0-2]~60[0-5]~88[01]~94[01]~81[0-2]~80[01]~801[1-5]~82[01]~84[12]~68[0-2]~504[4-6]~36[01]~29[0-2]~27[0-3]~50([0-3]|4[0-3])~33[0-3]~38(0[79]|1[0-4])~25[0-2]~40[0-3]~92[0-2]~9(3[0-2]|7[01])~65([0-3]|4[0-2])~96[01]~490~54[0-2]~~51[01]~52[01]~72[01]~90[0-2]~26(1[6-9]|2[0-5])~61[0-3]~62[0-2]~64[0-2]~31[0-3]~37[01]~53[0-2]~20[0-2]~24[0-4]~43[0-4]~340~1[89]~55[01]~67[0-2]~8015~62(2[5-9]|30)~47[01]~95[01]~66[10]~98[01]~74[01]~84[0-2]~83[01]~23[01]~750~22[01]~71[0-2]~70[0-4]~70[0-4]",
+ zip: "\\d{4}",
+ zipex: "1008,1050,1135,1207,2000,1000",
+ },
+ "data/PK": {
+ fmt: "%N%n%O%n%A%n%C-%Z",
+ id: "data/PK",
+ key: "PK",
+ name: "PAKISTAN",
+ posturl: "http://www.pakpost.gov.pk/postcode.php",
+ zip: "\\d{5}",
+ zipex: "44000",
+ },
+ "data/PL": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/PL",
+ key: "PL",
+ name: "POLAND",
+ posturl: "http://kody.poczta-polska.pl/",
+ require: "ACZ",
+ zip: "\\d{2}-\\d{3}",
+ zipex: "00-950,05-470,48-300,32-015,00-940",
+ },
+ "data/PM": {
+ fmt: "%O%n%N%n%A%n%Z %C %X",
+ id: "data/PM",
+ key: "PM",
+ name: "ST. PIERRE AND MIQUELON",
+ require: "ACZ",
+ upper: "ACX",
+ zip: "9[78]5\\d{2}",
+ zipex: "97500",
+ },
+ "data/PN": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/PN",
+ key: "PN",
+ name: "PITCAIRN",
+ require: "ACZ",
+ upper: "CZ",
+ zip: "PCRN 1ZZ",
+ zipex: "PCRN 1ZZ",
+ },
+ "data/PR": {
+ fmt: "%N%n%O%n%A%n%C PR %Z",
+ id: "data/PR",
+ key: "PR",
+ name: "PUERTO RICO",
+ postprefix: "PR ",
+ posturl: "http://zip4.usps.com/zip4/welcome.jsp",
+ require: "ACZ",
+ upper: "ACNO",
+ zip: "(00[679]\\d{2})(?:[ \\-](\\d{4}))?",
+ zip_name_type: "zip",
+ zipex: "00930",
+ },
+ "data/PT": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/PT",
+ key: "PT",
+ name: "PORTUGAL",
+ posturl: "http://www.ctt.pt/feapl_2/app/open/tools.jspx?tool=1",
+ require: "ACZ",
+ zip: "\\d{4}-\\d{3}",
+ zipex: "2725-079,1250-096,1201-950,2860-571,1208-148",
+ },
+ "data/PW": {
+ fmt: "%N%n%O%n%A%n%C %S %Z",
+ id: "data/PW",
+ key: "PW",
+ name: "PALAU",
+ posturl: "http://zip4.usps.com/zip4/welcome.jsp",
+ require: "ACSZ",
+ state_name_type: "state",
+ upper: "ACNOS",
+ zip: "(969(?:39|40))(?:[ \\-](\\d{4}))?",
+ zip_name_type: "zip",
+ zipex: "96940",
+ },
+ "data/PY": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/PY",
+ key: "PY",
+ name: "PARAGUAY",
+ zip: "\\d{4}",
+ zipex: "1536,1538,1209",
+ },
+ "data/QA": { id: "data/QA", key: "QA", name: "QATAR", upper: "AC" },
+ "data/RE": {
+ fmt: "%O%n%N%n%A%n%Z %C %X",
+ id: "data/RE",
+ key: "RE",
+ name: "REUNION",
+ posturl:
+ "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal",
+ require: "ACZ",
+ upper: "ACX",
+ zip: "9[78]4\\d{2}",
+ zipex: "97400",
+ },
+ "data/RO": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/RO",
+ key: "RO",
+ name: "ROMANIA",
+ posturl: "http://www.posta-romana.ro/zip_codes",
+ upper: "AC",
+ zip: "\\d{6}",
+ zipex: "060274,061357,200716",
+ },
+ "data/RS": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/RS",
+ key: "RS",
+ name: "REPUBLIC OF SERBIA",
+ posturl:
+ "http://www.posta.rs/struktura/lat/aplikacije/pronadji/nadji-postu.asp",
+ zip: "\\d{5,6}",
+ zipex: "106314",
+ },
+ "data/RU": {
+ fmt: "%N%n%O%n%A%n%C%n%S%n%Z",
+ id: "data/RU",
+ key: "RU",
+ lang: "ru",
+ languages: "ru",
+ lfmt: "%N%n%O%n%A%n%C%n%S%n%Z",
+ name: "RUSSIAN FEDERATION",
+ posturl: "http://info.russianpost.ru/servlet/department",
+ require: "ACSZ",
+ state_name_type: "oblast",
+ sub_isoids:
+ "ALT~AMU~ARK~AST~BEL~BRY~VLA~VGG~VLG~VOR~YEV~ZAB~IVA~IRK~KB~KGD~KLU~KAM~KC~KEM~KIR~KOS~KDA~KYA~KGN~KRS~LEN~LIP~MAG~MOW~MOS~MUR~NEN~NIZ~NGR~NVS~OMS~ORE~ORL~PNZ~PER~PRI~PSK~AD~AL~BA~BU~DA~IN~KL~KR~KO~~ME~MO~SA~SE~TA~TY~UD~KK~ROS~RYA~SAM~SPE~SAR~SAK~SVE~~SMO~STA~TAM~TVE~TOM~TUL~TYU~ULY~KHA~KHM~CHE~CE~CU~CHU~YAN~YAR",
+ sub_keys:
+ "Алтайский край~Амурская область~Архангельская область~Астраханская область~Белгородская область~Брянская область~Владимирская область~Волгоградская область~Вологодская область~Воронежская область~Еврейская автономная область~Забайкальский край~Ивановская область~Иркутская область~Кабардино-Балкарская Республика~Калининградская область~Калужская область~Камчатский край~Карачаево-Черкесская Республика~Кемеровская область~Кировская область~Костромская область~Краснодарский край~Красноярский край~Курганская область~Курская область~Ленинградская область~Липецкая область~Магаданская область~Москва~Московская область~Мурманская область~Ненецкий автономный округ~Нижегородская область~Новгородская область~Новосибирская область~Омская область~Оренбургская область~Орловская область~Пензенская область~Пермский край~Приморский край~Псковская область~Республика Адыгея~Республика Алтай~Республика Башкортостан~Республика Бурятия~Республика Дагестан~Республика Ингушетия~Республика Калмыкия~Республика Карелия~Республика Коми~Автономна Республіка Крим~Республика Марий Эл~Республика Мордовия~Республика Саха (Якутия)~Республика Северная Осетия-Алания~Республика Татарстан~Республика Тыва~Республика Удмуртия~Республика Хакасия~Ростовская область~Рязанская область~Самарская область~Санкт-Петербург~Саратовская область~Сахалинская область~Свердловская область~Севастополь~Смоленская область~Ставропольский край~Тамбовская область~Тверская область~Томская область~Тульская область~Тюменская область~Ульяновская область~Хабаровский край~Ханты-Мансийский автономный округ~Челябинская область~Чеченская Республика~Чувашская Республика~Чукотский автономный округ~Ямало-Ненецкий автономный округ~Ярославская область",
+ sub_lnames:
+ "Altayskiy kray~Amurskaya oblast'~Arkhangelskaya oblast'~Astrakhanskaya oblast'~Belgorodskaya oblast'~Bryanskaya oblast'~Vladimirskaya oblast'~Volgogradskaya oblast'~Vologodskaya oblast'~Voronezhskaya oblast'~Evreyskaya avtonomnaya oblast'~Zabaykalskiy kray~Ivanovskaya oblast'~Irkutskaya oblast'~Kabardino-Balkarskaya Republits~Kaliningradskaya oblast'~Kaluzhskaya oblast'~Kamchatskiy kray~Karachaevo-Cherkesskaya Republits~Kemerovskaya oblast'~Kirovskaya oblast'~Kostromskaya oblast'~Krasnodarskiy kray~Krasnoyarskiy kray~Kurganskaya oblast'~Kurskaya oblast'~Leningradskaya oblast'~Lipetskaya oblast'~Magadanskaya oblast'~Moskva~Moskovskaya oblast'~Murmanskaya oblast'~Nenetskiy~Nizhegorodskaya oblast'~Novgorodskaya oblast'~Novosibirskaya oblast'~Omskaya oblast'~Orenburgskaya oblast'~Orlovskaya oblast'~Penzenskaya oblast'~Permskiy kray~Primorskiy kray~Pskovskaya oblast'~Respublika Adygeya~Altay Republits~Bashkortostan Republits~Buryatiya Republits~Dagestan Republits~Ingushetiya Republits~Respublika Kalmykiya~Kareliya Republits~Komi Republits~Respublika Krym~Respublika Mariy El~Respublika Mordoviya~Sakha (Yakutiya) Republits~Respublika Severnaya Osetiya-Alaniya~Respublika Tatarstan~Tyva Republits~Respublika Udmurtiya~Khakasiya Republits~Rostovskaya oblast'~Ryazanskaya oblast'~Samarskaya oblast'~Sankt-Peterburg~Saratovskaya oblast'~Sakhalinskaya oblast'~Sverdlovskaya oblast'~Sevastopol'~Smolenskaya oblast'~Stavropolskiy kray~Tambovskaya oblast'~Tverskaya oblast'~Tomskaya oblast'~Tulskaya oblast'~Tyumenskaya oblast'~Ulyanovskaya oblast'~Khabarovskiy kray~Khanty-Mansiyskiy avtonomnyy okrug~Chelyabinskaya oblast'~Chechenskaya Republits~Chuvashia~Chukotskiy~Yamalo-Nenetskiy~Yaroslavskaya oblast'",
+ sub_names:
+ "Алтайский край~Амурская область~Архангельская область~Астраханская область~Белгородская область~Брянская область~Владимирская область~Волгоградская область~Вологодская область~Воронежская область~Еврейская автономная область~Забайкальский край~Ивановская область~Иркутская область~Кабардино-Балкарская Республика~Калининградская область~Калужская область~Камчатский край~Карачаево-Черкесская Республика~Кемеровская область~Кировская область~Костромская область~Краснодарский край~Красноярский край~Курганская область~Курская область~Ленинградская область~Липецкая область~Магаданская область~Москва~Московская область~Мурманская область~Ненецкий автономный округ~Нижегородская область~Новгородская область~Новосибирская область~Омская область~Оренбургская область~Орловская область~Пензенская область~Пермский край~Приморский край~Псковская область~Республика Адыгея~Республика Алтай~Республика Башкортостан~Республика Бурятия~Республика Дагестан~Республика Ингушетия~Республика Калмыкия~Республика Карелия~Республика Коми~Республика Крым~Республика Марий Эл~Республика Мордовия~Республика Саха (Якутия)~Республика Северная Осетия-Алания~Республика Татарстан~Республика Тыва~Республика Удмуртия~Республика Хакасия~Ростовская область~Рязанская область~Самарская область~Санкт-Петербург~Саратовская область~Сахалинская область~Свердловская область~Севастополь~Смоленская область~Ставропольский край~Тамбовская область~Тверская область~Томская область~Тульская область~Тюменская область~Ульяновская область~Хабаровский край~Ханты-Мансийский автономный округ~Челябинская область~Чеченская Республика~Чувашская Республика~Чукотский автономный округ~Ямало-Ненецкий автономный округ~Ярославская область",
+ sub_zips:
+ "65[6-9]~67[56]~16[3-5]~41[4-6]~30[89]~24[1-3]~60[0-2]~40[0-4]~16[0-2]~39[4-7]~679~6(?:7[2-4]|87)~15[3-5]~66[4-9]~36[01]~23[6-8]~24[89]~68[348]~369~65[0-4]~61[0-3]~15[67]~35[0-4]~6(?:6[0-3]|4[78])~64[01]~30[5-7]~18[78]~39[89]~68[56]~1(?:0[1-9]|1|2|3[0-5]|4[0-4])~14[0-4]~18[34]~166~60[3-7]~17[3-5]~63[0-3]~64[4-6]~46[0-2]~30[23]~44[0-2]~61[4-9]~69[0-2]~18[0-2]~385~649~45[0-3]~67[01]~36[78]~386~35[89]~18[56]~16[7-9]~29[5-8]~42[45]~43[01]~67[78]~36[23]~42[0-3]~66[78]~42[67]~655~34[4-7]~39[01]~44[3-6]~19~41[0-3]~69[34]~62[0-4]~299~21[4-6]~35[5-7]~39[23]~17[0-2]~63[4-6]~30[01]~62[5-7]~43[23]~68[0-2]~628~45[4-7]~36[4-6]~42[89]~689~629~15[0-2]",
+ upper: "AC",
+ zip: "\\d{6}",
+ zipex: "247112,103375,188300",
+ },
+ "data/RW": { id: "data/RW", key: "RW", name: "RWANDA", upper: "AC" },
+ "data/SA": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/SA",
+ key: "SA",
+ name: "SAUDI ARABIA",
+ zip: "\\d{5}",
+ zipex: "11564,11187,11142",
+ },
+ "data/SB": { id: "data/SB", key: "SB", name: "SOLOMON ISLANDS" },
+ "data/SC": {
+ fmt: "%N%n%O%n%A%n%C%n%S",
+ id: "data/SC",
+ key: "SC",
+ name: "SEYCHELLES",
+ state_name_type: "island",
+ upper: "S",
+ },
+ "data/SD": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/SD",
+ key: "SD",
+ locality_name_type: "district",
+ name: "SUDAN",
+ zip: "\\d{5}",
+ zipex: "11042,11113",
+ },
+ "data/SE": {
+ fmt: "%O%n%N%n%A%nSE-%Z %C",
+ id: "data/SE",
+ key: "SE",
+ locality_name_type: "post_town",
+ name: "SWEDEN",
+ postprefix: "SE-",
+ posturl:
+ "http://www.posten.se/sv/Kundservice/Sidor/Sok-postnummer-resultat.aspx",
+ require: "ACZ",
+ zip: "\\d{3} ?\\d{2}",
+ zipex: "11455,12345,10500",
+ },
+ "data/SG": {
+ fmt: "%N%n%O%n%A%nSINGAPORE %Z",
+ id: "data/SG",
+ key: "SG",
+ name: "REP. OF SINGAPORE",
+ posturl: "https://www.singpost.com/find-postal-code",
+ require: "AZ",
+ zip: "\\d{6}",
+ zipex: "546080,308125,408600",
+ },
+ "data/SH": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/SH",
+ key: "SH",
+ name: "SAINT HELENA",
+ require: "ACZ",
+ upper: "CZ",
+ zip: "(?:ASCN|STHL) 1ZZ",
+ zipex: "STHL 1ZZ",
+ },
+ "data/SI": {
+ fmt: "%N%n%O%n%A%nSI-%Z %C",
+ id: "data/SI",
+ key: "SI",
+ name: "SLOVENIA",
+ postprefix: "SI-",
+ zip: "\\d{4}",
+ zipex: "4000,1001,2500",
+ },
+ "data/SK": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/SK",
+ key: "SK",
+ name: "SLOVAKIA",
+ posturl: "http://psc.posta.sk",
+ require: "ACZ",
+ zip: "\\d{3} ?\\d{2}",
+ zipex: "010 01,023 14,972 48,921 01,975 99",
+ },
+ "data/SL": { id: "data/SL", key: "SL", name: "SIERRA LEONE" },
+ "data/SM": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/SM",
+ key: "SM",
+ name: "SAN MARINO",
+ posturl: "http://www.poste.it/online/cercacap/",
+ require: "AZ",
+ zip: "4789\\d",
+ zipex: "47890,47891,47895,47899",
+ },
+ "data/SN": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/SN",
+ key: "SN",
+ name: "SENEGAL",
+ zip: "\\d{5}",
+ zipex: "12500,46024,16556,10000",
+ },
+ "data/SO": {
+ fmt: "%N%n%O%n%A%n%C, %S %Z",
+ id: "data/SO",
+ key: "SO",
+ lang: "so",
+ languages: "so",
+ name: "SOMALIA",
+ require: "ACS",
+ sub_isoids: "AW~BK~BN~BR~BY~GA~GE~HI~JD~JH~MU~NU~SA~SD~SH~SO~TO~WO",
+ sub_keys: "AD~BK~BN~BR~BY~GG~GD~HR~JD~JH~MD~NG~SG~SD~SH~SL~TG~WG",
+ sub_names:
+ "Awdal~Bakool~Banaadir~Bari~Bay~Galguduud~Gedo~Hiiraan~Jubbada Dhexe~Jubbada Hoose~Mudug~Nugaal~Sanaag~Shabeellaha Dhexe~Shabeellaha Hoose~Sool~Togdheer~Woqooyi Galbeed",
+ upper: "ACS",
+ zip: "[A-Z]{2} ?\\d{5}",
+ zipex: "JH 09010,AD 11010",
+ },
+ "data/SR": {
+ fmt: "%N%n%O%n%A%n%C%n%S",
+ id: "data/SR",
+ key: "SR",
+ lang: "nl",
+ languages: "nl",
+ name: "SURINAME",
+ sub_isoids: "BR~CM~CR~MA~NI~PR~PM~SA~SI~WA",
+ sub_keys:
+ "Brokopondo~Commewijne~Coronie~Marowijne~Nickerie~Para~Paramaribo~Saramacca~Sipaliwini~Wanica",
+ upper: "AS",
+ },
+ "data/SS": { id: "data/SS", key: "SS", name: "SOUTH SUDAN" },
+ "data/ST": { id: "data/ST", key: "ST", name: "SAO TOME AND PRINCIPE" },
+ "data/SV": {
+ fmt: "%N%n%O%n%A%n%Z-%C%n%S",
+ id: "data/SV",
+ key: "SV",
+ lang: "es",
+ languages: "es",
+ name: "EL SALVADOR",
+ require: "ACS",
+ sub_isoids: "AH~CA~CH~CU~LI~PA~UN~MO~SM~SS~SV~SA~SO~US",
+ sub_keys:
+ "Ahuachapan~Cabanas~Calatenango~Cuscatlan~La Libertad~La Paz~La Union~Morazan~San Miguel~San Salvador~San Vicente~Santa Ana~Sonsonate~Usulutan",
+ sub_names:
+ "Ahuachapán~Cabañas~Chalatenango~Cuscatlán~La Libertad~La Paz~La Unión~Morazán~San Miguel~San Salvador~San Vicente~Santa Ana~Sonsonate~Usulután",
+ sub_zipexs:
+ "CP 2101~CP 1201~CP 1301~CP 1401~CP 1501~CP 1601~CP 3101~CP 3201~CP 3301~CP 1101~CP 1701~CP 2201~CP 2301~CP 3401",
+ sub_zips:
+ "CP 21~CP 12~CP 13~CP 14~CP 15~CP 16~CP 31~CP 32~CP 33~CP 11~CP 17~CP 22~CP 23~CP 34",
+ upper: "CSZ",
+ zip: "CP [1-3][1-7][0-2]\\d",
+ zipex: "CP 1101",
+ },
+ "data/SX": { id: "data/SX", key: "SX", name: "SINT MAARTEN" },
+ "data/SY": {
+ id: "data/SY",
+ key: "SY",
+ locality_name_type: "district",
+ name: "SYRIA",
+ },
+ "data/SZ": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/SZ",
+ key: "SZ",
+ name: "SWAZILAND",
+ posturl: "http://www.sptc.co.sz/swazipost/codes/index.php",
+ upper: "ACZ",
+ zip: "[HLMS]\\d{3}",
+ zipex: "H100",
+ },
+ "data/TC": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/TC",
+ key: "TC",
+ name: "TURKS AND CAICOS ISLANDS",
+ require: "ACZ",
+ upper: "CZ",
+ zip: "TKCA 1ZZ",
+ zipex: "TKCA 1ZZ",
+ },
+ "data/TD": { id: "data/TD", key: "TD", name: "CHAD" },
+ "data/TF": { id: "data/TF", key: "TF", name: "FRENCH SOUTHERN TERRITORIES" },
+ "data/TG": { id: "data/TG", key: "TG", name: "TOGO" },
+ "data/TH": {
+ fmt: "%N%n%O%n%A%n%D %C%n%S %Z",
+ id: "data/TH",
+ key: "TH",
+ lang: "th",
+ languages: "th",
+ lfmt: "%N%n%O%n%A%n%D, %C%n%S %Z",
+ name: "THAILAND",
+ sub_isoids:
+ "81~10~71~46~62~40~38~22~24~20~18~36~86~57~50~92~23~63~26~73~48~30~80~60~12~96~55~31~13~77~25~94~14~56~82~93~66~65~76~67~54~83~44~49~58~35~95~45~85~21~70~16~52~51~42~33~47~90~91~11~75~74~27~19~17~64~72~84~32~43~39~15~37~41~53~61~34",
+ sub_keys:
+ "กระบี่~กรุงเทพมหานคร~กาญจนบุรี~กาฬสินธุ์~กำแพงเพชร~ขอนแก่น~จังหวัด บึงกาฬ~จันทบุรี~ฉะเชิงเทรา~ชลบุรี~ชัยนาท~ชัยภูมิ~ชุมพร~เชียงราย~เชียงใหม่~ตรัง~ตราด~ตาก~นครนายก~นครปฐม~นครพนม~นครราชสีมา~นครศรีธรรมราช~นครสวรรค์~นนทบุรี~นราธิวาส~น่าน~บุรีรัมย์~ปทุมธานี~ประจวบคีรีขันธ์~ปราจีนบุรี~ปัตตานี~พระนครศรีอยุธยา~พะเยา~พังงา~พัทลุง~พิจิตร~พิษณุโลก~เพชรบุรี~เพชรบูรณ์~แพร่~ภูเก็ต~มหาสารคาม~มุกดาหาร~แม่ฮ่องสอน~ยโสธร~ยะลา~ร้อยเอ็ด~ระนอง~ระยอง~ราชบุรี~ลพบุรี~ลำปาง~ลำพูน~เลย~ศรีสะเกษ~สกลนคร~สงขลา~สตูล~สมุทรปราการ~สมุทรสงคราม~สมุทรสาคร~สระแก้ว~สระบุรี~สิงห์บุรี~สุโขทัย~สุพรรณบุรี~สุราษฎร์ธานี~สุรินทร์~หนองคาย~หนองบัวลำภู~อ่างทอง~อำนาจเจริญ~อุดรธานี~อุตรดิตถ์~อุทัยธานี~อุบลราชธานี",
+ sub_lnames:
+ "Krabi~Bangkok~Kanchanaburi~Kalasin~Kamphaeng Phet~Khon Kaen~Bueng Kan~Chanthaburi~Chachoengsao~Chon Buri~Chai Nat~Chaiyaphum~Chumpon~Chiang Rai~Chiang Mai~Trang~Trat~Tak~Nakhon Nayok~Nakhon Pathom~Nakhon Phanom~Nakhon Ratchasima~Nakhon Si Thammarat~Nakhon Sawan~Nonthaburi~Narathiwat~Nan~Buri Ram~Pathum Thani~Prachuap Khiri Khan~Prachin Buri~Pattani~Phra Nakhon Si Ayutthaya~Phayao~Phang Nga~Phattalung~Phichit~Phitsanulok~Phetchaburi~Phetchabun~Phrae~Phuket~Maha Sarakham~Mukdahan~Mae Hong Son~Yasothon~Yala~Roi Et~Ranong~Rayong~Ratchaburi~Lop Buri~Lampang~Lamphun~Loei~Si Sa Ket~Sakon Nakhon~Songkhla~Satun~Samut Prakan~Samut Songkhram~Samut Sakhon~Sa Kaeo~Saraburi~Sing Buri~Sukhothai~Suphanburi~Surat Thani~Surin~Nong Khai~Nong Bua Lam Phu~Ang Thong~Amnat Charoen~Udon Thani~Uttaradit~Uthai Thani~Ubon Ratchathani",
+ sub_zips:
+ "81~10~71~46~62~40~~22~24~20~17~36~86~57~50~92~23~63~26~73~48~30~80~60~11~96~55~31~12~77~25~94~13~56~82~93~66~65~76~67~54~83~44~49~58~35~95~45~85~21~70~15~52~51~42~33~47~90~91~10~75~74~27~18~16~64~72~84~32~43~39~14~37~41~53~61~34",
+ upper: "S",
+ zip: "\\d{5}",
+ zipex: "10150,10210",
+ },
+ "data/TJ": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/TJ",
+ key: "TJ",
+ name: "TAJIKISTAN",
+ zip: "\\d{6}",
+ zipex: "735450,734025",
+ },
+ "data/TK": { id: "data/TK", key: "TK", name: "TOKELAU" },
+ "data/TL": { id: "data/TL", key: "TL", name: "TIMOR-LESTE" },
+ "data/TM": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/TM",
+ key: "TM",
+ name: "TURKMENISTAN",
+ zip: "\\d{6}",
+ zipex: "744000",
+ },
+ "data/TN": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/TN",
+ key: "TN",
+ name: "TUNISIA",
+ posturl: "http://www.poste.tn/codes.php",
+ zip: "\\d{4}",
+ zipex: "1002,8129,3100,1030",
+ },
+ "data/TO": { id: "data/TO", key: "TO", name: "TONGA" },
+ "data/TR": {
+ fmt: "%N%n%O%n%A%n%Z %C/%S",
+ id: "data/TR",
+ key: "TR",
+ lang: "tr",
+ languages: "tr",
+ locality_name_type: "district",
+ name: "TURKEY",
+ posturl: "http://postakodu.ptt.gov.tr/",
+ require: "ACZ",
+ sub_isoids:
+ "01~02~03~04~68~05~06~07~75~08~09~10~74~72~69~11~12~13~14~15~16~17~18~19~20~21~81~22~23~24~25~26~27~28~29~30~31~76~32~34~35~46~78~70~36~37~38~71~39~40~79~41~42~43~44~45~47~33~48~49~50~51~52~80~53~54~55~56~57~58~63~73~59~60~61~62~64~65~77~66~67",
+ sub_keys:
+ "Adana~Adıyaman~Afyon~Ağrı~Aksaray~Amasya~Ankara~Antalya~Ardahan~Artvin~Aydın~Balıkesir~Bartın~Batman~Bayburt~Bilecik~Bingöl~Bitlis~Bolu~Burdur~Bursa~Çanakkale~Çankırı~Çorum~Denizli~Diyarbakır~Düzce~Edirne~Elazığ~Erzincan~Erzurum~Eskişehir~Gaziantep~Giresun~Gümüşhane~Hakkari~Hatay~Iğdır~Isparta~İstanbul~İzmir~Kahramanmaraş~Karabük~Karaman~Kars~Kastamonu~Kayseri~Kırıkkale~Kırklareli~Kırşehir~Kilis~Kocaeli~Konya~Kütahya~Malatya~Manisa~Mardin~Mersin~Muğla~Muş~Nevşehir~Niğde~Ordu~Osmaniye~Rize~Sakarya~Samsun~Siirt~Sinop~Sivas~Şanlıurfa~Şırnak~Tekirdağ~Tokat~Trabzon~Tunceli~Uşak~Van~Yalova~Yozgat~Zonguldak",
+ sub_zips:
+ "01~02~03~04~68~05~06~07~75~08~09~10~74~72~69~11~12~13~14~15~16~17~18~19~20~21~81~22~23~24~25~26~27~28~29~30~31~76~32~34~35~46~78~70~36~37~38~71~39~40~79~41~42~43~44~45~47~33~48~49~50~51~52~80~53~54~55~56~57~58~63~73~59~60~61~62~64~65~77~66~67",
+ zip: "\\d{5}",
+ zipex: "01960,06101",
+ },
+ "data/TT": { id: "data/TT", key: "TT", name: "TRINIDAD AND TOBAGO" },
+ "data/TV": {
+ fmt: "%N%n%O%n%A%n%C%n%S",
+ id: "data/TV",
+ key: "TV",
+ lang: "tyv",
+ languages: "tyv",
+ name: "TUVALU",
+ state_name_type: "island",
+ sub_isoids: "FUN~NMG~NMA~~NIT~NUI~NKF~NKL~VAI",
+ sub_keys:
+ "Funafuti~Nanumanga~Nanumea~Niulakita~Niutao~Nui~Nukufetau~Nukulaelae~Vaitupu",
+ upper: "ACS",
+ },
+ "data/TW": {
+ fmt: "%Z%n%S%C%n%A%n%O%n%N",
+ id: "data/TW",
+ key: "TW",
+ lang: "zh-Hant",
+ languages: "zh-Hant",
+ lfmt: "%N%n%O%n%A%n%C, %S %Z",
+ name: "TAIWAN",
+ posturl:
+ "http://www.post.gov.tw/post/internet/f_searchzone/index.jsp?ID=190102",
+ require: "ACSZ",
+ state_name_type: "county",
+ sub_isoids:
+ "TXG~TPE~TTT~TNN~ILA~HUA~~NAN~PIF~MIA~TAO~KHH~KEE~~YUN~NWT~HSZ~HSQ~CYI~CYQ~CHA~PEN",
+ sub_keys:
+ "台中市~台北市~台東縣~台南市~宜蘭縣~花蓮縣~金門縣~南投縣~屏東縣~苗栗縣~桃園市~高雄市~基隆市~連江縣~雲林縣~新北市~新竹市~新竹縣~嘉義市~嘉義縣~彰化縣~澎湖縣",
+ sub_lnames:
+ "Taichung City~Taipei City~Taitung County~Tainan City~Yilan County~Hualien County~Kinmen County~Nantou County~Pingtung County~Miaoli County~Taoyuan City~Kaohsiung City~Keelung City~Lienchiang County~Yunlin County~New Taipei City~Hsinchu City~Hsinchu County~Chiayi City~Chiayi County~Changhua County~Penghu County",
+ sub_mores:
+ "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true",
+ sub_zipexs:
+ "400,408,411,439~100,119~950,966~700,745~260,272~970,983~890,896~540,558~900,947~350,369~320,338~800,815,817,852~200,206~209,212~630,655~207,208,220,253~~302,315~~602,625~500,530~880,885",
+ sub_zips:
+ "4[0-3]~1[01]~9[56]~7[0-4]~2[67]~9[78]~89~5[45]~9[0-4]~3[56]~3[23]~8[02-5]|81[1-579]~20[0-6]~209|21[012]~6[3-5]~20[78]|2[2345]~300~30[2-8]|31~600~60[1-9]|6[12]~5[0123]~88",
+ zip: "\\d{3}(?:\\d{2})?",
+ zipex: "104,106,10603,40867",
+ },
+ "data/TZ": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/TZ",
+ key: "TZ",
+ name: "TANZANIA (UNITED REP.)",
+ zip: "\\d{4,5}",
+ zipex: "6090,34413",
+ },
+ "data/UA": {
+ fmt: "%N%n%O%n%A%n%C%n%S%n%Z",
+ id: "data/UA",
+ key: "UA",
+ lang: "uk",
+ languages: "uk",
+ lfmt: "%N%n%O%n%A%n%C%n%S%n%Z",
+ name: "UKRAINE",
+ posturl: "http://services.ukrposhta.com/postindex_new/",
+ require: "ACSZ",
+ state_name_type: "oblast",
+ sub_isoids:
+ "43~05~07~12~14~18~21~23~26~30~32~35~09~46~48~51~53~56~40~59~61~63~65~68~71~77~74",
+ sub_keys:
+ "Автономна Республіка Крим~Вінницька область~Волинська область~Дніпропетровська область~Донецька область~Житомирська область~Закарпатська область~Запорізька область~Івано-Франківська область~місто Київ~Київська область~Кіровоградська область~Луганська область~Львівська область~Миколаївська область~Одеська область~Полтавська область~Рівненська область~місто Севастополь~Сумська область~Тернопільська область~Харківська область~Херсонська область~Хмельницька область~Черкаська область~Чернівецька область~Чернігівська область",
+ sub_lnames:
+ "Crimea~Vinnyts'ka oblast~Volyns'ka oblast~Dnipropetrovsk oblast~Donetsk oblast~Zhytomyrs'ka oblast~Zakarpats'ka oblast~Zaporiz'ka oblast~Ivano-Frankivs'ka oblast~Kyiv city~Kiev oblast~Kirovohrads'ka oblast~Luhans'ka oblast~Lviv oblast~Mykolaivs'ka oblast~Odessa oblast~Poltavs'ka oblast~Rivnens'ka oblast~Sevastopol' city~Sums'ka oblast~Ternopil's'ka oblast~Kharkiv oblast~Khersons'ka oblast~Khmel'nyts'ka oblast~Cherkas'ka oblast~Chernivets'ka oblast~Chernihivs'ka oblast",
+ sub_names:
+ "Автономна Республіка Крим~Вінницька область~Волинська область~Дніпропетровська область~Донецька область~Житомирська область~Закарпатська область~Запорізька область~Івано-Франківська область~Київ~Київська область~Кіровоградська область~Луганська область~Львівська область~Миколаївська область~Одеська область~Полтавська область~Рівненська область~Севастополь~Сумська область~Тернопільська область~Харківська область~Херсонська область~Хмельницька область~Черкаська область~Чернівецька область~Чернігівська область",
+ sub_zips:
+ "9[5-8]~2[1-4]~4[3-5]~49|5[0-3]~8[3-7]~1[0-3]~8[89]|90~69|7[0-2]~7[6-8]~0[1-6]~0[7-9]~2[5-8]~9[1-4]~79|8[0-2]~5[4-7]~6[5-8]~3[6-9]~3[3-5]~99~4[0-2]~4[6-8]~6[1-4]~7[3-5]~29|3[0-2]~1[89]|20~5[89]|60~1[4-7]",
+ zip: "\\d{5}",
+ zipex: "15432,01055,01001",
+ },
+ "data/UG": { id: "data/UG", key: "UG", name: "UGANDA" },
+ "data/US": {
+ fmt: "%N%n%O%n%A%n%C, %S %Z",
+ id: "data/US",
+ key: "US",
+ lang: "en",
+ languages: "en",
+ name: "UNITED STATES",
+ posturl: "https://tools.usps.com/go/ZipLookupAction!input.action",
+ require: "ACSZ",
+ state_name_type: "state",
+ sub_isoids:
+ "AL~AK~~AZ~AR~~~~CA~CO~CT~DE~DC~FL~GA~~HI~ID~IL~IN~IA~KS~KY~LA~ME~~MD~MA~MI~~MN~MS~MO~MT~NE~NV~NH~NJ~NM~NY~NC~ND~~OH~OK~OR~~PA~~RI~SC~SD~TN~TX~UT~VT~~VA~WA~WV~WI~WY",
+ sub_keys:
+ "AL~AK~AS~AZ~AR~AA~AE~AP~CA~CO~CT~DE~DC~FL~GA~GU~HI~ID~IL~IN~IA~KS~KY~LA~ME~MH~MD~MA~MI~FM~MN~MS~MO~MT~NE~NV~NH~NJ~NM~NY~NC~ND~MP~OH~OK~OR~PW~PA~PR~RI~SC~SD~TN~TX~UT~VT~VI~VA~WA~WV~WI~WY",
+ sub_names:
+ "Alabama~Alaska~American Samoa~Arizona~Arkansas~Armed Forces (AA)~Armed Forces (AE)~Armed Forces (AP)~California~Colorado~Connecticut~Delaware~District of Columbia~Florida~Georgia~Guam~Hawaii~Idaho~Illinois~Indiana~Iowa~Kansas~Kentucky~Louisiana~Maine~Marshall Islands~Maryland~Massachusetts~Michigan~Micronesia~Minnesota~Mississippi~Missouri~Montana~Nebraska~Nevada~New Hampshire~New Jersey~New Mexico~New York~North Carolina~North Dakota~Northern Mariana Islands~Ohio~Oklahoma~Oregon~Palau~Pennsylvania~Puerto Rico~Rhode Island~South Carolina~South Dakota~Tennessee~Texas~Utah~Vermont~Virgin Islands~Virginia~Washington~West Virginia~Wisconsin~Wyoming",
+ sub_zipexs:
+ "35000,36999~99500,99999~96799~85000,86999~71600,72999~34000,34099~09000,09999~96200,96699~90000,96199~80000,81999~06000,06999~19700,19999~20000,56999~32000,34999~30000,39901~96910,96932~96700,96899~83200,83999~60000,62999~46000,47999~50000,52999~66000,67999~40000,42799~70000,71599~03900,04999~96960,96979~20600,21999~01000,05544~48000,49999~96941,96944~55000,56799~38600,39799~63000,65999~59000,59999~68000,69999~88900,89999~03000,03899~07000,08999~87000,88499~10000,00544~27000,28999~58000,58999~96950,96952~43000,45999~73000,74999~97000,97999~96940~15000,19699~00600,00999~02800,02999~29000,29999~57000,57999~37000,38599~75000,73344~84000,84999~05000,05999~00800,00899~20100,24699~98000,99499~24700,26999~53000,54999~82000,83414",
+ sub_zips:
+ "3[56]~99[5-9]~96799~8[56]~71[6-9]|72~340~09~96[2-6]~9[0-5]|96[01]~8[01]~06~19[7-9]~20[02-5]|569~3[23]|34[1-9]~3[01]|398|39901~969([1-2]\\d|3[12])~967[0-8]|9679[0-8]|968~83[2-9]~6[0-2]~4[67]~5[0-2]~6[67]~4[01]|42[0-7]~70|71[0-5]~039|04~969[67]~20[6-9]|21~01|02[0-7]|05501|05544~4[89]~9694[1-4]~55|56[0-7]~38[6-9]|39[0-7]~6[3-5]~59~6[89]~889|89~03[0-8]~0[78]~87|88[0-4]~1[0-4]|06390|00501|00544~2[78]~58~9695[0-2]~4[3-5]~7[34]~97~969(39|40)~1[5-8]|19[0-6]~00[679]~02[89]~29~57~37|38[0-5]~7[5-9]|885|73301|73344~84~05~008~201|2[23]|24[0-6]~98|99[0-4]~24[7-9]|2[56]~5[34]~82|83[01]|83414",
+ upper: "CS",
+ zip: "(\\d{5})(?:[ \\-](\\d{4}))?",
+ zip_name_type: "zip",
+ zipex: "95014,22162-1010",
+ },
+ "data/UY": {
+ fmt: "%N%n%O%n%A%n%Z %C %S",
+ id: "data/UY",
+ key: "UY",
+ lang: "es",
+ languages: "es",
+ name: "URUGUAY",
+ posturl:
+ "http://www.correo.com.uy/index.asp?codPag=codPost&switchMapa=codPost",
+ sub_isoids: "AR~CA~CL~CO~DU~FS~FD~LA~MA~MO~PA~RN~RV~RO~SA~SJ~SO~TA~TT",
+ sub_keys:
+ "Artigas~Canelones~Cerro Largo~Colonia~Durazno~Flores~Florida~Lavalleja~Maldonado~Montevideo~Paysandú~Río Negro~Rivera~Rocha~Salto~San José~Soriano~Tacuarembó~Treinta y Tres",
+ sub_zips:
+ "55~9[01]|1[456]~37~70|75204~97~85~94|9060|97005~30~20~1|91600~60~65|60002~40~27~50~80~75|70003~45~33|30203|30204|30302|37007",
+ upper: "CS",
+ zip: "\\d{5}",
+ zipex: "11600",
+ },
+ "data/UZ": {
+ fmt: "%N%n%O%n%A%n%Z %C%n%S",
+ id: "data/UZ",
+ key: "UZ",
+ name: "UZBEKISTAN",
+ posturl: "http://www.pochta.uz/ru/uslugi/indexsearch.html",
+ upper: "CS",
+ zip: "\\d{6}",
+ zipex: "702100,700000",
+ },
+ "data/VA": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/VA",
+ key: "VA",
+ name: "VATICAN",
+ zip: "00120",
+ zipex: "00120",
+ },
+ "data/VC": {
+ fmt: "%N%n%O%n%A%n%C %Z",
+ id: "data/VC",
+ key: "VC",
+ name: "SAINT VINCENT AND THE GRENADINES (ANTILLES)",
+ posturl:
+ "http://www.svgpost.gov.vc/?option=com_content&view=article&id=3&Itemid=16",
+ zip: "VC\\d{4}",
+ zipex: "VC0100,VC0110,VC0400",
+ },
+ "data/VE": {
+ fmt: "%N%n%O%n%A%n%C %Z, %S",
+ id: "data/VE",
+ key: "VE",
+ lang: "es",
+ languages: "es",
+ name: "VENEZUELA",
+ posturl: "http://www.ipostel.gob.ve/index.php/oficinas-postales",
+ require: "ACS",
+ state_name_type: "state",
+ sub_isoids: "Z~B~C~D~E~F~G~H~Y~W~A~I~J~K~L~M~N~O~P~R~S~T~X~U~V",
+ sub_keys:
+ "Amazonas~Anzoátegui~Apure~Aragua~Barinas~Bolívar~Carabobo~Cojedes~Delta Amacuro~Dependencias Federales~Distrito Federal~Falcón~Guárico~Lara~Mérida~Miranda~Monagas~Nueva Esparta~Portuguesa~Sucre~Táchira~Trujillo~Vargas~Yaracuy~Zulia",
+ upper: "CS",
+ zip: "\\d{4}",
+ zipex: "1010,3001,8011,1020",
+ },
+ "data/VG": {
+ fmt: "%N%n%O%n%A%n%C%n%Z",
+ id: "data/VG",
+ key: "VG",
+ name: "VIRGIN ISLANDS (BRITISH)",
+ require: "A",
+ zip: "VG\\d{4}",
+ zipex: "VG1110,VG1150,VG1160",
+ },
+ "data/VI": {
+ fmt: "%N%n%O%n%A%n%C %S %Z",
+ id: "data/VI",
+ key: "VI",
+ name: "VIRGIN ISLANDS (U.S.)",
+ posturl: "http://zip4.usps.com/zip4/welcome.jsp",
+ require: "ACSZ",
+ state_name_type: "state",
+ upper: "ACNOS",
+ zip: "(008(?:(?:[0-4]\\d)|(?:5[01])))(?:[ \\-](\\d{4}))?",
+ zip_name_type: "zip",
+ zipex: "00802-1222,00850-9802",
+ },
+ "data/VN": {
+ fmt: "%N%n%O%n%A%n%C%n%S %Z",
+ id: "data/VN",
+ key: "VN",
+ lang: "vi",
+ languages: "vi",
+ lfmt: "%N%n%O%n%A%n%C%n%S %Z",
+ name: "VIET NAM",
+ posturl: "http://postcode.vnpost.vn/services/search.aspx",
+ sub_isoids:
+ "44~43~55~54~53~56~50~57~31~58~40~59~04~CT~DN~33~72~71~39~45~30~03~63~HN~23~61~HP~73~14~66~34~47~28~01~09~02~35~41~67~22~18~36~68~32~24~27~29~13~25~52~05~37~20~69~21~SG~26~46~51~07~49~70~06",
+ sub_keys:
+ "An Giang~Bà Rịa–Vũng Tàu~Bạc Liêu~Bắc Giang~Bắc Kạn~Bắc Ninh~Bến Tre~Bình Dương~Bình Định~Bình Phước~Bình Thuận~Cà Mau~Cao Bằng~Cần Thơ~Đà Nẵng~Đắk Lắk~Đăk Nông~Điện Biên~Đồng Nai~Đồng Tháp~Gia Lai~Hà Giang~Hà Nam~Hà Nội~Hà Tĩnh~Hải Dương~Hải Phòng~Hậu Giang~Hòa Bình~Hưng Yên~Khánh Hòa~Kiên Giang~Kon Tum~Lai Châu~Lạng Sơn~Lào Cai~Lâm Đồng~Long An~Nam Định~Nghệ An~Ninh Bình~Ninh Thuận~Phú Thọ~Phú Yên~Quảng Bình~Quảng Nam~Quảng Ngãi~Quảng Ninh~Quảng Trị~Sóc Trăng~Sơn La~Tây Ninh~Thái Bình~Thái Nguyên~Thanh Hóa~Thành phố Hồ Chí Minh~Thừa Thiên–Huế~Tiền Giang~Trà Vinh~Tuyên Quang~Vĩnh Long~Vĩnh Phúc~Yên Bái",
+ sub_lnames:
+ "An Giang Province~Ba Ria-Vung Tau Province~Bac Lieu Province~Bac Giang Province~Bac Kan Province~Bac Ninh Province~Ben Tre Province~Binh Duong Province~Binh Dinh Province~Binh Phuoc Province~Binh Thuan Province~Ca Mau Province~Cao Bang Province~Can Tho City~Da Nang City~Dak Lak Province~Dak Nong Province~Dien Bien Province~Dong Nai Province~Dong Thap Province~Gia Lai Province~Ha Giang Province~Ha Nam Province~Hanoi City~Ha Tinh Province~Hai Duong Province~Haiphong City~Hau Giang Province~Hoa Binh Province~Hung Yen Province~Khanh Hoa Province~Kien Giang Province~Kon Tum Province~Lai Chau Province~Lang Song Province~Lao Cai Province~Lam Dong Province~Long An Province~Nam Dinh Province~Nghe An Province~Ninh Binh Province~Ninh Thuan Province~Phu Tho Province~Phu Yen Province~Quang Binh Province~Quang Nam Province~Quang Ngai Province~Quang Ninh Province~Quang Tri Province~Soc Trang Province~Son La Province~Tay Ninh Province~Thai Binh Province~Thai Nguyen Province~Thanh Hoa Province~Ho Chi Minh City~Thua Thien-Hue Province~Tien Giang Province~Tra Vinh Province~Tuyen Quang Province~Vinh Long Province~Vinh Phuc Province~Yen Bai Province",
+ zip: "\\d{5}\\d?",
+ zipex: "70010,55999",
+ },
+ "data/VU": { id: "data/VU", key: "VU", name: "VANUATU" },
+ "data/WF": {
+ fmt: "%O%n%N%n%A%n%Z %C %X",
+ id: "data/WF",
+ key: "WF",
+ name: "WALLIS AND FUTUNA ISLANDS",
+ require: "ACZ",
+ upper: "ACX",
+ zip: "986\\d{2}",
+ zipex: "98600",
+ },
+ "data/WS": { id: "data/WS", key: "WS", name: "SAMOA" },
+ "data/XK": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/XK",
+ key: "XK",
+ name: "KOSOVO",
+ zip: "[1-7]\\d{4}",
+ zipex: "10000",
+ },
+ "data/YE": { id: "data/YE", key: "YE", name: "YEMEN" },
+ "data/YT": {
+ fmt: "%O%n%N%n%A%n%Z %C %X",
+ id: "data/YT",
+ key: "YT",
+ name: "MAYOTTE",
+ require: "ACZ",
+ upper: "ACX",
+ zip: "976\\d{2}",
+ zipex: "97600",
+ },
+ "data/ZA": {
+ fmt: "%N%n%O%n%A%n%D%n%C%n%Z",
+ id: "data/ZA",
+ key: "ZA",
+ name: "SOUTH AFRICA",
+ posturl: "https://www.postoffice.co.za/Questions/postalcode.html",
+ require: "ACZ",
+ zip: "\\d{4}",
+ zipex: "0083,1451,0001",
+ },
+ "data/ZM": {
+ fmt: "%N%n%O%n%A%n%Z %C",
+ id: "data/ZM",
+ key: "ZM",
+ name: "ZAMBIA",
+ zip: "\\d{5}",
+ zipex: "50100,50101",
+ },
+ "data/ZW": { id: "data/ZW", key: "ZW", name: "ZIMBABWE" },
+};
+export default AddressMetaData;
diff --git a/toolkit/components/formautofill/shared/AddressMetaDataExtension.sys.mjs b/toolkit/components/formautofill/shared/AddressMetaDataExtension.sys.mjs
new file mode 100644
index 0000000000..da13b66784
--- /dev/null
+++ b/toolkit/components/formautofill/shared/AddressMetaDataExtension.sys.mjs
@@ -0,0 +1,765 @@
+/* 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/. */
+
+export const AddressMetaDataExtension = {
+ "data/AF": {
+ alpha_3_code: "AFG",
+ },
+ "data/AX": {
+ alpha_3_code: "ALA",
+ },
+ "data/AL": {
+ alpha_3_code: "ALB",
+ },
+ "data/DZ": {
+ alpha_3_code: "DZA",
+ },
+ "data/AS": {
+ alpha_3_code: "ASM",
+ },
+ "data/AD": {
+ alpha_3_code: "AND",
+ },
+ "data/AO": {
+ alpha_3_code: "AGO",
+ },
+ "data/AI": {
+ alpha_3_code: "AIA",
+ },
+ "data/AQ": {
+ alpha_3_code: "ATA",
+ },
+ "data/AG": {
+ alpha_3_code: "ATG",
+ },
+ "data/AR": {
+ alpha_3_code: "ARG",
+ },
+ "data/AM": {
+ alpha_3_code: "ARM",
+ },
+ "data/AW": {
+ alpha_3_code: "ABW",
+ },
+ "data/AU": {
+ alpha_3_code: "AUS",
+ },
+ "data/AT": {
+ alpha_3_code: "AUT",
+ },
+ "data/AZ": {
+ alpha_3_code: "AZE",
+ },
+ "data/BS": {
+ alpha_3_code: "BHS",
+ },
+ "data/BH": {
+ alpha_3_code: "BHR",
+ },
+ "data/BD": {
+ alpha_3_code: "BGD",
+ },
+ "data/BB": {
+ alpha_3_code: "BRB",
+ },
+ "data/BY": {
+ alpha_3_code: "BLR",
+ },
+ "data/BE": {
+ alpha_3_code: "BEL",
+ },
+ "data/BZ": {
+ alpha_3_code: "BLZ",
+ },
+ "data/BJ": {
+ alpha_3_code: "BEN",
+ },
+ "data/BM": {
+ alpha_3_code: "BMU",
+ },
+ "data/BT": {
+ alpha_3_code: "BTN",
+ },
+ "data/BO": {
+ alpha_3_code: "BOL",
+ },
+ "data/BQ": {
+ alpha_3_code: "BES",
+ },
+ "data/BA": {
+ alpha_3_code: "BIH",
+ },
+ "data/BW": {
+ alpha_3_code: "BWA",
+ },
+ "data/BV": {
+ alpha_3_code: "BVT",
+ },
+ "data/BR": {
+ alpha_3_code: "BRA",
+ },
+ "data/IO": {
+ alpha_3_code: "IOT",
+ },
+ "data/BN": {
+ alpha_3_code: "BRN",
+ },
+ "data/BG": {
+ alpha_3_code: "BGR",
+ },
+ "data/BF": {
+ alpha_3_code: "BFA",
+ },
+ "data/BI": {
+ alpha_3_code: "BDI",
+ },
+ "data/CV": {
+ alpha_3_code: "CPV",
+ },
+ "data/KH": {
+ alpha_3_code: "KHM",
+ },
+ "data/CM": {
+ alpha_3_code: "CMR",
+ },
+ "data/CA": {
+ alpha_3_code: "CAN",
+ },
+ "data/KY": {
+ alpha_3_code: "CYM",
+ },
+ "data/CF": {
+ alpha_3_code: "CAF",
+ },
+ "data/TD": {
+ alpha_3_code: "TCD",
+ },
+ "data/CL": {
+ alpha_3_code: "CHL",
+ },
+ "data/CN": {
+ alpha_3_code: "CHN",
+ },
+ "data/CX": {
+ alpha_3_code: "CXR",
+ },
+ "data/CC": {
+ alpha_3_code: "CCK",
+ },
+ "data/CO": {
+ alpha_3_code: "COL",
+ },
+ "data/KM": {
+ alpha_3_code: "COM",
+ },
+ "data/CG": {
+ alpha_3_code: "COG",
+ },
+ "data/CD": {
+ alpha_3_code: "COD",
+ },
+ "data/CK": {
+ alpha_3_code: "COK",
+ },
+ "data/CR": {
+ alpha_3_code: "CRI",
+ },
+ "data/CI": {
+ alpha_3_code: "CIV",
+ },
+ "data/HR": {
+ alpha_3_code: "HRV",
+ },
+ "data/CU": {
+ alpha_3_code: "CUB",
+ },
+ "data/CW": {
+ alpha_3_code: "CUW",
+ },
+ "data/CY": {
+ alpha_3_code: "CYP",
+ },
+ "data/CZ": {
+ alpha_3_code: "CZE",
+ },
+ "data/DK": {
+ alpha_3_code: "DNK",
+ },
+ "data/DJ": {
+ alpha_3_code: "DJI",
+ },
+ "data/DM": {
+ alpha_3_code: "DMA",
+ },
+ "data/DO": {
+ alpha_3_code: "DOM",
+ },
+ "data/EC": {
+ alpha_3_code: "ECU",
+ },
+ "data/EG": {
+ alpha_3_code: "EGY",
+ },
+ "data/SV": {
+ alpha_3_code: "SLV",
+ },
+ "data/GQ": {
+ alpha_3_code: "GNQ",
+ },
+ "data/ER": {
+ alpha_3_code: "ERI",
+ },
+ "data/EE": {
+ alpha_3_code: "EST",
+ },
+ "data/SZ": {
+ alpha_3_code: "SWZ",
+ },
+ "data/ET": {
+ alpha_3_code: "ETH",
+ },
+ "data/FK": {
+ alpha_3_code: "FLK",
+ },
+ "data/FO": {
+ alpha_3_code: "FRO",
+ },
+ "data/FJ": {
+ alpha_3_code: "FJI",
+ },
+ "data/FI": {
+ alpha_3_code: "FIN",
+ },
+ "data/FR": {
+ alpha_3_code: "FRA",
+ },
+ "data/GF": {
+ alpha_3_code: "GUF",
+ },
+ "data/PF": {
+ alpha_3_code: "PYF",
+ },
+ "data/TF": {
+ alpha_3_code: "ATF",
+ },
+ "data/GA": {
+ alpha_3_code: "GAB",
+ },
+ "data/GM": {
+ alpha_3_code: "GMB",
+ },
+ "data/GE": {
+ alpha_3_code: "GEO",
+ },
+ "data/DE": {
+ alpha_3_code: "DEU",
+ },
+ "data/GH": {
+ alpha_3_code: "GHA",
+ },
+ "data/GI": {
+ alpha_3_code: "GIB",
+ },
+ "data/GR": {
+ alpha_3_code: "GRC",
+ },
+ "data/GL": {
+ alpha_3_code: "GRL",
+ },
+ "data/GD": {
+ alpha_3_code: "GRD",
+ },
+ "data/GP": {
+ alpha_3_code: "GLP",
+ },
+ "data/GU": {
+ alpha_3_code: "GUM",
+ },
+ "data/GT": {
+ alpha_3_code: "GTM",
+ },
+ "data/GG": {
+ alpha_3_code: "GGY",
+ },
+ "data/GN": {
+ alpha_3_code: "GIN",
+ },
+ "data/GW": {
+ alpha_3_code: "GNB",
+ },
+ "data/GY": {
+ alpha_3_code: "GUY",
+ },
+ "data/HT": {
+ alpha_3_code: "HTI",
+ },
+ "data/HM": {
+ alpha_3_code: "HMD",
+ },
+ "data/VA": {
+ alpha_3_code: "VAT",
+ },
+ "data/HN": {
+ alpha_3_code: "HND",
+ },
+ "data/HK": {
+ alpha_3_code: "HKG",
+ },
+ "data/HU": {
+ alpha_3_code: "HUN",
+ },
+ "data/IS": {
+ alpha_3_code: "ISL",
+ },
+ "data/IN": {
+ alpha_3_code: "IND",
+ },
+ "data/ID": {
+ alpha_3_code: "IDN",
+ },
+ "data/IR": {
+ alpha_3_code: "IRN",
+ },
+ "data/IQ": {
+ alpha_3_code: "IRQ",
+ },
+ "data/IE": {
+ alpha_3_code: "IRL",
+ },
+ "data/IM": {
+ alpha_3_code: "IMN",
+ },
+ "data/IL": {
+ alpha_3_code: "ISR",
+ },
+ "data/IT": {
+ alpha_3_code: "ITA",
+ },
+ "data/JM": {
+ alpha_3_code: "JAM",
+ },
+ "data/JP": {
+ alpha_3_code: "JPN",
+ },
+ "data/JE": {
+ alpha_3_code: "JEY",
+ },
+ "data/JO": {
+ alpha_3_code: "JOR",
+ },
+ "data/KZ": {
+ alpha_3_code: "KAZ",
+ },
+ "data/KE": {
+ alpha_3_code: "KEN",
+ },
+ "data/KI": {
+ alpha_3_code: "KIR",
+ },
+ "data/KP": {
+ alpha_3_code: "PRK",
+ },
+ "data/KR": {
+ alpha_3_code: "KOR",
+ },
+ "data/KW": {
+ alpha_3_code: "KWT",
+ },
+ "data/KG": {
+ alpha_3_code: "KGZ",
+ },
+ "data/LA": {
+ alpha_3_code: "LAO",
+ },
+ "data/LV": {
+ alpha_3_code: "LVA",
+ },
+ "data/LB": {
+ alpha_3_code: "LBN",
+ },
+ "data/LS": {
+ alpha_3_code: "LSO",
+ },
+ "data/LR": {
+ alpha_3_code: "LBR",
+ },
+ "data/LY": {
+ alpha_3_code: "LBY",
+ },
+ "data/LI": {
+ alpha_3_code: "LIE",
+ },
+ "data/LT": {
+ alpha_3_code: "LTU",
+ },
+ "data/LU": {
+ alpha_3_code: "LUX",
+ },
+ "data/MO": {
+ alpha_3_code: "MAC",
+ },
+ "data/MG": {
+ alpha_3_code: "MDG",
+ },
+ "data/MW": {
+ alpha_3_code: "MWI",
+ },
+ "data/MY": {
+ alpha_3_code: "MYS",
+ },
+ "data/MV": {
+ alpha_3_code: "MDV",
+ },
+ "data/ML": {
+ alpha_3_code: "MLI",
+ },
+ "data/MT": {
+ alpha_3_code: "MLT",
+ },
+ "data/MH": {
+ alpha_3_code: "MHL",
+ },
+ "data/MQ": {
+ alpha_3_code: "MTQ",
+ },
+ "data/MR": {
+ alpha_3_code: "MRT",
+ },
+ "data/MU": {
+ alpha_3_code: "MUS",
+ },
+ "data/YT": {
+ alpha_3_code: "MYT",
+ },
+ "data/MX": {
+ alpha_3_code: "MEX",
+ },
+ "data/FM": {
+ alpha_3_code: "FSM",
+ },
+ "data/MD": {
+ alpha_3_code: "MDA",
+ },
+ "data/MC": {
+ alpha_3_code: "MCO",
+ },
+ "data/MN": {
+ alpha_3_code: "MNG",
+ },
+ "data/ME": {
+ alpha_3_code: "MNE",
+ },
+ "data/MS": {
+ alpha_3_code: "MSR",
+ },
+ "data/MA": {
+ alpha_3_code: "MAR",
+ },
+ "data/MZ": {
+ alpha_3_code: "MOZ",
+ },
+ "data/MM": {
+ alpha_3_code: "MMR",
+ },
+ "data/NA": {
+ alpha_3_code: "NAM",
+ },
+ "data/NR": {
+ alpha_3_code: "NRU",
+ },
+ "data/NP": {
+ alpha_3_code: "NPL",
+ },
+ "data/NL": {
+ alpha_3_code: "NLD",
+ },
+ "data/NC": {
+ alpha_3_code: "NCL",
+ },
+ "data/NZ": {
+ alpha_3_code: "NZL",
+ },
+ "data/NI": {
+ alpha_3_code: "NIC",
+ },
+ "data/NE": {
+ alpha_3_code: "NER",
+ },
+ "data/NG": {
+ alpha_3_code: "NGA",
+ },
+ "data/NU": {
+ alpha_3_code: "NIU",
+ },
+ "data/NF": {
+ alpha_3_code: "NFK",
+ },
+ "data/MK": {
+ alpha_3_code: "MKD",
+ },
+ "data/MP": {
+ alpha_3_code: "MNP",
+ },
+ "data/NO": {
+ alpha_3_code: "NOR",
+ },
+ "data/OM": {
+ alpha_3_code: "OMN",
+ },
+ "data/PK": {
+ alpha_3_code: "PAK",
+ },
+ "data/PW": {
+ alpha_3_code: "PLW",
+ },
+ "data/PS": {
+ alpha_3_code: "PSE",
+ },
+ "data/PA": {
+ alpha_3_code: "PAN",
+ },
+ "data/PG": {
+ alpha_3_code: "PNG",
+ },
+ "data/PY": {
+ alpha_3_code: "PRY",
+ },
+ "data/PE": {
+ alpha_3_code: "PER",
+ },
+ "data/PH": {
+ alpha_3_code: "PHL",
+ },
+ "data/PN": {
+ alpha_3_code: "PCN",
+ },
+ "data/PL": {
+ alpha_3_code: "POL",
+ },
+ "data/PT": {
+ alpha_3_code: "PRT",
+ },
+ "data/PR": {
+ alpha_3_code: "PRI",
+ },
+ "data/QA": {
+ alpha_3_code: "QAT",
+ },
+ "data/RE": {
+ alpha_3_code: "REU",
+ },
+ "data/RO": {
+ alpha_3_code: "ROU",
+ },
+ "data/RU": {
+ alpha_3_code: "RUS",
+ },
+ "data/RW": {
+ alpha_3_code: "RWA",
+ },
+ "data/BL": {
+ alpha_3_code: "BLM",
+ },
+ "data/SH": {
+ alpha_3_code: "SHN",
+ },
+ "data/KN": {
+ alpha_3_code: "KNA",
+ },
+ "data/LC": {
+ alpha_3_code: "LCA",
+ },
+ "data/MF": {
+ alpha_3_code: "MAF",
+ },
+ "data/PM": {
+ alpha_3_code: "SPM",
+ },
+ "data/VC": {
+ alpha_3_code: "VCT",
+ },
+ "data/WS": {
+ alpha_3_code: "WSM",
+ },
+ "data/SM": {
+ alpha_3_code: "SMR",
+ },
+ "data/ST": {
+ alpha_3_code: "STP",
+ },
+ "data/SA": {
+ alpha_3_code: "SAU",
+ },
+ "data/SN": {
+ alpha_3_code: "SEN",
+ },
+ "data/RS": {
+ alpha_3_code: "SRB",
+ },
+ "data/SC": {
+ alpha_3_code: "SYC",
+ },
+ "data/SL": {
+ alpha_3_code: "SLE",
+ },
+ "data/SG": {
+ alpha_3_code: "SGP",
+ },
+ "data/SX": {
+ alpha_3_code: "SXM",
+ },
+ "data/SK": {
+ alpha_3_code: "SVK",
+ },
+ "data/SI": {
+ alpha_3_code: "SVN",
+ },
+ "data/SB": {
+ alpha_3_code: "SLB",
+ },
+ "data/SO": {
+ alpha_3_code: "SOM",
+ },
+ "data/ZA": {
+ alpha_3_code: "ZAF",
+ },
+ "data/GS": {
+ alpha_3_code: "SGS",
+ },
+ "data/SS": {
+ alpha_3_code: "SSD",
+ },
+ "data/ES": {
+ alpha_3_code: "ESP",
+ },
+ "data/LK": {
+ alpha_3_code: "LKA",
+ },
+ "data/SD": {
+ alpha_3_code: "SDN",
+ },
+ "data/SR": {
+ alpha_3_code: "SUR",
+ },
+ "data/SJ": {
+ alpha_3_code: "SJM",
+ },
+ "data/SE": {
+ alpha_3_code: "SWE",
+ },
+ "data/CH": {
+ alpha_3_code: "CHE",
+ },
+ "data/SY": {
+ alpha_3_code: "SYR",
+ },
+ "data/TW": {
+ alpha_3_code: "TWN",
+ },
+ "data/TJ": {
+ alpha_3_code: "TJK",
+ },
+ "data/TZ": {
+ alpha_3_code: "TZA",
+ },
+ "data/TH": {
+ alpha_3_code: "THA",
+ },
+ "data/TL": {
+ alpha_3_code: "TLS",
+ },
+ "data/TG": {
+ alpha_3_code: "TGO",
+ },
+ "data/TK": {
+ alpha_3_code: "TKL",
+ },
+ "data/TO": {
+ alpha_3_code: "TON",
+ },
+ "data/TT": {
+ alpha_3_code: "TTO",
+ },
+ "data/TN": {
+ alpha_3_code: "TUN",
+ },
+ "data/TR": {
+ alpha_3_code: "TUR",
+ },
+ "data/TM": {
+ alpha_3_code: "TKM",
+ },
+ "data/TC": {
+ alpha_3_code: "TCA",
+ },
+ "data/TV": {
+ alpha_3_code: "TUV",
+ },
+ "data/UG": {
+ alpha_3_code: "UGA",
+ },
+ "data/UA": {
+ alpha_3_code: "UKR",
+ },
+ "data/AE": {
+ alpha_3_code: "ARE",
+ },
+ "data/GB": {
+ alpha_3_code: "GBR",
+ },
+ "data/US": {
+ alternative_names: [
+ "US",
+ "United States of America",
+ "United States",
+ "America",
+ "U.S.",
+ "USA",
+ "U.S.A.",
+ "U.S.A",
+ ],
+ alpha_3_code: "USA",
+ },
+ "data/UM": {
+ alpha_3_code: "UMI",
+ },
+ "data/UY": {
+ alpha_3_code: "URY",
+ },
+ "data/UZ": {
+ alpha_3_code: "UZB",
+ },
+ "data/VU": {
+ alpha_3_code: "VUT",
+ },
+ "data/VE": {
+ alpha_3_code: "VEN",
+ },
+ "data/VN": {
+ alpha_3_code: "VNM",
+ },
+ "data/VG": {
+ alpha_3_code: "VGB",
+ },
+ "data/VI": {
+ alpha_3_code: "VIR",
+ },
+ "data/WF": {
+ alpha_3_code: "WLF",
+ },
+ "data/EH": {
+ alpha_3_code: "ESH",
+ },
+ "data/YE": {
+ alpha_3_code: "YEM",
+ },
+ "data/ZM": {
+ alpha_3_code: "ZMB",
+ },
+ "data/ZW": {
+ alpha_3_code: "ZWE",
+ },
+};
+
+export default AddressMetaDataExtension;
diff --git a/toolkit/components/formautofill/shared/AddressMetaDataLoader.sys.mjs b/toolkit/components/formautofill/shared/AddressMetaDataLoader.sys.mjs
new file mode 100644
index 0000000000..a7be227921
--- /dev/null
+++ b/toolkit/components/formautofill/shared/AddressMetaDataLoader.sys.mjs
@@ -0,0 +1,168 @@
+/* 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/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddressMetaData: "resource://gre/modules/shared/AddressMetaData.sys.mjs",
+ AddressMetaDataExtension:
+ "resource://gre/modules/shared/AddressMetaDataExtension.sys.mjs",
+});
+
+export class AddressMetaDataLoader {
+ // Status of address data loading. We'll load all the countries with basic level 1
+ // information while requesting conutry information, and set country to true.
+ // Level 1 Set is for recording which country's level 1/level 2 data is loaded,
+ // since we only load this when getCountryAddressData called with level 1 parameter.
+ static dataLoaded = {
+ country: false,
+ level1: new Set(),
+ };
+
+ static addressData = {};
+
+ static DATA_PREFIX = "data/";
+
+ /**
+ * Load address meta data and extension into one object.
+ *
+ * @returns {object}
+ * An object containing address data object with properties from extension.
+ */
+ static loadAddressMetaData() {
+ const addressMetaData = lazy.AddressMetaData;
+
+ for (const key in lazy.AddressMetaDataExtension) {
+ let addressDataForKey = addressMetaData[key];
+ if (!addressDataForKey) {
+ addressDataForKey = addressMetaData[key] = {};
+ }
+
+ Object.assign(addressDataForKey, lazy.AddressMetaDataExtension[key]);
+ }
+ return addressMetaData;
+ }
+
+ /**
+ * Convert certain properties' string value into array. We should make sure
+ * the cached data is parsed.
+ *
+ * @param {object} data Original metadata from addressReferences.
+ * @returns {object} parsed metadata with property value that converts to array.
+ */
+ static #parse(data) {
+ if (!data) {
+ return null;
+ }
+
+ const properties = [
+ "languages",
+ "sub_keys",
+ "sub_isoids",
+ "sub_names",
+ "sub_lnames",
+ ];
+ for (const key of properties) {
+ if (!data[key]) {
+ continue;
+ }
+ // No need to normalize data if the value is array already.
+ if (Array.isArray(data[key])) {
+ return data;
+ }
+
+ data[key] = data[key].split("~");
+ }
+ return data;
+ }
+
+ /**
+ * We'll cache addressData in the loader once the data loaded from scripts.
+ * It'll become the example below after loading addressReferences with extension:
+ * addressData: {
+ * "data/US": {"lang": ["en"], ...// Data defined in libaddressinput metadata
+ * "alternative_names": ... // Data defined in extension }
+ * "data/CA": {} // Other supported country metadata
+ * "data/TW": {} // Other supported country metadata
+ * "data/TW/台北市": {} // Other supported country level 1 metadata
+ * }
+ *
+ * @param {string} country
+ * @param {string?} level1
+ * @returns {object} Default locale metadata
+ */
+ static #loadData(country, level1 = null) {
+ // Load the addressData if needed
+ if (!this.dataLoaded.country) {
+ this.addressData = this.loadAddressMetaData();
+ this.dataLoaded.country = true;
+ }
+ if (!level1) {
+ return this.#parse(this.addressData[`${this.DATA_PREFIX}${country}`]);
+ }
+ // If level1 is set, load addressReferences under country folder with specific
+ // country/level 1 for level 2 information.
+ if (!this.dataLoaded.level1.has(country)) {
+ Object.assign(this.addressData, this.loadAddressMetaData());
+ this.dataLoaded.level1.add(country);
+ }
+ return this.#parse(
+ this.addressData[`${this.DATA_PREFIX}${country}/${level1}`]
+ );
+ }
+
+ /**
+ * Return the region metadata with default locale and other locales (if exists).
+ *
+ * @param {string} country
+ * @param {string?} level1
+ * @returns {object} Return default locale and other locales metadata.
+ */
+ static getData(country, level1 = null) {
+ const defaultLocale = this.#loadData(country, level1);
+ if (!defaultLocale) {
+ return null;
+ }
+
+ const countryData = this.#parse(
+ this.addressData[`${this.DATA_PREFIX}${country}`]
+ );
+ let locales = [];
+ // TODO: Should be able to support multi-locale level 1/ level 2 metadata query
+ // in Bug 1421886
+ if (countryData.languages) {
+ const list = countryData.languages.filter(
+ key => key !== countryData.lang
+ );
+ locales = list.map(key =>
+ this.#parse(this.addressData[`${defaultLocale.id}--${key}`])
+ );
+ }
+ return { defaultLocale, locales };
+ }
+
+ /**
+ * Return an array containing countries alpha2 codes.
+ *
+ * @returns {Array} Return an array containing countries alpha2 codes.
+ */
+ static get #countryCodes() {
+ return Object.keys(lazy.AddressMetaDataExtension).map(dataKey =>
+ dataKey.replace(this.DATA_PREFIX, "")
+ );
+ }
+
+ static getCountries(locales = []) {
+ const displayNames = new Intl.DisplayNames(locales, {
+ type: "region",
+ fallback: "none",
+ });
+ const countriesMap = new Map();
+ for (const countryCode of this.#countryCodes) {
+ countriesMap.set(countryCode, displayNames.of(countryCode));
+ }
+ return countriesMap;
+ }
+}
+
+export default AddressMetaDataLoader;
diff --git a/toolkit/components/formautofill/shared/AddressParser.sys.mjs b/toolkit/components/formautofill/shared/AddressParser.sys.mjs
new file mode 100644
index 0000000000..5cb76934c1
--- /dev/null
+++ b/toolkit/components/formautofill/shared/AddressParser.sys.mjs
@@ -0,0 +1,285 @@
+/* eslint-disable no-useless-concat */
+/* 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/. */
+
+// NamedCaptureGroup class represents a named capturing group in a regular expression
+class NamedCaptureGroup {
+ // The named of this capturing group
+ #name = null;
+
+ // The capturing group
+ #capture = null;
+
+ // The matched result
+ #match = null;
+
+ constructor(name, capture) {
+ this.#name = name;
+ this.#capture = capture;
+ }
+
+ get name() {
+ return this.#name;
+ }
+
+ get capture() {
+ return this.#capture;
+ }
+
+ get match() {
+ return this.#match;
+ }
+
+ // Setter for the matched result based on the match groups
+ setMatch(matchGroups) {
+ this.#match = matchGroups[this.#name];
+ }
+}
+
+// Base class for different part of a street address regular expression.
+// The regular expression is constructed with prefix, pattern, suffix
+// and separator to extract "value" part.
+// For examplem, when we write "apt 4." to for floor number, its prefix is `apt`,
+// suffix is `.` and value to represent apartment number is `4`.
+class StreetAddressPartRegExp extends NamedCaptureGroup {
+ constructor(name, prefix, pattern, suffix, sep, optional = false) {
+ prefix = prefix ?? "";
+ suffix = suffix ?? "";
+ super(
+ name,
+ `((?:${prefix})(?<${name}>${pattern})(?:${suffix})(?:${sep})+)${
+ optional ? "?" : ""
+ }`
+ );
+ }
+}
+
+// A regular expression to match the street number portion of a street address,
+class StreetNumberRegExp extends StreetAddressPartRegExp {
+ static PREFIX = "((no|°|º|number)(\\.|-|\\s)*)?"; // From chromium source
+
+ static PATTERN = "\\d+\\w?";
+
+ // TODO: possible suffix : (th\\.|\\.)?
+ static SUFFIX = null;
+
+ constructor(sep, optional) {
+ super(
+ StreetNumberRegExp.name,
+ StreetNumberRegExp.PREFIX,
+ StreetNumberRegExp.PATTERN,
+ StreetNumberRegExp.SUFFIX,
+ sep,
+ optional
+ );
+ }
+}
+
+// A regular expression to match the street name portion of a street address,
+class StreetNameRegExp extends StreetAddressPartRegExp {
+ static PREFIX = null;
+
+ static PATTERN = "(?:[^\\s,]+(?:[^\\S\\r\\n]+[^\\s,]+)*?)"; // From chromium source
+
+ // TODO: Should we consider suffix like (ave|st)?
+ static SUFFIX = null;
+
+ constructor(sep, optional) {
+ super(
+ StreetNameRegExp.name,
+ StreetNameRegExp.PREFIX,
+ StreetNameRegExp.PATTERN,
+ StreetNameRegExp.SUFFIX,
+ sep,
+ optional
+ );
+ }
+}
+
+// A regular expression to match the apartment number portion of a street address,
+class ApartmentNumberRegExp extends StreetAddressPartRegExp {
+ static keyword = "apt|apartment|wohnung|apto|-" + "|unit|suite|ste|#|room"; // From chromium source // Firefox specific
+ static PREFIX = `(${ApartmentNumberRegExp.keyword})(\\.|\\s|-)*`;
+
+ static PATTERN = "\\w*([-|\\/]\\w*)?";
+
+ static SUFFIX = "(\\.|\\s|-)*(ª)?"; // From chromium source
+
+ constructor(sep, optional) {
+ super(
+ ApartmentNumberRegExp.name,
+ ApartmentNumberRegExp.PREFIX,
+ ApartmentNumberRegExp.PATTERN,
+ ApartmentNumberRegExp.SUFFIX,
+ sep,
+ optional
+ );
+ }
+}
+
+// A regular expression to match the floor number portion of a street address,
+class FloorNumberRegExp extends StreetAddressPartRegExp {
+ static keyword =
+ "floor|flur|fl|og|obergeschoss|ug|untergeschoss|geschoss|andar|piso|º" + // From chromium source
+ "|level|lvl"; // Firefox specific
+ static PREFIX = `(${FloorNumberRegExp.keyword})?(\\.|\\s|-)*`; // TODO
+ static PATTERN = "\\d{1,3}\\w?";
+ static SUFFIX = `(st|nd|rd|th)?(\\.|\\s|-)*(${FloorNumberRegExp.keyword})?`; // TODO
+
+ constructor(sep, optional) {
+ super(
+ FloorNumberRegExp.name,
+ FloorNumberRegExp.PREFIX,
+ FloorNumberRegExp.PATTERN,
+ FloorNumberRegExp.SUFFIX,
+ sep,
+ optional
+ );
+ }
+}
+
+/**
+ * Class represents a street address with the following fields:
+ * - street number
+ * - street name
+ * - apartment number
+ * - floor number
+ */
+export class StructuredStreetAddress {
+ #street_number = null;
+ #street_name = null;
+ #apartment_number = null;
+ #floor_number = null;
+
+ constructor(street_number, street_name, apartment_number, floor_number) {
+ this.#street_number = street_number?.toString();
+ this.#street_name = street_name?.toString();
+ this.#apartment_number = apartment_number?.toString();
+ this.#floor_number = floor_number?.toString();
+ }
+
+ get street_number() {
+ return this.#street_number;
+ }
+
+ get street_name() {
+ return this.#street_name;
+ }
+
+ get apartment_number() {
+ return this.#apartment_number;
+ }
+
+ get floor_number() {
+ return this.#floor_number;
+ }
+
+ toString() {
+ return `
+ street number: ${this.#street_number}\n
+ street name: ${this.#street_name}\n
+ apartment number: ${this.#apartment_number}\n
+ floor number: ${this.#floor_number}\n
+ `;
+ }
+}
+
+export class AddressParser {
+ /**
+ * Parse street address with the following pattern.
+ * street number, street name, apartment number(optional), floor number(optional)
+ * For example, 2 Harrison St #175 floor 2
+ *
+ * @param {string} address The street address to be parsed.
+ * @returns {StructuredStreetAddress}
+ */
+ static parseStreetAddress(address) {
+ if (!address) {
+ return null;
+ }
+
+ const separator = "(\\s|,|$)";
+
+ const regexpes = [
+ new StreetNumberRegExp(separator),
+ new StreetNameRegExp(separator),
+ new ApartmentNumberRegExp(separator, true),
+ new FloorNumberRegExp(separator, true),
+ ];
+
+ return AddressParser.parse(address, regexpes)
+ ? new StructuredStreetAddress(...regexpes.map(regexp => regexp.match))
+ : null;
+ }
+
+ static parse(address, regexpes) {
+ const options = {
+ trim: true,
+ merge_whitespace: true,
+ ignore_case: true,
+ };
+ address = AddressParser.normalizeString(address, options);
+
+ const match = address.match(
+ new RegExp(`^(${regexpes.map(regexp => regexp.capture).join("")})$`)
+ );
+ if (!match) {
+ return null;
+ }
+
+ regexpes.forEach(regexp => regexp.setMatch(match.groups));
+ return regexpes.reduce((acc, current) => {
+ return { ...acc, [current.name]: current.match };
+ }, {});
+ }
+
+ static normalizeString(s, options) {
+ if (typeof s != "string") {
+ return s;
+ }
+
+ if (options.ignore_case) {
+ s = s.toLowerCase();
+ }
+
+ // process punctuation before whitespace because if a punctuation
+ // is replaced with whitespace, we might want to merge it later
+ if (options.remove_punctuation) {
+ s = AddressParser.replacePunctuation(s, "");
+ } else if ("replace_punctuation" in options) {
+ const replace = options.replace_punctuation;
+ s = AddressParser.replacePunctuation(s, replace);
+ }
+
+ // process whitespace
+ if (options.merge_whitespace) {
+ s = AddressParser.mergeWhitespace(s);
+ } else if (options.remove_whitespace) {
+ s = AddressParser.removeWhitespace(s);
+ }
+
+ return s.trim();
+ }
+
+ static replacePunctuation(s, replace) {
+ const regex = /\p{Punctuation}/gu;
+ return s?.replace(regex, replace);
+ }
+
+ static removePunctuation(s) {
+ return s?.replace(/[.,\/#!$%\^&\*;:{}=\-_~()]/g, "");
+ }
+
+ static replaceControlCharacters(s, replace) {
+ return s?.replace(/[\t\n\r]/g, " ");
+ }
+
+ static removeWhitespace(s) {
+ return s?.replace(/[\s]/g, "");
+ }
+
+ static mergeWhitespace(s) {
+ return s?.replace(/\s{2,}/g, " ");
+ }
+}
diff --git a/toolkit/components/formautofill/shared/CreditCardRecord.sys.mjs b/toolkit/components/formautofill/shared/CreditCardRecord.sys.mjs
new file mode 100644
index 0000000000..97235e8cdd
--- /dev/null
+++ b/toolkit/components/formautofill/shared/CreditCardRecord.sys.mjs
@@ -0,0 +1,66 @@
+/* eslint-disable no-useless-concat */
+/* 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/. */
+
+import { CreditCard } from "resource://gre/modules/CreditCard.sys.mjs";
+import { FormAutofillNameUtils } from "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs";
+
+/**
+ * The CreditCardRecord class serves to handle and normalize internal credit card records.
+ * Unlike the CreditCard class, which represents actual card data, CreditCardRecord is used
+ * for processing and consistent data representation.
+ */
+export class CreditCardRecord {
+ static normalizeFields(creditCard) {
+ this.#normalizeCCNameFields(creditCard);
+ this.#normalizeCCNumberFields(creditCard);
+ this.#normalizeCCExpirationDateFields(creditCard);
+ this.#normalizeCCTypeFields(creditCard);
+ }
+
+ static #normalizeCCNameFields(creditCard) {
+ if (!creditCard["cc-name"]) {
+ creditCard["cc-name"] = FormAutofillNameUtils.joinNameParts({
+ given: creditCard["cc-given-name"] ?? "",
+ middle: creditCard["cc-additional-name"] ?? "",
+ family: creditCard["cc-family-name"] ?? "",
+ });
+ }
+
+ delete creditCard["cc-given-name"];
+ delete creditCard["cc-additional-name"];
+ delete creditCard["cc-family-name"];
+ }
+
+ static #normalizeCCNumberFields(creditCard) {
+ if (!("cc-number" in creditCard)) {
+ return;
+ }
+
+ if (!CreditCard.isValidNumber(creditCard["cc-number"])) {
+ delete creditCard["cc-number"];
+ return;
+ }
+
+ const card = new CreditCard({ number: creditCard["cc-number"] });
+ creditCard["cc-number"] = card.number;
+ }
+
+ static #normalizeCCExpirationDateFields(creditCard) {
+ let normalizedExpiration = CreditCard.normalizeExpiration({
+ expirationMonth: creditCard["cc-exp-month"],
+ expirationYear: creditCard["cc-exp-year"],
+ expirationString: creditCard["cc-exp"],
+ });
+
+ creditCard["cc-exp-month"] = normalizedExpiration.month ?? "";
+ creditCard["cc-exp-year"] = normalizedExpiration.year ?? "";
+ delete creditCard["cc-exp"];
+ }
+
+ static #normalizeCCTypeFields(creditCard) {
+ // Let's overwrite the credit card type with auto-detect algorithm
+ creditCard["cc-type"] = CreditCard.getType(creditCard["cc-number"]) ?? "";
+ }
+}
diff --git a/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs b/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs
new file mode 100644
index 0000000000..26651fe65a
--- /dev/null
+++ b/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs
@@ -0,0 +1,1221 @@
+/* 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/. */
+
+/**
+ * Fathom ML model for identifying the fields of credit-card forms
+ *
+ * This is developed out-of-tree at https://github.com/mozilla-services/fathom-
+ * form-autofill, where there is also over a GB of training, validation, and
+ * testing data. To make changes, do your edits there (whether adding new
+ * training pages, adding new rules, or both), retrain and evaluate as
+ * documented at https://mozilla.github.io/fathom/training.html, paste the
+ * coefficients emitted by the trainer into the ruleset, and finally copy the
+ * ruleset's "CODE TO COPY INTO PRODUCTION" section to this file's "CODE FROM
+ * TRAINING REPOSITORY" section.
+ */
+
+/**
+ * CODE UNIQUE TO PRODUCTION--NOT IN THE TRAINING REPOSITORY:
+ */
+
+import {
+ element as clickedElement,
+ out,
+ rule,
+ ruleset,
+ score,
+ type,
+} from "resource://gre/modules/third_party/fathom/fathom.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import {
+ CreditCard,
+ NETWORK_NAMES,
+} from "resource://gre/modules/CreditCard.sys.mjs";
+
+import { FormLikeFactory } from "resource://gre/modules/FormLikeFactory.sys.mjs";
+import { LabelUtils } from "resource://gre/modules/shared/LabelUtils.sys.mjs";
+
+/**
+ * Callthrough abstraction to allow .getAutocompleteInfo() to be mocked out
+ * during training
+ *
+ * @param {Element} element DOM element to get info about
+ * @returns {object} Page-author-provided autocomplete metadata
+ */
+function getAutocompleteInfo(element) {
+ return element.getAutocompleteInfo();
+}
+
+/**
+ * @param {string} selector A CSS selector that prunes away ineligible elements
+ * @returns {Lhs} An LHS yielding the element the user has clicked or, if
+ * pruned, none
+ */
+function queriedOrClickedElements(selector) {
+ return clickedElement(selector);
+}
+
+/**
+ * START OF CODE PASTED FROM TRAINING REPOSITORY
+ */
+
+var FathomHeuristicsRegExp = {
+ RULES: {
+ "cc-name": undefined,
+ "cc-number": undefined,
+ "cc-exp-month": undefined,
+ "cc-exp-year": undefined,
+ "cc-exp": undefined,
+ "cc-type": undefined,
+ },
+
+ RULE_SETS: [
+ {
+ /* eslint-disable */
+ // Let us keep our consistent wrapping.
+ "cc-name":
+ // Firefox-specific rules
+ "account.*holder.*name" +
+ "|^(credit[-\\s]?card|card).*name" +
+ // de-DE
+ "|^(kredit)?(karten|konto)inhaber" +
+ "|^(name).*karte" +
+ // fr-FR
+ "|nom.*(titulaire|détenteur)" +
+ "|(titulaire|détenteur).*(carte)" +
+ // it-IT
+ "|titolare.*carta" +
+ // pl-PL
+ "|posiadacz.*karty" +
+ // es-ES
+ "|nombre.*(titular|tarjeta)" +
+ // nl-NL
+ "|naam.*op.*kaart" +
+ // Rules from Bitwarden
+ "|cc-?name" +
+ "|card-?name" +
+ "|cardholder-?name" +
+ "|(^nom$)" +
+ // Rules are from Chromium source codes
+ "|card.?(?:holder|owner)|name.*(\\b)?on(\\b)?.*card" +
+ "|(?:card|cc).?name|cc.?full.?name" +
+ "|(?:card|cc).?owner" +
+ "|nom.*carte" + // fr-FR
+ "|nome.*cart" + // it-IT
+ "|名前" + // ja-JP
+ "|Имя.*карты" + // ru
+ "|信用卡开户名|开户名|持卡人姓名" + // zh-CN
+ "|持卡人姓名", // zh-TW
+
+ "cc-number":
+ // Firefox-specific rules
+ // de-DE
+ "(cc|kk)nr" +
+ "|(kredit)?(karten)(nummer|nr)" +
+ // it-IT
+ "|numero.*carta" +
+ // fr-FR
+ "|(numero|número|numéro).*(carte)" +
+ // pl-PL
+ "|numer.*karty" +
+ // es-ES
+ "|(número|numero).*tarjeta" +
+ // nl-NL
+ "|kaartnummer" +
+ // Rules from Bitwarden
+ "|cc-?number" +
+ "|cc-?num" +
+ "|card-?number" +
+ "|card-?num" +
+ "|cc-?no" +
+ "|card-?no" +
+ "|numero-?carte" +
+ "|num-?carte" +
+ "|cb-?num" +
+ // Rules are from Chromium source codes
+ "|(add)?(?:card|cc|acct).?(?:number|#|no|num)" +
+ "|カード番号" + // ja-JP
+ "|Номер.*карты" + // ru
+ "|信用卡号|信用卡号码" + // zh-CN
+ "|信用卡卡號" + // zh-TW
+ "|카드", // ko-KR
+
+ "cc-exp":
+ // Firefox-specific rules
+ "mm\\s*(\/|\\|-)\\s*(yy|jj|aa)" +
+ "|(month|mois)\\s*(\/|\\|-|et)\\s*(year|année)" +
+ // de-DE
+ // fr-FR
+ // Rules from Bitwarden
+ "|(^cc-?exp$)" +
+ "|(^card-?exp$)" +
+ "|(^cc-?expiration$)" +
+ "|(^card-?expiration$)" +
+ "|(^cc-?ex$)" +
+ "|(^card-?ex$)" +
+ "|(^card-?expire$)" +
+ "|(^card-?expiry$)" +
+ "|(^validite$)" +
+ "|(^expiration$)" +
+ "|(^expiry$)" +
+ "|mm-?yy" +
+ "|mm-?yyyy" +
+ "|yy-?mm" +
+ "|yyyy-?mm" +
+ "|expiration-?date" +
+ "|payment-?card-?expiration" +
+ "|(^payment-?cc-?date$)" +
+ // Rules are from Chromium source codes
+ "|expir|exp.*date|^expfield$" +
+ "|ablaufdatum|gueltig|gültig" + // de-DE
+ "|fecha" + // es
+ "|date.*exp" + // fr-FR
+ "|scadenza" + // it-IT
+ "|有効期限" + // ja-JP
+ "|validade" + // pt-BR, pt-PT
+ "|Срок действия карты", // ru
+
+ "cc-exp-month":
+ // Firefox-specific rules
+ "(cc|kk)month" + // de-DE
+ // Rules from Bitwarden
+ "|(^exp-?month$)" +
+ "|(^cc-?exp-?month$)" +
+ "|(^cc-?month$)" +
+ "|(^card-?month$)" +
+ "|(^cc-?mo$)" +
+ "|(^card-?mo$)" +
+ "|(^exp-?mo$)" +
+ "|(^card-?exp-?mo$)" +
+ "|(^cc-?exp-?mo$)" +
+ "|(^card-?expiration-?month$)" +
+ "|(^expiration-?month$)" +
+ "|(^cc-?mm$)" +
+ "|(^cc-?m$)" +
+ "|(^card-?mm$)" +
+ "|(^card-?m$)" +
+ "|(^card-?exp-?mm$)" +
+ "|(^cc-?exp-?mm$)" +
+ "|(^exp-?mm$)" +
+ "|(^exp-?m$)" +
+ "|(^expire-?month$)" +
+ "|(^expire-?mo$)" +
+ "|(^expiry-?month$)" +
+ "|(^expiry-?mo$)" +
+ "|(^card-?expire-?month$)" +
+ "|(^card-?expire-?mo$)" +
+ "|(^card-?expiry-?month$)" +
+ "|(^card-?expiry-?mo$)" +
+ "|(^mois-?validite$)" +
+ "|(^mois-?expiration$)" +
+ "|(^m-?validite$)" +
+ "|(^m-?expiration$)" +
+ "|(^expiry-?date-?field-?month$)" +
+ "|(^expiration-?date-?month$)" +
+ "|(^expiration-?date-?mm$)" +
+ "|(^exp-?mon$)" +
+ "|(^validity-?mo$)" +
+ "|(^exp-?date-?mo$)" +
+ "|(^cb-?date-?mois$)" +
+ "|(^date-?m$)" +
+ // Rules are from Chromium source codes
+ "|exp.*mo|ccmonth|cardmonth|addmonth" +
+ "|monat" + // de-DE
+ // "|fecha" + // es
+ // "|date.*exp" + // fr-FR
+ // "|scadenza" + // it-IT
+ // "|有効期限" + // ja-JP
+ // "|validade" + // pt-BR, pt-PT
+ // "|Срок действия карты" + // ru
+ "|月", // zh-CN
+
+ "cc-exp-year":
+ // Firefox-specific rules
+ "(cc|kk)year" + // de-DE
+ // Rules from Bitwarden
+ "|(^exp-?year$)" +
+ "|(^cc-?exp-?year$)" +
+ "|(^cc-?year$)" +
+ "|(^card-?year$)" +
+ "|(^cc-?yr$)" +
+ "|(^card-?yr$)" +
+ "|(^exp-?yr$)" +
+ "|(^card-?exp-?yr$)" +
+ "|(^cc-?exp-?yr$)" +
+ "|(^card-?expiration-?year$)" +
+ "|(^expiration-?year$)" +
+ "|(^cc-?yy$)" +
+ "|(^cc-?y$)" +
+ "|(^card-?yy$)" +
+ "|(^card-?y$)" +
+ "|(^card-?exp-?yy$)" +
+ "|(^cc-?exp-?yy$)" +
+ "|(^exp-?yy$)" +
+ "|(^exp-?y$)" +
+ "|(^cc-?yyyy$)" +
+ "|(^card-?yyyy$)" +
+ "|(^card-?exp-?yyyy$)" +
+ "|(^cc-?exp-?yyyy$)" +
+ "|(^expire-?year$)" +
+ "|(^expire-?yr$)" +
+ "|(^expiry-?year$)" +
+ "|(^expiry-?yr$)" +
+ "|(^card-?expire-?year$)" +
+ "|(^card-?expire-?yr$)" +
+ "|(^card-?expiry-?year$)" +
+ "|(^card-?expiry-?yr$)" +
+ "|(^an-?validite$)" +
+ "|(^an-?expiration$)" +
+ "|(^annee-?validite$)" +
+ "|(^annee-?expiration$)" +
+ "|(^expiry-?date-?field-?year$)" +
+ "|(^expiration-?date-?year$)" +
+ "|(^cb-?date-?ann$)" +
+ "|(^expiration-?date-?yy$)" +
+ "|(^expiration-?date-?yyyy$)" +
+ "|(^validity-?year$)" +
+ "|(^exp-?date-?year$)" +
+ "|(^date-?y$)" +
+ // Rules are from Chromium source codes
+ "|(add)?year" +
+ "|jahr" + // de-DE
+ // "|fecha" + // es
+ // "|scadenza" + // it-IT
+ // "|有効期限" + // ja-JP
+ // "|validade" + // pt-BR, pt-PT
+ // "|Срок действия карты" + // ru
+ "|年|有效期", // zh-CN
+
+ "cc-type":
+ // Firefox-specific rules
+ "type" +
+ // de-DE
+ "|Kartenmarke" +
+ // Rules from Bitwarden
+ "|(^cc-?type$)" +
+ "|(^card-?type$)" +
+ "|(^card-?brand$)" +
+ "|(^cc-?brand$)" +
+ "|(^cb-?type$)",
+ // Rules are from Chromium source codes
+ },
+ ],
+
+ _getRule(name) {
+ let rules = [];
+ this.RULE_SETS.forEach(set => {
+ if (set[name]) {
+ rules.push(`(${set[name]})`.normalize("NFKC"));
+ }
+ });
+
+ const value = new RegExp(rules.join("|"), "iu");
+ Object.defineProperty(this.RULES, name, { get: undefined });
+ Object.defineProperty(this.RULES, name, { value });
+ return value;
+ },
+
+ init() {
+ Object.keys(this.RULES).forEach(field =>
+ Object.defineProperty(this.RULES, field, {
+ get() {
+ return FathomHeuristicsRegExp._getRule(field);
+ },
+ })
+ );
+ },
+};
+
+FathomHeuristicsRegExp.init();
+
+const MMRegExp = /^mm$|\(mm\)/i;
+const YYorYYYYRegExp = /^(yy|yyyy)$|\(yy\)|\(yyyy\)/i;
+const monthRegExp = /month/i;
+const yearRegExp = /year/i;
+const MMYYRegExp = /mm\s*(\/|\\)\s*yy/i;
+const VisaCheckoutRegExp = /visa(-|\s)checkout/i;
+const CREDIT_CARD_NETWORK_REGEXP = new RegExp(
+ CreditCard.getSupportedNetworks()
+ .concat(Object.keys(NETWORK_NAMES))
+ .join("|"),
+ "gui"
+ );
+const TwoDigitYearRegExp = /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yy(?:[^y]|$)/i;
+const FourDigitYearRegExp = /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yyyy(?:[^y]|$)/i;
+const dwfrmRegExp = /^dwfrm/i;
+const bmlRegExp = /bml/i;
+const templatedValue = /^\{\{.*\}\}$/;
+const firstRegExp = /first/i;
+const lastRegExp = /last/i;
+const giftRegExp = /gift/i;
+const subscriptionRegExp = /subscription/i;
+
+function autocompleteStringMatches(element, ccString) {
+ const info = getAutocompleteInfo(element);
+ return info.fieldName === ccString;
+}
+
+function getFillableFormElements(element) {
+ const formLike = FormLikeFactory.createFromField(element);
+ return Array.from(formLike.elements).filter(el =>
+ FormAutofillUtils.isCreditCardOrAddressFieldType(el)
+ );
+}
+
+function nextFillableFormField(element) {
+ const fillableFormElements = getFillableFormElements(element);
+ const elementIndex = fillableFormElements.indexOf(element);
+ return fillableFormElements[elementIndex + 1];
+}
+
+function previousFillableFormField(element) {
+ const fillableFormElements = getFillableFormElements(element);
+ const elementIndex = fillableFormElements.indexOf(element);
+ return fillableFormElements[elementIndex - 1];
+}
+
+function nextFieldPredicateIsTrue(element, predicate) {
+ const nextField = nextFillableFormField(element);
+ return !!nextField && predicate(nextField);
+}
+
+function previousFieldPredicateIsTrue(element, predicate) {
+ const previousField = previousFillableFormField(element);
+ return !!previousField && predicate(previousField);
+}
+
+function nextFieldMatchesExpYearAutocomplete(fnode) {
+ return nextFieldPredicateIsTrue(fnode.element, nextField =>
+ autocompleteStringMatches(nextField, "cc-exp-year")
+ );
+}
+
+function previousFieldMatchesExpMonthAutocomplete(fnode) {
+ return previousFieldPredicateIsTrue(fnode.element, previousField =>
+ autocompleteStringMatches(previousField, "cc-exp-month")
+ );
+}
+
+//////////////////////////////////////////////
+// Attribute Regular Expression Rules
+function idOrNameMatchRegExp(element, regExp) {
+ for (const str of [element.id, element.name]) {
+ if (regExp.test(str)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function getElementLabels(element) {
+ return {
+ *[Symbol.iterator]() {
+ const labels = LabelUtils.findLabelElements(element);
+ for (let label of labels) {
+ yield* LabelUtils.extractLabelStrings(label);
+ }
+ },
+ };
+}
+
+function labelsMatchRegExp(element, regExp) {
+ const elemStrings = getElementLabels(element);
+ for (const str of elemStrings) {
+ if (regExp.test(str)) {
+ return true;
+ }
+ }
+
+ const parentElement = element.parentElement;
+ // Bug 1634819: element.parentElement is null if element.parentNode is a ShadowRoot
+ if (!parentElement) {
+ return false;
+ }
+ // Check if the input is in a <td>, and, if so, check the textContent of the containing <tr>
+ if (parentElement.tagName === "TD" && parentElement.parentElement) {
+ // TODO: How bad is the assumption that the <tr> won't be the parent of the <td>?
+ return regExp.test(parentElement.parentElement.textContent);
+ }
+
+ // Check if the input is in a <dd>, and, if so, check the textContent of the preceding <dt>
+ if (
+ parentElement.tagName === "DD" &&
+ // previousElementSibling can be null
+ parentElement.previousElementSibling
+ ) {
+ return regExp.test(parentElement.previousElementSibling.textContent);
+ }
+ return false;
+}
+
+function closestLabelMatchesRegExp(element, regExp) {
+ const previousElementSibling = element.previousElementSibling;
+ if (
+ previousElementSibling !== null &&
+ previousElementSibling.tagName === "LABEL"
+ ) {
+ return regExp.test(previousElementSibling.textContent);
+ }
+
+ const nextElementSibling = element.nextElementSibling;
+ if (nextElementSibling !== null && nextElementSibling.tagName === "LABEL") {
+ return regExp.test(nextElementSibling.textContent);
+ }
+
+ return false;
+}
+
+function ariaLabelMatchesRegExp(element, regExp) {
+ const ariaLabel = element.getAttribute("aria-label");
+ return !!ariaLabel && regExp.test(ariaLabel);
+}
+
+function placeholderMatchesRegExp(element, regExp) {
+ const placeholder = element.getAttribute("placeholder");
+ return !!placeholder && regExp.test(placeholder);
+}
+
+function nextFieldIdOrNameMatchRegExp(element, regExp) {
+ return nextFieldPredicateIsTrue(element, nextField =>
+ idOrNameMatchRegExp(nextField, regExp)
+ );
+}
+
+function nextFieldLabelsMatchRegExp(element, regExp) {
+ return nextFieldPredicateIsTrue(element, nextField =>
+ labelsMatchRegExp(nextField, regExp)
+ );
+}
+
+function nextFieldPlaceholderMatchesRegExp(element, regExp) {
+ return nextFieldPredicateIsTrue(element, nextField =>
+ placeholderMatchesRegExp(nextField, regExp)
+ );
+}
+
+function nextFieldAriaLabelMatchesRegExp(element, regExp) {
+ return nextFieldPredicateIsTrue(element, nextField =>
+ ariaLabelMatchesRegExp(nextField, regExp)
+ );
+}
+
+function previousFieldIdOrNameMatchRegExp(element, regExp) {
+ return previousFieldPredicateIsTrue(element, previousField =>
+ idOrNameMatchRegExp(previousField, regExp)
+ );
+}
+
+function previousFieldLabelsMatchRegExp(element, regExp) {
+ return previousFieldPredicateIsTrue(element, previousField =>
+ labelsMatchRegExp(previousField, regExp)
+ );
+}
+
+function previousFieldPlaceholderMatchesRegExp(element, regExp) {
+ return previousFieldPredicateIsTrue(element, previousField =>
+ placeholderMatchesRegExp(previousField, regExp)
+ );
+}
+
+function previousFieldAriaLabelMatchesRegExp(element, regExp) {
+ return previousFieldPredicateIsTrue(element, previousField =>
+ ariaLabelMatchesRegExp(previousField, regExp)
+ );
+}
+//////////////////////////////////////////////
+
+function isSelectWithCreditCardOptions(fnode) {
+ // Check every select for options that match credit card network names in
+ // value or label.
+ const element = fnode.element;
+ if (element.tagName === "SELECT") {
+ for (let option of element.querySelectorAll("option")) {
+ if (
+ CreditCard.getNetworkFromName(option.value) ||
+ CreditCard.getNetworkFromName(option.text)
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+/**
+ * If any of the regular expressions match multiple times, we assume the tested
+ * string belongs to a radio button for payment type instead of card type.
+ *
+ * @param {Fnode} fnode
+ * @returns {boolean}
+ */
+function isRadioWithCreditCardText(fnode) {
+ const element = fnode.element;
+ const inputType = element.type;
+ if (!!inputType && inputType === "radio") {
+ const valueMatches = element.value.match(CREDIT_CARD_NETWORK_REGEXP);
+ if (valueMatches) {
+ return valueMatches.length === 1;
+ }
+
+ // Here we are checking that only one label matches only one entry in the regular expression.
+ const labels = getElementLabels(element);
+ let labelsMatched = 0;
+ for (const label of labels) {
+ const labelMatches = label.match(CREDIT_CARD_NETWORK_REGEXP);
+ if (labelMatches) {
+ if (labelMatches.length > 1) {
+ return false;
+ }
+ labelsMatched++;
+ }
+ }
+ if (labelsMatched > 0) {
+ return labelsMatched === 1;
+ }
+
+ const textContentMatches = element.textContent.match(
+ CREDIT_CARD_NETWORK_REGEXP
+ );
+ if (textContentMatches) {
+ return textContentMatches.length === 1;
+ }
+ }
+ return false;
+}
+
+function matchContiguousSubArray(array, subArray) {
+ return array.some((elm, i) =>
+ subArray.every((sElem, j) => sElem === array[i + j])
+ );
+}
+
+function isExpirationMonthLikely(element) {
+ if (element.tagName !== "SELECT") {
+ return false;
+ }
+
+ const options = [...element.options];
+ const desiredValues = Array(12)
+ .fill(1)
+ .map((v, i) => v + i);
+
+ // The number of month options shouldn't be less than 12 or larger than 13
+ // including the default option.
+ if (options.length < 12 || options.length > 13) {
+ return false;
+ }
+
+ return (
+ matchContiguousSubArray(
+ options.map(e => +e.value),
+ desiredValues
+ ) ||
+ matchContiguousSubArray(
+ options.map(e => +e.label),
+ desiredValues
+ )
+ );
+}
+
+function isExpirationYearLikely(element) {
+ if (element.tagName !== "SELECT") {
+ return false;
+ }
+
+ const options = [...element.options];
+ // A normal expiration year select should contain at least the last three years
+ // in the list.
+ const curYear = new Date().getFullYear();
+ const desiredValues = Array(3)
+ .fill(0)
+ .map((v, i) => v + curYear + i);
+
+ return (
+ matchContiguousSubArray(
+ options.map(e => +e.value),
+ desiredValues
+ ) ||
+ matchContiguousSubArray(
+ options.map(e => +e.label),
+ desiredValues
+ )
+ );
+}
+
+function nextFieldIsExpirationYearLikely(fnode) {
+ return nextFieldPredicateIsTrue(fnode.element, isExpirationYearLikely);
+}
+
+function previousFieldIsExpirationMonthLikely(fnode) {
+ return previousFieldPredicateIsTrue(fnode.element, isExpirationMonthLikely);
+}
+
+function attrsMatchExpWith2Or4DigitYear(fnode, regExpMatchingFunction) {
+ const element = fnode.element;
+ return (
+ regExpMatchingFunction(element, TwoDigitYearRegExp) ||
+ regExpMatchingFunction(element, FourDigitYearRegExp)
+ );
+}
+
+function maxLengthIs(fnode, maxLengthValue) {
+ return fnode.element.maxLength === maxLengthValue;
+}
+
+function roleIsMenu(fnode) {
+ const role = fnode.element.getAttribute("role");
+ return !!role && role === "menu";
+}
+
+function idOrNameMatchDwfrmAndBml(fnode) {
+ return (
+ idOrNameMatchRegExp(fnode.element, dwfrmRegExp) &&
+ idOrNameMatchRegExp(fnode.element, bmlRegExp)
+ );
+}
+
+function hasTemplatedValue(fnode) {
+ const value = fnode.element.getAttribute("value");
+ return !!value && templatedValue.test(value);
+}
+
+function inputTypeNotNumbery(fnode) {
+ const inputType = fnode.element.type;
+ if (inputType) {
+ return !["text", "tel", "number"].includes(inputType);
+ }
+ return false;
+}
+
+function idOrNameMatchFirstAndLast(fnode) {
+ return (
+ idOrNameMatchRegExp(fnode.element, firstRegExp) &&
+ idOrNameMatchRegExp(fnode.element, lastRegExp)
+ );
+}
+
+/**
+ * Compactly generate a series of rules that all take a single LHS type with no
+ * .when() clause and have only a score() call on the right- hand side.
+ *
+ * @param {Lhs} inType The incoming fnode type that all rules take
+ * @param {object} ruleMap A simple object used as a map with rule names
+ * pointing to scoring callbacks
+ * @yields {Rule}
+ */
+function* simpleScoringRules(inType, ruleMap) {
+ for (const [name, scoringCallback] of Object.entries(ruleMap)) {
+ yield rule(type(inType), score(scoringCallback), { name });
+ }
+}
+
+function makeRuleset(coeffs, biases) {
+ return ruleset(
+ [
+ /**
+ * Factor out the page scan just for a little more speed during training.
+ * This selector is good for most fields. cardType is an exception: it
+ * cannot be type=month.
+ */
+ rule(
+ queriedOrClickedElements(
+ "input:not([type]), input[type=text], input[type=textbox], input[type=email], input[type=tel], input[type=number], input[type=month], select, button"
+ ),
+ type("typicalCandidates")
+ ),
+
+ /**
+ * number rules
+ */
+ rule(type("typicalCandidates"), type("cc-number")),
+ ...simpleScoringRules("cc-number", {
+ idOrNameMatchNumberRegExp: fnode =>
+ idOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-number"]
+ ),
+ labelsMatchNumberRegExp: fnode =>
+ labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-number"]),
+ closestLabelMatchesNumberRegExp: fnode =>
+ closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-number"]),
+ placeholderMatchesNumberRegExp: fnode =>
+ placeholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-number"]
+ ),
+ ariaLabelMatchesNumberRegExp: fnode =>
+ ariaLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-number"]
+ ),
+ idOrNameMatchGift: fnode =>
+ idOrNameMatchRegExp(fnode.element, giftRegExp),
+ labelsMatchGift: fnode => labelsMatchRegExp(fnode.element, giftRegExp),
+ placeholderMatchesGift: fnode =>
+ placeholderMatchesRegExp(fnode.element, giftRegExp),
+ ariaLabelMatchesGift: fnode =>
+ ariaLabelMatchesRegExp(fnode.element, giftRegExp),
+ idOrNameMatchSubscription: fnode =>
+ idOrNameMatchRegExp(fnode.element, subscriptionRegExp),
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ inputTypeNotNumbery,
+ }),
+ rule(type("cc-number"), out("cc-number")),
+
+ /**
+ * name rules
+ */
+ rule(type("typicalCandidates"), type("cc-name")),
+ ...simpleScoringRules("cc-name", {
+ idOrNameMatchNameRegExp: fnode =>
+ idOrNameMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-name"]),
+ labelsMatchNameRegExp: fnode =>
+ labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-name"]),
+ closestLabelMatchesNameRegExp: fnode =>
+ closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-name"]),
+ placeholderMatchesNameRegExp: fnode =>
+ placeholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-name"]
+ ),
+ ariaLabelMatchesNameRegExp: fnode =>
+ ariaLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-name"]
+ ),
+ idOrNameMatchFirst: fnode =>
+ idOrNameMatchRegExp(fnode.element, firstRegExp),
+ labelsMatchFirst: fnode =>
+ labelsMatchRegExp(fnode.element, firstRegExp),
+ placeholderMatchesFirst: fnode =>
+ placeholderMatchesRegExp(fnode.element, firstRegExp),
+ ariaLabelMatchesFirst: fnode =>
+ ariaLabelMatchesRegExp(fnode.element, firstRegExp),
+ idOrNameMatchLast: fnode =>
+ idOrNameMatchRegExp(fnode.element, lastRegExp),
+ labelsMatchLast: fnode => labelsMatchRegExp(fnode.element, lastRegExp),
+ placeholderMatchesLast: fnode =>
+ placeholderMatchesRegExp(fnode.element, lastRegExp),
+ ariaLabelMatchesLast: fnode =>
+ ariaLabelMatchesRegExp(fnode.element, lastRegExp),
+ idOrNameMatchSubscription: fnode =>
+ idOrNameMatchRegExp(fnode.element, subscriptionRegExp),
+ idOrNameMatchFirstAndLast,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ }),
+ rule(type("cc-name"), out("cc-name")),
+
+ /**
+ * cardType rules
+ */
+ rule(
+ queriedOrClickedElements(
+ "input:not([type]), input[type=text], input[type=textbox], input[type=email], input[type=tel], input[type=number], input[type=radio], select, button"
+ ),
+ type("cc-type")
+ ),
+ ...simpleScoringRules("cc-type", {
+ idOrNameMatchTypeRegExp: fnode =>
+ idOrNameMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-type"]),
+ labelsMatchTypeRegExp: fnode =>
+ labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-type"]),
+ closestLabelMatchesTypeRegExp: fnode =>
+ closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-type"]),
+ idOrNameMatchVisaCheckout: fnode =>
+ idOrNameMatchRegExp(fnode.element, VisaCheckoutRegExp),
+ ariaLabelMatchesVisaCheckout: fnode =>
+ ariaLabelMatchesRegExp(fnode.element, VisaCheckoutRegExp),
+ isSelectWithCreditCardOptions,
+ isRadioWithCreditCardText,
+ idOrNameMatchSubscription: fnode =>
+ idOrNameMatchRegExp(fnode.element, subscriptionRegExp),
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ }),
+ rule(type("cc-type"), out("cc-type")),
+
+ /**
+ * expiration rules
+ */
+ rule(type("typicalCandidates"), type("cc-exp")),
+ ...simpleScoringRules("cc-exp", {
+ labelsMatchExpRegExp: fnode =>
+ labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-exp"]),
+ closestLabelMatchesExpRegExp: fnode =>
+ closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-exp"]),
+ placeholderMatchesExpRegExp: fnode =>
+ placeholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp"]
+ ),
+ labelsMatchExpWith2Or4DigitYear: fnode =>
+ attrsMatchExpWith2Or4DigitYear(fnode, labelsMatchRegExp),
+ placeholderMatchesExpWith2Or4DigitYear: fnode =>
+ attrsMatchExpWith2Or4DigitYear(fnode, placeholderMatchesRegExp),
+ labelsMatchMMYY: fnode => labelsMatchRegExp(fnode.element, MMYYRegExp),
+ placeholderMatchesMMYY: fnode =>
+ placeholderMatchesRegExp(fnode.element, MMYYRegExp),
+ maxLengthIs7: fnode => maxLengthIs(fnode, 7),
+ idOrNameMatchSubscription: fnode =>
+ idOrNameMatchRegExp(fnode.element, subscriptionRegExp),
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ isExpirationMonthLikely: fnode =>
+ isExpirationMonthLikely(fnode.element),
+ isExpirationYearLikely: fnode => isExpirationYearLikely(fnode.element),
+ idOrNameMatchMonth: fnode =>
+ idOrNameMatchRegExp(fnode.element, monthRegExp),
+ idOrNameMatchYear: fnode =>
+ idOrNameMatchRegExp(fnode.element, yearRegExp),
+ idOrNameMatchExpMonthRegExp: fnode =>
+ idOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ idOrNameMatchExpYearRegExp: fnode =>
+ idOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ idOrNameMatchValidation: fnode =>
+ idOrNameMatchRegExp(fnode.element, /validate|validation/i),
+ }),
+ rule(type("cc-exp"), out("cc-exp")),
+
+ /**
+ * expirationMonth rules
+ */
+ rule(type("typicalCandidates"), type("cc-exp-month")),
+ ...simpleScoringRules("cc-exp-month", {
+ idOrNameMatchExpMonthRegExp: fnode =>
+ idOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ labelsMatchExpMonthRegExp: fnode =>
+ labelsMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ closestLabelMatchesExpMonthRegExp: fnode =>
+ closestLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ placeholderMatchesExpMonthRegExp: fnode =>
+ placeholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ ariaLabelMatchesExpMonthRegExp: fnode =>
+ ariaLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ idOrNameMatchMonth: fnode =>
+ idOrNameMatchRegExp(fnode.element, monthRegExp),
+ labelsMatchMonth: fnode =>
+ labelsMatchRegExp(fnode.element, monthRegExp),
+ placeholderMatchesMonth: fnode =>
+ placeholderMatchesRegExp(fnode.element, monthRegExp),
+ ariaLabelMatchesMonth: fnode =>
+ ariaLabelMatchesRegExp(fnode.element, monthRegExp),
+ nextFieldIdOrNameMatchExpYearRegExp: fnode =>
+ nextFieldIdOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ nextFieldLabelsMatchExpYearRegExp: fnode =>
+ nextFieldLabelsMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ nextFieldPlaceholderMatchExpYearRegExp: fnode =>
+ nextFieldPlaceholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ nextFieldAriaLabelMatchExpYearRegExp: fnode =>
+ nextFieldAriaLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ nextFieldIdOrNameMatchYear: fnode =>
+ nextFieldIdOrNameMatchRegExp(fnode.element, yearRegExp),
+ nextFieldLabelsMatchYear: fnode =>
+ nextFieldLabelsMatchRegExp(fnode.element, yearRegExp),
+ nextFieldPlaceholderMatchesYear: fnode =>
+ nextFieldPlaceholderMatchesRegExp(fnode.element, yearRegExp),
+ nextFieldAriaLabelMatchesYear: fnode =>
+ nextFieldAriaLabelMatchesRegExp(fnode.element, yearRegExp),
+ nextFieldMatchesExpYearAutocomplete,
+ isExpirationMonthLikely: fnode =>
+ isExpirationMonthLikely(fnode.element),
+ nextFieldIsExpirationYearLikely,
+ maxLengthIs2: fnode => maxLengthIs(fnode, 2),
+ placeholderMatchesMM: fnode =>
+ placeholderMatchesRegExp(fnode.element, MMRegExp),
+ roleIsMenu,
+ idOrNameMatchSubscription: fnode =>
+ idOrNameMatchRegExp(fnode.element, subscriptionRegExp),
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ }),
+ rule(type("cc-exp-month"), out("cc-exp-month")),
+
+ /**
+ * expirationYear rules
+ */
+ rule(type("typicalCandidates"), type("cc-exp-year")),
+ ...simpleScoringRules("cc-exp-year", {
+ idOrNameMatchExpYearRegExp: fnode =>
+ idOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ labelsMatchExpYearRegExp: fnode =>
+ labelsMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ closestLabelMatchesExpYearRegExp: fnode =>
+ closestLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ placeholderMatchesExpYearRegExp: fnode =>
+ placeholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ ariaLabelMatchesExpYearRegExp: fnode =>
+ ariaLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ idOrNameMatchYear: fnode =>
+ idOrNameMatchRegExp(fnode.element, yearRegExp),
+ labelsMatchYear: fnode => labelsMatchRegExp(fnode.element, yearRegExp),
+ placeholderMatchesYear: fnode =>
+ placeholderMatchesRegExp(fnode.element, yearRegExp),
+ ariaLabelMatchesYear: fnode =>
+ ariaLabelMatchesRegExp(fnode.element, yearRegExp),
+ previousFieldIdOrNameMatchExpMonthRegExp: fnode =>
+ previousFieldIdOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ previousFieldLabelsMatchExpMonthRegExp: fnode =>
+ previousFieldLabelsMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ previousFieldPlaceholderMatchExpMonthRegExp: fnode =>
+ previousFieldPlaceholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ previousFieldAriaLabelMatchExpMonthRegExp: fnode =>
+ previousFieldAriaLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ previousFieldIdOrNameMatchMonth: fnode =>
+ previousFieldIdOrNameMatchRegExp(fnode.element, monthRegExp),
+ previousFieldLabelsMatchMonth: fnode =>
+ previousFieldLabelsMatchRegExp(fnode.element, monthRegExp),
+ previousFieldPlaceholderMatchesMonth: fnode =>
+ previousFieldPlaceholderMatchesRegExp(fnode.element, monthRegExp),
+ previousFieldAriaLabelMatchesMonth: fnode =>
+ previousFieldAriaLabelMatchesRegExp(fnode.element, monthRegExp),
+ previousFieldMatchesExpMonthAutocomplete,
+ isExpirationYearLikely: fnode => isExpirationYearLikely(fnode.element),
+ previousFieldIsExpirationMonthLikely,
+ placeholderMatchesYYOrYYYY: fnode =>
+ placeholderMatchesRegExp(fnode.element, YYorYYYYRegExp),
+ roleIsMenu,
+ idOrNameMatchSubscription: fnode =>
+ idOrNameMatchRegExp(fnode.element, subscriptionRegExp),
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ }),
+ rule(type("cc-exp-year"), out("cc-exp-year")),
+ ],
+ coeffs,
+ biases
+ );
+}
+
+const coefficients = {
+ "cc-number": [
+ ["idOrNameMatchNumberRegExp", 7.679469585418701],
+ ["labelsMatchNumberRegExp", 5.122580051422119],
+ ["closestLabelMatchesNumberRegExp", 2.1256935596466064],
+ ["placeholderMatchesNumberRegExp", 9.471800804138184],
+ ["ariaLabelMatchesNumberRegExp", 6.067715644836426],
+ ["idOrNameMatchGift", -22.946273803710938],
+ ["labelsMatchGift", -7.852959632873535],
+ ["placeholderMatchesGift", -2.355496406555176],
+ ["ariaLabelMatchesGift", -2.940307855606079],
+ ["idOrNameMatchSubscription", 0.11255314946174622],
+ ["idOrNameMatchDwfrmAndBml", -0.0006645023822784424],
+ ["hasTemplatedValue", -0.11370040476322174],
+ ["inputTypeNotNumbery", -3.750155210494995]
+ ],
+ "cc-name": [
+ ["idOrNameMatchNameRegExp", 7.496212959289551],
+ ["labelsMatchNameRegExp", 6.081472873687744],
+ ["closestLabelMatchesNameRegExp", 2.600574254989624],
+ ["placeholderMatchesNameRegExp", 5.750874042510986],
+ ["ariaLabelMatchesNameRegExp", 5.162227153778076],
+ ["idOrNameMatchFirst", -6.742659091949463],
+ ["labelsMatchFirst", -0.5234538912773132],
+ ["placeholderMatchesFirst", -3.4615235328674316],
+ ["ariaLabelMatchesFirst", -1.3145145177841187],
+ ["idOrNameMatchLast", -12.561869621276855],
+ ["labelsMatchLast", -0.27417105436325073],
+ ["placeholderMatchesLast", -1.434966802597046],
+ ["ariaLabelMatchesLast", -2.9319725036621094],
+ ["idOrNameMatchFirstAndLast", 24.123435974121094],
+ ["idOrNameMatchSubscription", 0.08349418640136719],
+ ["idOrNameMatchDwfrmAndBml", 0.01882520318031311],
+ ["hasTemplatedValue", 0.182317852973938]
+ ],
+ "cc-type": [
+ ["idOrNameMatchTypeRegExp", 2.0581533908843994],
+ ["labelsMatchTypeRegExp", 1.0784518718719482],
+ ["closestLabelMatchesTypeRegExp", 0.6995877623558044],
+ ["idOrNameMatchVisaCheckout", -3.320356845855713],
+ ["ariaLabelMatchesVisaCheckout", -3.4196767807006836],
+ ["isSelectWithCreditCardOptions", 10.337477684020996],
+ ["isRadioWithCreditCardText", 4.530318737030029],
+ ["idOrNameMatchSubscription", -3.7206356525421143],
+ ["idOrNameMatchDwfrmAndBml", -0.08782318234443665],
+ ["hasTemplatedValue", 0.1772511601448059]
+ ],
+ "cc-exp": [
+ ["labelsMatchExpRegExp", 7.588159561157227],
+ ["closestLabelMatchesExpRegExp", 1.41484534740448],
+ ["placeholderMatchesExpRegExp", 8.759064674377441],
+ ["labelsMatchExpWith2Or4DigitYear", -3.876218795776367],
+ ["placeholderMatchesExpWith2Or4DigitYear", 2.8364884853363037],
+ ["labelsMatchMMYY", 8.836017608642578],
+ ["placeholderMatchesMMYY", -0.5231751799583435],
+ ["maxLengthIs7", 1.3565447330474854],
+ ["idOrNameMatchSubscription", 0.1779913753271103],
+ ["idOrNameMatchDwfrmAndBml", 0.21037884056568146],
+ ["hasTemplatedValue", 0.14900512993335724],
+ ["isExpirationMonthLikely", -3.223409652709961],
+ ["isExpirationYearLikely", -2.536919593811035],
+ ["idOrNameMatchMonth", -3.6893014907836914],
+ ["idOrNameMatchYear", -3.108184337615967],
+ ["idOrNameMatchExpMonthRegExp", -2.264357089996338],
+ ["idOrNameMatchExpYearRegExp", -2.7957723140716553],
+ ["idOrNameMatchValidation", -2.29402756690979]
+ ],
+ "cc-exp-month": [
+ ["idOrNameMatchExpMonthRegExp", 0.2787344455718994],
+ ["labelsMatchExpMonthRegExp", 1.298413634300232],
+ ["closestLabelMatchesExpMonthRegExp", -11.206244468688965],
+ ["placeholderMatchesExpMonthRegExp", 1.2605619430541992],
+ ["ariaLabelMatchesExpMonthRegExp", 1.1330018043518066],
+ ["idOrNameMatchMonth", 6.1464314460754395],
+ ["labelsMatchMonth", 0.7051732540130615],
+ ["placeholderMatchesMonth", 0.7463492751121521],
+ ["ariaLabelMatchesMonth", 1.8244760036468506],
+ ["nextFieldIdOrNameMatchExpYearRegExp", 0.06347066164016724],
+ ["nextFieldLabelsMatchExpYearRegExp", -0.1692247837781906],
+ ["nextFieldPlaceholderMatchExpYearRegExp", 1.0434566736221313],
+ ["nextFieldAriaLabelMatchExpYearRegExp", 1.751156210899353],
+ ["nextFieldIdOrNameMatchYear", -0.532447338104248],
+ ["nextFieldLabelsMatchYear", 1.3248541355133057],
+ ["nextFieldPlaceholderMatchesYear", 0.604235827922821],
+ ["nextFieldAriaLabelMatchesYear", 1.5364223718643188],
+ ["nextFieldMatchesExpYearAutocomplete", 6.285938262939453],
+ ["isExpirationMonthLikely", 13.117807388305664],
+ ["nextFieldIsExpirationYearLikely", 7.182341575622559],
+ ["maxLengthIs2", 4.477289199829102],
+ ["placeholderMatchesMM", 14.403288841247559],
+ ["roleIsMenu", 5.770959854125977],
+ ["idOrNameMatchSubscription", -0.043085768818855286],
+ ["idOrNameMatchDwfrmAndBml", 0.02823038399219513],
+ ["hasTemplatedValue", 0.07234494388103485]
+ ],
+ "cc-exp-year": [
+ ["idOrNameMatchExpYearRegExp", 5.426016807556152],
+ ["labelsMatchExpYearRegExp", 1.3240209817886353],
+ ["closestLabelMatchesExpYearRegExp", -8.702284812927246],
+ ["placeholderMatchesExpYearRegExp", 0.9059725999832153],
+ ["ariaLabelMatchesExpYearRegExp", 0.5550334453582764],
+ ["idOrNameMatchYear", 5.362994194030762],
+ ["labelsMatchYear", 2.7185044288635254],
+ ["placeholderMatchesYear", 0.7883157134056091],
+ ["ariaLabelMatchesYear", 0.311492383480072],
+ ["previousFieldIdOrNameMatchExpMonthRegExp", 1.8155208826065063],
+ ["previousFieldLabelsMatchExpMonthRegExp", -0.46133187413215637],
+ ["previousFieldPlaceholderMatchExpMonthRegExp", 1.0374903678894043],
+ ["previousFieldAriaLabelMatchExpMonthRegExp", -0.5901495814323425],
+ ["previousFieldIdOrNameMatchMonth", -5.960310935974121],
+ ["previousFieldLabelsMatchMonth", 0.6495584845542908],
+ ["previousFieldPlaceholderMatchesMonth", 0.7198042273521423],
+ ["previousFieldAriaLabelMatchesMonth", 3.4590985774993896],
+ ["previousFieldMatchesExpMonthAutocomplete", 2.986003875732422],
+ ["isExpirationYearLikely", 4.021566390991211],
+ ["previousFieldIsExpirationMonthLikely", 9.298635482788086],
+ ["placeholderMatchesYYOrYYYY", 10.457176208496094],
+ ["roleIsMenu", 1.1051956415176392],
+ ["idOrNameMatchSubscription", 0.000688597559928894],
+ ["idOrNameMatchDwfrmAndBml", 0.15687309205532074],
+ ["hasTemplatedValue", -0.19141331315040588]
+ ],
+};
+
+const biases = [
+ ["cc-number", -4.948795795440674],
+ ["cc-name", -5.3578081130981445],
+ ["cc-type", -5.979659557342529],
+ ["cc-exp", -5.849575996398926],
+ ["cc-exp-month", -8.844199180603027],
+ ["cc-exp-year", -6.499860763549805],
+];
+
+/**
+ * END OF CODE PASTED FROM TRAINING REPOSITORY
+ */
+
+/**
+ * MORE CODE UNIQUE TO PRODUCTION--NOT IN THE TRAINING REPOSITORY:
+ */
+// Currently there is a bug when a ruleset has multple types (ex, cc-name, cc-number)
+// and those types also has the same rules (ex. rule `hasTemplatedValue` is used in
+// all the tyoes). When the above case exists, the coefficient of the rule will be
+// overwritten, which means, we can't have different coefficient for the same rule on
+// different types. To workaround this issue, we create a new ruleset for each type.
+export var CreditCardRulesets = {
+ init() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "supportedTypes",
+ "extensions.formautofill.creditCards.heuristics.fathom.types",
+ null,
+ null,
+ val => val.split(",")
+ );
+
+ for (const type of this.types) {
+ this[type] = makeRuleset([...coefficients[type]], biases);
+ }
+ },
+
+ get types() {
+ return this.supportedTypes;
+ },
+};
+
+CreditCardRulesets.init();
+
+export default CreditCardRulesets;
diff --git a/toolkit/components/formautofill/shared/FieldScanner.sys.mjs b/toolkit/components/formautofill/shared/FieldScanner.sys.mjs
new file mode 100644
index 0000000000..22adfdabe8
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FieldScanner.sys.mjs
@@ -0,0 +1,224 @@
+/* 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/. */
+
+/**
+ * Represents the detailed information about a form field, including
+ * the inferred field name, the approach used for inferring, and additional metadata.
+ */
+export class FieldDetail {
+ // Reference to the elemenet
+ elementWeakRef = null;
+
+ // id/name. This is only used for debugging
+ identifier = "";
+
+ // The inferred field name for this element
+ fieldName = null;
+
+ // The approach we use to infer the information for this element
+ // The possible values are "autocomplete", "fathom", and "regex-heuristic"
+ reason = null;
+
+ /*
+ * The "section", "addressType", and "contactType" values are
+ * used to identify the exact field when the serializable data is received
+ * from the backend. There cannot be multiple fields which have
+ * the same exact combination of these values.
+ */
+
+ // Which section the field belongs to. The value comes from autocomplete attribute.
+ // See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-detail-tokens for more details
+ section = "";
+ addressType = "";
+ contactType = "";
+ credentialType = "";
+
+ // When a field is split into N fields, we use part to record which field it is
+ // For example, a credit card number field is split into 4 fields, the value of
+ // "part" for the first cc-number field is 1, for the last one is 4.
+ // If the field is not split, the value is null
+ part = null;
+
+ // Confidence value when the field name is inferred by "fathom"
+ confidence = null;
+
+ constructor(
+ element,
+ fieldName = null,
+ { autocompleteInfo = {}, confidence = null } = {}
+ ) {
+ this.elementWeakRef = new WeakRef(element);
+ this.identifier = `${element.id}/${element.name}`;
+ this.fieldName = fieldName;
+
+ if (autocompleteInfo) {
+ this.reason = "autocomplete";
+ this.section = autocompleteInfo.section;
+ this.addressType = autocompleteInfo.addressType;
+ this.contactType = autocompleteInfo.contactType;
+ this.credentialType = autocompleteInfo.credentialType;
+ } else if (confidence) {
+ this.reason = "fathom";
+ this.confidence = confidence;
+ } else {
+ this.reason = "regex-heuristic";
+ }
+ }
+
+ get element() {
+ return this.elementWeakRef.deref();
+ }
+
+ get sectionName() {
+ return this.section || this.addressType;
+ }
+}
+
+/**
+ * A scanner for traversing all elements in a form. It also provides a
+ * cursor (parsingIndex) to indicate which element is waiting for parsing.
+ *
+ * The scanner retrives the field detail by calling heuristics handlers
+ * `inferFieldInfo` function.
+ */
+export class FieldScanner {
+ #elementsWeakRef = null;
+ #inferFieldInfoFn = null;
+
+ #parsingIndex = 0;
+
+ fieldDetails = [];
+
+ /**
+ * Create a FieldScanner based on form elements with the existing
+ * fieldDetails.
+ *
+ * @param {Array.DOMElement} elements
+ * The elements from a form for each parser.
+ * @param {Funcion} inferFieldInfoFn
+ * The callback function that is used to infer the field info of a given element
+ */
+ constructor(elements, inferFieldInfoFn) {
+ this.#elementsWeakRef = new WeakRef(elements);
+ this.#inferFieldInfoFn = inferFieldInfoFn;
+ }
+
+ get #elements() {
+ return this.#elementsWeakRef.deref();
+ }
+
+ /**
+ * This cursor means the index of the element which is waiting for parsing.
+ *
+ * @returns {number}
+ * The index of the element which is waiting for parsing.
+ */
+ get parsingIndex() {
+ return this.#parsingIndex;
+ }
+
+ get parsingFinished() {
+ return this.parsingIndex >= this.#elements.length;
+ }
+
+ /**
+ * Move the parsingIndex to the next elements. Any elements behind this index
+ * means the parsing tasks are finished.
+ *
+ * @param {number} index
+ * The latest index of elements waiting for parsing.
+ */
+ set parsingIndex(index) {
+ if (index > this.#elements.length) {
+ throw new Error("The parsing index is out of range.");
+ }
+ this.#parsingIndex = index;
+ }
+
+ /**
+ * Retrieve the field detail by the index. If the field detail is not ready,
+ * the elements will be traversed until matching the index.
+ *
+ * @param {number} index
+ * The index of the element that you want to retrieve.
+ * @returns {object}
+ * The field detail at the specific index.
+ */
+ getFieldDetailByIndex(index) {
+ if (index >= this.#elements.length) {
+ return null;
+ }
+
+ if (index < this.fieldDetails.length) {
+ return this.fieldDetails[index];
+ }
+
+ for (let i = this.fieldDetails.length; i < index + 1; i++) {
+ this.pushDetail();
+ }
+
+ return this.fieldDetails[index];
+ }
+
+ /**
+ * This function retrieves the first unparsed element and obtains its
+ * information by invoking the `inferFieldInfoFn` callback function.
+ * The field information is then stored in a FieldDetail object and
+ * appended to the `fieldDetails` array.
+ *
+ * Any element without the related detail will be used for adding the detail
+ * to the end of field details.
+ */
+ pushDetail() {
+ const elementIndex = this.fieldDetails.length;
+ if (elementIndex >= this.#elements.length) {
+ throw new Error("Try to push the non-existing element info.");
+ }
+ const element = this.#elements[elementIndex];
+ const [fieldName, autocompleteInfo, confidence] =
+ this.#inferFieldInfoFn(element);
+ const fieldDetail = new FieldDetail(element, fieldName, {
+ autocompleteInfo,
+ confidence,
+ });
+
+ this.fieldDetails.push(fieldDetail);
+ }
+
+ /**
+ * When a field detail should be changed its fieldName after parsing, use
+ * this function to update the fieldName which is at a specific index.
+ *
+ * @param {number} index
+ * The index indicates a field detail to be updated.
+ * @param {string} fieldName
+ * The new name of the field
+ * @param {boolean} [ignoreAutocomplete=false]
+ * Whether to change the field name when the field name is determined by
+ * autocomplete attribute
+ */
+ updateFieldName(index, fieldName, ignoreAutocomplete = false) {
+ if (index >= this.fieldDetails.length) {
+ throw new Error("Try to update the non-existing field detail.");
+ }
+
+ const fieldDetail = this.fieldDetails[index];
+ if (fieldDetail.fieldName == fieldName) {
+ return;
+ }
+
+ if (!ignoreAutocomplete && fieldDetail.reason == "autocomplete") {
+ return;
+ }
+
+ this.fieldDetails[index].fieldName = fieldName;
+ this.fieldDetails[index].reason = "update-heuristic";
+ }
+
+ elementExisting(index) {
+ return index < this.#elements.length;
+ }
+}
+
+export default FieldScanner;
diff --git a/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs
new file mode 100644
index 0000000000..49f79be77a
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs
@@ -0,0 +1,411 @@
+/* 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/. */
+
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormAutofillAddressSection:
+ "resource://gre/modules/shared/FormAutofillSection.sys.mjs",
+ FormAutofillCreditCardSection:
+ "resource://gre/modules/shared/FormAutofillSection.sys.mjs",
+ FormAutofillHeuristics:
+ "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs",
+ FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs",
+ FormSection: "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs",
+});
+
+const { FIELD_STATES } = FormAutofillUtils;
+
+/**
+ * Handles profile autofill for a DOM Form element.
+ */
+export class FormAutofillHandler {
+ // The window to which this form belongs
+ window = null;
+
+ // A WindowUtils reference of which Window the form belongs
+ winUtils = null;
+
+ // DOM Form element to which this object is attached
+ form = null;
+
+ // An array of section that are found in this form
+ sections = [];
+
+ // The section contains the focused input
+ #focusedSection = null;
+
+ // Caches the element to section mapping
+ #cachedSectionByElement = new WeakMap();
+
+ // Keeps track of filled state for all identified elements
+ #filledStateByElement = new WeakMap();
+ /**
+ * Array of collected data about relevant form fields. Each item is an object
+ * storing the identifying details of the field and a reference to the
+ * originally associated element from the form.
+ *
+ * The "section", "addressType", "contactType", and "fieldName" values are
+ * used to identify the exact field when the serializable data is received
+ * from the backend. There cannot be multiple fields which have
+ * the same exact combination of these values.
+ *
+ * A direct reference to the associated element cannot be sent to the user
+ * interface because processing may be done in the parent process.
+ */
+ fieldDetails = null;
+
+ /**
+ * Initialize the form from `FormLike` object to handle the section or form
+ * operations.
+ *
+ * @param {FormLike} form Form that need to be auto filled
+ * @param {Function} onFormSubmitted Function that can be invoked
+ * to simulate form submission. Function is passed
+ * four arguments: (1) a FormLike for the form being
+ * submitted, (2) the reason for infering the form
+ * submission (3) the corresponding Window, and (4)
+ * the responsible FormAutofillHandler.
+ * @param {Function} onAutofillCallback Function that can be invoked
+ * when we want to suggest autofill on a form.
+ */
+ constructor(form, onFormSubmitted = () => {}, onAutofillCallback = () => {}) {
+ this._updateForm(form);
+
+ this.window = this.form.rootElement.ownerGlobal;
+ this.winUtils = this.window.windowUtils;
+
+ // Enum for form autofill MANUALLY_MANAGED_STATES values
+ this.FIELD_STATE_ENUM = {
+ // not themed
+ [FIELD_STATES.NORMAL]: null,
+ // highlighted
+ [FIELD_STATES.AUTO_FILLED]: "autofill",
+ // highlighted && grey color text
+ [FIELD_STATES.PREVIEW]: "-moz-autofill-preview",
+ };
+
+ /**
+ * This function is used if the form handler (or one of its sections)
+ * determines that it needs to act as if the form had been submitted.
+ */
+ this.onFormSubmitted = formSubmissionReason => {
+ onFormSubmitted(this.form, formSubmissionReason, this.window, this);
+ };
+
+ this.onAutofillCallback = onAutofillCallback;
+
+ ChromeUtils.defineLazyGetter(this, "log", () =>
+ FormAutofill.defineLogGetter(this, "FormAutofillHandler")
+ );
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "input": {
+ if (!event.isTrusted) {
+ return;
+ }
+ const target = event.target;
+ const targetFieldDetail = this.getFieldDetailByElement(target);
+ const isCreditCardField = FormAutofillUtils.isCreditCardField(
+ targetFieldDetail.fieldName
+ );
+
+ // If the user manually blanks a credit card field, then
+ // we want the popup to be activated.
+ if (
+ !HTMLSelectElement.isInstance(target) &&
+ isCreditCardField &&
+ target.value === ""
+ ) {
+ this.onAutofillCallback();
+ }
+
+ if (this.getFilledStateByElement(target) == FIELD_STATES.NORMAL) {
+ return;
+ }
+
+ this.changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL);
+ const section = this.getSectionByElement(targetFieldDetail.element);
+ section?.clearFilled(targetFieldDetail);
+ }
+ }
+ }
+
+ set focusedInput(element) {
+ const section = this.getSectionByElement(element);
+ if (!section) {
+ return;
+ }
+
+ this.#focusedSection = section;
+ this.#focusedSection.focusedInput = element;
+ }
+
+ getSectionByElement(element) {
+ const section =
+ this.#cachedSectionByElement.get(element) ??
+ this.sections.find(s => s.getFieldDetailByElement(element));
+ if (!section) {
+ return null;
+ }
+
+ this.#cachedSectionByElement.set(element, section);
+ return section;
+ }
+
+ getFieldDetailByElement(element) {
+ for (const section of this.sections) {
+ const detail = section.getFieldDetailByElement(element);
+ if (detail) {
+ return detail;
+ }
+ }
+ return null;
+ }
+
+ get activeSection() {
+ return this.#focusedSection;
+ }
+
+ /**
+ * Check the form is necessary to be updated. This function should be able to
+ * detect any changes including all control elements in the form.
+ *
+ * @param {HTMLElement} element The element supposed to be in the form.
+ * @returns {boolean} FormAutofillHandler.form is updated or not.
+ */
+ updateFormIfNeeded(element) {
+ // When the following condition happens, FormAutofillHandler.form should be
+ // updated:
+ // * The count of form controls is changed.
+ // * When the element can not be found in the current form.
+ //
+ // However, we should improve the function to detect the element changes.
+ // e.g. a tel field is changed from type="hidden" to type="tel".
+
+ let _formLike;
+ const getFormLike = () => {
+ if (!_formLike) {
+ _formLike = lazy.FormLikeFactory.createFromField(element);
+ }
+ return _formLike;
+ };
+
+ const currentForm = element.form ?? getFormLike();
+ if (currentForm.elements.length != this.form.elements.length) {
+ this.log.debug("The count of form elements is changed.");
+ this._updateForm(getFormLike());
+ return true;
+ }
+
+ if (!this.form.elements.includes(element)) {
+ this.log.debug("The element can not be found in the current form.");
+ this._updateForm(getFormLike());
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Update the form with a new FormLike, and the related fields should be
+ * updated or clear to ensure the data consistency.
+ *
+ * @param {FormLike} form a new FormLike to replace the original one.
+ */
+ _updateForm(form) {
+ this.form = form;
+
+ this.fieldDetails = null;
+
+ this.sections = [];
+ this.#cachedSectionByElement = new WeakMap();
+ }
+
+ /**
+ * Set fieldDetails from the form about fields that can be autofilled.
+ *
+ * @returns {Array} The valid address and credit card details.
+ */
+ collectFormFields(ignoreInvalid = true) {
+ const sections = lazy.FormAutofillHeuristics.getFormInfo(this.form);
+ const allValidDetails = [];
+ for (const section of sections) {
+ // We don't support csc field, so remove csc fields from section
+ const fieldDetails = section.fieldDetails.filter(
+ f => !["cc-csc"].includes(f.fieldName)
+ );
+ if (!fieldDetails.length) {
+ continue;
+ }
+
+ let autofillableSection;
+ if (section.type == lazy.FormSection.ADDRESS) {
+ autofillableSection = new lazy.FormAutofillAddressSection(
+ fieldDetails,
+ this
+ );
+ } else {
+ autofillableSection = new lazy.FormAutofillCreditCardSection(
+ fieldDetails,
+ this
+ );
+ }
+
+ // Do not include section that is either disabled or invalid.
+ // We only include invalid section for testing purpose.
+ if (
+ !autofillableSection.isEnabled() ||
+ (ignoreInvalid && !autofillableSection.isValidSection())
+ ) {
+ continue;
+ }
+
+ this.sections.push(autofillableSection);
+ allValidDetails.push(...autofillableSection.fieldDetails);
+ }
+
+ this.fieldDetails = allValidDetails;
+ return allValidDetails;
+ }
+
+ #hasFilledSection() {
+ return this.sections.some(section => section.isFilled());
+ }
+
+ getFilledStateByElement(element) {
+ return this.#filledStateByElement.get(element);
+ }
+
+ /**
+ * Change the state of a field to correspond with different presentations.
+ *
+ * @param {object} fieldDetail
+ * A fieldDetail of which its element is about to update the state.
+ * @param {string} nextState
+ * Used to determine the next state
+ */
+ changeFieldState(fieldDetail, nextState) {
+ const element = fieldDetail.element;
+ if (!element) {
+ this.log.warn(
+ fieldDetail.fieldName,
+ "is unreachable while changing state"
+ );
+ return;
+ }
+ if (!(nextState in this.FIELD_STATE_ENUM)) {
+ this.log.warn(
+ fieldDetail.fieldName,
+ "is trying to change to an invalid state"
+ );
+ return;
+ }
+
+ if (this.#filledStateByElement.get(element) == nextState) {
+ return;
+ }
+
+ let nextStateValue = null;
+ for (const [state, mmStateValue] of Object.entries(this.FIELD_STATE_ENUM)) {
+ // The NORMAL state is simply the absence of other manually
+ // managed states so we never need to add or remove it.
+ if (!mmStateValue) {
+ continue;
+ }
+
+ if (state == nextState) {
+ nextStateValue = mmStateValue;
+ } else {
+ this.winUtils.removeManuallyManagedState(element, mmStateValue);
+ }
+ }
+
+ if (nextStateValue) {
+ this.winUtils.addManuallyManagedState(element, nextStateValue);
+ }
+
+ if (nextState == FIELD_STATES.AUTO_FILLED) {
+ element.addEventListener("input", this, { mozSystemGroup: true });
+ }
+
+ this.#filledStateByElement.set(element, nextState);
+ }
+
+ /**
+ * Processes form fields that can be autofilled, and populates them with the
+ * profile provided by backend.
+ *
+ * @param {object} profile
+ * A profile to be filled in.
+ */
+ async autofillFormFields(profile) {
+ const noFilledSectionsPreviously = !this.#hasFilledSection();
+ await this.activeSection.autofillFields(profile);
+
+ const onChangeHandler = e => {
+ if (!e.isTrusted) {
+ return;
+ }
+ if (e.type == "reset") {
+ this.sections.map(section => section.resetFieldStates());
+ }
+ // Unregister listeners once no field is in AUTO_FILLED state.
+ if (!this.#hasFilledSection()) {
+ this.form.rootElement.removeEventListener("input", onChangeHandler, {
+ mozSystemGroup: true,
+ });
+ this.form.rootElement.removeEventListener("reset", onChangeHandler, {
+ mozSystemGroup: true,
+ });
+ }
+ };
+
+ if (noFilledSectionsPreviously) {
+ // Handle the highlight style resetting caused by user's correction afterward.
+ this.log.debug("register change handler for filled form:", this.form);
+ this.form.rootElement.addEventListener("input", onChangeHandler, {
+ mozSystemGroup: true,
+ });
+ this.form.rootElement.addEventListener("reset", onChangeHandler, {
+ mozSystemGroup: true,
+ });
+ }
+ }
+
+ /**
+ * Collect the filled sections within submitted form and convert all the valid
+ * field data into multiple records.
+ *
+ * @returns {object} records
+ * {Array.<Object>} records.address
+ * {Array.<Object>} records.creditCard
+ */
+ createRecords() {
+ const records = {
+ address: [],
+ creditCard: [],
+ };
+
+ for (const section of this.sections) {
+ const secRecord = section.createRecord();
+ if (!secRecord) {
+ continue;
+ }
+ if (section instanceof lazy.FormAutofillAddressSection) {
+ records.address.push(secRecord);
+ } else if (section instanceof lazy.FormAutofillCreditCardSection) {
+ records.creditCard.push(secRecord);
+ } else {
+ throw new Error("Unknown section type");
+ }
+ }
+
+ return records;
+ }
+}
diff --git a/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs
new file mode 100644
index 0000000000..4ee1fc1fe1
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs
@@ -0,0 +1,1213 @@
+/* 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/. */
+
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { HeuristicsRegExp } from "resource://gre/modules/shared/HeuristicsRegExp.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ CreditCardRulesets: "resource://gre/modules/shared/CreditCardRuleset.sys.mjs",
+ FieldScanner: "resource://gre/modules/shared/FieldScanner.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs",
+});
+
+/**
+ * To help us classify sections, we want to know what fields can appear
+ * multiple times in a row.
+ * Such fields, like `address-line{X}`, should not break sections.
+ */
+const MULTI_FIELD_NAMES = [
+ "address-level3",
+ "address-level2",
+ "address-level1",
+ "tel",
+ "postal-code",
+ "email",
+ "street-address",
+];
+
+/**
+ * To help us classify sections that can appear only N times in a row.
+ * For example, the only time multiple cc-number fields are valid is when
+ * there are four of these fields in a row.
+ * Otherwise, multiple cc-number fields should be in separate sections.
+ */
+const MULTI_N_FIELD_NAMES = {
+ "cc-number": 4,
+};
+
+export class FormSection {
+ static ADDRESS = "address";
+ static CREDIT_CARD = "creditCard";
+
+ #fieldDetails = [];
+
+ #name = "";
+
+ constructor(fieldDetails) {
+ if (!fieldDetails.length) {
+ throw new TypeError("A section should contain at least one field");
+ }
+
+ fieldDetails.forEach(field => this.addField(field));
+
+ const fieldName = fieldDetails[0].fieldName;
+ if (lazy.FormAutofillUtils.isAddressField(fieldName)) {
+ this.type = FormSection.ADDRESS;
+ } else if (lazy.FormAutofillUtils.isCreditCardField(fieldName)) {
+ this.type = FormSection.CREDIT_CARD;
+ } else {
+ throw new Error("Unknown field type to create a section.");
+ }
+ }
+
+ get fieldDetails() {
+ return this.#fieldDetails;
+ }
+
+ get name() {
+ return this.#name;
+ }
+
+ addField(fieldDetail) {
+ this.#name ||= fieldDetail.sectionName;
+ this.#fieldDetails.push(fieldDetail);
+ }
+}
+
+/**
+ * Returns the autocomplete information of fields according to heuristics.
+ */
+export const FormAutofillHeuristics = {
+ RULES: HeuristicsRegExp.getRules(),
+ LABEL_RULES: HeuristicsRegExp.getLabelRules(),
+
+ CREDIT_CARD_FIELDNAMES: [],
+ ADDRESS_FIELDNAMES: [],
+ /**
+ * Try to find a contiguous sub-array within an array.
+ *
+ * @param {Array} array
+ * @param {Array} subArray
+ *
+ * @returns {boolean}
+ * Return whether subArray was found within the array or not.
+ */
+ _matchContiguousSubArray(array, subArray) {
+ return array.some((elm, i) =>
+ subArray.every((sElem, j) => sElem == array[i + j])
+ );
+ },
+
+ /**
+ * Try to find the field that is look like a month select.
+ *
+ * @param {DOMElement} element
+ * @returns {boolean}
+ * Return true if we observe the trait of month select in
+ * the current element.
+ */
+ _isExpirationMonthLikely(element) {
+ if (!HTMLSelectElement.isInstance(element)) {
+ return false;
+ }
+
+ const options = [...element.options];
+ const desiredValues = Array(12)
+ .fill(1)
+ .map((v, i) => v + i);
+
+ // The number of month options shouldn't be less than 12 or larger than 13
+ // including the default option.
+ if (options.length < 12 || options.length > 13) {
+ return false;
+ }
+
+ return (
+ this._matchContiguousSubArray(
+ options.map(e => +e.value),
+ desiredValues
+ ) ||
+ this._matchContiguousSubArray(
+ options.map(e => +e.label),
+ desiredValues
+ )
+ );
+ },
+
+ /**
+ * Try to find the field that is look like a year select.
+ *
+ * @param {DOMElement} element
+ * @returns {boolean}
+ * Return true if we observe the trait of year select in
+ * the current element.
+ */
+ _isExpirationYearLikely(element) {
+ if (!HTMLSelectElement.isInstance(element)) {
+ return false;
+ }
+
+ const options = [...element.options];
+ // A normal expiration year select should contain at least the last three years
+ // in the list.
+ const curYear = new Date().getFullYear();
+ const desiredValues = Array(3)
+ .fill(0)
+ .map((v, i) => v + curYear + i);
+
+ return (
+ this._matchContiguousSubArray(
+ options.map(e => +e.value),
+ desiredValues
+ ) ||
+ this._matchContiguousSubArray(
+ options.map(e => +e.label),
+ desiredValues
+ )
+ );
+ },
+
+ /**
+ * Try to match the telephone related fields to the grammar
+ * list to see if there is any valid telephone set and correct their
+ * field names.
+ *
+ * @param {FieldScanner} scanner
+ * The current parsing status for all elements
+ * @returns {boolean}
+ * Return true if there is any field can be recognized in the parser,
+ * otherwise false.
+ */
+ _parsePhoneFields(scanner, detail) {
+ let matchingResult;
+ const GRAMMARS = this.PHONE_FIELD_GRAMMARS;
+
+ function isGrammarSeparator(index) {
+ return !GRAMMARS[index][0];
+ }
+
+ const savedIndex = scanner.parsingIndex;
+ for (let ruleFrom = 0; ruleFrom < GRAMMARS.length; ) {
+ const detailStart = scanner.parsingIndex;
+ let ruleTo = ruleFrom;
+ for (let count = 0; ruleTo < GRAMMARS.length; ruleTo++, count++) {
+ // Bail out when reaching the end of the current set of grammars
+ // or there are no more elements to parse
+ if (
+ isGrammarSeparator(ruleTo) ||
+ !scanner.elementExisting(detailStart + count)
+ ) {
+ break;
+ }
+
+ const [category, , length] = GRAMMARS[ruleTo];
+ const detail = scanner.getFieldDetailByIndex(detailStart + count);
+
+ // If the field is not what this grammar rule is interested in, skip processing.
+ if (
+ !detail ||
+ detail.fieldName != category ||
+ detail.reason == "autocomplete"
+ ) {
+ break;
+ }
+
+ const element = detail.element;
+ if (length && (!element.maxLength || length < element.maxLength)) {
+ break;
+ }
+ }
+
+ // if we reach the grammar separator, that means all the previous rules are matched.
+ // Set the matchingResult so we update field names accordingly.
+ if (isGrammarSeparator(ruleTo)) {
+ matchingResult = { ruleFrom, ruleTo };
+ break;
+ }
+
+ // Fast forward to the next rule set.
+ for (; ruleFrom < GRAMMARS.length; ) {
+ if (isGrammarSeparator(ruleFrom++)) {
+ break;
+ }
+ }
+ }
+
+ if (matchingResult) {
+ const { ruleFrom, ruleTo } = matchingResult;
+ for (let i = ruleFrom; i < ruleTo; i++) {
+ scanner.updateFieldName(scanner.parsingIndex, GRAMMARS[i][1]);
+ scanner.parsingIndex++;
+ }
+ }
+
+ // If the previous parsed field is a "tel" field, run heuristic to see
+ // if the current field is a "tel-extension" field
+ const field = scanner.getFieldDetailByIndex(scanner.parsingIndex);
+ if (field && field.reason != "autocomplete") {
+ const prev = scanner.getFieldDetailByIndex(scanner.parsingIndex - 1);
+ if (
+ prev &&
+ lazy.FormAutofillUtils.getCategoryFromFieldName(prev.fieldName) == "tel"
+ ) {
+ const regExpTelExtension = new RegExp(
+ "\\bext|ext\\b|extension|ramal", // pt-BR, pt-PT
+ "iug"
+ );
+ if (this._matchRegexp(field.element, regExpTelExtension)) {
+ scanner.updateFieldName(scanner.parsingIndex, "tel-extension");
+ scanner.parsingIndex++;
+ }
+ }
+ }
+ return savedIndex != scanner.parsingIndex;
+ },
+
+ /**
+ * Try to find the correct address-line[1-3] sequence and correct their field
+ * names.
+ *
+ * @param {FieldScanner} scanner
+ * The current parsing status for all elements
+ * @returns {boolean}
+ * Return true if there is any field can be recognized in the parser,
+ * otherwise false.
+ */
+ _parseStreetAddressFields(scanner, fieldDetail) {
+ const INTERESTED_FIELDS = [
+ "street-address",
+ "address-line1",
+ "address-line2",
+ "address-line3",
+ ];
+
+ const fields = [];
+ for (let idx = scanner.parsingIndex; !scanner.parsingFinished; idx++) {
+ const detail = scanner.getFieldDetailByIndex(idx);
+ if (!INTERESTED_FIELDS.includes(detail?.fieldName)) {
+ break;
+ }
+ fields.push(detail);
+ }
+
+ if (!fields.length) {
+ return false;
+ }
+
+ switch (fields.length) {
+ case 1:
+ if (
+ fields[0].reason != "autocomplete" &&
+ ["address-line2", "address-line3"].includes(fields[0].fieldName)
+ ) {
+ scanner.updateFieldName(scanner.parsingIndex, "address-line1");
+ }
+ break;
+ case 2:
+ if (fields[0].reason == "autocomplete") {
+ if (
+ fields[0].fieldName == "street-address" &&
+ (fields[1].fieldName == "address-line2" ||
+ fields[1].reason != "autocomplete")
+ ) {
+ scanner.updateFieldName(
+ scanner.parsingIndex,
+ "address-line1",
+ true
+ );
+ }
+ } else {
+ scanner.updateFieldName(scanner.parsingIndex, "address-line1");
+ }
+
+ scanner.updateFieldName(scanner.parsingIndex + 1, "address-line2");
+ break;
+ case 3:
+ default:
+ scanner.updateFieldName(scanner.parsingIndex, "address-line1");
+ scanner.updateFieldName(scanner.parsingIndex + 1, "address-line2");
+ scanner.updateFieldName(scanner.parsingIndex + 2, "address-line3");
+ break;
+ }
+
+ scanner.parsingIndex += fields.length;
+ return true;
+ },
+
+ _parseAddressFields(scanner, fieldDetail) {
+ const INTERESTED_FIELDS = ["address-level1", "address-level2"];
+
+ if (!INTERESTED_FIELDS.includes(fieldDetail.fieldName)) {
+ return false;
+ }
+
+ const fields = [];
+ for (let idx = scanner.parsingIndex; !scanner.parsingFinished; idx++) {
+ const detail = scanner.getFieldDetailByIndex(idx);
+ if (!INTERESTED_FIELDS.includes(detail?.fieldName)) {
+ break;
+ }
+ fields.push(detail);
+ }
+
+ if (!fields.length) {
+ return false;
+ }
+
+ // State & City(address-level2)
+ if (fields.length == 1) {
+ if (fields[0].fieldName == "address-level2") {
+ const prev = scanner.getFieldDetailByIndex(scanner.parsingIndex - 1);
+ if (
+ prev &&
+ !prev.fieldName &&
+ HTMLSelectElement.isInstance(prev.element)
+ ) {
+ scanner.updateFieldName(scanner.parsingIndex - 1, "address-level1");
+ scanner.parsingIndex += 1;
+ return true;
+ }
+ const next = scanner.getFieldDetailByIndex(scanner.parsingIndex + 1);
+ if (
+ next &&
+ !next.fieldName &&
+ HTMLSelectElement.isInstance(next.element)
+ ) {
+ scanner.updateFieldName(scanner.parsingIndex + 1, "address-level1");
+ scanner.parsingIndex += 2;
+ return true;
+ }
+ }
+ }
+
+ scanner.parsingIndex += fields.length;
+ return true;
+ },
+
+ /**
+ * Try to look for expiration date fields and revise the field names if needed.
+ *
+ * @param {FieldScanner} scanner
+ * The current parsing status for all elements
+ * @returns {boolean}
+ * Return true if there is any field can be recognized in the parser,
+ * otherwise false.
+ */
+ _parseCreditCardExpiryFields(scanner, fieldDetail) {
+ const INTERESTED_FIELDS = ["cc-exp", "cc-exp-month", "cc-exp-year"];
+
+ if (!INTERESTED_FIELDS.includes(fieldDetail.fieldName)) {
+ return false;
+ }
+
+ const fields = [];
+ for (let idx = scanner.parsingIndex; ; idx++) {
+ const detail = scanner.getFieldDetailByIndex(idx);
+ if (!INTERESTED_FIELDS.includes(detail?.fieldName)) {
+ break;
+ }
+ fields.push(detail);
+ }
+
+ // Don't process the fields if expiration month and expiration year are already
+ // matched by regex in correct order.
+ if (
+ (fields.length == 1 && fields[0].fieldName == "cc-exp") ||
+ (fields.length == 2 &&
+ fields[0].fieldName == "cc-exp-month" &&
+ fields[1].fieldName == "cc-exp-year")
+ ) {
+ scanner.parsingIndex += fields.length;
+ return true;
+ }
+
+ const prevCCFields = new Set();
+ for (let idx = scanner.parsingIndex - 1; ; idx--) {
+ const detail = scanner.getFieldDetailByIndex(idx);
+ if (
+ lazy.FormAutofillUtils.getCategoryFromFieldName(detail?.fieldName) !=
+ "creditCard"
+ ) {
+ break;
+ }
+ prevCCFields.add(detail.fieldName);
+ }
+ // We update the "cc-exp-*" fields to correct "cc-ex-*" fields order when
+ // the following conditions are met:
+ // 1. The previous elements are identified as credit card fields and
+ // cc-number is in it
+ // 2. There is no "cc-exp-*" fields in the previous credit card elements
+ if (
+ ["cc-number", "cc-name"].some(f => prevCCFields.has(f)) &&
+ !["cc-exp", "cc-exp-month", "cc-exp-year"].some(f => prevCCFields.has(f))
+ ) {
+ if (fields.length == 1) {
+ scanner.updateFieldName(scanner.parsingIndex, "cc-exp");
+ } else if (fields.length == 2) {
+ scanner.updateFieldName(scanner.parsingIndex, "cc-exp-month");
+ scanner.updateFieldName(scanner.parsingIndex + 1, "cc-exp-year");
+ }
+ scanner.parsingIndex += fields.length;
+ return true;
+ }
+
+ // Set field name to null as it failed to match any patterns.
+ for (let idx = 0; idx < fields.length; idx++) {
+ scanner.updateFieldName(scanner.parsingIndex + idx, null);
+ }
+ return false;
+ },
+
+ /**
+ * Look for cc-*-name fields when *-name field is present
+ *
+ * @param {FieldScanner} scanner
+ * The current parsing status for all elements
+ * @returns {boolean}
+ * Return true if there is any field can be recognized in the parser,
+ * otherwise false.
+ */
+ _parseCreditCardNameFields(scanner, fieldDetail) {
+ const INTERESTED_FIELDS = [
+ "name",
+ "given-name",
+ "additional-name",
+ "family-name",
+ ];
+
+ if (!INTERESTED_FIELDS.includes(fieldDetail.fieldName)) {
+ return false;
+ }
+
+ const fields = [];
+ for (let idx = scanner.parsingIndex; ; idx++) {
+ const detail = scanner.getFieldDetailByIndex(idx);
+ if (!INTERESTED_FIELDS.includes(detail?.fieldName)) {
+ break;
+ }
+ fields.push(detail);
+ }
+
+ const prevCCFields = new Set();
+ for (let idx = scanner.parsingIndex - 1; ; idx--) {
+ const detail = scanner.getFieldDetailByIndex(idx);
+ if (
+ lazy.FormAutofillUtils.getCategoryFromFieldName(detail?.fieldName) !=
+ "creditCard"
+ ) {
+ break;
+ }
+ prevCCFields.add(detail.fieldName);
+ }
+
+ // We update the "name" fields to "cc-name" fields when the following
+ // conditions are met:
+ // 1. The preceding fields are identified as credit card fields and
+ // contain the "cc-number" field.
+ // 2. No "cc-name-*" field is found among the preceding credit card fields.
+ // 3. The "cc-csc" field is not present among the preceding credit card fields.
+ if (
+ ["cc-number"].some(f => prevCCFields.has(f)) &&
+ !["cc-name", "cc-given-name", "cc-family-name", "cc-csc"].some(f =>
+ prevCCFields.has(f)
+ )
+ ) {
+ // If there is only one field, assume the name field a `cc-name` field
+ if (fields.length == 1) {
+ scanner.updateFieldName(scanner.parsingIndex, `cc-name`);
+ scanner.parsingIndex += 1;
+ } else {
+ // update *-name to cc-*-name
+ for (const field of fields) {
+ scanner.updateFieldName(
+ scanner.parsingIndex,
+ `cc-${field.fieldName}`
+ );
+ scanner.parsingIndex += 1;
+ }
+ }
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * This function should provide all field details of a form which are placed
+ * in the belonging section. The details contain the autocomplete info
+ * (e.g. fieldName, section, etc).
+ *
+ * @param {HTMLFormElement} form
+ * the elements in this form to be predicted the field info.
+ * @returns {Array<FormSection>}
+ * all sections within its field details in the form.
+ */
+ getFormInfo(form) {
+ let elements = this.getFormElements(form);
+
+ const scanner = new lazy.FieldScanner(elements, element =>
+ this.inferFieldInfo(element, elements)
+ );
+
+ while (!scanner.parsingFinished) {
+ const savedIndex = scanner.parsingIndex;
+
+ // First, we get the inferred field info
+ const fieldDetail = scanner.getFieldDetailByIndex(scanner.parsingIndex);
+
+ if (
+ this._parsePhoneFields(scanner, fieldDetail) ||
+ this._parseStreetAddressFields(scanner, fieldDetail) ||
+ this._parseAddressFields(scanner, fieldDetail) ||
+ this._parseCreditCardExpiryFields(scanner, fieldDetail) ||
+ this._parseCreditCardNameFields(scanner, fieldDetail)
+ ) {
+ continue;
+ }
+
+ // If there is no field parsed, the parsing cursor can be moved
+ // forward to the next one.
+ if (savedIndex == scanner.parsingIndex) {
+ scanner.parsingIndex++;
+ }
+ }
+
+ lazy.LabelUtils.clearLabelMap();
+
+ const fields = scanner.fieldDetails;
+ const sections = [
+ ...this._classifySections(
+ fields.filter(f => lazy.FormAutofillUtils.isAddressField(f.fieldName))
+ ),
+ ...this._classifySections(
+ fields.filter(f =>
+ lazy.FormAutofillUtils.isCreditCardField(f.fieldName)
+ )
+ ),
+ ];
+
+ return sections.sort(
+ (a, b) =>
+ fields.indexOf(a.fieldDetails[0]) - fields.indexOf(b.fieldDetails[0])
+ );
+ },
+
+ /**
+ * Get focusable form elements that are of credit card or address type
+ *
+ * @param {HTMLElement} form
+ * @returns {Array<HTMLElement>} focusable elements
+ */
+ getFormElements(form) {
+ let elements = Array.from(form.elements).filter(
+ element =>
+ lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element) &&
+ lazy.FormAutofillUtils.isFieldFocusable(element)
+ );
+
+ return elements;
+ },
+
+ /**
+ * The result is an array contains the sections with its belonging field details.
+ *
+ * @param {Array<FieldDetails>} fieldDetails field detail array to be classified
+ * @returns {Array<FormSection>} The array with the sections.
+ */
+ _classifySections(fieldDetails) {
+ let sections = [];
+ for (let i = 0; i < fieldDetails.length; i++) {
+ const fieldName = fieldDetails[i].fieldName;
+ const sectionName = fieldDetails[i].sectionName;
+
+ const [currentSection] = sections.slice(-1);
+
+ // The section this field might belong to
+ let candidateSection = null;
+
+ // If the field doesn't have a section name, MAYBE put it to the previous
+ // section if exists. If the field has a section name, maybe put it to the
+ // nearest section that either has the same name or it doesn't has a name.
+ // Otherwise, create a new section.
+ if (!currentSection || !sectionName) {
+ candidateSection = currentSection;
+ } else if (sectionName) {
+ for (let idx = sections.length - 1; idx >= 0; idx--) {
+ if (!sections[idx].name || sections[idx].name == sectionName) {
+ candidateSection = sections[idx];
+ break;
+ }
+ }
+ }
+
+ // We got an candidate section to put the field to, check whether the section
+ // already has a field with the same field name. If yes, only add the field to when
+ // the type of the field might appear multiple times in a row.
+ if (candidateSection) {
+ let createNewSection = true;
+ if (candidateSection.fieldDetails.find(f => f.fieldName == fieldName)) {
+ const [lastFieldDetail] = candidateSection.fieldDetails.slice(-1);
+ if (lastFieldDetail.fieldName == fieldName) {
+ if (MULTI_FIELD_NAMES.includes(fieldName)) {
+ createNewSection = false;
+ } else if (fieldName in MULTI_N_FIELD_NAMES) {
+ // This is the heuristic to handle special cases where we can have multiple
+ // fields in one section, but only if the field has appeared N times in a row.
+ // For example, websites can use 4 consecutive 4-digit `cc-number` fields
+ // instead of one 16-digit `cc-number` field.
+
+ const N = MULTI_N_FIELD_NAMES[fieldName];
+ if (lastFieldDetail.part) {
+ // If `part` is set, we have already identified this field can be
+ // merged previously
+ if (lastFieldDetail.part < N) {
+ createNewSection = false;
+ fieldDetails[i].part = lastFieldDetail.part + 1;
+ }
+ // If the next N fields are all the same field, we can merge them
+ } else if (
+ N == 2 ||
+ fieldDetails
+ .slice(i + 1, i + N - 1)
+ .every(f => f.fieldName == fieldName)
+ ) {
+ lastFieldDetail.part = 1;
+ fieldDetails[i].part = 2;
+ createNewSection = false;
+ }
+ }
+ }
+ } else {
+ // The field doesn't exist in the candidate section, add it.
+ createNewSection = false;
+ }
+
+ if (!createNewSection) {
+ candidateSection.addField(fieldDetails[i]);
+ continue;
+ }
+ }
+
+ // Create a new section
+ sections.push(new FormSection([fieldDetails[i]]));
+ }
+
+ return sections;
+ },
+
+ _getPossibleFieldNames(element) {
+ let fieldNames = [];
+ const isAutoCompleteOff =
+ element.autocomplete == "off" || element.form?.autocomplete == "off";
+ if (!isAutoCompleteOff || FormAutofill.creditCardsAutocompleteOff) {
+ fieldNames.push(...this.CREDIT_CARD_FIELDNAMES);
+ }
+ if (!isAutoCompleteOff || FormAutofill.addressesAutocompleteOff) {
+ fieldNames.push(...this.ADDRESS_FIELDNAMES);
+ }
+
+ if (HTMLSelectElement.isInstance(element)) {
+ const FIELDNAMES_FOR_SELECT_ELEMENT = [
+ "address-level1",
+ "address-level2",
+ "country",
+ "cc-exp-month",
+ "cc-exp-year",
+ "cc-exp",
+ "cc-type",
+ ];
+ fieldNames = fieldNames.filter(name =>
+ FIELDNAMES_FOR_SELECT_ELEMENT.includes(name)
+ );
+ }
+
+ return fieldNames;
+ },
+
+ /**
+ * Get inferred information about an input element using autocomplete info, fathom and regex-based heuristics.
+ *
+ * @param {HTMLElement} element - The input element to infer information about.
+ * @param {Array<HTMLElement>} elements - See `getFathomField` for details
+ * @returns {Array} - An array containing:
+ * [0]the inferred field name
+ * [1]autocomplete information if the element has autocompelte attribute, null otherwise.
+ * [2]fathom confidence if fathom considers it a cc field, null otherwise.
+ */
+ inferFieldInfo(element, elements = []) {
+ const autocompleteInfo = element.getAutocompleteInfo();
+
+ // An input[autocomplete="on"] will not be early return here since it stll
+ // needs to find the field name.
+ if (
+ autocompleteInfo?.fieldName &&
+ !["on", "off"].includes(autocompleteInfo.fieldName)
+ ) {
+ return [autocompleteInfo.fieldName, autocompleteInfo, null];
+ }
+
+ const fields = this._getPossibleFieldNames(element);
+
+ // "email" type of input is accurate for heuristics to determine its Email
+ // field or not. However, "tel" type is used for ZIP code for some web site
+ // (e.g. HomeDepot, BestBuy), so "tel" type should be not used for "tel"
+ // prediction.
+ if (element.type == "email" && fields.includes("email")) {
+ return ["email", null, null];
+ }
+
+ if (lazy.FormAutofillUtils.isFathomCreditCardsEnabled()) {
+ // We don't care fields that are not supported by fathom
+ const fathomFields = fields.filter(r =>
+ lazy.CreditCardRulesets.types.includes(r)
+ );
+ const [matchedFieldName, confidence] = this.getFathomField(
+ element,
+ fathomFields,
+ elements
+ );
+ // At this point, use fathom's recommendation if it has one
+ if (matchedFieldName) {
+ return [matchedFieldName, null, confidence];
+ }
+
+ // Continue to run regex-based heuristics even when fathom doesn't recognize
+ // the field. Since the regex-based heuristic has good search coverage but
+ // has a worse precision. We use it in conjunction with fathom to maximize
+ // our search coverage. For example, when a <input> is not considered cc-name
+ // by fathom but is considered cc-name by regex-based heuristic, if the form
+ // also contains a cc-number identified by fathom, we will treat the form as a
+ // valid cc form; hence both cc-number & cc-name are identified.
+ }
+
+ // Check every select for options that
+ // match credit card network names in value or label.
+ if (HTMLSelectElement.isInstance(element)) {
+ if (this._isExpirationMonthLikely(element)) {
+ return ["cc-exp-month", null, null];
+ } else if (this._isExpirationYearLikely(element)) {
+ return ["cc-exp-year", null, null];
+ }
+
+ const options = Array.from(element.querySelectorAll("option"));
+ if (
+ options.find(
+ option =>
+ lazy.CreditCard.getNetworkFromName(option.value) ||
+ lazy.CreditCard.getNetworkFromName(option.text)
+ )
+ ) {
+ return ["cc-type", null, null];
+ }
+
+ // At least two options match the country name, otherwise some state name might
+ // also match a country name, ex, Georgia. We check the last two
+ // options rather than the first, as selects often start with a non-country display option.
+ const countryDisplayNames = Array.from(FormAutofill.countries.values());
+ if (
+ options.length >= 2 &&
+ options
+ .slice(-2)
+ .every(
+ option =>
+ countryDisplayNames.includes(option.value) ||
+ countryDisplayNames.includes(option.text)
+ )
+ ) {
+ return ["country", null, null];
+ }
+ }
+
+ // Find a matched field name using regexp-based heuristics
+ const matchedFieldName = this._findMatchedFieldName(element, fields);
+ return [matchedFieldName, null, null];
+ },
+
+ /**
+ * Using Fathom, say what kind of CC field an element is most likely to be.
+ * This function deoesn't only run fathom on the passed elements. It also
+ * runs fathom for all elements in the FieldScanner for optimization purpose.
+ *
+ * @param {HTMLElement} element
+ * @param {Array} fields
+ * @param {Array<HTMLElement>} elements - All other eligible elements in the same form. This is mainly used as an
+ * optimization approach to run fathom model on all eligible elements
+ * once instead of one by one
+ * @returns {Array} A tuple of [field name, probability] describing the
+ * highest-confidence classification
+ */
+ getFathomField(element, fields, elements = []) {
+ if (!fields.length) {
+ return [null, null];
+ }
+
+ if (!this._fathomConfidences?.get(element)) {
+ this._fathomConfidences = new Map();
+
+ // This should not throw unless we run into an OOM situation, at which
+ // point we have worse problems and this failing is not a big deal.
+ elements = elements.includes(element) ? elements : [element];
+ const confidences = this.getFormAutofillConfidences(elements);
+
+ for (let i = 0; i < elements.length; i++) {
+ this._fathomConfidences.set(elements[i], confidences[i]);
+ }
+ }
+
+ const elementConfidences = this._fathomConfidences.get(element);
+ if (!elementConfidences) {
+ return [null, null];
+ }
+
+ let highestField = null;
+ let highestConfidence = lazy.FormAutofillUtils.ccFathomConfidenceThreshold; // Start with a threshold of 0.5
+ for (let [key, value] of Object.entries(elementConfidences)) {
+ if (!fields.includes(key)) {
+ // ignore field that we don't care
+ continue;
+ }
+
+ if (value > highestConfidence) {
+ highestConfidence = value;
+ highestField = key;
+ }
+ }
+
+ if (!highestField) {
+ return [null, null];
+ }
+
+ // Used by test ONLY! This ensure testcases always get the same confidence
+ if (lazy.FormAutofillUtils.ccFathomTestConfidence > 0) {
+ highestConfidence = lazy.FormAutofillUtils.ccFathomTestConfidence;
+ }
+
+ return [highestField, highestConfidence];
+ },
+
+ /**
+ * @param {Array} elements Array of elements that we want to get result from fathom cc rules
+ * @returns {object} Fathom confidence keyed by field-type.
+ */
+ getFormAutofillConfidences(elements) {
+ if (
+ lazy.FormAutofillUtils.ccHeuristicsMode ==
+ lazy.FormAutofillUtils.CC_FATHOM_NATIVE
+ ) {
+ const confidences = ChromeUtils.getFormAutofillConfidences(elements);
+ return confidences.map(c => {
+ let result = {};
+ for (let [fieldName, confidence] of Object.entries(c)) {
+ let type =
+ lazy.FormAutofillUtils.formAutofillConfidencesKeyToCCFieldType(
+ fieldName
+ );
+ result[type] = confidence;
+ }
+ return result;
+ });
+ }
+
+ return elements.map(element => {
+ /**
+ * Return how confident our ML model is that `element` is a field of the
+ * given type.
+ *
+ * @param {string} fieldName The Fathom type to check against. This is
+ * conveniently the same as the autocomplete attribute value that means
+ * the same thing.
+ * @returns {number} Confidence in range [0, 1]
+ */
+ function confidence(fieldName) {
+ const ruleset = lazy.CreditCardRulesets[fieldName];
+ const fnodes = ruleset.against(element).get(fieldName);
+
+ // fnodes is either 0 or 1 item long, since we ran the ruleset
+ // against a single element:
+ return fnodes.length ? fnodes[0].scoreFor(fieldName) : 0;
+ }
+
+ // Bang the element against the ruleset for every type of field:
+ const confidences = {};
+ lazy.CreditCardRulesets.types.map(fieldName => {
+ confidences[fieldName] = confidence(fieldName);
+ });
+
+ return confidences;
+ });
+ },
+
+ /**
+ * @typedef ElementStrings
+ * @type {object}
+ * @yields {string} id - element id.
+ * @yields {string} name - element name.
+ * @yields {Array<string>} labels - extracted labels.
+ */
+
+ /**
+ * Extract all the signature strings of an element.
+ *
+ * @param {HTMLElement} element
+ * @returns {Array<string>}
+ */
+ _getElementStrings(element) {
+ return [element.id, element.name, element.placeholder?.trim()];
+ },
+
+ /**
+ * Extract all the label strings associated with an element.
+ *
+ * @param {HTMLElement} element
+ * @returns {ElementStrings}
+ */
+ _getElementLabelStrings(element) {
+ return {
+ *[Symbol.iterator]() {
+ const labels = lazy.LabelUtils.findLabelElements(element);
+ for (let label of labels) {
+ yield* lazy.LabelUtils.extractLabelStrings(label);
+ }
+
+ const ariaLabels = element.getAttribute("aria-label");
+ if (ariaLabels) {
+ yield* [ariaLabels];
+ }
+ },
+ };
+ },
+
+ // In order to support webkit we need to avoid usage of negative lookbehind due to low support
+ // First safari version with support is 16.4 (Release Date: 27th March 2023)
+ // https://caniuse.com/js-regexp-lookbehind
+ // We can mimic the behaviour of negative lookbehinds by using a named capture group
+ // (?<!not)word -> (?<neg>notword)|word
+ // TODO: Bug 1829583
+ testRegex(regex, string) {
+ const matches = string?.matchAll(regex);
+ if (!matches) {
+ return false;
+ }
+
+ const excludeNegativeCaptureGroups = [];
+
+ for (const match of matches) {
+ excludeNegativeCaptureGroups.push(
+ ...match.filter(m => m !== match?.groups?.neg).filter(Boolean)
+ );
+ }
+ return excludeNegativeCaptureGroups?.length > 0;
+ },
+
+ /**
+ * Find the first matching field name from a given list of field names
+ * that matches an HTML element.
+ *
+ * The function first tries to match the element against a set of
+ * pre-defined regular expression rules. If no match is found, it
+ * then checks for label-specific rules, if they exist.
+ *
+ * Note: For label rules, the keyword is often more general
+ * (e.g., "^\\W*address"), hence they are only searched within labels
+ * to reduce the occurrence of false positives.
+ *
+ * @param {HTMLElement} element The element to match.
+ * @param {Array<string>} fieldNames An array of field names to compare against.
+ * @returns {string|null} The name of the matched field, or null if no match was found.
+ */
+ _findMatchedFieldName(element, fieldNames) {
+ if (!fieldNames.length) {
+ return null;
+ }
+
+ // Attempt to match the element against the default set of rules
+ let matchedFieldName = fieldNames.find(fieldName =>
+ this._matchRegexp(element, this.RULES[fieldName])
+ );
+
+ // If no match is found, and if a label rule exists for the field,
+ // attempt to match against the label rules
+ if (!matchedFieldName) {
+ matchedFieldName = fieldNames.find(fieldName => {
+ const regexp = this.LABEL_RULES[fieldName];
+ return this._matchRegexp(element, regexp, { attribute: false });
+ });
+ }
+ return matchedFieldName;
+ },
+
+ /**
+ * Determine whether the regexp can match any of element strings.
+ *
+ * @param {HTMLElement} element The HTML element to match.
+ * @param {RegExp} regexp The regular expression to match against.
+ * @param {object} [options] Optional parameters for matching.
+ * @param {boolean} [options.attribute=true]
+ * Whether to match against the element's attributes.
+ * @param {boolean} [options.label=true]
+ * Whether to match against the element's labels.
+ * @returns {boolean} True if a match is found, otherwise false.
+ */
+ _matchRegexp(element, regexp, { attribute = true, label = true } = {}) {
+ if (!regexp) {
+ return false;
+ }
+
+ if (attribute) {
+ const elemStrings = this._getElementStrings(element);
+ if (elemStrings.find(s => this.testRegex(regexp, s?.toLowerCase()))) {
+ return true;
+ }
+ }
+
+ if (label) {
+ const elementLabelStrings = this._getElementLabelStrings(element);
+ for (const s of elementLabelStrings) {
+ if (this.testRegex(regexp, s?.toLowerCase())) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ },
+
+ /**
+ * Phone field grammars - first matched grammar will be parsed. Grammars are
+ * separated by { REGEX_SEPARATOR, FIELD_NONE, 0 }. Suffix and extension are
+ * parsed separately unless they are necessary parts of the match.
+ * The following notation is used to describe the patterns:
+ * <cc> - country code field.
+ * <ac> - area code field.
+ * <phone> - phone or prefix.
+ * <suffix> - suffix.
+ * <ext> - extension.
+ * :N means field is limited to N characters, otherwise it is unlimited.
+ * (pattern <field>)? means pattern is optional and matched separately.
+ *
+ * This grammar list from Chromium will be enabled partially once we need to
+ * support more cases of Telephone fields.
+ */
+ PHONE_FIELD_GRAMMARS: [
+ // Country code: <cc> Area Code: <ac> Phone: <phone> (- <suffix>
+
+ // (Ext: <ext>)?)?
+ // {REGEX_COUNTRY, FIELD_COUNTRY_CODE, 0},
+ // {REGEX_AREA, FIELD_AREA_CODE, 0},
+ // {REGEX_PHONE, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // \( <ac> \) <phone>:3 <suffix>:4 (Ext: <ext>)?
+ // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 3},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 3},
+ // {REGEX_PHONE, FIELD_SUFFIX, 4},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <cc> <ac>:3 - <phone>:3 - <suffix>:4 (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0},
+ // {REGEX_PHONE, FIELD_AREA_CODE, 3},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 3},
+ // {REGEX_SUFFIX_SEPARATOR, FIELD_SUFFIX, 4},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <cc>:3 <ac>:3 <phone>:3 <suffix>:4 (Ext: <ext>)?
+ ["tel", "tel-country-code", 3],
+ ["tel", "tel-area-code", 3],
+ ["tel", "tel-local-prefix", 3],
+ ["tel", "tel-local-suffix", 4],
+ [null, null, 0],
+
+ // Area Code: <ac> Phone: <phone> (- <suffix> (Ext: <ext>)?)?
+ // {REGEX_AREA, FIELD_AREA_CODE, 0},
+ // {REGEX_PHONE, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <ac> <phone>:3 <suffix>:4 (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_AREA_CODE, 0},
+ // {REGEX_PHONE, FIELD_PHONE, 3},
+ // {REGEX_PHONE, FIELD_SUFFIX, 4},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <cc> \( <ac> \) <phone> (- <suffix> (Ext: <ext>)?)?
+ // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0},
+ // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 0},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: \( <ac> \) <phone> (- <suffix> (Ext: <ext>)?)?
+ // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0},
+ // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 0},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <cc> - <ac> - <phone> - <suffix> (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_AREA_CODE, 0},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0},
+ // {REGEX_SUFFIX_SEPARATOR, FIELD_SUFFIX, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Area code: <ac>:3 Prefix: <prefix>:3 Suffix: <suffix>:4 (Ext: <ext>)?
+ // {REGEX_AREA, FIELD_AREA_CODE, 3},
+ // {REGEX_PREFIX, FIELD_PHONE, 3},
+ // {REGEX_SUFFIX, FIELD_SUFFIX, 4},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <ac> Prefix: <phone> Suffix: <suffix> (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_AREA_CODE, 0},
+ // {REGEX_PREFIX, FIELD_PHONE, 0},
+ // {REGEX_SUFFIX, FIELD_SUFFIX, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <ac> - <phone>:3 - <suffix>:4 (Ext: <ext>)?
+ ["tel", "tel-area-code", 0],
+ ["tel", "tel-local-prefix", 3],
+ ["tel", "tel-local-suffix", 4],
+ [null, null, 0],
+
+ // Phone: <cc> - <ac> - <phone> (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_AREA_CODE, 0},
+ // {REGEX_SUFFIX_SEPARATOR, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <ac> - <phone> (Ext: <ext>)?
+ // {REGEX_AREA, FIELD_AREA_CODE, 0},
+ // {REGEX_PHONE, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <cc>:3 - <phone>:10 (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_COUNTRY_CODE, 3},
+ // {REGEX_PHONE, FIELD_PHONE, 10},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Ext: <ext>
+ // {REGEX_EXTENSION, FIELD_EXTENSION, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <phone> (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+ ],
+};
+
+ChromeUtils.defineLazyGetter(
+ FormAutofillHeuristics,
+ "CREDIT_CARD_FIELDNAMES",
+ () =>
+ Object.keys(FormAutofillHeuristics.RULES).filter(name =>
+ lazy.FormAutofillUtils.isCreditCardField(name)
+ )
+);
+
+ChromeUtils.defineLazyGetter(FormAutofillHeuristics, "ADDRESS_FIELDNAMES", () =>
+ Object.keys(FormAutofillHeuristics.RULES).filter(name =>
+ lazy.FormAutofillUtils.isAddressField(name)
+ )
+);
+
+export default FormAutofillHeuristics;
diff --git a/toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs
new file mode 100644
index 0000000000..8a1d5ba55e
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs
@@ -0,0 +1,406 @@
+/* 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/. */
+
+// FormAutofillNameUtils is initially translated from
+// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util.cc?rcl=b861deff77abecff11ae6a9f6946e9cc844b9817
+export var FormAutofillNameUtils = {
+ NAME_PREFIXES: [
+ "1lt",
+ "1st",
+ "2lt",
+ "2nd",
+ "3rd",
+ "admiral",
+ "capt",
+ "captain",
+ "col",
+ "cpt",
+ "dr",
+ "gen",
+ "general",
+ "lcdr",
+ "lt",
+ "ltc",
+ "ltg",
+ "ltjg",
+ "maj",
+ "major",
+ "mg",
+ "mr",
+ "mrs",
+ "ms",
+ "pastor",
+ "prof",
+ "rep",
+ "reverend",
+ "rev",
+ "sen",
+ "st",
+ ],
+
+ NAME_SUFFIXES: [
+ "b.a",
+ "ba",
+ "d.d.s",
+ "dds",
+ "i",
+ "ii",
+ "iii",
+ "iv",
+ "ix",
+ "jr",
+ "m.a",
+ "m.d",
+ "ma",
+ "md",
+ "ms",
+ "ph.d",
+ "phd",
+ "sr",
+ "v",
+ "vi",
+ "vii",
+ "viii",
+ "x",
+ ],
+
+ FAMILY_NAME_PREFIXES: [
+ "d'",
+ "de",
+ "del",
+ "der",
+ "di",
+ "la",
+ "le",
+ "mc",
+ "san",
+ "st",
+ "ter",
+ "van",
+ "von",
+ ],
+
+ // The common and non-ambiguous CJK surnames (last names) that have more than
+ // one character.
+ COMMON_CJK_MULTI_CHAR_SURNAMES: [
+ // Korean, taken from the list of surnames:
+ // https://ko.wikipedia.org/wiki/%ED%95%9C%EA%B5%AD%EC%9D%98_%EC%84%B1%EC%94%A8_%EB%AA%A9%EB%A1%9D
+ "남궁",
+ "사공",
+ "서문",
+ "선우",
+ "제갈",
+ "황보",
+ "독고",
+ "망절",
+
+ // Chinese, taken from the top 10 Chinese 2-character surnames:
+ // https://zh.wikipedia.org/wiki/%E8%A4%87%E5%A7%93#.E5.B8.B8.E8.A6.8B.E7.9A.84.E8.A4.87.E5.A7.93
+ // Simplified Chinese (mostly mainland China)
+ "欧阳",
+ "令狐",
+ "皇甫",
+ "上官",
+ "司徒",
+ "诸葛",
+ "司马",
+ "宇文",
+ "呼延",
+ "端木",
+ // Traditional Chinese (mostly Taiwan)
+ "張簡",
+ "歐陽",
+ "諸葛",
+ "申屠",
+ "尉遲",
+ "司馬",
+ "軒轅",
+ "夏侯",
+ ],
+
+ // All Korean surnames that have more than one character, even the
+ // rare/ambiguous ones.
+ KOREAN_MULTI_CHAR_SURNAMES: [
+ "강전",
+ "남궁",
+ "독고",
+ "동방",
+ "망절",
+ "사공",
+ "서문",
+ "선우",
+ "소봉",
+ "어금",
+ "장곡",
+ "제갈",
+ "황목",
+ "황보",
+ ],
+
+ // The whitespace definition based on
+ // https://cs.chromium.org/chromium/src/base/strings/string_util_constants.cc?l=9&rcl=b861deff77abecff11ae6a9f6946e9cc844b9817
+ WHITESPACE: [
+ "\u0009", // CHARACTER TABULATION
+ "\u000A", // LINE FEED (LF)
+ "\u000B", // LINE TABULATION
+ "\u000C", // FORM FEED (FF)
+ "\u000D", // CARRIAGE RETURN (CR)
+ "\u0020", // SPACE
+ "\u0085", // NEXT LINE (NEL)
+ "\u00A0", // NO-BREAK SPACE
+ "\u1680", // OGHAM SPACE MARK
+ "\u2000", // EN QUAD
+ "\u2001", // EM QUAD
+ "\u2002", // EN SPACE
+ "\u2003", // EM SPACE
+ "\u2004", // THREE-PER-EM SPACE
+ "\u2005", // FOUR-PER-EM SPACE
+ "\u2006", // SIX-PER-EM SPACE
+ "\u2007", // FIGURE SPACE
+ "\u2008", // PUNCTUATION SPACE
+ "\u2009", // THIN SPACE
+ "\u200A", // HAIR SPACE
+ "\u2028", // LINE SEPARATOR
+ "\u2029", // PARAGRAPH SEPARATOR
+ "\u202F", // NARROW NO-BREAK SPACE
+ "\u205F", // MEDIUM MATHEMATICAL SPACE
+ "\u3000", // IDEOGRAPHIC SPACE
+ ],
+
+ // The middle dot is used as a separator for foreign names in Japanese.
+ MIDDLE_DOT: [
+ "\u30FB", // KATAKANA MIDDLE DOT
+ "\u00B7", // A (common?) typo for "KATAKANA MIDDLE DOT"
+ ],
+
+ // The Unicode range is based on Wiki:
+ // https://en.wikipedia.org/wiki/CJK_Unified_Ideographs
+ // https://en.wikipedia.org/wiki/Hangul
+ // https://en.wikipedia.org/wiki/Japanese_writing_system
+ CJK_RANGE: [
+ "\u1100-\u11FF", // Hangul Jamo
+ "\u3040-\u309F", // Hiragana
+ "\u30A0-\u30FF", // Katakana
+ "\u3105-\u312C", // Bopomofo
+ "\u3130-\u318F", // Hangul Compatibility Jamo
+ "\u31F0-\u31FF", // Katakana Phonetic Extensions
+ "\u3200-\u32FF", // Enclosed CJK Letters and Months
+ "\u3400-\u4DBF", // CJK unified ideographs Extension A
+ "\u4E00-\u9FFF", // CJK Unified Ideographs
+ "\uA960-\uA97F", // Hangul Jamo Extended-A
+ "\uAC00-\uD7AF", // Hangul Syllables
+ "\uD7B0-\uD7FF", // Hangul Jamo Extended-B
+ "\uFF00-\uFFEF", // Halfwidth and Fullwidth Forms
+ ],
+
+ HANGUL_RANGE: [
+ "\u1100-\u11FF", // Hangul Jamo
+ "\u3130-\u318F", // Hangul Compatibility Jamo
+ "\uA960-\uA97F", // Hangul Jamo Extended-A
+ "\uAC00-\uD7AF", // Hangul Syllables
+ "\uD7B0-\uD7FF", // Hangul Jamo Extended-B
+ ],
+
+ _dataLoaded: false,
+
+ // Returns true if |set| contains |token|, modulo a final period.
+ _containsString(set, token) {
+ let target = token.replace(/\.$/, "").toLowerCase();
+ return set.includes(target);
+ },
+
+ // Removes common name prefixes from |name_tokens|.
+ _stripPrefixes(nameTokens) {
+ for (let i in nameTokens) {
+ if (!this._containsString(this.NAME_PREFIXES, nameTokens[i])) {
+ return nameTokens.slice(i);
+ }
+ }
+ return [];
+ },
+
+ // Removes common name suffixes from |name_tokens|.
+ _stripSuffixes(nameTokens) {
+ for (let i = nameTokens.length - 1; i >= 0; i--) {
+ if (!this._containsString(this.NAME_SUFFIXES, nameTokens[i])) {
+ return nameTokens.slice(0, i + 1);
+ }
+ }
+ return [];
+ },
+
+ _isCJKName(name) {
+ // The name is considered to be a CJK name if it is only CJK characters,
+ // spaces, and "middle dot" separators, with at least one CJK character, and
+ // no more than 2 words.
+ //
+ // Chinese and Japanese names are usually spelled out using the Han
+ // characters (logographs), which constitute the "CJK Unified Ideographs"
+ // block in Unicode, also referred to as Unihan. Korean names are usually
+ // spelled out in the Korean alphabet (Hangul), although they do have a Han
+ // equivalent as well.
+
+ if (!name) {
+ return false;
+ }
+
+ let previousWasCJK = false;
+ let wordCount = 0;
+
+ for (let c of name) {
+ let isMiddleDot = this.MIDDLE_DOT.includes(c);
+ let isCJK = !isMiddleDot && this.reCJK.test(c);
+ if (!isCJK && !isMiddleDot && !this.WHITESPACE.includes(c)) {
+ return false;
+ }
+ if (isCJK && !previousWasCJK) {
+ wordCount++;
+ }
+ previousWasCJK = isCJK;
+ }
+
+ return wordCount > 0 && wordCount < 3;
+ },
+
+ // Tries to split a Chinese, Japanese, or Korean name into its given name &
+ // surname parts. If splitting did not work for whatever reason, returns null.
+ _splitCJKName(nameTokens) {
+ // The convention for CJK languages is to put the surname (last name) first,
+ // and the given name (first name) second. In a continuous text, there is
+ // normally no space between the two parts of the name. When entering their
+ // name into a field, though, some people add a space to disambiguate. CJK
+ // names (almost) never have a middle name.
+
+ let reHangulName = new RegExp(
+ "^[" + this.HANGUL_RANGE.join("") + this.WHITESPACE.join("") + "]+$",
+ "u"
+ );
+ let nameParts = {
+ given: "",
+ middle: "",
+ family: "",
+ };
+
+ if (nameTokens.length == 1) {
+ // There is no space between the surname and given name. Try to infer
+ // where to separate between the two. Most Chinese and Korean surnames
+ // have only one character, but there are a few that have 2. If the name
+ // does not start with a surname from a known list, default to one
+ // character.
+ let name = nameTokens[0];
+ let isKorean = reHangulName.test(name);
+ let surnameLength = 0;
+
+ // 4-character Korean names are more likely to be 2/2 than 1/3, so use
+ // the full list of Korean 2-char surnames. (instead of only the common
+ // ones)
+ let multiCharSurnames =
+ isKorean && name.length > 3
+ ? this.KOREAN_MULTI_CHAR_SURNAMES
+ : this.COMMON_CJK_MULTI_CHAR_SURNAMES;
+
+ // Default to 1 character if the surname is not in the list.
+ surnameLength = multiCharSurnames.some(surname =>
+ name.startsWith(surname)
+ )
+ ? 2
+ : 1;
+
+ nameParts.family = name.substr(0, surnameLength);
+ nameParts.given = name.substr(surnameLength);
+ } else if (nameTokens.length == 2) {
+ // The user entered a space between the two name parts. This makes our job
+ // easier. Family name first, given name second.
+ nameParts.family = nameTokens[0];
+ nameParts.given = nameTokens[1];
+ } else {
+ return null;
+ }
+
+ return nameParts;
+ },
+
+ init() {
+ if (this._dataLoaded) {
+ return;
+ }
+ this._dataLoaded = true;
+
+ this.reCJK = new RegExp("[" + this.CJK_RANGE.join("") + "]", "u");
+ },
+
+ splitName(name) {
+ let nameParts = {
+ given: "",
+ middle: "",
+ family: "",
+ };
+
+ if (!name) {
+ return nameParts;
+ }
+
+ let nameTokens = name.trim().split(/[ ,\u3000\u30FB\u00B7]+/);
+ nameTokens = this._stripPrefixes(nameTokens);
+
+ if (this._isCJKName(name)) {
+ let parts = this._splitCJKName(nameTokens);
+ if (parts) {
+ return parts;
+ }
+ }
+
+ // Don't assume "Ma" is a suffix in John Ma.
+ if (nameTokens.length > 2) {
+ nameTokens = this._stripSuffixes(nameTokens);
+ }
+
+ if (!nameTokens.length) {
+ // Bad things have happened; just assume the whole thing is a given name.
+ nameParts.given = name;
+ return nameParts;
+ }
+
+ // Only one token, assume given name.
+ if (nameTokens.length == 1) {
+ nameParts.given = nameTokens[0];
+ return nameParts;
+ }
+
+ // 2 or more tokens. Grab the family, which is the last word plus any
+ // recognizable family prefixes.
+ let familyTokens = [nameTokens.pop()];
+ while (nameTokens.length) {
+ let lastToken = nameTokens[nameTokens.length - 1];
+ if (!this._containsString(this.FAMILY_NAME_PREFIXES, lastToken)) {
+ break;
+ }
+ familyTokens.unshift(lastToken);
+ nameTokens.pop();
+ }
+ nameParts.family = familyTokens.join(" ");
+
+ // Take the last remaining token as the middle name (if there are at least 2
+ // tokens).
+ if (nameTokens.length >= 2) {
+ nameParts.middle = nameTokens.pop();
+ }
+
+ // Remainder is given name.
+ nameParts.given = nameTokens.join(" ");
+
+ return nameParts;
+ },
+
+ joinNameParts({ given, middle, family }) {
+ if (this._isCJKName(given) && this._isCJKName(family) && !middle) {
+ return family + given;
+ }
+ return [given, middle, family]
+ .filter(part => part && part.length)
+ .join(" ");
+ },
+};
+
+FormAutofillNameUtils.init();
diff --git a/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs
new file mode 100644
index 0000000000..1c7696432a
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs
@@ -0,0 +1,1292 @@
+/* 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/. */
+
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ AutofillTelemetry: "resource://autofill/AutofillTelemetry.sys.mjs",
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ FormAutofillNameUtils:
+ "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs",
+ LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs",
+});
+
+const { FIELD_STATES } = FormAutofillUtils;
+
+export class FormAutofillSection {
+ static SHOULD_FOCUS_ON_AUTOFILL = true;
+ #focusedInput = null;
+
+ #fieldDetails = [];
+
+ constructor(fieldDetails, handler) {
+ this.#fieldDetails = fieldDetails;
+
+ if (!this.isValidSection()) {
+ return;
+ }
+
+ this.handler = handler;
+ this.filledRecordGUID = null;
+
+ ChromeUtils.defineLazyGetter(this, "log", () =>
+ FormAutofill.defineLogGetter(this, "FormAutofillHandler")
+ );
+
+ this._cacheValue = {
+ allFieldNames: null,
+ matchingSelectOption: null,
+ };
+
+ // Identifier used to correlate events relating to the same form
+ this.flowId = Services.uuid.generateUUID().toString();
+ this.log.debug(
+ "Creating new credit card section with flowId =",
+ this.flowId
+ );
+ }
+
+ get fieldDetails() {
+ return this.#fieldDetails;
+ }
+
+ /*
+ * Examine the section is a valid section or not based on its fieldDetails or
+ * other information. This method must be overrided.
+ *
+ * @returns {boolean} True for a valid section, otherwise false
+ *
+ */
+ isValidSection() {
+ throw new TypeError("isValidSection method must be overrided");
+ }
+
+ /*
+ * Examine the section is an enabled section type or not based on its
+ * preferences. This method must be overrided.
+ *
+ * @returns {boolean} True for an enabled section type, otherwise false
+ *
+ */
+ isEnabled() {
+ throw new TypeError("isEnabled method must be overrided");
+ }
+
+ /*
+ * Examine the section is createable for storing the profile. This method
+ * must be overrided.
+ *
+ * @param {Object} record The record for examining createable
+ * @returns {boolean} True for the record is createable, otherwise false
+ *
+ */
+ isRecordCreatable(record) {
+ throw new TypeError("isRecordCreatable method must be overridden");
+ }
+
+ /**
+ * Override this method if the profile is needed to apply some transformers.
+ *
+ * @param {object} profile
+ * A profile should be converted based on the specific requirement.
+ */
+ applyTransformers(profile) {}
+
+ /**
+ * Override this method if the profile is needed to be customized for
+ * previewing values.
+ *
+ * @param {object} profile
+ * A profile for pre-processing before previewing values.
+ */
+ preparePreviewProfile(profile) {}
+
+ /**
+ * Override this method if the profile is needed to be customized for filling
+ * values.
+ *
+ * @param {object} profile
+ * A profile for pre-processing before filling values.
+ * @returns {boolean} Whether the profile should be filled.
+ */
+ async prepareFillingProfile(profile) {
+ return true;
+ }
+
+ /**
+ * Override this method if the profile is needed to be customized for filling
+ * values.
+ *
+ * @param {object} fieldDetail A fieldDetail of the related element.
+ * @param {object} profile The profile to fill.
+ * @returns {string} The value to fill for the given field.
+ */
+ getFilledValueFromProfile(fieldDetail, profile) {
+ return (
+ profile[`${fieldDetail.fieldName}-formatted`] ||
+ profile[fieldDetail.fieldName]
+ );
+ }
+
+ /*
+ * Override this method if there is any field value needs to compute for a
+ * specific case. Return the original value in the default case.
+ * @param {String} value
+ * The original field value.
+ * @param {Object} fieldDetail
+ * A fieldDetail of the related element.
+ * @param {HTMLElement} element
+ * A element for checking converting value.
+ *
+ * @returns {String}
+ * A string of the converted value.
+ */
+ computeFillingValue(value, fieldName, element) {
+ return value;
+ }
+
+ set focusedInput(element) {
+ this.#focusedInput = element;
+ }
+
+ getFieldDetailByElement(element) {
+ return this.fieldDetails.find(detail => detail.element == element);
+ }
+
+ getFieldDetailByName(fieldName) {
+ return this.fieldDetails.find(detail => detail.fieldName == fieldName);
+ }
+
+ get allFieldNames() {
+ if (!this._cacheValue.allFieldNames) {
+ this._cacheValue.allFieldNames = this.fieldDetails.map(
+ record => record.fieldName
+ );
+ }
+ return this._cacheValue.allFieldNames;
+ }
+
+ matchSelectOptions(profile) {
+ if (!this._cacheValue.matchingSelectOption) {
+ this._cacheValue.matchingSelectOption = new WeakMap();
+ }
+
+ for (const fieldName in profile) {
+ const fieldDetail = this.getFieldDetailByName(fieldName);
+ const element = fieldDetail?.element;
+
+ if (!HTMLSelectElement.isInstance(element)) {
+ continue;
+ }
+
+ const cache = this._cacheValue.matchingSelectOption.get(element) || {};
+ const value = profile[fieldName];
+ if (cache[value] && cache[value].deref()) {
+ continue;
+ }
+
+ const option = FormAutofillUtils.findSelectOption(
+ element,
+ profile,
+ fieldName
+ );
+
+ if (option) {
+ cache[value] = new WeakRef(option);
+ this._cacheValue.matchingSelectOption.set(element, cache);
+ } else {
+ if (cache[value]) {
+ delete cache[value];
+ this._cacheValue.matchingSelectOption.set(element, cache);
+ }
+ // Skip removing cc-type since this is needed for displaying the icon for credit card network
+ // TODO(Bug 1874339): Cleanup transformation and normalization of data to not remove any
+ // fields and be more consistent
+ if (!["cc-type"].includes(fieldName)) {
+ // Delete the field so the phishing hint won't treat it as a "also fill"
+ // field.
+ delete profile[fieldName];
+ }
+ }
+ }
+ }
+
+ adaptFieldMaxLength(profile) {
+ for (let key in profile) {
+ let detail = this.getFieldDetailByName(key);
+ if (!detail) {
+ continue;
+ }
+
+ let element = detail.element;
+ if (!element) {
+ continue;
+ }
+
+ let maxLength = element.maxLength;
+ if (
+ maxLength === undefined ||
+ maxLength < 0 ||
+ profile[key].toString().length <= maxLength
+ ) {
+ continue;
+ }
+
+ if (maxLength) {
+ switch (typeof profile[key]) {
+ case "string":
+ // If this is an expiration field and our previous
+ // adaptations haven't resulted in a string that is
+ // short enough to satisfy the field length, and the
+ // field is constrained to a length of 4 or 5, then we
+ // assume it is intended to hold an expiration of the
+ // form "MMYY" or "MM/YY".
+ if (key == "cc-exp" && (maxLength == 4 || maxLength == 5)) {
+ const month2Digits = (
+ "0" + profile["cc-exp-month"].toString()
+ ).slice(-2);
+ const year2Digits = profile["cc-exp-year"].toString().slice(-2);
+ const separator = maxLength == 5 ? "/" : "";
+ profile[key] = `${month2Digits}${separator}${year2Digits}`;
+ } else if (key == "cc-number") {
+ // We want to show the last four digits of credit card so that
+ // the masked credit card previews correctly and appears correctly
+ // in the autocomplete menu
+ profile[key] = profile[key].substr(
+ profile[key].length - maxLength
+ );
+ } else {
+ profile[key] = profile[key].substr(0, maxLength);
+ }
+ break;
+ case "number":
+ // There's no way to truncate a number smaller than a
+ // single digit.
+ if (maxLength < 1) {
+ maxLength = 1;
+ }
+ // The only numbers we store are expiration month/year,
+ // and if they truncate, we want the final digits, not
+ // the initial ones.
+ profile[key] = profile[key] % Math.pow(10, maxLength);
+ break;
+ default:
+ }
+ } else {
+ delete profile[key];
+ delete profile[`${key}-formatted`];
+ }
+ }
+ }
+
+ fillFieldValue(element, value) {
+ if (FormAutofillUtils.focusOnAutofill) {
+ element.focus({ preventScroll: true });
+ }
+ if (HTMLInputElement.isInstance(element)) {
+ element.setUserInput(value);
+ } else if (HTMLSelectElement.isInstance(element)) {
+ // Set the value of the select element so that web event handlers can react accordingly
+ element.value = value;
+ element.dispatchEvent(
+ new element.ownerGlobal.Event("input", { bubbles: true })
+ );
+ element.dispatchEvent(
+ new element.ownerGlobal.Event("change", { bubbles: true })
+ );
+ }
+ }
+
+ getAdaptedProfiles(originalProfiles) {
+ for (let profile of originalProfiles) {
+ this.applyTransformers(profile);
+ }
+ return originalProfiles;
+ }
+
+ /**
+ * Processes form fields that can be autofilled, and populates them with the
+ * profile provided by backend.
+ *
+ * @param {object} profile
+ * A profile to be filled in.
+ * @returns {boolean}
+ * True if successful, false if failed
+ */
+ async autofillFields(profile) {
+ if (!this.#focusedInput) {
+ throw new Error("No focused input.");
+ }
+
+ const focusedDetail = this.getFieldDetailByElement(this.#focusedInput);
+ if (!focusedDetail) {
+ throw new Error("No fieldDetail for the focused input.");
+ }
+
+ this.getAdaptedProfiles([profile]);
+ if (!(await this.prepareFillingProfile(profile))) {
+ this.log.debug("profile cannot be filled");
+ return false;
+ }
+
+ this.filledRecordGUID = profile.guid;
+ for (const fieldDetail of this.fieldDetails) {
+ // Avoid filling field value in the following cases:
+ // 1. a non-empty input field for an unfocused input
+ // 2. the invalid value set
+ // 3. value already chosen in select element
+
+ const element = fieldDetail.element;
+ // Skip the field if it is null or readonly or disabled
+ if (!FormAutofillUtils.isFieldAutofillable(element)) {
+ continue;
+ }
+
+ element.previewValue = "";
+ // Bug 1687679: Since profile appears to be presentation ready data, we need to utilize the "x-formatted" field
+ // that is generated when presentation ready data doesn't fit into the autofilling element.
+ // For example, autofilling expiration month into an input element will not work as expected if
+ // the month is less than 10, since the input is expected a zero-padded string.
+ // See Bug 1722941 for follow up.
+ const value = this.getFilledValueFromProfile(fieldDetail, profile);
+ if (HTMLInputElement.isInstance(element) && value) {
+ // For the focused input element, it will be filled with a valid value
+ // anyway.
+ // For the others, the fields should be only filled when their values are empty
+ // or their values are equal to the site prefill value
+ // or are the result of an earlier auto-fill.
+ if (
+ element == this.#focusedInput ||
+ (element != this.#focusedInput &&
+ (!element.value || element.value == element.defaultValue)) ||
+ this.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.AUTO_FILLED
+ ) {
+ this.fillFieldValue(element, value);
+ this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED);
+ }
+ } else if (HTMLSelectElement.isInstance(element)) {
+ let cache = this._cacheValue.matchingSelectOption.get(element) || {};
+ let option = cache[value] && cache[value].deref();
+ if (!option) {
+ continue;
+ }
+ // Do not change value or dispatch events if the option is already selected.
+ // Use case for multiple select is not considered here.
+ if (!option.selected) {
+ option.selected = true;
+ this.fillFieldValue(element, option.value);
+ }
+ // Autofill highlight appears regardless if value is changed or not
+ this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED);
+ }
+ }
+ this.#focusedInput.focus({ preventScroll: true });
+
+ lazy.AutofillTelemetry.recordFormInteractionEvent("filled", this, {
+ profile,
+ });
+
+ return true;
+ }
+
+ /**
+ * Populates result to the preview layers with given profile.
+ *
+ * @param {object} profile
+ * A profile to be previewed with
+ */
+ previewFormFields(profile) {
+ this.preparePreviewProfile(profile);
+
+ for (const fieldDetail of this.fieldDetails) {
+ let element = fieldDetail.element;
+ // Skip the field if it is null or readonly or disabled
+ if (!FormAutofillUtils.isFieldAutofillable(element)) {
+ continue;
+ }
+
+ let value =
+ profile[`${fieldDetail.fieldName}-formatted`] ||
+ profile[fieldDetail.fieldName] ||
+ "";
+ if (HTMLSelectElement.isInstance(element)) {
+ // Unlike text input, select element is always previewed even if
+ // the option is already selected.
+ if (value) {
+ const cache =
+ this._cacheValue.matchingSelectOption.get(element) ?? {};
+ const option = cache[value]?.deref();
+ value = option?.text ?? "";
+ }
+ } else if (element.value && element.value != element.defaultValue) {
+ // Skip the field if the user has already entered text and that text is not the site prefilled value.
+ continue;
+ }
+ element.previewValue = value?.toString().replaceAll("*", "•");
+ this.handler.changeFieldState(
+ fieldDetail,
+ value ? FIELD_STATES.PREVIEW : FIELD_STATES.NORMAL
+ );
+ }
+ }
+
+ /**
+ * Clear a previously autofilled field in this section
+ */
+ clearFilled(fieldDetail) {
+ lazy.AutofillTelemetry.recordFormInteractionEvent("filled_modified", this, {
+ fieldName: fieldDetail.fieldName,
+ });
+
+ let isAutofilled = false;
+ const dimFieldDetails = [];
+ for (const fieldDetail of this.fieldDetails) {
+ const element = fieldDetail.element;
+
+ if (HTMLSelectElement.isInstance(element)) {
+ // Dim fields are those we don't attempt to revert their value
+ // when clear the target set, such as <select>.
+ dimFieldDetails.push(fieldDetail);
+ } else {
+ isAutofilled |=
+ this.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.AUTO_FILLED;
+ }
+ }
+ if (!isAutofilled) {
+ // Restore the dim fields to initial state as well once we knew
+ // that user had intention to clear the filled form manually.
+ for (const fieldDetail of dimFieldDetails) {
+ // If we can't find a selected option, then we should just reset to the first option's value
+ let element = fieldDetail.element;
+ this._resetSelectElementValue(element);
+ this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
+ }
+ this.filledRecordGUID = null;
+ }
+ }
+
+ /**
+ * Clear preview text and background highlight of all fields.
+ */
+ clearPreviewedFormFields() {
+ this.log.debug("clear previewed fields");
+
+ for (const fieldDetail of this.fieldDetails) {
+ let element = fieldDetail.element;
+ if (!element) {
+ this.log.warn(fieldDetail.fieldName, "is unreachable");
+ continue;
+ }
+
+ element.previewValue = "";
+
+ // We keep the state if this field has
+ // already been auto-filled.
+ if (
+ this.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.AUTO_FILLED
+ ) {
+ continue;
+ }
+
+ this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
+ }
+ }
+
+ /**
+ * Clear value and highlight style of all filled fields.
+ */
+ clearPopulatedForm() {
+ for (let fieldDetail of this.fieldDetails) {
+ let element = fieldDetail.element;
+ if (!element) {
+ this.log.warn(fieldDetail.fieldName, "is unreachable");
+ continue;
+ }
+
+ if (
+ this.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.AUTO_FILLED
+ ) {
+ if (HTMLInputElement.isInstance(element)) {
+ element.setUserInput("");
+ } else if (HTMLSelectElement.isInstance(element)) {
+ // If we can't find a selected option, then we should just reset to the first option's value
+ this._resetSelectElementValue(element);
+ }
+ }
+ }
+ }
+
+ resetFieldStates() {
+ for (const fieldDetail of this.fieldDetails) {
+ const element = fieldDetail.element;
+ element.removeEventListener("input", this, { mozSystemGroup: true });
+ this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
+ }
+ this.filledRecordGUID = null;
+ }
+
+ isFilled() {
+ return !!this.filledRecordGUID;
+ }
+
+ /**
+ * Condenses multiple credit card number fields into one fieldDetail
+ * in order to submit the credit card record correctly.
+ *
+ * @param {Array.<object>} condensedDetails
+ * An array of fieldDetails
+ * @memberof FormAutofillSection
+ */
+ _condenseMultipleCCNumberFields(condensedDetails) {
+ let countOfCCNumbers = 0;
+ // We ignore the cases where there are more than or less than four credit card number
+ // fields in a form as this is not a valid case for filling the credit card number.
+ for (let i = condensedDetails.length - 1; i >= 0; i--) {
+ if (condensedDetails[i].fieldName == "cc-number") {
+ countOfCCNumbers++;
+ if (countOfCCNumbers == 4) {
+ countOfCCNumbers = 0;
+ condensedDetails[i].fieldValue =
+ condensedDetails[i].element?.value +
+ condensedDetails[i + 1].element?.value +
+ condensedDetails[i + 2].element?.value +
+ condensedDetails[i + 3].element?.value;
+ condensedDetails.splice(i + 1, 3);
+ }
+ } else {
+ countOfCCNumbers = 0;
+ }
+ }
+ }
+ /**
+ * Return the record that is converted from `fieldDetails` and only valid
+ * form record is included.
+ *
+ * @returns {object | null}
+ * A record object consists of three properties:
+ * - guid: The id of the previously-filled profile or null if omitted.
+ * - record: A valid record converted from details with trimmed result.
+ * - untouchedFields: Fields that aren't touched after autofilling.
+ * Return `null` for any uncreatable or invalid record.
+ */
+ createRecord() {
+ let details = this.fieldDetails;
+ if (!this.isEnabled() || !details || !details.length) {
+ return null;
+ }
+
+ let data = {
+ guid: this.filledRecordGUID,
+ record: {},
+ untouchedFields: [],
+ section: this,
+ };
+ if (this.flowId) {
+ data.flowId = this.flowId;
+ }
+ let condensedDetails = this.fieldDetails;
+
+ // TODO: This is credit card specific code...
+ this._condenseMultipleCCNumberFields(condensedDetails);
+
+ condensedDetails.forEach(detail => {
+ const element = detail.element;
+ // Remove the unnecessary spaces
+ let value = detail.fieldValue ?? (element && element.value.trim());
+ value = this.computeFillingValue(value, detail, element);
+
+ if (!value || value.length > FormAutofillUtils.MAX_FIELD_VALUE_LENGTH) {
+ // Keep the property and preserve more information for updating
+ data.record[detail.fieldName] = "";
+ return;
+ }
+
+ data.record[detail.fieldName] = value;
+
+ if (
+ this.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.AUTO_FILLED
+ ) {
+ data.untouchedFields.push(detail.fieldName);
+ }
+ });
+
+ const telFields = this.fieldDetails.filter(
+ f => FormAutofillUtils.getCategoryFromFieldName(f.fieldName) == "tel"
+ );
+ if (
+ telFields.length &&
+ telFields.every(f => data.untouchedFields.includes(f.fieldName))
+ ) {
+ // No need to verify it if none of related fields are modified after autofilling.
+ if (!data.untouchedFields.includes("tel")) {
+ data.untouchedFields.push("tel");
+ }
+ }
+
+ if (!this.isRecordCreatable(data.record)) {
+ return null;
+ }
+
+ return data;
+ }
+
+ /**
+ * Resets a <select> element to its selected option or the first option if there is none selected.
+ *
+ * @param {HTMLElement} element
+ * @memberof FormAutofillSection
+ */
+ _resetSelectElementValue(element) {
+ if (!element.options.length) {
+ return;
+ }
+ let selected = [...element.options].find(option =>
+ option.hasAttribute("selected")
+ );
+ element.value = selected ? selected.value : element.options[0].value;
+ element.dispatchEvent(
+ new element.ownerGlobal.Event("input", { bubbles: true })
+ );
+ element.dispatchEvent(
+ new element.ownerGlobal.Event("change", { bubbles: true })
+ );
+ }
+}
+
+export class FormAutofillAddressSection extends FormAutofillSection {
+ constructor(fieldDetails, handler) {
+ super(fieldDetails, handler);
+
+ if (!this.isValidSection()) {
+ return;
+ }
+
+ this._cacheValue.oneLineStreetAddress = null;
+
+ lazy.AutofillTelemetry.recordDetectedSectionCount(this);
+ lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this);
+ }
+
+ isValidSection() {
+ const fields = new Set(this.fieldDetails.map(f => f.fieldName));
+ return fields.size >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD;
+ }
+
+ isEnabled() {
+ return FormAutofill.isAutofillAddressesEnabled;
+ }
+
+ isRecordCreatable(record) {
+ const country = FormAutofillUtils.identifyCountryCode(
+ record.country || record["country-name"]
+ );
+ if (
+ country &&
+ !FormAutofill.isAutofillAddressesAvailableInCountry(country)
+ ) {
+ // We don't want to save data in the wrong fields due to not having proper
+ // heuristic regexes in countries we don't yet support.
+ this.log.warn(
+ "isRecordCreatable: Country not supported:",
+ record.country
+ );
+ return false;
+ }
+
+ // Multiple name or tel fields are treat as 1 field while countng whether
+ // the number of fields exceed the valid address secton threshold
+ const categories = Object.entries(record)
+ .filter(e => !!e[1])
+ .map(e => FormAutofillUtils.getCategoryFromFieldName(e[0]));
+
+ return (
+ categories.reduce(
+ (acc, category) =>
+ ["name", "tel"].includes(category) && acc.includes(category)
+ ? acc
+ : [...acc, category],
+ []
+ ).length >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD
+ );
+ }
+
+ _getOneLineStreetAddress(address) {
+ if (!this._cacheValue.oneLineStreetAddress) {
+ this._cacheValue.oneLineStreetAddress = {};
+ }
+ if (!this._cacheValue.oneLineStreetAddress[address]) {
+ this._cacheValue.oneLineStreetAddress[address] =
+ FormAutofillUtils.toOneLineAddress(address);
+ }
+ return this._cacheValue.oneLineStreetAddress[address];
+ }
+
+ addressTransformer(profile) {
+ if (profile["street-address"]) {
+ // "-moz-street-address-one-line" is used by the labels in
+ // ProfileAutoCompleteResult.
+ profile["-moz-street-address-one-line"] = this._getOneLineStreetAddress(
+ profile["street-address"]
+ );
+ let streetAddressDetail = this.getFieldDetailByName("street-address");
+ if (
+ streetAddressDetail &&
+ HTMLInputElement.isInstance(streetAddressDetail.element)
+ ) {
+ profile["street-address"] = profile["-moz-street-address-one-line"];
+ }
+
+ let waitForConcat = [];
+ for (let f of ["address-line3", "address-line2", "address-line1"]) {
+ waitForConcat.unshift(profile[f]);
+ if (this.getFieldDetailByName(f)) {
+ if (waitForConcat.length > 1) {
+ profile[f] = FormAutofillUtils.toOneLineAddress(waitForConcat);
+ }
+ waitForConcat = [];
+ }
+ }
+ }
+ }
+
+ /**
+ * Replace tel with tel-national if tel violates the input element's
+ * restriction.
+ *
+ * @param {object} profile
+ * A profile to be converted.
+ */
+ telTransformer(profile) {
+ if (!profile.tel || !profile["tel-national"]) {
+ return;
+ }
+
+ let detail = this.getFieldDetailByName("tel");
+ if (!detail) {
+ return;
+ }
+
+ let element = detail.element;
+ let _pattern;
+ let testPattern = str => {
+ if (!_pattern) {
+ // The pattern has to match the entire value.
+ _pattern = new RegExp("^(?:" + element.pattern + ")$", "u");
+ }
+ return _pattern.test(str);
+ };
+ if (element.pattern) {
+ if (testPattern(profile.tel)) {
+ return;
+ }
+ } else if (element.maxLength) {
+ if (
+ detail.reason == "autocomplete" &&
+ profile.tel.length <= element.maxLength
+ ) {
+ return;
+ }
+ }
+
+ if (detail.reason != "autocomplete") {
+ // Since we only target people living in US and using en-US websites in
+ // MVP, it makes more sense to fill `tel-national` instead of `tel`
+ // if the field is identified by heuristics and no other clues to
+ // determine which one is better.
+ // TODO: [Bug 1407545] This should be improved once more countries are
+ // supported.
+ profile.tel = profile["tel-national"];
+ } else if (element.pattern) {
+ if (testPattern(profile["tel-national"])) {
+ profile.tel = profile["tel-national"];
+ }
+ } else if (element.maxLength) {
+ if (profile["tel-national"].length <= element.maxLength) {
+ profile.tel = profile["tel-national"];
+ }
+ }
+ }
+
+ /*
+ * Apply all address related transformers.
+ *
+ * @param {Object} profile
+ * A profile for adjusting address related value.
+ * @override
+ */
+ applyTransformers(profile) {
+ this.addressTransformer(profile);
+ this.telTransformer(profile);
+ this.matchSelectOptions(profile);
+ this.adaptFieldMaxLength(profile);
+ }
+
+ computeFillingValue(value, fieldDetail, element) {
+ // Try to abbreviate the value of select element.
+ if (
+ fieldDetail.fieldName == "address-level1" &&
+ HTMLSelectElement.isInstance(element)
+ ) {
+ // Don't save the record when the option value is empty *OR* there
+ // are multiple options being selected. The empty option is usually
+ // assumed to be default along with a meaningless text to users.
+ if (!value || element.selectedOptions.length != 1) {
+ // Keep the property and preserve more information for address updating
+ value = "";
+ } else {
+ let text = element.selectedOptions[0].text.trim();
+ value =
+ FormAutofillUtils.getAbbreviatedSubregionName([value, text]) || text;
+ }
+ }
+ return value;
+ }
+}
+
+export class FormAutofillCreditCardSection extends FormAutofillSection {
+ /**
+ * Credit Card Section Constructor
+ *
+ * @param {Array<FieldDetails>} fieldDetails
+ * The fieldDetail objects for the fields in this section
+ * @param {Object<FormAutofillHandler>} handler
+ * The handler responsible for this section
+ */
+ constructor(fieldDetails, handler) {
+ super(fieldDetails, handler);
+
+ if (!this.isValidSection()) {
+ return;
+ }
+
+ lazy.AutofillTelemetry.recordDetectedSectionCount(this);
+ lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this);
+
+ // Check whether the section is in an <iframe>; and, if so,
+ // watch for the <iframe> to pagehide.
+ if (handler.window.location != handler.window.parent?.location) {
+ this.log.debug(
+ "Credit card form is in an iframe -- watching for pagehide",
+ fieldDetails
+ );
+ handler.window.addEventListener(
+ "pagehide",
+ this._handlePageHide.bind(this)
+ );
+ }
+ }
+
+ _handlePageHide(event) {
+ this.handler.window.removeEventListener(
+ "pagehide",
+ this._handlePageHide.bind(this)
+ );
+ this.log.debug("Credit card subframe is pagehideing", this.handler.form);
+
+ const formSubmissionReason =
+ FormAutofillUtils.FORM_SUBMISSION_REASON.IFRAME_PAGEHIDE;
+ this.handler.onFormSubmitted(formSubmissionReason);
+ }
+
+ /**
+ * Determine whether a set of cc fields identified by our heuristics form a
+ * valid credit card section.
+ * There are 4 different cases when a field is considered a credit card field
+ * 1. Identified by autocomplete attribute. ex <input autocomplete="cc-number">
+ * 2. Identified by fathom and fathom is pretty confident (when confidence
+ * value is higher than `highConfidenceThreshold`)
+ * 3. Identified by fathom. Confidence value is between `fathom.confidenceThreshold`
+ * and `fathom.highConfidenceThreshold`
+ * 4. Identified by regex-based heurstic. There is no confidence value in thise case.
+ *
+ * A form is considered a valid credit card form when one of the following condition
+ * is met:
+ * A. One of the cc field is identified by autocomplete (case 1)
+ * B. One of the cc field is identified by fathom (case 2 or 3), and there is also
+ * another cc field found by any of our heuristic (case 2, 3, or 4)
+ * C. Only one cc field is found in the section, but fathom is very confident (Case 2).
+ * Currently we add an extra restriction to this rule to decrease the false-positive
+ * rate. See comments below for details.
+ *
+ * @returns {boolean} True for a valid section, otherwise false
+ */
+ isValidSection() {
+ let ccNumberDetail = null;
+ let ccNameDetail = null;
+ let ccExpiryDetail = null;
+
+ for (let detail of this.fieldDetails) {
+ switch (detail.fieldName) {
+ case "cc-number":
+ ccNumberDetail = detail;
+ break;
+ case "cc-name":
+ case "cc-given-name":
+ case "cc-additional-name":
+ case "cc-family-name":
+ ccNameDetail = detail;
+ break;
+ case "cc-exp":
+ case "cc-exp-month":
+ case "cc-exp-year":
+ ccExpiryDetail = detail;
+ break;
+ }
+ }
+
+ // Condition A. Always trust autocomplete attribute. A section is considered a valid
+ // cc section as long as a field has autocomplete=cc-number, cc-name or cc-exp*
+ if (
+ ccNumberDetail?.reason == "autocomplete" ||
+ ccNameDetail?.reason == "autocomplete" ||
+ ccExpiryDetail?.reason == "autocomplete"
+ ) {
+ return true;
+ }
+
+ // Condition B. One of the field is identified by fathom, if this section also
+ // contains another cc field found by our heuristic (Case 2, 3, or 4), we consider
+ // this section a valid credit card seciton
+ if (ccNumberDetail?.reason == "fathom") {
+ if (ccNameDetail || ccExpiryDetail) {
+ return true;
+ }
+ } else if (ccNameDetail?.reason == "fathom") {
+ if (ccNumberDetail || ccExpiryDetail) {
+ return true;
+ }
+ }
+
+ // Condition C.
+ let highConfidenceThreshold =
+ FormAutofillUtils.ccFathomHighConfidenceThreshold;
+ let highConfidenceField;
+ if (ccNumberDetail?.confidence > highConfidenceThreshold) {
+ highConfidenceField = ccNumberDetail;
+ } else if (ccNameDetail?.confidence > highConfidenceThreshold) {
+ highConfidenceField = ccNameDetail;
+ }
+ if (highConfidenceField) {
+ // Temporarily add an addtional "the field is the only visible input" constraint
+ // when determining whether a form has only a high-confidence cc-* field a valid
+ // credit card section. We can remove this restriction once we are confident
+ // about only using fathom.
+ const element = highConfidenceField.element;
+ const root = element.form || element.ownerDocument;
+ const inputs = root.querySelectorAll("input:not([type=hidden])");
+ if (inputs.length == 1 && inputs[0] == element) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ isEnabled() {
+ return FormAutofill.isAutofillCreditCardsEnabled;
+ }
+
+ isRecordCreatable(record) {
+ return (
+ record["cc-number"] && FormAutofillUtils.isCCNumber(record["cc-number"])
+ );
+ }
+
+ /**
+ * Handles credit card expiry date transformation when
+ * the expiry date exists in a cc-exp field.
+ *
+ * @param {object} profile
+ * @memberof FormAutofillCreditCardSection
+ */
+ creditCardExpiryDateTransformer(profile) {
+ if (!profile["cc-exp"]) {
+ return;
+ }
+
+ const element = this.getFieldDetailByName("cc-exp")?.element;
+ if (!element) {
+ return;
+ }
+
+ function updateExpiry(_string, _month, _year) {
+ // Bug 1687681: This is a short term fix to other locales having
+ // different characters to represent year.
+ // - FR locales may use "A" to represent year.
+ // - DE locales may use "J" to represent year.
+ // - PL locales may use "R" to represent year.
+ // This approach will not scale well and should be investigated in a follow up bug.
+ const monthChars = "m";
+ const yearChars = "yy|aa|jj|rr";
+ const expiryDateFormatRegex = (firstChars, secondChars) =>
+ new RegExp(
+ "(?:\\b|^)((?:[" +
+ firstChars +
+ "]{2}){1,2})\\s*([\\-/])\\s*((?:[" +
+ secondChars +
+ "]{2}){1,2})(?:\\b|$)",
+ "i"
+ );
+
+ // If the month first check finds a result, where placeholder is "mm - yyyy",
+ // the result will be structured as such: ["mm - yyyy", "mm", "-", "yyyy"]
+ let result = expiryDateFormatRegex(monthChars, yearChars).exec(_string);
+ if (result) {
+ return (
+ _month.padStart(result[1].length, "0") +
+ result[2] +
+ _year.substr(-1 * result[3].length)
+ );
+ }
+
+ // If the year first check finds a result, where placeholder is "yyyy mm",
+ // the result will be structured as such: ["yyyy mm", "yyyy", " ", "mm"]
+ result = expiryDateFormatRegex(yearChars, monthChars).exec(_string);
+ if (result) {
+ return (
+ _year.substr(-1 * result[1].length) +
+ result[2] +
+ _month.padStart(result[3].length, "0")
+ );
+ }
+ return null;
+ }
+
+ let newExpiryString = null;
+ const month = profile["cc-exp-month"].toString();
+ const year = profile["cc-exp-year"].toString();
+ if (element.tagName == "INPUT") {
+ // Use the placeholder or label to determine the expiry string format.
+ const possibleExpiryStrings = [];
+ if (element.placeholder) {
+ possibleExpiryStrings.push(element.placeholder);
+ }
+ const labels = lazy.LabelUtils.findLabelElements(element);
+ if (labels) {
+ // Not consider multiple lable for now.
+ possibleExpiryStrings.push(element.labels[0]?.textContent);
+ }
+ if (element.previousElementSibling?.tagName == "LABEL") {
+ possibleExpiryStrings.push(element.previousElementSibling.textContent);
+ }
+
+ possibleExpiryStrings.some(string => {
+ newExpiryString = updateExpiry(string, month, year);
+ return !!newExpiryString;
+ });
+ }
+
+ // Bug 1688576: Change YYYY-MM to MM/YYYY since MM/YYYY is the
+ // preferred presentation format for credit card expiry dates.
+ profile["cc-exp"] = newExpiryString ?? `${month.padStart(2, "0")}/${year}`;
+ }
+
+ /**
+ * Handles credit card expiry date transformation when the expiry date exists in
+ * the separate cc-exp-month and cc-exp-year fields
+ *
+ * @param {object} profile
+ * @memberof FormAutofillCreditCardSection
+ */
+ creditCardExpMonthAndYearTransformer(profile) {
+ const getInputElementByField = (field, self) => {
+ if (!field) {
+ return null;
+ }
+ let detail = self.getFieldDetailByName(field);
+ if (!detail) {
+ return null;
+ }
+ let element = detail.element;
+ return element.tagName === "INPUT" ? element : null;
+ };
+ let month = getInputElementByField("cc-exp-month", this);
+ if (month) {
+ // Transform the expiry month to MM since this is a common format needed for filling.
+ profile["cc-exp-month-formatted"] = profile["cc-exp-month"]
+ ?.toString()
+ .padStart(2, "0");
+ }
+ let year = getInputElementByField("cc-exp-year", this);
+ // If the expiration year element is an input,
+ // then we examine any placeholder to see if we should format the expiration year
+ // as a zero padded string in order to autofill correctly.
+ if (year) {
+ let placeholder = year.placeholder;
+
+ // Checks for 'YY'|'AA'|'JJ'|'RR' placeholder and converts the year to a two digit string using the last two digits.
+ let result = /\b(yy|aa|jj|rr)\b/i.test(placeholder);
+ if (result) {
+ profile["cc-exp-year-formatted"] = profile["cc-exp-year"]
+ .toString()
+ .substring(2);
+ }
+ }
+ }
+
+ /**
+ * Handles credit card name transformation when the name exists in
+ * the separate cc-given-name, cc-middle-name, and cc-family name fields
+ *
+ * @param {object} profile
+ * @memberof FormAutofillCreditCardSection
+ */
+ creditCardNameTransformer(profile) {
+ const name = profile["cc-name"];
+ if (!name) {
+ return;
+ }
+
+ const given = this.getFieldDetailByName("cc-given-name");
+ const middle = this.getFieldDetailByName("cc-middle-name");
+ const family = this.getFieldDetailByName("cc-family-name");
+ if (given || middle || family) {
+ const nameParts = lazy.FormAutofillNameUtils.splitName(name);
+ if (given && nameParts.given) {
+ profile["cc-given-name"] = nameParts.given;
+ }
+ if (middle && nameParts.middle) {
+ profile["cc-middle-name"] = nameParts.middle;
+ }
+ if (family && nameParts.family) {
+ profile["cc-family-name"] = nameParts.family;
+ }
+ }
+ }
+
+ async _decrypt(cipherText, reauth) {
+ // Get the window for the form field.
+ let window;
+ for (let fieldDetail of this.fieldDetails) {
+ let element = fieldDetail.element;
+ if (element) {
+ window = element.ownerGlobal;
+ break;
+ }
+ }
+ if (!window) {
+ return null;
+ }
+
+ let actor = window.windowGlobalChild.getActor("FormAutofill");
+ return actor.sendQuery("FormAutofill:GetDecryptedString", {
+ cipherText,
+ reauth,
+ });
+ }
+
+ /*
+ * Apply all credit card related transformers.
+ *
+ * @param {Object} profile
+ * A profile for adjusting credit card related value.
+ * @override
+ */
+ applyTransformers(profile) {
+ // The matchSelectOptions transformer must be placed after the expiry transformers.
+ // This ensures that the expiry value that is cached in the matchSelectOptions
+ // matches the expiry value that is stored in the profile ensuring that autofill works
+ // correctly when dealing with option elements.
+ this.creditCardExpiryDateTransformer(profile);
+ this.creditCardExpMonthAndYearTransformer(profile);
+ this.creditCardNameTransformer(profile);
+ this.matchSelectOptions(profile);
+ this.adaptFieldMaxLength(profile);
+ }
+
+ getFilledValueFromProfile(fieldDetail, profile) {
+ const value = super.getFilledValueFromProfile(fieldDetail, profile);
+ if (fieldDetail.fieldName == "cc-number" && fieldDetail.part != null) {
+ const part = fieldDetail.part;
+ return value.slice((part - 1) * 4, part * 4);
+ }
+ return value;
+ }
+
+ computeFillingValue(value, fieldDetail, element) {
+ if (
+ fieldDetail.fieldName != "cc-type" ||
+ !HTMLSelectElement.isInstance(element)
+ ) {
+ return value;
+ }
+
+ if (lazy.CreditCard.isValidNetwork(value)) {
+ return value;
+ }
+
+ // Don't save the record when the option value is empty *OR* there
+ // are multiple options being selected. The empty option is usually
+ // assumed to be default along with a meaningless text to users.
+ if (value && element.selectedOptions.length == 1) {
+ let selectedOption = element.selectedOptions[0];
+ let networkType =
+ lazy.CreditCard.getNetworkFromName(selectedOption.text) ??
+ lazy.CreditCard.getNetworkFromName(selectedOption.value);
+ if (networkType) {
+ return networkType;
+ }
+ }
+ // If we couldn't match the value to any network, we'll
+ // strip this field when submitting.
+ return value;
+ }
+
+ /**
+ * Customize for previewing profile
+ *
+ * @param {object} profile
+ * A profile for pre-processing before previewing values.
+ * @override
+ */
+ preparePreviewProfile(profile) {
+ // Always show the decrypted credit card number when Master Password is
+ // disabled.
+ if (profile["cc-number-decrypted"]) {
+ profile["cc-number"] = profile["cc-number-decrypted"];
+ } else if (!profile["cc-number"].startsWith("****")) {
+ // Show the previewed credit card as "**** 4444" which is
+ // needed when a credit card number field has a maxlength of four.
+ profile["cc-number"] = "****" + profile["cc-number"];
+ }
+ }
+
+ /**
+ * Customize for filling profile
+ *
+ * @param {object} profile
+ * A profile for pre-processing before filling values.
+ * @returns {boolean} Whether the profile should be filled.
+ * @override
+ */
+ async prepareFillingProfile(profile) {
+ // Prompt the OS login dialog to get the decrypted credit card number.
+ if (profile["cc-number-encrypted"]) {
+ const promptMessage = FormAutofillUtils.reauthOSPromptMessage(
+ "autofill-use-payment-method-os-prompt-macos",
+ "autofill-use-payment-method-os-prompt-windows",
+ "autofill-use-payment-method-os-prompt-other"
+ );
+ let decrypted = await this._decrypt(
+ profile["cc-number-encrypted"],
+ promptMessage
+ );
+
+ if (!decrypted) {
+ // Early return if the decrypted is empty or undefined
+ return false;
+ }
+
+ profile["cc-number"] = decrypted;
+ }
+ return true;
+ }
+}
diff --git a/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs
new file mode 100644
index 0000000000..ce10c71ce1
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs
@@ -0,0 +1,1129 @@
+/* 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/. */
+
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ FormAutofillNameUtils:
+ "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+ AddressMetaDataLoader:
+ "resource://gre/modules/shared/AddressMetaDataLoader.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () =>
+ new Localization(
+ ["toolkit/formautofill/formAutofill.ftl", "branding/brand.ftl"],
+ true
+ )
+);
+
+export let FormAutofillUtils;
+
+const ADDRESSES_COLLECTION_NAME = "addresses";
+const CREDITCARDS_COLLECTION_NAME = "creditCards";
+const MANAGE_ADDRESSES_L10N_IDS = [
+ "autofill-add-address-title",
+ "autofill-manage-addresses-title",
+];
+const EDIT_ADDRESS_L10N_IDS = [
+ "autofill-address-given-name",
+ "autofill-address-additional-name",
+ "autofill-address-family-name",
+ "autofill-address-organization",
+ "autofill-address-street",
+ "autofill-address-state",
+ "autofill-address-province",
+ "autofill-address-city",
+ "autofill-address-country",
+ "autofill-address-zip",
+ "autofill-address-postal-code",
+ "autofill-address-email",
+ "autofill-address-tel",
+];
+const MANAGE_CREDITCARDS_L10N_IDS = [
+ "autofill-add-card-title",
+ "autofill-manage-payment-methods-title",
+];
+const EDIT_CREDITCARD_L10N_IDS = [
+ "autofill-card-number",
+ "autofill-card-name-on-card",
+ "autofill-card-expires-month",
+ "autofill-card-expires-year",
+ "autofill-card-network",
+];
+const FIELD_STATES = {
+ NORMAL: "NORMAL",
+ AUTO_FILLED: "AUTO_FILLED",
+ PREVIEW: "PREVIEW",
+};
+const FORM_SUBMISSION_REASON = {
+ FORM_SUBMIT_EVENT: "form-submit-event",
+ FORM_REMOVAL_AFTER_FETCH: "form-removal-after-fetch",
+ IFRAME_PAGEHIDE: "iframe-pagehide",
+ PAGE_NAVIGATION: "page-navigation",
+};
+
+const ELIGIBLE_INPUT_TYPES = ["text", "email", "tel", "number", "month"];
+
+// The maximum length of data to be saved in a single field for preventing DoS
+// attacks that fill the user's hard drive(s).
+const MAX_FIELD_VALUE_LENGTH = 200;
+
+FormAutofillUtils = {
+ get AUTOFILL_FIELDS_THRESHOLD() {
+ return 3;
+ },
+
+ ADDRESSES_COLLECTION_NAME,
+ CREDITCARDS_COLLECTION_NAME,
+ MANAGE_ADDRESSES_L10N_IDS,
+ EDIT_ADDRESS_L10N_IDS,
+ MANAGE_CREDITCARDS_L10N_IDS,
+ EDIT_CREDITCARD_L10N_IDS,
+ MAX_FIELD_VALUE_LENGTH,
+ FIELD_STATES,
+ FORM_SUBMISSION_REASON,
+
+ _fieldNameInfo: {
+ name: "name",
+ "given-name": "name",
+ "additional-name": "name",
+ "family-name": "name",
+ organization: "organization",
+ "street-address": "address",
+ "address-line1": "address",
+ "address-line2": "address",
+ "address-line3": "address",
+ "address-level1": "address",
+ "address-level2": "address",
+ "postal-code": "address",
+ country: "address",
+ "country-name": "address",
+ tel: "tel",
+ "tel-country-code": "tel",
+ "tel-national": "tel",
+ "tel-area-code": "tel",
+ "tel-local": "tel",
+ "tel-local-prefix": "tel",
+ "tel-local-suffix": "tel",
+ "tel-extension": "tel",
+ email: "email",
+ "cc-name": "creditCard",
+ "cc-given-name": "creditCard",
+ "cc-additional-name": "creditCard",
+ "cc-family-name": "creditCard",
+ "cc-number": "creditCard",
+ "cc-exp-month": "creditCard",
+ "cc-exp-year": "creditCard",
+ "cc-exp": "creditCard",
+ "cc-type": "creditCard",
+ "cc-csc": "creditCard",
+ },
+
+ _collators: {},
+ _reAlternativeCountryNames: {},
+
+ isAddressField(fieldName) {
+ return (
+ !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName)
+ );
+ },
+
+ isCreditCardField(fieldName) {
+ return this._fieldNameInfo?.[fieldName] == "creditCard";
+ },
+
+ isCCNumber(ccNumber) {
+ return ccNumber && lazy.CreditCard.isValidNumber(ccNumber);
+ },
+
+ ensureLoggedIn(promptMessage) {
+ return lazy.OSKeyStore.ensureLoggedIn(
+ this._reauthEnabledByUser && promptMessage ? promptMessage : false
+ );
+ },
+
+ /**
+ * Get the array of credit card network ids ("types") we expect and offer as valid choices
+ *
+ * @returns {Array}
+ */
+ getCreditCardNetworks() {
+ return lazy.CreditCard.getSupportedNetworks();
+ },
+
+ getCategoryFromFieldName(fieldName) {
+ return this._fieldNameInfo[fieldName];
+ },
+
+ getCategoriesFromFieldNames(fieldNames) {
+ let categories = new Set();
+ for (let fieldName of fieldNames) {
+ let info = this.getCategoryFromFieldName(fieldName);
+ if (info) {
+ categories.add(info);
+ }
+ }
+ return Array.from(categories);
+ },
+
+ getAddressSeparator() {
+ // The separator should be based on the L10N address format, and using a
+ // white space is a temporary solution.
+ return " ";
+ },
+
+ /**
+ * Get address display label. It should display information separated
+ * by a comma.
+ *
+ * @param {object} address
+ * @returns {string}
+ */
+ getAddressLabel(address) {
+ // TODO: Implement a smarter way for deciding what to display
+ // as option text. Possibly improve the algorithm in
+ // ProfileAutoCompleteResult.jsm and reuse it here.
+ let fieldOrder = [
+ "name",
+ "-moz-street-address-one-line", // Street address
+ "address-level3", // Townland / Neighborhood / Village
+ "address-level2", // City/Town
+ "organization", // Company or organization name
+ "address-level1", // Province/State (Standardized code if possible)
+ "country", // Country name
+ "postal-code", // Postal code
+ "tel", // Phone number
+ "email", // Email address
+ ];
+
+ address = { ...address };
+ let parts = [];
+ if (address["street-address"]) {
+ address["-moz-street-address-one-line"] = this.toOneLineAddress(
+ address["street-address"]
+ );
+ }
+
+ if (!("name" in address)) {
+ address.name = lazy.FormAutofillNameUtils.joinNameParts({
+ given: address["given-name"],
+ middle: address["additional-name"],
+ family: address["family-name"],
+ });
+ }
+
+ for (const fieldName of fieldOrder) {
+ let string = address[fieldName];
+ if (string) {
+ parts.push(string);
+ }
+ }
+ return parts.join(", ");
+ },
+
+ /**
+ * Internal method to split an address to multiple parts per the provided delimiter,
+ * removing blank parts.
+ *
+ * @param {string} address The address the split
+ * @param {string} [delimiter] The separator that is used between lines in the address
+ * @returns {string[]}
+ */
+ _toStreetAddressParts(address, delimiter = "\n") {
+ let array = typeof address == "string" ? address.split(delimiter) : address;
+
+ if (!Array.isArray(array)) {
+ return [];
+ }
+ return array.map(s => (s ? s.trim() : "")).filter(s => s);
+ },
+
+ /**
+ * Converts a street address to a single line, removing linebreaks marked by the delimiter
+ *
+ * @param {string} address The address the convert
+ * @param {string} [delimiter] The separator that is used between lines in the address
+ * @returns {string}
+ */
+ toOneLineAddress(address, delimiter = "\n") {
+ let addressParts = this._toStreetAddressParts(address, delimiter);
+ return addressParts.join(this.getAddressSeparator());
+ },
+
+ /**
+ * In-place concatenate tel-related components into a single "tel" field and
+ * delete unnecessary fields.
+ *
+ * @param {object} address An address record.
+ */
+ compressTel(address) {
+ let telCountryCode = address["tel-country-code"] || "";
+ let telAreaCode = address["tel-area-code"] || "";
+
+ if (!address.tel) {
+ if (address["tel-national"]) {
+ address.tel = telCountryCode + address["tel-national"];
+ } else if (address["tel-local"]) {
+ address.tel = telCountryCode + telAreaCode + address["tel-local"];
+ } else if (address["tel-local-prefix"] && address["tel-local-suffix"]) {
+ address.tel =
+ telCountryCode +
+ telAreaCode +
+ address["tel-local-prefix"] +
+ address["tel-local-suffix"];
+ }
+ }
+
+ for (let field in address) {
+ if (field != "tel" && this.getCategoryFromFieldName(field) == "tel") {
+ delete address[field];
+ }
+ }
+ },
+
+ /**
+ * Determines if an element can be autofilled or not.
+ *
+ * @param {HTMLElement} element
+ * @returns {boolean} true if the element can be autofilled
+ */
+ isFieldAutofillable(element) {
+ return element && !element.readOnly && !element.disabled;
+ },
+
+ /**
+ * Determines if an element is focusable
+ * and accessible via keyboard navigation or not.
+ *
+ * @param {HTMLElement} element
+ *
+ * @returns {bool} true if the element is focusable and accessible
+ */
+ isFieldFocusable(element) {
+ return (
+ // The Services.focus.elementIsFocusable API considers elements with
+ // tabIndex="-1" set as focusable. But since they are not accessible
+ // via keyboard navigation we treat them as non-interactive
+ Services.focus.elementIsFocusable(element, 0) && element.tabIndex != "-1"
+ );
+ },
+
+ /**
+ * Determines if an element is eligible to be used by credit card or address autofill.
+ *
+ * @param {HTMLElement} element
+ * @returns {boolean} true if element can be used by credit card or address autofill
+ */
+ isCreditCardOrAddressFieldType(element) {
+ if (!element) {
+ return false;
+ }
+
+ if (HTMLInputElement.isInstance(element)) {
+ // `element.type` can be recognized as `text`, if it's missing or invalid.
+ return ELIGIBLE_INPUT_TYPES.includes(element.type);
+ }
+
+ return HTMLSelectElement.isInstance(element);
+ },
+
+ loadDataFromScript(url, sandbox = {}) {
+ Services.scriptloader.loadSubScript(url, sandbox);
+ return sandbox;
+ },
+
+ /**
+ * Get country address data and fallback to US if not found.
+ * See AddressMetaDataLoader.#loadData for more details of addressData structure.
+ *
+ * @param {string} [country=FormAutofill.DEFAULT_REGION]
+ * The country code for requesting specific country's metadata. It'll be
+ * default region if parameter is not set.
+ * @param {string} [level1=null]
+ * Return address level 1/level 2 metadata if parameter is set.
+ * @returns {object|null}
+ * Return metadata of specific region with default locale and other supported
+ * locales. We need to return a default country metadata for layout format
+ * and collator, but for sub-region metadata we'll just return null if not found.
+ */
+ getCountryAddressRawData(
+ country = FormAutofill.DEFAULT_REGION,
+ level1 = null
+ ) {
+ let metadata = lazy.AddressMetaDataLoader.getData(country, level1);
+ if (!metadata) {
+ if (level1) {
+ return null;
+ }
+ // Fallback to default region if we couldn't get data from given country.
+ if (country != FormAutofill.DEFAULT_REGION) {
+ metadata = lazy.AddressMetaDataLoader.getData(
+ FormAutofill.DEFAULT_REGION
+ );
+ }
+ }
+
+ // TODO: Now we fallback to US if we couldn't get data from default region,
+ // but it could be removed in bug 1423464 if it's not necessary.
+ if (!metadata) {
+ metadata = lazy.AddressMetaDataLoader.getData("US");
+ }
+ return metadata;
+ },
+
+ /**
+ * Get country address data with default locale.
+ *
+ * @param {string} country
+ * @param {string} level1
+ * @returns {object|null} Return metadata of specific region with default locale.
+ * NOTE: The returned data may be for a default region if the
+ * specified one cannot be found. Callers who only want the specific
+ * region should check the returned country code.
+ */
+ getCountryAddressData(country, level1) {
+ let metadata = this.getCountryAddressRawData(country, level1);
+ return metadata && metadata.defaultLocale;
+ },
+
+ /**
+ * Get country address data with all locales.
+ *
+ * @param {string} country
+ * @param {string} level1
+ * @returns {Array<object> | null}
+ * Return metadata of specific region with all the locales.
+ * NOTE: The returned data may be for a default region if the
+ * specified one cannot be found. Callers who only want the specific
+ * region should check the returned country code.
+ */
+ getCountryAddressDataWithLocales(country, level1) {
+ let metadata = this.getCountryAddressRawData(country, level1);
+ return metadata && [metadata.defaultLocale, ...metadata.locales];
+ },
+
+ /**
+ * Get the collators based on the specified country.
+ *
+ * @param {string} country The specified country.
+ * @param {object} [options = {}] a list of options for this method
+ * @param {boolean} [options.ignorePunctuation = true] Whether punctuation should be ignored.
+ * @param {string} [options.sensitivity = 'base'] Which differences in the strings should lead to non-zero result values
+ * @param {string} [options.usage = 'search'] Whether the comparison is for sorting or for searching for matching strings
+ * @returns {Array} An array containing several collator objects.
+ */
+ getSearchCollators(
+ country,
+ { ignorePunctuation = true, sensitivity = "base", usage = "search" } = {}
+ ) {
+ // TODO: Only one language should be used at a time per country. The locale
+ // of the page should be taken into account to do this properly.
+ // We are going to support more countries in bug 1370193 and this
+ // should be addressed when we start to implement that bug.
+
+ if (!this._collators[country]) {
+ let dataset = this.getCountryAddressData(country);
+ let languages = dataset.languages || [dataset.lang];
+ let options = {
+ ignorePunctuation,
+ sensitivity,
+ usage,
+ };
+ this._collators[country] = languages.map(
+ lang => new Intl.Collator(lang, options)
+ );
+ }
+ return this._collators[country];
+ },
+
+ // Based on the list of fields abbreviations in
+ // https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata
+ FIELDS_LOOKUP: {
+ N: "name",
+ O: "organization",
+ A: "street-address",
+ S: "address-level1",
+ C: "address-level2",
+ D: "address-level3",
+ Z: "postal-code",
+ n: "newLine",
+ },
+
+ /**
+ * Parse a country address format string and outputs an array of fields.
+ * Spaces, commas, and other literals are ignored in this implementation.
+ * For example, format string "%A%n%C, %S" should return:
+ * [
+ * {fieldId: "street-address", newLine: true},
+ * {fieldId: "address-level2"},
+ * {fieldId: "address-level1"},
+ * ]
+ *
+ * @param {string} fmt Country address format string
+ * @returns {Array<object>} List of fields
+ */
+ parseAddressFormat(fmt) {
+ if (!fmt) {
+ throw new Error("fmt string is missing.");
+ }
+
+ return fmt.match(/%[^%]/g).reduce((parsed, part) => {
+ // Take the first letter of each segment and try to identify it
+ let fieldId = this.FIELDS_LOOKUP[part[1]];
+ // Early return if cannot identify part.
+ if (!fieldId) {
+ return parsed;
+ }
+ // If a new line is detected, add an attribute to the previous field.
+ if (fieldId == "newLine") {
+ let size = parsed.length;
+ if (size) {
+ parsed[size - 1].newLine = true;
+ }
+ return parsed;
+ }
+ return parsed.concat({ fieldId });
+ }, []);
+ },
+
+ /**
+ * Used to populate dropdowns in the UI (e.g. FormAutofill preferences).
+ * Use findAddressSelectOption for matching a value to a region.
+ *
+ * @param {string[]} subKeys An array of regionCode strings
+ * @param {string[]} subIsoids An array of ISO ID strings, if provided will be preferred over the key
+ * @param {string[]} subNames An array of regionName strings
+ * @param {string[]} subLnames An array of latinised regionName strings
+ * @returns {Map?} Returns null if subKeys or subNames are not truthy.
+ * Otherwise, a Map will be returned mapping keys -> names.
+ */
+ buildRegionMapIfAvailable(subKeys, subIsoids, subNames, subLnames) {
+ // Not all regions have sub_keys. e.g. DE
+ if (
+ !subKeys ||
+ !subKeys.length ||
+ (!subNames && !subLnames) ||
+ (subNames && subKeys.length != subNames.length) ||
+ (subLnames && subKeys.length != subLnames.length)
+ ) {
+ return null;
+ }
+
+ // Overwrite subKeys with subIsoids, when available
+ if (subIsoids && subIsoids.length && subIsoids.length == subKeys.length) {
+ for (let i = 0; i < subIsoids.length; i++) {
+ if (subIsoids[i]) {
+ subKeys[i] = subIsoids[i];
+ }
+ }
+ }
+
+ // Apply sub_lnames if sub_names does not exist
+ let names = subNames || subLnames;
+ return new Map(subKeys.map((key, index) => [key, names[index]]));
+ },
+
+ /**
+ * Parse a require string and outputs an array of fields.
+ * Spaces, commas, and other literals are ignored in this implementation.
+ * For example, a require string "ACS" should return:
+ * ["street-address", "address-level2", "address-level1"]
+ *
+ * @param {string} requireString Country address require string
+ * @returns {Array<string>} List of fields
+ */
+ parseRequireString(requireString) {
+ if (!requireString) {
+ throw new Error("requireString string is missing.");
+ }
+
+ return requireString.split("").map(fieldId => this.FIELDS_LOOKUP[fieldId]);
+ },
+
+ /**
+ * Use address data and alternative country name list to identify a country code from a
+ * specified country name.
+ *
+ * @param {string} countryName A country name to be identified
+ * @param {string} [countrySpecified] A country code indicating that we only
+ * search its alternative names if specified.
+ * @returns {string} The matching country code.
+ */
+ identifyCountryCode(countryName, countrySpecified) {
+ if (!countryName) {
+ return null;
+ }
+
+ if (lazy.AddressMetaDataLoader.getData(countryName)) {
+ return countryName;
+ }
+
+ const countries = countrySpecified
+ ? [countrySpecified]
+ : [...FormAutofill.countries.keys()];
+
+ for (const country of countries) {
+ let collators = this.getSearchCollators(country);
+ let metadata = this.getCountryAddressData(country);
+ if (country != metadata.key) {
+ // We hit the fallback logic in getCountryAddressRawData so ignore it as
+ // it's not related to `country` and use the name from l10n instead.
+ metadata = {
+ id: `data/${country}`,
+ key: country,
+ name: FormAutofill.countries.get(country),
+ };
+ }
+ let alternativeCountryNames = metadata.alternative_names || [
+ metadata.name,
+ ];
+ let reAlternativeCountryNames = this._reAlternativeCountryNames[country];
+ if (!reAlternativeCountryNames) {
+ reAlternativeCountryNames = this._reAlternativeCountryNames[country] =
+ [];
+ }
+
+ if (countryName.length == 3) {
+ if (this.strCompare(metadata.alpha_3_code, countryName, collators)) {
+ return country;
+ }
+ }
+
+ for (let i = 0; i < alternativeCountryNames.length; i++) {
+ let name = alternativeCountryNames[i];
+ let reName = reAlternativeCountryNames[i];
+ if (!reName) {
+ reName = reAlternativeCountryNames[i] = new RegExp(
+ "\\b" + this.escapeRegExp(name) + "\\b",
+ "i"
+ );
+ }
+
+ if (
+ this.strCompare(name, countryName, collators) ||
+ reName.test(countryName)
+ ) {
+ return country;
+ }
+ }
+ }
+
+ return null;
+ },
+
+ findSelectOption(selectEl, record, fieldName) {
+ if (this.isAddressField(fieldName)) {
+ return this.findAddressSelectOption(selectEl, record, fieldName);
+ }
+ if (this.isCreditCardField(fieldName)) {
+ return this.findCreditCardSelectOption(selectEl, record, fieldName);
+ }
+ return null;
+ },
+
+ /**
+ * Try to find the abbreviation of the given sub-region name
+ *
+ * @param {string[]} subregionValues A list of inferable sub-region values.
+ * @param {string} [country] A country name to be identified.
+ * @returns {string} The matching sub-region abbreviation.
+ */
+ getAbbreviatedSubregionName(subregionValues, country) {
+ let values = Array.isArray(subregionValues)
+ ? subregionValues
+ : [subregionValues];
+
+ let collators = this.getSearchCollators(country);
+ for (let metadata of this.getCountryAddressDataWithLocales(country)) {
+ let {
+ sub_keys: subKeys,
+ sub_names: subNames,
+ sub_lnames: subLnames,
+ } = metadata;
+ if (!subKeys) {
+ // Not all regions have sub_keys. e.g. DE
+ continue;
+ }
+ // Apply sub_lnames if sub_names does not exist
+ subNames = subNames || subLnames;
+
+ let speculatedSubIndexes = [];
+ for (const val of values) {
+ let identifiedValue = this.identifyValue(
+ subKeys,
+ subNames,
+ val,
+ collators
+ );
+ if (identifiedValue) {
+ return identifiedValue;
+ }
+
+ // Predict the possible state by partial-matching if no exact match.
+ [subKeys, subNames].forEach(sub => {
+ speculatedSubIndexes.push(
+ sub.findIndex(token => {
+ let pattern = new RegExp(
+ "\\b" + this.escapeRegExp(token) + "\\b"
+ );
+
+ return pattern.test(val);
+ })
+ );
+ });
+ }
+ let subKey = subKeys[speculatedSubIndexes.find(i => !!~i)];
+ if (subKey) {
+ return subKey;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Find the option element from select element.
+ * 1. Try to find the locale using the country from address.
+ * 2. First pass try to find exact match.
+ * 3. Second pass try to identify values from address value and options,
+ * and look for a match.
+ *
+ * @param {DOMElement} selectEl
+ * @param {object} address
+ * @param {string} fieldName
+ * @returns {DOMElement}
+ */
+ findAddressSelectOption(selectEl, address, fieldName) {
+ if (selectEl.options.length > 512) {
+ // Allow enough space for all countries (roughly 300 distinct values) and all
+ // timezones (roughly 400 distinct values), plus some extra wiggle room.
+ return null;
+ }
+ let value = address[fieldName];
+ if (!value) {
+ return null;
+ }
+
+ let collators = this.getSearchCollators(address.country);
+
+ for (let option of selectEl.options) {
+ if (
+ this.strCompare(value, option.value, collators) ||
+ this.strCompare(value, option.text, collators)
+ ) {
+ return option;
+ }
+ }
+
+ switch (fieldName) {
+ case "address-level1": {
+ let { country } = address;
+ let identifiedValue = this.getAbbreviatedSubregionName(
+ [value],
+ country
+ );
+ // No point going any further if we cannot identify value from address level 1
+ if (!identifiedValue) {
+ return null;
+ }
+ for (let dataset of this.getCountryAddressDataWithLocales(country)) {
+ let keys = dataset.sub_keys;
+ if (!keys) {
+ // Not all regions have sub_keys. e.g. DE
+ continue;
+ }
+ // Apply sub_lnames if sub_names does not exist
+ let names = dataset.sub_names || dataset.sub_lnames;
+
+ // Go through options one by one to find a match.
+ // Also check if any option contain the address-level1 key.
+ let pattern = new RegExp(
+ "\\b" + this.escapeRegExp(identifiedValue) + "\\b",
+ "i"
+ );
+ for (let option of selectEl.options) {
+ let optionValue = this.identifyValue(
+ keys,
+ names,
+ option.value,
+ collators
+ );
+ let optionText = this.identifyValue(
+ keys,
+ names,
+ option.text,
+ collators
+ );
+ if (
+ identifiedValue === optionValue ||
+ identifiedValue === optionText ||
+ pattern.test(option.value)
+ ) {
+ return option;
+ }
+ }
+ }
+ break;
+ }
+ case "country": {
+ if (this.getCountryAddressData(value)) {
+ for (let option of selectEl.options) {
+ if (
+ this.identifyCountryCode(option.text, value) ||
+ this.identifyCountryCode(option.value, value)
+ ) {
+ return option;
+ }
+ }
+ }
+ break;
+ }
+ }
+
+ return null;
+ },
+
+ findCreditCardSelectOption(selectEl, creditCard, fieldName) {
+ let oneDigitMonth = creditCard["cc-exp-month"]
+ ? creditCard["cc-exp-month"].toString()
+ : null;
+ let twoDigitsMonth = oneDigitMonth ? oneDigitMonth.padStart(2, "0") : null;
+ let fourDigitsYear = creditCard["cc-exp-year"]
+ ? creditCard["cc-exp-year"].toString()
+ : null;
+ let twoDigitsYear = fourDigitsYear ? fourDigitsYear.substr(2, 2) : null;
+ let options = Array.from(selectEl.options);
+
+ switch (fieldName) {
+ case "cc-exp-month": {
+ if (!oneDigitMonth) {
+ return null;
+ }
+ for (let option of options) {
+ if (
+ [option.text, option.label, option.value].some(s => {
+ let result = /[1-9]\d*/.exec(s);
+ return result && result[0] == oneDigitMonth;
+ })
+ ) {
+ return option;
+ }
+ }
+ break;
+ }
+ case "cc-exp-year": {
+ if (!fourDigitsYear) {
+ return null;
+ }
+ for (let option of options) {
+ if (
+ [option.text, option.label, option.value].some(
+ s => s == twoDigitsYear || s == fourDigitsYear
+ )
+ ) {
+ return option;
+ }
+ }
+ break;
+ }
+ case "cc-exp": {
+ if (!oneDigitMonth || !fourDigitsYear) {
+ return null;
+ }
+ let patterns = [
+ oneDigitMonth + "/" + twoDigitsYear, // 8/22
+ oneDigitMonth + "/" + fourDigitsYear, // 8/2022
+ twoDigitsMonth + "/" + twoDigitsYear, // 08/22
+ twoDigitsMonth + "/" + fourDigitsYear, // 08/2022
+ oneDigitMonth + "-" + twoDigitsYear, // 8-22
+ oneDigitMonth + "-" + fourDigitsYear, // 8-2022
+ twoDigitsMonth + "-" + twoDigitsYear, // 08-22
+ twoDigitsMonth + "-" + fourDigitsYear, // 08-2022
+ twoDigitsYear + "-" + twoDigitsMonth, // 22-08
+ fourDigitsYear + "-" + twoDigitsMonth, // 2022-08
+ fourDigitsYear + "/" + oneDigitMonth, // 2022/8
+ twoDigitsMonth + twoDigitsYear, // 0822
+ twoDigitsYear + twoDigitsMonth, // 2208
+ ];
+
+ for (let option of options) {
+ if (
+ [option.text, option.label, option.value].some(str =>
+ patterns.some(pattern => str.includes(pattern))
+ )
+ ) {
+ return option;
+ }
+ }
+ break;
+ }
+ case "cc-type": {
+ let network = creditCard["cc-type"] || "";
+ for (let option of options) {
+ if (
+ [option.text, option.label, option.value].some(
+ s => lazy.CreditCard.getNetworkFromName(s) == network
+ )
+ ) {
+ return option;
+ }
+ }
+ break;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Try to match value with keys and names, but always return the key.
+ *
+ * @param {Array<string>} keys
+ * @param {Array<string>} names
+ * @param {string} value
+ * @param {Array} collators
+ * @returns {string}
+ */
+ identifyValue(keys, names, value, collators) {
+ let resultKey = keys.find(key => this.strCompare(value, key, collators));
+ if (resultKey) {
+ return resultKey;
+ }
+
+ let index = names.findIndex(name =>
+ this.strCompare(value, name, collators)
+ );
+ if (index !== -1) {
+ return keys[index];
+ }
+
+ return null;
+ },
+
+ /**
+ * Compare if two strings are the same.
+ *
+ * @param {string} a
+ * @param {string} b
+ * @param {Array} collators
+ * @returns {boolean}
+ */
+ strCompare(a = "", b = "", collators) {
+ return collators.some(collator => !collator.compare(a, b));
+ },
+
+ /**
+ * Determine whether one string(b) may be found within another string(a)
+ *
+ * @param {string} a
+ * @param {string} b
+ * @param {Array} collators
+ * @returns {boolean} True if the string is found
+ */
+ strInclude(a = "", b = "", collators) {
+ const len = a.length - b.length;
+ for (let i = 0; i <= len; i++) {
+ if (this.strCompare(a.substring(i, i + b.length), b, collators)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Escaping user input to be treated as a literal string within a regular
+ * expression.
+ *
+ * @param {string} string
+ * @returns {string}
+ */
+ escapeRegExp(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ },
+
+ /**
+ * Get formatting information of a given country
+ *
+ * @param {string} country
+ * @returns {object}
+ * {
+ * {string} addressLevel3L10nId
+ * {string} addressLevel2L10nId
+ * {string} addressLevel1L10nId
+ * {string} postalCodeL10nId
+ * {object} fieldsOrder
+ * {string} postalCodePattern
+ * }
+ */
+ getFormFormat(country) {
+ let dataset = this.getCountryAddressData(country);
+ // We hit a country fallback in `getCountryAddressRawData` but it's not relevant here.
+ if (country != dataset.key) {
+ // Use a sparse object so the below default values take effect.
+ dataset = {
+ /**
+ * Even though data/ZZ only has address-level2, include the other levels
+ * in case they are needed for unknown countries. Users can leave the
+ * unnecessary fields blank which is better than forcing users to enter
+ * the data in incorrect fields.
+ */
+ fmt: "%N%n%O%n%A%n%C %S %Z",
+ };
+ }
+ return {
+ // When particular values are missing for a country, the
+ // data/ZZ value should be used instead:
+ // https://chromium-i18n.appspot.com/ssl-aggregate-address/data/ZZ
+ addressLevel3L10nId: this.getAddressFieldL10nId(
+ dataset.sublocality_name_type || "suburb"
+ ),
+ addressLevel2L10nId: this.getAddressFieldL10nId(
+ dataset.locality_name_type || "city"
+ ),
+ addressLevel1L10nId: this.getAddressFieldL10nId(
+ dataset.state_name_type || "province"
+ ),
+ addressLevel1Options: this.buildRegionMapIfAvailable(
+ dataset.sub_keys,
+ dataset.sub_isoids,
+ dataset.sub_names,
+ dataset.sub_lnames
+ ),
+ countryRequiredFields: this.parseRequireString(dataset.require || "AC"),
+ fieldsOrder: this.parseAddressFormat(dataset.fmt || "%N%n%O%n%A%n%C"),
+ postalCodeL10nId: this.getAddressFieldL10nId(
+ dataset.zip_name_type || "postal-code"
+ ),
+ postalCodePattern: dataset.zip,
+ };
+ },
+
+ getAddressFieldL10nId(type) {
+ return "autofill-address-" + type.replace(/_/g, "-");
+ },
+
+ CC_FATHOM_NONE: 0,
+ CC_FATHOM_JS: 1,
+ CC_FATHOM_NATIVE: 2,
+ isFathomCreditCardsEnabled() {
+ return this.ccHeuristicsMode != this.CC_FATHOM_NONE;
+ },
+
+ /**
+ * Transform the key in FormAutofillConfidences (defined in ChromeUtils.webidl)
+ * to fathom recognized field type.
+ *
+ * @param {string} key key from FormAutofillConfidences dictionary
+ * @returns {string} fathom field type
+ */
+ formAutofillConfidencesKeyToCCFieldType(key) {
+ const MAP = {
+ ccNumber: "cc-number",
+ ccName: "cc-name",
+ ccType: "cc-type",
+ ccExp: "cc-exp",
+ ccExpMonth: "cc-exp-month",
+ ccExpYear: "cc-exp-year",
+ };
+ return MAP[key];
+ },
+ /**
+ * Generates the localized os dialog message that
+ * prompts the user to reauthenticate
+ *
+ * @param {string} msgMac fluent message id for macos clients
+ * @param {string} msgWin fluent message id for windows clients
+ * @param {string} msgOther fluent message id for other clients
+ * @param {string} msgLin (optional) fluent message id for linux clients
+ * @returns {string} localized os prompt message
+ */
+ reauthOSPromptMessage(msgMac, msgWin, msgOther, msgLin = null) {
+ const platform = AppConstants.platform;
+ let messageID;
+
+ switch (platform) {
+ case "win":
+ messageID = msgWin;
+ break;
+ case "macosx":
+ messageID = msgMac;
+ break;
+ case "linux":
+ messageID = msgLin ?? msgOther;
+ break;
+ default:
+ messageID = msgOther;
+ }
+ return lazy.l10n.formatValueSync(messageID);
+ },
+};
+
+ChromeUtils.defineLazyGetter(FormAutofillUtils, "stringBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://formautofill/locale/formautofill.properties"
+ );
+});
+
+ChromeUtils.defineLazyGetter(FormAutofillUtils, "brandBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "_reauthEnabledByUser",
+ "extensions.formautofill.reauth.enabled",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "ccHeuristicsMode",
+ "extensions.formautofill.creditCards.heuristics.mode",
+ 0
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "ccFathomConfidenceThreshold",
+ "extensions.formautofill.creditCards.heuristics.fathom.confidenceThreshold",
+ null,
+ null,
+ pref => parseFloat(pref)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "ccFathomHighConfidenceThreshold",
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ null,
+ null,
+ pref => parseFloat(pref)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "ccFathomTestConfidence",
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ null,
+ null,
+ pref => parseFloat(pref)
+);
+
+// This is only used in iOS
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "focusOnAutofill",
+ "extensions.formautofill.focusOnAutofill",
+ true
+);
diff --git a/toolkit/components/formautofill/shared/FormStateManager.sys.mjs b/toolkit/components/formautofill/shared/FormStateManager.sys.mjs
new file mode 100644
index 0000000000..064b4e5356
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormStateManager.sys.mjs
@@ -0,0 +1,157 @@
+/* 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/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs",
+ FormAutofillHandler:
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs",
+});
+
+export class FormStateManager {
+ constructor(onSubmit, onAutofillCallback) {
+ /**
+ * @type {WeakMap} mapping FormLike root HTML elements to FormAutofillHandler objects.
+ */
+ this._formsDetails = new WeakMap();
+ /**
+ * @type {object} The object where to store the active items, e.g. element,
+ * handler, section, and field detail.
+ */
+ this._activeItems = {};
+
+ this.onSubmit = onSubmit;
+
+ this.onAutofillCallback = onAutofillCallback;
+ }
+
+ /**
+ * Get the active input's information from cache which is created after page
+ * identified.
+ *
+ * @returns {object | null}
+ * Return the active input's information that cloned from content cache
+ * (or return null if the information is not found in the cache).
+ */
+ get activeFieldDetail() {
+ if (!this._activeItems.fieldDetail) {
+ let formDetails = this.activeFormDetails;
+ if (!formDetails) {
+ return null;
+ }
+ for (let detail of formDetails) {
+ let detailElement = detail.element;
+ if (detailElement && this.activeInput == detailElement) {
+ this._activeItems.fieldDetail = detail;
+ break;
+ }
+ }
+ }
+ return this._activeItems.fieldDetail;
+ }
+
+ /**
+ * Get the active form's information from cache which is created after page
+ * identified.
+ *
+ * @returns {Array<object> | null}
+ * Return target form's information from content cache
+ * (or return null if the information is not found in the cache).
+ *
+ */
+ get activeFormDetails() {
+ let formHandler = this.activeHandler;
+ return formHandler ? formHandler.fieldDetails : null;
+ }
+
+ get activeInput() {
+ return this._activeItems.elementWeakRef?.deref();
+ }
+
+ get activeHandler() {
+ const activeInput = this.activeInput;
+ if (!activeInput) {
+ return null;
+ }
+
+ // XXX: We are recomputing the activeHandler every time to avoid keeping a
+ // reference on the active element. This might be called quite frequently
+ // so if _getFormHandler/findRootForField become more costly, we should
+ // look into caching this result (eg by adding a weakmap).
+ let handler = this._getFormHandler(activeInput);
+ if (handler) {
+ handler.focusedInput = activeInput;
+ }
+ return handler;
+ }
+
+ get activeSection() {
+ let formHandler = this.activeHandler;
+ return formHandler ? formHandler.activeSection : null;
+ }
+
+ /**
+ * Get the form's handler from cache which is created after page identified.
+ *
+ * @param {HTMLInputElement} element Focused input which triggered profile searching
+ * @returns {Array<object> | null}
+ * Return target form's handler from content cache
+ * (or return null if the information is not found in the cache).
+ *
+ */
+ _getFormHandler(element) {
+ if (!element) {
+ return null;
+ }
+ let rootElement = lazy.FormLikeFactory.findRootForField(element);
+ return this._formsDetails.get(rootElement);
+ }
+
+ identifyAutofillFields(element) {
+ let formHandler = this._getFormHandler(element);
+ if (!formHandler) {
+ let formLike = lazy.FormLikeFactory.createFromField(element);
+ formHandler = new lazy.FormAutofillHandler(
+ formLike,
+ this.onSubmit,
+ this.onAutofillCallback
+ );
+ } else if (!formHandler.updateFormIfNeeded(element)) {
+ return formHandler.fieldDetails;
+ }
+ this._formsDetails.set(formHandler.form.rootElement, formHandler);
+ return formHandler.collectFormFields();
+ }
+
+ updateActiveInput(element) {
+ if (!element) {
+ this._activeItems = {};
+ return;
+ }
+ this._activeItems = {
+ elementWeakRef: new WeakRef(element),
+ fieldDetail: null,
+ };
+ }
+
+ getRecords(formElement, handler) {
+ handler = handler || this._formsDetails.get(formElement);
+ const records = handler?.createRecords();
+
+ if (
+ !handler ||
+ !records ||
+ !Object.values(records).some(typeRecords => typeRecords.length)
+ ) {
+ return null;
+ }
+ return records;
+ }
+
+ didDestroy() {
+ this._activeItems = null;
+ }
+}
+
+export default FormStateManager;
diff --git a/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs b/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs
new file mode 100644
index 0000000000..c4141628f8
--- /dev/null
+++ b/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs
@@ -0,0 +1,687 @@
+/* eslint-disable no-useless-concat */
+/* 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/. */
+
+// prettier-ignore
+export const HeuristicsRegExp = {
+ RULES: {
+ email: undefined,
+ tel: undefined,
+ organization: undefined,
+ "street-address": undefined,
+ "address-line1": undefined,
+ "address-line2": undefined,
+ "address-line3": undefined,
+ "address-level2": undefined,
+ "address-level1": undefined,
+ "postal-code": undefined,
+ country: undefined,
+ // Note: We place the `cc-name` field for Credit Card first, because
+ // it is more specific than the `name` field below and we want to check
+ // for it before we catch the more generic one.
+ "cc-name": undefined,
+ name: undefined,
+ "given-name": undefined,
+ "additional-name": undefined,
+ "family-name": undefined,
+ "cc-csc": undefined,
+ "cc-number": undefined,
+ "cc-exp-month": undefined,
+ "cc-exp-year": undefined,
+ "cc-exp": undefined,
+ "cc-type": undefined,
+ },
+
+ // regular expressions that only apply to label
+ LABEL_RULES: {
+ "address-line1": undefined,
+ "address-line2": undefined,
+ },
+
+ RULE_SETS: [
+ //=========================================================================
+ // Firefox-specific rules
+ {
+ "address-line1": "addrline1|address_1|addl1",
+ "address-line2": "addrline2|address_2|addl2",
+ "address-line3": "addrline3|address_3|addl3",
+ "address-level1": "land", // de-DE
+ "additional-name": "apellido.?materno|lastlastname",
+ "cc-name":
+ "accountholdername" +
+ "|titulaire", // fr-FR
+ "cc-number":
+ "(cc|kk)nr", // de-DE
+ "cc-exp":
+ "ważna.*do" + // pl-PL
+ "|data.*ważności" + // pl-PL
+ "|mm\\s*[\\-\\/]\\s*yy" + // en-US
+ "|mm\\s*[\\-\\/]\\s*aa" + // es-ES
+ "|mm\\s*[\\-\\/]\\s*jj" + // de-AT
+ "|vervaldatum", // nl-NL
+ "cc-exp-month":
+ "month" +
+ "|(cc|kk)month" + // de-DE
+ "|miesiąc" + // pl-PL
+ "|mes" + // es-ES
+ "|maand", // nl-NL
+ "cc-exp-year":
+ "year" +
+ "|(cc|kk)year" + // de-DE
+ "|rok" + // pl-PL
+ "|(anno|año)" + // es-ES
+ "|jaar", // nl-NL
+ "cc-type":
+ "type" +
+ "|kartenmarke" + // de-DE
+ "|typ.*karty", // pl-PL
+ "cc-csc":
+ "(\\bcvn\\b|\\bcvv\\b|\\bcvc\\b|\\bcsc\\b|\\bcvd\\b|\\bcid\\b|\\bccv\\b)",
+ },
+
+ //=========================================================================
+ // These are the rules used by Bitwarden [0], converted into RegExp form.
+ // [0] https://github.com/bitwarden/browser/blob/c2b8802201fac5e292d55d5caf3f1f78088d823c/src/services/autofill.service.ts#L436
+ {
+ email: "(^e-?mail$)|(^email-?address$)",
+
+ tel:
+ "(^phone$)" +
+ "|(^mobile$)" +
+ "|(^mobile-?phone$)" +
+ "|(^tel$)" +
+ "|(^telephone$)" +
+ "|(^phone-?number$)",
+
+ organization:
+ "(^company$)" +
+ "|(^company-?name$)" +
+ "|(^organization$)" +
+ "|(^organization-?name$)",
+
+ "street-address":
+ "(^address$)" +
+ "|(^street-?address$)" +
+ "|(^addr$)" +
+ "|(^street$)" +
+ "|(^mailing-?addr(ess)?$)" + // Modified to not grab lines, below
+ "|(^billing-?addr(ess)?$)" + // Modified to not grab lines, below
+ "|(^mail-?addr(ess)?$)" + // Modified to not grab lines, below
+ "|(^bill-?addr(ess)?$)", // Modified to not grab lines, below
+
+ "address-line1":
+ "(^address-?1$)" +
+ "|(^address-?line-?1$)" +
+ "|(^addr-?1$)" +
+ "|(^street-?1$)",
+
+ "address-line2":
+ "(^address-?2$)" +
+ "|(^address-?line-?2$)" +
+ "|(^addr-?2$)" +
+ "|(^street-?2$)",
+
+ "address-line3":
+ "(^address-?3$)" +
+ "|(^address-?line-?3$)" +
+ "|(^addr-?3$)" +
+ "|(^street-?3$)",
+
+ "address-level2":
+ "(^city$)" +
+ "|(^town$)" +
+ "|(^address-?level-?2$)" +
+ "|(^address-?city$)" +
+ "|(^address-?town$)",
+
+ "address-level1":
+ "(^state$)" +
+ "|(^province$)" +
+ "|(^provence$)" +
+ "|(^address-?level-?1$)" +
+ "|(^address-?state$)" +
+ "|(^address-?province$)",
+
+ "postal-code":
+ "(^postal$)" +
+ "|(^zip$)" +
+ "|(^zip2$)" +
+ "|(^zip-?code$)" +
+ "|(^postal-?code$)" +
+ "|(^post-?code$)" +
+ "|(^address-?zip$)" +
+ "|(^address-?postal$)" +
+ "|(^address-?code$)" +
+ "|(^address-?postal-?code$)" +
+ "|(^address-?zip-?code$)",
+
+ country:
+ "(^country$)" +
+ "|(^country-?code$)" +
+ "|(^country-?name$)" +
+ "|(^address-?country$)" +
+ "|(^address-?country-?name$)" +
+ "|(^address-?country-?code$)",
+
+ name: "(^name$)|full-?name|your-?name",
+
+ "given-name":
+ "(^f-?name$)" +
+ "|(^first-?name$)" +
+ "|(^given-?name$)" +
+ "|(^first-?n$)",
+
+ "additional-name":
+ "(^m-?name$)" +
+ "|(^middle-?name$)" +
+ "|(^additional-?name$)" +
+ "|(^middle-?initial$)" +
+ "|(^middle-?n$)" +
+ "|(^middle-?i$)",
+
+ "family-name":
+ "(^l-?name$)" +
+ "|(^last-?name$)" +
+ "|(^s-?name$)" +
+ "|(^surname$)" +
+ "|(^family-?name$)" +
+ "|(^family-?n$)" +
+ "|(^last-?n$)",
+
+ "cc-name":
+ "cc-?name" +
+ "|card-?name" +
+ "|cardholder-?name" +
+ "|cardholder" +
+ // "|(^name$)" + // Removed to avoid overwriting "name", above.
+ "|(^nom$)",
+
+ "cc-number":
+ "cc-?number" +
+ "|cc-?num" +
+ "|card-?number" +
+ "|card-?num" +
+ "|(^number$)" +
+ "|(^cc$)" +
+ "|cc-?no" +
+ "|card-?no" +
+ "|(^credit-?card$)" +
+ "|numero-?carte" +
+ "|(^carte$)" +
+ "|(^carte-?credit$)" +
+ "|num-?carte" +
+ "|cb-?num",
+
+ "cc-exp":
+ "(^cc-?exp$)" +
+ "|(^card-?exp$)" +
+ "|(^cc-?expiration$)" +
+ "|(^card-?expiration$)" +
+ "|(^cc-?ex$)" +
+ "|(^card-?ex$)" +
+ "|(^card-?expire$)" +
+ "|(^card-?expiry$)" +
+ "|(^validite$)" +
+ "|(^expiration$)" +
+ "|(^expiry$)" +
+ "|mm-?yy" +
+ "|mm-?yyyy" +
+ "|yy-?mm" +
+ "|yyyy-?mm" +
+ "|expiration-?date" +
+ "|payment-?card-?expiration" +
+ "|(^payment-?cc-?date$)",
+
+ "cc-exp-month":
+ "(^exp-?month$)" +
+ "|(^cc-?exp-?month$)" +
+ "|(^cc-?month$)" +
+ "|(^card-?month$)" +
+ "|(^cc-?mo$)" +
+ "|(^card-?mo$)" +
+ "|(^exp-?mo$)" +
+ "|(^card-?exp-?mo$)" +
+ "|(^cc-?exp-?mo$)" +
+ "|(^card-?expiration-?month$)" +
+ "|(^expiration-?month$)" +
+ "|(^cc-?mm$)" +
+ "|(^cc-?m$)" +
+ "|(^card-?mm$)" +
+ "|(^card-?m$)" +
+ "|(^card-?exp-?mm$)" +
+ "|(^cc-?exp-?mm$)" +
+ "|(^exp-?mm$)" +
+ "|(^exp-?m$)" +
+ "|(^expire-?month$)" +
+ "|(^expire-?mo$)" +
+ "|(^expiry-?month$)" +
+ "|(^expiry-?mo$)" +
+ "|(^card-?expire-?month$)" +
+ "|(^card-?expire-?mo$)" +
+ "|(^card-?expiry-?month$)" +
+ "|(^card-?expiry-?mo$)" +
+ "|(^mois-?validite$)" +
+ "|(^mois-?expiration$)" +
+ "|(^m-?validite$)" +
+ "|(^m-?expiration$)" +
+ "|(^expiry-?date-?field-?month$)" +
+ "|(^expiration-?date-?month$)" +
+ "|(^expiration-?date-?mm$)" +
+ "|(^exp-?mon$)" +
+ "|(^validity-?mo$)" +
+ "|(^exp-?date-?mo$)" +
+ "|(^cb-?date-?mois$)" +
+ "|(^date-?m$)",
+
+ "cc-exp-year":
+ "(^exp-?year$)" +
+ "|(^cc-?exp-?year$)" +
+ "|(^cc-?year$)" +
+ "|(^card-?year$)" +
+ "|(^cc-?yr$)" +
+ "|(^card-?yr$)" +
+ "|(^exp-?yr$)" +
+ "|(^card-?exp-?yr$)" +
+ "|(^cc-?exp-?yr$)" +
+ "|(^card-?expiration-?year$)" +
+ "|(^expiration-?year$)" +
+ "|(^cc-?yy$)" +
+ "|(^cc-?y$)" +
+ "|(^card-?yy$)" +
+ "|(^card-?y$)" +
+ "|(^card-?exp-?yy$)" +
+ "|(^cc-?exp-?yy$)" +
+ "|(^exp-?yy$)" +
+ "|(^exp-?y$)" +
+ "|(^cc-?yyyy$)" +
+ "|(^card-?yyyy$)" +
+ "|(^card-?exp-?yyyy$)" +
+ "|(^cc-?exp-?yyyy$)" +
+ "|(^expire-?year$)" +
+ "|(^expire-?yr$)" +
+ "|(^expiry-?year$)" +
+ "|(^expiry-?yr$)" +
+ "|(^card-?expire-?year$)" +
+ "|(^card-?expire-?yr$)" +
+ "|(^card-?expiry-?year$)" +
+ "|(^card-?expiry-?yr$)" +
+ "|(^an-?validite$)" +
+ "|(^an-?expiration$)" +
+ "|(^annee-?validite$)" +
+ "|(^annee-?expiration$)" +
+ "|(^expiry-?date-?field-?year$)" +
+ "|(^expiration-?date-?year$)" +
+ "|(^cb-?date-?ann$)" +
+ "|(^expiration-?date-?yy$)" +
+ "|(^expiration-?date-?yyyy$)" +
+ "|(^validity-?year$)" +
+ "|(^exp-?date-?year$)" +
+ "|(^date-?y$)",
+
+ "cc-type":
+ "(^cc-?type$)" +
+ "|(^card-?type$)" +
+ "|(^card-?brand$)" +
+ "|(^cc-?brand$)" +
+ "|(^cb-?type$)",
+ },
+
+ //=========================================================================
+ // These rules are from Chromium source codes [1]. Most of them
+ // converted to JS format have the same meaning with the original ones except
+ // the first line of "address-level1".
+ // [1] https://source.chromium.org/chromium/chromium/src/+/master:components/autofill/core/common/autofill_regex_constants.cc
+ {
+ // ==== Email ====
+ email:
+ "e.?mail" +
+ "|courriel" + // fr
+ "|correo.*electr(o|ó)nico" + // es-ES
+ "|メールアドレス" + // ja-JP
+ "|Электронной.?Почты" + // ru
+ "|邮件|邮箱" + // zh-CN
+ "|電郵地址" + // zh-TW
+ "|ഇ-മെയില്‍|ഇലക്ട്രോണിക്.?" +
+ "മെയിൽ" + // ml
+ "|ایمیل|پست.*الکترونیک" + // fa
+ "|ईमेल|इलॅक्ट्रॉनिक.?मेल" + // hi
+ "|(\\b|_)eposta(\\b|_)" + // tr
+ "|(?:이메일|전자.?우편|[Ee]-?mail)(.?주소)?", // ko-KR
+
+ // ==== Telephone ====
+ tel:
+ "phone|mobile|contact.?number" +
+ "|telefonnummer" + // de-DE
+ "|telefono|teléfono" + // es
+ "|telfixe" + // fr-FR
+ "|電話" + // ja-JP
+ "|telefone|telemovel" + // pt-BR, pt-PT
+ "|телефон" + // ru
+ "|मोबाइल" + // hi for mobile
+ "|(\\b|_|\\*)telefon(\\b|_|\\*)" + // tr
+ "|电话" + // zh-CN
+ "|മൊബൈല്‍" + // ml for mobile
+ "|(?:전화|핸드폰|휴대폰|휴대전화)(?:.?번호)?", // ko-KR
+
+ // ==== Address Fields ====
+ organization:
+ "company|business|organization|organisation" +
+ // In order to support webkit we convert all negative lookbehinds to a capture group
+ // (?<!not)word -> (?<neg>notword)|word
+ // TODO: Bug 1829583
+ "|(?<neg>confirma)" +
+ "|firma|firmenname" + // de-DE
+ "|empresa" + // es
+ "|societe|société" + // fr-FR
+ "|ragione.?sociale" + // it-IT
+ "|会社" + // ja-JP
+ "|название.?компании" + // ru
+ "|单位|公司" + // zh-CN
+ "|شرکت" + // fa
+ "|회사|직장", // ko-KR
+
+ "street-address": "streetaddress|street-address",
+ "address-line1":
+ "^address$|address[_-]?line(one)?|address1|addr1|street" +
+ "|(?:shipping|billing)address$" +
+ "|strasse|straße|hausnummer|housenumber" + // de-DE
+ "|house.?name" + // en-GB
+ "|direccion|dirección" + // es
+ "|adresse" + // fr-FR
+ "|indirizzo" + // it-IT
+ "|^住所$|住所1" + // ja-JP
+ "|morada" + // pt-BR, pt-PT
+ // In order to support webkit we convert all negative lookbehinds to a capture group
+ // (?<!not)word -> (?<neg>notword)|word
+ // TODO: Bug 1829583
+ "|(?<neg>identificação do endereço)" +
+ "|(endereço)" + // pt-BR, pt-PT
+ "|Адрес" + // ru
+ "|地址" + // zh-CN
+ "|(\\b|_)adres(?! (başlığı(nız)?|tarifi))(\\b|_)" + // tr
+ "|^주소.?$|주소.?1", // ko-KR
+
+ "address-line2":
+ "address[_-]?line(2|two)|address2|addr2|street|suite|unit(?!e)" + // Firefox adds `(?!e)` to unit to skip `United State`
+ "|adresszusatz|ergänzende.?angaben" + // de-DE
+ "|direccion2|colonia|adicional" + // es
+ "|addresssuppl|complementnom|appartement" + // fr-FR
+ "|indirizzo2" + // it-IT
+ "|住所2" + // ja-JP
+ "|complemento|addrcomplement" + // pt-BR, pt-PT
+ "|Улица" + // ru
+ "|地址2" + // zh-CN
+ "|주소.?2", // ko-KR
+
+ "address-line3":
+ "address[_-]?line(3|three)|address3|addr3|street|suite|unit(?!e)" + // Firefox adds `(?!e)` to unit to skip `United State`
+ "|adresszusatz|ergänzende.?angaben" + // de-DE
+ "|direccion3|colonia|adicional" + // es
+ "|addresssuppl|complementnom|appartement" + // fr-FR
+ "|indirizzo3" + // it-IT
+ "|住所3" + // ja-JP
+ "|complemento|addrcomplement" + // pt-BR, pt-PT
+ "|Улица" + // ru
+ "|地址3" + // zh-CN
+ "|주소.?3", // ko-KR
+
+ "address-level2":
+ "city|town" +
+ "|\\bort\\b|stadt" + // de-DE
+ "|suburb" + // en-AU
+ "|ciudad|provincia|localidad|poblacion" + // es
+ "|ville|commune" + // fr-FR
+ "|localita" + // it-IT
+ "|市区町村" + // ja-JP
+ "|cidade" + // pt-BR, pt-PT
+ "|Город" + // ru
+ "|市" + // zh-CN
+ "|分區" + // zh-TW
+ "|شهر" + // fa
+ "|शहर" + // hi for city
+ "|ग्राम|गाँव" + // hi for village
+ "|നഗരം|ഗ്രാമം" + // ml for town|village
+ "|((\\b|_|\\*)([İii̇]l[cç]e(miz|niz)?)(\\b|_|\\*))" + // tr
+ "|^시[^도·・]|시[·・]?군[·・]?구", // ko-KR
+
+ "address-level1":
+ // In order to support webkit we convert all negative lookbehinds to a capture group
+ // (?<!not)word -> (?<neg>notword)|word
+ // TODO: Bug 1829583
+ "(?<neg>united?.state|hist?.state|history?.state)" +
+ "|state|county|region|province" +
+ "|principality" + // en-UK
+ "|都道府県" + // ja-JP
+ "|estado|provincia" + // pt-BR, pt-PT
+ "|область" + // ru
+ "|省" + // zh-CN
+ "|地區" + // zh-TW
+ "|സംസ്ഥാനം" + // ml
+ "|استان" + // fa
+ "|राज्य" + // hi
+ "|((\\b|_|\\*)(eyalet|[şs]ehir|[İii̇]l(imiz)?|kent)(\\b|_|\\*))" + // tr
+ "|^시[·・]?도", // ko-KR
+
+ "postal-code":
+ "zip|postal|post.*code|pcode" +
+ "|pin.?code" + // en-IN
+ "|postleitzahl" + // de-DE
+ "|\\bcp\\b" + // es
+ "|\\bcdp\\b" + // fr-FR
+ "|\\bcap\\b" + // it-IT
+ "|郵便番号" + // ja-JP
+ "|codigo|codpos|\\bcep\\b" + // pt-BR, pt-PT
+ "|Почтовый.?Индекс" + // ru
+ "|पिन.?कोड" + // hi
+ "|പിന്‍കോഡ്" + // ml
+ "|邮政编码|邮编" + // zh-CN
+ "|郵遞區號" + // zh-TW
+ "|(\\b|_)posta kodu(\\b|_)" + // tr
+ "|우편.?번호", // ko-KR
+
+ country:
+ "country|countries" +
+ "|país|pais" + // es
+ "|(\\b|_)land(\\b|_)(?!.*(mark.*))" + // de-DE landmark is a type in india.
+ // In order to support webkit we convert all negative lookbehinds to a capture group
+ // (?<!not)word -> (?<neg>notword)|word
+ // TODO: Bug 1829583
+ "|(?<neg>入国|出国)" +
+ "|国" + // ja-JP
+ "|国家" + // zh-CN
+ "|국가|나라" + // ko-KR
+ "|(\\b|_)(ülke|ulce|ulke)(\\b|_)" + // tr
+ "|کشور", // fa
+
+ // ==== Name Fields ====
+ "cc-name":
+ "card.?(?:holder|owner)|name.*(\\b)?on(\\b)?.*card" +
+ "|^(credit[-\\s]?card|card).*name|cc.?full.?name" +
+ "|karteninhaber" + // de-DE
+ "|nombre.*tarjeta" + // es
+ "|nom.*carte" + // fr-FR
+ "|nome.*cart" + // it-IT
+ "|名前" + // ja-JP
+ "|Имя.*карты" + // ru
+ "|信用卡开户名|开户名|持卡人姓名" + // zh-CN
+ "|持卡人姓名", // zh-TW
+
+ name:
+ "^name|full.?name|your.?name|customer.?name|bill.?name|ship.?name" +
+ "|name.*first.*last|firstandlastname" +
+ "|nombre.*y.*apellidos" + // es
+ "|^nom(?!bre)" + // fr-FR
+ "|お名前|氏名" + // ja-JP
+ "|^nome" + // pt-BR, pt-PT
+ "|نام.*نام.*خانوادگی" + // fa
+ "|姓名" + // zh-CN
+ "|(\\b|_|\\*)ad[ı]? soyad[ı]?(\\b|_|\\*)" + // tr
+ "|성명", // ko-KR
+
+ "given-name":
+ "first.*name|initials|fname|first$|given.*name" +
+ "|vorname" + // de-DE
+ "|nombre" + // es
+ "|forename|prénom|prenom" + // fr-FR
+ "|名" + // ja-JP
+ "|nome" + // pt-BR, pt-PT
+ "|Имя" + // ru
+ "|نام" + // fa
+ "|이름" + // ko-KR
+ "|പേര്" + // ml
+ "|(\\b|_|\\*)(isim|ad|ad(i|ı|iniz|ınız)?)(\\b|_|\\*)" + // tr
+ "|नाम", // hi
+
+ "additional-name":
+ "middle.*name|mname|middle$|middle.*initial|m\\.i\\.|mi$|\\bmi\\b",
+
+ "family-name":
+ "last.*name|lname|surname|last$|secondname|family.*name" +
+ "|nachname" + // de-DE
+ "|apellidos?" + // es
+ "|famille|^nom(?!bre)" + // fr-FR
+ "|cognome" + // it-IT
+ "|姓" + // ja-JP
+ "|apelidos|surename|sobrenome" + // pt-BR, pt-PT
+ "|Фамилия" + // ru
+ "|نام.*خانوادگی" + // fa
+ "|उपनाम" + // hi
+ "|മറുപേര്" + // ml
+ "|(\\b|_|\\*)(soyisim|soyad(i|ı|iniz|ınız)?)(\\b|_|\\*)" + // tr
+ "|\\b성(?:[^명]|\\b)", // ko-KR
+
+ // ==== Credit Card Fields ====
+ // Note: `cc-name` expression has been moved up, above `name`, in
+ // order to handle specialization through ordering.
+ "cc-number":
+ "(add)?(?:card|cc|acct).?(?:number|#|no|num|field)" +
+ // In order to support webkit we convert all negative lookbehinds to a capture group
+ // (?<!not)word -> (?<neg>notword)|word
+ // TODO: Bug 1829583
+ "|(?<neg>telefonnummer|hausnummer|personnummer|fødselsnummer)" + // de-DE, sv-SE, no
+ "|nummer" +
+ "|カード番号" + // ja-JP
+ "|Номер.*карты" + // ru
+ "|信用卡号|信用卡号码" + // zh-CN
+ "|信用卡卡號" + // zh-TW
+ "|카드" + // ko-KR
+ // es/pt/fr
+ "|(numero|número|numéro)(?!.*(document|fono|phone|réservation))",
+
+ "cc-exp-month":
+ "expir|exp.*mo|exp.*date|ccmonth|cardmonth|addmonth" +
+ "|gueltig|gültig|monat" + // de-DE
+ "|fecha" + // es
+ "|date.*exp" + // fr-FR
+ "|scadenza" + // it-IT
+ "|有効期限" + // ja-JP
+ "|validade" + // pt-BR, pt-PT
+ "|Срок действия карты" + // ru
+ "|月", // zh-CN
+
+ "cc-exp-year":
+ "exp|^/|(add)?year" +
+ "|ablaufdatum|gueltig|gültig|jahr" + // de-DE
+ "|fecha" + // es
+ "|scadenza" + // it-IT
+ "|有効期限" + // ja-JP
+ "|validade" + // pt-BR, pt-PT
+ "|Срок действия карты" + // ru
+ "|年|有效期", // zh-CN
+
+ "cc-exp":
+ "expir|exp.*date|^expfield$" +
+ "|gueltig|gültig" + // de-DE
+ "|fecha" + // es
+ "|date.*exp" + // fr-FR
+ "|scadenza" + // it-IT
+ "|有効期限" + // ja-JP
+ "|validade" + // pt-BR, pt-PT
+ "|Срок действия карты", // ru
+
+ "cc-csc":
+ "verification|card.?identification|security.?code|card.?code" +
+ "|security.?value" +
+ "|security.?number|card.?pin|c-v-v" +
+ // We omit this regexp in favor of being less generic.
+ // See "Firefox-specific" rules for cc-csc
+ // "|(cvn|cvv|cvc|csc|cvd|cid|ccv)(field)?" +
+ "|\\bcid\\b",
+ },
+ ],
+
+ LABEL_RULE_SETS: [
+ {
+ "address-line1":
+ "(^\\W*address)" +
+ "|(address\\W*$)" +
+ "|(?:shipping|billing|mailing|pick.?up|drop.?off|delivery|sender|postal|" +
+ "recipient|home|work|office|school|business|mail)[\\s\\-]+address" +
+ "|address\\s+(of|for|to|from)" +
+ "|adresse" + // fr-FR
+ "|indirizzo" + // it-IT
+ "|住所" + // ja-JP
+ "|地址" + // zh-CN
+ "|(\\b|_)adres(?! tarifi)(\\b|_)" + // tr
+ "|주소" + // ko-KR
+ "|^alamat" + // id
+ // Should contain street and any other address component, in any order
+ "|street.*(house|building|apartment|floor)" + // en
+ "|(house|building|apartment|floor).*street" +
+ "|(sokak|cadde).*(apartman|bina|daire|mahalle)" + // tr
+ "|(apartman|bina|daire|mahalle).*(sokak|cadde)" +
+ "|улиц.*(дом|корпус|квартир|этаж)|(дом|корпус|квартир|этаж).*улиц", // ru
+ },
+ {
+ "address-line2":
+ "address|line" +
+ "|adresse" + // fr-FR
+ "|indirizzo" + // it-IT
+ "|地址" + // zh-CN
+ "|주소", // ko-KR
+ },
+ ],
+
+ _getRules(rules, rulesets) {
+ function computeRule(name) {
+ let regexps = [];
+ rulesets.forEach(set => {
+ if (set[name]) {
+ // Add the rule.
+ // We make the regex lower case so that we can match it against the
+ // lower-cased field name and get a rough equivalent of a case-insensitive
+ // match. This avoids a performance cliff with the "iu" flag on regular
+ // expressions.
+ regexps.push(`(${set[name].toLowerCase()})`.normalize("NFKC"));
+ }
+ });
+
+ const value = new RegExp(regexps.join("|"), "gu");
+
+ Object.defineProperty(rules, name, { get: undefined });
+ Object.defineProperty(rules, name, { value });
+ return value;
+ }
+
+ Object.keys(rules).forEach(field =>
+ Object.defineProperty(rules, field, {
+ get() {
+ return computeRule(field);
+ },
+ })
+ );
+
+ return rules;
+ },
+
+ getLabelRules() {
+ return this._getRules(this.LABEL_RULES, this.LABEL_RULE_SETS);
+ },
+
+ getRules() {
+ return this._getRules(this.RULES, this.RULE_SETS);
+ },
+};
+
+export default HeuristicsRegExp;
diff --git a/toolkit/components/formautofill/shared/LabelUtils.sys.mjs b/toolkit/components/formautofill/shared/LabelUtils.sys.mjs
new file mode 100644
index 0000000000..9bfedee105
--- /dev/null
+++ b/toolkit/components/formautofill/shared/LabelUtils.sys.mjs
@@ -0,0 +1,120 @@
+/* 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 is a utility object to work with HTML labels in web pages,
+ * including finding label elements and label text extraction.
+ */
+export const LabelUtils = {
+ // The tag name list is from Chromium except for "STYLE":
+ // eslint-disable-next-line max-len
+ // https://cs.chromium.org/chromium/src/components/autofill/content/renderer/form_autofill_util.cc?l=216&rcl=d33a171b7c308a64dc3372fac3da2179c63b419e
+ EXCLUDED_TAGS: ["SCRIPT", "NOSCRIPT", "OPTION", "STYLE"],
+
+ // A map object, whose keys are the id's of form fields and each value is an
+ // array consisting of label elements correponding to the id.
+ // @type {Map<string, array>}
+ _mappedLabels: null,
+
+ // An array consisting of label elements whose correponding form field doesn't
+ // have an id attribute.
+ // @type {Array<[HTMLLabelElement, HTMLElement]>}
+ _unmappedLabelControls: null,
+
+ // A weak map consisting of label element and extracted strings pairs.
+ // @type {WeakMap<HTMLLabelElement, array>}
+ _labelStrings: null,
+
+ /**
+ * Extract all strings of an element's children to an array.
+ * "element.textContent" is a string which is merged of all children nodes,
+ * and this function provides an array of the strings contains in an element.
+ *
+ * @param {object} element
+ * A DOM element to be extracted.
+ * @returns {Array}
+ * All strings in an element.
+ */
+ extractLabelStrings(element) {
+ if (this._labelStrings.has(element)) {
+ return this._labelStrings.get(element);
+ }
+ let strings = [];
+ let _extractLabelStrings = el => {
+ if (this.EXCLUDED_TAGS.includes(el.tagName)) {
+ return;
+ }
+
+ if (el.nodeType == el.TEXT_NODE || !el.childNodes.length) {
+ let trimmedText = el.textContent.trim();
+ if (trimmedText) {
+ strings.push(trimmedText);
+ }
+ return;
+ }
+
+ for (let node of el.childNodes) {
+ let nodeType = node.nodeType;
+ if (nodeType != node.ELEMENT_NODE && nodeType != node.TEXT_NODE) {
+ continue;
+ }
+ _extractLabelStrings(node);
+ }
+ };
+ _extractLabelStrings(element);
+ this._labelStrings.set(element, strings);
+ return strings;
+ },
+
+ generateLabelMap(doc) {
+ this._mappedLabels = new Map();
+ this._unmappedLabelControls = [];
+ this._labelStrings = new WeakMap();
+
+ for (let label of doc.querySelectorAll("label")) {
+ let id = label.htmlFor;
+ let control;
+ if (!id) {
+ control = label.control;
+ if (!control) {
+ continue;
+ }
+ id = control.id;
+ }
+ if (id) {
+ let labels = this._mappedLabels.get(id);
+ if (labels) {
+ labels.push(label);
+ } else {
+ this._mappedLabels.set(id, [label]);
+ }
+ } else {
+ // control must be non-empty here
+ this._unmappedLabelControls.push({ label, control });
+ }
+ }
+ },
+
+ clearLabelMap() {
+ this._mappedLabels = null;
+ this._unmappedLabelControls = null;
+ this._labelStrings = null;
+ },
+
+ findLabelElements(element) {
+ if (!this._mappedLabels) {
+ this.generateLabelMap(element.ownerDocument);
+ }
+
+ let id = element.id;
+ if (!id) {
+ return this._unmappedLabelControls
+ .filter(lc => lc.control == element)
+ .map(lc => lc.label);
+ }
+ return this._mappedLabels.get(id) || [];
+ },
+};
+
+export default LabelUtils;