diff options
Diffstat (limited to 'toolkit/components/formautofill/shared')
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; |