summaryrefslogtreecommitdiffstats
path: root/toolkit/components/formautofill/shared
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/formautofill/shared')
-rw-r--r--toolkit/components/formautofill/shared/AddressComponent.sys.mjs1090
-rw-r--r--toolkit/components/formautofill/shared/AddressParser.sys.mjs281
-rw-r--r--toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs1212
-rw-r--r--toolkit/components/formautofill/shared/FieldScanner.sys.mjs211
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs400
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs1168
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs406
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs1353
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs1253
-rw-r--r--toolkit/components/formautofill/shared/FormStateManager.sys.mjs154
-rw-r--r--toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs620
-rw-r--r--toolkit/components/formautofill/shared/LabelUtils.sys.mjs120
12 files changed, 8268 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/shared/AddressComponent.sys.mjs b/toolkit/components/formautofill/shared/AddressComponent.sys.mjs
new file mode 100644
index 0000000000..95779837b8
--- /dev/null
+++ b/toolkit/components/formautofill/shared/AddressComponent.sys.mjs
@@ -0,0 +1,1090 @@
+/* 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 {
+ #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) {
+ 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()
+ );
+ }
+
+ 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)
+ );
+ }
+}
+
+/**
+ * A postal code / zip code
+ * See autocomplete="postal-code"
+ */
+class PostalCode extends AddressField {
+ 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)
+ );
+ }
+}
+
+/**
+ * City name.
+ * See autocomplete="address-level1"
+ */
+class City extends AddressField {
+ #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)
+ );
+ }
+}
+
+/**
+ * State.
+ * See autocomplete="address-level2"
+ */
+class State extends AddressField {
+ // 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);
+ }
+}
+
+/**
+ * A country or territory code.
+ * See autocomplete="country"
+ */
+class Country extends AddressField {
+ // 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;
+ }
+}
+
+/**
+ * The field expects the value to be a person's full name.
+ * See autocomplete="name"
+ */
+class Name extends AddressField {
+ 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;
+ }
+}
+
+/**
+ * A full telephone number, including the country code.
+ * See autocomplete="tel"
+ */
+class Tel extends AddressField {
+ #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`;
+ }
+}
+
+/**
+ * A company or organization name.
+ * See autocomplete="organization".
+ */
+class Organization extends AddressField {
+ 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));
+ }
+}
+
+/**
+ * An email address
+ * See autocomplete="email".
+ */
+class Email extends AddressField {
+ 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;
+ }
+}
+
+/**
+ * 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.name;
+ 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.name;
+ 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 {string} defaultRegion The default region to use if the record's
+ * country is not specified.
+ * @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,
+ defaultRegion = FormAutofill.DEFAULT_REGION,
+ { ignoreInvalid = false } = {}
+ ) {
+ const fieldValue = this.#recordToFieldValue(record);
+
+ // Get country code first so we can use it to parse other fields
+ const country = new Country(fieldValue.country, defaultRegion);
+ this.#fields[Country.name] = country;
+ const region = country.isEmpty() ? defaultRegion : country.country_code;
+
+ this.#fields[State.name] = new State(fieldValue.state, region);
+ this.#fields[City.name] = new City(fieldValue.city, region);
+ this.#fields[PostalCode.name] = new PostalCode(
+ fieldValue.postal_code,
+ region
+ );
+ this.#fields[Tel.name] = new Tel(fieldValue.tel, region);
+ this.#fields[StreetAddress.name] = new StreetAddress(
+ fieldValue.street_address,
+ region
+ );
+ this.#fields[Name.name] = new Name(fieldValue.name, region);
+ this.#fields[Organization.name] = new Organization(
+ fieldValue.organization,
+ region
+ );
+ this.#fields[Email.name] = new Email(fieldValue.email, region);
+
+ if (ignoreInvalid) {
+ // TODO: We have to reset it or ignore non-existing fields while comparing
+ this.#fields.filter(f => f.IsValid());
+ }
+ }
+
+ /**
+ * Converts address record to a field value object.
+ *
+ * @param {object} record The record object containing address data.
+ * @returns {object} A value object with keys corresponding to specific
+ * address fields and their respective values.
+ */
+ #recordToFieldValue(record) {
+ let value = {};
+
+ if (record.name) {
+ value.name = record.name;
+ } else {
+ value.name = lazy.FormAutofillNameUtils.joinNameParts({
+ given: record["given-name"],
+ middle: record["additional-name"],
+ family: record["family-name"],
+ });
+ }
+
+ value.email = record.email ?? "";
+ value.organization = record.organization ?? "";
+ value.street_address = record["street-address"] ?? "";
+ value.state = record["address-level1"] ?? "";
+ value.city = record["address-level2"] ?? "";
+ value.country = record.country ?? "";
+ value.postal_code = record["postal-code"] ?? "";
+ value.tel = record.tel ?? "";
+
+ return value;
+ }
+
+ /**
+ * 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/AddressParser.sys.mjs b/toolkit/components/formautofill/shared/AddressParser.sys.mjs
new file mode 100644
index 0000000000..8fe0dc7f80
--- /dev/null
+++ b/toolkit/components/formautofill/shared/AddressParser.sys.mjs
@@ -0,0 +1,281 @@
+/* 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) {
+ 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/CreditCardRuleset.sys.mjs b/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs
new file mode 100644
index 0000000000..ed72d26018
--- /dev/null
+++ b/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs
@@ -0,0 +1,1212 @@
+/* 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" +
+ // 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" +
+ // 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" +
+ "|nombre.*tarjeta" + // es
+ "|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" +
+ // 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..ba64d046ea
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FieldScanner.sys.mjs
@@ -0,0 +1,211 @@
+/* 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;
+
+ // 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 = "";
+
+ // 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,
+ { autocompleteInfo = {}, confidence = null }
+ ) {
+ this.elementWeakRef = Cu.getWeakReference(element);
+ this.fieldName = fieldName;
+
+ if (autocompleteInfo) {
+ this.reason = "autocomplete";
+ this.section = autocompleteInfo.section;
+ this.addressType = autocompleteInfo.addressType;
+ this.contactType = autocompleteInfo.contactType;
+ } else if (confidence) {
+ this.reason = "fathom";
+ this.confidence = confidence;
+ } else {
+ this.reason = "regex-heuristic";
+ }
+ }
+
+ get element() {
+ return this.elementWeakRef.get();
+ }
+
+ 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 = Cu.getWeakReference(elements);
+ this.#inferFieldInfoFn = inferFieldInfoFn;
+ }
+
+ get #elements() {
+ return this.#elementsWeakRef.get();
+ }
+
+ /**
+ * 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) {
+ throw new Error(
+ `The index ${index} is out of range.(${this.#elements.length})`
+ );
+ }
+
+ 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 fieldName
+ * @param {string} reason
+ * What approach we use to identify this field
+ */
+ updateFieldName(index, fieldName, reason = null) {
+ if (index >= this.fieldDetails.length) {
+ throw new Error("Try to update the non-existing field detail.");
+ }
+ this.fieldDetails[index].fieldName = fieldName;
+ if (reason) {
+ this.fieldDetails[index].reason = reason;
+ }
+ }
+
+ 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..b84064b716
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillHandler.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 { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.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
+ * three arguments: (1) a FormLike for the form being
+ * submitted, (2) the corresponding Window, and (3) 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 = () => {
+ onFormSubmitted(this.form, this.window, this);
+ };
+
+ this.onAutofillCallback = onAutofillCallback;
+
+ XPCOMUtils.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.elementWeakRef.get()
+ );
+ 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) {
+ let autofillableSection;
+ if (section.type == lazy.FormSection.ADDRESS) {
+ autofillableSection = new lazy.FormAutofillAddressSection(
+ section,
+ this
+ );
+ } else {
+ autofillableSection = new lazy.FormAutofillCreditCardSection(
+ section,
+ this
+ );
+ }
+
+ if (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.elementWeakRef.get();
+ 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..f73af3a8f3
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs
@@ -0,0 +1,1168 @@
+/* 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 { 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",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () =>
+ FormAutofill.defineLogGetter(lazy, "FormAutofillHeuristics")
+);
+
+/**
+ * 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(),
+
+ 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} fieldScanner
+ * 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(fieldScanner) {
+ let matchingResult;
+
+ const GRAMMARS = this.PHONE_FIELD_GRAMMARS;
+ for (let i = 0; i < GRAMMARS.length; i++) {
+ let detailStart = fieldScanner.parsingIndex;
+ let ruleStart = i;
+ for (
+ ;
+ i < GRAMMARS.length &&
+ GRAMMARS[i][0] &&
+ fieldScanner.elementExisting(detailStart);
+ i++, detailStart++
+ ) {
+ let detail = fieldScanner.getFieldDetailByIndex(detailStart);
+ if (
+ !detail ||
+ GRAMMARS[i][0] != detail.fieldName ||
+ detail?.reason == "autocomplete"
+ ) {
+ break;
+ }
+ let element = detail.elementWeakRef.get();
+ if (!element) {
+ break;
+ }
+ if (
+ GRAMMARS[i][2] &&
+ (!element.maxLength || GRAMMARS[i][2] < element.maxLength)
+ ) {
+ break;
+ }
+ }
+ if (i >= GRAMMARS.length) {
+ break;
+ }
+
+ if (!GRAMMARS[i][0]) {
+ matchingResult = {
+ ruleFrom: ruleStart,
+ ruleTo: i,
+ };
+ break;
+ }
+
+ // Fast rewinding to the next rule.
+ for (; i < GRAMMARS.length; i++) {
+ if (!GRAMMARS[i][0]) {
+ break;
+ }
+ }
+ }
+
+ let parsedField = false;
+ if (matchingResult) {
+ let { ruleFrom, ruleTo } = matchingResult;
+ let detailStart = fieldScanner.parsingIndex;
+ for (let i = ruleFrom; i < ruleTo; i++) {
+ fieldScanner.updateFieldName(detailStart, GRAMMARS[i][1]);
+ fieldScanner.parsingIndex++;
+ detailStart++;
+ parsedField = true;
+ }
+ }
+
+ if (fieldScanner.parsingFinished) {
+ return parsedField;
+ }
+
+ let nextField = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex
+ );
+ if (
+ nextField &&
+ nextField.reason != "autocomplete" &&
+ fieldScanner.parsingIndex > 0
+ ) {
+ const regExpTelExtension = new RegExp(
+ "\\bext|ext\\b|extension|ramal", // pt-BR, pt-PT
+ "iu"
+ );
+ const previousField = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex - 1
+ );
+ const previousFieldType = lazy.FormAutofillUtils.getCategoryFromFieldName(
+ previousField.fieldName
+ );
+ if (
+ previousField &&
+ previousFieldType == "tel" &&
+ this._matchRegexp(nextField.elementWeakRef.get(), regExpTelExtension)
+ ) {
+ fieldScanner.updateFieldName(
+ fieldScanner.parsingIndex,
+ "tel-extension"
+ );
+ fieldScanner.parsingIndex++;
+ parsedField = true;
+ }
+ }
+
+ return parsedField;
+ },
+
+ /**
+ * Try to find the correct address-line[1-3] sequence and correct their field
+ * names.
+ *
+ * @param {FieldScanner} fieldScanner
+ * The current parsing status for all elements
+ * @returns {boolean}
+ * Return true if there is any field can be recognized in the parser,
+ * otherwise false.
+ */
+ _parseAddressFields(fieldScanner) {
+ if (fieldScanner.parsingFinished) {
+ return false;
+ }
+
+ // TODO: These address-line* regexps are for the lines with numbers, and
+ // they are the subset of the regexps in `heuristicsRegexp.js`. We have to
+ // find a better way to make them consistent.
+ const addressLines = ["address-line1", "address-line2", "address-line3"];
+ const addressLineRegexps = {
+ "address-line1": new RegExp(
+ "address[_-]?line(1|one)|address1|addr1" +
+ "|addrline1|address_1" + // Extra rules by Firefox
+ "|indirizzo1" + // it-IT
+ "|住所1" + // ja-JP
+ "|地址1" + // zh-CN
+ "|주소.?1", // ko-KR
+ "iu"
+ ),
+ "address-line2": new RegExp(
+ "address[_-]?line(2|two)|address2|addr2" +
+ "|addrline2|address_2" + // Extra rules by Firefox
+ "|indirizzo2" + // it-IT
+ "|住所2" + // ja-JP
+ "|地址2" + // zh-CN
+ "|주소.?2", // ko-KR
+ "iu"
+ ),
+ "address-line3": new RegExp(
+ "address[_-]?line(3|three)|address3|addr3" +
+ "|addrline3|address_3" + // Extra rules by Firefox
+ "|indirizzo3" + // it-IT
+ "|住所3" + // ja-JP
+ "|地址3" + // zh-CN
+ "|주소.?3", // ko-KR
+ "iu"
+ ),
+ };
+
+ let parsedFields = false;
+ const startIndex = fieldScanner.parsingIndex;
+ while (!fieldScanner.parsingFinished) {
+ let detail = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex
+ );
+ if (
+ !detail ||
+ !addressLines.includes(detail.fieldName) ||
+ detail.reason == "autocomplete"
+ ) {
+ // When the field is not related to any address-line[1-3] fields or
+ // determined by autocomplete attr, it means the parsing process can be
+ // terminated.
+ break;
+ }
+ parsedFields = false;
+ const elem = detail.elementWeakRef.get();
+ for (let regexp of Object.keys(addressLineRegexps)) {
+ if (this._matchRegexp(elem, addressLineRegexps[regexp])) {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, regexp);
+ parsedFields = true;
+ }
+ }
+ if (!parsedFields) {
+ break;
+ }
+ fieldScanner.parsingIndex++;
+ }
+
+ // If "address-line2" is found but the previous field is "street-address",
+ // then we assume what the website actually wants is "address-line1" instead
+ // of "street-address".
+ if (
+ startIndex > 0 &&
+ fieldScanner.getFieldDetailByIndex(startIndex)?.fieldName ==
+ "address-line2" &&
+ fieldScanner.getFieldDetailByIndex(startIndex - 1)?.fieldName ==
+ "street-address"
+ ) {
+ fieldScanner.updateFieldName(
+ startIndex - 1,
+ "address-line1",
+ "regexp-heuristic"
+ );
+ }
+
+ return parsedFields;
+ },
+
+ // The old heuristics can be removed when we fully adopt fathom, so disable the
+ // esline complexity check for now
+ /* eslint-disable complexity */
+ /**
+ * Try to look for expiration date fields and revise the field names if needed.
+ *
+ * @param {FieldScanner} fieldScanner
+ * The current parsing status for all elements
+ * @returns {boolean}
+ * Return true if there is any field can be recognized in the parser,
+ * otherwise false.
+ */
+ _parseCreditCardFields(fieldScanner) {
+ if (fieldScanner.parsingFinished) {
+ return false;
+ }
+
+ const savedIndex = fieldScanner.parsingIndex;
+ const detail = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex
+ );
+
+ // Respect to autocomplete attr
+ if (!detail || detail?.reason == "autocomplete") {
+ return false;
+ }
+
+ const monthAndYearFieldNames = ["cc-exp-month", "cc-exp-year"];
+ // Skip the uninteresting fields
+ if (!["cc-exp", ...monthAndYearFieldNames].includes(detail.fieldName)) {
+ return false;
+ }
+
+ // The heuristic below should be covered by fathom rules, so we can skip doing
+ // it.
+ if (
+ lazy.FormAutofillUtils.isFathomCreditCardsEnabled() &&
+ lazy.CreditCardRulesets.types.includes(detail.fieldName)
+ ) {
+ fieldScanner.parsingIndex++;
+ return true;
+ }
+
+ const element = detail.elementWeakRef.get();
+
+ // If the input type is a month picker, then assume it's cc-exp.
+ if (element.type == "month") {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp");
+ fieldScanner.parsingIndex++;
+
+ return true;
+ }
+
+ // Don't process the fields if expiration month and expiration year are already
+ // matched by regex in correct order.
+ if (
+ fieldScanner.getFieldDetailByIndex(fieldScanner.parsingIndex++)
+ .fieldName == "cc-exp-month" &&
+ !fieldScanner.parsingFinished &&
+ fieldScanner.getFieldDetailByIndex(fieldScanner.parsingIndex++)
+ .fieldName == "cc-exp-year"
+ ) {
+ return true;
+ }
+ fieldScanner.parsingIndex = savedIndex;
+
+ // Determine the field name by checking if the fields are month select and year select
+ // likely.
+ if (this._isExpirationMonthLikely(element)) {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp-month");
+ fieldScanner.parsingIndex++;
+ if (!fieldScanner.parsingFinished) {
+ const nextDetail = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex
+ );
+ const nextElement = nextDetail.elementWeakRef.get();
+ if (this._isExpirationYearLikely(nextElement)) {
+ fieldScanner.updateFieldName(
+ fieldScanner.parsingIndex,
+ "cc-exp-year"
+ );
+ fieldScanner.parsingIndex++;
+ return true;
+ }
+ }
+ }
+ fieldScanner.parsingIndex = savedIndex;
+
+ // Verify that the following consecutive two fields can match cc-exp-month and cc-exp-year
+ // respectively.
+ if (this._findMatchedFieldName(element, ["cc-exp-month"])) {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp-month");
+ fieldScanner.parsingIndex++;
+ if (!fieldScanner.parsingFinished) {
+ const nextDetail = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex
+ );
+ const nextElement = nextDetail.elementWeakRef.get();
+ if (this._findMatchedFieldName(nextElement, ["cc-exp-year"])) {
+ fieldScanner.updateFieldName(
+ fieldScanner.parsingIndex,
+ "cc-exp-year"
+ );
+ fieldScanner.parsingIndex++;
+ return true;
+ }
+ }
+ }
+ fieldScanner.parsingIndex = savedIndex;
+
+ // Look for MM and/or YY(YY).
+ if (this._matchRegexp(element, /^mm$/gi)) {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp-month");
+ fieldScanner.parsingIndex++;
+ if (!fieldScanner.parsingFinished) {
+ const nextDetail = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex
+ );
+ const nextElement = nextDetail.elementWeakRef.get();
+ if (this._matchRegexp(nextElement, /^(yy|yyyy)$/)) {
+ fieldScanner.updateFieldName(
+ fieldScanner.parsingIndex,
+ "cc-exp-year"
+ );
+ fieldScanner.parsingIndex++;
+
+ return true;
+ }
+ }
+ }
+ fieldScanner.parsingIndex = savedIndex;
+
+ // Look for a cc-exp with 2-digit or 4-digit year.
+ if (
+ this._matchRegexp(
+ element,
+ /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yy(?:[^y]|$)/gi
+ ) ||
+ this._matchRegexp(
+ element,
+ /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yyyy(?:[^y]|$)/gi
+ )
+ ) {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp");
+ fieldScanner.parsingIndex++;
+ return true;
+ }
+ fieldScanner.parsingIndex = savedIndex;
+
+ // Match general cc-exp regexp at last.
+ if (this._findMatchedFieldName(element, ["cc-exp"])) {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp");
+ fieldScanner.parsingIndex++;
+ return true;
+ }
+ fieldScanner.parsingIndex = savedIndex;
+
+ // Set current field name to null as it failed to match any patterns.
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, null);
+ fieldScanner.parsingIndex++;
+ return true;
+ },
+
+ /**
+ * 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 = Array.from(form.elements).filter(element =>
+ lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element)
+ );
+
+ // Due to potential performance impact while running visibility check on
+ // a large amount of elements, a comprehensive visibility check
+ // (considering opacity and CSS visibility) is only applied when the number
+ // of eligible elements is below a certain threshold.
+ const runVisiblityCheck =
+ elements.length < lazy.FormAutofillUtils.visibilityCheckThreshold;
+ if (!runVisiblityCheck) {
+ lazy.log.debug(
+ `Skip running visibility check, because of too many elements (${elements.length})`
+ );
+ }
+
+ elements = elements.filter(element =>
+ lazy.FormAutofillUtils.isFieldVisible(element, runVisiblityCheck)
+ );
+
+ const fieldScanner = new lazy.FieldScanner(elements, element =>
+ this.inferFieldInfo(element, elements)
+ );
+
+ while (!fieldScanner.parsingFinished) {
+ let parsedPhoneFields = this._parsePhoneFields(fieldScanner);
+ let parsedAddressFields = this._parseAddressFields(fieldScanner);
+ let parsedExpirationDateFields =
+ this._parseCreditCardFields(fieldScanner);
+
+ // If there is no field parsed, the parsing cursor can be moved
+ // forward to the next one.
+ if (
+ !parsedPhoneFields &&
+ !parsedAddressFields &&
+ !parsedExpirationDateFields
+ ) {
+ fieldScanner.parsingIndex++;
+ }
+ }
+
+ lazy.LabelUtils.clearLabelMap();
+
+ const fields = fieldScanner.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])
+ );
+ },
+
+ /**
+ * 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 (
+ FormAutofill.isAutofillCreditCardsAvailable &&
+ (!isAutoCompleteOff || FormAutofill.creditCardsAutocompleteOff)
+ ) {
+ fieldNames.push(...this.CREDIT_CARD_FIELDNAMES);
+ }
+ if (
+ FormAutofill.isAutofillAddressesAvailable &&
+ (!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)) {
+ for (let option of element.querySelectorAll("option")) {
+ if (
+ lazy.CreditCard.getNetworkFromName(option.value) ||
+ lazy.CreditCard.getNetworkFromName(option.text)
+ ) {
+ return ["cc-type", null, null];
+ }
+ }
+ }
+
+ if (fields.length) {
+ // Find a matched field name using regex-based heuristics
+ const matchedFieldName = this._findMatchedFieldName(element, fields);
+ if (matchedFieldName) {
+ return [matchedFieldName, null, null];
+ }
+ }
+
+ return [null, 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 {ElementStrings}
+ */
+ _getElementStrings(element) {
+ return {
+ *[Symbol.iterator]() {
+ yield element.id;
+ yield element.name;
+ yield element.placeholder?.trim();
+
+ const labels = lazy.LabelUtils.findLabelElements(element);
+ for (let label of labels) {
+ yield* lazy.LabelUtils.extractLabelStrings(label);
+ }
+ },
+ };
+ },
+
+ // 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 matched field name of the element wih given regex list.
+ *
+ * @param {HTMLElement} element
+ * @param {Array<string>} regexps
+ * The regex key names that correspond to pattern in the rule list. It will
+ * be matched against the element string converted to lower case.
+ * @returns {?string} The first matched field name
+ */
+ _findMatchedFieldName(element, regexps) {
+ const getElementStrings = this._getElementStrings(element);
+ for (let regexp of regexps) {
+ for (let string of getElementStrings) {
+ if (this.testRegex(this.RULES[regexp], string?.toLowerCase())) {
+ return regexp;
+ }
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Determine whether the regexp can match any of element strings.
+ *
+ * @param {HTMLElement} element
+ * @param {RegExp} regexp
+ *
+ * @returns {boolean}
+ */
+ _matchRegexp(element, regexp) {
+ const elemStrings = this._getElementStrings(element);
+ for (const str of elemStrings) {
+ if (regexp.test(str)) {
+ 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},
+ ],
+};
+
+XPCOMUtils.defineLazyGetter(
+ FormAutofillHeuristics,
+ "CREDIT_CARD_FIELDNAMES",
+ () =>
+ Object.keys(FormAutofillHeuristics.RULES).filter(name =>
+ lazy.FormAutofillUtils.isCreditCardField(name)
+ )
+);
+
+XPCOMUtils.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..c7eb7622b5
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs
@@ -0,0 +1,1353 @@
+/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ AutofillTelemetry: "resource://autofill/AutofillTelemetry.sys.mjs",
+});
+
+const { FIELD_STATES } = FormAutofillUtils;
+
+export class FormAutofillSection {
+ static SHOULD_FOCUS_ON_AUTOFILL = true;
+ #focusedInput = null;
+
+ #section = null;
+
+ constructor(section, handler) {
+ this.#section = section;
+
+ if (!this.isValidSection()) {
+ return;
+ }
+
+ this.handler = handler;
+ this.filledRecordGUID = null;
+
+ XPCOMUtils.defineLazyGetter(this, "reauthPasswordPromptMessage", () => {
+ const brandShortName =
+ FormAutofillUtils.brandBundle.GetStringFromName("brandShortName");
+ // The string name for Mac is changed because the value needed updating.
+ const platform = AppConstants.platform.replace("macosx", "macos");
+ return FormAutofillUtils.stringBundle.formatStringFromName(
+ `useCreditCardPasswordPrompt.${platform}`,
+ [brandShortName]
+ );
+ });
+
+ XPCOMUtils.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.#section.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 any data for `createRecord` is needed to be
+ * normalized before submitting the record.
+ *
+ * @param {Object} profile
+ * A record for normalization.
+ */
+ createNormalizedRecord(data) {}
+
+ /**
+ * 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.elementWeakRef.get() == 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 (let fieldName in profile) {
+ let fieldDetail = this.getFieldDetailByName(fieldName);
+ if (!fieldDetail) {
+ continue;
+ }
+
+ let element = fieldDetail.elementWeakRef.get();
+ if (!HTMLSelectElement.isInstance(element)) {
+ continue;
+ }
+
+ let cache = this._cacheValue.matchingSelectOption.get(element) || {};
+ let value = profile[fieldName];
+ if (cache[value] && cache[value].get()) {
+ continue;
+ }
+
+ let option = FormAutofillUtils.findSelectOption(
+ element,
+ profile,
+ fieldName
+ );
+ if (option) {
+ cache[value] = Cu.getWeakReference(option);
+ this._cacheValue.matchingSelectOption.set(element, cache);
+ } else {
+ if (cache[value]) {
+ delete cache[value];
+ this._cacheValue.matchingSelectOption.set(element, cache);
+ }
+ // 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.elementWeakRef.get();
+ 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 5, then we
+ // assume it is intended to hold an expiration of the
+ // form "MM/YY".
+ if (key == "cc-exp" && maxLength == 5) {
+ const month2Digits = (
+ "0" + profile["cc-exp-month"].toString()
+ ).slice(-2);
+ const year2Digits = profile["cc-exp-year"].toString().slice(-2);
+ profile[key] = `${month2Digits}/${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.");
+ }
+
+ 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.elementWeakRef.get();
+ // 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].get();
+ 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.elementWeakRef.get();
+ // 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]?.get();
+ 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;
+ 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.elementWeakRef.get();
+
+ 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.elementWeakRef.get();
+ 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.elementWeakRef.get();
+ 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.elementWeakRef.get();
+ 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.elementWeakRef.get();
+ 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].elementWeakRef.get()?.value +
+ condensedDetails[i + 1].elementWeakRef.get()?.value +
+ condensedDetails[i + 2].elementWeakRef.get()?.value +
+ condensedDetails[i + 3].elementWeakRef.get()?.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.elementWeakRef.get();
+ // 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);
+ }
+ });
+
+ this.createNormalizedRecord(data);
+
+ 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) {
+ if (
+ record.country &&
+ !FormAutofill.isAutofillAddressesAvailableInCountry(record.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;
+ }
+
+ let hasName = 0;
+ let length = 0;
+ for (let key of Object.keys(record)) {
+ if (!record[key]) {
+ continue;
+ }
+ if (FormAutofillUtils.getCategoryFromFieldName(key) == "name") {
+ hasName = 1;
+ continue;
+ }
+ length++;
+ }
+ return length + hasName >= 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.elementWeakRef.get())
+ ) {
+ 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.elementWeakRef.get();
+ 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;
+ }
+
+ createNormalizedRecord(address) {
+ if (!address) {
+ return;
+ }
+
+ // Normalize Country
+ if (address.record.country) {
+ let detail = this.getFieldDetailByName("country");
+ // Try identifying country field aggressively if it doesn't come from
+ // @autocomplete.
+ if (detail.reason != "autocomplete") {
+ let countryCode = FormAutofillUtils.identifyCountryCode(
+ address.record.country
+ );
+ if (countryCode) {
+ address.record.country = countryCode;
+ }
+ }
+ }
+
+ // Normalize Tel
+ FormAutofillUtils.compressTel(address.record);
+ if (address.record.tel) {
+ let allTelComponentsAreUntouched = Object.keys(address.record)
+ .filter(
+ field => FormAutofillUtils.getCategoryFromFieldName(field) == "tel"
+ )
+ .every(field => address.untouchedFields.includes(field));
+ if (allTelComponentsAreUntouched) {
+ // No need to verify it if none of related fields are modified after autofilling.
+ if (!address.untouchedFields.includes("tel")) {
+ address.untouchedFields.push("tel");
+ }
+ } else {
+ let strippedNumber = address.record.tel.replace(/[\s\(\)-]/g, "");
+
+ // Remove "tel" if it contains invalid characters or the length of its
+ // number part isn't between 5 and 15.
+ // (The maximum length of a valid number in E.164 format is 15 digits
+ // according to https://en.wikipedia.org/wiki/E.164 )
+ if (!/^(\+?)[\da-zA-Z]{5,15}$/.test(strippedNumber)) {
+ address.record.tel = "";
+ }
+ }
+ }
+ }
+}
+
+export class FormAutofillCreditCardSection extends FormAutofillSection {
+ /**
+ * Credit Card Section Constructor
+ *
+ * @param {object} fieldDetails
+ * The fieldDetail objects for the fields in this section
+ * @param {object} handler
+ * The FormAutofillHandler 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);
+ this.handler.onFormSubmitted();
+ }
+
+ /**
+ * 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.elementWeakRef.get();
+ 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;
+ }
+
+ let detail = this.getFieldDetailByName("cc-exp");
+ if (!detail) {
+ return;
+ }
+
+ function monthYearOrderCheck(
+ _expiryDateTransformFormat,
+ _ccExpMonth,
+ _ccExpYear
+ ) {
+ // Bug 1687681: This is a short term fix to other locales having
+ // different characters to represent year.
+ // For example, FR locales may use "A" to represent year.
+ // For example, DE locales may use "J" to represent year.
+ // This approach will not scale well and should be investigated in a follow up bug.
+ let monthChars = "m";
+ let yearChars = "yaj";
+ let result;
+
+ let monthFirstCheck = new RegExp(
+ "(?:\\b|^)((?:[" +
+ monthChars +
+ "]{2}){1,2})\\s*([\\-/])\\s*((?:[" +
+ yearChars +
+ "]{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"]
+ result = monthFirstCheck.exec(_expiryDateTransformFormat);
+ if (result) {
+ return (
+ _ccExpMonth.toString().padStart(result[1].length, "0") +
+ result[2] +
+ _ccExpYear.toString().substr(-1 * result[3].length)
+ );
+ }
+
+ let yearFirstCheck = new RegExp(
+ "(?:\\b|^)((?:[" +
+ yearChars +
+ "]{2}){1,2})\\s*([\\-/])\\s*((?:[" + // either one or two counts of 'yy' or 'aa' sequence
+ monthChars +
+ "]){1,2})(?:\\b|$)",
+ "i" // either one or two counts of a 'm' sequence
+ );
+
+ // 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 = yearFirstCheck.exec(_expiryDateTransformFormat);
+
+ if (result) {
+ return (
+ _ccExpYear.toString().substr(-1 * result[1].length) +
+ result[2] +
+ _ccExpMonth.toString().padStart(result[3].length, "0")
+ );
+ }
+ return null;
+ }
+
+ let element = detail.elementWeakRef.get();
+ let result;
+ let ccExpMonth = profile["cc-exp-month"];
+ let ccExpYear = profile["cc-exp-year"];
+ if (element.tagName == "INPUT") {
+ // Use the placeholder to determine the expiry string format.
+ if (element.placeholder) {
+ result = monthYearOrderCheck(
+ element.placeholder,
+ ccExpMonth,
+ ccExpYear
+ );
+ }
+ // If the previous sibling is a label, it is most likely meant to describe the
+ // expiry field.
+ if (!result && element.previousElementSibling?.tagName == "LABEL") {
+ result = monthYearOrderCheck(
+ element.previousElementSibling.textContent,
+ ccExpMonth,
+ ccExpYear
+ );
+ }
+ }
+
+ if (result) {
+ profile["cc-exp"] = result;
+ } else {
+ // Bug 1688576: Change YYYY-MM to MM/YYYY since MM/YYYY is the
+ // preferred presentation format for credit card expiry dates.
+ profile["cc-exp"] =
+ ccExpMonth.toString().padStart(2, "0") + "/" + ccExpYear.toString();
+ }
+ }
+
+ /**
+ * 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.elementWeakRef.get();
+ 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' placeholder and converts the year to a two digit string using the last two digits.
+ let result = /\b(yy|aa|jj)\b/i.test(placeholder);
+ if (result) {
+ profile["cc-exp-year-formatted"] = profile["cc-exp-year"]
+ .toString()
+ .substring(2);
+ }
+ }
+ }
+
+ async _decrypt(cipherText, reauth) {
+ // Get the window for the form field.
+ let window;
+ for (let fieldDetail of this.fieldDetails) {
+ let element = fieldDetail.elementWeakRef.get();
+ 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.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"]) {
+ let decrypted = await this._decrypt(
+ profile["cc-number-encrypted"],
+ this.reauthPasswordPromptMessage
+ );
+
+ if (!decrypted) {
+ // Early return if the decrypted is empty or undefined
+ return false;
+ }
+
+ profile["cc-number"] = decrypted;
+ }
+ return true;
+ }
+
+ async autofillFields(profile) {
+ this.getAdaptedProfiles([profile]);
+ if (!(await super.autofillFields(profile))) {
+ return false;
+ }
+
+ return true;
+ }
+
+ createNormalizedRecord(creditCard) {
+ if (!creditCard?.record["cc-number"]) {
+ return;
+ }
+ // Normalize cc-number
+ creditCard.record["cc-number"] = lazy.CreditCard.normalizeCardNumber(
+ creditCard.record["cc-number"]
+ );
+
+ // Normalize cc-exp-month and cc-exp-year
+ let { month, year } = lazy.CreditCard.normalizeExpiration({
+ expirationString: creditCard.record["cc-exp"],
+ expirationMonth: creditCard.record["cc-exp-month"],
+ expirationYear: creditCard.record["cc-exp-year"],
+ });
+ if (month) {
+ creditCard.record["cc-exp-month"] = month;
+ }
+ if (year) {
+ creditCard.record["cc-exp-year"] = year;
+ }
+ }
+}
diff --git a/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs
new file mode 100644
index 0000000000..31845ee73c
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs
@@ -0,0 +1,1253 @@
+/* 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";
+
+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",
+});
+
+export let FormAutofillUtils;
+
+const ADDRESS_METADATA_PATH = "resource://autofill/addressmetadata/";
+const ADDRESS_REFERENCES = "addressReferences.js";
+const ADDRESS_REFERENCES_EXT = "addressReferencesExt.js";
+
+const ADDRESSES_COLLECTION_NAME = "addresses";
+const CREDITCARDS_COLLECTION_NAME = "creditCards";
+const MANAGE_ADDRESSES_L10N_IDS = [
+ "autofill-add-new-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-new-card-title",
+ "autofill-manage-credit-cards-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 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;
+
+export let AddressDataLoader = {
+ // 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.
+ _dataLoaded: {
+ country: false,
+ level1: new Set(),
+ },
+
+ /**
+ * Load address data and extension script into a sandbox from different paths.
+ *
+ * @param {string} path
+ * The path for address data and extension script. It could be root of the address
+ * metadata folder(addressmetadata/) or under specific country(addressmetadata/TW/).
+ * @returns {object}
+ * A sandbox that contains address data object with properties from extension.
+ */
+ _loadScripts(path) {
+ let sandbox = {};
+ let extSandbox = {};
+
+ try {
+ sandbox = FormAutofillUtils.loadDataFromScript(path + ADDRESS_REFERENCES);
+ extSandbox = FormAutofillUtils.loadDataFromScript(
+ path + ADDRESS_REFERENCES_EXT
+ );
+ } catch (e) {
+ // Will return only address references if extension loading failed or empty sandbox if
+ // address references loading failed.
+ return sandbox;
+ }
+
+ if (extSandbox.addressDataExt) {
+ for (let key in extSandbox.addressDataExt) {
+ let addressDataForKey = sandbox.addressData[key];
+ if (!addressDataForKey) {
+ addressDataForKey = sandbox.addressData[key] = {};
+ }
+
+ Object.assign(addressDataForKey, extSandbox.addressDataExt[key]);
+ }
+ }
+ return sandbox;
+ },
+
+ /**
+ * 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.
+ */
+ _parse(data) {
+ if (!data) {
+ return null;
+ }
+
+ const properties = [
+ "languages",
+ "sub_keys",
+ "sub_isoids",
+ "sub_names",
+ "sub_lnames",
+ ];
+ for (let 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
+ */
+ _loadData(country, level1 = null) {
+ // Load the addressData if needed
+ if (!this._dataLoaded.country) {
+ this._addressData = this._loadScripts(ADDRESS_METADATA_PATH).addressData;
+ this._dataLoaded.country = true;
+ }
+ if (!level1) {
+ return this._parse(this._addressData[`data/${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._loadScripts(`${ADDRESS_METADATA_PATH}${country}/`).addressData
+ );
+ this._dataLoaded.level1.add(country);
+ }
+ return this._parse(this._addressData[`data/${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.
+ */
+ getData(country, level1 = null) {
+ let defaultLocale = this._loadData(country, level1);
+ if (!defaultLocale) {
+ return null;
+ }
+
+ let countryData = this._parse(this._addressData[`data/${country}`]);
+ let locales = [];
+ // TODO: Should be able to support multi-locale level 1/ level 2 metadata query
+ // in Bug 1421886
+ if (countryData.languages) {
+ let list = countryData.languages.filter(key => key !== countryData.lang);
+ locales = list.map(key =>
+ this._parse(this._addressData[`${defaultLocale.id}--${key}`])
+ );
+ }
+ return { defaultLocale, locales };
+ },
+};
+
+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,
+
+ _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",
+ },
+
+ _collators: {},
+ _reAlternativeCountryNames: {},
+
+ isAddressField(fieldName) {
+ return (
+ !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName)
+ );
+ },
+
+ isCreditCardField(fieldName) {
+ return this._fieldNameInfo[fieldName] == "creditCard";
+ },
+
+ isCCNumber(ccNumber) {
+ return 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-name", // 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());
+ },
+
+ /**
+ * Compares two addresses, removing internal whitespace
+ *
+ * @param {string} a The first address to compare
+ * @param {string} b The second address to compare
+ * @param {Array} collators Search collators that will be used for comparison
+ * @param {string} [delimiter="\n"] The separator that is used between lines in the address
+ * @returns {boolean} True if the addresses are equal, false otherwise
+ */
+ compareStreetAddress(a, b, collators, delimiter = "\n") {
+ let oneLineA = this._toStreetAddressParts(a, delimiter)
+ .map(p => p.replace(/\s/g, ""))
+ .join("");
+ let oneLineB = this._toStreetAddressParts(b, delimiter)
+ .map(p => p.replace(/\s/g, ""))
+ .join("");
+ return this.strCompare(oneLineA, oneLineB, collators);
+ },
+
+ /**
+ * 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 visually hidden or not.
+ *
+ * @param {HTMLElement} element
+ * @param {boolean} visibilityCheck true to run visiblity check against
+ * element.checkVisibility API. Otherwise, test by only checking
+ * `hidden` and `display` attributes
+ * @returns {boolean} true if the element is visible
+ */
+ isFieldVisible(element, visibilityCheck = true) {
+ if (visibilityCheck) {
+ return element.checkVisibility({
+ checkOpacity: true,
+ checkVisibilityCSS: true,
+ });
+ }
+
+ return !element.hidden && element.style.display != "none";
+ },
+
+ /**
+ * 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 AddressDataLoader._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 = AddressDataLoader.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 = AddressDataLoader.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 = AddressDataLoader.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 (AddressDataLoader.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];
+ },
+};
+
+XPCOMUtils.defineLazyGetter(FormAutofillUtils, "stringBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://formautofill/locale/formautofill.properties"
+ );
+});
+
+XPCOMUtils.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)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "visibilityCheckThreshold",
+ "extensions.formautofill.heuristics.visibilityCheckThreshold",
+ 200
+);
+
+// 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..18f5a2f05b
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormStateManager.sys.mjs
@@ -0,0 +1,154 @@
+/* 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.elementWeakRef.get();
+ 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() {
+ let elementWeakRef = this._activeItems.elementWeakRef;
+ return elementWeakRef ? elementWeakRef.get() : null;
+ }
+
+ 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: Cu.getWeakReference(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;
+ }
+}
+
+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..9df83d5cd8
--- /dev/null
+++ b/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs
@@ -0,0 +1,620 @@
+/* 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-number": undefined,
+ "cc-exp-month": undefined,
+ "cc-exp-year": undefined,
+ "cc-exp": undefined,
+ "cc-type": undefined,
+ },
+
+ RULE_SETS: [
+ //=========================================================================
+ // Firefox-specific rules
+ {
+ "address-line1": "addrline1|address_1",
+ "address-line2": "addrline2|address_2",
+ "address-line3": "addrline3|address_3",
+ "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
+ "cc-exp-month":
+ "month" +
+ "|(cc|kk)month" + // de-DE
+ "|miesiąc", // pl-PL
+ "cc-exp-year":
+ "year" +
+ "|(cc|kk)year" + // de-DE
+ "|rok", // pl-PL
+ "cc-type":
+ "type" +
+ "|kartenmarke" + // de-DE
+ "|typ.*karty", // pl-PL
+ },
+
+ //=========================================================================
+ // 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" +
+ "|(?:card|cc).?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
+ },
+ ],
+
+ _getRule(name) {
+ let rules = [];
+ this.RULE_SETS.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.
+ rules.push(`(${set[name].toLowerCase()})`.normalize("NFKC"));
+ }
+ });
+
+ const value = new RegExp(rules.join("|"), "gu");
+ Object.defineProperty(this.RULES, name, { get: undefined });
+ Object.defineProperty(this.RULES, name, { value });
+ return value;
+ },
+
+ getRules() {
+ Object.keys(this.RULES).forEach(field =>
+ Object.defineProperty(this.RULES, field, {
+ get() {
+ return HeuristicsRegExp._getRule(field);
+ },
+ })
+ );
+
+ return this.RULES;
+ },
+};
+
+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;