diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/formautofill | |
parent | Initial commit. (diff) | |
download | firefox-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')
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; |